Compare commits
63
Commits
+6
-3
@@ -1,9 +1,12 @@
|
|||||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
# The root build context is used by web/Dockerfile (which needs web/ and
|
||||||
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
|
||||||
# out of the context upload.
|
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
|
||||||
|
# context upload.
|
||||||
*
|
*
|
||||||
!web
|
!web
|
||||||
!api/openapi.json
|
!api/openapi.json
|
||||||
|
!rust-toolchain.toml
|
||||||
|
!ci/pf-host-cc
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/.output
|
web/.output
|
||||||
web/dist
|
web/dist
|
||||||
|
|||||||
@@ -61,6 +61,43 @@ jobs:
|
|||||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
|
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||||
|
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||||
|
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||||
|
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||||
|
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||||
|
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||||
|
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||||
|
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||||
|
#
|
||||||
|
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
|
||||||
|
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
|
||||||
|
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
|
||||||
|
# (E0061 x10) without any job noticing.
|
||||||
|
#
|
||||||
|
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
|
||||||
|
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
|
||||||
|
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
|
||||||
|
# can only type-check these tests via clippy — see the note there.)
|
||||||
|
#
|
||||||
|
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
|
||||||
|
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
|
||||||
|
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
|
||||||
|
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
|
||||||
|
# re-run its entire test suite a second time for no extra coverage.
|
||||||
|
#
|
||||||
|
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
|
||||||
|
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
|
||||||
|
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
|
||||||
|
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
|
||||||
|
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
|
||||||
|
# that combination is what deserves the lint.
|
||||||
|
- name: Clippy + test the feature-gated Linux encode backends
|
||||||
|
run: |
|
||||||
|
cargo clippy -p pf-encode --all-targets --locked \
|
||||||
|
--features nvenc,vulkan-encode,pyrowave -- -D warnings
|
||||||
|
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
|
||||||
|
|
||||||
- name: C ABI harness (standalone link proof)
|
- name: C ABI harness (standalone link proof)
|
||||||
run: bash crates/punktfunk-core/tests/c/run.sh
|
run: bash crates/punktfunk-core/tests/c/run.sh
|
||||||
|
|
||||||
@@ -71,6 +108,53 @@ jobs:
|
|||||||
git diff --exit-code include/punktfunk_core.h \
|
git diff --exit-code include/punktfunk_core.h \
|
||||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||||
|
|
||||||
|
# The client stack cross-checked for aarch64. NOT an artifact job — deb.yml ships those —
|
||||||
|
# this exists so a portability defect fails here instead of surfacing in a release build or
|
||||||
|
# on a user's board. It earns its runtime: the bug that motivated it (a Vulkan extension
|
||||||
|
# array typed `*const i8`, where `c_char` is signed on x86_64 and UNSIGNED on aarch64)
|
||||||
|
# compiled cleanly on every target CI built at the time.
|
||||||
|
#
|
||||||
|
# Client crates only, listed explicitly: the host's encode stack is x86 (NVENC/QSV/AMF) and
|
||||||
|
# `--workspace` would drag it in. Runs in the cross image (amd64 toolchain + arm64 sysroot,
|
||||||
|
# ci/rust-ci-arm64cross.Dockerfile) on the ordinary runner — no arm64 runner involved.
|
||||||
|
rust-arm64:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache keys
|
||||||
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry
|
||||||
|
/usr/local/cargo/git
|
||||||
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
|
||||||
|
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||||
|
|
||||||
|
- name: Clippy for aarch64 (deny warnings)
|
||||||
|
run: |
|
||||||
|
cargo clippy --target aarch64-unknown-linux-gnu --all-targets --locked \
|
||||||
|
-p punktfunk-core -p pf-client-core -p pf-presenter -p pf-console-ui \
|
||||||
|
-p punktfunk-client-session -p punktfunk-client-linux \
|
||||||
|
-- -D warnings
|
||||||
|
|
||||||
|
# The minimal embedded build — no Skia, no PyroWave — is what a small image installs, so
|
||||||
|
# it has to keep compiling on its own, not just as a subset of the default features.
|
||||||
|
- name: Build the session binary, minimal features
|
||||||
|
run: |
|
||||||
|
cargo build --release --target aarch64-unknown-linux-gnu --locked \
|
||||||
|
-p punktfunk-client-session --no-default-features
|
||||||
|
|
||||||
web:
|
web:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
container:
|
container:
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||||
# apt distribution/component:
|
# apt distribution/component:
|
||||||
#
|
#
|
||||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||||
|
# build-publish-client-arm64
|
||||||
|
# — the same client package for arm64, CROSS-compiled on the same amd64
|
||||||
|
# runner in the rust-ci-arm64cross image. No host counterpart: the Linux
|
||||||
|
# host's encode stack is x86 (NVENC/QSV/AMF).
|
||||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||||
@@ -269,3 +273,94 @@ jobs:
|
|||||||
for DEB in dist/*.deb; do
|
for DEB in dist/*.deb; do
|
||||||
upsert_asset "$RID" "$DEB"
|
upsert_asset "$RID" "$DEB"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||||
|
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||||
|
# ci/rust-ci-arm64cross.Dockerfile); there is no arm64 runner in the fleet and none is needed.
|
||||||
|
# Client only, by decision: the Linux host encodes with NVENC/QSV/AMF, all x86.
|
||||||
|
# Publishes to the same distribution/component as the amd64 jobs — the apt registry keys pool
|
||||||
|
# entries by arch, so `apt` on an arm64 box picks this one up with no client-side configuration.
|
||||||
|
build-publish-client-arm64:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
|
||||||
|
# commit), so the arm64 package always shares the amd64 version line.
|
||||||
|
- name: Version + channel
|
||||||
|
run: |
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)"
|
||||||
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||||
|
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||||
|
esac
|
||||||
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
|
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||||
|
echo "package version $V -> apt distribution '$DIST' (arm64)"
|
||||||
|
|
||||||
|
# dpkg-shlibdeps + dpkg-deb. The arm64 link deps themselves are the cross image's whole
|
||||||
|
# point and are already baked in; python3 is for scripts/ci/gitea-release.sh.
|
||||||
|
- name: dpkg-dev
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends dpkg-dev python3
|
||||||
|
|
||||||
|
- name: Cache keys
|
||||||
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry
|
||||||
|
/usr/local/cargo/git
|
||||||
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
|
||||||
|
# and must never share the amd64 jobs' target cache.
|
||||||
|
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||||
|
|
||||||
|
- name: Build the arm64 client .deb
|
||||||
|
env:
|
||||||
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
|
||||||
|
bash packaging/debian/build-client-deb.sh
|
||||||
|
# Fail here rather than shipping an amd64 binary under an arm64 package name.
|
||||||
|
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
|
||||||
|
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
|
||||||
|
|
||||||
|
- name: Publish to the Gitea apt registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for DEB in dist/*.deb; do
|
||||||
|
echo "uploading $DEB"
|
||||||
|
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||||
|
VER=$(dpkg-deb -f "$DEB" Version)
|
||||||
|
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
|
done
|
||||||
|
echo "published arm64 client to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||||
|
|
||||||
|
- name: Attach the arm64 .deb to the Gitea release (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. scripts/ci/gitea-release.sh
|
||||||
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
|
for DEB in dist/*.deb; do
|
||||||
|
upsert_asset "$RID" "$DEB"
|
||||||
|
done
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
||||||
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
||||||
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
||||||
|
# punktfunk-rust-ci-arm64cross — the above + an arm64 sysroot, for the aarch64 client legs
|
||||||
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
||||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||||
#
|
#
|
||||||
@@ -80,6 +81,41 @@ jobs:
|
|||||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||||
|
|
||||||
|
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||||
|
# and so must not race the matrix entry that publishes that base. Consumed by the arm64
|
||||||
|
# client legs in deb.yml/rpm.yml/arch.yml. Root context: it needs rust-toolchain.toml to
|
||||||
|
# install the target against the toolchain the workspace actually pins.
|
||||||
|
build-push-arm64cross:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: build-push
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
IMAGE: punktfunk-rust-ci-arm64cross
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
| docker login "$REGISTRY" -u enricobuehler --password-stdin
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
EXTRA=""
|
||||||
|
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||||
|
docker build --pull \
|
||||||
|
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||||
|
-t "$REGISTRY/$OWNER/$IMAGE:latest" \
|
||||||
|
-t "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}" \
|
||||||
|
$EXTRA \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
run: |
|
||||||
|
docker push "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}"
|
||||||
|
docker push "$REGISTRY/$OWNER/$IMAGE:latest"
|
||||||
|
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||||
|
|
||||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||||
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the
|
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ on:
|
|||||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||||
- 'crates/pf-encode/**'
|
- 'crates/pf-encode/**'
|
||||||
- 'crates/libvpl-sys/**'
|
- 'crates/libvpl-sys/**'
|
||||||
|
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
|
||||||
|
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
|
||||||
|
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
|
||||||
|
# Windows host through one of THEM reached main with no Windows build at all — the same
|
||||||
|
# Cargo.lock-luck gap the two lines above were added to close.
|
||||||
|
- 'crates/pf-frame/**'
|
||||||
|
- 'crates/pf-gpu/**'
|
||||||
|
- 'crates/pf-zerocopy/**'
|
||||||
|
- 'crates/pf-host-config/**'
|
||||||
|
- 'crates/pf-capture/**'
|
||||||
|
- 'crates/pf-win-display/**'
|
||||||
|
- 'crates/pf-vdisplay/**'
|
||||||
|
- 'crates/pf-inject/**'
|
||||||
|
- 'crates/pf-paths/**'
|
||||||
|
- 'crates/pf-driver-proto/**'
|
||||||
|
- 'crates/pf-clipboard/**'
|
||||||
|
- 'crates/pyrowave-sys/**'
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/**'
|
- 'scripts/windows/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
@@ -154,8 +171,23 @@ jobs:
|
|||||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||||
# pf-vkhdr-layer's clippy below runs --release.
|
# pf-vkhdr-layer's clippy below runs --release.
|
||||||
|
#
|
||||||
|
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
|
||||||
|
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
|
||||||
|
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
|
||||||
|
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
|
||||||
|
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
|
||||||
|
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
|
||||||
|
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
|
||||||
|
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
|
||||||
|
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
|
||||||
|
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
|
||||||
|
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
|
||||||
|
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
|
||||||
|
# worth it while ci.yml executes the same tests.
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
|
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
|
|||||||
Generated
+1
@@ -3073,6 +3073,7 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -5106,7 +5106,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"audio_streaming": {
|
"audio_streaming": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True while the audio stream thread is running."
|
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
|
||||||
},
|
},
|
||||||
"conflicts": {
|
"conflicts": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -5150,7 +5150,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionInfo",
|
"$ref": "#/components/schemas/SessionInfo",
|
||||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
"description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -5160,7 +5160,7 @@
|
|||||||
},
|
},
|
||||||
"video_streaming": {
|
"video_streaming": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True while the video stream thread is running."
|
"description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Host-side C compiler wrapper for the aarch64 cross image (ci/rust-ci-arm64cross.Dockerfile).
|
||||||
|
#
|
||||||
|
# Why this exists: ffmpeg-sys-next's build script compiles a probe it intends to RUN — it
|
||||||
|
# executes the binary to read the libav* version macros — so it forces `.target(HOST)` with
|
||||||
|
# the comment "don't cross-compile this", but still hands that host compile the TARGET's
|
||||||
|
# pkg-config include paths. `-I/usr/include/aarch64-linux-gnu` then shadows the host's own
|
||||||
|
# multiarch libc headers and the x86 compiler dies inside bits/math-vector.h on NEON/SVE
|
||||||
|
# types it has never heard of.
|
||||||
|
#
|
||||||
|
# Prepending the host's multiarch dir does NOT fix it: GCC drops a `-I` that duplicates a
|
||||||
|
# directory already on its system include path (keeping it in the original, later position),
|
||||||
|
# so the arm64 dir stays in front. The reliable fix is to remove the target include dirs from
|
||||||
|
# the host compile entirely — the probe only wants FFmpeg's version macros, and the amd64
|
||||||
|
# libav*-dev headers are installed and on the default search path, at the same version (both
|
||||||
|
# come from this Ubuntu release).
|
||||||
|
#
|
||||||
|
# Scope: only ever invoked as CC for the HOST triple (CC_x86_64_unknown_linux_gnu). Target
|
||||||
|
# compiles go to aarch64-linux-gnu-gcc and never pass through here.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
declare -a out=()
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
# `-I dir` as two arguments — the form cc's Command building and ffmpeg-sys both emit.
|
||||||
|
-I)
|
||||||
|
if [[ ${2-} == *aarch64-linux-gnu* ]]; then
|
||||||
|
shift 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
out+=("$1" "${2-}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
# `-Idir` glued into one argument.
|
||||||
|
-I*aarch64-linux-gnu*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
out+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exec /usr/bin/cc "${out[@]}"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Cross-compiling CI builder: amd64 host toolchain + an arm64 multiarch sysroot, for the
|
||||||
|
# aarch64 Linux CLIENT artifacts (punktfunk-client + punktfunk-session).
|
||||||
|
#
|
||||||
|
# docker build -f ci/rust-ci-arm64cross.Dockerfile -t punktfunk-rust-ci-arm64cross .
|
||||||
|
#
|
||||||
|
# Derived from punktfunk-rust-ci so the Rust toolchain, clang, and CMake are byte-identical
|
||||||
|
# to the amd64 legs — this image only adds the target side. Kept as a SEPARATE image rather
|
||||||
|
# than folded into the base because the :arm64 dev libs are ~1 GB that every other CI job
|
||||||
|
# would otherwise pull for nothing.
|
||||||
|
#
|
||||||
|
# Client only: the Linux HOST stays amd64 (its encode stack is NVENC/QSV/AMF), so none of the
|
||||||
|
# host's CUDA/GBM link deps are mirrored here.
|
||||||
|
#
|
||||||
|
# Ubuntu splits archives by architecture: amd64 lives on archive.ubuntu.com, every port
|
||||||
|
# (arm64 included) on ports.ubuntu.com. Both stanzas therefore have to be pinned with an
|
||||||
|
# explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s.
|
||||||
|
#
|
||||||
|
# Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below.
|
||||||
|
FROM git.unom.io/unom/punktfunk-rust-ci:latest
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# 1. Pin the stock sources to amd64, add ports.ubuntu.com for arm64.
|
||||||
|
RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.list.d/ubuntu.sources \
|
||||||
|
&& . /etc/os-release \
|
||||||
|
&& printf 'Types: deb\nArchitectures: arm64\nURIs: http://ports.ubuntu.com/ubuntu-ports/\nSuites: %s %s-updates %s-backports %s-security\nComponents: main universe restricted multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \
|
||||||
|
"$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" \
|
||||||
|
> /etc/apt/sources.list.d/ubuntu-ports-arm64.sources \
|
||||||
|
&& dpkg --add-architecture arm64
|
||||||
|
|
||||||
|
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
|
||||||
|
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
|
||||||
|
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
crossbuild-essential-arm64 \
|
||||||
|
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
|
||||||
|
libavfilter-dev:arm64 libavdevice-dev:arm64 \
|
||||||
|
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
|
||||||
|
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
|
||||||
|
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
|
||||||
|
# default. The base image bakes whatever `stable` was at its build time, while every build
|
||||||
|
# in the repo switches to the exact channel in rust-toolchain.toml; adding the target to
|
||||||
|
# the default toolchain instead leaves the pinned one without an aarch64 std, and the build
|
||||||
|
# dies on `can't find crate for core` a few hundred crates in. Running rustup from a
|
||||||
|
# directory that contains the pin file resolves the right toolchain (and pre-downloads it,
|
||||||
|
# which every workspace job would otherwise pay for on first use).
|
||||||
|
COPY rust-toolchain.toml /opt/pf-toolchain/
|
||||||
|
WORKDIR /opt/pf-toolchain
|
||||||
|
RUN rustup target add aarch64-unknown-linux-gnu && rustup show
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
# 4. Cross wiring. Everything in this image is a cross build, so the plain (un-suffixed)
|
||||||
|
# variables are safe and cover the crates that roll their own pkg-config/bindgen calls
|
||||||
|
# instead of going through the target-scoped lookups.
|
||||||
|
# * PKG_CONFIG uses Debian's multiarch wrapper, which resolves the arm64 .pc files and
|
||||||
|
# rewrites -I/-L into the sysroot without per-crate cooperation.
|
||||||
|
# * BINDGEN_EXTRA_CLANG_ARGS: clang defaults to the host triple, so bindgen would parse
|
||||||
|
# arm64 headers with amd64 type layouts (silently wrong, not a build error) — the
|
||||||
|
# explicit --target plus the multiarch include dir is what keeps the layouts honest.
|
||||||
|
# * CC_x86_64_unknown_linux_gnu routes HOST-targeted compiles through a wrapper that
|
||||||
|
# strips the arm64 include dirs — see ci/pf-host-cc for the ffmpeg-sys-next probe it
|
||||||
|
# exists for.
|
||||||
|
COPY ci/pf-host-cc /usr/local/bin/pf-host-cc
|
||||||
|
RUN chmod 0755 /usr/local/bin/pf-host-cc
|
||||||
|
|
||||||
|
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||||
|
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||||
|
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
|
||||||
|
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
|
||||||
|
CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc \
|
||||||
|
PKG_CONFIG=aarch64-linux-gnu-pkg-config \
|
||||||
|
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||||
|
BINDGEN_EXTRA_CLANG_ARGS="--target=aarch64-unknown-linux-gnu -I/usr/include/aarch64-linux-gnu"
|
||||||
|
|
||||||
|
# Fail the BUILD, not some later CI job, if the wrapper or a sysroot .pc is missing.
|
||||||
|
RUN command -v aarch64-linux-gnu-pkg-config \
|
||||||
|
&& aarch64-linux-gnu-pkg-config --cflags libavcodec sdl3 gtk4 libpipewire-0.3 \
|
||||||
|
&& aarch64-linux-gnu-gcc -dumpmachine | grep -q aarch64
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//! scenes.
|
//! scenes.
|
||||||
|
|
||||||
use crate::app::AppModel;
|
use crate::app::AppModel;
|
||||||
use crate::trust::{KnownHost, KnownHosts};
|
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
@@ -252,18 +252,6 @@ fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop an fp-less placeholder for `addr:port` (see `headless_pair`). No-op when none exists.
|
|
||||||
fn forget_placeholder(addr: &str, port: u16) {
|
|
||||||
let mut known = KnownHosts::load();
|
|
||||||
let before = known.hosts.len();
|
|
||||||
known
|
|
||||||
.hosts
|
|
||||||
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
|
|
||||||
if known.hosts.len() != before {
|
|
||||||
let _ = known.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
||||||
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
||||||
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ presenter of the Linux client re-architecture (punktfunk-planning:
|
|||||||
```
|
```
|
||||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||||
|
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
|
||||||
```
|
```
|
||||||
|
|
||||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||||
@@ -17,9 +18,16 @@ pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<fil
|
|||||||
feeds canned entries with no host (portrait paths starting with `/` load from disk).
|
feeds canned entries with no host (portrait paths starting with `/` load from disk).
|
||||||
|
|
||||||
Reads the same identity / known-hosts / settings stores as the desktop client
|
Reads the same identity / known-hosts / settings stores as the desktop client
|
||||||
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never
|
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||||
|
|
||||||
|
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
|
||||||
|
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
|
||||||
|
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
|
||||||
|
label the host files this client under, defaulting to the hostname. It is in the
|
||||||
|
`--no-default-features` build too: enrolling must never be the reason a minimal image has
|
||||||
|
to pull in Skia.
|
||||||
|
|
||||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||||
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let opts = ConsoleOptions {
|
let opts = ConsoleOptions {
|
||||||
device_name: device_name(),
|
device_name: trust::device_name(),
|
||||||
deck: is_steam_deck(),
|
deck: is_steam_deck(),
|
||||||
};
|
};
|
||||||
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
|
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
|
||||||
@@ -251,22 +251,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The machine's name — what the host lists this client as after pairing.
|
|
||||||
fn device_name() -> String {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
|
||||||
let s = s.trim();
|
|
||||||
if !s.is_empty() {
|
|
||||||
return s.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::env::var("COMPUTERNAME")
|
|
||||||
.or_else(|_| std::env::var("HOSTNAME"))
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.trim().is_empty())
|
|
||||||
.unwrap_or_else(|| "This device".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn host_display_name(name: &str, addr: &str) -> String {
|
fn host_display_name(name: &str, addr: &str) -> String {
|
||||||
if name.trim().is_empty() {
|
if name.trim().is_empty() {
|
||||||
addr.to_string()
|
addr.to_string()
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||||
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
||||||
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
||||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
|
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||||
//! via the shell's headless `--pair`) makes this binary connect silently.
|
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
|
||||||
|
//! with no window and no toolkit, for machines that have only a shell.
|
||||||
//!
|
//!
|
||||||
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||||
@@ -61,6 +62,54 @@ mod session_main {
|
|||||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
|
||||||
|
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
|
||||||
|
/// client, a headless box, an image being provisioned. Writes the verified host into the
|
||||||
|
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
|
||||||
|
/// later stream connect silently.
|
||||||
|
///
|
||||||
|
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
|
||||||
|
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
|
||||||
|
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
|
||||||
|
/// enrolment must not be the reason an embedded image has to pull in Skia.
|
||||||
|
fn headless_pair(pin: &str) -> u8 {
|
||||||
|
let Some(target) = arg_value("--connect") else {
|
||||||
|
eprintln!("--pair requires --connect host[:port]");
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
};
|
||||||
|
let (addr, port) = parse_host_port(&target);
|
||||||
|
// The label the HOST files this client under. A headless box has nobody to ask, so
|
||||||
|
// the hostname is the only name that will mean anything in the paired-devices list.
|
||||||
|
let name = arg_value("--name").unwrap_or_else(trust::device_name);
|
||||||
|
|
||||||
|
let identity = match trust::load_or_create_identity() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("client identity: {e:#}");
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||||
|
Ok(fp) => {
|
||||||
|
let fp_hex = trust::hex(&fp);
|
||||||
|
trust::persist_host(
|
||||||
|
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||||
|
&addr,
|
||||||
|
port,
|
||||||
|
&fp_hex,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
trust::forget_placeholder(&addr, port);
|
||||||
|
println!("paired {addr}:{port} fp={fp_hex}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
|
||||||
|
EXIT_TRUST_REJECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `host[:port]`, port defaulting to the native 9777.
|
/// `host[:port]`, port defaulting to the native 9777.
|
||||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||||
match target.rsplit_once(':') {
|
match target.rsplit_once(':') {
|
||||||
@@ -312,6 +361,13 @@ mod session_main {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `--pair <PIN>`: enrol this machine against a host and exit. Sits with the other
|
||||||
|
// non-streaming subcommands, above every graphics call — the box doing this may have
|
||||||
|
// no display at all.
|
||||||
|
if let Some(pin) = arg_value("--pair") {
|
||||||
|
return headless_pair(&pin);
|
||||||
|
}
|
||||||
|
|
||||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||||
@@ -369,12 +425,14 @@ mod session_main {
|
|||||||
eprintln!(
|
eprintln!(
|
||||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
||||||
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||||
|
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||||
\n\
|
\n\
|
||||||
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
||||||
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
||||||
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
||||||
library. --connect never dials a host it has no pinned fingerprint for —\n\
|
library. --connect never dials a host it has no pinned fingerprint for —\n\
|
||||||
pair in the console or via `punktfunk-client --pair <PIN> --connect …`."
|
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||||
|
client."
|
||||||
);
|
);
|
||||||
return EXIT_CONNECT_FAILED;
|
return EXIT_CONNECT_FAILED;
|
||||||
};
|
};
|
||||||
@@ -406,7 +464,7 @@ mod session_main {
|
|||||||
"error",
|
"error",
|
||||||
&format!(
|
&format!(
|
||||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||||
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||||
),
|
),
|
||||||
Some(true),
|
Some(true),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2191,7 +2191,15 @@ mod pipewire {
|
|||||||
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
|
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
|
||||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
||||||
// dmabuf straight to the encoder.
|
// dmabuf straight to the encoder.
|
||||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
//
|
||||||
|
// ...unless the encoder already proved it cannot import them here. A driver that refuses
|
||||||
|
// the compositor's buffers refuses them identically on every retry, so without this the
|
||||||
|
// session died on its first frame and every reconnect repeated it. The latch (set by the
|
||||||
|
// encode side after consecutive import failures) is what turns that into one bad session
|
||||||
|
// followed by a working, if slower, host.
|
||||||
|
let raw_dmabuf_off = raw_passthrough && pf_zerocopy::raw_dmabuf_import_disabled();
|
||||||
|
let vaapi_passthrough =
|
||||||
|
zerocopy && !force_shm && importer.is_none() && raw_passthrough && !raw_dmabuf_off;
|
||||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
||||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
||||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
||||||
@@ -2249,6 +2257,12 @@ mod pipewire {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
|
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
|
||||||
);
|
);
|
||||||
|
} else if raw_dmabuf_off {
|
||||||
|
tracing::warn!(
|
||||||
|
"zero-copy raw-dmabuf passthrough disabled after repeated encoder import failures \
|
||||||
|
— capturing CPU frames instead (this host's GPU driver would not import the \
|
||||||
|
compositor's buffers)"
|
||||||
|
);
|
||||||
} else if zerocopy && !want_dmabuf {
|
} else if zerocopy && !want_dmabuf {
|
||||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||||
@@ -2652,6 +2666,18 @@ mod pipewire {
|
|||||||
build_default_format_obj(preferred)
|
build_default_format_obj(preferred)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
|
||||||
|
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
|
||||||
|
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
|
||||||
|
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
|
||||||
|
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
|
||||||
|
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
|
||||||
|
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
|
||||||
|
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
|
||||||
|
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
|
||||||
|
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
|
||||||
|
// version and are composited host-side instead (see `xfixes_cursor.rs`).
|
||||||
|
//
|
||||||
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
|
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
|
||||||
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
||||||
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
||||||
|
|||||||
@@ -212,6 +212,40 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
|||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This machine's name — the label a host files this client under in its paired-devices list.
|
||||||
|
/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal
|
||||||
|
/// build with no GTK to ask), then the usual environment fallbacks.
|
||||||
|
pub fn device_name() -> String {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||||
|
let s = s.trim();
|
||||||
|
if !s.is_empty() {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::var("COMPUTERNAME")
|
||||||
|
.or_else(|_| std::env::var("HOSTNAME"))
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "This device".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any
|
||||||
|
/// ceremony (`--add-host` with no `--fp`) is stored keyed by address with an empty fingerprint;
|
||||||
|
/// once pairing yields the real one, [`persist_host`] writes a second, fp-keyed entry — so the
|
||||||
|
/// placeholder has to go or the host list shows the same box twice. No-op (and no disk write)
|
||||||
|
/// when there is none, which is the usual case.
|
||||||
|
pub fn forget_placeholder(addr: &str, port: u16) {
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
let before = known.hosts.len();
|
||||||
|
known
|
||||||
|
.hosts
|
||||||
|
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
|
||||||
|
if known.hosts.len() != before {
|
||||||
|
let _ = known.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||||
/// the hosts page can call it on every discovery tick without churning the store.
|
/// the hosts page can call it on every discovery tick without churning the store.
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
//! queue under the device's [`QueueLock`], fence-waited (sub-ms — Phase-0 measured
|
//! queue under the device's [`QueueLock`], fence-waited (sub-ms — Phase-0 measured
|
||||||
//! 0.067 ms GPU at 1080p on the RTX 5070 Ti).
|
//! 0.067 ms GPU at 1080p on the RTX 5070 Ti).
|
||||||
//!
|
//!
|
||||||
//! Output: three separate R8 planes (Y full-res, Cb/Cr half-res) — the decode path
|
//! Output: three separate single-component planes (Y full-res; Cb/Cr half-res, or
|
||||||
//! requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
//! full-res on a 4:4:4 session) — R8, or R16 UNORM on a 10-bit session — the decode
|
||||||
//! RG8 trick is not allowed here (pyrowave.h validation). The presenter samples them
|
//! path requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||||
//! with its planar CSC variant (BT.709 limited — the codec's fixed colour contract,
|
//! RG trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||||
//! there is no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
//! with its planar CSC variant (colour per the negotiated `ColorInfo` — the wavelet
|
||||||
|
//! bitstream carries no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
||||||
//! the presenter is still sampling; the synchronous fence bounds decode-side reuse and
|
//! the presenter is still sampling; the synchronous fence bounds decode-side reuse and
|
||||||
//! the ring depth covers present-side latency (≤ 1–2 frames in this pipeline).
|
//! the ring depth covers present-side latency (≤ 1–2 frames in this pipeline).
|
||||||
//!
|
//!
|
||||||
@@ -222,9 +223,10 @@ unsafe extern "C" fn queue_unlock_cb(ud: *mut c_void) {
|
|||||||
unsafe { (*(ud as *const crate::video::QueueLock)).unlock() }
|
unsafe { (*(ud as *const crate::video::QueueLock)).unlock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One decoded PyroWave frame: three R8 plane images on the presenter's device, GENERAL
|
/// One decoded PyroWave frame: three single-component plane images (R8, or R16 on a
|
||||||
/// layout, decode-complete (the decoder fence-waits before handing it over). `slot`
|
/// 10-bit session) on the presenter's device, GENERAL layout, decode-complete (the
|
||||||
/// identifies the ring entry; the images/views live as long as the decoder.
|
/// decoder fence-waits before handing it over). `slot` identifies the ring entry; the
|
||||||
|
/// images/views live as long as the decoder.
|
||||||
pub struct PyroWavePlanarFrame {
|
pub struct PyroWavePlanarFrame {
|
||||||
/// Raw `VkImageView`s (Y, Cb, Cr) for the presenter's planar CSC sampling.
|
/// Raw `VkImageView`s (Y, Cb, Cr) for the presenter's planar CSC sampling.
|
||||||
pub views: [u64; 3],
|
pub views: [u64; 3],
|
||||||
@@ -252,7 +254,8 @@ struct RetiredRing {
|
|||||||
retired_at: Instant,
|
retired_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One decode-output plane: R8, storage (decode writes) + sampled (presenter CSC).
|
/// One decode-output plane (`fmt` = R8, or R16 on a 10-bit session): storage (decode
|
||||||
|
/// writes) + sampled (presenter CSC).
|
||||||
unsafe fn make_plane(
|
unsafe fn make_plane(
|
||||||
device: &ash::Device,
|
device: &ash::Device,
|
||||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
@@ -539,7 +542,8 @@ impl PyroWaveDecoder {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plane-set ring: 3 × R8, storage (decode writes) + sampled (presenter CSC).
|
// Plane-set ring: 3 single-component planes (R8; R16 on a 10-bit session),
|
||||||
|
// storage (decode writes) + sampled (presenter CSC).
|
||||||
let mem_props = instance.get_physical_device_memory_properties(
|
let mem_props = instance.get_physical_device_memory_properties(
|
||||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||||
);
|
);
|
||||||
@@ -873,12 +877,26 @@ impl PyroWaveDecoder {
|
|||||||
&vk::DependencyInfo::default().image_memory_barriers(&pre),
|
&vk::DependencyInfo::default().image_memory_barriers(&pre),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The declared format/extent MUST equal the ring image's real ones: pyrowave wraps
|
||||||
|
// our VkImage under `image_format` and vkCreateImageView's its storage view with
|
||||||
|
// `view_format` (pyrowave_c.cpp `WrappedViewBuffers::wrap` — its own
|
||||||
|
// `pyrowave_image_get_image_view` helper fills both from the image itself).
|
||||||
|
// Declaring R8 over a 10-bit session's R16_UNORM planes is an invalid view the
|
||||||
|
// driver executes anyway: its addressing covers half the surface, so decoded 8-bit
|
||||||
|
// codes fuse pairwise into 16-bit texels (structured garbage) and the never-written
|
||||||
|
// remainder samples as all-plane zeros (saturated green) — the 2026-07 AMD-client
|
||||||
|
// field report. Same discipline for chroma extents: a 4:4:4 ring is full-res.
|
||||||
|
let fmt = if self.hdr16 {
|
||||||
|
pw::VkFormat_VK_FORMAT_R16_UNORM
|
||||||
|
} else {
|
||||||
|
pw::VkFormat_VK_FORMAT_R8_UNORM
|
||||||
|
};
|
||||||
let plane = |img: vk::Image, w: u32, h: u32| pw::pyrowave_image_view {
|
let plane = |img: vk::Image, w: u32, h: u32| pw::pyrowave_image_view {
|
||||||
image: img.as_raw() as usize as pw::VkImage,
|
image: img.as_raw() as usize as pw::VkImage,
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
image_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
image_format: fmt,
|
||||||
view_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
view_format: fmt,
|
||||||
mip_level: 0,
|
mip_level: 0,
|
||||||
layer: 0,
|
layer: 0,
|
||||||
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
||||||
@@ -886,11 +904,16 @@ impl PyroWaveDecoder {
|
|||||||
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
||||||
};
|
};
|
||||||
let (w, h) = (self.width, self.height);
|
let (w, h) = (self.width, self.height);
|
||||||
|
let (cw, ch) = if self.chroma444 {
|
||||||
|
(w, h)
|
||||||
|
} else {
|
||||||
|
(w / 2, h / 2)
|
||||||
|
};
|
||||||
let buffers = pw::pyrowave_gpu_buffers {
|
let buffers = pw::pyrowave_gpu_buffers {
|
||||||
planes: [
|
planes: [
|
||||||
plane(self.ring[slot].imgs[0], w, h),
|
plane(self.ring[slot].imgs[0], w, h),
|
||||||
plane(self.ring[slot].imgs[1], w / 2, h / 2),
|
plane(self.ring[slot].imgs[1], cw, ch),
|
||||||
plane(self.ring[slot].imgs[2], w / 2, h / 2),
|
plane(self.ring[slot].imgs[2], cw, ch),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
pw::pyrowave_device_set_command_buffer(
|
pw::pyrowave_device_set_command_buffer(
|
||||||
|
|||||||
@@ -7,6 +7,45 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pf_frame::CapturedFrame;
|
use pf_frame::CapturedFrame;
|
||||||
|
|
||||||
|
/// Whether an encoder fed `format` must be built 10-bit — decided by **the pixels that actually
|
||||||
|
/// arrive**, never by the negotiated `bit_depth`.
|
||||||
|
///
|
||||||
|
/// The three Windows backends each derived this as `bit_depth >= 10 || matches!(format, P010 |
|
||||||
|
/// Rgb10a2)`, i.e. the *negotiated* depth could force a 10-bit encoder over an 8-bit capture. That
|
||||||
|
/// combination is not hypothetical: a client advertises 10-bit, the handshake negotiates
|
||||||
|
/// `bit_depth = 10`, and then enabling advanced colour on the IDD virtual display fails — at which
|
||||||
|
/// point the capturer says so and delivers 8-bit NV12 anyway (`pf-capture`'s idd_push logs "10-bit
|
||||||
|
/// HDR was negotiated but enabling advanced color on the virtual display FAILED — encoding 8-bit
|
||||||
|
/// SDR"). Every backend then lost the session, each in its own way: native AMF and native QSV
|
||||||
|
/// `bail!` at open because the format does not match the P010 they derived, and the libavcodec
|
||||||
|
/// path accepted the open and then failed EVERY submit forever (its per-frame depth check
|
||||||
|
/// recomputes from the frame, which never matches), where `reset()` could not help because the
|
||||||
|
/// rebuild re-derived the same wrong depth.
|
||||||
|
///
|
||||||
|
/// Following the pixels keeps the stream alive and, more importantly, keeps it HONEST: the depth
|
||||||
|
/// also selects the colour signalling (BT.2020 PQ vs BT.709) and the staging surface format, so an
|
||||||
|
/// 8-bit capture now yields an 8-bit stream that says it is SDR — which is what the capturer
|
||||||
|
/// already reported it is sending. The negotiated depth remains an upper bound; the session label
|
||||||
|
/// may still claim HDR, and that mismatch belongs to the negotiation, not to the encoder.
|
||||||
|
/// Windows-only: the three backends that derive an encoder depth from a capture live there
|
||||||
|
/// (native AMF, native QSV, libavcodec AMF/QSV). The Linux backends take the depth from the
|
||||||
|
/// negotiated `bit_depth` alone because their capture formats carry it unambiguously.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn ten_bit_input(format: pf_frame::PixelFormat, negotiated_depth: u8) -> bool {
|
||||||
|
use pf_frame::PixelFormat;
|
||||||
|
let ten = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||||
|
if negotiated_depth >= 10 && !ten {
|
||||||
|
tracing::warn!(
|
||||||
|
?format,
|
||||||
|
negotiated_depth,
|
||||||
|
"session negotiated 10-bit but the capturer delivers an 8-bit format — encoding 8-bit \
|
||||||
|
SDR (the stream's colour signalling follows the pixels; check whether advanced colour \
|
||||||
|
failed to enable on the virtual display)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ten
|
||||||
|
}
|
||||||
|
|
||||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||||
@@ -73,7 +112,7 @@ impl AuChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Codec selection negotiated with the client.
|
/// Codec selection negotiated with the client.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum Codec {
|
pub enum Codec {
|
||||||
H264,
|
H264,
|
||||||
H265,
|
H265,
|
||||||
@@ -255,6 +294,19 @@ pub struct EncoderCaps {
|
|||||||
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
||||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||||
pub intra_refresh_period: u32,
|
pub intra_refresh_period: u32,
|
||||||
|
/// The encoder composites [`CapturedFrame::cursor`] into the picture it encodes.
|
||||||
|
///
|
||||||
|
/// `open_video`'s `cursor_blend` argument is a REQUEST, and for most of this crate's life it was
|
||||||
|
/// nothing else: `lib.rs` literally did `let _ = cursor_blend;` and only three backends ever read
|
||||||
|
/// `frame.cursor`. So a session could ask for a composited pointer, get a backend that silently
|
||||||
|
/// discards it, and stream with no mouse cursor at all — the confirmed symptom on the VAAPI
|
||||||
|
/// dmabuf path and the libav-NVENC CUDA path.
|
||||||
|
///
|
||||||
|
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
||||||
|
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
||||||
|
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
|
||||||
|
/// `open_video` can only warn, which it does.
|
||||||
|
pub blends_cursor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A hardware encoder. One per session; runs on the encode thread.
|
/// A hardware encoder. One per session; runs on the encode thread.
|
||||||
@@ -313,8 +365,15 @@ pub trait Encoder: Send {
|
|||||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
||||||
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
|
||||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
/// `false` (the default impl) = unsupported — the session loop stops asking.
|
||||||
|
///
|
||||||
|
/// `set_pipelined(false)` requests the wind-back (de-escalation, latency recovery): the
|
||||||
|
/// backend restores its sync-retrieve mode — and the latency features that mode carries
|
||||||
|
/// (IO-stream binding, sub-frame chunking) — at its next safe point, usually via a session
|
||||||
|
/// rebuild whose first frame is an IDR. The return is still "is pipelined retrieve active":
|
||||||
|
/// the caller polls until it reads `false`. Backends that never escalate return `false`
|
||||||
|
/// trivially. An operator pin (`PUNKTFUNK_NVENC_ASYNC=1`) refuses the wind-back.
|
||||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -360,6 +419,16 @@ pub trait Encoder: Send {
|
|||||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
/// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened
|
||||||
|
/// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC
|
||||||
|
/// codec-level ceiling search. The session loop reads this after every open/reconfigure and
|
||||||
|
/// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console
|
||||||
|
/// and the client controller's ack all track what the ASIC really targets (a controller fed
|
||||||
|
/// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the
|
||||||
|
/// default) = the backend doesn't track an applied rate; the caller keeps the requested one.
|
||||||
|
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||||
@@ -379,6 +448,20 @@ pub trait Encoder: Send {
|
|||||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
||||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||||
|
///
|
||||||
|
/// **The two production encode loops deliberately do not call this**, and that is not an
|
||||||
|
/// oversight to be "fixed" by a later sweep. Both reach their exit only after the transport is
|
||||||
|
/// already gone (the client disconnected, or the session was stopped), so the AUs a flush would
|
||||||
|
/// recover have nowhere to go — while flushing is the one call on this trait that can BLOCK on a
|
||||||
|
/// wedged encoder, on precisely the teardown path a stopped session needs to complete promptly.
|
||||||
|
/// The Linux direct-SDK NVENC backend makes that concrete: its retrieve-thread join is untimed
|
||||||
|
/// (see the note in `enc/linux/nvenc_cuda.rs`), so a flush there could hang a session that is
|
||||||
|
/// already ending.
|
||||||
|
///
|
||||||
|
/// It is kept rather than deleted because it does have real consumers: the `spike` dev
|
||||||
|
/// subcommand, which encodes a FINITE clip and genuinely wants the tail, and the `#[ignore]`d
|
||||||
|
/// hardware smoke tests across the backends, which assert the drain contract on real GPUs.
|
||||||
|
/// Those are finite-stream users; a live session is not one.
|
||||||
fn flush(&mut self) -> Result<()>;
|
fn flush(&mut self) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +496,17 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way —
|
||||||
|
/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav
|
||||||
|
/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree
|
||||||
|
/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't
|
||||||
|
/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set
|
||||||
|
/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 =
|
||||||
|
/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps
|
||||||
|
/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M,
|
||||||
|
/// comfortably single-engine) on AUTO.
|
||||||
|
pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000;
|
||||||
|
|
||||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||||
@@ -426,6 +520,35 @@ pub(crate) fn vbv_frames_env() -> f64 {
|
|||||||
.unwrap_or(1.0)
|
.unwrap_or(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same HRD/VBV window as [`vbv_frames_env`], expressed the way the Vulkan Video encode API
|
||||||
|
/// wants it: `(virtualBufferSizeInMs, initialVirtualBufferSizeInMs)`.
|
||||||
|
///
|
||||||
|
/// Every other backend states the window in **bits** (`bitrate / fps × frames`); Vulkan states it
|
||||||
|
/// in **milliseconds**. `vulkan_video.rs` consumes this ONLY when the driver advertises VBR
|
||||||
|
/// (WP6.3): a tight window under CBR makes the driver stuff underspent frames with filler NALs up
|
||||||
|
/// to the exact rate share — measured 97 % filler on the 780M — because CBR must keep the CPB from
|
||||||
|
/// overflowing and Vulkan exposes no filler-suppression control. VBR permits the underspend, so
|
||||||
|
/// the tight window only ever *bounds* a complex frame.
|
||||||
|
///
|
||||||
|
/// The initial fill stays at half the window, preserving the RATIO the hardcoded (1000, 500)
|
||||||
|
/// pair had — the direct-NVENC house shape uses a FULL-window initial fill instead; measured on
|
||||||
|
/// RADV the difference is inert (the firmware showed no window sensitivity at all). Both
|
||||||
|
/// VUIDs on `VkVideoEncodeRateControlInfoKHR`'s window fields are satisfied by construction: the
|
||||||
|
/// window clamps to `>= 1` so it is non-zero, and `window / 2 <= window` always
|
||||||
|
/// (`VUID-...-08358` is `<=`, relaxed in Vulkan 1.3.299).
|
||||||
|
///
|
||||||
|
/// Carries its only caller's gate: `vulkan_video.rs` is the sole ms-form consumer, and with the
|
||||||
|
/// crate-wide `allow(dead_code)` gone (WP0.3) an item unused in ANY feature combination is a hard
|
||||||
|
/// error — this is dead on every Windows leg.
|
||||||
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
|
pub(crate) fn vbv_window_ms(fps: u32) -> (u32, u32) {
|
||||||
|
let frames = vbv_frames_env();
|
||||||
|
let ms = (frames * 1000.0 / fps.max(1) as f64).round();
|
||||||
|
// `f64 as u32` saturates at the bounds in Rust, so an absurd `PUNKTFUNK_VBV_FRAMES` cannot wrap.
|
||||||
|
let window = (ms as u32).max(1);
|
||||||
|
(window, window / 2)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||||
@@ -454,6 +577,26 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
|||||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// PyroWave's vendored rate controller packs the 32×32 block index into the low 16 bits of
|
||||||
|
// `RDOperation::block_offset_saving` (pyrowave-sys `patches/0002-rdo-saving-clamp.patch`).
|
||||||
|
// Past `u16::MAX` blocks the index collides with the `saving` field, the resolve over-credits,
|
||||||
|
// and the emitted payload can overshoot the buffer `pyrowave_encoder_packetize` writes into —
|
||||||
|
// whose only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles out.
|
||||||
|
// So this is a hard cap, not a quality knob.
|
||||||
|
//
|
||||||
|
// Checked against 4:2:0, the *most permissive* chroma: a mode that cannot fit even there can
|
||||||
|
// fit no PyroWave session at all, so it belongs at this single chokepoint (which both the
|
||||||
|
// negotiator and `open_video_backend` run) rather than only in the per-backend opens. 4:4:4
|
||||||
|
// has twice the block count and is checked again at open, where the real chroma is known —
|
||||||
|
// and the negotiator's 4:4:4 → 4:2:0 downgrade means an oversized mode arrives at the encoder
|
||||||
|
// as 4:2:0, which is exactly the case the old open-time guard skipped.
|
||||||
|
#[cfg(feature = "pyrowave")]
|
||||||
|
if codec == Codec::PyroWave && !crate::pyrowave_mode_fits_rdo(width, height, false) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"invalid PyroWave resolution {width}x{height}: exceeds the rate controller's 16-bit \
|
||||||
|
block index (pyrowave-sys patches/0002) — lower the client resolution"
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +604,31 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// WP6.3. The window VUIDs on `VkVideoEncodeRateControlInfoKHR` are the whole contract of
|
||||||
|
/// this helper, and both are edge cases: the window must be non-zero (a high-refresh mode
|
||||||
|
/// rounds a sub-1 ms window down to nothing) and the initial fill must be at most the window
|
||||||
|
/// (`<=` — `VUID-...-08358` was relaxed in 1.3.299). Env-free so it pins the default shape —
|
||||||
|
/// the scaled cases belong to whoever sets `PUNKTFUNK_VBV_FRAMES`. Carries the helper's own
|
||||||
|
/// cfg gate (see its note), so it runs on the Linux `vulkan-encode` leg.
|
||||||
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
|
#[test]
|
||||||
|
fn vbv_window_is_about_one_frame_and_always_legal() {
|
||||||
|
// The house default is ~1 frame interval, not the 1000 ms the Vulkan backend hardwired.
|
||||||
|
assert_eq!(vbv_window_ms(60).0, 17); // 16.67 ms
|
||||||
|
assert_eq!(vbv_window_ms(30).0, 33);
|
||||||
|
assert_eq!(vbv_window_ms(240).0, 4);
|
||||||
|
for fps in [1, 24, 30, 60, 120, 144, 240, 480, 1000, 4000, u32::MAX] {
|
||||||
|
let (window, initial) = vbv_window_ms(fps);
|
||||||
|
assert!(window > 0, "virtualBufferSizeInMs must be > 0 (fps {fps})");
|
||||||
|
assert!(
|
||||||
|
initial <= window,
|
||||||
|
"initialVirtualBufferSizeInMs must be <= virtualBufferSizeInMs (fps {fps})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// fps 0 must not divide by zero — `open` clamps, but the helper is called directly too.
|
||||||
|
assert!(vbv_window_ms(0).0 > 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_zero_and_odd_dimensions() {
|
fn rejects_zero_and_odd_dimensions() {
|
||||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||||
@@ -477,6 +645,26 @@ mod tests {
|
|||||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PyroWave's hard cap is the rate controller's 16-bit block index, not just
|
||||||
|
/// `max_dimension()`. Checked at 4:2:0 (the most permissive chroma), because a mode that
|
||||||
|
/// cannot fit there cannot fit at any chroma — and because the negotiator's 4:4:4 → 4:2:0
|
||||||
|
/// downgrade delivers oversized modes to the encoder AS 4:2:0. HEVC/AV1 at the same
|
||||||
|
/// dimensions must stay unaffected.
|
||||||
|
#[cfg(feature = "pyrowave")]
|
||||||
|
#[test]
|
||||||
|
fn pyrowave_rejects_modes_past_the_rdo_block_index() {
|
||||||
|
// Fits: 8K 4:2:0 is 49125 blocks.
|
||||||
|
assert!(validate_dimensions(Codec::PyroWave, 7680, 4320).is_ok());
|
||||||
|
// Does not fit at 4:2:0 (73728 / 98304 blocks) — must be refused even though both are
|
||||||
|
// within `Codec::PyroWave.max_dimension()` (8192).
|
||||||
|
assert!(validate_dimensions(Codec::PyroWave, 8192, 6144).is_err());
|
||||||
|
assert!(validate_dimensions(Codec::PyroWave, 8192, 8192).is_err());
|
||||||
|
// The same modes remain legal for the H.26x/AV1 codecs, which have no such rate
|
||||||
|
// controller — the cap must not leak across codecs.
|
||||||
|
assert!(validate_dimensions(Codec::H265, 8192, 8192).is_ok());
|
||||||
|
assert!(validate_dimensions(Codec::Av1, 8192, 6144).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hevc_and_av1_allow_up_to_8192() {
|
fn hevc_and_av1_allow_up_to_8192() {
|
||||||
for c in [Codec::H265, Codec::Av1] {
|
for c in [Codec::H265, Codec::Av1] {
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ pub struct NvencEncoder {
|
|||||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
src_format: PixelFormat,
|
src_format: PixelFormat,
|
||||||
expand: bool,
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
fps: u32,
|
fps: u32,
|
||||||
@@ -413,57 +412,6 @@ impl NvencEncoder {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
|
||||||
// input frame. Two users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag) and HDR
|
|
||||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
|
||||||
// through the matrix untouched). Skipped on the zero-copy path (`cuda`): the worker's GPU
|
|
||||||
// convert already delivers ready CUDA frames — no CPU pixels exist to scale.
|
|
||||||
let sws_csc = if (want_444 || want_hdr10) && !cuda {
|
|
||||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
|
||||||
let dst_av = pixel_to_av(nvenc_pixel);
|
|
||||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
|
||||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
|
||||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
|
||||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
|
||||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
|
||||||
// null-checked below.
|
|
||||||
let sws = unsafe {
|
|
||||||
ffi::sws_getContext(
|
|
||||||
width as c_int,
|
|
||||||
height as c_int,
|
|
||||||
src_av,
|
|
||||||
width as c_int,
|
|
||||||
height as c_int,
|
|
||||||
dst_av,
|
|
||||||
SWS_POINT,
|
|
||||||
ptr::null_mut(),
|
|
||||||
ptr::null_mut(),
|
|
||||||
ptr::null(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if sws.is_null() {
|
|
||||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
|
||||||
}
|
|
||||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
|
||||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for HDR
|
|
||||||
// — matching the VUI written above) are process-lifetime libswscale statics, reused for
|
|
||||||
// src+dst matrices; `sws_setColorspaceDetails` only reads them and writes scalar CSC
|
|
||||||
// settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
|
||||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
|
||||||
unsafe {
|
|
||||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
|
||||||
super::libav::SWS_CS_BT2020
|
|
||||||
} else {
|
|
||||||
SWS_CS_ITU709
|
|
||||||
});
|
|
||||||
let dst_range = i32::from(full_range_444);
|
|
||||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
|
||||||
}
|
|
||||||
Some(sws)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
||||||
let mut opts = Dictionary::new();
|
let mut opts = Dictionary::new();
|
||||||
opts.set("preset", "p1"); // fastest
|
opts.set("preset", "p1"); // fastest
|
||||||
@@ -491,15 +439,19 @@ impl NvencEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
|
||||||
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
// (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||||
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
||||||
// Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
// Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
|
||||||
|
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
|
||||||
|
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||||
let pix_rate = width as u64 * height as u64 * fps as u64;
|
let pix_rate = width as u64 * height as u64 * fps as u64;
|
||||||
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
||||||
match split.as_deref() {
|
match split.as_deref() {
|
||||||
Some(mode) => opts.set("split_encode_mode", mode),
|
Some(mode) => opts.set("split_encode_mode", mode),
|
||||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||||
|
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||||
|
{
|
||||||
opts.set("split_encode_mode", "2");
|
opts.set("split_encode_mode", "2");
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
pix_rate,
|
pix_rate,
|
||||||
@@ -549,6 +501,84 @@ impl NvencEncoder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
|
||||||
|
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
|
||||||
|
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
|
||||||
|
// retry, which recurses into `Self::open`, and the plain error return). Creating the
|
||||||
|
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
|
||||||
|
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
|
||||||
|
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
|
||||||
|
// so this placement makes the leak unrepresentable rather than merely unlikely.
|
||||||
|
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||||
|
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
|
||||||
|
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||||
|
// through the matrix untouched), and the packed 3-bpp expand (RGB24/BGR24→rgb0/bgr0).
|
||||||
|
//
|
||||||
|
// The expand used to be a hand-written per-pixel loop in `submit_cpu`: `w*h` iterations,
|
||||||
|
// each building two bounds-checked sub-slices for a 3-byte copy — a shape LLVM will not
|
||||||
|
// vectorise into the byte shuffle it is, on the COMMON CPU path (the portal and wlroots
|
||||||
|
// both commonly fixate packed 24-bit RGB, and pf-capture offers it first). swscale's
|
||||||
|
// packed-RGB expanders are SIMD, the sibling VAAPI backend already routes RGB24/BGR24
|
||||||
|
// through them, and this file already owned the context lifecycle — so the change is net
|
||||||
|
// subtractive. The three are mutually exclusive by construction: `expand` is only ever
|
||||||
|
// true on the packed-RGB 4:2:0 path (see `nvenc_pixel`/`expand` above), never with
|
||||||
|
// `want_444`, so one context serves whichever applies.
|
||||||
|
//
|
||||||
|
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers ready
|
||||||
|
// CUDA frames — no CPU pixels exist to scale.
|
||||||
|
let sws_csc = if (want_444 || want_hdr10 || expand) && !cuda {
|
||||||
|
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||||
|
let dst_av = pixel_to_av(nvenc_pixel);
|
||||||
|
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||||
|
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||||
|
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||||
|
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||||
|
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||||
|
// null-checked below.
|
||||||
|
let sws = unsafe {
|
||||||
|
ffi::sws_getContext(
|
||||||
|
width as c_int,
|
||||||
|
height as c_int,
|
||||||
|
src_av,
|
||||||
|
width as c_int,
|
||||||
|
height as c_int,
|
||||||
|
dst_av,
|
||||||
|
SWS_POINT,
|
||||||
|
ptr::null_mut(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
ptr::null(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if sws.is_null() {
|
||||||
|
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||||
|
}
|
||||||
|
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
|
||||||
|
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
|
||||||
|
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
|
||||||
|
// here would silently range-convert every packed-RGB session, which is exactly what the
|
||||||
|
// module header promises does not happen ("no colour math").
|
||||||
|
if want_444 || want_hdr10 {
|
||||||
|
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||||
|
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for
|
||||||
|
// HDR — matching the VUI written above) are process-lifetime libswscale statics,
|
||||||
|
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads them and writes
|
||||||
|
// scalar CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||||
|
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||||
|
unsafe {
|
||||||
|
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||||
|
super::libav::SWS_CS_BT2020
|
||||||
|
} else {
|
||||||
|
SWS_CS_ITU709
|
||||||
|
});
|
||||||
|
let dst_range = i32::from(full_range_444);
|
||||||
|
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(sws)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let frame = if cuda {
|
let frame = if cuda {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -561,7 +591,6 @@ impl NvencEncoder {
|
|||||||
sws_csc,
|
sws_csc,
|
||||||
want_444,
|
want_444,
|
||||||
src_format: format,
|
src_format: format,
|
||||||
expand,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fps,
|
fps,
|
||||||
@@ -591,6 +620,9 @@ impl NvencEncoder {
|
|||||||
impl Encoder for NvencEncoder {
|
impl Encoder for NvencEncoder {
|
||||||
fn caps(&self) -> super::EncoderCaps {
|
fn caps(&self) -> super::EncoderCaps {
|
||||||
super::EncoderCaps {
|
super::EncoderCaps {
|
||||||
|
// libav NVENC hands the frame straight to the encoder — `frame.cursor` is never read,
|
||||||
|
// so a cursor-as-metadata session loses its pointer on this backend (audit finding).
|
||||||
|
blends_cursor: false,
|
||||||
// 4:4:4 iff this session opened FREXT — the CPU swscale path or the zero-copy GPU
|
// 4:4:4 iff this session opened FREXT — the CPU swscale path or the zero-copy GPU
|
||||||
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
||||||
chroma_444: self.want_444,
|
chroma_444: self.want_444,
|
||||||
@@ -695,8 +727,10 @@ impl NvencEncoder {
|
|||||||
bytes.len(),
|
bytes.len(),
|
||||||
src_row * h
|
src_row * h
|
||||||
);
|
);
|
||||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
// swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
|
||||||
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
|
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
|
||||||
|
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
|
||||||
|
// conversion at all — just a row copy honouring the destination stride.
|
||||||
if let Some(sws) = self.sws_csc {
|
if let Some(sws) = self.sws_csc {
|
||||||
let frame = self
|
let frame = self
|
||||||
.frame
|
.frame
|
||||||
@@ -705,10 +739,13 @@ impl NvencEncoder {
|
|||||||
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
|
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
|
||||||
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
|
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
|
||||||
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
|
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
|
||||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`: its
|
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
|
||||||
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
|
// `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
|
||||||
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
|
// `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
|
||||||
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
|
// YUV444P, 2 for P010, 1 packed plane for `rgb0`/`bgr0` — so swscale writes exactly the
|
||||||
|
// planes it allocated, at the strides it reports. All pointers are live locals for this
|
||||||
|
// synchronous call; the encoder runs only on this thread (`unsafe impl Send`), so no
|
||||||
|
// aliasing/race.
|
||||||
unsafe {
|
unsafe {
|
||||||
let dst_av = frame.as_mut_ptr();
|
let dst_av = frame.as_mut_ptr();
|
||||||
let src_data: [*const u8; 4] =
|
let src_data: [*const u8; 4] =
|
||||||
@@ -724,7 +761,7 @@ impl NvencEncoder {
|
|||||||
(*dst_av).linesize.as_ptr(),
|
(*dst_av).linesize.as_ptr(),
|
||||||
);
|
);
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
frame.set_pts(Some(pts));
|
frame.set_pts(Some(pts));
|
||||||
@@ -733,7 +770,7 @@ impl NvencEncoder {
|
|||||||
} else {
|
} else {
|
||||||
ffmpeg::picture::Type::None
|
ffmpeg::picture::Type::None
|
||||||
});
|
});
|
||||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
self.enc.send_frame(frame).context("send_frame(swscale)")?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let frame = self
|
let frame = self
|
||||||
@@ -742,18 +779,9 @@ impl NvencEncoder {
|
|||||||
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
||||||
let stride = frame.stride(0); // dst is 4-bpp, aligned
|
let stride = frame.stride(0); // dst is 4-bpp, aligned
|
||||||
let dst = frame.data_mut(0);
|
let dst = frame.data_mut(0);
|
||||||
if self.expand {
|
{
|
||||||
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
|
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
|
||||||
for y in 0..h {
|
// to live here as a per-pixel loop is now swscale's job (see the branch above).
|
||||||
let s = &bytes[y * src_row..y * src_row + src_row];
|
|
||||||
let drow = &mut dst[y * stride..y * stride + w * 4];
|
|
||||||
for x in 0..w {
|
|
||||||
drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]);
|
|
||||||
drow[x * 4 + 3] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride
|
|
||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
dst[y * stride..y * stride + src_row]
|
dst[y * stride..y * stride + src_row]
|
||||||
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
||||||
@@ -876,6 +904,58 @@ impl Drop for NvencEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
|
||||||
|
/// an encoder open it *expects* to fail.
|
||||||
|
///
|
||||||
|
/// libav's log level is one process-global `int`, and the probes race each other for real: the
|
||||||
|
/// NVENC and VAAPI 4:4:4/10-bit probes are reached from `/serverinfo` and from session bring-up.
|
||||||
|
/// Two overlapping save/restore pairs interleave as get(INFO) → get(FATAL) → set(INFO) →
|
||||||
|
/// set(FATAL), and the process is then pinned at `AV_LOG_FATAL` for good — every later libav
|
||||||
|
/// diagnostic silently dropped, which is precisely the logging you want when a stream later fails
|
||||||
|
/// to open. The probes run process-once and already cost a real encoder open, so serialising them
|
||||||
|
/// costs nothing measurable.
|
||||||
|
static LIBAV_LOG_LEVEL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
/// RAII quiet-window over libav's global log level: drops it to `AV_LOG_FATAL` on construction and
|
||||||
|
/// restores the previous level on drop, holding [`LIBAV_LOG_LEVEL`] for the whole window.
|
||||||
|
///
|
||||||
|
/// Callers must have completed `ffmpeg::init()` first. Not re-entrant — no probe may construct a
|
||||||
|
/// second guard while holding one (none do; the probe bodies only reach encoder-open helpers).
|
||||||
|
/// `pub(crate)` so the VAAPI probes share the one lock: they race the NVENC probes on the same
|
||||||
|
/// global.
|
||||||
|
pub(crate) struct QuietLibavLog {
|
||||||
|
prev: c_int,
|
||||||
|
// Held for the lifetime of the guard. `Drop for QuietLibavLog` runs before the struct's fields
|
||||||
|
// are dropped, so the restore below still happens under the lock.
|
||||||
|
_lock: std::sync::MutexGuard<'static, ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuietLibavLog {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
// Poison-tolerant: a probe that panicked mid-window already restored the level via `Drop`,
|
||||||
|
// and refusing the lock forever afterwards would be a worse outcome than proceeding.
|
||||||
|
let lock = LIBAV_LOG_LEVEL
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
// SAFETY: libav is initialized by the caller; `av_log_{get,set}_level` only read/write the
|
||||||
|
// global int level (no pointer args) and are always sound post-init.
|
||||||
|
let prev = unsafe {
|
||||||
|
let p = ffi::av_log_get_level();
|
||||||
|
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||||
|
p
|
||||||
|
};
|
||||||
|
Self { prev, _lock: lock }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for QuietLibavLog {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: restore the saved global level (scalar arg, no pointers); libav was initialized
|
||||||
|
// before this guard was constructed.
|
||||||
|
unsafe { ffi::av_log_set_level(self.prev) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
||||||
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
||||||
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
||||||
@@ -889,14 +969,9 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Quiet ffmpeg's open error on a GPU that lacks 4:4:4 — the probe failing is an expected outcome.
|
// Quiet ffmpeg's open error on a GPU that lacks 4:4:4 — the probe failing is an expected outcome.
|
||||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
// Held until the function returns, so the level is restored after the open either way.
|
||||||
// (no pointer args) and are always sound post-init.
|
let _quiet = QuietLibavLog::new();
|
||||||
let prev = unsafe {
|
NvencEncoder::open(
|
||||||
let p = ffi::av_log_get_level();
|
|
||||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
|
||||||
p
|
|
||||||
};
|
|
||||||
let ok = NvencEncoder::open(
|
|
||||||
codec,
|
codec,
|
||||||
PixelFormat::Bgra,
|
PixelFormat::Bgra,
|
||||||
640,
|
640,
|
||||||
@@ -907,10 +982,7 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv444,
|
ChromaFormat::Yuv444,
|
||||||
)
|
)
|
||||||
.is_ok();
|
.is_ok()
|
||||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
|
||||||
unsafe { ffi::av_log_set_level(prev) };
|
|
||||||
ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
||||||
@@ -926,14 +998,9 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Quiet ffmpeg's open error on a GPU that lacks 10-bit — the probe failing is an expected outcome.
|
// Quiet ffmpeg's open error on a GPU that lacks 10-bit — the probe failing is an expected outcome.
|
||||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
// Held until the function returns, so the level is restored after the open either way.
|
||||||
// (no pointer args) and are always sound post-init.
|
let _quiet = QuietLibavLog::new();
|
||||||
let prev = unsafe {
|
NvencEncoder::open(
|
||||||
let p = ffi::av_log_get_level();
|
|
||||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
|
||||||
p
|
|
||||||
};
|
|
||||||
let ok = NvencEncoder::open(
|
|
||||||
codec,
|
codec,
|
||||||
PixelFormat::X2Rgb10,
|
PixelFormat::X2Rgb10,
|
||||||
640,
|
640,
|
||||||
@@ -944,10 +1011,7 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
|||||||
10,
|
10,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
)
|
)
|
||||||
.is_ok();
|
.is_ok()
|
||||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
|
||||||
unsafe { ffi::av_log_set_level(prev) };
|
|
||||||
ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -61,8 +61,9 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::nvenc_core::{
|
use super::nvenc_core::{
|
||||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey,
|
||||||
|
LowLatencyConfig, NvStatusExt, RangePlan,
|
||||||
};
|
};
|
||||||
use super::nvenc_status;
|
use super::nvenc_status;
|
||||||
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
@@ -268,13 +269,19 @@ fn async_retrieve_requested() -> bool {
|
|||||||
|
|
||||||
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
||||||
/// `2..=POOL-1` — a bitstream must never be reused mid-encode, and the input ring is the same
|
/// `2..=POOL-1` — a bitstream must never be reused mid-encode, and the input ring is the same
|
||||||
/// depth). Mirrors the Windows knob exactly.
|
/// depth). Mirrors the Windows knob exactly, memoization included: this is the backpressure
|
||||||
|
/// **loop condition** in `submit`, so an engaged two-thread session re-read the environment once
|
||||||
|
/// per spin. The default session never pays it (the condition short-circuits on `async_rt`), which
|
||||||
|
/// is why the audit's severity ranking for this site was inverted — but an escalated one did.
|
||||||
fn async_inflight_cap() -> usize {
|
fn async_inflight_cap() -> usize {
|
||||||
|
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||||
|
*CAP.get_or_init(|| {
|
||||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||||
.unwrap_or(4)
|
.unwrap_or(4)
|
||||||
.clamp(2, POOL - 1)
|
.clamp(2, POOL - 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
|
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
|
||||||
@@ -595,6 +602,12 @@ pub struct NvencCudaEncoder {
|
|||||||
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
||||||
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
||||||
want_async: bool,
|
want_async: bool,
|
||||||
|
/// A de-escalation request ([`Encoder::set_pipelined(false)`]) waiting for its safe point:
|
||||||
|
/// the next drained moment tears the session down and lazily re-inits SYNC (IO-stream
|
||||||
|
/// binding and sub-frame chunking re-arm at that re-init). Distinct from `!want_async` —
|
||||||
|
/// an operator-forced async session (`PUNKTFUNK_NVENC_ASYNC=1`) also has `want_async`
|
||||||
|
/// false, and a de-escalation must never tear THAT down.
|
||||||
|
want_sync: bool,
|
||||||
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
||||||
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
||||||
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
||||||
@@ -702,6 +715,7 @@ impl NvencCudaEncoder {
|
|||||||
last_rfi_range: None,
|
last_rfi_range: None,
|
||||||
async_rt: None,
|
async_rt: None,
|
||||||
want_async: false,
|
want_async: false,
|
||||||
|
want_sync: false,
|
||||||
io_stream: ptr::null_mut(),
|
io_stream: ptr::null_mut(),
|
||||||
stream_ordered: false,
|
stream_ordered: false,
|
||||||
slices: 1,
|
slices: 1,
|
||||||
@@ -735,6 +749,29 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`maybe_engage_async`](Self::maybe_engage_async)'s inverse — wind the escalated pipelined
|
||||||
|
/// retrieve back at a safe point: nothing in flight, then a clean session rebuild whose lazy
|
||||||
|
/// SYNC re-init restores the IO-stream binding and re-arms sub-frame chunking (the two
|
||||||
|
/// latency features the escalation traded away). No-op until
|
||||||
|
/// [`want_sync`](Self::want_sync) is set and `pending` drains.
|
||||||
|
fn maybe_disengage_async(&mut self) {
|
||||||
|
if !self.want_sync || self.async_rt.is_none() || !self.pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.want_sync = false;
|
||||||
|
if self.inited {
|
||||||
|
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight (and nothing queued
|
||||||
|
// to the retrieve thread); `teardown` joins the retrieve thread and handles exactly
|
||||||
|
// this live-session state — the next submit lazily re-inits sync.
|
||||||
|
unsafe { self.teardown() };
|
||||||
|
tracing::info!(
|
||||||
|
"NVENC pipelined-retrieve de-escalation: rebuilding the session with the sync \
|
||||||
|
retrieve (IO-stream binding and sub-frame chunking restored); next frame opens \
|
||||||
|
with an IDR"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
||||||
unsafe fn teardown(&mut self) {
|
unsafe fn teardown(&mut self) {
|
||||||
if self.encoder.is_null() {
|
if self.encoder.is_null() {
|
||||||
@@ -839,6 +876,9 @@ impl NvencCudaEncoder {
|
|||||||
e,
|
e,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// The handshake with the kernel module just succeeded — from here on, an
|
||||||
|
// `NV_ENC_ERR_INVALID_VERSION` in this process cannot be a driver version skew.
|
||||||
|
nvenc_status::note_session_opened();
|
||||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||||
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
|
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
|
||||||
@@ -1000,6 +1040,23 @@ impl NvencCudaEncoder {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||||
|
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the process-global shared
|
||||||
|
/// `CUcontext` pointer — one context per process, stable for its lifetime; only valid once
|
||||||
|
/// `cu_ctx` is bound (`init_session` start), which every caller is downstream of.
|
||||||
|
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||||
|
CeilingKey {
|
||||||
|
gpu: self.cu_ctx as u64,
|
||||||
|
codec: self.codec,
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
fps: self.fps,
|
||||||
|
bit_depth: self.bit_depth,
|
||||||
|
chroma_444: self.chroma_444,
|
||||||
|
split_mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||||
/// Returns the session handle, or destroys it and returns the error.
|
/// Returns the session handle, or destroys it and returns the error.
|
||||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||||
@@ -1019,6 +1076,7 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
||||||
}
|
}
|
||||||
|
nvenc_status::note_session_opened();
|
||||||
|
|
||||||
let mut cfg = match self.build_config(enc, bitrate) {
|
let mut cfg = match self.build_config(enc, bitrate) {
|
||||||
Ok(cfg) => cfg,
|
Ok(cfg) => cfg,
|
||||||
@@ -1074,58 +1132,71 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
const FLOOR_BPS: u64 = 10_000_000;
|
const FLOOR_BPS: u64 = 10_000_000;
|
||||||
let requested_bps = self.bitrate_bps;
|
let requested_bps = self.bitrate_bps;
|
||||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see
|
||||||
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
|
// [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate).
|
||||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||||
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||||
{
|
|
||||||
Some("0") | Some("disable") => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
|
||||||
}
|
|
||||||
Some("1") | Some("auto") => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
|
||||||
}
|
|
||||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
|
||||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
|
||||||
_ if self.bit_depth >= 10 => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
|
||||||
}
|
|
||||||
_ if pixel_rate > 1_000_000_000 => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
|
||||||
}
|
|
||||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
|
||||||
};
|
|
||||||
const CLAMP_TOL_BPS: u64 = 20_000_000;
|
const CLAMP_TOL_BPS: u64 = 20_000_000;
|
||||||
|
|
||||||
let mut probe = self.try_open_session(requested_bps, split_mode);
|
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
|
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||||
|
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||||
|
let mut target_bps = requested_bps;
|
||||||
|
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||||
|
if requested_bps > ceiling {
|
||||||
|
tracing::info!(
|
||||||
|
requested_mbps = requested_bps / 1_000_000,
|
||||||
|
ceiling_mbps = ceiling / 1_000_000,
|
||||||
|
"NVENC (Linux): requested bitrate above the cached codec-level ceiling — \
|
||||||
|
opening at the ceiling"
|
||||||
|
);
|
||||||
|
target_bps = ceiling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut probe = self.try_open_session(target_bps, split_mode);
|
||||||
|
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||||
|
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||||
|
if probe.is_err() && target_bps < requested_bps {
|
||||||
|
target_bps = requested_bps;
|
||||||
|
probe = self.try_open_session(requested_bps, split_mode);
|
||||||
|
}
|
||||||
|
// Disambiguate a forced-split rejection from a bitrate-cap rejection. `used_split`
|
||||||
|
// tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||||
|
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||||
|
let mut used_split = split_mode;
|
||||||
let split_on =
|
let split_on =
|
||||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||||
if probe.is_err() && split_on {
|
if probe.is_err() && split_on {
|
||||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||||
if let Ok(e) = self.try_open_session(requested_bps, no_split) {
|
if let Ok(e) = self.try_open_session(target_bps, no_split) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"NVENC (Linux): split-encode rejected by codec/config — disabled"
|
"NVENC (Linux): split-encode rejected by codec/config — disabled"
|
||||||
);
|
);
|
||||||
split_mode = no_split;
|
used_split = no_split;
|
||||||
probe = Ok(e);
|
probe = Ok(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let enc = match probe {
|
let enc = match probe {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
self.bitrate_bps = requested_bps;
|
self.bitrate_bps = target_bps;
|
||||||
enc
|
enc
|
||||||
}
|
}
|
||||||
|
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||||
|
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||||
|
// version skew) must propagate — a search steered by it would discover, and
|
||||||
|
// cache, a bogus ceiling.
|
||||||
|
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||||
let mut lo = FLOOR_BPS;
|
let mut lo = FLOOR_BPS;
|
||||||
let mut hi = requested_bps;
|
let mut hi = target_bps;
|
||||||
let mut best: *mut c_void = ptr::null_mut();
|
let mut best: *mut c_void = ptr::null_mut();
|
||||||
let mut best_bps = 0u64;
|
let mut best_bps = 0u64;
|
||||||
while hi > lo + CLAMP_TOL_BPS {
|
while hi > lo + CLAMP_TOL_BPS {
|
||||||
let mid = lo + (hi - lo) / 2;
|
let mid = lo + (hi - lo) / 2;
|
||||||
match self.try_open_session(mid, split_mode) {
|
match self.try_open_session(mid, used_split) {
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
if !best.is_null() {
|
if !best.is_null() {
|
||||||
let _ = (api().destroy_encoder)(best);
|
let _ = (api().destroy_encoder)(best);
|
||||||
@@ -1134,18 +1205,30 @@ impl NvencCudaEncoder {
|
|||||||
best_bps = mid;
|
best_bps = mid;
|
||||||
lo = mid;
|
lo = mid;
|
||||||
}
|
}
|
||||||
Err(_) => hi = mid,
|
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||||
|
Err(e) => {
|
||||||
|
// Environmental mid-search failure: don't let it shrink the
|
||||||
|
// search — release the partial result and propagate.
|
||||||
|
if !best.is_null() {
|
||||||
|
let _ = (api().destroy_encoder)(best);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if best.is_null() {
|
if best.is_null() {
|
||||||
let no_split =
|
let no_split =
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||||
best = self
|
best = match self.try_open_session(FLOOR_BPS, used_split) {
|
||||||
.try_open_session(FLOOR_BPS, split_mode)
|
Ok(e) => e,
|
||||||
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
|
Err(_) => {
|
||||||
.context(
|
let e = self.try_open_session(FLOOR_BPS, no_split).context(
|
||||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||||
)?;
|
)?;
|
||||||
|
used_split = no_split;
|
||||||
|
e
|
||||||
|
}
|
||||||
|
};
|
||||||
best_bps = FLOOR_BPS;
|
best_bps = FLOOR_BPS;
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1153,15 +1236,13 @@ impl NvencCudaEncoder {
|
|||||||
clamped_mbps = best_bps / 1_000_000,
|
clamped_mbps = best_bps / 1_000_000,
|
||||||
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
|
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
|
||||||
);
|
);
|
||||||
|
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||||
self.bitrate_bps = best_bps;
|
self.bitrate_bps = best_bps;
|
||||||
best
|
best
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.encoder = enc;
|
self.encoder = enc;
|
||||||
// (Best effort: the floor fallback above may have succeeded split-disabled without
|
self.split_mode = used_split;
|
||||||
// updating `split_mode` — a later reconfigure then presents the forced mode, NVENC
|
|
||||||
// rejects it, and the caller's rebuild fallback covers the mismatch.)
|
|
||||||
self.split_mode = split_mode;
|
|
||||||
|
|
||||||
// Output bitstream pool.
|
// Output bitstream pool.
|
||||||
for _ in 0..POOL {
|
for _ in 0..POOL {
|
||||||
@@ -1345,6 +1426,10 @@ impl NvencCudaEncoder {
|
|||||||
mbps = self.bitrate_bps / 1_000_000,
|
mbps = self.bitrate_bps / 1_000_000,
|
||||||
codec = ?self.codec_guid,
|
codec = ?self.codec_guid,
|
||||||
fmt = ?self.buffer_fmt,
|
fmt = ?self.buffer_fmt,
|
||||||
|
// The FINAL split mode (post any rejection fallback) at INFO — journals run
|
||||||
|
// INFO+, and "did 4K120 actually split across engines?" was undiagnosable from
|
||||||
|
// a user log without it (Windows only had a debug! at selection time).
|
||||||
|
split_mode = self.split_mode,
|
||||||
"NVENC CUDA session ready"
|
"NVENC CUDA session ready"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1426,9 +1511,10 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
|
// A pending pipelined-retrieve escalation — or de-escalation — engages here, at the
|
||||||
// (nothing in flight after the previous poll drained).
|
// submit-side safe point (nothing in flight after the previous poll drained).
|
||||||
self.maybe_engage_async();
|
self.maybe_engage_async();
|
||||||
|
self.maybe_disengage_async();
|
||||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||||
let new_fmt = buffer_format(buf);
|
let new_fmt = buffer_format(buf);
|
||||||
@@ -1560,25 +1646,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.cursor_blend_warned = false;
|
self.cursor_blend_warned = false;
|
||||||
// TEMP KWin composite probe (DROP BEFORE MERGE): prove the blend dispatches
|
|
||||||
// and with what geometry.
|
|
||||||
{
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering as ProbeOrd};
|
|
||||||
static PROBE_BLEND: AtomicU64 = AtomicU64::new(0);
|
|
||||||
let n = PROBE_BLEND.fetch_add(1, ProbeOrd::Relaxed) + 1;
|
|
||||||
if n == 1 || n % 512 == 0 {
|
|
||||||
tracing::info!(
|
|
||||||
n,
|
|
||||||
fmt = ?self.buffer_fmt,
|
|
||||||
ov_x = ov.x,
|
|
||||||
ov_y = ov.y,
|
|
||||||
ov_w = ov.w,
|
|
||||||
ov_h = ov.h,
|
|
||||||
visible = ov.visible,
|
|
||||||
"cursor blend probe: vulkan dispatch submitted"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if !self.cursor_blend_warned {
|
} else if !self.cursor_blend_warned {
|
||||||
self.cursor_blend_warned = true;
|
self.cursor_blend_warned = true;
|
||||||
@@ -1736,12 +1803,25 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
|
|
||||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||||
if !on {
|
if !on {
|
||||||
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
|
// De-escalation (the v2 of escalate-and-hold): latch the wind-back intent; the
|
||||||
|
// switch itself happens at the next drained safe point
|
||||||
|
// ([`maybe_disengage_async`](Self::maybe_disengage_async)) — the caller polls
|
||||||
|
// this same method until it reports inactive.
|
||||||
|
if async_retrieve_env() == Some(true) {
|
||||||
|
// Operator pinned async on — de-escalation must not undo an explicit choice.
|
||||||
|
return self.want_async || self.async_rt.is_some();
|
||||||
|
}
|
||||||
|
if self.want_async || self.async_rt.is_some() {
|
||||||
|
self.want_async = false;
|
||||||
|
self.want_sync = true;
|
||||||
|
self.maybe_disengage_async();
|
||||||
|
}
|
||||||
return self.want_async || self.async_rt.is_some();
|
return self.want_async || self.async_rt.is_some();
|
||||||
}
|
}
|
||||||
if async_retrieve_env() == Some(false) {
|
if async_retrieve_env() == Some(false) {
|
||||||
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
||||||
}
|
}
|
||||||
|
self.want_sync = false; // latest intent wins — cancel a pending wind-back
|
||||||
if !self.want_async && self.async_rt.is_none() {
|
if !self.want_async && self.async_rt.is_none() {
|
||||||
self.want_async = true;
|
self.want_async = true;
|
||||||
self.maybe_engage_async();
|
self.maybe_engage_async();
|
||||||
@@ -1751,6 +1831,8 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
|
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
|
||||||
|
blends_cursor: true,
|
||||||
supports_rfi: self.rfi_supported,
|
supports_rfi: self.rfi_supported,
|
||||||
supports_hdr_metadata: self.hdr,
|
supports_hdr_metadata: self.hdr,
|
||||||
chroma_444: self.chroma_444,
|
chroma_444: self.chroma_444,
|
||||||
@@ -1765,33 +1847,29 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
// Range validity, covering-range dedup, DPB window and clamp all live in
|
||||||
|
// `nvenc_core::plan_range_recovery` — one policy for both direct-NVENC backends; only the
|
||||||
|
// session gate and the driver loop are this backend's.
|
||||||
|
if self.encoder.is_null() || !self.rfi_supported {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Already invalidated a covering range for this loss event — re-arm the anchor (the previous
|
match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
|
||||||
// anchor AU may itself have been lost) but skip the driver calls.
|
// Already invalidated a covering range for this loss event — re-arm the anchor (the
|
||||||
if let Some((pf, pl)) = self.last_rfi_range {
|
// previous anchor AU may itself have been lost) but skip the driver calls.
|
||||||
if first >= pf && last <= pl {
|
RangePlan::Covered => {
|
||||||
self.pending_anchor = true;
|
self.pending_anchor = true;
|
||||||
return true;
|
true
|
||||||
}
|
}
|
||||||
}
|
RangePlan::Decline => false,
|
||||||
// The DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`; a lost frame older than that can't be
|
RangePlan::Invalidate { first, last } => {
|
||||||
// invalidated, so the only correct recovery is an IDR.
|
// Each input's `inputTimeStamp` is the WIRE frame index (pinned by
|
||||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
// `submit_indexed`), so the client's lost-frame range maps 1:1 onto the timestamps
|
||||||
if first < oldest_in_dpb {
|
// NVENC invalidates here.
|
||||||
return false;
|
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime table;
|
||||||
}
|
// `self.encoder` was checked non-null and is the live session; this runs on the
|
||||||
let last = last.min(self.frame_idx - 1);
|
// encode thread (no concurrent NVENC use). The plan clamped each `ts` to
|
||||||
if first > last {
|
// `[oldest_in_dpb, frame_idx - 1]`, naming a frame still in the DPB; the call
|
||||||
return false;
|
// passes only that `u64` (no struct).
|
||||||
}
|
|
||||||
// Each input's `inputTimeStamp` is the WIRE frame index (pinned by `submit_indexed`), so the
|
|
||||||
// client's lost-frame range maps 1:1 onto the timestamps NVENC invalidates here.
|
|
||||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime table; `self.encoder`
|
|
||||||
// was checked non-null and is the live session; this runs on the encode thread (no concurrent
|
|
||||||
// NVENC use). Each `ts` is clamped to `[oldest_in_dpb, frame_idx - 1]`, naming a frame still
|
|
||||||
// in the DPB; the call passes only that `u64` (no struct).
|
|
||||||
unsafe {
|
unsafe {
|
||||||
for ts in first..=last {
|
for ts in first..=last {
|
||||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||||
@@ -1803,11 +1881,13 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.last_rfi_range = Some((first, last));
|
self.last_rfi_range = Some((first, last));
|
||||||
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships with
|
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships
|
||||||
// `recovery_anchor` and the client lifts its post-loss freeze on it.
|
// with `recovery_anchor` and the client lifts its post-loss freeze on it.
|
||||||
self.pending_anchor = true;
|
self.pending_anchor = true;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
// A partially-chunked AU must be finished through `poll_chunk`: its emitted prefix is
|
// A partially-chunked AU must be finished through `poll_chunk`: its emitted prefix is
|
||||||
@@ -2061,6 +2141,15 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
self.bitrate_bps = bps;
|
self.bitrate_bps = bps;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||||
|
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||||
|
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||||
|
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||||
|
// [`Encoder::applied_bitrate_bps`].
|
||||||
|
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||||
|
Some(ceiling) => bps.min(ceiling),
|
||||||
|
None => bps,
|
||||||
|
};
|
||||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
|
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
|
||||||
// encode thread with no NVENC call in flight (the session loop calls this between
|
// encode thread with no NVENC call in flight (the session loop calls this between
|
||||||
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
|
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
|
||||||
@@ -2108,6 +2197,12 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||||
|
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||||
|
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||||
|
Some(self.bitrate_bps)
|
||||||
|
}
|
||||||
|
|
||||||
fn flush(&mut self) -> Result<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||||
}
|
}
|
||||||
@@ -2165,6 +2260,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA session");
|
.expect("open NVENC CUDA session");
|
||||||
|
|
||||||
@@ -2241,6 +2337,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv444,
|
ChromaFormat::Yuv444,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA 4:4:4 session");
|
.expect("open NVENC CUDA 4:4:4 session");
|
||||||
|
|
||||||
@@ -2286,6 +2383,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA session");
|
.expect("open NVENC CUDA session");
|
||||||
|
|
||||||
@@ -2343,6 +2441,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
) else {
|
) else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
|
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
|
||||||
@@ -2369,6 +2468,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA encoder")
|
.expect("open NVENC CUDA encoder")
|
||||||
}
|
}
|
||||||
@@ -2403,6 +2503,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open");
|
.expect("open");
|
||||||
for f in 0..4u32 {
|
for f in 0..4u32 {
|
||||||
@@ -2540,6 +2641,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA session");
|
.expect("open NVENC CUDA session");
|
||||||
let frame = nv12_frame(W, H, 0);
|
let frame = nv12_frame(W, H, 0);
|
||||||
@@ -2581,6 +2683,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA session");
|
.expect("open NVENC CUDA session");
|
||||||
// Steady sync frames first (stream-ordered mode).
|
// Steady sync frames first (stream-ordered mode).
|
||||||
@@ -2660,6 +2763,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA session");
|
.expect("open NVENC CUDA session");
|
||||||
|
|
||||||
@@ -2765,6 +2869,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
8,
|
8,
|
||||||
ChromaFormat::Yuv420,
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("open NVENC CUDA session");
|
.expect("open NVENC CUDA session");
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,22 +28,18 @@ use ffmpeg::format::Pixel;
|
|||||||
use ffmpeg::{codec, encoder, Dictionary};
|
use ffmpeg::{codec, encoder, Dictionary};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ffi::{CStr, CString};
|
use std::ffi::{CStr, CString};
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
use std::os::raw::c_int;
|
use std::os::raw::c_int;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use super::libav::{
|
use super::libav::{
|
||||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||||
};
|
};
|
||||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||||
|
|
||||||
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
|
|
||||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
|
||||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
|
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
|
||||||
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
||||||
/// default.
|
/// default.
|
||||||
@@ -71,21 +67,38 @@ fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which VAAPI entrypoint mode opened successfully, cached per codec (index = [`lp_idx`]):
|
/// Which VAAPI entrypoint mode opened successfully: 1 = default (full-feature `EncSlice`),
|
||||||
/// 0 = unknown, 1 = default (full-feature `EncSlice`), 2 = low-power (`EncSliceLP`/VDEnc).
|
/// 2 = low-power (`EncSliceLP`/VDEnc). Modern Intel (Gen12+/Arc) removed the full-feature encode
|
||||||
/// Modern Intel (Gen12+/Arc) removed the full-feature encode entrypoints, so the default open
|
/// entrypoints, so the default open fails there and only `low_power=1` works; AMD (radeonsi) is the
|
||||||
/// fails there and only `low_power=1` works; AMD (radeonsi) is the reverse. Caching the resolved
|
/// reverse. Caching the resolved mode lets later sessions/probes skip the known-failing attempt
|
||||||
/// mode lets later sessions/probes skip the known-failing attempt (and its libav error spew).
|
/// (and its libav error spew).
|
||||||
static LP_MODE: [AtomicU8; 3] = [AtomicU8::new(0), AtomicU8::new(0), AtomicU8::new(0)];
|
///
|
||||||
|
/// Keyed on **(render node, codec, bit depth)**, and every part of that key is load-bearing:
|
||||||
|
///
|
||||||
|
/// * The render node, because the entrypoint is a property of the DEVICE libva opens — and
|
||||||
|
/// `render_node()` follows the web-console GPU preference. This used to be a process-global array
|
||||||
|
/// keyed by codec alone, which made it a session-killer rather than a staleness bug: once a mode
|
||||||
|
/// is latched the open tries exactly ONE mode and the `[false, true]` fallback is gone. Latch
|
||||||
|
/// low-power on an Intel Arc, switch the preference to an AMD dGPU, and every VAAPI open there
|
||||||
|
/// passes `low_power=1` — which radeonsi rejects — with no full-feature retry, for the process
|
||||||
|
/// lifetime: the probe reports all-false AND the session's own encoder open fails.
|
||||||
|
/// Keyed on the node rather than `pf_gpu::selection_key()` on purpose — the node is literally what
|
||||||
|
/// `render_node()` hands libva, so key and device cannot describe different GPUs.
|
||||||
|
/// * The bit depth, because Main10 and 8-bit can resolve to different entrypoints on the same
|
||||||
|
/// device; one shared slot let an 8-bit answer pin the 10-bit open (HDR under-advertisement).
|
||||||
|
static LP_MODE: OnceLock<Mutex<HashMap<LpKey, u8>>> = OnceLock::new();
|
||||||
|
|
||||||
fn lp_idx(codec: Codec) -> usize {
|
/// (render-node path, codec label, 10-bit) — see [`LP_MODE`].
|
||||||
match codec {
|
type LpKey = (String, &'static str, bool);
|
||||||
Codec::H264 => 0,
|
|
||||||
Codec::H265 => 1,
|
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
|
||||||
Codec::Av1 => 2,
|
/// so a GPU-preference change is picked up on the next open.
|
||||||
// Guarded by the open_video dispatch: PyroWave never opens the VAAPI backend.
|
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
|
||||||
Codec::PyroWave => unreachable!("PyroWave has no VAAPI encoder"),
|
(
|
||||||
}
|
render_node().to_string_lossy().into_owned(),
|
||||||
|
codec.label(),
|
||||||
|
ten_bit,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
||||||
@@ -116,11 +129,16 @@ unsafe fn open_vaapi_encoder(
|
|||||||
frames_ref: *mut ffi::AVBufferRef,
|
frames_ref: *mut ffi::AVBufferRef,
|
||||||
ten_bit: bool,
|
ten_bit: bool,
|
||||||
) -> Result<encoder::video::Encoder> {
|
) -> Result<encoder::video::Encoder> {
|
||||||
let idx = lp_idx(codec);
|
let key = lp_key(codec, ten_bit);
|
||||||
|
let cached = LP_MODE
|
||||||
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
.lock()
|
||||||
|
.map(|m| m.get(&key).copied().unwrap_or(0))
|
||||||
|
.unwrap_or(0);
|
||||||
let modes: &[bool] = match low_power_override() {
|
let modes: &[bool] = match low_power_override() {
|
||||||
Some(true) => &[true],
|
Some(true) => &[true],
|
||||||
Some(false) => &[false],
|
Some(false) => &[false],
|
||||||
None => match LP_MODE[idx].load(Ordering::Relaxed) {
|
None => match cached {
|
||||||
1 => &[false],
|
1 => &[false],
|
||||||
2 => &[true],
|
2 => &[true],
|
||||||
_ => &[false, true],
|
_ => &[false, true],
|
||||||
@@ -140,7 +158,9 @@ unsafe fn open_vaapi_encoder(
|
|||||||
lp,
|
lp,
|
||||||
) {
|
) {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
LP_MODE[idx].store(if lp { 2 } else { 1 }, Ordering::Relaxed);
|
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||||
|
m.insert(key.clone(), if lp { 2 } else { 1 });
|
||||||
|
}
|
||||||
if lp {
|
if lp {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
encoder = codec.vaapi_name(),
|
encoder = codec.vaapi_name(),
|
||||||
@@ -253,20 +273,20 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
|||||||
if ffmpeg::init().is_err() {
|
if ffmpeg::init().is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
|
|
||||||
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
|
|
||||||
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
|
|
||||||
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
|
|
||||||
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
|
|
||||||
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
|
|
||||||
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
|
|
||||||
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
|
||||||
unsafe {
|
|
||||||
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
||||||
// ffmpeg's "No VA display found" error for the probe, then restore the level.
|
// ffmpeg's "No VA display found" error for the probe. Held until the function returns, so the
|
||||||
let prev = ffi::av_log_get_level();
|
// level is restored after the open either way. Shares one lock with the NVENC probes, which
|
||||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
// race this one on the same libav global (see [`crate::linux::QuietLibavLog`]).
|
||||||
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
let _quiet = crate::linux::QuietLibavLog::new();
|
||||||
|
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `VaapiHw::new` (an
|
||||||
|
// `unsafe fn`) builds a VAAPI device + NV12 frames pool from the literal NV12/640x480/pool=2
|
||||||
|
// args and hands back a RAII handle that unrefs both `AVBufferRef`s on drop.
|
||||||
|
// `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — the two
|
||||||
|
// non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them into the encoder; `hw`
|
||||||
|
// is a live local for the whole match arm, so the borrows outlive the synchronous call, and
|
||||||
|
// both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
||||||
|
unsafe {
|
||||||
|
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
||||||
Ok(hw) => open_vaapi_encoder(
|
Ok(hw) => open_vaapi_encoder(
|
||||||
codec,
|
codec,
|
||||||
640,
|
640,
|
||||||
@@ -279,9 +299,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
|||||||
)
|
)
|
||||||
.is_ok(),
|
.is_ok(),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
};
|
}
|
||||||
ffi::av_log_set_level(prev);
|
|
||||||
ok
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,18 +315,17 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
|||||||
if ffmpeg::init().is_err() {
|
if ffmpeg::init().is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_{get,set}_level`
|
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
|
||||||
// only read/write libav's global integer log level (no pointer args). `VaapiHw::new` (an
|
// error for the probe. Held until the function returns, so the level is restored after the open
|
||||||
|
// either way, and shared with the other probes (see [`crate::linux::QuietLibavLog`]).
|
||||||
|
let _quiet = crate::linux::QuietLibavLog::new();
|
||||||
|
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `VaapiHw::new` (an
|
||||||
// `unsafe fn`) builds a VAAPI device + P010 frames pool from the literal args and hands back a
|
// `unsafe fn`) builds a VAAPI device + P010 frames pool from the literal args and hands back a
|
||||||
// RAII handle; `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` —
|
// RAII handle; `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` —
|
||||||
// the two non-null refs `VaapiHw::new` just created, live locals for the whole match arm — and
|
// the two non-null refs `VaapiHw::new` just created, live locals for the whole match arm — and
|
||||||
// `av_buffer_ref`s them into the probe encoder. Both `hw` and the encoder drop (RAII) at arm end.
|
// `av_buffer_ref`s them into the probe encoder. Both `hw` and the encoder drop (RAII) at arm end.
|
||||||
unsafe {
|
unsafe {
|
||||||
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
|
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
|
||||||
// error for the probe, then restore the level.
|
|
||||||
let prev = ffi::av_log_get_level();
|
|
||||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
|
||||||
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
|
|
||||||
Ok(hw) => open_vaapi_encoder(
|
Ok(hw) => open_vaapi_encoder(
|
||||||
codec,
|
codec,
|
||||||
640,
|
640,
|
||||||
@@ -321,9 +338,7 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
|||||||
)
|
)
|
||||||
.is_ok(),
|
.is_ok(),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
};
|
}
|
||||||
ffi::av_log_set_level(prev);
|
|
||||||
ok
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,7 +949,8 @@ impl DmabufInner {
|
|||||||
// `Box` puts it on the heap with a unique owner.
|
// `Box` puts it on the heap with a unique owner.
|
||||||
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
|
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
|
||||||
// whole synchronous `submit`; we describe one object/layer/plane from its
|
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||||
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
|
// fourcc/modifier/offset/stride and its `lseek`-queried size. `libc::lseek` on that live
|
||||||
|
// fd only reads the description's size and returns it (or -1); it touches no Rust memory.
|
||||||
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||||
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||||
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||||
@@ -950,7 +966,17 @@ impl DmabufInner {
|
|||||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||||
desc.nb_objects = 1;
|
desc.nb_objects = 1;
|
||||||
desc.objects[0].fd = dmabuf.fd.as_raw_fd();
|
desc.objects[0].fd = dmabuf.fd.as_raw_fd();
|
||||||
desc.objects[0].size = 0;
|
// The object's REAL size, not 0. libav does not query it for us — both of its import
|
||||||
|
// paths hand this value straight to libva, as `prime_desc.objects[i].size` on the
|
||||||
|
// PRIME_2 path and `buffer_desc.data_size` on the legacy fallback — so a 0 told every
|
||||||
|
// VA driver the backing object was empty and left it to work the real size out itself.
|
||||||
|
// The drivers this has run on (radeonsi, modern Intel iHD) do; a Gen9 Intel host
|
||||||
|
// answered `vaCreateSurfaces` with VA_STATUS_ERROR_ALLOCATION_FAILED on every single
|
||||||
|
// frame. `lseek(SEEK_END)` is the standard dma-buf size query — the same one the
|
||||||
|
// Vulkan bridge already uses on these fds (`pf_zerocopy::imp::vulkan`). If a kernel
|
||||||
|
// refuses it, keep the old 0 rather than drop a frame we could still have encoded.
|
||||||
|
let obj_size = libc::lseek(dmabuf.fd.as_raw_fd(), 0, libc::SEEK_END);
|
||||||
|
desc.objects[0].size = if obj_size > 0 { obj_size as _ } else { 0 };
|
||||||
desc.objects[0].format_modifier = dmabuf.modifier;
|
desc.objects[0].format_modifier = dmabuf.modifier;
|
||||||
desc.nb_layers = 1;
|
desc.nb_layers = 1;
|
||||||
desc.layers[0].format = self.fourcc;
|
desc.layers[0].format = self.fourcc;
|
||||||
@@ -999,8 +1025,18 @@ impl DmabufInner {
|
|||||||
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
|
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
|
||||||
);
|
);
|
||||||
ffi::av_frame_free(&mut drm);
|
ffi::av_frame_free(&mut drm);
|
||||||
|
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
|
||||||
|
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
|
||||||
|
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
|
||||||
|
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
|
||||||
|
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
|
||||||
|
// deliberately NOT counted: that one is the encoder stalling, which the in-place
|
||||||
|
// rebuild above us exists to recover, and disabling zero-copy over it would be a
|
||||||
|
// permanent penalty for a transient fault.
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
bail!("av_buffersrc_add_frame failed ({r})");
|
let e = format!("av_buffersrc_add_frame failed ({r})");
|
||||||
|
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||||
|
bail!("{e}");
|
||||||
}
|
}
|
||||||
t_push = t0.elapsed();
|
t_push = t0.elapsed();
|
||||||
let mut nv12 = ffi::av_frame_alloc();
|
let mut nv12 = ffi::av_frame_alloc();
|
||||||
@@ -1010,8 +1046,11 @@ impl DmabufInner {
|
|||||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
ffi::av_frame_free(&mut nv12);
|
ffi::av_frame_free(&mut nv12);
|
||||||
bail!("av_buffersink_get_frame failed ({r})");
|
let e = format!("av_buffersink_get_frame failed ({r})");
|
||||||
|
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||||
|
bail!("{e}");
|
||||||
}
|
}
|
||||||
|
pf_zerocopy::note_raw_dmabuf_import_ok();
|
||||||
t_pull = t0.elapsed() - t_push;
|
t_pull = t0.elapsed() - t_push;
|
||||||
(*nv12).pts = pts;
|
(*nv12).pts = pts;
|
||||||
(*nv12).pict_type = if idr {
|
(*nv12).pict_type = if idr {
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ pub const PRIMARY_REF_NONE: u8 = 7;
|
|||||||
/// `VK_VIDEO_ENCODE_AV1_SUPERBLOCK_SIZE_128_BIT_KHR` (bit 1 of the superblock-size flags).
|
/// `VK_VIDEO_ENCODE_AV1_SUPERBLOCK_SIZE_128_BIT_KHR` (bit 1 of the superblock-size flags).
|
||||||
pub const SUPERBLOCK_SIZE_128: u32 = 0x2;
|
pub const SUPERBLOCK_SIZE_128: u32 = 0x2;
|
||||||
|
|
||||||
|
// `VkVideoEncodeAV1CapabilityFlagBitsKHR` — the two that decide whether the encode source may be a
|
||||||
|
// different size from the declared frame. Both absent on RADV PHOENIX.
|
||||||
|
/// Without this, the source's `codedExtent` MUST equal the sequence header's
|
||||||
|
/// `max_frame_{width,height}_minus_1 + 1` (`VUID-vkCmdEncodeVideoKHR-flags-10324`).
|
||||||
|
pub const CAPABILITY_FRAME_SIZE_OVERRIDE: u32 = 0x0000_0008;
|
||||||
|
/// Without this, EVERY reference slot's `codedExtent` MUST equal the source's
|
||||||
|
/// (`VUID-vkCmdEncodeVideoKHR-flags-10325`).
|
||||||
|
pub const CAPABILITY_MOTION_VECTOR_SCALING: u32 = 0x0000_0010;
|
||||||
|
|
||||||
// `VkVideoEncodeAV1PredictionModeKHR`
|
// `VkVideoEncodeAV1PredictionModeKHR`
|
||||||
pub const PREDICTION_MODE_INTRA_ONLY: i32 = 0;
|
pub const PREDICTION_MODE_INTRA_ONLY: i32 = 0;
|
||||||
pub const PREDICTION_MODE_SINGLE_REFERENCE: i32 = 1;
|
pub const PREDICTION_MODE_SINGLE_REFERENCE: i32 = 1;
|
||||||
@@ -498,3 +507,366 @@ pub struct StdVideoEncodeAV1OperatingPointInfo {
|
|||||||
pub fn stype(raw: i32) -> vk::StructureType {
|
pub fn stype(raw: i32) -> vk::StructureType {
|
||||||
vk::StructureType::from_raw(raw)
|
vk::StructureType::from_raw(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- ABI layout guard ----------
|
||||||
|
//
|
||||||
|
// These structs are hand-copied and handed to the driver through raw `p_next` chains, so nothing in
|
||||||
|
// the type system relates them to the C definitions any more: an edit that inserts, drops, widens or
|
||||||
|
// re-pads a field is not a compile error, it is the driver reading our bytes at the wrong offsets.
|
||||||
|
// The assertions below are the missing compile error. They are `const` rather than `#[cfg(test)]`
|
||||||
|
// (the shape `amf.rs` uses) so they hold in every build, including the shipped one, and on any
|
||||||
|
// target this module compiles for.
|
||||||
|
//
|
||||||
|
// What they catch: a changed field width, an inserted or removed field, a changed array length, a
|
||||||
|
// padding assumption that only holds on one target. What they CANNOT catch: swapping two fields of
|
||||||
|
// the same type — offsets are unchanged. That case is only caught by reading the registry, so the
|
||||||
|
// field order here was diffed against `vulkan_core.h` and `vk_video/vulkan_video_codec_av1std_encode.h`
|
||||||
|
// (Vulkan-Headers `main`, 2026-07-25) when these assertions were written, along with every `ST_*`,
|
||||||
|
// flag-bit and enum value above; the bitfield member order is pinned by the test module below.
|
||||||
|
//
|
||||||
|
// Deliberately duplicated in `vk_valve_rgb.rs` rather than shared: both modules exist to be deleted
|
||||||
|
// wholesale once `ash` ships these bindings, and a shared helper would make deleting one break the
|
||||||
|
// other.
|
||||||
|
macro_rules! assert_abi_layout {
|
||||||
|
($t:ty { size: $size:expr, align: $align:expr $(, $field:ident @ $off:expr)* $(,)? }) => {
|
||||||
|
const _: () = {
|
||||||
|
assert!(
|
||||||
|
::core::mem::size_of::<$t>() == $size,
|
||||||
|
concat!(stringify!($t), ": size does not match the C ABI")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
::core::mem::align_of::<$t>() == $align,
|
||||||
|
concat!(stringify!($t), ": alignment does not match the C ABI")
|
||||||
|
);
|
||||||
|
$(assert!(
|
||||||
|
::core::mem::offset_of!($t, $field) == $off,
|
||||||
|
concat!(stringify!($t), ".", stringify!($field), ": offset does not match the C ABI")
|
||||||
|
);)*
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Std encode structs. The three `*Flags` types are a single C `uint32_t` of bitfields, so only their
|
||||||
|
// size and alignment are layout-checkable here; their member order is covered by `abi_tests`.
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1PictureInfoFlags { size: 4, align: 4 });
|
||||||
|
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1PictureInfo {
|
||||||
|
size: 152, align: 8,
|
||||||
|
flags @ 0,
|
||||||
|
frame_type @ 4,
|
||||||
|
frame_presentation_time @ 8,
|
||||||
|
current_frame_id @ 12,
|
||||||
|
order_hint @ 16,
|
||||||
|
primary_ref_frame @ 17,
|
||||||
|
refresh_frame_flags @ 18,
|
||||||
|
coded_denom @ 19,
|
||||||
|
render_width_minus_1 @ 20,
|
||||||
|
render_height_minus_1 @ 22,
|
||||||
|
interpolation_filter @ 24,
|
||||||
|
TxMode @ 28,
|
||||||
|
delta_q_res @ 32,
|
||||||
|
delta_lf_res @ 33,
|
||||||
|
ref_order_hint @ 34,
|
||||||
|
ref_frame_idx @ 42,
|
||||||
|
reserved1 @ 49,
|
||||||
|
delta_frame_id_minus_1 @ 52,
|
||||||
|
pTileInfo @ 80,
|
||||||
|
pQuantization @ 88,
|
||||||
|
pSegmentation @ 96,
|
||||||
|
pLoopFilter @ 104,
|
||||||
|
pCDEF @ 112,
|
||||||
|
pLoopRestoration @ 120,
|
||||||
|
pGlobalMotion @ 128,
|
||||||
|
pExtensionHeader @ 136,
|
||||||
|
pBufferRemovalTimes @ 144,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1ReferenceInfoFlags { size: 4, align: 4 });
|
||||||
|
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1ReferenceInfo {
|
||||||
|
size: 24, align: 8,
|
||||||
|
flags @ 0,
|
||||||
|
RefFrameId @ 4,
|
||||||
|
frame_type @ 8,
|
||||||
|
OrderHint @ 12,
|
||||||
|
reserved1 @ 13,
|
||||||
|
pExtensionHeader @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1ExtensionHeader {
|
||||||
|
size: 2, align: 1,
|
||||||
|
temporal_id @ 0,
|
||||||
|
spatial_id @ 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1OperatingPointInfoFlags { size: 4, align: 4 });
|
||||||
|
|
||||||
|
assert_abi_layout!(StdVideoEncodeAV1OperatingPointInfo {
|
||||||
|
size: 20, align: 4,
|
||||||
|
flags @ 0,
|
||||||
|
operating_point_idc @ 4,
|
||||||
|
seq_level_idx @ 6,
|
||||||
|
seq_tier @ 7,
|
||||||
|
decoder_buffer_delay @ 8,
|
||||||
|
encoder_buffer_delay @ 12,
|
||||||
|
initial_display_delay_minus_1 @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
// KHR extension structs.
|
||||||
|
assert_abi_layout!(VideoEncodeAV1ProfileInfoKHR {
|
||||||
|
size: 24, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
std_profile @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(PhysicalDeviceVideoEncodeAV1FeaturesKHR {
|
||||||
|
size: 24, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
video_encode_av1 @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1CapabilitiesKHR {
|
||||||
|
size: 128, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
flags @ 16,
|
||||||
|
max_level @ 20,
|
||||||
|
coded_picture_alignment @ 24,
|
||||||
|
max_tiles @ 32,
|
||||||
|
min_tile_size @ 40,
|
||||||
|
max_tile_size @ 48,
|
||||||
|
superblock_sizes @ 56,
|
||||||
|
max_single_reference_count @ 60,
|
||||||
|
single_reference_name_mask @ 64,
|
||||||
|
max_unidirectional_compound_reference_count @ 68,
|
||||||
|
max_unidirectional_compound_group1_reference_count @ 72,
|
||||||
|
unidirectional_compound_reference_name_mask @ 76,
|
||||||
|
max_bidirectional_compound_reference_count @ 80,
|
||||||
|
max_bidirectional_compound_group1_reference_count @ 84,
|
||||||
|
max_bidirectional_compound_group2_reference_count @ 88,
|
||||||
|
bidirectional_compound_reference_name_mask @ 92,
|
||||||
|
max_temporal_layer_count @ 96,
|
||||||
|
max_spatial_layer_count @ 100,
|
||||||
|
max_operating_points @ 104,
|
||||||
|
min_q_index @ 108,
|
||||||
|
max_q_index @ 112,
|
||||||
|
prefers_gop_remaining_frames @ 116,
|
||||||
|
requires_gop_remaining_frames @ 120,
|
||||||
|
std_syntax_flags @ 124,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1SessionParametersCreateInfoKHR {
|
||||||
|
size: 48, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
p_std_sequence_header @ 16,
|
||||||
|
p_std_decoder_model_info @ 24,
|
||||||
|
std_operating_point_count @ 32,
|
||||||
|
p_std_operating_points @ 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1PictureInfoKHR {
|
||||||
|
size: 80, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
prediction_mode @ 16,
|
||||||
|
rate_control_group @ 20,
|
||||||
|
constant_q_index @ 24,
|
||||||
|
p_std_picture_info @ 32,
|
||||||
|
reference_name_slot_indices @ 40,
|
||||||
|
primary_reference_cdf_only @ 68,
|
||||||
|
generate_obu_extension_header @ 72,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1DpbSlotInfoKHR {
|
||||||
|
size: 24, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
p_std_reference_info @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1RateControlInfoKHR {
|
||||||
|
size: 40, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
flags @ 16,
|
||||||
|
gop_frame_count @ 20,
|
||||||
|
key_frame_period @ 24,
|
||||||
|
consecutive_bipredictive_frame_count @ 28,
|
||||||
|
temporal_layer_count @ 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1QIndexKHR {
|
||||||
|
size: 12, align: 4,
|
||||||
|
intra_q_index @ 0,
|
||||||
|
predictive_q_index @ 4,
|
||||||
|
bipredictive_q_index @ 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1FrameSizeKHR {
|
||||||
|
size: 12, align: 4,
|
||||||
|
intra_frame_size @ 0,
|
||||||
|
predictive_frame_size @ 4,
|
||||||
|
bipredictive_frame_size @ 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1RateControlLayerInfoKHR {
|
||||||
|
size: 64, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
use_min_q_index @ 16,
|
||||||
|
min_q_index @ 20,
|
||||||
|
use_max_q_index @ 32,
|
||||||
|
max_q_index @ 36,
|
||||||
|
use_max_frame_size @ 48,
|
||||||
|
max_frame_size @ 52,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1GopRemainingFrameInfoKHR {
|
||||||
|
size: 32, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
use_gop_remaining_frames @ 16,
|
||||||
|
gop_remaining_intra @ 20,
|
||||||
|
gop_remaining_predictive @ 24,
|
||||||
|
gop_remaining_bipredictive @ 28,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeAV1SessionCreateInfoKHR {
|
||||||
|
size: 24, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
use_max_level @ 16,
|
||||||
|
max_level @ 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod abi_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Assert that `set` lights exactly one bit of the flags word, at `bit`.
|
||||||
|
fn sets_only_bit(
|
||||||
|
storage: __BindgenBitfieldUnit<[u8; 4]>,
|
||||||
|
name: &str,
|
||||||
|
bit: usize,
|
||||||
|
expected_width: usize,
|
||||||
|
) {
|
||||||
|
for probe in 0..32 {
|
||||||
|
let want = probe >= bit && probe < bit + expected_width;
|
||||||
|
assert_eq!(
|
||||||
|
storage.get_bit(probe),
|
||||||
|
want,
|
||||||
|
"`{name}` should occupy bit(s) {bit}..{}, but bit {probe} disagrees",
|
||||||
|
bit + expected_width
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A C bitfield allocates its members from bit 0 upward in declaration order, so the setter for
|
||||||
|
/// the Nth member of `StdVideoEncodeAV1PictureInfoFlags` must write bit N. The array below is
|
||||||
|
/// the member list of `vulkan_video_codec_av1std_encode.h` **in declaration order** — so this
|
||||||
|
/// pins the hand-copied bit indices to the header rather than merely to themselves. A silent
|
||||||
|
/// renumbering here would make the driver read, say, `use_superres` where we meant
|
||||||
|
/// `render_and_frame_size_different`.
|
||||||
|
#[test]
|
||||||
|
fn picture_info_flag_setters_follow_the_c_declaration_order() {
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
let members: [(&str, fn(&mut StdVideoEncodeAV1PictureInfoFlags)); 29] = [
|
||||||
|
("error_resilient_mode", |f| f.set_error_resilient_mode(1)),
|
||||||
|
("disable_cdf_update", |f| f.set_disable_cdf_update(1)),
|
||||||
|
("use_superres", |f| f.set_use_superres(1)),
|
||||||
|
("render_and_frame_size_different", |f| {
|
||||||
|
f.set_render_and_frame_size_different(1)
|
||||||
|
}),
|
||||||
|
("allow_screen_content_tools", |f| {
|
||||||
|
f.set_allow_screen_content_tools(1)
|
||||||
|
}),
|
||||||
|
("is_filter_switchable", |f| f.set_is_filter_switchable(1)),
|
||||||
|
("force_integer_mv", |f| f.set_force_integer_mv(1)),
|
||||||
|
("frame_size_override_flag", |f| {
|
||||||
|
f.set_frame_size_override_flag(1)
|
||||||
|
}),
|
||||||
|
("buffer_removal_time_present_flag", |f| {
|
||||||
|
f.set_buffer_removal_time_present_flag(1)
|
||||||
|
}),
|
||||||
|
("allow_intrabc", |f| f.set_allow_intrabc(1)),
|
||||||
|
("frame_refs_short_signaling", |f| {
|
||||||
|
f.set_frame_refs_short_signaling(1)
|
||||||
|
}),
|
||||||
|
("allow_high_precision_mv", |f| {
|
||||||
|
f.set_allow_high_precision_mv(1)
|
||||||
|
}),
|
||||||
|
("is_motion_mode_switchable", |f| {
|
||||||
|
f.set_is_motion_mode_switchable(1)
|
||||||
|
}),
|
||||||
|
("use_ref_frame_mvs", |f| f.set_use_ref_frame_mvs(1)),
|
||||||
|
("disable_frame_end_update_cdf", |f| {
|
||||||
|
f.set_disable_frame_end_update_cdf(1)
|
||||||
|
}),
|
||||||
|
("allow_warped_motion", |f| f.set_allow_warped_motion(1)),
|
||||||
|
("reduced_tx_set", |f| f.set_reduced_tx_set(1)),
|
||||||
|
("skip_mode_present", |f| f.set_skip_mode_present(1)),
|
||||||
|
("delta_q_present", |f| f.set_delta_q_present(1)),
|
||||||
|
("delta_lf_present", |f| f.set_delta_lf_present(1)),
|
||||||
|
("delta_lf_multi", |f| f.set_delta_lf_multi(1)),
|
||||||
|
("segmentation_enabled", |f| f.set_segmentation_enabled(1)),
|
||||||
|
("segmentation_update_map", |f| {
|
||||||
|
f.set_segmentation_update_map(1)
|
||||||
|
}),
|
||||||
|
("segmentation_temporal_update", |f| {
|
||||||
|
f.set_segmentation_temporal_update(1)
|
||||||
|
}),
|
||||||
|
("segmentation_update_data", |f| {
|
||||||
|
f.set_segmentation_update_data(1)
|
||||||
|
}),
|
||||||
|
("UsesLr", |f| f.set_UsesLr(1)),
|
||||||
|
("usesChromaLr", |f| f.set_usesChromaLr(1)),
|
||||||
|
("show_frame", |f| f.set_show_frame(1)),
|
||||||
|
("showable_frame", |f| f.set_showable_frame(1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (bit, (name, set)) in members.into_iter().enumerate() {
|
||||||
|
let mut flags = StdVideoEncodeAV1PictureInfoFlags {
|
||||||
|
_bitfield_align_1: [],
|
||||||
|
_bitfield_1: __BindgenBitfieldUnit::new([0u8; 4]),
|
||||||
|
};
|
||||||
|
set(&mut flags);
|
||||||
|
sets_only_bit(flags._bitfield_1, name, bit, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `reserved : 3` closes out the word — bits 29..32. Checking it is what proves the 29 members
|
||||||
|
/// above are the *whole* list: a dropped member would shift `reserved` down and fail here.
|
||||||
|
#[test]
|
||||||
|
fn picture_info_reserved_occupies_the_top_three_bits() {
|
||||||
|
let mut flags = StdVideoEncodeAV1PictureInfoFlags {
|
||||||
|
_bitfield_align_1: [],
|
||||||
|
_bitfield_1: __BindgenBitfieldUnit::new([0u8; 4]),
|
||||||
|
};
|
||||||
|
flags.set_reserved(0b111);
|
||||||
|
sets_only_bit(flags._bitfield_1, "reserved", 29, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same invariant for the two-member reference-info flags word.
|
||||||
|
#[test]
|
||||||
|
fn reference_info_flag_setters_follow_the_c_declaration_order() {
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
let members: [(&str, fn(&mut StdVideoEncodeAV1ReferenceInfoFlags)); 2] = [
|
||||||
|
("disable_frame_end_update_cdf", |f| {
|
||||||
|
f.set_disable_frame_end_update_cdf(1)
|
||||||
|
}),
|
||||||
|
("segmentation_enabled", |f| f.set_segmentation_enabled(1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (bit, (name, set)) in members.into_iter().enumerate() {
|
||||||
|
let mut flags = StdVideoEncodeAV1ReferenceInfoFlags {
|
||||||
|
_bitfield_align_1: [],
|
||||||
|
_bitfield_1: __BindgenBitfieldUnit::new([0u8; 4]),
|
||||||
|
};
|
||||||
|
set(&mut flags);
|
||||||
|
sets_only_bit(flags._bitfield_1, name, bit, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,836 @@
|
|||||||
|
//! Session/frame **construction** for the Vulkan Video encoder — the unsafe builders
|
||||||
|
//! (`make_frame*`, `make_video_image`, `probe_rgb_direct`) and the parameter-set bitstream
|
||||||
|
//! writers (`build_parameters_h265`/`_av1`, the AV1 sequence-header OBU). Split from
|
||||||
|
//! `vulkan_video.rs` (WP7.5) the way `amf_sys.rs` was split from `amf.rs`: a `#[path]` child
|
||||||
|
//! module, so this file sees the parent's private items (`Frame` and friends) with zero
|
||||||
|
//! visibility churn, and ~800 lines of construction `unsafe` get their own review surface.
|
||||||
|
//! Steady-state encode logic stays in the parent.
|
||||||
|
|
||||||
|
// The parent's whole item namespace (Frame, the consts, sibling helpers) — the point of the
|
||||||
|
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
|
||||||
|
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
|
||||||
|
use super::*;
|
||||||
|
use crate::vk_util::{find_mem, make_plain_image, make_view};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use ash::vk;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
pub(super) fn align_up(v: u64, a: u64) -> u64 {
|
||||||
|
v.div_ceil(a) * a
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe for the RGB-direct encode source (design/vulkan-rgb-direct-encode.md): can this device
|
||||||
|
/// take the captured RGB dmabuf directly, with the VCN EFC front-end doing the 709-narrow CSC,
|
||||||
|
/// via `VK_VALVE_video_encode_rgb_conversion` (RADV since Mesa 26.0, gated on EFC hardware)?
|
||||||
|
/// `Ok((x_offset, y_offset))` carries the chroma-siting bits a session must be created with
|
||||||
|
/// (the preferred available bit per axis); `Err` is the first missing requirement, logged as
|
||||||
|
/// the open-time verdict.
|
||||||
|
pub(super) unsafe fn probe_rgb_direct(
|
||||||
|
instance: &ash::Instance,
|
||||||
|
vq_inst: &ash::khr::video_queue::Instance,
|
||||||
|
pd: vk::PhysicalDevice,
|
||||||
|
codec_op: vk::VideoCodecOperationFlagsKHR,
|
||||||
|
av1: bool,
|
||||||
|
) -> Result<(u32, u32), &'static str> {
|
||||||
|
use crate::vk_av1_encode as av1b;
|
||||||
|
use crate::vk_valve_rgb as vrgb;
|
||||||
|
// 1. The device extension must exist (Mesa >= 26.0 AND the VCN has an EFC block).
|
||||||
|
let Ok(exts) = instance.enumerate_device_extension_properties(pd) else {
|
||||||
|
return Err("probe-failed(ext-enum)");
|
||||||
|
};
|
||||||
|
if !exts
|
||||||
|
.iter()
|
||||||
|
.any(|e| std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == vrgb::EXTENSION_NAME)
|
||||||
|
{
|
||||||
|
return Err("no-ext(mesa<26.0-or-no-efc)");
|
||||||
|
}
|
||||||
|
// 2. Feature bit.
|
||||||
|
let mut feat = vrgb::PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
|
||||||
|
s_type: vrgb::stype(vrgb::ST_PHYSICAL_DEVICE_FEATURES),
|
||||||
|
p_next: std::ptr::null_mut(),
|
||||||
|
video_encode_rgb_conversion: vk::FALSE,
|
||||||
|
};
|
||||||
|
let mut f2 = vk::PhysicalDeviceFeatures2 {
|
||||||
|
p_next: &mut feat as *mut _ as *mut c_void,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
instance.get_physical_device_features2(pd, &mut f2);
|
||||||
|
if feat.video_encode_rgb_conversion == vk::FALSE {
|
||||||
|
return Err("no-feature");
|
||||||
|
}
|
||||||
|
// 3. Capabilities under the rgb-chained profile — the conversion must cover the compute
|
||||||
|
// CSC's colour math (rgb2yuv.comp: BT.709, narrow range; chroma siting is looser, see
|
||||||
|
// below). The profile chain is the same one every rgb-direct consumer presents.
|
||||||
|
let mut ps = RgbProfileStack::new(codec_op);
|
||||||
|
let profile = *ps.wire(av1);
|
||||||
|
let mut rgb_caps = vrgb::VideoEncodeRgbConversionCapabilitiesVALVE {
|
||||||
|
s_type: vrgb::stype(vrgb::ST_CAPABILITIES),
|
||||||
|
p_next: std::ptr::null_mut(),
|
||||||
|
rgb_models: 0,
|
||||||
|
rgb_ranges: 0,
|
||||||
|
x_chroma_offsets: 0,
|
||||||
|
y_chroma_offsets: 0,
|
||||||
|
};
|
||||||
|
let mut h265_caps = vk::VideoEncodeH265CapabilitiesKHR::default();
|
||||||
|
let mut av1_caps: av1b::VideoEncodeAV1CapabilitiesKHR = std::mem::zeroed();
|
||||||
|
av1_caps.s_type = av1b::stype(av1b::ST_CAPABILITIES);
|
||||||
|
let mut enc_caps = vk::VideoEncodeCapabilitiesKHR::default();
|
||||||
|
let mut caps = vk::VideoCapabilitiesKHR::default();
|
||||||
|
if av1 {
|
||||||
|
av1_caps.p_next = &mut rgb_caps as *mut _ as *mut c_void;
|
||||||
|
enc_caps.p_next = &mut av1_caps as *mut _ as *mut c_void;
|
||||||
|
} else {
|
||||||
|
h265_caps.p_next = &mut rgb_caps as *mut _ as *mut c_void;
|
||||||
|
enc_caps.p_next = &mut h265_caps as *mut _ as *mut c_void;
|
||||||
|
}
|
||||||
|
caps.p_next = &mut enc_caps as *mut _ as *mut c_void;
|
||||||
|
let r = (vq_inst.fp().get_physical_device_video_capabilities_khr)(pd, &profile, &mut caps);
|
||||||
|
if r != vk::Result::SUCCESS {
|
||||||
|
return Err("no-rgb-profile(caps)");
|
||||||
|
}
|
||||||
|
// Colour model + range must match the shader exactly (709 narrow). Chroma siting is looser
|
||||||
|
// BY ON-GLASS FINDING (RADV 26.0.4 / 780M): the VCN EFC advertises x=COSITED_EVEN only —
|
||||||
|
// the canonical H.26x left-cosited siting — while our 2x2-average shader is midpoint. The
|
||||||
|
// difference is a half-pel chroma-x phase, imperceptible (and EFC's is arguably the more
|
||||||
|
// correct one since nothing in our bitstream signals siting). Accept either bit per axis
|
||||||
|
// and choose the closest to the shader's math: midpoint if offered, else cosited-even.
|
||||||
|
let pick = |offered: u32| -> Option<u32> {
|
||||||
|
if offered & vrgb::CHROMA_OFFSET_MIDPOINT != 0 {
|
||||||
|
Some(vrgb::CHROMA_OFFSET_MIDPOINT)
|
||||||
|
} else if offered & vrgb::CHROMA_OFFSET_COSITED_EVEN != 0 {
|
||||||
|
Some(vrgb::CHROMA_OFFSET_COSITED_EVEN)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rgb_caps.rgb_models & vrgb::MODEL_YCBCR_709 == 0
|
||||||
|
|| rgb_caps.rgb_ranges & vrgb::RANGE_NARROW == 0
|
||||||
|
{
|
||||||
|
return Err("no-709-narrow");
|
||||||
|
}
|
||||||
|
let (Some(x_offset), Some(y_offset)) = (
|
||||||
|
pick(rgb_caps.x_chroma_offsets),
|
||||||
|
pick(rgb_caps.y_chroma_offsets),
|
||||||
|
) else {
|
||||||
|
return Err("no-chroma-siting");
|
||||||
|
};
|
||||||
|
// 4. The encode-src format set under this profile must offer BGRA with DRM-modifier tiling —
|
||||||
|
// the capture hands LINEAR BGRx dmabufs (fourcc XR24), which import as B8G8R8A8_UNORM.
|
||||||
|
let profile_arr = [profile];
|
||||||
|
let plist = vk::VideoProfileListInfoKHR::default().profiles(&profile_arr);
|
||||||
|
let mut fmt_info = vk::PhysicalDeviceVideoFormatInfoKHR::default()
|
||||||
|
.image_usage(vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR);
|
||||||
|
fmt_info.p_next = &plist as *const _ as *const c_void;
|
||||||
|
let get_fmt = vq_inst.fp().get_physical_device_video_format_properties_khr;
|
||||||
|
let mut count = 0u32;
|
||||||
|
let r = get_fmt(pd, &fmt_info, &mut count, std::ptr::null_mut());
|
||||||
|
if r != vk::Result::SUCCESS || count == 0 {
|
||||||
|
return Err("no-rgb-format");
|
||||||
|
}
|
||||||
|
let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize];
|
||||||
|
let r = get_fmt(pd, &fmt_info, &mut count, props.as_mut_ptr());
|
||||||
|
if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE {
|
||||||
|
return Err("no-rgb-format");
|
||||||
|
}
|
||||||
|
if !props[..count as usize].iter().any(|p| {
|
||||||
|
p.format == vk::Format::B8G8R8A8_UNORM
|
||||||
|
&& p.image_tiling == vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT
|
||||||
|
}) {
|
||||||
|
return Err("no-bgra-modifier-tiling");
|
||||||
|
}
|
||||||
|
Ok((x_offset, y_offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) unsafe fn make_video_image(
|
||||||
|
device: &ash::Device,
|
||||||
|
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||||
|
fmt: vk::Format,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
layers: u32,
|
||||||
|
usage: vk::ImageUsageFlags,
|
||||||
|
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||||
|
concurrent: &[u32],
|
||||||
|
) -> Result<(vk::Image, vk::DeviceMemory)> {
|
||||||
|
let mut ci = vk::ImageCreateInfo::default()
|
||||||
|
.image_type(vk::ImageType::TYPE_2D)
|
||||||
|
.format(fmt)
|
||||||
|
.extent(vk::Extent3D {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
depth: 1,
|
||||||
|
})
|
||||||
|
.mip_levels(1)
|
||||||
|
.array_layers(layers)
|
||||||
|
.samples(vk::SampleCountFlags::TYPE_1)
|
||||||
|
.tiling(vk::ImageTiling::OPTIMAL)
|
||||||
|
.usage(usage)
|
||||||
|
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||||
|
.push_next(profile_list);
|
||||||
|
if concurrent.len() >= 2 {
|
||||||
|
ci = ci
|
||||||
|
.sharing_mode(vk::SharingMode::CONCURRENT)
|
||||||
|
.queue_family_indices(concurrent);
|
||||||
|
} else {
|
||||||
|
ci = ci.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||||||
|
}
|
||||||
|
let img = device.create_image(&ci, None)?;
|
||||||
|
let req = device.get_image_memory_requirements(img);
|
||||||
|
// Unwind on failure: callers (the open path) only ever see the completed pair.
|
||||||
|
let mem = match device.allocate_memory(
|
||||||
|
&vk::MemoryAllocateInfo::default()
|
||||||
|
.allocation_size(req.size)
|
||||||
|
.memory_type_index(find_mem(
|
||||||
|
mp,
|
||||||
|
req.memory_type_bits,
|
||||||
|
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
device.destroy_image(img, None);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
||||||
|
device.destroy_image(img, None);
|
||||||
|
device.free_memory(mem, None);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
Ok((img, mem))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one in-flight frame's private resources: NV12 encode-src, Y/UV CSC scratch, its CSC
|
||||||
|
/// descriptor set (Y/UV bound now, RGB per use), the bitstream buffer + feedback query, and the
|
||||||
|
/// per-frame command buffers + sync. `profile_list`/`profile` are borrowed only during creation.
|
||||||
|
///
|
||||||
|
/// Builds in place into `f` — a [`Frame::default`] the caller has already parked in its
|
||||||
|
/// [`VkTeardown`] guard — so every handle is owned by the unwind the moment it exists and a
|
||||||
|
/// mid-build failure leaks nothing.
|
||||||
|
pub(super) unsafe fn make_frame(
|
||||||
|
device: &ash::Device,
|
||||||
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
fams: &[u32],
|
||||||
|
profile: &vk::VideoProfileInfoKHR,
|
||||||
|
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||||
|
csc_dsl: vk::DescriptorSetLayout,
|
||||||
|
csc_pool: vk::DescriptorPool,
|
||||||
|
cmd_pool: vk::CommandPool,
|
||||||
|
compute_pool: vk::CommandPool,
|
||||||
|
bs_size: u64,
|
||||||
|
sampler: vk::Sampler,
|
||||||
|
with_ts: bool,
|
||||||
|
csc: bool,
|
||||||
|
pad_fmt: Option<vk::Format>,
|
||||||
|
f: &mut Frame,
|
||||||
|
) -> Result<()> {
|
||||||
|
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
|
||||||
|
f.cursor_serial = u64::MAX;
|
||||||
|
// Padded-copy staging (unaligned-mode RGB-direct or native NV12): an aligned encode-src in
|
||||||
|
// the session's picture format, filled by a transfer blit each frame — concurrent compute
|
||||||
|
// (copy) + encode (source read). TRANSFER_SRC because the width-padding pass self-copies the
|
||||||
|
// staging image's own last visible column (see `record_pad_blit`).
|
||||||
|
if let Some(fmt) = pad_fmt {
|
||||||
|
(f.pad_img, f.pad_mem) = make_video_image(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
fmt,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
1,
|
||||||
|
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR
|
||||||
|
| vk::ImageUsageFlags::TRANSFER_DST
|
||||||
|
| vk::ImageUsageFlags::TRANSFER_SRC,
|
||||||
|
profile_list,
|
||||||
|
fams,
|
||||||
|
)?;
|
||||||
|
f.pad_view = make_view(device, f.pad_img, fmt, 0)?;
|
||||||
|
}
|
||||||
|
// RGB-direct sessions never touch the CSC pipeline: no NV12 encode-src, no Y/UV scratch, no
|
||||||
|
// cursor overlay, no descriptor set — the encode source is the imported RGB itself (or the
|
||||||
|
// CPU staging image, built lazily). Their Frame keeps the null handles (teardown-safe).
|
||||||
|
if csc {
|
||||||
|
make_frame_csc(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
fams,
|
||||||
|
profile_list,
|
||||||
|
csc_dsl,
|
||||||
|
csc_pool,
|
||||||
|
sampler,
|
||||||
|
f,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
make_frame_common(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
profile,
|
||||||
|
profile_list,
|
||||||
|
cmd_pool,
|
||||||
|
compute_pool,
|
||||||
|
bs_size,
|
||||||
|
with_ts,
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CSC-only half of [`make_frame`]: NV12 encode-src + Y/UV scratch + cursor + descriptors.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
unsafe fn make_frame_csc(
|
||||||
|
device: &ash::Device,
|
||||||
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
fams: &[u32],
|
||||||
|
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||||
|
csc_dsl: vk::DescriptorSetLayout,
|
||||||
|
csc_pool: vk::DescriptorPool,
|
||||||
|
sampler: vk::Sampler,
|
||||||
|
f: &mut Frame,
|
||||||
|
) -> Result<()> {
|
||||||
|
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
|
||||||
|
(f.nv12_src, f.nv12_mem) = make_video_image(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
NV12,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
1,
|
||||||
|
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST,
|
||||||
|
profile_list,
|
||||||
|
fams,
|
||||||
|
)?;
|
||||||
|
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?;
|
||||||
|
// CSC scratch (Y R8 full-res, UV RG8 half-res).
|
||||||
|
(f.y_img, f.y_mem, f.y_view) = make_plain_image(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
vk::Format::R8_UNORM,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||||
|
)?;
|
||||||
|
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
vk::Format::R8G8_UNORM,
|
||||||
|
w / 2,
|
||||||
|
h / 2,
|
||||||
|
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||||
|
)?;
|
||||||
|
// Cursor overlay: fixed CURSOR_MAX² RGBA8 sampled image + host staging (cursor-as-metadata). The
|
||||||
|
// view/descriptor is static (bound at binding 3 below); only the image *content* changes, and
|
||||||
|
// only when the pointer bitmap does — see `prep_cursor`.
|
||||||
|
(f.cursor_img, f.cursor_mem, f.cursor_view) = make_plain_image(
|
||||||
|
device,
|
||||||
|
mem_props,
|
||||||
|
vk::Format::R8G8B8A8_UNORM,
|
||||||
|
CURSOR_MAX,
|
||||||
|
CURSOR_MAX,
|
||||||
|
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
|
||||||
|
)?;
|
||||||
|
f.cursor_stage = device.create_buffer(
|
||||||
|
&vk::BufferCreateInfo::default()
|
||||||
|
.size((CURSOR_MAX * CURSOR_MAX * 4) as u64)
|
||||||
|
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let cs_req = device.get_buffer_memory_requirements(f.cursor_stage);
|
||||||
|
f.cursor_stage_mem = device.allocate_memory(
|
||||||
|
&vk::MemoryAllocateInfo::default()
|
||||||
|
.allocation_size(cs_req.size)
|
||||||
|
.memory_type_index(find_mem(
|
||||||
|
mem_props,
|
||||||
|
cs_req.memory_type_bits,
|
||||||
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
device.bind_buffer_memory(f.cursor_stage, f.cursor_stage_mem, 0)?;
|
||||||
|
// Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use; binding 3
|
||||||
|
// (cursor) points at the static cursor image (its layout is SHADER_READ_ONLY once prepped).
|
||||||
|
let dsls = [csc_dsl];
|
||||||
|
f.csc_set = device.allocate_descriptor_sets(
|
||||||
|
&vk::DescriptorSetAllocateInfo::default()
|
||||||
|
.descriptor_pool(csc_pool)
|
||||||
|
.set_layouts(&dsls),
|
||||||
|
)?[0];
|
||||||
|
let y_info = [vk::DescriptorImageInfo::default()
|
||||||
|
.image_view(f.y_view)
|
||||||
|
.image_layout(vk::ImageLayout::GENERAL)];
|
||||||
|
let uv_info = [vk::DescriptorImageInfo::default()
|
||||||
|
.image_view(f.uv_view)
|
||||||
|
.image_layout(vk::ImageLayout::GENERAL)];
|
||||||
|
let cur_info = [vk::DescriptorImageInfo::default()
|
||||||
|
.sampler(sampler)
|
||||||
|
.image_view(f.cursor_view)
|
||||||
|
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
|
||||||
|
device.update_descriptor_sets(
|
||||||
|
&[
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(f.csc_set)
|
||||||
|
.dst_binding(1)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
|
||||||
|
.image_info(&y_info),
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(f.csc_set)
|
||||||
|
.dst_binding(2)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
|
||||||
|
.image_info(&uv_info),
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(f.csc_set)
|
||||||
|
.dst_binding(3)
|
||||||
|
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||||
|
.image_info(&cur_info),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode-independent half of [`make_frame`]: bitstream buffer (+ persistent map), feedback
|
||||||
|
/// query, optional timestamp pool, command buffers and sync objects.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
unsafe fn make_frame_common(
|
||||||
|
device: &ash::Device,
|
||||||
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
|
profile: &vk::VideoProfileInfoKHR,
|
||||||
|
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||||
|
cmd_pool: vk::CommandPool,
|
||||||
|
compute_pool: vk::CommandPool,
|
||||||
|
bs_size: u64,
|
||||||
|
with_ts: bool,
|
||||||
|
f: &mut Frame,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Bitstream buffer + feedback query.
|
||||||
|
f.bs_buf = device.create_buffer(
|
||||||
|
&vk::BufferCreateInfo::default()
|
||||||
|
.size(bs_size)
|
||||||
|
.usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR)
|
||||||
|
.push_next(profile_list),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let bs_req = device.get_buffer_memory_requirements(f.bs_buf);
|
||||||
|
f.bs_mem = device.allocate_memory(
|
||||||
|
&vk::MemoryAllocateInfo::default()
|
||||||
|
.allocation_size(bs_req.size)
|
||||||
|
.memory_type_index(find_mem(
|
||||||
|
mem_props,
|
||||||
|
bs_req.memory_type_bits,
|
||||||
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
device.bind_buffer_memory(f.bs_buf, f.bs_mem, 0)?;
|
||||||
|
// Map once for the slot's lifetime — read_slot copies AUs straight out of this (coherent
|
||||||
|
// memory, no per-frame map/unmap); vkFreeMemory implicitly unmaps at teardown.
|
||||||
|
f.bs_ptr = BsPtr(
|
||||||
|
device.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8,
|
||||||
|
);
|
||||||
|
// PUNKTFUNK_PERF: a 2-slot timestamp pool bracketing this slot's compute batch (CSC split).
|
||||||
|
if with_ts {
|
||||||
|
f.ts_pool = device.create_query_pool(
|
||||||
|
&vk::QueryPoolCreateInfo::default()
|
||||||
|
.query_type(vk::QueryType::TIMESTAMP)
|
||||||
|
.query_count(2),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let mut fb_ci = vk::QueryPoolVideoEncodeFeedbackCreateInfoKHR::default().encode_feedback_flags(
|
||||||
|
vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET
|
||||||
|
| vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN,
|
||||||
|
);
|
||||||
|
fb_ci.p_next = profile as *const _ as *const c_void;
|
||||||
|
let mut query_ci = vk::QueryPoolCreateInfo::default()
|
||||||
|
.query_type(vk::QueryType::VIDEO_ENCODE_FEEDBACK_KHR)
|
||||||
|
.query_count(1);
|
||||||
|
query_ci.p_next = &fb_ci as *const _ as *const c_void;
|
||||||
|
f.query_pool = device.create_query_pool(&query_ci, None)?;
|
||||||
|
// Command buffers + per-frame sync.
|
||||||
|
f.cmd = device.allocate_command_buffers(
|
||||||
|
&vk::CommandBufferAllocateInfo::default()
|
||||||
|
.command_pool(cmd_pool)
|
||||||
|
.command_buffer_count(1),
|
||||||
|
)?[0];
|
||||||
|
f.compute_cmd = device.allocate_command_buffers(
|
||||||
|
&vk::CommandBufferAllocateInfo::default()
|
||||||
|
.command_pool(compute_pool)
|
||||||
|
.command_buffer_count(1),
|
||||||
|
)?[0];
|
||||||
|
f.csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
|
||||||
|
f.fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Author VPS/SPS/PPS (Main, level 4.0, low-latency, conformance-window crop) and return the
|
||||||
|
/// session-parameters object + the encoded header bytes (VPS+SPS+PPS NALs) for keyframes.
|
||||||
|
pub(super) unsafe fn build_parameters_h265(
|
||||||
|
device: &ash::Device,
|
||||||
|
vq_dev: &ash::khr::video_queue::Device,
|
||||||
|
venc_dev: &ash::khr::video_encode_queue::Device,
|
||||||
|
session: vk::VideoSessionKHR,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
rw: u32,
|
||||||
|
rh: u32,
|
||||||
|
quality_level: u32,
|
||||||
|
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> {
|
||||||
|
use ash::vk::native as hh;
|
||||||
|
let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed();
|
||||||
|
ptl.flags.set_general_progressive_source_flag(1);
|
||||||
|
ptl.flags.set_general_frame_only_constraint_flag(1);
|
||||||
|
ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN;
|
||||||
|
ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0;
|
||||||
|
|
||||||
|
let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed();
|
||||||
|
dpbm.max_dec_pic_buffering_minus1[0] = (DPB_SLOTS - 1) as u8;
|
||||||
|
dpbm.max_num_reorder_pics[0] = 0;
|
||||||
|
dpbm.max_latency_increase_plus1[0] = 0;
|
||||||
|
|
||||||
|
let mut vps: hh::StdVideoH265VideoParameterSet = std::mem::zeroed();
|
||||||
|
vps.flags.set_vps_temporal_id_nesting_flag(1);
|
||||||
|
vps.flags.set_vps_sub_layer_ordering_info_present_flag(1);
|
||||||
|
vps.pDecPicBufMgr = &dpbm;
|
||||||
|
vps.pProfileTierLevel = &ptl;
|
||||||
|
|
||||||
|
let mut sps: hh::StdVideoH265SequenceParameterSet = std::mem::zeroed();
|
||||||
|
sps.flags.set_sps_temporal_id_nesting_flag(1);
|
||||||
|
sps.flags.set_sps_sub_layer_ordering_info_present_flag(1);
|
||||||
|
sps.chroma_format_idc = hh::StdVideoH265ChromaFormatIdc_STD_VIDEO_H265_CHROMA_FORMAT_IDC_420;
|
||||||
|
sps.pic_width_in_luma_samples = w;
|
||||||
|
sps.pic_height_in_luma_samples = h;
|
||||||
|
sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
|
||||||
|
sps.log2_diff_max_min_luma_coding_block_size = 3;
|
||||||
|
sps.log2_diff_max_min_luma_transform_block_size = 3;
|
||||||
|
sps.max_transform_hierarchy_depth_inter = 4;
|
||||||
|
sps.max_transform_hierarchy_depth_intra = 4;
|
||||||
|
sps.pProfileTierLevel = &ptl;
|
||||||
|
sps.pDecPicBufMgr = &dpbm;
|
||||||
|
if w != rw || h != rh {
|
||||||
|
sps.flags.set_conformance_window_flag(1);
|
||||||
|
sps.conf_win_right_offset = (w - rw) / 2; // 4:2:0 SubWidthC = 2
|
||||||
|
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed();
|
||||||
|
pps.flags.set_cu_qp_delta_enabled_flag(1);
|
||||||
|
pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1);
|
||||||
|
|
||||||
|
let vps_arr = [vps];
|
||||||
|
let sps_arr = [sps];
|
||||||
|
let pps_arr = [pps];
|
||||||
|
let add = vk::VideoEncodeH265SessionParametersAddInfoKHR::default()
|
||||||
|
.std_vp_ss(&vps_arr)
|
||||||
|
.std_sp_ss(&sps_arr)
|
||||||
|
.std_pp_ss(&pps_arr);
|
||||||
|
let mut h265_ci = vk::VideoEncodeH265SessionParametersCreateInfoKHR::default()
|
||||||
|
.max_std_vps_count(1)
|
||||||
|
.max_std_sps_count(1)
|
||||||
|
.max_std_pps_count(1)
|
||||||
|
.parameters_add_info(&add);
|
||||||
|
// Bake the session's quality level into the parameters object — the spec requires it to match
|
||||||
|
// the level the first frame's ENCODE_QUALITY_LEVEL control installs.
|
||||||
|
let mut q_info = vk::VideoEncodeQualityLevelInfoKHR::default().quality_level(quality_level);
|
||||||
|
let ci = vk::VideoSessionParametersCreateInfoKHR::default()
|
||||||
|
.video_session(session)
|
||||||
|
.push_next(&mut h265_ci)
|
||||||
|
.push_next(&mut q_info);
|
||||||
|
let mut params = vk::VideoSessionParametersKHR::null();
|
||||||
|
let r = (vq_dev.fp().create_video_session_parameters_khr)(
|
||||||
|
device.handle(),
|
||||||
|
&ci,
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut params,
|
||||||
|
);
|
||||||
|
if r != vk::Result::SUCCESS {
|
||||||
|
bail!("create_video_session_parameters: {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut get_h265 = vk::VideoEncodeH265SessionParametersGetInfoKHR::default()
|
||||||
|
.write_std_vps(true)
|
||||||
|
.write_std_sps(true)
|
||||||
|
.write_std_pps(true)
|
||||||
|
.std_vps_id(0)
|
||||||
|
.std_sps_id(0)
|
||||||
|
.std_pps_id(0);
|
||||||
|
let get = vk::VideoEncodeSessionParametersGetInfoKHR::default()
|
||||||
|
.video_session_parameters(params)
|
||||||
|
.push_next(&mut get_h265);
|
||||||
|
let get_fn = venc_dev.fp().get_encoded_video_session_parameters_khr;
|
||||||
|
let mut fb = vk::VideoEncodeSessionParametersFeedbackInfoKHR::default();
|
||||||
|
let mut size: usize = 0;
|
||||||
|
let r = get_fn(
|
||||||
|
device.handle(),
|
||||||
|
&get,
|
||||||
|
&mut fb,
|
||||||
|
&mut size,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if r != vk::Result::SUCCESS {
|
||||||
|
// `params` is live but not yet the caller's guard's to unwind — destroy before bailing.
|
||||||
|
(vq_dev.fp().destroy_video_session_parameters_khr)(
|
||||||
|
device.handle(),
|
||||||
|
params,
|
||||||
|
std::ptr::null(),
|
||||||
|
);
|
||||||
|
bail!("get header size: {r:?}");
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; size];
|
||||||
|
let r = get_fn(
|
||||||
|
device.handle(),
|
||||||
|
&get,
|
||||||
|
&mut fb,
|
||||||
|
&mut size,
|
||||||
|
buf.as_mut_ptr() as *mut c_void,
|
||||||
|
);
|
||||||
|
if r != vk::Result::SUCCESS {
|
||||||
|
(vq_dev.fp().destroy_video_session_parameters_khr)(
|
||||||
|
device.handle(),
|
||||||
|
params,
|
||||||
|
std::ptr::null(),
|
||||||
|
);
|
||||||
|
bail!("get header bytes: {r:?}");
|
||||||
|
}
|
||||||
|
buf.truncate(size);
|
||||||
|
Ok((params, buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AV1 low-overhead OBU bit-writer (MSB-first), used to hand-pack the sequence-header OBU that
|
||||||
|
/// Vulkan AV1 encode (unlike H26x) never emits itself.
|
||||||
|
struct Av1BitWriter {
|
||||||
|
buf: Vec<u8>,
|
||||||
|
cur: u8,
|
||||||
|
fill: u8,
|
||||||
|
}
|
||||||
|
impl Av1BitWriter {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buf: Vec::new(),
|
||||||
|
cur: 0,
|
||||||
|
fill: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn bit(&mut self, b: u32) {
|
||||||
|
self.cur = (self.cur << 1) | (b as u8 & 1);
|
||||||
|
self.fill += 1;
|
||||||
|
if self.fill == 8 {
|
||||||
|
self.buf.push(self.cur);
|
||||||
|
self.cur = 0;
|
||||||
|
self.fill = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn put(&mut self, val: u32, bits: u32) {
|
||||||
|
for i in (0..bits).rev() {
|
||||||
|
self.bit((val >> i) & 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Flush, zero-padding the final partial byte (OBU size field delimits the payload).
|
||||||
|
fn finish(mut self) -> Vec<u8> {
|
||||||
|
if self.fill > 0 {
|
||||||
|
self.cur <<= 8 - self.fill;
|
||||||
|
self.buf.push(self.cur);
|
||||||
|
}
|
||||||
|
self.buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AV1 leb128 (little-endian base-128) encoding of an OBU size.
|
||||||
|
fn leb128(mut v: u64) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut byte = (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
if v != 0 {
|
||||||
|
byte |= 0x80;
|
||||||
|
}
|
||||||
|
out.push(byte);
|
||||||
|
if v == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bit-pack a `sequence_header_obu` (AV1 spec §5.5) into a size-delimited OBU. The field values here
|
||||||
|
/// MUST mirror the `StdVideoAV1SequenceHeader` handed to the driver in `build_parameters_av1` so the
|
||||||
|
/// driver-emitted frame OBUs parse against this header. Single operating point, 8-bit 4:2:0,
|
||||||
|
/// order-hint on, CDEF+restoration+filter-intra allowed, everything exotic (compound/warp/superres)
|
||||||
|
/// disabled — the profile our single-reference P-frame encoder actually uses.
|
||||||
|
fn av1_sequence_header_obu(
|
||||||
|
sb128: bool,
|
||||||
|
fwb: u32,
|
||||||
|
fhb: u32,
|
||||||
|
max_w_m1: u32,
|
||||||
|
max_h_m1: u32,
|
||||||
|
order_hint_bits_minus_1: u32,
|
||||||
|
seq_level_idx: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut w = Av1BitWriter::new();
|
||||||
|
w.put(0, 3); // seq_profile = MAIN
|
||||||
|
w.bit(0); // still_picture
|
||||||
|
w.bit(0); // reduced_still_picture_header
|
||||||
|
w.bit(0); // timing_info_present_flag
|
||||||
|
w.bit(0); // initial_display_delay_present_flag
|
||||||
|
w.put(0, 5); // operating_points_cnt_minus_1 = 0
|
||||||
|
w.put(0, 12); // operating_point_idc[0]
|
||||||
|
w.put(seq_level_idx, 5); // seq_level_idx[0]
|
||||||
|
if seq_level_idx > 7 {
|
||||||
|
w.bit(0); // seq_tier[0] = 0
|
||||||
|
}
|
||||||
|
w.put(fwb, 4); // frame_width_bits_minus_1
|
||||||
|
w.put(fhb, 4); // frame_height_bits_minus_1
|
||||||
|
w.put(max_w_m1, fwb + 1); // max_frame_width_minus_1
|
||||||
|
w.put(max_h_m1, fhb + 1); // max_frame_height_minus_1
|
||||||
|
w.bit(0); // frame_id_numbers_present_flag
|
||||||
|
w.bit(sb128 as u32); // use_128x128_superblock
|
||||||
|
w.bit(0); // enable_filter_intra
|
||||||
|
w.bit(0); // enable_intra_edge_filter
|
||||||
|
w.bit(0); // enable_interintra_compound
|
||||||
|
w.bit(0); // enable_masked_compound
|
||||||
|
w.bit(0); // enable_warped_motion
|
||||||
|
w.bit(0); // enable_dual_filter
|
||||||
|
w.bit(1); // enable_order_hint
|
||||||
|
w.bit(0); // enable_jnt_comp
|
||||||
|
w.bit(0); // enable_ref_frame_mvs
|
||||||
|
w.bit(1); // seq_choose_screen_content_tools -> seq_force_screen_content_tools = SELECT
|
||||||
|
w.bit(1); // seq_choose_integer_mv -> seq_force_integer_mv = SELECT
|
||||||
|
w.put(order_hint_bits_minus_1, 3); // order_hint_bits_minus_1
|
||||||
|
w.bit(0); // enable_superres
|
||||||
|
w.bit(0); // enable_cdef
|
||||||
|
w.bit(0); // enable_restoration
|
||||||
|
// color_config(): 8-bit 4:2:0, unspecified primaries/transfer/matrix, limited range
|
||||||
|
w.bit(0); // high_bitdepth
|
||||||
|
w.bit(0); // mono_chrome
|
||||||
|
w.bit(0); // color_description_present_flag
|
||||||
|
w.bit(0); // color_range (studio/limited)
|
||||||
|
w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0)
|
||||||
|
w.bit(0); // separate_uv_delta_q
|
||||||
|
w.bit(0); // film_grain_params_present
|
||||||
|
|
||||||
|
// trailing_bits(): a stop `1` bit then zero-pad to a byte (the size field delimits the OBU, but
|
||||||
|
// the parser still requires the trailing_one_bit — dav1d/cbs reject a plain zero pad).
|
||||||
|
w.bit(1);
|
||||||
|
let payload = w.finish();
|
||||||
|
let mut obu = vec![0x0au8]; // obu_header: type=OBU_SEQUENCE_HEADER(1), has_size_field=1
|
||||||
|
obu.extend_from_slice(&leb128(payload.len() as u64));
|
||||||
|
obu.extend_from_slice(&payload);
|
||||||
|
obu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AV1 session parameters + header framing. Vulkan AV1 encode emits only the per-frame OBU, so we
|
||||||
|
/// return the app-owned prefixes: a temporal-delimiter OBU that opens every temporal unit
|
||||||
|
/// (`frame_prefix`), and TD + the bit-packed sequence-header OBU for keyframes (`header`).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) unsafe fn build_parameters_av1(
|
||||||
|
device: &ash::Device,
|
||||||
|
vq_dev: &ash::khr::video_queue::Device,
|
||||||
|
session: vk::VideoSessionKHR,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
_rw: u32,
|
||||||
|
_rh: u32,
|
||||||
|
max_level: ash::vk::native::StdVideoAV1Level,
|
||||||
|
sb128: bool,
|
||||||
|
quality_level: u32,
|
||||||
|
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>, Vec<u8>)> {
|
||||||
|
use crate::vk_av1_encode as av1;
|
||||||
|
use ash::vk::native as hh;
|
||||||
|
|
||||||
|
let fwb = 31 - w.leading_zeros(); // av_log2(w): enough bits for max_frame_width_minus_1 = w-1
|
||||||
|
let fhb = 31 - h.leading_zeros();
|
||||||
|
let order_hint_bits_minus_1: u32 = 7; // OrderHintBits = 8
|
||||||
|
let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx
|
||||||
|
|
||||||
|
// ---- Std sequence header (must match the OBU packed below) ----
|
||||||
|
let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed();
|
||||||
|
let _ = &mut cc_flags; // all zero: mono_chrome/color_range/description/separate_uv_delta_q = 0
|
||||||
|
let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed();
|
||||||
|
cc.flags = cc_flags;
|
||||||
|
cc.BitDepth = 8;
|
||||||
|
cc.subsampling_x = 1;
|
||||||
|
cc.subsampling_y = 1;
|
||||||
|
cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_UNSPECIFIED;
|
||||||
|
cc.transfer_characteristics =
|
||||||
|
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
|
||||||
|
cc.matrix_coefficients =
|
||||||
|
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_UNSPECIFIED;
|
||||||
|
cc.chroma_sample_position =
|
||||||
|
hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN;
|
||||||
|
|
||||||
|
// Match FFmpeg's Vulkan AV1 encoder (proven on this RADV/VCN path): the ONLY coding tools
|
||||||
|
// enabled are order-hint and (per caps) 128x128 superblocks. CDEF, loop restoration, filter-
|
||||||
|
// intra, warped/compound motion, superres all OFF — enabling them made VCN emit frame-header
|
||||||
|
// sections whose bit layout our sequence header didn't match, desyncing every inter frame.
|
||||||
|
let mut sh_flags: hh::StdVideoAV1SequenceHeaderFlags = std::mem::zeroed();
|
||||||
|
if sb128 {
|
||||||
|
sh_flags.set_use_128x128_superblock(1);
|
||||||
|
}
|
||||||
|
sh_flags.set_enable_order_hint(1);
|
||||||
|
let mut sh: hh::StdVideoAV1SequenceHeader = std::mem::zeroed();
|
||||||
|
sh.flags = sh_flags;
|
||||||
|
sh.seq_profile = hh::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN;
|
||||||
|
sh.frame_width_bits_minus_1 = fwb as u8;
|
||||||
|
sh.frame_height_bits_minus_1 = fhb as u8;
|
||||||
|
sh.max_frame_width_minus_1 = (w - 1) as u16;
|
||||||
|
sh.max_frame_height_minus_1 = (h - 1) as u16;
|
||||||
|
sh.order_hint_bits_minus_1 = order_hint_bits_minus_1 as u8;
|
||||||
|
sh.seq_force_integer_mv = 2; // SELECT
|
||||||
|
sh.seq_force_screen_content_tools = 2; // SELECT
|
||||||
|
sh.pColorConfig = &cc;
|
||||||
|
|
||||||
|
// ---- single operating point conveying the level/tier the driver targets ----
|
||||||
|
let op = av1::StdVideoEncodeAV1OperatingPointInfo {
|
||||||
|
flags: std::mem::zeroed(),
|
||||||
|
operating_point_idc: 0,
|
||||||
|
seq_level_idx: seq_level_idx as u8,
|
||||||
|
seq_tier: 0,
|
||||||
|
decoder_buffer_delay: 0,
|
||||||
|
encoder_buffer_delay: 0,
|
||||||
|
initial_display_delay_minus_1: 0,
|
||||||
|
};
|
||||||
|
let ops = [op];
|
||||||
|
let av1_spci = av1::VideoEncodeAV1SessionParametersCreateInfoKHR {
|
||||||
|
s_type: av1::stype(av1::ST_SESSION_PARAMETERS_CREATE_INFO),
|
||||||
|
p_next: std::ptr::null(),
|
||||||
|
p_std_sequence_header: &sh,
|
||||||
|
p_std_decoder_model_info: std::ptr::null(),
|
||||||
|
std_operating_point_count: 1,
|
||||||
|
p_std_operating_points: ops.as_ptr() as *const c_void,
|
||||||
|
};
|
||||||
|
// Bake the session's quality level into the parameters object (must match the level the first
|
||||||
|
// frame's ENCODE_QUALITY_LEVEL control installs); chained raw ahead of the vendored AV1 struct.
|
||||||
|
let mut q_info = vk::VideoEncodeQualityLevelInfoKHR::default().quality_level(quality_level);
|
||||||
|
q_info.p_next = &av1_spci as *const _ as *const c_void;
|
||||||
|
let mut ci = vk::VideoSessionParametersCreateInfoKHR::default().video_session(session);
|
||||||
|
ci.p_next = &q_info as *const _ as *const c_void;
|
||||||
|
let mut params = vk::VideoSessionParametersKHR::null();
|
||||||
|
let r = (vq_dev.fp().create_video_session_parameters_khr)(
|
||||||
|
device.handle(),
|
||||||
|
&ci,
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut params,
|
||||||
|
);
|
||||||
|
if r != vk::Result::SUCCESS {
|
||||||
|
bail!("create_video_session_parameters (av1): {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- header framing: TD every temporal unit; TD + seq-header OBU on keyframes ----
|
||||||
|
let td = vec![0x12u8, 0x00]; // temporal_delimiter OBU (type=2, size=0)
|
||||||
|
let seq_obu = av1_sequence_header_obu(
|
||||||
|
sb128,
|
||||||
|
fwb,
|
||||||
|
fhb,
|
||||||
|
w - 1,
|
||||||
|
h - 1,
|
||||||
|
order_hint_bits_minus_1,
|
||||||
|
seq_level_idx,
|
||||||
|
);
|
||||||
|
let mut keyframe_prefix = td.clone();
|
||||||
|
keyframe_prefix.extend_from_slice(&seq_obu);
|
||||||
|
Ok((params, keyframe_prefix, td))
|
||||||
|
}
|
||||||
@@ -54,6 +54,59 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a CPU RGB payload for Vulkan upload. The packed 24-bpp `Rgb`/`Bgr` the PipeWire
|
||||||
|
/// capturer can negotiate are expanded 3→4 into `scratch` (kept by the caller across frames — no
|
||||||
|
/// per-frame allocation) with the pad byte = 0xFF; refusing them instead used to kill a session
|
||||||
|
/// at its first frame (WP5.4). No packed 24-bpp VkFormat is reliably uploadable/sampleable on
|
||||||
|
/// target GPUs, and this path is CPU-sourced by definition, so one cheap expand pass serves it
|
||||||
|
/// (the same call NVENC answers with its swscale 3→4 expand, WP1.4).
|
||||||
|
///
|
||||||
|
/// `bgra_target = false` (the CSC paths): channel order is preserved — the sampler reads through
|
||||||
|
/// the matching view format, so any 4-bpp order works and 4-bpp inputs pass through borrowed.
|
||||||
|
/// `bgra_target = true` (the RGB-direct encode source): the output byte order is forced to
|
||||||
|
/// B,G,R,X, because the video session's `pictureFormat` is `B8G8R8A8_UNORM` and
|
||||||
|
/// VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 requires the source image to match it — an
|
||||||
|
/// R-first source (`Rgbx`/`Rgba`/`Rgb`) is channel-swapped during the same pass. (Caught live on
|
||||||
|
/// RADV by `vulkan_smoke_rgb_cpu24`; the mismatch predates the 24-bpp support for `Rgbx` CPU
|
||||||
|
/// sources.)
|
||||||
|
///
|
||||||
|
/// Payloads are tightly packed with no row padding (`FramePayload::Cpu`'s contract), so the
|
||||||
|
/// conversion is row-agnostic; a truncated source yields a truncated output, which the upload
|
||||||
|
/// paths already bound-check exactly as they did the raw bytes.
|
||||||
|
pub(crate) fn normalize_cpu_rgb<'a>(
|
||||||
|
fmt: PixelFormat,
|
||||||
|
bytes: &'a [u8],
|
||||||
|
scratch: &'a mut Vec<u8>,
|
||||||
|
bgra_target: bool,
|
||||||
|
) -> (PixelFormat, &'a [u8]) {
|
||||||
|
// Per-pixel source layout: bytes-per-pixel + where R, G, B sit in each pixel.
|
||||||
|
let (bpp, r, g, b) = match fmt {
|
||||||
|
PixelFormat::Rgb => (3usize, 0usize, 1usize, 2usize),
|
||||||
|
PixelFormat::Bgr => (3, 2, 1, 0),
|
||||||
|
PixelFormat::Rgbx | PixelFormat::Rgba => (4, 0, 1, 2),
|
||||||
|
PixelFormat::Bgrx | PixelFormat::Bgra => (4, 2, 1, 0),
|
||||||
|
_ => return (fmt, bytes),
|
||||||
|
};
|
||||||
|
if bpp == 4 && (!bgra_target || b == 0) {
|
||||||
|
return (fmt, bytes); // 4-bpp in an acceptable order: borrow untouched
|
||||||
|
}
|
||||||
|
let px = bytes.len() / bpp;
|
||||||
|
scratch.clear();
|
||||||
|
scratch.resize(px * 4, 0xFF);
|
||||||
|
let (dr, dg, db) = if bgra_target { (2, 1, 0) } else { (r, g, b) };
|
||||||
|
for (dst, src) in scratch.chunks_exact_mut(4).zip(bytes.chunks_exact(bpp)) {
|
||||||
|
dst[dr] = src[r];
|
||||||
|
dst[dg] = src[g];
|
||||||
|
dst[db] = src[b];
|
||||||
|
}
|
||||||
|
let out_fmt = if bgra_target || b == 0 {
|
||||||
|
PixelFormat::Bgrx
|
||||||
|
} else {
|
||||||
|
PixelFormat::Rgbx
|
||||||
|
};
|
||||||
|
(out_fmt, scratch.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) unsafe fn make_view(
|
pub(crate) unsafe fn make_view(
|
||||||
device: &ash::Device,
|
device: &ash::Device,
|
||||||
image: vk::Image,
|
image: vk::Image,
|
||||||
@@ -70,6 +123,21 @@ pub(crate) unsafe fn make_view(
|
|||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a failed dmabuf import should count toward pf-zerocopy's raw-dmabuf degrade latch
|
||||||
|
/// (`note_raw_dmabuf_import_failure` — 3 consecutive failures flip capture to CPU delivery for
|
||||||
|
/// the process). Deterministic refusals (unsupported fourcc, the driver rejecting the buffer)
|
||||||
|
/// must count — they repeat identically forever and the latch is their only recovery. Transient
|
||||||
|
/// VRAM pressure must NOT: three tight allocation OOMs would otherwise permanently downgrade a
|
||||||
|
/// working host to CPU capture.
|
||||||
|
pub(crate) fn import_failure_feeds_latch(e: &anyhow::Error) -> bool {
|
||||||
|
match e.downcast_ref::<vk::Result>() {
|
||||||
|
Some(&r) => {
|
||||||
|
r != vk::Result::ERROR_OUT_OF_DEVICE_MEMORY && r != vk::Result::ERROR_OUT_OF_HOST_MEMORY
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys all
|
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys all
|
||||||
/// three returned handles. Extracted verbatim from `vulkan_video.rs`'s import path.
|
/// three returned handles. Extracted verbatim from `vulkan_video.rs`'s import path.
|
||||||
pub(crate) unsafe fn import_rgb_dmabuf(
|
pub(crate) unsafe fn import_rgb_dmabuf(
|
||||||
@@ -108,9 +176,15 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
|||||||
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use std::os::fd::IntoRawFd;
|
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||||
let fmt = fourcc_to_vk(d.fourcc)
|
let fmt = fourcc_to_vk(d.fourcc)
|
||||||
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
||||||
|
// Dup the fd FIRST, and keep it OWNED: ownership transfers to Vulkan only on a SUCCESSFUL
|
||||||
|
// `allocate_memory` (VK_KHR_external_memory_fd — from then on `vkFreeMemory` closes it), so
|
||||||
|
// the release below sits in exactly that arm. Every earlier failure drops the `OwnedFd` for
|
||||||
|
// a single clean close. An explicit `close` after a successful import would be a double
|
||||||
|
// close — and a recycled fd number then clobbers an unrelated descriptor in this process.
|
||||||
|
let dup = d.fd.try_clone().context("dup dmabuf fd")?;
|
||||||
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
|
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
|
||||||
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
|
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
|
||||||
d.offset as u64 + d.stride as u64 * ch as u64,
|
d.offset as u64 + d.stride as u64 * ch as u64,
|
||||||
@@ -155,14 +229,16 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
|||||||
ci = ci.push_next(pl);
|
ci = ci.push_next(pl);
|
||||||
}
|
}
|
||||||
let img = device.create_image(&ci, None)?;
|
let img = device.create_image(&ci, None)?;
|
||||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
// Unwind discipline below mirrors `make_plain_image`: every failure destroys what this call
|
||||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
// created (and ONLY that — the caller's `DmabufFrame` fd stays theirs).
|
||||||
let fd_props = {
|
let fd_props = {
|
||||||
let mut p = vk::MemoryFdPropertiesKHR::default();
|
let mut p = vk::MemoryFdPropertiesKHR::default();
|
||||||
|
// Borrow-only query (no ownership transfer); an error leaves memory_type_bits = 0 and
|
||||||
|
// the fallback below uses the image requirements alone.
|
||||||
let _ = (ext_fd.fp().get_memory_fd_properties_khr)(
|
let _ = (ext_fd.fp().get_memory_fd_properties_khr)(
|
||||||
device.handle(),
|
device.handle(),
|
||||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||||
dup,
|
dup.as_raw_fd(),
|
||||||
&mut p,
|
&mut p,
|
||||||
);
|
);
|
||||||
p.memory_type_bits
|
p.memory_type_bits
|
||||||
@@ -181,27 +257,87 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
|||||||
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
|
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
|
||||||
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
||||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||||
.fd(dup);
|
.fd(dup.as_raw_fd());
|
||||||
let mem = device.allocate_memory(
|
let mem = match device.allocate_memory(
|
||||||
&vk::MemoryAllocateInfo::default()
|
&vk::MemoryAllocateInfo::default()
|
||||||
.allocation_size(req.size)
|
.allocation_size(req.size)
|
||||||
.memory_type_index(ti)
|
.memory_type_index(ti)
|
||||||
.push_next(&mut ded)
|
.push_next(&mut ded)
|
||||||
.push_next(&mut import),
|
.push_next(&mut import),
|
||||||
None,
|
None,
|
||||||
)?;
|
) {
|
||||||
device.bind_image_memory(img, mem, 0)?;
|
Ok(mem) => {
|
||||||
let view = device.create_image_view(
|
// Success transferred fd ownership to the memory object — release, don't close.
|
||||||
|
let _ = dup.into_raw_fd();
|
||||||
|
mem
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
device.destroy_image(img, None);
|
||||||
|
return Err(e.into()); // `dup` drops here: the one close of the failed import's fd
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
||||||
|
device.destroy_image(img, None);
|
||||||
|
device.free_memory(mem, None); // closes the imported fd
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
let view = match device.create_image_view(
|
||||||
&vk::ImageViewCreateInfo::default()
|
&vk::ImageViewCreateInfo::default()
|
||||||
.image(img)
|
.image(img)
|
||||||
.view_type(vk::ImageViewType::TYPE_2D)
|
.view_type(vk::ImageViewType::TYPE_2D)
|
||||||
.format(fmt)
|
.format(fmt)
|
||||||
.subresource_range(color_range(0)),
|
.subresource_range(color_range(0)),
|
||||||
None,
|
None,
|
||||||
)?;
|
) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
device.destroy_image(img, None);
|
||||||
|
device.free_memory(mem, None);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
Ok((img, mem, view))
|
Ok((img, mem, view))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create + allocate + bind a host-visible/coherent buffer with `make_plain_image`'s unwind
|
||||||
|
/// discipline: on any failure everything this call created is destroyed before returning, so
|
||||||
|
/// callers can `?` freely. Both `ensure_cpu_rgb` staging twins open-coded this sequence and
|
||||||
|
/// leaked the buffer (and then buffer+memory) on the allocate/bind failure arms.
|
||||||
|
pub(crate) unsafe fn make_host_buffer(
|
||||||
|
device: &ash::Device,
|
||||||
|
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||||
|
size: u64,
|
||||||
|
usage: vk::BufferUsageFlags,
|
||||||
|
) -> Result<(vk::Buffer, vk::DeviceMemory)> {
|
||||||
|
let buf = device.create_buffer(
|
||||||
|
&vk::BufferCreateInfo::default().size(size).usage(usage),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let req = device.get_buffer_memory_requirements(buf);
|
||||||
|
let mem = match device.allocate_memory(
|
||||||
|
&vk::MemoryAllocateInfo::default()
|
||||||
|
.allocation_size(req.size)
|
||||||
|
.memory_type_index(find_mem(
|
||||||
|
mp,
|
||||||
|
req.memory_type_bits,
|
||||||
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
device.destroy_buffer(buf, None);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = device.bind_buffer_memory(buf, mem, 0) {
|
||||||
|
device.destroy_buffer(buf, None);
|
||||||
|
device.free_memory(mem, None);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
Ok((buf, mem))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) unsafe fn make_plain_image(
|
pub(crate) unsafe fn make_plain_image(
|
||||||
device: &ash::Device,
|
device: &ash::Device,
|
||||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||||
@@ -259,3 +395,80 @@ pub(crate) unsafe fn make_plain_image(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel
|
||||||
|
/// reorder, pad byte 0xFF, truncated tail pixels dropped (never overrun) — and 4-bpp inputs
|
||||||
|
/// pass through borrowed untouched.
|
||||||
|
#[test]
|
||||||
|
fn normalize_cpu_rgb_expands_24bpp_and_borrows_4bpp() {
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3, 4, 5, 6], &mut scratch, false);
|
||||||
|
assert_eq!(f, PixelFormat::Rgbx);
|
||||||
|
assert_eq!(b, &[1, 2, 3, 0xFF, 4, 5, 6, 0xFF]);
|
||||||
|
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgr, &[9, 8, 7], &mut scratch, false);
|
||||||
|
assert_eq!(f, PixelFormat::Bgrx);
|
||||||
|
assert_eq!(b, &[9, 8, 7, 0xFF]);
|
||||||
|
|
||||||
|
// Truncated tail: 5 bytes = one whole pixel + a 2-byte remainder that must be dropped.
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (_, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3, 4, 5], &mut scratch, false);
|
||||||
|
assert_eq!(b, &[1, 2, 3, 0xFF]);
|
||||||
|
|
||||||
|
// 4-bpp passthrough: borrowed, scratch untouched.
|
||||||
|
let src = [10u8, 20, 30, 40];
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgrx, &src, &mut scratch, false);
|
||||||
|
assert_eq!(f, PixelFormat::Bgrx);
|
||||||
|
assert!(std::ptr::eq(b.as_ptr(), src.as_ptr()));
|
||||||
|
assert!(scratch.is_empty());
|
||||||
|
|
||||||
|
// The 4-bpp mapping the expand lands on matches pixel_to_vk's existing table.
|
||||||
|
assert_eq!(
|
||||||
|
pixel_to_vk(PixelFormat::Rgbx),
|
||||||
|
Some(vk::Format::R8G8B8A8_UNORM)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pixel_to_vk(PixelFormat::Bgrx),
|
||||||
|
Some(vk::Format::B8G8R8A8_UNORM)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGB-direct mode (`bgra_target = true`): everything lands in B,G,R,X order because the
|
||||||
|
/// video session's `pictureFormat` is `B8G8R8A8_UNORM` and the encode source must match it
|
||||||
|
/// (VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 — caught live on RADV). B-first inputs pass
|
||||||
|
/// through borrowed; R-first inputs are channel-swapped, 3-bpp and 4-bpp alike.
|
||||||
|
#[test]
|
||||||
|
fn normalize_cpu_rgb_forces_bgra_for_the_encode_source() {
|
||||||
|
// Rgb (R,G,B) → B,G,R,X with the swap folded into the expand.
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3], &mut scratch, true);
|
||||||
|
assert_eq!(f, PixelFormat::Bgrx);
|
||||||
|
assert_eq!(b, &[3, 2, 1, 0xFF]);
|
||||||
|
|
||||||
|
// Bgr (B,G,R) → same order, expanded.
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgr, &[9, 8, 7], &mut scratch, true);
|
||||||
|
assert_eq!(f, PixelFormat::Bgrx);
|
||||||
|
assert_eq!(b, &[9, 8, 7, 0xFF]);
|
||||||
|
|
||||||
|
// Rgbx: 4-bpp but R-first — swapped, alpha replaced by the 0xFF pad.
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgbx, &[1, 2, 3, 4], &mut scratch, true);
|
||||||
|
assert_eq!(f, PixelFormat::Bgrx);
|
||||||
|
assert_eq!(b, &[3, 2, 1, 0xFF]);
|
||||||
|
|
||||||
|
// Bgrx/Bgra already match the session order: borrowed untouched.
|
||||||
|
let src = [10u8, 20, 30, 40];
|
||||||
|
let mut scratch = Vec::new();
|
||||||
|
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgra, &src, &mut scratch, true);
|
||||||
|
assert_eq!(f, PixelFormat::Bgra);
|
||||||
|
assert!(std::ptr::eq(b.as_ptr(), src.as_ptr()));
|
||||||
|
assert!(scratch.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,74 @@ pub struct VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
|||||||
pub fn stype(raw: i32) -> vk::StructureType {
|
pub fn stype(raw: i32) -> vk::StructureType {
|
||||||
vk::StructureType::from_raw(raw)
|
vk::StructureType::from_raw(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- ABI layout guard ----------
|
||||||
|
//
|
||||||
|
// These structs are hand-copied from the registry and handed to the driver through raw `p_next`
|
||||||
|
// chains, so nothing in the type system relates them to the C definitions any more: an edit that
|
||||||
|
// inserts, drops, widens or re-pads a field is not a compile error, it is the driver reading our
|
||||||
|
// bytes at the wrong offsets. The assertions below are the missing compile error. They are `const`
|
||||||
|
// rather than `#[cfg(test)]` (the shape `amf.rs` uses) so they hold in every build, including the
|
||||||
|
// shipped one, and on any target this module compiles for.
|
||||||
|
//
|
||||||
|
// What they catch: a changed field width, an inserted or removed field, a changed array length, a
|
||||||
|
// padding assumption that only holds on one target. What they CANNOT catch: swapping two fields of
|
||||||
|
// the same type — offsets are unchanged. That case is only caught by reading the registry, so the
|
||||||
|
// field order here was diffed against `vulkan_core.h` (Vulkan-Headers `main`, 2026-07-25) when
|
||||||
|
// these assertions were written, along with every `ST_*` and flag-bit value above.
|
||||||
|
//
|
||||||
|
// Deliberately duplicated in `vk_av1_encode.rs` rather than shared: both modules exist to be
|
||||||
|
// deleted wholesale once `ash` ships these bindings, and a shared helper would make deleting one
|
||||||
|
// break the other.
|
||||||
|
macro_rules! assert_abi_layout {
|
||||||
|
($t:ty { size: $size:expr, align: $align:expr $(, $field:ident @ $off:expr)* $(,)? }) => {
|
||||||
|
const _: () = {
|
||||||
|
assert!(
|
||||||
|
::core::mem::size_of::<$t>() == $size,
|
||||||
|
concat!(stringify!($t), ": size does not match the C ABI")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
::core::mem::align_of::<$t>() == $align,
|
||||||
|
concat!(stringify!($t), ": alignment does not match the C ABI")
|
||||||
|
);
|
||||||
|
$(assert!(
|
||||||
|
::core::mem::offset_of!($t, $field) == $off,
|
||||||
|
concat!(stringify!($t), ".", stringify!($field), ": offset does not match the C ABI")
|
||||||
|
);)*
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_abi_layout!(PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
|
||||||
|
size: 24, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
video_encode_rgb_conversion @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeRgbConversionCapabilitiesVALVE {
|
||||||
|
size: 32, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
rgb_models @ 16,
|
||||||
|
rgb_ranges @ 20,
|
||||||
|
x_chroma_offsets @ 24,
|
||||||
|
y_chroma_offsets @ 28,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeProfileRgbConversionInfoVALVE {
|
||||||
|
size: 24, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
perform_encode_rgb_conversion @ 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_abi_layout!(VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
||||||
|
size: 32, align: 8,
|
||||||
|
s_type @ 0,
|
||||||
|
p_next @ 8,
|
||||||
|
rgb_model @ 16,
|
||||||
|
rgb_range @ 20,
|
||||||
|
x_chroma_offset @ 24,
|
||||||
|
y_chroma_offset @ 28,
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -67,12 +67,385 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and
|
||||||
|
/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which
|
||||||
|
/// logged and one didn't). Precedence:
|
||||||
|
/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator
|
||||||
|
/// override, always wins.
|
||||||
|
/// 2. 10-bit → DISABLE: 2-way split is measurably SLOWER on Ada for Main10 — at 5120×1440@240
|
||||||
|
/// forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine (the split/merge
|
||||||
|
/// overhead dominates), and a single engine handles 5K@240 Main10 well under budget. This was
|
||||||
|
/// the "broken animations in HDR" cap at ~131 fps.
|
||||||
|
/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force 2-way (AUTO never engages below
|
||||||
|
/// ~2112 px height, so 4K120 must be forced onto the second engine).
|
||||||
|
/// 4. Else AUTO (the ~2% BD-rate split cost isn't worth it at low pixel rates).
|
||||||
|
///
|
||||||
|
/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that
|
||||||
|
/// rejects the chosen mode downgrades at open, not here.
|
||||||
|
pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 {
|
||||||
|
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||||
|
let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() {
|
||||||
|
Some("0") | Some("disable") => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||||
|
Some("1") | Some("auto") => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32,
|
||||||
|
Some("3") => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||||
|
Some("2") => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||||
|
_ if bit_depth >= 10 => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||||
|
_ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||||
|
_ => M::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||||
|
};
|
||||||
|
tracing::debug!(
|
||||||
|
split_mode = mode,
|
||||||
|
bit_depth,
|
||||||
|
pixel_rate,
|
||||||
|
"NVENC split-encode mode selected"
|
||||||
|
);
|
||||||
|
mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One session config's identity for the process-lifetime bitrate-ceiling cache
|
||||||
|
/// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys
|
||||||
|
/// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma
|
||||||
|
/// rate selects the level), depth/chroma (they select the profile) and the split mode the
|
||||||
|
/// sessions ACTUALLY opened with (a split session budgets per engine).
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(super) struct CeilingKey {
|
||||||
|
/// GPU identity — Linux: the process-global shared `CUcontext` pointer; Windows: the render
|
||||||
|
/// adapter LUID (0 when unresolved). Best effort: the cache is advisory (see
|
||||||
|
/// [`cached_ceiling`]), so a colliding identity costs one failed open + re-search, never a
|
||||||
|
/// wrong session.
|
||||||
|
pub gpu: u64,
|
||||||
|
pub codec: Codec,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
pub bit_depth: u8,
|
||||||
|
pub chroma_444: bool,
|
||||||
|
pub split_mode: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ceilings() -> &'static std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>> {
|
||||||
|
static CEILINGS: std::sync::OnceLock<
|
||||||
|
std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>>,
|
||||||
|
> = std::sync::OnceLock::new();
|
||||||
|
CEILINGS.get_or_init(Default::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The codec-level bitrate ceiling (bps) a previous clamp search discovered for `key` this
|
||||||
|
/// process lifetime, if any. ADVISORY: the consumer must treat a failed open at the cached value
|
||||||
|
/// as a stale entry (fall back to the full search, which rewrites it via [`store_ceiling`]) —
|
||||||
|
/// that self-healing is what lets the key's GPU identity be best-effort. What this buys: an ABR
|
||||||
|
/// overshoot on a config whose ceiling is already known opens (or in-place reconfigures) straight
|
||||||
|
/// AT the ceiling instead of re-running the ~6-open binary search and its ~half-second of session
|
||||||
|
/// churn per rebuild.
|
||||||
|
pub(super) fn cached_ceiling(key: &CeilingKey) -> Option<u64> {
|
||||||
|
ceilings().lock().unwrap().get(key).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the clamp search's discovered max accepted bitrate (bps) for `key`.
|
||||||
|
pub(super) fn store_ceiling(key: CeilingKey, bps: u64) {
|
||||||
|
ceilings().lock().unwrap().insert(key, bps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||||
|
|
||||||
|
// These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins.
|
||||||
|
|
||||||
|
/// `encodeCodecConfig` is a C union, so the HEVC 4:4:4 arm must be codec-gated or it stamps
|
||||||
|
/// `hevcConfig` bytes onto another codec's config. Before the gate this branch was reached on
|
||||||
|
/// ANY codec with `chroma_444 && full_chroma_input` and stayed non-UB only because `lib.rs`
|
||||||
|
/// degrades 4:4:4 for non-HEVC — a two-file invariant with nothing asserting it.
|
||||||
|
///
|
||||||
|
/// It also had to stop swallowing the per-codec bit-depth arm: this is an `if`/`else if`, so a
|
||||||
|
/// non-HEVC 4:4:4 session used to take the HEVC branch and get NEITHER 4:4:4 nor its own 10-bit
|
||||||
|
/// setup. AV1 asserts the depth it actually needs.
|
||||||
|
fn low_latency_cfg(codec: Codec, chroma_444: bool, bit_depth: u8) -> LowLatencyConfig {
|
||||||
|
LowLatencyConfig {
|
||||||
|
codec,
|
||||||
|
bitrate: 20_000_000,
|
||||||
|
fps: 60,
|
||||||
|
custom_vbv: false,
|
||||||
|
chroma_444,
|
||||||
|
full_chroma_input: true,
|
||||||
|
bit_depth,
|
||||||
|
av1_input_depth_minus8: 0,
|
||||||
|
hdr: false,
|
||||||
|
rfi_supported: false,
|
||||||
|
slices: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hevc_444_still_takes_the_frext_path() {
|
||||||
|
// `NV_ENC_CONFIG` must NOT be `mem::zeroed` — `frameFieldMode`/`mvPrecision` are C enums
|
||||||
|
// whose discriminants start at 1, so all-zero is not a valid value and Rust's own
|
||||||
|
// zero-init check aborts the process. Production seeds it the same way, from `Default`
|
||||||
|
// (then overwrites from the driver's preset).
|
||||||
|
// SAFETY: `apply_low_latency_config` only writes into the caller's config (union writes
|
||||||
|
// included) and makes no driver calls, so this is pure in-memory work.
|
||||||
|
let cfg = unsafe {
|
||||||
|
let mut cfg = nv::NV_ENC_CONFIG {
|
||||||
|
version: nv::NV_ENC_CONFIG_VER,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
apply_low_latency_config(&mut cfg, low_latency_cfg(Codec::H265, true, 10));
|
||||||
|
cfg
|
||||||
|
};
|
||||||
|
assert_eq!(cfg.profileGUID, nv::NV_ENC_HEVC_PROFILE_FREXT_GUID);
|
||||||
|
// SAFETY: an HEVC session's union arm is `hevcConfig` — the one this path wrote.
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3);
|
||||||
|
assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn av1_never_takes_the_hevc_444_union_write() {
|
||||||
|
// SAFETY: as above — pure in-memory config authoring, no driver involvement.
|
||||||
|
let cfg = unsafe {
|
||||||
|
let mut cfg = nv::NV_ENC_CONFIG {
|
||||||
|
version: nv::NV_ENC_CONFIG_VER,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
apply_low_latency_config(&mut cfg, low_latency_cfg(Codec::Av1, true, 10));
|
||||||
|
cfg
|
||||||
|
};
|
||||||
|
// The HEVC FREXT profile GUID on an AV1 session is an INVALID_PARAM at open.
|
||||||
|
assert_ne!(
|
||||||
|
cfg.profileGUID,
|
||||||
|
nv::NV_ENC_HEVC_PROFILE_FREXT_GUID,
|
||||||
|
"4:4:4 on AV1 must not stamp the HEVC FREXT profile"
|
||||||
|
);
|
||||||
|
// ...and the AV1 arm must still have run, which the old if/else-if skipped entirely.
|
||||||
|
// SAFETY: an AV1 session's union arm is `av1Config`.
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(
|
||||||
|
cfg.encodeCodecConfig.av1Config.pixelBitDepthMinus8(),
|
||||||
|
2,
|
||||||
|
"AV1 10-bit setup was swallowed by the HEVC 4:4:4 branch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_forces_two_way_at_4k120() {
|
||||||
|
// The regression this threshold constant exists for: 3840×2160×120 = 995,328,000 sat
|
||||||
|
// 0.47% under the old `> 1_000_000_000` gate and stayed AUTO — pinned ~107 fps on a
|
||||||
|
// 4090 because AUTO never engages at 2160 px height.
|
||||||
|
let four_k_120 = 3840u64 * 2160 * 120;
|
||||||
|
assert_eq!(
|
||||||
|
resolve_split_mode(8, four_k_120),
|
||||||
|
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_leaves_1440p240_auto() {
|
||||||
|
// 884.7 Mpix/s is comfortably single-engine — the threshold move must not drag it in.
|
||||||
|
let qhd_240 = 2560u64 * 1440 * 240;
|
||||||
|
assert_eq!(
|
||||||
|
resolve_split_mode(8, qhd_240),
|
||||||
|
M::NV_ENC_SPLIT_AUTO_MODE as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_disabled_for_10bit_even_at_high_pixel_rate() {
|
||||||
|
// The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2
|
||||||
|
// vs 2.8 ms single-engine at 5K240) — 10-bit precedes the pixel-rate arm.
|
||||||
|
let five_k_240 = 5120u64 * 1440 * 240;
|
||||||
|
assert_eq!(
|
||||||
|
resolve_split_mode(10, five_k_240),
|
||||||
|
M::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ceiling_cache_round_trips_and_keys_precisely() {
|
||||||
|
let key = CeilingKey {
|
||||||
|
gpu: 0xB0B0,
|
||||||
|
codec: Codec::H265,
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
fps: 120,
|
||||||
|
bit_depth: 8,
|
||||||
|
chroma_444: false,
|
||||||
|
split_mode: M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||||
|
};
|
||||||
|
assert_eq!(cached_ceiling(&key), None);
|
||||||
|
store_ceiling(key, 794_000_000);
|
||||||
|
assert_eq!(cached_ceiling(&key), Some(794_000_000));
|
||||||
|
// Any config-identity change is a different ceiling — a miss, never a wrong clamp.
|
||||||
|
assert_eq!(cached_ceiling(&CeilingKey { fps: 60, ..key }), None);
|
||||||
|
assert_eq!(
|
||||||
|
cached_ceiling(&CeilingKey {
|
||||||
|
split_mode: M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||||
|
..key
|
||||||
|
}),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// A re-search overwrites (the advisory-cache stale-entry path).
|
||||||
|
store_ceiling(key, 620_000_000);
|
||||||
|
assert_eq!(cached_ceiling(&key), Some(620_000_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
/// P-frame single-reference for low latency. Also the window [`plan_range_recovery`] checks against
|
||||||
/// paths check against (`frame_idx - RFI_DPB` = the oldest frame still in the DPB).
|
/// (`next_ts - RFI_DPB` = the oldest frame still in the DPB).
|
||||||
pub(super) const RFI_DPB: u32 = 5;
|
pub(super) const RFI_DPB: u32 = 5;
|
||||||
|
|
||||||
|
/// One loss event's recovery decision for the timestamp-range RFI both direct-NVENC backends run
|
||||||
|
/// (the range half of WP7.2's policy extraction; the slot half — AMF/QSV/Vulkan — is
|
||||||
|
/// `crate::rfi`). The mechanism (the per-timestamp `nvEncInvalidateRefFrames` loop, the
|
||||||
|
/// `last_rfi_range`/`pending_anchor` stores, the null-handle/`rfi_supported` gate) stays in each
|
||||||
|
/// backend.
|
||||||
|
pub(super) enum RangePlan {
|
||||||
|
/// The last successful invalidation already covers this range — no new driver calls, no IDR.
|
||||||
|
/// The caller must still RE-ARM its recovery anchor: the client re-asking means the previous
|
||||||
|
/// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor.
|
||||||
|
Covered,
|
||||||
|
/// Invalidate `first..=last` (the CLAMPED range — this is also what the caller must record in
|
||||||
|
/// `last_rfi_range` on success, exactly as the inline code stored the post-clamp values).
|
||||||
|
Invalidate { first: i64, last: i64 },
|
||||||
|
/// Recovery without an IDR is impossible (nonsense range, loss older than the DPB, or a range
|
||||||
|
/// entirely in the future) — the caller returns `false` and its (coalesced) keyframe path
|
||||||
|
/// recovers. Deliberately NOT paired with any state clearing: neither twin touches
|
||||||
|
/// `pending_anchor` on decline (matching Vulkan's decline, opposite of AMF/QSV's
|
||||||
|
/// `pending_force` clear — see `crate::rfi`'s module doc before "harmonizing").
|
||||||
|
Decline,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The range-RFI policy, extracted verbatim from the two backends' `invalidate_ref_frames` (they
|
||||||
|
/// were hand-copied twins). Step order is load-bearing and pinned by tests:
|
||||||
|
///
|
||||||
|
/// 1. nonsense range (`first < 0 || first > last`) → [`RangePlan::Decline`];
|
||||||
|
/// 2. covering-range dedup — checked with the UNCLAMPED `last`, BEFORE the DPB window, so a
|
||||||
|
/// covered re-ask never touches the driver even when the range has since left the DPB;
|
||||||
|
/// 3. DPB window: `first < next_ts - RFI_DPB` → Decline (a lost frame older than the DPB cannot
|
||||||
|
/// be invalidated; the only correct recovery is an IDR);
|
||||||
|
/// 4. clamp `last` to `next_ts - 1` (never invalidate a timestamp never assigned); an inverted
|
||||||
|
/// range after the clamp (loss entirely in the future — a prediction desync) → Decline.
|
||||||
|
///
|
||||||
|
/// `next_ts` is the backend's `frame_idx`: the NEXT timestamp to assign, which `submit_indexed`
|
||||||
|
/// pins to the wire frame index — so the client's lost-frame range maps 1:1 onto the timestamps
|
||||||
|
/// the driver invalidates, across every rebuild/reset. Note `teardown()` clears `last_rfi_range`
|
||||||
|
/// but NOT `frame_idx`, so a post-reset call legitimately sees a stale-high `next_ts` with a
|
||||||
|
/// `None` range — the same view the inline code had.
|
||||||
|
pub(super) fn plan_range_recovery(
|
||||||
|
first: i64,
|
||||||
|
last: i64,
|
||||||
|
next_ts: i64,
|
||||||
|
last_rfi_range: Option<(i64, i64)>,
|
||||||
|
) -> RangePlan {
|
||||||
|
if first < 0 || first > last {
|
||||||
|
return RangePlan::Decline;
|
||||||
|
}
|
||||||
|
if let Some((pf, pl)) = last_rfi_range {
|
||||||
|
if first >= pf && last <= pl {
|
||||||
|
return RangePlan::Covered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let oldest_in_dpb = next_ts - RFI_DPB as i64;
|
||||||
|
if first < oldest_in_dpb {
|
||||||
|
return RangePlan::Decline;
|
||||||
|
}
|
||||||
|
let last = last.min(next_ts - 1);
|
||||||
|
if first > last {
|
||||||
|
return RangePlan::Decline;
|
||||||
|
}
|
||||||
|
RangePlan::Invalidate { first, last }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod range_policy_tests {
|
||||||
|
use super::{plan_range_recovery, RangePlan, RFI_DPB};
|
||||||
|
|
||||||
|
/// Convenience: the plan with no prior invalidation recorded.
|
||||||
|
fn plan(first: i64, last: i64, next_ts: i64) -> RangePlan {
|
||||||
|
plan_range_recovery(first, last, next_ts, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonsense_ranges_decline() {
|
||||||
|
assert!(matches!(plan(-1, 5, 100), RangePlan::Decline));
|
||||||
|
assert!(matches!(plan(7, 5, 100), RangePlan::Decline));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn covering_range_dedups_partial_overlap_does_not() {
|
||||||
|
let prior = Some((90i64, 95i64));
|
||||||
|
// Exact cover and sub-range → Covered. This pins EXISTING behavior, including that a
|
||||||
|
// covered range survives a forced IDR with zero driver calls (nothing clears
|
||||||
|
// `last_rfi_range` on a keyframe) — a recorded fact, not an endorsement.
|
||||||
|
assert!(matches!(
|
||||||
|
plan_range_recovery(90, 95, 100, prior),
|
||||||
|
RangePlan::Covered
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
plan_range_recovery(92, 94, 100, prior),
|
||||||
|
RangePlan::Covered
|
||||||
|
));
|
||||||
|
// Partial overlap re-invalidates the FULL new range (next_ts = 98 keeps the window open:
|
||||||
|
// oldest_in_dpb = 93; at next_ts = 100 the same range would age out and Decline instead).
|
||||||
|
assert!(matches!(
|
||||||
|
plan_range_recovery(93, 97, 98, prior),
|
||||||
|
RangePlan::Invalidate {
|
||||||
|
first: 93,
|
||||||
|
last: 97
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The covering check runs BEFORE the DPB window: a covered re-ask stays Covered (no driver
|
||||||
|
/// calls needed) even when the range has since aged out of the DPB.
|
||||||
|
#[test]
|
||||||
|
fn covered_is_checked_before_the_dpb_window() {
|
||||||
|
let prior = Some((10i64, 12i64));
|
||||||
|
assert!(matches!(
|
||||||
|
plan_range_recovery(10, 12, 100, prior),
|
||||||
|
RangePlan::Covered
|
||||||
|
));
|
||||||
|
// ...whereas the same range with no prior invalidation is outside the window → Decline.
|
||||||
|
assert!(matches!(plan(10, 12, 100), RangePlan::Decline));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dpb_window_boundary() {
|
||||||
|
let next_ts = 100i64;
|
||||||
|
let oldest = next_ts - RFI_DPB as i64; // 95: the oldest timestamp still in the DPB
|
||||||
|
assert!(matches!(
|
||||||
|
plan(oldest, oldest, next_ts),
|
||||||
|
RangePlan::Invalidate { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
plan(oldest - 1, oldest, next_ts),
|
||||||
|
RangePlan::Decline
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `last` clamps to `next_ts - 1` (the newest encoded frame); the Invalidate carries the
|
||||||
|
/// CLAMPED value — which is also what the caller records in `last_rfi_range`.
|
||||||
|
#[test]
|
||||||
|
fn clamps_to_newest_encoded() {
|
||||||
|
assert!(matches!(
|
||||||
|
plan(98, 150, 100),
|
||||||
|
RangePlan::Invalidate {
|
||||||
|
first: 98,
|
||||||
|
last: 99
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// A range entirely in the future inverts under the clamp → Decline (prediction desync).
|
||||||
|
assert!(matches!(plan(100, 150, 100), RangePlan::Decline));
|
||||||
|
// Fresh session (`frame_idx == 0`): window passes (oldest = -5) but the clamp gives
|
||||||
|
// last = -1 < first → Decline. The inline code behaved identically.
|
||||||
|
assert!(matches!(plan(0, 3, 0), RangePlan::Decline));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The per-session knobs both direct-NVENC backends feed [`apply_low_latency_config`]. `Copy` so the
|
/// The per-session knobs both direct-NVENC backends feed [`apply_low_latency_config`]. `Copy` so the
|
||||||
/// backend fills it from `self` at the call. The two input-format fields bridge the only real
|
/// backend fills it from `self` at the call. The two input-format fields bridge the only real
|
||||||
/// divergence between the CUDA and D3D11 paths (which surface formats can carry full chroma / 10-bit
|
/// divergence between the CUDA and D3D11 paths (which surface formats can carry full chroma / 10-bit
|
||||||
@@ -219,7 +592,27 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
|||||||
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
||||||
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
||||||
// HEVC Main10 GUID onto an AV1 session is an INVALID_PARAM, so bit depth is set PER CODEC.
|
// HEVC Main10 GUID onto an AV1 session is an INVALID_PARAM, so bit depth is set PER CODEC.
|
||||||
if c.chroma_444 && c.full_chroma_input {
|
// `encodeCodecConfig` is a C UNION, so the `hevcConfig` writes below are only meaningful on an
|
||||||
|
// HEVC session — on an H.264 or AV1 one they reinterpret that codec's own config bytes. The
|
||||||
|
// codec test is therefore load-bearing, not defensive: without it this branch was gated purely
|
||||||
|
// on `chroma_444 && full_chroma_input` and stayed non-UB only because `lib.rs` degrades 4:4:4
|
||||||
|
// for non-HEVC codecs. That was a two-file invariant with nothing asserting it, on the path
|
||||||
|
// BOTH direct-NVENC backends take.
|
||||||
|
//
|
||||||
|
// Being a codec test also fixes a second, quieter bug in the same shape: this is an
|
||||||
|
// `if`/`else if`, so a non-HEVC session that somehow arrived with `chroma_444` set took this
|
||||||
|
// branch and skipped the per-codec bit-depth arm entirely — ending up with neither HEVC 4:4:4
|
||||||
|
// (wrong for it) nor its own 10-bit configuration (simply missing). Non-HEVC now falls through
|
||||||
|
// to the arm that knows what to do with it.
|
||||||
|
let want_444 = c.chroma_444 && c.full_chroma_input;
|
||||||
|
if want_444 && c.codec != Codec::H265 {
|
||||||
|
tracing::warn!(
|
||||||
|
codec = ?c.codec,
|
||||||
|
"4:4:4 requested on a non-HEVC NVENC session — ignoring it (Range Extensions are \
|
||||||
|
HEVC-only); the negotiator should have degraded this to 4:2:0 before the open"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if want_444 && c.codec == Codec::H265 {
|
||||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||||
if c.bit_depth == 10 {
|
if c.bit_depth == 10 {
|
||||||
|
|||||||
@@ -8,27 +8,77 @@
|
|||||||
//! means and what the operator should do, and folds that cause into the `anyhow::Error` at
|
//! means and what the operator should do, and folds that cause into the `anyhow::Error` at
|
||||||
//! construction, so every downstream `{e:#}` log (the encode-recovery loop, session teardown) says
|
//! construction, so every downstream `{e:#}` log (the encode-recovery loop, session teardown) says
|
||||||
//! the useful thing without extra plumbing.
|
//! the useful thing without extra plumbing.
|
||||||
|
//!
|
||||||
|
//! One status needs process state to explain honestly: the driver reports BOTH "your headers are
|
||||||
|
//! newer than my kernel module" and "I can no longer hand this process a session" as
|
||||||
|
//! `NV_ENC_ERR_INVALID_VERSION`. [`note_session_opened`] latches the fact that a session already
|
||||||
|
//! opened here, which tells the two apart — see [`explain`].
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||||
|
|
||||||
|
/// Latched the first time `nvEncOpenEncodeSessionEx` succeeds in this process (the caps probe, a
|
||||||
|
/// real session open, or the Windows availability probe — every one of them completes the
|
||||||
|
/// userspace↔kernel-module handshake).
|
||||||
|
///
|
||||||
|
/// The load-time gate (`NvEncodeAPIGetMaxSupportedVersion`, both backends' `load_api`) can NOT
|
||||||
|
/// serve this purpose: it is a pure userspace query, so the genuine "updated the driver, didn't
|
||||||
|
/// reboot" skew sails through it and fails later, at the open. Only a session that actually opened
|
||||||
|
/// proves the kernel module agreed.
|
||||||
|
static SESSION_OPENED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Record that an NVENC session opened. Call right after every successful
|
||||||
|
/// `open_encode_session_ex`, so [`explain`] can rule a version skew out for the rest of the
|
||||||
|
/// process.
|
||||||
|
pub(super) fn note_session_opened() {
|
||||||
|
SESSION_OPENED.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two very different failures the driver reports as `NV_ENC_ERR_INVALID_VERSION`, split on
|
||||||
|
/// whether a session has already opened here (`session_opened`). Pure, so both halves are testable
|
||||||
|
/// without touching the process-wide latch.
|
||||||
|
fn invalid_version(session_opened: bool) -> String {
|
||||||
|
if session_opened {
|
||||||
|
// Same status, opposite cause: a session ALREADY opened in this process, so the driver's
|
||||||
|
// kernel module accepted this exact build's version word minutes ago. A version skew is
|
||||||
|
// static — it cannot come and go — so "update the driver / reboot" is the wrong advice
|
||||||
|
// here, and following it costs the operator a reboot per stream (2026-07 field report: one
|
||||||
|
// stream works, the next fails at the caps probe, forever, until the PROCESS restarts).
|
||||||
|
// What is left is per-process driver state: a resource the last session did not give back,
|
||||||
|
// or a wedged/lost device. Say that, and point at the cheap fix.
|
||||||
|
// Worded for ANY call (`explain` also serves `lock_bitstream`); `call_err` already names
|
||||||
|
// the entry point ahead of this text, so it must not assume the session open.
|
||||||
|
return "this process already opened an NVENC session successfully, so this is NOT a driver \
|
||||||
|
version mismatch — that cannot come and go within a process, and a reboot is not \
|
||||||
|
the fix. The NVIDIA driver state in THIS process is exhausted or wedged: restart \
|
||||||
|
the Punktfunk host service to clear it, and please report this with the host log \
|
||||||
|
so it can be fixed properly"
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
// No session has ever opened here, so the version word really is in question. Either the
|
||||||
|
// driver is genuinely older than our headers, or (the sneaky case) the userspace
|
||||||
|
// `libnvidia-encode` reports a new-enough version to the pre-flight probe but the running
|
||||||
|
// kernel module is older and rejects the session — the classic "updated the driver, didn't
|
||||||
|
// reboot" skew. Both heal the same way.
|
||||||
|
format!(
|
||||||
|
"the NVIDIA driver is older than this build's NVENC headers (needs NVENC API {}.{} or \
|
||||||
|
newer), or the userspace and kernel-module driver versions are mismatched — common right \
|
||||||
|
after a driver update without a reboot. Update the NVIDIA driver, or reboot if you just \
|
||||||
|
updated it (a host restart is the usual fix).",
|
||||||
|
nv::NVENCAPI_MAJOR_VERSION,
|
||||||
|
nv::NVENCAPI_MINOR_VERSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// A one-line, operator-actionable cause for an NVENC status. Does not repeat the raw code —
|
/// A one-line, operator-actionable cause for an NVENC status. Does not repeat the raw code —
|
||||||
/// callers print that alongside (see [`call_err`]). Public for the few sites that build a
|
/// callers print that alongside (see [`call_err`]). Public for the few sites that build a
|
||||||
/// `String`/`format!` error instead of an `anyhow::Error`.
|
/// `String`/`format!` error instead of an `anyhow::Error`.
|
||||||
pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||||
match status {
|
match status {
|
||||||
// The one this whole module exists for: a version word the driver rejects. Either the
|
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => {
|
||||||
// driver is genuinely older than our headers, or (the sneaky case) the userspace
|
invalid_version(SESSION_OPENED.load(Ordering::Relaxed))
|
||||||
// `libnvidia-encode` reports a new-enough version to the pre-flight probe but the running
|
}
|
||||||
// kernel module is older and rejects the session — the classic "updated the driver, didn't
|
|
||||||
// reboot" skew. Both heal the same way.
|
|
||||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => format!(
|
|
||||||
"the NVIDIA driver is older than this build's NVENC headers (needs NVENC API {}.{} or \
|
|
||||||
newer), or the userspace and kernel-module driver versions are mismatched — common \
|
|
||||||
right after a driver update without a reboot. Update the NVIDIA driver, or reboot if \
|
|
||||||
you just updated it (a host restart is the usual fix).",
|
|
||||||
nv::NVENCAPI_MAJOR_VERSION,
|
|
||||||
nv::NVENCAPI_MINOR_VERSION,
|
|
||||||
),
|
|
||||||
nv::NVENCSTATUS::NV_ENC_ERR_NO_ENCODE_DEVICE => {
|
nv::NVENCSTATUS::NV_ENC_ERR_NO_ENCODE_DEVICE => {
|
||||||
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
|
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
|
||||||
disabled on this card"
|
disabled on this card"
|
||||||
@@ -72,9 +122,144 @@ pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typed root of a failed NVENC entry-point call: carries the raw status so callers can classify
|
||||||
|
/// the failure class, not just print it — the bitrate-clamp search must only read a
|
||||||
|
/// parameter/caps rejection as "above the codec-level ceiling"; a transient failure shrinking the
|
||||||
|
/// search would discover (and cache) a bogus ceiling. Recover it through an `anyhow` chain with
|
||||||
|
/// `err.downcast_ref::<NvCallError>()` (see [`is_param_rejection`]).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct NvCallError(pub(super) nv::NVENCSTATUS);
|
||||||
|
|
||||||
|
impl std::fmt::Display for NvCallError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?} — {}", self.0, explain(self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NvCallError {}
|
||||||
|
|
||||||
|
/// Whether `err` is an NVENC parameter/capability rejection: the driver understood the request
|
||||||
|
/// and says THIS config is not encodable — the clamp search's "bitrate above the ceiling"
|
||||||
|
/// evidence. Everything else (busy engine, session limit, OOM, device loss, version skew) is
|
||||||
|
/// environmental and must propagate instead of steering the search.
|
||||||
|
pub(super) fn is_param_rejection(err: &anyhow::Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
err.downcast_ref::<NvCallError>(),
|
||||||
|
Some(NvCallError(
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM
|
||||||
|
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM
|
||||||
|
| nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
|
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
|
||||||
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
|
/// (e.g. `"open_encode_session_ex"`); the chain carries both the raw status and its real-world
|
||||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
|
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". The
|
||||||
|
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
|
||||||
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
||||||
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
|
anyhow::Error::new(NvCallError(status)).context(format!("NVENC {call} failed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a FAILED `nvEncDestroyEncoder` status PROVES the driver holds no session for the
|
||||||
|
/// handle — i.e. the per-process concurrent-session slot is not consumed, so the session's budget
|
||||||
|
/// units can be refunded immediately. These are the statuses the driver returns when the session
|
||||||
|
/// or its device no longer exists on its side (a TDR/device removal reclaims every session with
|
||||||
|
/// the context). Everything else — `GENERIC`, `ENCODER_BUSY`, OOM, ... — is AMBIGUOUS: the slot
|
||||||
|
/// may genuinely still be held, so the caller must park the handle fail-closed (units stay
|
||||||
|
/// charged) and let a later retry-destroy produce the proof. Splitting on proof is what keeps the
|
||||||
|
/// session budget from drifting low on failures (over-admitting parallel displays) WITHOUT letting
|
||||||
|
/// one transient wedge episode permanently poison admission until a host restart.
|
||||||
|
///
|
||||||
|
/// Used by the Windows D3D11 backend's teardown accounting; the Linux CUDA backend has no session
|
||||||
|
/// budget (parallel-display admission is a Windows feature), so there this exists for the unit
|
||||||
|
/// tests only.
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
pub(super) fn destroy_proves_no_session(status: nv::NVENCSTATUS) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_DEVICE_NOT_EXIST
|
||||||
|
| nv::NVENCSTATUS::NV_ENC_ERR_INVALID_ENCODERDEVICE
|
||||||
|
| nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PTR
|
||||||
|
| nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_NOT_INITIALIZED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Before any session has opened, the version word IS in question — keep the skew advice.
|
||||||
|
#[test]
|
||||||
|
fn invalid_version_before_any_session_blames_the_driver_version() {
|
||||||
|
let msg = invalid_version(false);
|
||||||
|
assert!(
|
||||||
|
msg.contains("older than this build's NVENC headers"),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
|
assert!(msg.contains("reboot if you just updated it"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once a session has opened here, a skew is impossible — the message must stop sending
|
||||||
|
/// operators to reboot (2026-07 field report: one stream per boot, forever).
|
||||||
|
#[test]
|
||||||
|
fn invalid_version_after_a_session_blames_process_state_not_the_driver() {
|
||||||
|
let msg = invalid_version(true);
|
||||||
|
assert!(msg.contains("NOT a driver version mismatch"), "{msg}");
|
||||||
|
assert!(msg.contains("restart the Punktfunk host service"), "{msg}");
|
||||||
|
assert!(
|
||||||
|
!msg.contains("older than this build's NVENC headers"),
|
||||||
|
"must not repeat the version-skew advice: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!msg.contains("Update the NVIDIA driver"),
|
||||||
|
"must not tell the operator to update a driver that just worked: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latch is one-way and only touches this status.
|
||||||
|
#[test]
|
||||||
|
fn note_session_opened_latches() {
|
||||||
|
note_session_opened();
|
||||||
|
assert!(SESSION_OPENED.load(Ordering::Relaxed));
|
||||||
|
note_session_opened();
|
||||||
|
assert!(SESSION_OPENED.load(Ordering::Relaxed));
|
||||||
|
assert_eq!(
|
||||||
|
explain(nv::NVENCSTATUS::NV_ENC_ERR_OUT_OF_MEMORY),
|
||||||
|
"the GPU is out of memory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy-failure classification: session-gone statuses refund; everything ambiguous parks.
|
||||||
|
/// The split is the load-bearing part of the session-budget accounting — a wrong `true`
|
||||||
|
/// under-counts (over-admits parallel displays), a wrong `false` merely defers the refund to
|
||||||
|
/// a reap retry.
|
||||||
|
#[test]
|
||||||
|
fn destroy_classification_refunds_only_on_proof() {
|
||||||
|
for gone in [
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_DEVICE_NOT_EXIST,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_ENCODERDEVICE,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PTR,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_NOT_INITIALIZED,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
destroy_proves_no_session(gone),
|
||||||
|
"{gone:?} proves no session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for ambiguous in [
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_GENERIC,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_BUSY,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_OUT_OF_MEMORY,
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM,
|
||||||
|
// INVALID_DEVICE sounds like the gone class but is also what a transiently-confused
|
||||||
|
// driver returns — deliberately fail-closed (park + retry), not refunded.
|
||||||
|
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_DEVICE,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!destroy_proves_no_session(ambiguous),
|
||||||
|
"{ambiguous:?} must park fail-closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,17 @@ mod tests {
|
|||||||
assert!(block_count_32x32(3840, 2160, true) <= u16::MAX as u32);
|
assert!(block_count_32x32(3840, 2160, true) <= u16::MAX as u32);
|
||||||
assert!(block_count_32x32(7680, 4320, true) > u16::MAX as u32);
|
assert!(block_count_32x32(7680, 4320, true) > u16::MAX as u32);
|
||||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||||
|
// …and 4:2:0 wraps it too, just later — the hole the old 4:4:4-only open guard left.
|
||||||
|
// `Codec::max_dimension()` allows PyroWave 8192px per axis, so these modes were
|
||||||
|
// reachable from a client-requested `mode=WxHxFPS`, and the negotiator's 4:4:4 → 4:2:0
|
||||||
|
// downgrade routed oversized modes straight into the unguarded branch.
|
||||||
|
// `validate_dimensions` now rejects them against this 4:2:0 count.
|
||||||
|
assert_eq!(block_count_32x32(8192, 6144, false), 73728);
|
||||||
|
assert_eq!(block_count_32x32(8192, 8192, false), 98304);
|
||||||
|
assert!(block_count_32x32(8192, 6144, false) > u16::MAX as u32);
|
||||||
|
assert!(block_count_32x32(8192, 8192, false) > u16::MAX as u32);
|
||||||
|
// The largest 4:2:0 mode that still fits, for the boundary the validator enforces.
|
||||||
|
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! The slot-family RFI (reference-frame invalidation) recovery **policy**, shared by the three
|
||||||
|
//! backends that answer a loss with a re-reference to a known-good older frame instead of an IDR:
|
||||||
|
//! native AMF (user-LTR bitfield), native QSV (`mfxExtRefListCtrl` LTR) and Vulkan Video (the
|
||||||
|
//! app-owned DPB slot table). One decision, three mechanisms — the policy lived as three
|
||||||
|
//! hand-copies and had already diverged once (the fecbec2d taint sweep reached AMF/QSV a commit
|
||||||
|
//! before the Vulkan backend was carved out, and Vulkan shipped without it until a later fix).
|
||||||
|
//! The NVENC twins' *range* policy is the other half of WP7.2, in
|
||||||
|
//! [`super::nvenc_core::plan_range_recovery`].
|
||||||
|
//!
|
||||||
|
//! Policy only. Every mechanism — how a force is applied, how distrust is *persisted* (AMF clears
|
||||||
|
//! its mirror slot to `None`, QSV sets a separate `ltr_tainted` flag because its mirror must keep
|
||||||
|
//! naming the slot for the RejectedRefList, Vulkan blanks `slot_wire` to `-1` while `slot_poc`
|
||||||
|
//! keeps the picture resident for the RPS) — stays in its backend. Callers feed their
|
||||||
|
//! **currently-trusted** references and apply the returned taints through their own marker; that
|
||||||
|
//! caller-side filter is exactly what makes the three persistence schemes equivalent under one
|
||||||
|
//! pure function.
|
||||||
|
//!
|
||||||
|
//! The decline arm is also the backend's: AMF/QSV clear an un-consumed `pending_force` (the sweep
|
||||||
|
//! may have emptied the slot it points at), while Vulkan deliberately leaves `pending_loss` armed
|
||||||
|
//! (a stale arm is re-resolved at frame-build, where a failed re-pick forces the IDR that heals
|
||||||
|
//! the stream). Do not harmonize them here.
|
||||||
|
|
||||||
|
/// One loss event's recovery decision over a slot table: which trusted references become
|
||||||
|
/// untrustworthy, and which one anchors the recovery.
|
||||||
|
pub(super) struct SlotPlan {
|
||||||
|
/// Bitmask of slots whose reference was encoded **at or after** the loss start — inside the
|
||||||
|
/// client's corrupt window, so the client either never received it or decoded it against a
|
||||||
|
/// broken chain. Serving one as "known-good" on a LATER loss ships corruption tagged as the
|
||||||
|
/// recovery anchor; the backend must record the distrust in its own persistent marker, because
|
||||||
|
/// these wires would otherwise look like valid pre-loss anchors to the next loss event.
|
||||||
|
pub(super) tainted: u32,
|
||||||
|
/// The recovery anchor: the newest trusted reference **strictly older** than the loss —
|
||||||
|
/// `(slot, wire)` — i.e. the most recent picture the client still holds intact, so
|
||||||
|
/// re-referencing it costs the smallest residual. `None`: every candidate is inside or after
|
||||||
|
/// the corrupt window — the caller declines and its (coalesced) keyframe path recovers.
|
||||||
|
pub(super) anchor: Option<(usize, i64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan the recovery for a loss starting at wire frame `loss_first`, over the backend's
|
||||||
|
/// currently-trusted references (`(slot, wire)`; previously-distrusted entries must already be
|
||||||
|
/// filtered out by the caller — see the module doc). Sweep and pick are one call so the anchor is
|
||||||
|
/// chosen from the same snapshot the taints are computed from, by construction: the anchor
|
||||||
|
/// delegates to [`pick_anchor`], and `wire >= loss_first` (taint) and `wire < loss_first`
|
||||||
|
/// (anchor) are disjoint, so a slot tainted by this call can never be this call's anchor.
|
||||||
|
pub(super) fn plan_slot_recovery(refs: &[(usize, i64)], loss_first: i64) -> SlotPlan {
|
||||||
|
// The callers' validity gate (`first < 0 → decline`) is what makes their sentinel filters
|
||||||
|
// (-1 / `None`) exact views of "trusted"; this assert keeps the contract visible from inside
|
||||||
|
// the extracted code. Plain assert: the lint legs run --release, and a compiled-out check
|
||||||
|
// here would silently drop taints instead of failing loudly.
|
||||||
|
assert!(
|
||||||
|
loss_first >= 0,
|
||||||
|
"loss_first must be validity-gated by the caller"
|
||||||
|
);
|
||||||
|
let mut tainted = 0u32;
|
||||||
|
for &(slot, wire) in refs {
|
||||||
|
if wire >= loss_first {
|
||||||
|
assert!(slot < 32, "slot table exceeds the u32 taint mask");
|
||||||
|
tainted |= 1 << slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SlotPlan {
|
||||||
|
tainted,
|
||||||
|
anchor: pick_anchor(refs, loss_first),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pick half alone: newest trusted reference strictly older than the loss. Ties break to the
|
||||||
|
/// first entry in `refs` (every caller feeds ascending slot order, so the lowest slot wins —
|
||||||
|
/// the strict `>` all three backends used). Standalone because Vulkan re-picks at frame-build
|
||||||
|
/// time: its arm carries the loss start, not the slot, so the slot is resolved against the table
|
||||||
|
/// as it stands when the recovery frame is actually encoded.
|
||||||
|
pub(super) fn pick_anchor(refs: &[(usize, i64)], loss_first: i64) -> Option<(usize, i64)> {
|
||||||
|
let mut best: Option<(usize, i64)> = None;
|
||||||
|
for &(slot, wire) in refs {
|
||||||
|
if wire < loss_first && best.is_none_or(|(_, b)| wire > b) {
|
||||||
|
best = Some((slot, wire));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{pick_anchor, plan_slot_recovery};
|
||||||
|
|
||||||
|
/// Adapt a raw slot table (the Vulkan `slot_wire` shape: `-1` = empty) into the trusted view
|
||||||
|
/// the policy takes — the same filter the backend adapters apply.
|
||||||
|
fn view(wires: &[i64]) -> Vec<(usize, i64)> {
|
||||||
|
wires
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(s, &w)| (w >= 0).then_some((s, w)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a plan's taints the way the Vulkan adapter does (blank the wire) — the persistence
|
||||||
|
/// half the pure fn hands back to the caller.
|
||||||
|
fn apply(wires: &mut [i64], tainted: u32) {
|
||||||
|
for (s, w) in wires.iter_mut().enumerate() {
|
||||||
|
if tainted & (1 << s) != 0 {
|
||||||
|
*w = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
|
||||||
|
/// slots never qualify. (Migrated 1:1 from `vulkan_video.rs`'s `pick_recovery_slot` tests —
|
||||||
|
/// same vectors, now `(slot, wire)`-valued.)
|
||||||
|
#[test]
|
||||||
|
fn picks_newest_pre_loss() {
|
||||||
|
// slots hold wires 5..12 (ring position arbitrary); loss starts at 9 → anchor = wire 8.
|
||||||
|
let wires = [8i64, 9, 10, 11, 12, 5, 6, 7];
|
||||||
|
assert_eq!(pick_anchor(&view(&wires), 9), Some((0, 8)));
|
||||||
|
// loss older than everything resident → no anchor (caller keyframes).
|
||||||
|
assert_eq!(pick_anchor(&view(&wires), 5), None);
|
||||||
|
// empty slots (-1) are skipped by the view filter and never anchor.
|
||||||
|
assert_eq!(pick_anchor(&view(&[-1, 3, -1, 4]), 5), Some((3, 4)));
|
||||||
|
assert_eq!(pick_anchor(&view(&[-1; 8]), 5), None);
|
||||||
|
// wire == loss_first is INSIDE the corrupt window: strictly-older only.
|
||||||
|
assert_eq!(pick_anchor(&view(&[9, 8]), 9), Some((1, 8)));
|
||||||
|
// tie on the wire → first entry (= lowest slot) wins, the strict `>` all three backends
|
||||||
|
// used.
|
||||||
|
assert_eq!(pick_anchor(&[(2, 7), (5, 7)], 9), Some((2, 7)));
|
||||||
|
// empty view.
|
||||||
|
assert_eq!(pick_anchor(&[], 9), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The taint sweep (fecbec2d's fix): a slot encoded inside an EARLIER, still unrepaired loss
|
||||||
|
/// window must not become the "known-good" anchor of a LATER loss. Without persisted
|
||||||
|
/// distrust the picker accepts it — it is resident and its wire is below the second loss
|
||||||
|
/// start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto a
|
||||||
|
/// reference it never decoded. (Migrated 1:1 from `vulkan_video.rs`; the hand-replicated
|
||||||
|
/// sweep is now `plan_slot_recovery` itself.)
|
||||||
|
#[test]
|
||||||
|
fn taint_sweep_excludes_slots_from_an_earlier_loss() {
|
||||||
|
// Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the
|
||||||
|
// client. A second loss report arrives at wire 6 while they are all still resident.
|
||||||
|
let tainted_wires = [4i64, 5, 6, 7];
|
||||||
|
|
||||||
|
// WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside
|
||||||
|
// loss 1's unrepaired window — and it would be served as the "known-good" anchor.
|
||||||
|
let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7];
|
||||||
|
let (_, picked_wire) = pick_anchor(&view(&unswept), 6).expect("unswept picks something");
|
||||||
|
assert!(
|
||||||
|
tainted_wires.contains(&picked_wire),
|
||||||
|
"precondition: without the sweep the anchor comes from the earlier loss window"
|
||||||
|
);
|
||||||
|
|
||||||
|
// WITH the plan, loss 1 taints 4..7 (and anchors on wire 3 — never a tainted wire, by
|
||||||
|
// the disjoint predicates), so loss 2 can only reach genuinely clean wires.
|
||||||
|
let mut wires = unswept;
|
||||||
|
let plan = plan_slot_recovery(&view(&wires), 4);
|
||||||
|
assert_eq!(plan.tainted, 0b1111_0000);
|
||||||
|
assert_eq!(plan.anchor, Some((3, 3)));
|
||||||
|
apply(&mut wires, plan.tainted);
|
||||||
|
assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]);
|
||||||
|
let (slot, wire) = pick_anchor(&view(&wires), 6).expect("clean wires remain");
|
||||||
|
assert_eq!((slot, wire), (3, 3), "newest clean survivor is wire 3");
|
||||||
|
|
||||||
|
// Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A
|
||||||
|
// later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject.
|
||||||
|
wires[4] = 8;
|
||||||
|
wires[5] = 9;
|
||||||
|
wires[6] = 10;
|
||||||
|
wires[7] = 11;
|
||||||
|
let plan = plan_slot_recovery(&view(&wires), 10);
|
||||||
|
assert_eq!(plan.anchor, Some((5, 9)), "wire 9 is post-recovery, clean");
|
||||||
|
apply(&mut wires, plan.tainted);
|
||||||
|
|
||||||
|
// A loss covering every live wire leaves nothing clean → decline, caller serves an IDR.
|
||||||
|
let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12];
|
||||||
|
let plan = plan_slot_recovery(&view(&all), 5);
|
||||||
|
assert_eq!(plan.tainted, 0b1111_1111);
|
||||||
|
assert_eq!(plan.anchor, None);
|
||||||
|
apply(&mut all, plan.tainted);
|
||||||
|
assert_eq!(pick_anchor(&view(&all), 5), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,24 @@ pub struct OpenH264Encoder {
|
|||||||
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
|
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
|
||||||
unsafe impl Send for OpenH264Encoder {}
|
unsafe impl Send for OpenH264Encoder {}
|
||||||
|
|
||||||
|
/// openh264's own ceiling: level 5.2, so 3840x2160 landscape or 2160x3840 portrait.
|
||||||
|
///
|
||||||
|
/// The long edge may reach 3840 and the short edge 2160 — the rule is orientation-aware, not a
|
||||||
|
/// per-axis `w <= 3840 && h <= 2160`, so a portrait 2160x3840 session is legal.
|
||||||
|
const OPENH264_MAX_LONG_EDGE: u32 = 3840;
|
||||||
|
const OPENH264_MAX_SHORT_EDGE: u32 = 2160;
|
||||||
|
|
||||||
|
/// Whether the bundled openh264 can encode this resolution at all.
|
||||||
|
///
|
||||||
|
/// Mirrors the check inside the crate we ship (openh264 0.9.3, `encoder.rs` `reinit`). That check
|
||||||
|
/// runs on the FIRST ENCODE, not at encoder construction — so without this gate a too-large mode
|
||||||
|
/// opens perfectly and then fails *every* submit, and the session connects and never delivers a
|
||||||
|
/// frame. `Codec::max_dimension` does not cover it: it is keyed on the codec, and H.264 legitimately
|
||||||
|
/// reaches 4096 on every hardware backend — this ceiling belongs to the software backend alone.
|
||||||
|
fn openh264_supports_dimensions(width: u32, height: u32) -> bool {
|
||||||
|
width.max(height) <= OPENH264_MAX_LONG_EDGE && width.min(height) <= OPENH264_MAX_SHORT_EDGE
|
||||||
|
}
|
||||||
|
|
||||||
impl OpenH264Encoder {
|
impl OpenH264Encoder {
|
||||||
pub fn open(
|
pub fn open(
|
||||||
format: PixelFormat,
|
format: PixelFormat,
|
||||||
@@ -59,7 +77,16 @@ impl OpenH264Encoder {
|
|||||||
fps: u32,
|
fps: u32,
|
||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// validate_dimensions() ran in open_video: even, non-zero, <= 4096.
|
// validate_dimensions() ran in open_video: even, non-zero, <= 4096. That leaves modes this
|
||||||
|
// encoder cannot serve (e.g. a legal 4096-wide H.264 mode), so refuse them here — at the
|
||||||
|
// open, where the caller still gets a real error — rather than at every submit.
|
||||||
|
ensure!(
|
||||||
|
openh264_supports_dimensions(width, height),
|
||||||
|
"openh264 cannot encode {width}x{height}: the software encoder tops out at \
|
||||||
|
{OPENH264_MAX_LONG_EDGE}x{OPENH264_MAX_SHORT_EDGE} (or \
|
||||||
|
{OPENH264_MAX_SHORT_EDGE}x{OPENH264_MAX_LONG_EDGE} portrait) — lower the client \
|
||||||
|
resolution, or use a host with a hardware encoder"
|
||||||
|
);
|
||||||
let bps: u32 = bitrate_bps.try_into().unwrap_or(u32::MAX);
|
let bps: u32 = bitrate_bps.try_into().unwrap_or(u32::MAX);
|
||||||
let cfg = EncoderConfig::new()
|
let cfg = EncoderConfig::new()
|
||||||
.usage_type(UsageType::ScreenContentRealTime)
|
.usage_type(UsageType::ScreenContentRealTime)
|
||||||
@@ -336,4 +363,40 @@ mod tests {
|
|||||||
.any(|w| w[0] == 0 && w[1] == 0 && w[2] == 0 && w[3] == 1 && (w[4] & 0x1f) == 7);
|
.any(|w| w[0] == 0 && w[1] == 0 && w[2] == 0 && w[3] == 1 && (w[4] & 0x1f) == 7);
|
||||||
assert!(has_sps, "IDR must carry an SPS NAL (type 7)");
|
assert!(has_sps, "IDR must carry an SPS NAL (type 7)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The modes the software encoder can actually serve — including the portrait orientation,
|
||||||
|
/// which a naive per-axis `w <= 3840 && h <= 2160` would wrongly reject.
|
||||||
|
#[test]
|
||||||
|
fn openh264_accepts_up_to_4k_in_either_orientation() {
|
||||||
|
assert!(openh264_supports_dimensions(1920, 1080));
|
||||||
|
assert!(openh264_supports_dimensions(3840, 2160));
|
||||||
|
assert!(openh264_supports_dimensions(2160, 3840));
|
||||||
|
assert!(openh264_supports_dimensions(1080, 1920));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modes `validate_dimensions` lets through (H.264 legitimately reaches 4096 on hardware) but
|
||||||
|
/// openh264 rejects on the first encode. Catching them at open is the whole point of the gate:
|
||||||
|
/// otherwise the session opens and then fails every single submit.
|
||||||
|
#[test]
|
||||||
|
fn openh264_rejects_modes_that_would_fail_on_first_submit() {
|
||||||
|
// 4096-wide is legal H.264 and passes `Codec::max_dimension`, but exceeds the long edge.
|
||||||
|
assert!(!openh264_supports_dimensions(4096, 2160));
|
||||||
|
assert!(!openh264_supports_dimensions(2160, 4096));
|
||||||
|
// Long edge is fine, short edge is not (e.g. an ultrawide-tall composite desktop).
|
||||||
|
assert!(!openh264_supports_dimensions(3840, 2400));
|
||||||
|
assert!(!openh264_supports_dimensions(2400, 3840));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A too-large mode must fail at `open`, not silently at every `submit`.
|
||||||
|
#[test]
|
||||||
|
fn open_refuses_a_mode_openh264_cannot_encode() {
|
||||||
|
// Matched rather than `expect_err`: `OpenH264Encoder` is not `Debug` (it wraps a raw C
|
||||||
|
// handle), so `expect_err` would not compile.
|
||||||
|
let err = match OpenH264Encoder::open(PixelFormat::Bgra, 4096, 2160, 60, 20_000_000) {
|
||||||
|
Ok(_) => panic!("4096x2160 exceeds openh264's long-edge ceiling and must be refused"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(msg.contains("openh264 cannot encode 4096x2160"), "{msg}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,425 +66,11 @@ use windows::Win32::Storage::FileSystem::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// The hand-mirrored AMF C ABI lives in `amf_sys.rs` (WP7.4): pure FFI surface, no policy —
|
||||||
// Mirrored AMF C ABI (written against GPUOpen header release v1.4.36 — amf/public/include; every
|
// isolating the unsafe vtable mirror from the encoder logic is the crate's stated review
|
||||||
// slot below is a base-interface slot whose layout is stable since <= v1.4.34, the loader's
|
// goal, and the `#[path]` keeps the module name (`sys::`) and every call site unchanged.
|
||||||
// accepted ABI floor, so the mirror is valid on every runtime the loader admits).
|
#[path = "amf_sys.rs"]
|
||||||
//
|
mod sys;
|
||||||
// Layout rules this mirror relies on: every AMF interface is a struct whose sole member is a
|
|
||||||
// pointer to a C vtable; derived interfaces PREPEND their base's slots in order (AMFInterface →
|
|
||||||
// AMFPropertyStorage → AMFData → AMFBuffer/AMFSurface), so a derived pointer is usable through a
|
|
||||||
// base vtable mirror. Slots we never call are declared as bare `*const c_void` placeholders —
|
|
||||||
// same size/alignment as the function pointer they stand in for. `AMF_STD_CALL` is `__stdcall`
|
|
||||||
// (Rust `extern "system"`); the two DLL entry points are `__cdecl` (`extern "C"`); on x86_64
|
|
||||||
// both collapse to the one Windows calling convention.
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
mod sys {
|
|
||||||
use std::ffi::c_void;
|
|
||||||
|
|
||||||
/// `AMF_RESULT` (core/Result.h) — a plain C enum, sequential from 0. Only the codes this
|
|
||||||
/// module branches on are named; everything else is reported numerically via [`result_name`].
|
|
||||||
pub type AmfResult = i32;
|
|
||||||
pub const AMF_OK: AmfResult = 0;
|
|
||||||
pub const AMF_EOF: AmfResult = 23;
|
|
||||||
pub const AMF_REPEAT: AmfResult = 24;
|
|
||||||
pub const AMF_INPUT_FULL: AmfResult = 25;
|
|
||||||
pub const AMF_NEED_MORE_INPUT: AmfResult = 44;
|
|
||||||
|
|
||||||
/// Human-readable name for an `AMF_RESULT` (diagnostics only — the numeric value rides along
|
|
||||||
/// so an unnamed code is still identifiable against Result.h).
|
|
||||||
pub fn result_name(r: AmfResult) -> &'static str {
|
|
||||||
match r {
|
|
||||||
0 => "AMF_OK",
|
|
||||||
1 => "AMF_FAIL",
|
|
||||||
2 => "AMF_UNEXPECTED",
|
|
||||||
3 => "AMF_ACCESS_DENIED",
|
|
||||||
4 => "AMF_INVALID_ARG",
|
|
||||||
5 => "AMF_OUT_OF_RANGE",
|
|
||||||
6 => "AMF_OUT_OF_MEMORY",
|
|
||||||
7 => "AMF_INVALID_POINTER",
|
|
||||||
8 => "AMF_NO_INTERFACE",
|
|
||||||
9 => "AMF_NOT_IMPLEMENTED",
|
|
||||||
10 => "AMF_NOT_SUPPORTED",
|
|
||||||
11 => "AMF_NOT_FOUND",
|
|
||||||
12 => "AMF_ALREADY_INITIALIZED",
|
|
||||||
13 => "AMF_NOT_INITIALIZED",
|
|
||||||
14 => "AMF_INVALID_FORMAT",
|
|
||||||
15 => "AMF_WRONG_STATE",
|
|
||||||
17 => "AMF_NO_DEVICE",
|
|
||||||
18 => "AMF_DIRECTX_FAILED",
|
|
||||||
23 => "AMF_EOF",
|
|
||||||
24 => "AMF_REPEAT",
|
|
||||||
25 => "AMF_INPUT_FULL",
|
|
||||||
26 => "AMF_RESOLUTION_CHANGED",
|
|
||||||
28 => "AMF_INVALID_DATA_TYPE",
|
|
||||||
29 => "AMF_INVALID_RESOLUTION",
|
|
||||||
30 => "AMF_CODEC_NOT_SUPPORTED",
|
|
||||||
31 => "AMF_SURFACE_FORMAT_NOT_SUPPORTED",
|
|
||||||
32 => "AMF_SURFACE_MUST_BE_SHARED",
|
|
||||||
36 => "AMF_ENCODER_NOT_PRESENT",
|
|
||||||
44 => "AMF_NEED_MORE_INPUT",
|
|
||||||
_ => "AMF_<unnamed>",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The AMF header release this FFI mirror was written against: `AMF_FULL_VERSION` for 1.4.36.0
|
|
||||||
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). This is the version claimed to `AMFInit` — but
|
|
||||||
/// capped at the runtime's own reported version (see `load_factory`), so an older-but-accepted
|
|
||||||
/// runtime is asked only for the ABI it actually provides.
|
|
||||||
pub const AMF_HEADER_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
|
|
||||||
|
|
||||||
/// The oldest AMF runtime the loader accepts (`AMF_FULL_VERSION` 1.4.34.0 — AMD Adrenalin
|
|
||||||
/// 24.6.1). This is an **ABI floor, not a feature floor**: every vtable slot mirrored in this
|
|
||||||
/// module belongs to a base interface (`AMFFactory`/`AMFContext`/`AMFComponent`/`AMFData`/
|
|
||||||
/// `AMFBuffer`) whose layout has been stable — append-only, no mid-vtable insertions — since
|
|
||||||
/// well before 1.4.34, so a 1.4.34 runtime is guaranteed to expose every mirrored slot at its
|
|
||||||
/// mirrored offset. Everything 1.4.35/1.4.36 added that this path can touch (new HQ presets,
|
|
||||||
/// AV1 B-frame / picture management) is a *string-keyed encoder property*, applied through
|
|
||||||
/// [`set_prop`] with `required=false` — a runtime that lacks it rejects the property (logged)
|
|
||||||
/// and the feature degrades, rather than shifting any vtable offset. Below this floor the
|
|
||||||
/// mirror is not guaranteed to match, so the loader declines cleanly (an old-driver decline,
|
|
||||||
/// never UB).
|
|
||||||
pub const AMF_MIN_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (34u64 << 16);
|
|
||||||
|
|
||||||
/// `AMF_SURFACE_FORMAT` (core/Surface.h).
|
|
||||||
pub const AMF_SURFACE_NV12: i32 = 1;
|
|
||||||
pub const AMF_SURFACE_P010: i32 = 10;
|
|
||||||
|
|
||||||
/// `AMF_DX_VERSION::AMF_DX11_1` (core/Data.h) — the `InitDX11` version argument.
|
|
||||||
pub const AMF_DX11_1: i32 = 111;
|
|
||||||
|
|
||||||
/// `AMF_MEMORY_TYPE::AMF_MEMORY_HOST` (core/Data.h) — the `AllocBuffer` memory type for the
|
|
||||||
/// CPU-filled HDR-metadata buffer.
|
|
||||||
pub const AMF_MEMORY_HOST: i32 = 1;
|
|
||||||
|
|
||||||
/// `AMFHDRMetadata` (components/ColorSpace.h) — the payload of the `*InHDRMetadata` encoder
|
|
||||||
/// property (an `AMFBuffer` holding exactly this struct). Same units as the HEVC ST.2086 SEI
|
|
||||||
/// and [`punktfunk_core::quic::HdrMeta`]: chromaticities in 1/50000, mastering luminance in
|
|
||||||
/// 0.0001 cd/m², CLL/FALL in nits. 28 bytes, no padding.
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfHdrMetadata {
|
|
||||||
pub red_primary: [u16; 2],
|
|
||||||
pub green_primary: [u16; 2],
|
|
||||||
pub blue_primary: [u16; 2],
|
|
||||||
pub white_point: [u16; 2],
|
|
||||||
pub max_mastering_luminance: u32,
|
|
||||||
pub min_mastering_luminance: u32,
|
|
||||||
pub max_content_light_level: u16,
|
|
||||||
pub max_frame_average_light_level: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `AMFGuid` (core/Platform.h) — data41..data48 flattened into an array (identical layout).
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfGuid {
|
|
||||||
pub data1: u32,
|
|
||||||
pub data2: u16,
|
|
||||||
pub data3: u16,
|
|
||||||
pub data4: [u8; 8],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `IID_AMFBuffer` (core/Buffer.h `AMF_DECLARE_IID`) — for `QueryInterface` on the encoder's
|
|
||||||
/// output `AMFData` to reach `GetNative`/`GetSize`.
|
|
||||||
pub const IID_AMF_BUFFER: AmfGuid = AmfGuid {
|
|
||||||
data1: 0xb04b_7248,
|
|
||||||
data2: 0xb6f0,
|
|
||||||
data3: 0x4321,
|
|
||||||
data4: [0xb6, 0x91, 0xba, 0xa4, 0x74, 0x0f, 0x9f, 0xcb],
|
|
||||||
};
|
|
||||||
|
|
||||||
// `AMF_VARIANT_TYPE` (core/Variant.h) — the tags this module writes/reads.
|
|
||||||
pub const AMF_VARIANT_BOOL: i32 = 1;
|
|
||||||
pub const AMF_VARIANT_INT64: i32 = 2;
|
|
||||||
pub const AMF_VARIANT_RATE: i32 = 7;
|
|
||||||
pub const AMF_VARIANT_INTERFACE: i32 = 12;
|
|
||||||
|
|
||||||
/// `AMFVariantStruct` (core/Variant.h): a 4-byte C-enum tag + a 16-byte union whose largest
|
|
||||||
/// members are pointer/`amf_int64`/`AMFFloatVector4D` (align 8) → 24 bytes total, tag at 0,
|
|
||||||
/// payload at 8. Passed BY VALUE to `SetProperty` (Win64 passes >8-byte aggregates by hidden
|
|
||||||
/// reference on both sides, so declaring it by value matches the C compiler). The payload is
|
|
||||||
/// stored as two fully-initialised `u64`s — little-endian packing puts a bool in byte 0, an
|
|
||||||
/// `amf_int64` in word 0, and an `AMFRate{num,den}` as `num | den << 32`, exactly the union's
|
|
||||||
/// in-memory layout — so no partially-initialised union bytes ever cross the FFI.
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfVariant {
|
|
||||||
pub vtype: i32,
|
|
||||||
pub payload: [u64; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AmfVariant {
|
|
||||||
pub fn zeroed() -> Self {
|
|
||||||
AmfVariant {
|
|
||||||
vtype: 0, // AMF_VARIANT_EMPTY
|
|
||||||
payload: [0, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn from_i64(v: i64) -> Self {
|
|
||||||
AmfVariant {
|
|
||||||
vtype: AMF_VARIANT_INT64,
|
|
||||||
payload: [v as u64, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn from_bool(v: bool) -> Self {
|
|
||||||
AmfVariant {
|
|
||||||
vtype: AMF_VARIANT_BOOL,
|
|
||||||
payload: [v as u64, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// `AMFRate { num, den }` — two little-endian `amf_uint32`s in the union's first 8 bytes.
|
|
||||||
pub fn from_rate(num: u32, den: u32) -> Self {
|
|
||||||
AmfVariant {
|
|
||||||
vtype: AMF_VARIANT_RATE,
|
|
||||||
payload: [num as u64 | ((den as u64) << 32), 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// An `AMFInterface*` payload (`pInterface` in the union's first 8 bytes). The property
|
|
||||||
/// storage AddRefs the interface when it copies the variant in (the C++ template
|
|
||||||
/// `SetProperty(name, AMFVariant(value))` passes a temporary whose destructor releases,
|
|
||||||
/// so `SetProperty` must take its own reference) — the caller keeps sole ownership of the
|
|
||||||
/// reference it already holds.
|
|
||||||
pub fn from_interface(p: *mut c_void) -> Self {
|
|
||||||
AmfVariant {
|
|
||||||
vtype: AMF_VARIANT_INTERFACE,
|
|
||||||
payload: [p as usize as u64, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Read back an `amf_int64` payload (only valid when `vtype == AMF_VARIANT_INT64`).
|
|
||||||
pub fn as_i64(&self) -> Option<i64> {
|
|
||||||
(self.vtype == AMF_VARIANT_INT64).then_some(self.payload[0] as i64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Placeholder for a vtable slot this module never calls — same size/align as the function
|
|
||||||
/// pointer it stands in for, present only to keep the following slots at their C offsets.
|
|
||||||
pub type Slot = *const c_void;
|
|
||||||
|
|
||||||
// -- AMFFactory (core/Factory.h; NOT refcounted — a process singleton) ----------------------
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfFactory {
|
|
||||||
pub vtbl: *const AmfFactoryVtbl,
|
|
||||||
}
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfFactoryVtbl {
|
|
||||||
pub create_context:
|
|
||||||
unsafe extern "system" fn(*mut AmfFactory, *mut *mut AmfContext) -> AmfResult,
|
|
||||||
pub create_component: unsafe extern "system" fn(
|
|
||||||
*mut AmfFactory,
|
|
||||||
*mut AmfContext,
|
|
||||||
*const u16,
|
|
||||||
*mut *mut AmfComponent,
|
|
||||||
) -> AmfResult,
|
|
||||||
pub set_cache_folder: Slot,
|
|
||||||
pub get_cache_folder: Slot,
|
|
||||||
pub get_debug: Slot,
|
|
||||||
pub get_trace: Slot,
|
|
||||||
pub get_programs: Slot,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- AMFContext (core/Context.h) ------------------------------------------------------------
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfContext {
|
|
||||||
pub vtbl: *const AmfContextVtbl,
|
|
||||||
}
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfContextVtbl {
|
|
||||||
// AMFInterface
|
|
||||||
pub acquire: Slot,
|
|
||||||
pub release: unsafe extern "system" fn(*mut AmfContext) -> i32,
|
|
||||||
pub query_interface: Slot,
|
|
||||||
// AMFPropertyStorage
|
|
||||||
pub set_property: Slot,
|
|
||||||
pub get_property: Slot,
|
|
||||||
pub has_property: Slot,
|
|
||||||
pub get_property_count: Slot,
|
|
||||||
pub get_property_at: Slot,
|
|
||||||
pub clear: Slot,
|
|
||||||
pub add_to: Slot,
|
|
||||||
pub copy_to: Slot,
|
|
||||||
pub add_observer: Slot,
|
|
||||||
pub remove_observer: Slot,
|
|
||||||
// AMFContext
|
|
||||||
pub terminate: unsafe extern "system" fn(*mut AmfContext) -> AmfResult,
|
|
||||||
pub init_dx9: Slot,
|
|
||||||
pub get_dx9_device: Slot,
|
|
||||||
pub lock_dx9: Slot,
|
|
||||||
pub unlock_dx9: Slot,
|
|
||||||
pub init_dx11: unsafe extern "system" fn(*mut AmfContext, *mut c_void, i32) -> AmfResult,
|
|
||||||
pub get_dx11_device: Slot,
|
|
||||||
pub lock_dx11: Slot,
|
|
||||||
pub unlock_dx11: Slot,
|
|
||||||
pub init_opencl: Slot,
|
|
||||||
pub get_opencl_context: Slot,
|
|
||||||
pub get_opencl_command_queue: Slot,
|
|
||||||
pub get_opencl_device_id: Slot,
|
|
||||||
pub get_opencl_compute_factory: Slot,
|
|
||||||
pub init_opencl_ex: Slot,
|
|
||||||
pub lock_opencl: Slot,
|
|
||||||
pub unlock_opencl: Slot,
|
|
||||||
pub init_opengl: Slot,
|
|
||||||
pub get_opengl_context: Slot,
|
|
||||||
pub get_opengl_drawable: Slot,
|
|
||||||
pub lock_opengl: Slot,
|
|
||||||
pub unlock_opengl: Slot,
|
|
||||||
pub init_xv: Slot,
|
|
||||||
pub get_xv_device: Slot,
|
|
||||||
pub lock_xv: Slot,
|
|
||||||
pub unlock_xv: Slot,
|
|
||||||
pub init_gralloc: Slot,
|
|
||||||
pub get_gralloc_device: Slot,
|
|
||||||
pub lock_gralloc: Slot,
|
|
||||||
pub unlock_gralloc: Slot,
|
|
||||||
pub alloc_buffer: unsafe extern "system" fn(
|
|
||||||
*mut AmfContext,
|
|
||||||
i32, // AMF_MEMORY_TYPE
|
|
||||||
usize,
|
|
||||||
*mut *mut AmfBuffer,
|
|
||||||
) -> AmfResult,
|
|
||||||
pub alloc_surface: Slot,
|
|
||||||
pub alloc_audio_buffer: Slot,
|
|
||||||
pub create_buffer_from_host_native: Slot,
|
|
||||||
pub create_surface_from_host_native: Slot,
|
|
||||||
pub create_surface_from_dx9_native: Slot,
|
|
||||||
/// Out-param is `AMFSurface**` in the header; declared as the `AmfData` base here because
|
|
||||||
/// every surface call this module makes (`SetPts`, `SetProperty`, `Release`,
|
|
||||||
/// `SubmitInput`) lives in the `AMFData` vtable prefix, which `AMFSurfaceVtbl` reproduces
|
|
||||||
/// slot-for-slot (single inheritance, same object pointer).
|
|
||||||
pub create_surface_from_dx11_native: unsafe extern "system" fn(
|
|
||||||
*mut AmfContext,
|
|
||||||
*mut c_void,
|
|
||||||
*mut *mut AmfData,
|
|
||||||
*mut c_void,
|
|
||||||
) -> AmfResult,
|
|
||||||
pub create_surface_from_opengl_native: Slot,
|
|
||||||
pub create_surface_from_gralloc_native: Slot,
|
|
||||||
pub create_surface_from_opencl_native: Slot,
|
|
||||||
pub create_buffer_from_opencl_native: Slot,
|
|
||||||
pub get_compute: Slot,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- AMFComponent (components/Component.h) --------------------------------------------------
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfComponent {
|
|
||||||
pub vtbl: *const AmfComponentVtbl,
|
|
||||||
}
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfComponentVtbl {
|
|
||||||
// AMFInterface
|
|
||||||
pub acquire: Slot,
|
|
||||||
pub release: unsafe extern "system" fn(*mut AmfComponent) -> i32,
|
|
||||||
pub query_interface: Slot,
|
|
||||||
// AMFPropertyStorage
|
|
||||||
pub set_property:
|
|
||||||
unsafe extern "system" fn(*mut AmfComponent, *const u16, AmfVariant) -> AmfResult,
|
|
||||||
pub get_property: Slot,
|
|
||||||
pub has_property: Slot,
|
|
||||||
pub get_property_count: Slot,
|
|
||||||
pub get_property_at: Slot,
|
|
||||||
pub clear: Slot,
|
|
||||||
pub add_to: Slot,
|
|
||||||
pub copy_to: Slot,
|
|
||||||
pub add_observer: Slot,
|
|
||||||
pub remove_observer: Slot,
|
|
||||||
// AMFPropertyStorageEx
|
|
||||||
pub get_properties_info_count: Slot,
|
|
||||||
pub get_property_info_at: Slot,
|
|
||||||
pub get_property_info: Slot,
|
|
||||||
pub validate_property: Slot,
|
|
||||||
// AMFComponent
|
|
||||||
pub init: unsafe extern "system" fn(*mut AmfComponent, i32, i32, i32) -> AmfResult,
|
|
||||||
pub reinit: Slot,
|
|
||||||
pub terminate: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
|
||||||
pub drain: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
|
||||||
pub flush: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
|
||||||
pub submit_input: unsafe extern "system" fn(*mut AmfComponent, *mut AmfData) -> AmfResult,
|
|
||||||
pub query_output:
|
|
||||||
unsafe extern "system" fn(*mut AmfComponent, *mut *mut AmfData) -> AmfResult,
|
|
||||||
pub get_context: Slot,
|
|
||||||
pub set_output_data_allocator_cb: Slot,
|
|
||||||
pub get_caps: Slot,
|
|
||||||
pub optimize: Slot,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- AMFData (core/Data.h) — also the usable prefix of AMFSurface --------------------------
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfData {
|
|
||||||
pub vtbl: *const AmfDataVtbl,
|
|
||||||
}
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfDataVtbl {
|
|
||||||
// AMFInterface
|
|
||||||
pub acquire: Slot,
|
|
||||||
pub release: unsafe extern "system" fn(*mut AmfData) -> i32,
|
|
||||||
pub query_interface:
|
|
||||||
unsafe extern "system" fn(*mut AmfData, *const AmfGuid, *mut *mut c_void) -> AmfResult,
|
|
||||||
// AMFPropertyStorage
|
|
||||||
pub set_property:
|
|
||||||
unsafe extern "system" fn(*mut AmfData, *const u16, AmfVariant) -> AmfResult,
|
|
||||||
pub get_property:
|
|
||||||
unsafe extern "system" fn(*mut AmfData, *const u16, *mut AmfVariant) -> AmfResult,
|
|
||||||
pub has_property: Slot,
|
|
||||||
pub get_property_count: Slot,
|
|
||||||
pub get_property_at: Slot,
|
|
||||||
pub clear: Slot,
|
|
||||||
pub add_to: Slot,
|
|
||||||
pub copy_to: Slot,
|
|
||||||
pub add_observer: Slot,
|
|
||||||
pub remove_observer: Slot,
|
|
||||||
// AMFData
|
|
||||||
pub get_memory_type: Slot,
|
|
||||||
pub duplicate: Slot,
|
|
||||||
pub convert: Slot,
|
|
||||||
pub interop: Slot,
|
|
||||||
pub get_data_type: Slot,
|
|
||||||
pub is_reusable: Slot,
|
|
||||||
pub set_pts: unsafe extern "system" fn(*mut AmfData, i64),
|
|
||||||
pub get_pts: Slot,
|
|
||||||
pub set_duration: Slot,
|
|
||||||
pub get_duration: Slot,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- AMFBuffer (core/Buffer.h) — the encoder's output object -------------------------------
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfBuffer {
|
|
||||||
pub vtbl: *const AmfBufferVtbl,
|
|
||||||
}
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct AmfBufferVtbl {
|
|
||||||
// AMFInterface + AMFPropertyStorage + AMFData prefix (identical order to AmfDataVtbl).
|
|
||||||
pub acquire: Slot,
|
|
||||||
pub release: unsafe extern "system" fn(*mut AmfBuffer) -> i32,
|
|
||||||
pub query_interface: Slot,
|
|
||||||
pub set_property: Slot,
|
|
||||||
pub get_property: Slot,
|
|
||||||
pub has_property: Slot,
|
|
||||||
pub get_property_count: Slot,
|
|
||||||
pub get_property_at: Slot,
|
|
||||||
pub clear: Slot,
|
|
||||||
pub add_to: Slot,
|
|
||||||
pub copy_to: Slot,
|
|
||||||
pub add_observer: Slot,
|
|
||||||
pub remove_observer: Slot,
|
|
||||||
pub get_memory_type: Slot,
|
|
||||||
pub duplicate: Slot,
|
|
||||||
pub convert: Slot,
|
|
||||||
pub interop: Slot,
|
|
||||||
pub get_data_type: Slot,
|
|
||||||
pub is_reusable: Slot,
|
|
||||||
pub set_pts: Slot,
|
|
||||||
pub get_pts: Slot,
|
|
||||||
pub set_duration: Slot,
|
|
||||||
pub get_duration: Slot,
|
|
||||||
// AMFBuffer
|
|
||||||
pub set_size: Slot,
|
|
||||||
pub get_size: unsafe extern "system" fn(*mut AmfBuffer) -> usize,
|
|
||||||
pub get_native: unsafe extern "system" fn(*mut AmfBuffer) -> *mut c_void,
|
|
||||||
pub add_observer_buffer: Slot,
|
|
||||||
pub remove_observer_buffer: Slot,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- DLL entry points (core/Factory.h; AMF_CDECL_CALL) --------------------------------------
|
|
||||||
pub type AmfQueryVersionFn = unsafe extern "C" fn(*mut u64) -> AmfResult;
|
|
||||||
pub type AmfInitFn = unsafe extern "C" fn(u64, *mut *mut AmfFactory) -> AmfResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
use sys::{result_name, AmfVariant};
|
use sys::{result_name, AmfVariant};
|
||||||
|
|
||||||
@@ -1272,7 +858,11 @@ impl AmfEncoder {
|
|||||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||||
bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe");
|
bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe");
|
||||||
}
|
}
|
||||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
// Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
|
||||||
|
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
|
||||||
|
// capture derived `expected = P010`, tripped the format check below and ended the session
|
||||||
|
// at open — on exactly the configuration the capturer had already downgraded on purpose.
|
||||||
|
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||||
// Zero-copy by construction: the input ring is NV12/P010 fed by same-format
|
// Zero-copy by construction: the input ring is NV12/P010 fed by same-format
|
||||||
// CopySubresourceRegion. Any other capture format (Bgra/Rgb10a2 video-processor fallback,
|
// CopySubresourceRegion. Any other capture format (Bgra/Rgb10a2 video-processor fallback,
|
||||||
// CPU frames) has no native input path — and since Phase 3 no ffmpeg readback to degrade
|
// CPU frames) has no native input path — and since Phase 3 no ffmpeg readback to degrade
|
||||||
@@ -2369,31 +1959,27 @@ impl Encoder for AmfEncoder {
|
|||||||
if !self.ltr_active || first < 0 || first > last {
|
if !self.ltr_active || first < 0 || first > last {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
// The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
|
||||||
// encoded inside the client's corrupt window — the client either never received it or
|
// shared with QSV and Vulkan Video); this backend's mechanism is: distrust = clear the
|
||||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
// mirror slot (dropped slots stay dropped; the cadence re-marks a clean frame within
|
||||||
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
|
// ~1/4 s). `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed`
|
||||||
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
// pins `frame_idx` to it per submission), so they compare directly against the client's
|
||||||
for marked in self.ltr_slots.iter_mut() {
|
// `first` — and stay comparable across encoder rebuilds/resets, where an internal counter
|
||||||
if marked.is_some_and(|idx| idx >= first) {
|
// would make the pre-loss check vacuous and risk force-referencing an LTR marked INSIDE
|
||||||
|
// the lost range.
|
||||||
|
let view: Vec<(usize, i64)> = self
|
||||||
|
.ltr_slots
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(s, m)| m.map(|w| (s, w)))
|
||||||
|
.collect();
|
||||||
|
let plan = super::rfi::plan_slot_recovery(&view, first);
|
||||||
|
for (slot, marked) in self.ltr_slots.iter_mut().enumerate() {
|
||||||
|
if plan.tainted & (1 << slot) != 0 {
|
||||||
*marked = None;
|
*marked = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
match plan.anchor {
|
||||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
|
||||||
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
|
||||||
// `frame_idx` to it per submission), so they compare directly against the client's `first`
|
|
||||||
// — and stay comparable across encoder rebuilds/resets, where an internal counter would
|
|
||||||
// make this check vacuous and risk force-referencing an LTR marked INSIDE the lost range.
|
|
||||||
let mut best: Option<(usize, i64)> = None;
|
|
||||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
|
||||||
if let Some(idx) = *marked {
|
|
||||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
|
||||||
best = Some((slot, idx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match best {
|
|
||||||
Some((slot, ltr_frame)) => {
|
Some((slot, ltr_frame)) => {
|
||||||
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
||||||
self.pending_force = Some(slot);
|
self.pending_force = Some(slot);
|
||||||
@@ -2422,6 +2008,8 @@ impl Encoder for AmfEncoder {
|
|||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
|
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||||
|
blends_cursor: false,
|
||||||
// LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a
|
// LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a
|
||||||
// frame, force a later one to re-reference it). True only when the live driver accepted
|
// frame, force a later one to re-reference it). True only when the live driver accepted
|
||||||
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
||||||
@@ -2760,6 +2348,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `p`-quantile of `samples` (µs), sorting in place. `0` when empty.
|
/// The `p`-quantile of `samples` (µs), sorting in place. `0` when empty.
|
||||||
|
/// Gated like its only caller, the `amf-qsv`-only §5.2 latency A/B below — otherwise a
|
||||||
|
/// `--features nvenc,qsv` build compiles this helper with the benchmark cfg'd out and trips
|
||||||
|
/// `dead_code` (which the crate root no longer blanket-allows).
|
||||||
|
#[cfg(feature = "amf-qsv")]
|
||||||
fn percentile(samples: &mut [u128], p: f64) -> u128 {
|
fn percentile(samples: &mut [u128], p: f64) -> u128 {
|
||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -2778,6 +2370,8 @@ mod tests {
|
|||||||
/// so its submit→AU is the bare ASIC time. The last ~2 unflushed frames on the ffmpeg path
|
/// so its submit→AU is the bare ASIC time. The last ~2 unflushed frames on the ffmpeg path
|
||||||
/// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced
|
/// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced
|
||||||
/// submit→AU.
|
/// submit→AU.
|
||||||
|
/// Gated like its only caller (see [`percentile`]).
|
||||||
|
#[cfg(feature = "amf-qsv")]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn drive_and_measure(
|
fn drive_and_measure(
|
||||||
enc: &mut dyn Encoder,
|
enc: &mut dyn Encoder,
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
//! Mirrored AMF C ABI (written against GPUOpen header release v1.4.36 — amf/public/include; every
|
||||||
|
//! slot below is a base-interface slot whose layout is stable since <= v1.4.34, the loader's
|
||||||
|
//! accepted ABI floor, so the mirror is valid on every runtime the loader admits).
|
||||||
|
//!
|
||||||
|
//! Layout rules this mirror relies on: every AMF interface is a struct whose sole member is a
|
||||||
|
//! pointer to a C vtable; derived interfaces PREPEND their base's slots in order (AMFInterface →
|
||||||
|
//! AMFPropertyStorage → AMFData → AMFBuffer/AMFSurface), so a derived pointer is usable through a
|
||||||
|
//! base vtable mirror. Slots we never call are declared as bare `*const c_void` placeholders —
|
||||||
|
//! same size/alignment as the function pointer they stand in for. `AMF_STD_CALL` is `__stdcall`
|
||||||
|
//! (Rust `extern "system"`); the two DLL entry points are `__cdecl` (`extern "C"`); on x86_64
|
||||||
|
//! both collapse to the one Windows calling convention.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
/// `AMF_RESULT` (core/Result.h) — a plain C enum, sequential from 0. Only the codes this
|
||||||
|
/// module branches on are named; everything else is reported numerically via [`result_name`].
|
||||||
|
pub type AmfResult = i32;
|
||||||
|
pub const AMF_OK: AmfResult = 0;
|
||||||
|
pub const AMF_EOF: AmfResult = 23;
|
||||||
|
pub const AMF_REPEAT: AmfResult = 24;
|
||||||
|
pub const AMF_INPUT_FULL: AmfResult = 25;
|
||||||
|
pub const AMF_NEED_MORE_INPUT: AmfResult = 44;
|
||||||
|
|
||||||
|
/// Human-readable name for an `AMF_RESULT` (diagnostics only — the numeric value rides along
|
||||||
|
/// so an unnamed code is still identifiable against Result.h).
|
||||||
|
pub fn result_name(r: AmfResult) -> &'static str {
|
||||||
|
match r {
|
||||||
|
0 => "AMF_OK",
|
||||||
|
1 => "AMF_FAIL",
|
||||||
|
2 => "AMF_UNEXPECTED",
|
||||||
|
3 => "AMF_ACCESS_DENIED",
|
||||||
|
4 => "AMF_INVALID_ARG",
|
||||||
|
5 => "AMF_OUT_OF_RANGE",
|
||||||
|
6 => "AMF_OUT_OF_MEMORY",
|
||||||
|
7 => "AMF_INVALID_POINTER",
|
||||||
|
8 => "AMF_NO_INTERFACE",
|
||||||
|
9 => "AMF_NOT_IMPLEMENTED",
|
||||||
|
10 => "AMF_NOT_SUPPORTED",
|
||||||
|
11 => "AMF_NOT_FOUND",
|
||||||
|
12 => "AMF_ALREADY_INITIALIZED",
|
||||||
|
13 => "AMF_NOT_INITIALIZED",
|
||||||
|
14 => "AMF_INVALID_FORMAT",
|
||||||
|
15 => "AMF_WRONG_STATE",
|
||||||
|
17 => "AMF_NO_DEVICE",
|
||||||
|
18 => "AMF_DIRECTX_FAILED",
|
||||||
|
23 => "AMF_EOF",
|
||||||
|
24 => "AMF_REPEAT",
|
||||||
|
25 => "AMF_INPUT_FULL",
|
||||||
|
26 => "AMF_RESOLUTION_CHANGED",
|
||||||
|
28 => "AMF_INVALID_DATA_TYPE",
|
||||||
|
29 => "AMF_INVALID_RESOLUTION",
|
||||||
|
30 => "AMF_CODEC_NOT_SUPPORTED",
|
||||||
|
31 => "AMF_SURFACE_FORMAT_NOT_SUPPORTED",
|
||||||
|
32 => "AMF_SURFACE_MUST_BE_SHARED",
|
||||||
|
36 => "AMF_ENCODER_NOT_PRESENT",
|
||||||
|
44 => "AMF_NEED_MORE_INPUT",
|
||||||
|
_ => "AMF_<unnamed>",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AMF header release this FFI mirror was written against: `AMF_FULL_VERSION` for 1.4.36.0
|
||||||
|
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). This is the version claimed to `AMFInit` — but
|
||||||
|
/// capped at the runtime's own reported version (see `load_factory`), so an older-but-accepted
|
||||||
|
/// runtime is asked only for the ABI it actually provides.
|
||||||
|
pub const AMF_HEADER_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
|
||||||
|
|
||||||
|
/// The oldest AMF runtime the loader accepts (`AMF_FULL_VERSION` 1.4.34.0 — AMD Adrenalin
|
||||||
|
/// 24.6.1). This is an **ABI floor, not a feature floor**: every vtable slot mirrored in this
|
||||||
|
/// module belongs to a base interface (`AMFFactory`/`AMFContext`/`AMFComponent`/`AMFData`/
|
||||||
|
/// `AMFBuffer`) whose layout has been stable — append-only, no mid-vtable insertions — since
|
||||||
|
/// well before 1.4.34, so a 1.4.34 runtime is guaranteed to expose every mirrored slot at its
|
||||||
|
/// mirrored offset. Everything 1.4.35/1.4.36 added that this path can touch (new HQ presets,
|
||||||
|
/// AV1 B-frame / picture management) is a *string-keyed encoder property*, applied through
|
||||||
|
/// [`set_prop`] with `required=false` — a runtime that lacks it rejects the property (logged)
|
||||||
|
/// and the feature degrades, rather than shifting any vtable offset. Below this floor the
|
||||||
|
/// mirror is not guaranteed to match, so the loader declines cleanly (an old-driver decline,
|
||||||
|
/// never UB).
|
||||||
|
pub const AMF_MIN_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (34u64 << 16);
|
||||||
|
|
||||||
|
/// `AMF_SURFACE_FORMAT` (core/Surface.h).
|
||||||
|
pub const AMF_SURFACE_NV12: i32 = 1;
|
||||||
|
pub const AMF_SURFACE_P010: i32 = 10;
|
||||||
|
|
||||||
|
/// `AMF_DX_VERSION::AMF_DX11_1` (core/Data.h) — the `InitDX11` version argument.
|
||||||
|
pub const AMF_DX11_1: i32 = 111;
|
||||||
|
|
||||||
|
/// `AMF_MEMORY_TYPE::AMF_MEMORY_HOST` (core/Data.h) — the `AllocBuffer` memory type for the
|
||||||
|
/// CPU-filled HDR-metadata buffer.
|
||||||
|
pub const AMF_MEMORY_HOST: i32 = 1;
|
||||||
|
|
||||||
|
/// `AMFHDRMetadata` (components/ColorSpace.h) — the payload of the `*InHDRMetadata` encoder
|
||||||
|
/// property (an `AMFBuffer` holding exactly this struct). Same units as the HEVC ST.2086 SEI
|
||||||
|
/// and [`punktfunk_core::quic::HdrMeta`]: chromaticities in 1/50000, mastering luminance in
|
||||||
|
/// 0.0001 cd/m², CLL/FALL in nits. 28 bytes, no padding.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfHdrMetadata {
|
||||||
|
pub red_primary: [u16; 2],
|
||||||
|
pub green_primary: [u16; 2],
|
||||||
|
pub blue_primary: [u16; 2],
|
||||||
|
pub white_point: [u16; 2],
|
||||||
|
pub max_mastering_luminance: u32,
|
||||||
|
pub min_mastering_luminance: u32,
|
||||||
|
pub max_content_light_level: u16,
|
||||||
|
pub max_frame_average_light_level: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AMFGuid` (core/Platform.h) — data41..data48 flattened into an array (identical layout).
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfGuid {
|
||||||
|
pub data1: u32,
|
||||||
|
pub data2: u16,
|
||||||
|
pub data3: u16,
|
||||||
|
pub data4: [u8; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `IID_AMFBuffer` (core/Buffer.h `AMF_DECLARE_IID`) — for `QueryInterface` on the encoder's
|
||||||
|
/// output `AMFData` to reach `GetNative`/`GetSize`.
|
||||||
|
pub const IID_AMF_BUFFER: AmfGuid = AmfGuid {
|
||||||
|
data1: 0xb04b_7248,
|
||||||
|
data2: 0xb6f0,
|
||||||
|
data3: 0x4321,
|
||||||
|
data4: [0xb6, 0x91, 0xba, 0xa4, 0x74, 0x0f, 0x9f, 0xcb],
|
||||||
|
};
|
||||||
|
|
||||||
|
// `AMF_VARIANT_TYPE` (core/Variant.h) — the tags this module writes/reads.
|
||||||
|
pub const AMF_VARIANT_BOOL: i32 = 1;
|
||||||
|
pub const AMF_VARIANT_INT64: i32 = 2;
|
||||||
|
pub const AMF_VARIANT_RATE: i32 = 7;
|
||||||
|
pub const AMF_VARIANT_INTERFACE: i32 = 12;
|
||||||
|
|
||||||
|
/// `AMFVariantStruct` (core/Variant.h): a 4-byte C-enum tag + a 16-byte union whose largest
|
||||||
|
/// members are pointer/`amf_int64`/`AMFFloatVector4D` (align 8) → 24 bytes total, tag at 0,
|
||||||
|
/// payload at 8. Passed BY VALUE to `SetProperty` (Win64 passes >8-byte aggregates by hidden
|
||||||
|
/// reference on both sides, so declaring it by value matches the C compiler). The payload is
|
||||||
|
/// stored as two fully-initialised `u64`s — little-endian packing puts a bool in byte 0, an
|
||||||
|
/// `amf_int64` in word 0, and an `AMFRate{num,den}` as `num | den << 32`, exactly the union's
|
||||||
|
/// in-memory layout — so no partially-initialised union bytes ever cross the FFI.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfVariant {
|
||||||
|
pub vtype: i32,
|
||||||
|
pub payload: [u64; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmfVariant {
|
||||||
|
pub fn zeroed() -> Self {
|
||||||
|
AmfVariant {
|
||||||
|
vtype: 0, // AMF_VARIANT_EMPTY
|
||||||
|
payload: [0, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn from_i64(v: i64) -> Self {
|
||||||
|
AmfVariant {
|
||||||
|
vtype: AMF_VARIANT_INT64,
|
||||||
|
payload: [v as u64, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn from_bool(v: bool) -> Self {
|
||||||
|
AmfVariant {
|
||||||
|
vtype: AMF_VARIANT_BOOL,
|
||||||
|
payload: [v as u64, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// `AMFRate { num, den }` — two little-endian `amf_uint32`s in the union's first 8 bytes.
|
||||||
|
pub fn from_rate(num: u32, den: u32) -> Self {
|
||||||
|
AmfVariant {
|
||||||
|
vtype: AMF_VARIANT_RATE,
|
||||||
|
payload: [num as u64 | ((den as u64) << 32), 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// An `AMFInterface*` payload (`pInterface` in the union's first 8 bytes). The property
|
||||||
|
/// storage AddRefs the interface when it copies the variant in (the C++ template
|
||||||
|
/// `SetProperty(name, AMFVariant(value))` passes a temporary whose destructor releases,
|
||||||
|
/// so `SetProperty` must take its own reference) — the caller keeps sole ownership of the
|
||||||
|
/// reference it already holds.
|
||||||
|
pub fn from_interface(p: *mut c_void) -> Self {
|
||||||
|
AmfVariant {
|
||||||
|
vtype: AMF_VARIANT_INTERFACE,
|
||||||
|
payload: [p as usize as u64, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Read back an `amf_int64` payload (only valid when `vtype == AMF_VARIANT_INT64`).
|
||||||
|
pub fn as_i64(&self) -> Option<i64> {
|
||||||
|
(self.vtype == AMF_VARIANT_INT64).then_some(self.payload[0] as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for a vtable slot this module never calls — same size/align as the function
|
||||||
|
/// pointer it stands in for, present only to keep the following slots at their C offsets.
|
||||||
|
pub type Slot = *const c_void;
|
||||||
|
|
||||||
|
// -- AMFFactory (core/Factory.h; NOT refcounted — a process singleton) ----------------------
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfFactory {
|
||||||
|
pub vtbl: *const AmfFactoryVtbl,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfFactoryVtbl {
|
||||||
|
pub create_context:
|
||||||
|
unsafe extern "system" fn(*mut AmfFactory, *mut *mut AmfContext) -> AmfResult,
|
||||||
|
pub create_component: unsafe extern "system" fn(
|
||||||
|
*mut AmfFactory,
|
||||||
|
*mut AmfContext,
|
||||||
|
*const u16,
|
||||||
|
*mut *mut AmfComponent,
|
||||||
|
) -> AmfResult,
|
||||||
|
pub set_cache_folder: Slot,
|
||||||
|
pub get_cache_folder: Slot,
|
||||||
|
pub get_debug: Slot,
|
||||||
|
pub get_trace: Slot,
|
||||||
|
pub get_programs: Slot,
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- AMFContext (core/Context.h) ------------------------------------------------------------
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfContext {
|
||||||
|
pub vtbl: *const AmfContextVtbl,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfContextVtbl {
|
||||||
|
// AMFInterface
|
||||||
|
pub acquire: Slot,
|
||||||
|
pub release: unsafe extern "system" fn(*mut AmfContext) -> i32,
|
||||||
|
pub query_interface: Slot,
|
||||||
|
// AMFPropertyStorage
|
||||||
|
pub set_property: Slot,
|
||||||
|
pub get_property: Slot,
|
||||||
|
pub has_property: Slot,
|
||||||
|
pub get_property_count: Slot,
|
||||||
|
pub get_property_at: Slot,
|
||||||
|
pub clear: Slot,
|
||||||
|
pub add_to: Slot,
|
||||||
|
pub copy_to: Slot,
|
||||||
|
pub add_observer: Slot,
|
||||||
|
pub remove_observer: Slot,
|
||||||
|
// AMFContext
|
||||||
|
pub terminate: unsafe extern "system" fn(*mut AmfContext) -> AmfResult,
|
||||||
|
pub init_dx9: Slot,
|
||||||
|
pub get_dx9_device: Slot,
|
||||||
|
pub lock_dx9: Slot,
|
||||||
|
pub unlock_dx9: Slot,
|
||||||
|
pub init_dx11: unsafe extern "system" fn(*mut AmfContext, *mut c_void, i32) -> AmfResult,
|
||||||
|
pub get_dx11_device: Slot,
|
||||||
|
pub lock_dx11: Slot,
|
||||||
|
pub unlock_dx11: Slot,
|
||||||
|
pub init_opencl: Slot,
|
||||||
|
pub get_opencl_context: Slot,
|
||||||
|
pub get_opencl_command_queue: Slot,
|
||||||
|
pub get_opencl_device_id: Slot,
|
||||||
|
pub get_opencl_compute_factory: Slot,
|
||||||
|
pub init_opencl_ex: Slot,
|
||||||
|
pub lock_opencl: Slot,
|
||||||
|
pub unlock_opencl: Slot,
|
||||||
|
pub init_opengl: Slot,
|
||||||
|
pub get_opengl_context: Slot,
|
||||||
|
pub get_opengl_drawable: Slot,
|
||||||
|
pub lock_opengl: Slot,
|
||||||
|
pub unlock_opengl: Slot,
|
||||||
|
pub init_xv: Slot,
|
||||||
|
pub get_xv_device: Slot,
|
||||||
|
pub lock_xv: Slot,
|
||||||
|
pub unlock_xv: Slot,
|
||||||
|
pub init_gralloc: Slot,
|
||||||
|
pub get_gralloc_device: Slot,
|
||||||
|
pub lock_gralloc: Slot,
|
||||||
|
pub unlock_gralloc: Slot,
|
||||||
|
pub alloc_buffer: unsafe extern "system" fn(
|
||||||
|
*mut AmfContext,
|
||||||
|
i32, // AMF_MEMORY_TYPE
|
||||||
|
usize,
|
||||||
|
*mut *mut AmfBuffer,
|
||||||
|
) -> AmfResult,
|
||||||
|
pub alloc_surface: Slot,
|
||||||
|
pub alloc_audio_buffer: Slot,
|
||||||
|
pub create_buffer_from_host_native: Slot,
|
||||||
|
pub create_surface_from_host_native: Slot,
|
||||||
|
pub create_surface_from_dx9_native: Slot,
|
||||||
|
/// Out-param is `AMFSurface**` in the header; declared as the `AmfData` base here because
|
||||||
|
/// every surface call this module makes (`SetPts`, `SetProperty`, `Release`,
|
||||||
|
/// `SubmitInput`) lives in the `AMFData` vtable prefix, which `AMFSurfaceVtbl` reproduces
|
||||||
|
/// slot-for-slot (single inheritance, same object pointer).
|
||||||
|
pub create_surface_from_dx11_native: unsafe extern "system" fn(
|
||||||
|
*mut AmfContext,
|
||||||
|
*mut c_void,
|
||||||
|
*mut *mut AmfData,
|
||||||
|
*mut c_void,
|
||||||
|
) -> AmfResult,
|
||||||
|
pub create_surface_from_opengl_native: Slot,
|
||||||
|
pub create_surface_from_gralloc_native: Slot,
|
||||||
|
pub create_surface_from_opencl_native: Slot,
|
||||||
|
pub create_buffer_from_opencl_native: Slot,
|
||||||
|
pub get_compute: Slot,
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- AMFComponent (components/Component.h) --------------------------------------------------
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfComponent {
|
||||||
|
pub vtbl: *const AmfComponentVtbl,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfComponentVtbl {
|
||||||
|
// AMFInterface
|
||||||
|
pub acquire: Slot,
|
||||||
|
pub release: unsafe extern "system" fn(*mut AmfComponent) -> i32,
|
||||||
|
pub query_interface: Slot,
|
||||||
|
// AMFPropertyStorage
|
||||||
|
pub set_property:
|
||||||
|
unsafe extern "system" fn(*mut AmfComponent, *const u16, AmfVariant) -> AmfResult,
|
||||||
|
pub get_property: Slot,
|
||||||
|
pub has_property: Slot,
|
||||||
|
pub get_property_count: Slot,
|
||||||
|
pub get_property_at: Slot,
|
||||||
|
pub clear: Slot,
|
||||||
|
pub add_to: Slot,
|
||||||
|
pub copy_to: Slot,
|
||||||
|
pub add_observer: Slot,
|
||||||
|
pub remove_observer: Slot,
|
||||||
|
// AMFPropertyStorageEx
|
||||||
|
pub get_properties_info_count: Slot,
|
||||||
|
pub get_property_info_at: Slot,
|
||||||
|
pub get_property_info: Slot,
|
||||||
|
pub validate_property: Slot,
|
||||||
|
// AMFComponent
|
||||||
|
pub init: unsafe extern "system" fn(*mut AmfComponent, i32, i32, i32) -> AmfResult,
|
||||||
|
pub reinit: Slot,
|
||||||
|
pub terminate: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||||
|
pub drain: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||||
|
pub flush: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||||
|
pub submit_input: unsafe extern "system" fn(*mut AmfComponent, *mut AmfData) -> AmfResult,
|
||||||
|
pub query_output: unsafe extern "system" fn(*mut AmfComponent, *mut *mut AmfData) -> AmfResult,
|
||||||
|
pub get_context: Slot,
|
||||||
|
pub set_output_data_allocator_cb: Slot,
|
||||||
|
pub get_caps: Slot,
|
||||||
|
pub optimize: Slot,
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- AMFData (core/Data.h) — also the usable prefix of AMFSurface --------------------------
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfData {
|
||||||
|
pub vtbl: *const AmfDataVtbl,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfDataVtbl {
|
||||||
|
// AMFInterface
|
||||||
|
pub acquire: Slot,
|
||||||
|
pub release: unsafe extern "system" fn(*mut AmfData) -> i32,
|
||||||
|
pub query_interface:
|
||||||
|
unsafe extern "system" fn(*mut AmfData, *const AmfGuid, *mut *mut c_void) -> AmfResult,
|
||||||
|
// AMFPropertyStorage
|
||||||
|
pub set_property: unsafe extern "system" fn(*mut AmfData, *const u16, AmfVariant) -> AmfResult,
|
||||||
|
pub get_property:
|
||||||
|
unsafe extern "system" fn(*mut AmfData, *const u16, *mut AmfVariant) -> AmfResult,
|
||||||
|
pub has_property: Slot,
|
||||||
|
pub get_property_count: Slot,
|
||||||
|
pub get_property_at: Slot,
|
||||||
|
pub clear: Slot,
|
||||||
|
pub add_to: Slot,
|
||||||
|
pub copy_to: Slot,
|
||||||
|
pub add_observer: Slot,
|
||||||
|
pub remove_observer: Slot,
|
||||||
|
// AMFData
|
||||||
|
pub get_memory_type: Slot,
|
||||||
|
pub duplicate: Slot,
|
||||||
|
pub convert: Slot,
|
||||||
|
pub interop: Slot,
|
||||||
|
pub get_data_type: Slot,
|
||||||
|
pub is_reusable: Slot,
|
||||||
|
pub set_pts: unsafe extern "system" fn(*mut AmfData, i64),
|
||||||
|
pub get_pts: Slot,
|
||||||
|
pub set_duration: Slot,
|
||||||
|
pub get_duration: Slot,
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- AMFBuffer (core/Buffer.h) — the encoder's output object -------------------------------
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfBuffer {
|
||||||
|
pub vtbl: *const AmfBufferVtbl,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AmfBufferVtbl {
|
||||||
|
// AMFInterface + AMFPropertyStorage + AMFData prefix (identical order to AmfDataVtbl).
|
||||||
|
pub acquire: Slot,
|
||||||
|
pub release: unsafe extern "system" fn(*mut AmfBuffer) -> i32,
|
||||||
|
pub query_interface: Slot,
|
||||||
|
pub set_property: Slot,
|
||||||
|
pub get_property: Slot,
|
||||||
|
pub has_property: Slot,
|
||||||
|
pub get_property_count: Slot,
|
||||||
|
pub get_property_at: Slot,
|
||||||
|
pub clear: Slot,
|
||||||
|
pub add_to: Slot,
|
||||||
|
pub copy_to: Slot,
|
||||||
|
pub add_observer: Slot,
|
||||||
|
pub remove_observer: Slot,
|
||||||
|
pub get_memory_type: Slot,
|
||||||
|
pub duplicate: Slot,
|
||||||
|
pub convert: Slot,
|
||||||
|
pub interop: Slot,
|
||||||
|
pub get_data_type: Slot,
|
||||||
|
pub is_reusable: Slot,
|
||||||
|
pub set_pts: Slot,
|
||||||
|
pub get_pts: Slot,
|
||||||
|
pub set_duration: Slot,
|
||||||
|
pub get_duration: Slot,
|
||||||
|
// AMFBuffer
|
||||||
|
pub set_size: Slot,
|
||||||
|
pub get_size: unsafe extern "system" fn(*mut AmfBuffer) -> usize,
|
||||||
|
pub get_native: unsafe extern "system" fn(*mut AmfBuffer) -> *mut c_void,
|
||||||
|
pub add_observer_buffer: Slot,
|
||||||
|
pub remove_observer_buffer: Slot,
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- DLL entry points (core/Factory.h; AMF_CDECL_CALL) --------------------------------------
|
||||||
|
pub type AmfQueryVersionFn = unsafe extern "C" fn(*mut u64) -> AmfResult;
|
||||||
|
pub type AmfInitFn = unsafe extern "C" fn(u64, *mut *mut AmfFactory) -> AmfResult;
|
||||||
@@ -95,6 +95,13 @@ struct AVD3D11VAFramesContext {
|
|||||||
/// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers.
|
/// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum WinVendor {
|
pub enum WinVendor {
|
||||||
|
/// Benchmark-only, as the module header explains: native AMF replaced the libavcodec AMF path
|
||||||
|
/// in production, and the only remaining CONSTRUCTOR is the `#[cfg(feature = "amf-qsv")]`
|
||||||
|
/// latency A/B in `amf.rs` — the measurement that justifies the native backend existing. That
|
||||||
|
/// is test code, so the *lib* target constructs it nowhere and `dead_code` fires on it (the
|
||||||
|
/// crate root no longer blanket-allows that). Kept deliberately rather than deleted; the arms
|
||||||
|
/// below are what the benchmark drives.
|
||||||
|
#[allow(dead_code)]
|
||||||
Amf,
|
Amf,
|
||||||
Qsv,
|
Qsv,
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,44 @@ fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
|||||||
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
|
||||||
|
/// AU, so a value past one frame period is already self-defeating and a full second is far beyond
|
||||||
|
/// anything an operator would set on purpose. The clamp is also what makes the µs conversion
|
||||||
|
/// below provably overflow-free.
|
||||||
|
///
|
||||||
|
/// The reachable hazard is a slipped digit, not the overflow: pre-clamp, `PUNKTFUNK_FFWIN_POLL_MS=
|
||||||
|
/// 100000000` was a **27.7-hour** spin with no overflow anywhere near it.
|
||||||
|
const MAX_POLL_SPIN_MS: u64 = 1_000;
|
||||||
|
|
||||||
|
/// Bounded post-submit spin for [`FfmpegWinEncoder::poll`], in microseconds (0 = off, the default
|
||||||
|
/// and the correct choice on every VCN measured so far).
|
||||||
|
///
|
||||||
|
/// Read from the environment **once per process** (WP6.1): `poll` runs once per encode tick, and
|
||||||
|
/// this was an unconditional `env::var` + parse on it.
|
||||||
|
///
|
||||||
|
/// ⚠ The audit proposed `saturating_mul` for the µs conversion. It is still the wrong fix, but for
|
||||||
|
/// a reason worth stating precisely, because the obvious one is false: `Duration::from_micros(
|
||||||
|
/// u64::MAX)` is only ~1.8e13 seconds, six orders of magnitude below `Duration`'s `u64::MAX`-second
|
||||||
|
/// ceiling, so `Instant::now() + Duration::from_micros(u64::MAX)` does **not** overflow and does
|
||||||
|
/// **not** panic (measured, both with and without debug assertions). What it does instead is set a
|
||||||
|
/// deadline ~584,000 years out, and the loop below only exits on `Packet`/`Eof` — and this
|
||||||
|
/// function's own doc explains that a spin here *provably never* produces the owed AU on the
|
||||||
|
/// measured hardware. So `saturating_mul` converts a bad value into a **permanently wedged encode
|
||||||
|
/// thread**: a hang, not a panic. Clamping the parsed value first removes the bad value entirely.
|
||||||
|
///
|
||||||
|
/// (For the record on the pre-clamp behaviour: the workspace sets no `overflow-checks` in
|
||||||
|
/// `[profile.release]`, so `ms * 1000` wrapped silently in release and panicked only in debug.)
|
||||||
|
fn poll_spin_cap_us() -> u64 {
|
||||||
|
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||||
|
*CAP_US.get_or_init(|| {
|
||||||
|
std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||||
|
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
||||||
|
.unwrap_or(0) // default: no spin — the libavcodec AMF buffer can't be spun out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||||
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||||
Ok(match format {
|
Ok(match format {
|
||||||
@@ -151,8 +196,14 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
|
/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
|
||||||
fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
|
///
|
||||||
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
/// Depth follows the PIXELS, not the negotiated `bit_depth` — see
|
||||||
|
/// [`crate::ten_bit_input`] for why, and for the failure this shape used to produce here in
|
||||||
|
/// particular: a 10-bit-negotiated session over an 8-bit capture built a P010 encoder whose every
|
||||||
|
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
|
||||||
|
/// because the rebuild re-derived the same wrong answer.
|
||||||
|
fn is_10bit_format(format: PixelFormat) -> bool {
|
||||||
|
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||||
@@ -271,6 +322,11 @@ pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gated to the builds that can actually reach it: `lib.rs`'s only caller sits under
|
||||||
|
/// `cfg(all(not(feature = "qsv"), feature = "amf-qsv"))`, because with the native VPL backend
|
||||||
|
/// compiled in it is `qsv::probe_can_encode` that answers. So in the SHIPPED Windows combo
|
||||||
|
/// (`nvenc,amf-qsv,qsv`) this function has no caller at all.
|
||||||
|
#[cfg(not(feature = "qsv"))]
|
||||||
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||||
// Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`):
|
// Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`):
|
||||||
// the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own
|
// the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own
|
||||||
@@ -350,7 +406,7 @@ impl SystemInner {
|
|||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let ten_bit = is_10bit_format(format, bit_depth);
|
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||||
let sw_av = if ten_bit {
|
let sw_av = if ten_bit {
|
||||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||||
} else {
|
} else {
|
||||||
@@ -472,7 +528,10 @@ impl SystemInner {
|
|||||||
pts: i64,
|
pts: i64,
|
||||||
idr: bool,
|
idr: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let fmt_10 = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
|
||||||
|
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
|
||||||
|
// merely negotiated 10-bit over an 8-bit capture.
|
||||||
|
let fmt_10 = is_10bit_format(format);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
fmt_10 == self.ten_bit,
|
fmt_10 == self.ten_bit,
|
||||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||||
@@ -851,7 +910,7 @@ impl ZeroCopyInner {
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let ten_bit = is_10bit_format(format, bit_depth);
|
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||||
let sw_av = if ten_bit {
|
let sw_av = if ten_bit {
|
||||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||||
} else {
|
} else {
|
||||||
@@ -1311,11 +1370,7 @@ impl Encoder for FfmpegWinEncoder {
|
|||||||
Some(Inner::ZeroCopy(z)) => &mut z.enc,
|
Some(Inner::ZeroCopy(z)) => &mut z.enc,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
let cap_us = std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
let cap_us = poll_spin_cap_us();
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.map(|ms| ms * 1000)
|
|
||||||
.unwrap_or(0); // default: no spin — the libavcodec AMF buffer can't be spun out
|
|
||||||
let deadline = (cap_us > 0 && self.in_flight > 0)
|
let deadline = (cap_us > 0 && self.in_flight > 0)
|
||||||
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
|
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -37,8 +37,9 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::nvenc_core::{
|
use super::nvenc_core::{
|
||||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey,
|
||||||
|
LowLatencyConfig, NvStatusExt, RangePlan,
|
||||||
};
|
};
|
||||||
use super::nvenc_status;
|
use super::nvenc_status;
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
@@ -263,6 +264,112 @@ fn split_mode_units(split_mode: u32) -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes every `nvEncOpenEncodeSessionEx` in this process against [`reap_parked_sessions`].
|
||||||
|
/// The reap retry-destroys handles whose driver-side state is unknown; if it overlapped an open,
|
||||||
|
/// the driver could hand the NEW session the recycled address of the zombie being destroyed and
|
||||||
|
/// the reap would destroy a live session. Held across `init_session`'s whole open sequence
|
||||||
|
/// (including its caps probe and bitrate-clamp search) and around the standalone caps probes.
|
||||||
|
/// Admission (`can_open_another_session`) never takes it — that stays a lock-free atomic load.
|
||||||
|
static DRIVER_SESSION_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
/// A session whose `destroy_encoder` failed with an AMBIGUOUS status (see
|
||||||
|
/// [`nvenc_status::destroy_proves_no_session`]): the driver may still hold its concurrent-session
|
||||||
|
/// slot, so its units stay charged in [`LIVE_SESSION_UNITS`] (fail-closed for admission) and the
|
||||||
|
/// handle is parked here for [`reap_parked_sessions`] to retry. The retry is what keeps a
|
||||||
|
/// TRANSIENT failure (wedge episode a TDR later cleared) from poisoning the budget until a host
|
||||||
|
/// restart, while a genuine driver leak keeps failing the retry and stays correctly charged.
|
||||||
|
struct ParkedSession {
|
||||||
|
enc: usize,
|
||||||
|
units: u32,
|
||||||
|
/// Pins the D3D11 device the session was opened against. Teardown drops the texture
|
||||||
|
/// registrations (the only other refs), and the reinit-on-device-change path parks exactly
|
||||||
|
/// when the old device is on its way out — without this, the reap's late `destroy_encoder`
|
||||||
|
/// could touch a freed device.
|
||||||
|
_device: Option<ID3D11Device>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: the COM ref is the only non-Send field. The D3D11 device is free-threaded (capture
|
||||||
|
// creates it without `D3D11_CREATE_DEVICE_SINGLETHREADED` — see pf-frame/src/dxgi.rs), we never
|
||||||
|
// call methods on it from here, and dropping (Release) a free-threaded COM object from another
|
||||||
|
// thread is sound. The raw `enc` handle is only ever passed back to `destroy_encoder` under
|
||||||
|
// [`PARKED`]'s lock with [`DRIVER_SESSION_GATE`] held — single-threaded access in practice.
|
||||||
|
unsafe impl Send for ParkedSession {}
|
||||||
|
|
||||||
|
static PARKED: std::sync::Mutex<Vec<ParkedSession>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Park a session whose destroy failed ambiguously. Units are NOT refunded — they keep counting
|
||||||
|
/// against admission until a reap proves the slot free. Bounded: once parked units would exceed
|
||||||
|
/// the session cap the oldest entry ages out WITH a refund (a repeated wedge-rebuild loop would
|
||||||
|
/// otherwise grow this without limit, and by then the driver has almost certainly been through
|
||||||
|
/// the reset that reclaims the early handles anyway).
|
||||||
|
fn park_session(enc: usize, units: u32, device: Option<ID3D11Device>) {
|
||||||
|
let mut parked = PARKED.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
while !parked.is_empty() && parked.iter().map(|z| z.units).sum::<u32>() + units > session_cap()
|
||||||
|
{
|
||||||
|
let old = parked.remove(0);
|
||||||
|
LIVE_SESSION_UNITS.fetch_sub(old.units, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
tracing::warn!(
|
||||||
|
enc = old.enc,
|
||||||
|
units = old.units,
|
||||||
|
"NVENC parked-session graveyard full — aging out the oldest entry (units refunded)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parked.push(ParkedSession {
|
||||||
|
enc,
|
||||||
|
units,
|
||||||
|
_device: device,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry `destroy_encoder` on every parked session and refund the units of each one that now
|
||||||
|
/// succeeds (or fails with a session-gone status — same proof). Runs from `init_session` on the
|
||||||
|
/// encode thread, NEVER from admission (a wedged driver would block the admission registry lock).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must hold [`DRIVER_SESSION_GATE`] and there must be NO live NVENC session in the
|
||||||
|
/// process (checked here: live units == parked units). Under those two conditions no live session
|
||||||
|
/// can alias a recycled handle address, and no concurrent open can be handed one mid-reap. The
|
||||||
|
/// residual assumption — documented, unprovable from the SDK — is that a FAILED destroy leaves
|
||||||
|
/// the handle itself intact for a later retry; NVENC documents no retry semantics either way.
|
||||||
|
unsafe fn reap_parked_sessions() {
|
||||||
|
let mut parked = PARKED.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if parked.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parked_units: u32 = parked.iter().map(|z| z.units).sum();
|
||||||
|
if LIVE_SESSION_UNITS.load(std::sync::atomic::Ordering::Relaxed) != parked_units {
|
||||||
|
return; // a live session exists somewhere — its address space is off limits
|
||||||
|
}
|
||||||
|
parked.retain(
|
||||||
|
|z| match (api().destroy_encoder)(z.enc as *mut c_void).nv_ok() {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
enc = z.enc,
|
||||||
|
units = z.units,
|
||||||
|
"NVENC parked session reclaimed — retry-destroy succeeded, budget refunded"
|
||||||
|
);
|
||||||
|
LIVE_SESSION_UNITS.fetch_sub(z.units, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) if nvenc_status::destroy_proves_no_session(e) => {
|
||||||
|
tracing::info!(
|
||||||
|
enc = z.enc,
|
||||||
|
units = z.units,
|
||||||
|
status = ?e,
|
||||||
|
"NVENC parked session gone on the driver side — budget refunded"
|
||||||
|
);
|
||||||
|
LIVE_SESSION_UNITS.fetch_sub(z.units, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(enc = z.enc, status = ?e,
|
||||||
|
"NVENC parked session still refuses destroy — units stay charged");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the operator asked for the two-thread async retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy).
|
/// Whether the operator asked for the two-thread async retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy).
|
||||||
/// Combined with the GPU's `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` in `init_session`. Opt-in until
|
/// Combined with the GPU's `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` in `init_session`. Opt-in until
|
||||||
/// on-glass validated; note an async-rejecting config surfaces as a failed session open — unset
|
/// on-glass validated; note an async-rejecting config surfaces as a failed session open — unset
|
||||||
@@ -279,12 +386,24 @@ fn async_retrieve_requested() -> bool {
|
|||||||
/// the ring textures in place, so in-flight depth beyond the ring lets the capturer overwrite a
|
/// the ring textures in place, so in-flight depth beyond the ring lets the capturer overwrite a
|
||||||
/// frame mid-encode: visual corruption, not UB). IDD-push rings are sized around
|
/// frame mid-encode: visual corruption, not UB). IDD-push rings are sized around
|
||||||
/// `PUNKTFUNK_IDD_DEPTH`; raise both together if deeper pipelining is needed.
|
/// `PUNKTFUNK_IDD_DEPTH`; raise both together if deeper pipelining is needed.
|
||||||
|
///
|
||||||
|
/// Read from the environment **once per process** (WP6.1): `submit` consults this on EVERY frame —
|
||||||
|
/// both arms of the `cap` match, in sync mode too, where the result is then discarded because the
|
||||||
|
/// backpressure loop short-circuits on `async_rt`. Memoizing rather than latching into a session
|
||||||
|
/// field is deliberate: the composed `cap` also folds in `input_ring_depth`, which
|
||||||
|
/// `set_input_ring_depth` may change after open, and freezing that half would reintroduce the
|
||||||
|
/// in-place-overwrite bug the ring term exists to prevent. Nothing in the workspace mutates this
|
||||||
|
/// variable at runtime, so memoizing a process-constant read is behaviour-preserving by
|
||||||
|
/// construction.
|
||||||
fn async_inflight_cap() -> usize {
|
fn async_inflight_cap() -> usize {
|
||||||
|
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||||
|
*CAP.get_or_init(|| {
|
||||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||||
.unwrap_or(4)
|
.unwrap_or(4)
|
||||||
.clamp(2, POOL - 1)
|
.clamp(2, POOL - 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One in-flight encode handed to the retrieve thread: the output bitstream to lock once its
|
/// One in-flight encode handed to the retrieve thread: the output bitstream to lock once its
|
||||||
@@ -323,7 +442,23 @@ fn retrieve_loop(
|
|||||||
done_tx: mpsc::Sender<RetrieveDone>,
|
done_tx: mpsc::Sender<RetrieveDone>,
|
||||||
) {
|
) {
|
||||||
pf_frame::thread_qos::boost_thread_priority(false);
|
pf_frame::thread_qos::boost_thread_priority(false);
|
||||||
|
// Wedged-vs-routine drain (WP5.2b). Teardown drops the job sender and joins this thread, so
|
||||||
|
// every queued job is WAITED for (never abandoned — abandoning converts the routine drain
|
||||||
|
// into destroy/unmap/close-while-encoding). But on a wedged session that used to cost the
|
||||||
|
// full 5 s PER queued job: up to `cap x 5 s` on the encode thread, and the stall-recovery
|
||||||
|
// ladder rebuilds the session several times per episode, paying it each rebuild. The wedge
|
||||||
|
// evidence lives right here — a completion wait that burned its full budget — so after one
|
||||||
|
// full-budget timeout later jobs get a short slice instead. A first timeout is already
|
||||||
|
// encoder-fatal (its Err reaches `absorb_done` before any later completion is handed out,
|
||||||
|
// and the host tears the session down), so short-slicing behind an established wedge changes
|
||||||
|
// only how long teardown blocks, never an outcome. A successful wait resets the latch, which
|
||||||
|
// keeps a transient GPU stall from cascading short slices into false timeouts. (NVENC does
|
||||||
|
// not document completion ordering across in-flight frames; nothing here relies on it —
|
||||||
|
// FIFO only sharpens the latency bound.)
|
||||||
|
const WEDGED_DRAIN_WAIT_MS: u32 = 250;
|
||||||
|
let mut wedged = false;
|
||||||
while let Ok(job) = work_rx.recv() {
|
while let Ok(job) = work_rx.recv() {
|
||||||
|
let wait_ms = if wedged { WEDGED_DRAIN_WAIT_MS } else { 5000 };
|
||||||
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
||||||
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
||||||
// valid until `teardown`, which joins this thread first. `WaitForSingleObject` takes the
|
// valid until `teardown`, which joins this thread first. `WaitForSingleObject` takes the
|
||||||
@@ -334,9 +469,13 @@ fn retrieve_loop(
|
|||||||
// Lock/unlock from a secondary thread while the encode thread submits is the NVENC
|
// Lock/unlock from a secondary thread while the encode thread submits is the NVENC
|
||||||
// guide's documented threading model.
|
// guide's documented threading model.
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
if WaitForSingleObject(HANDLE(job.event as *mut c_void), 5000) != WAIT_OBJECT_0 {
|
if WaitForSingleObject(HANDLE(job.event as *mut c_void), wait_ms) != WAIT_OBJECT_0 {
|
||||||
Err("NVENC completion event timeout (5s) — encoder wedged?".to_string())
|
wedged = true;
|
||||||
|
Err(format!(
|
||||||
|
"NVENC completion event timeout ({wait_ms} ms) — encoder wedged?"
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
|
wedged = false;
|
||||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||||
outputBitstream: job.bs as *mut c_void,
|
outputBitstream: job.bs as *mut c_void,
|
||||||
@@ -387,6 +526,12 @@ pub struct NvencD3d11Encoder {
|
|||||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
|
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
|
||||||
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
|
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
|
||||||
chroma_444: bool,
|
chroma_444: bool,
|
||||||
|
/// What the SESSION NEGOTIATED, before any per-init downgrade. `chroma_444` above is the
|
||||||
|
/// EFFECTIVE value for the session currently open and is cleared whenever the capturer hands us
|
||||||
|
/// subsampled YUV — which used to overwrite the negotiation itself, so a capturer that went
|
||||||
|
/// back to RGB (HDR toggle, capture-path switch) could never recover 4:4:4 for the rest of the
|
||||||
|
/// stream. Keeping the request separate lets every re-init recompute the effective value.
|
||||||
|
chroma_444_requested: bool,
|
||||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` from the caps probe — whether this GPU can 4:4:4 encode at
|
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` from the caps probe — whether this GPU can 4:4:4 encode at
|
||||||
/// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
|
/// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
|
||||||
yuv444_supported: bool,
|
yuv444_supported: bool,
|
||||||
@@ -394,6 +539,17 @@ pub struct NvencD3d11Encoder {
|
|||||||
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
|
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
|
||||||
/// (HDR can toggle mid-session); a change re-inits the session.
|
/// (HDR can toggle mid-session); a change re-inits the session.
|
||||||
hdr: bool,
|
hdr: bool,
|
||||||
|
/// The HDR state the CAPTURE FORMAT asks for, independent of whether this GPU can serve it.
|
||||||
|
///
|
||||||
|
/// `hdr` above is the effective value and `query_caps` clears it on a card without 10-bit
|
||||||
|
/// encode. `submit` re-derives the requested value from every frame's pixel format, so
|
||||||
|
/// comparing that against the post-downgrade `hdr` made them disagree permanently: a P010
|
||||||
|
/// capturer on such a GPU reported "HDR changed" on EVERY frame and tore down and rebuilt the
|
||||||
|
/// whole session each time. The re-init trigger compares against THIS instead.
|
||||||
|
hdr_requested: bool,
|
||||||
|
/// Latched when `query_caps` finds no 10-bit encode support, so the downgrade is remembered
|
||||||
|
/// across re-inits instead of being rediscovered (and re-warned) every session.
|
||||||
|
hdr_unsupported: bool,
|
||||||
/// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
|
/// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
|
||||||
/// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
|
/// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
|
||||||
/// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
|
/// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
|
||||||
@@ -455,6 +611,11 @@ pub struct NvencD3d11Encoder {
|
|||||||
/// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
|
/// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
|
||||||
/// tear down and re-init NVENC against it.
|
/// tear down and re-init NVENC against it.
|
||||||
init_device: *mut c_void,
|
init_device: *mut c_void,
|
||||||
|
/// COM ref pinning that device while the session lives. `init_device` alone pins nothing, and
|
||||||
|
/// `teardown` releases the texture registrations (the only other refs) BEFORE destroying the
|
||||||
|
/// session — worse, a failed destroy parks the session handle for a later retry, which must
|
||||||
|
/// never outlive the device it references. Moved into the [`ParkedSession`] on that path.
|
||||||
|
init_device_com: Option<ID3D11Device>,
|
||||||
/// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 2–3
|
/// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 2–3
|
||||||
/// under forced split-encode — a split session occupies one session per engine). `0` while no
|
/// under forced split-encode — a split session occupies one session per engine). `0` while no
|
||||||
/// session is open; set by `init_session`, returned by `teardown`.
|
/// session is open; set by `init_session`, returned by `teardown`.
|
||||||
@@ -504,6 +665,9 @@ impl NvencD3d11Encoder {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
// 4:4:4 is HEVC-only; the GPU-support gate is applied in `query_caps`.
|
// 4:4:4 is HEVC-only; the GPU-support gate is applied in `query_caps`.
|
||||||
chroma_444: chroma.is_444() && codec == Codec::H265,
|
chroma_444: chroma.is_444() && codec == Codec::H265,
|
||||||
|
chroma_444_requested: chroma.is_444() && codec == Codec::H265,
|
||||||
|
hdr_requested: false,
|
||||||
|
hdr_unsupported: false,
|
||||||
yuv444_supported: false,
|
yuv444_supported: false,
|
||||||
hdr: false,
|
hdr: false,
|
||||||
hdr_meta: None,
|
hdr_meta: None,
|
||||||
@@ -525,6 +689,7 @@ impl NvencD3d11Encoder {
|
|||||||
session_async: false,
|
session_async: false,
|
||||||
last_rfi_range: None,
|
last_rfi_range: None,
|
||||||
init_device: ptr::null_mut(),
|
init_device: ptr::null_mut(),
|
||||||
|
init_device_com: None,
|
||||||
session_units: 0,
|
session_units: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -571,18 +736,41 @@ impl NvencD3d11Encoder {
|
|||||||
for &bs in &self.bitstreams {
|
for &bs in &self.bitstreams {
|
||||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||||
}
|
}
|
||||||
// A destroy failure means the driver may still hold this session's slot (the concurrent-
|
// Session-budget truth (see LIVE_SESSION_UNITS): refund only on PROOF the driver released
|
||||||
// session cap is per process and only a restart clears a leak) — make it visible instead
|
// the slot — a successful destroy, or a failure whose status says the session/device no
|
||||||
// of silently discarding the status.
|
// longer exists on the driver side (a TDR reclaims every session with the context). The
|
||||||
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
|
// old unconditional refund made the counter drift low on real leaks (over-admitting
|
||||||
|
// parallel displays); the opposite extreme — a permanent charge on ANY failure — let one
|
||||||
|
// transient wedge episode poison admission until a host restart. Ambiguous failures
|
||||||
|
// instead PARK the handle with its units still charged (fail-closed), and
|
||||||
|
// `reap_parked_sessions` retries the destroy once no session is live, so the charge lasts
|
||||||
|
// exactly as long as the driver keeps refusing — which is the definition of still-leaked.
|
||||||
|
let dev_pin = self.init_device_com.take();
|
||||||
|
match (api().destroy_encoder)(self.encoder).nv_ok() {
|
||||||
|
Ok(()) => {
|
||||||
|
LIVE_SESSION_UNITS
|
||||||
|
.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(e) if nvenc_status::destroy_proves_no_session(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
status = ?e,
|
status = ?e,
|
||||||
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
|
"NVENC destroy_encoder failed, but the status proves the driver holds no \
|
||||||
session's slot toward the concurrent-session cap"
|
session (device gone/reset) — budget refunded"
|
||||||
);
|
);
|
||||||
|
LIVE_SESSION_UNITS
|
||||||
|
.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
status = ?e,
|
||||||
|
units = self.session_units,
|
||||||
|
"NVENC destroy_encoder failed ambiguously — the driver may still hold this \
|
||||||
|
session's slot; parking the handle (units stay charged until a reap-destroy \
|
||||||
|
proves the slot free)"
|
||||||
|
);
|
||||||
|
park_session(self.encoder as usize, self.session_units, dev_pin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Return this session's units to the budget (see LIVE_SESSION_UNITS).
|
|
||||||
LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
self.session_units = 0;
|
self.session_units = 0;
|
||||||
self.regs.clear(); // drops the texture clones, releasing our refs
|
self.regs.clear(); // drops the texture clones, releasing our refs
|
||||||
self.bitstreams.clear();
|
self.bitstreams.clear();
|
||||||
@@ -641,6 +829,9 @@ impl NvencD3d11Encoder {
|
|||||||
e,
|
e,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// The handshake with the kernel-mode driver just succeeded — from here on, an
|
||||||
|
// `NV_ENC_ERR_INVALID_VERSION` in this process cannot be a driver version skew.
|
||||||
|
nvenc_status::note_session_opened();
|
||||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||||
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
|
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
|
||||||
@@ -668,7 +859,12 @@ impl NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
// Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
|
// Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
|
||||||
if self.bit_depth >= 10 && ten_bit == 0 {
|
if self.bit_depth >= 10 && ten_bit == 0 {
|
||||||
|
if !self.hdr_unsupported {
|
||||||
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
||||||
|
}
|
||||||
|
// Latched: `submit` compares the frame's REQUESTED hdr against `hdr_requested`, not
|
||||||
|
// against this cleared value, so a 10-bit capturer no longer re-inits every frame.
|
||||||
|
self.hdr_unsupported = true;
|
||||||
self.bit_depth = 8;
|
self.bit_depth = 8;
|
||||||
self.hdr = false;
|
self.hdr = false;
|
||||||
}
|
}
|
||||||
@@ -754,6 +950,27 @@ impl NvencD3d11Encoder {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||||
|
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render
|
||||||
|
/// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0`
|
||||||
|
/// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs
|
||||||
|
/// one failed open + re-search, never a wrong session.
|
||||||
|
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||||
|
let gpu = pf_gpu::resolve_render_adapter_luid()
|
||||||
|
.map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
CeilingKey {
|
||||||
|
gpu,
|
||||||
|
codec: self.codec,
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
fps: self.fps,
|
||||||
|
bit_depth: self.bit_depth,
|
||||||
|
chroma_444: self.chroma_444,
|
||||||
|
split_mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||||
@@ -780,6 +997,7 @@ impl NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
||||||
}
|
}
|
||||||
|
nvenc_status::note_session_opened();
|
||||||
|
|
||||||
let mut cfg = match self.build_config(enc, bitrate) {
|
let mut cfg = match self.build_config(enc, bitrate) {
|
||||||
Ok(cfg) => cfg,
|
Ok(cfg) => cfg,
|
||||||
@@ -812,6 +1030,15 @@ impl NvencD3d11Encoder {
|
|||||||
|
|
||||||
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
||||||
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
||||||
|
// Serialize this whole open sequence (caps probe + bitrate-clamp search + charge) against
|
||||||
|
// every other open and against the zombie reap — see DRIVER_SESSION_GATE. Cold path:
|
||||||
|
// sessions open on a stream start or a device-change rebuild, never per frame.
|
||||||
|
let _gate = DRIVER_SESSION_GATE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|p| p.into_inner());
|
||||||
|
// SAFETY: gate held (no open can be handed a recycled zombie address mid-reap) and the
|
||||||
|
// reap itself re-checks that no live session exists before touching any parked handle.
|
||||||
|
unsafe { reap_parked_sessions() };
|
||||||
// SAFETY: every call below goes through a function pointer resolved once from the
|
// SAFETY: every call below goes through a function pointer resolved once from the
|
||||||
// runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
|
// runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
|
||||||
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
||||||
@@ -834,43 +1061,14 @@ impl NvencD3d11Encoder {
|
|||||||
// gets the highest the GPU can actually do, not a coarse fraction of it.
|
// gets the highest the GPU can actually do, not a coarse fraction of it.
|
||||||
const FLOOR_BPS: u64 = 10_000_000;
|
const FLOOR_BPS: u64 = 10_000_000;
|
||||||
let requested_bps = self.bitrate_bps;
|
let requested_bps = self.bitrate_bps;
|
||||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever
|
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
|
||||||
// the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops
|
// A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
|
||||||
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
|
// (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
|
||||||
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
|
// both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
|
||||||
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
|
// precedence (env override / the measured Main10 don't-split rule / pixel rate).
|
||||||
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
|
// The init-failure fallback below disables it if a codec/config rejects it.
|
||||||
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
|
|
||||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||||
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||||
{
|
|
||||||
Some("0") | Some("disable") => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
|
||||||
}
|
|
||||||
Some("1") | Some("auto") => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
|
||||||
}
|
|
||||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
|
||||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
|
||||||
// Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240
|
|
||||||
// Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine
|
|
||||||
// (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already
|
|
||||||
// handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was
|
|
||||||
// the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides.
|
|
||||||
_ if self.bit_depth >= 10 => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
|
||||||
}
|
|
||||||
_ if pixel_rate > 1_000_000_000 => {
|
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
|
||||||
}
|
|
||||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
|
||||||
};
|
|
||||||
tracing::debug!(
|
|
||||||
split_mode,
|
|
||||||
bit_depth = self.bit_depth,
|
|
||||||
pixel_rate,
|
|
||||||
"NVENC split-encode mode selected"
|
|
||||||
);
|
|
||||||
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
|
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
|
||||||
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
|
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
|
||||||
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
|
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
|
||||||
@@ -884,39 +1082,69 @@ impl NvencD3d11Encoder {
|
|||||||
// built in the right mode from the start.
|
// built in the right mode from the start.
|
||||||
let use_async = self.async_supported && async_retrieve_requested();
|
let use_async = self.async_supported && async_retrieve_requested();
|
||||||
|
|
||||||
let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||||
|
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||||
|
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||||
|
let mut target_bps = requested_bps;
|
||||||
|
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||||
|
if requested_bps > ceiling {
|
||||||
|
tracing::info!(
|
||||||
|
requested_mbps = requested_bps / 1_000_000,
|
||||||
|
ceiling_mbps = ceiling / 1_000_000,
|
||||||
|
"NVENC: requested bitrate above the cached codec-level ceiling — opening \
|
||||||
|
at the ceiling"
|
||||||
|
);
|
||||||
|
target_bps = ceiling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut probe = self.try_open_session(device, target_bps, split_mode, use_async);
|
||||||
|
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||||
|
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||||
|
if probe.is_err() && target_bps < requested_bps {
|
||||||
|
target_bps = requested_bps;
|
||||||
|
probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||||
|
}
|
||||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
|
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
|
||||||
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
|
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
|
||||||
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
|
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
|
||||||
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
|
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
|
||||||
// which then masqueraded as a bitrate cap and failed "even at the floor".
|
// which then masqueraded as a bitrate cap and failed "even at the floor".
|
||||||
|
// `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||||
|
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||||
|
let mut used_split = split_mode;
|
||||||
let split_on =
|
let split_on =
|
||||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||||
if probe.is_err() && split_on {
|
if probe.is_err() && split_on {
|
||||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||||
if let Ok(e) = self.try_open_session(device, requested_bps, no_split, use_async) {
|
if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) {
|
||||||
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
||||||
split_mode = no_split;
|
used_split = no_split;
|
||||||
probe = Ok(e);
|
probe = Ok(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let enc = match probe {
|
let enc = match probe {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
self.bitrate_bps = requested_bps;
|
self.bitrate_bps = target_bps;
|
||||||
enc
|
enc
|
||||||
}
|
}
|
||||||
|
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||||
|
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||||
|
// version skew) must propagate — a search steered by it would discover, and
|
||||||
|
// cache, a bogus ceiling.
|
||||||
|
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||||
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
|
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
|
||||||
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
|
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
|
||||||
let mut lo = FLOOR_BPS;
|
let mut lo = FLOOR_BPS;
|
||||||
let mut hi = requested_bps;
|
let mut hi = target_bps;
|
||||||
let mut best: *mut c_void = ptr::null_mut();
|
let mut best: *mut c_void = ptr::null_mut();
|
||||||
let mut best_bps = 0u64;
|
let mut best_bps = 0u64;
|
||||||
while hi > lo + CLAMP_TOL_BPS {
|
while hi > lo + CLAMP_TOL_BPS {
|
||||||
let mid = lo + (hi - lo) / 2;
|
let mid = lo + (hi - lo) / 2;
|
||||||
match self.try_open_session(device, mid, split_mode, use_async) {
|
match self.try_open_session(device, mid, used_split, use_async) {
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
if !best.is_null() {
|
if !best.is_null() {
|
||||||
let _ = (api().destroy_encoder)(best);
|
let _ = (api().destroy_encoder)(best);
|
||||||
@@ -925,7 +1153,15 @@ impl NvencD3d11Encoder {
|
|||||||
best_bps = mid;
|
best_bps = mid;
|
||||||
lo = mid;
|
lo = mid;
|
||||||
}
|
}
|
||||||
Err(_) => hi = mid,
|
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||||
|
Err(e) => {
|
||||||
|
// Environmental mid-search failure: don't let it shrink the
|
||||||
|
// search — release the partial result and propagate.
|
||||||
|
if !best.is_null() {
|
||||||
|
let _ = (api().destroy_encoder)(best);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if best.is_null() {
|
if best.is_null() {
|
||||||
@@ -933,14 +1169,19 @@ impl NvencD3d11Encoder {
|
|||||||
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
|
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
|
||||||
let no_split =
|
let no_split =
|
||||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||||
best = self
|
best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async)
|
||||||
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
|
{
|
||||||
.or_else(|_| {
|
Ok(e) => e,
|
||||||
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
Err(_) => {
|
||||||
})
|
let e = self
|
||||||
|
.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||||
.context(
|
.context(
|
||||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||||
)?;
|
)?;
|
||||||
|
used_split = no_split;
|
||||||
|
e
|
||||||
|
}
|
||||||
|
};
|
||||||
best_bps = FLOOR_BPS;
|
best_bps = FLOOR_BPS;
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -948,21 +1189,22 @@ impl NvencD3d11Encoder {
|
|||||||
clamped_mbps = best_bps / 1_000_000,
|
clamped_mbps = best_bps / 1_000_000,
|
||||||
"NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
|
"NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
|
||||||
);
|
);
|
||||||
|
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||||
self.bitrate_bps = best_bps;
|
self.bitrate_bps = best_bps;
|
||||||
best
|
best
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.encoder = enc;
|
self.encoder = enc;
|
||||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
|
// Pin the device for the session's lifetime — and for a parked afterlife if the
|
||||||
// effort: the floor fallback above may have succeeded split-disabled without updating
|
// eventual destroy fails (see `ParkedSession::_device`).
|
||||||
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
|
self.init_device_com = Some(device.clone());
|
||||||
// the caller's rebuild fallback covers the mismatch.)
|
// Session init params a later `reconfigure_bitrate` must re-present verbatim.
|
||||||
self.split_mode = split_mode;
|
self.split_mode = used_split;
|
||||||
self.session_async = use_async;
|
self.session_async = use_async;
|
||||||
// Session-budget accounting (Stage W3): record what this open holds so admission can
|
// Session-budget accounting (Stage W3): record what this open holds so admission can
|
||||||
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
||||||
// mode (a split session occupies one hardware session per engine).
|
// mode (a split session occupies one hardware session per engine).
|
||||||
self.session_units = split_mode_units(split_mode);
|
self.session_units = split_mode_units(used_split);
|
||||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||||
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||||
// info line for the same event here.)
|
// info line for the same event here.)
|
||||||
@@ -986,6 +1228,12 @@ impl NvencD3d11Encoder {
|
|||||||
for _ in 0..POOL {
|
for _ in 0..POOL {
|
||||||
let ev = CreateEventW(None, false, false, PCWSTR::null())
|
let ev = CreateEventW(None, false, false, PCWSTR::null())
|
||||||
.context("CreateEvent (NVENC completion)")?;
|
.context("CreateEvent (NVENC completion)")?;
|
||||||
|
// Push BEFORE registering: a failed registration propagates out of
|
||||||
|
// `init_session` into submit's teardown call, and only handles that made it
|
||||||
|
// into `self.events` get closed there — pushing after the fallible call
|
||||||
|
// leaked the Win32 event on every registration failure. Teardown's
|
||||||
|
// unregister of the one never-registered event is a harmless error return.
|
||||||
|
self.events.push(ev.0 as usize);
|
||||||
let mut ep = nv::NV_ENC_EVENT_PARAMS {
|
let mut ep = nv::NV_ENC_EVENT_PARAMS {
|
||||||
version: nv::NV_ENC_EVENT_PARAMS_VER,
|
version: nv::NV_ENC_EVENT_PARAMS_VER,
|
||||||
completionEvent: ev.0,
|
completionEvent: ev.0,
|
||||||
@@ -994,7 +1242,6 @@ impl NvencD3d11Encoder {
|
|||||||
(api().register_async_event)(enc, &mut ep)
|
(api().register_async_event)(enc, &mut ep)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| nvenc_status::call_err("register_async_event", e))?;
|
.map_err(|e| nvenc_status::call_err("register_async_event", e))?;
|
||||||
self.events.push(ev.0 as usize);
|
|
||||||
}
|
}
|
||||||
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
||||||
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
|
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
|
||||||
@@ -1086,7 +1333,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
let dev_raw = frame.device.as_raw();
|
let dev_raw = frame.device.as_raw();
|
||||||
let size_changed =
|
let size_changed =
|
||||||
self.inited && (self.width != captured.width || self.height != captured.height);
|
self.inited && (self.width != captured.width || self.height != captured.height);
|
||||||
let hdr_changed = self.inited && self.hdr != hdr;
|
// Compare against what was REQUESTED last init, not the effective `self.hdr`: on a GPU
|
||||||
|
// without 10-bit encode `query_caps` clears `self.hdr`, so comparing it against a P010
|
||||||
|
// capturer's perpetual `hdr = true` reported a change on every single frame and tore the
|
||||||
|
// session down and rebuilt it each time.
|
||||||
|
let hdr_changed = self.inited && self.hdr_requested != hdr;
|
||||||
if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
|
if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
device_changed = self.init_device != dev_raw,
|
device_changed = self.init_device != dev_raw,
|
||||||
@@ -1107,7 +1358,13 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
// Adopt the current frame size + colour so the encoder always matches the capturer output.
|
// Adopt the current frame size + colour so the encoder always matches the capturer output.
|
||||||
self.width = captured.width;
|
self.width = captured.width;
|
||||||
self.height = captured.height;
|
self.height = captured.height;
|
||||||
|
self.hdr_requested = hdr;
|
||||||
|
// Effective until `query_caps` says otherwise (it clears this on a card without 10-bit).
|
||||||
self.hdr = hdr;
|
self.hdr = hdr;
|
||||||
|
// Recompute the EFFECTIVE 4:4:4 from the negotiation on every init. This used to read
|
||||||
|
// (and overwrite) `chroma_444` itself, so the first subsampled-YUV frame permanently
|
||||||
|
// demoted the session — 4:4:4 never returned even when the capturer went back to RGB.
|
||||||
|
self.chroma_444 = self.chroma_444_requested;
|
||||||
// Pick the NVENC input format from the captured pixel format. YUV (NV12/P010) is the
|
// Pick the NVENC input format from the captured pixel format. YUV (NV12/P010) is the
|
||||||
// video-processor path — NVENC encodes it natively (no internal RGB→YUV, which is a hidden
|
// video-processor path — NVENC encodes it natively (no internal RGB→YUV, which is a hidden
|
||||||
// 3D/compute step that would fight a GPU-saturating game). RGB (ARGB/ABGR10) is the legacy
|
// 3D/compute step that would fight a GPU-saturating game). RGB (ARGB/ABGR10) is the legacy
|
||||||
@@ -1133,9 +1390,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
};
|
};
|
||||||
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
|
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
|
||||||
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
|
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
|
||||||
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
|
// native YUV despite a 4:4:4 negotiation, THIS session encodes 4:2:0 — clear the
|
||||||
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
|
// effective flag now so `caps().chroma_444` (and native's post-open cross-check)
|
||||||
// the stream really carries instead of silently claiming full chroma.
|
// reports what the stream really carries instead of silently claiming full chroma.
|
||||||
|
// Only the effective value is cleared: `chroma_444_requested` keeps the negotiation, so
|
||||||
|
// a later re-init on an RGB capture recovers 4:4:4 instead of being stuck at 4:2:0.
|
||||||
if self.chroma_444
|
if self.chroma_444
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
self.buffer_fmt,
|
self.buffer_fmt,
|
||||||
@@ -1181,9 +1440,23 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
// despite this comment previously claiming otherwise. Since this backend encodes the
|
// despite this comment previously claiming otherwise. Since this backend encodes the
|
||||||
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
|
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
|
||||||
// rotate a texture out from under a live encode — torn frames, silently.
|
// rotate a texture out from under a live encode — torn frames, silently.
|
||||||
|
// FAIL SAFE when nobody told us. `set_input_ring_depth` is a defaulted trait method, so a
|
||||||
|
// caller that forgets it fails SILENTLY — and one did: the GameStream loop opens an encoder
|
||||||
|
// (`gamestream/stream.rs`) and never calls it, while the Windows IDD-push capturer declares a
|
||||||
|
// ring of 2 and `async_inflight_cap()` defaults to 4. That let a Moonlight session pipeline
|
||||||
|
// four encodes against a two-texture ring, i.e. exactly the in-place overwrite this cap
|
||||||
|
// exists to prevent, as torn/mixed frames and never an error.
|
||||||
|
//
|
||||||
|
// Guarding at the single point of CONSUMPTION rather than at each call site is deliberate:
|
||||||
|
// it covers every present and future caller, including ones that have not been written yet,
|
||||||
|
// whereas plumbing the setter into N loops only fixes the N sites someone remembered. An
|
||||||
|
// unknown ring is treated as the shallowest one any capturer in this tree declares, so the
|
||||||
|
// unconfigured path degrades to less pipelining — a latency cost, not corruption. Callers
|
||||||
|
// that DO configure it are unaffected.
|
||||||
|
const UNCONFIGURED_RING_DEPTH: usize = 2;
|
||||||
let cap = match self.input_ring_depth {
|
let cap = match self.input_ring_depth {
|
||||||
Some(d) => async_inflight_cap().min(d.max(1)),
|
Some(d) => async_inflight_cap().min(d.max(1)),
|
||||||
None => async_inflight_cap(),
|
None => async_inflight_cap().min(UNCONFIGURED_RING_DEPTH),
|
||||||
};
|
};
|
||||||
while self.async_rt.is_some() && self.pending.len() >= cap {
|
while self.async_rt.is_some() && self.pending.len() >= cap {
|
||||||
let done = {
|
let done = {
|
||||||
@@ -1380,6 +1653,8 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
|
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
|
||||||
|
blends_cursor: false,
|
||||||
supports_rfi: self.rfi_supported,
|
supports_rfi: self.rfi_supported,
|
||||||
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
||||||
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
||||||
@@ -1404,44 +1679,37 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||||
// No live session, the GPU can't invalidate, or a nonsense range → caller forces a full IDR.
|
// No live session or the GPU can't invalidate → caller forces a full IDR. (NVENC handles
|
||||||
// (NVENC handles are single-threaded; this runs on the encode thread, like submit/poll.)
|
// are single-threaded; this runs on the encode thread, like submit/poll.) Everything else
|
||||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
// — range validity, covering-range dedup, the DPB window, the clamp — is
|
||||||
|
// `nvenc_core::plan_range_recovery`, one policy for both direct-NVENC backends.
|
||||||
|
if self.encoder.is_null() || !self.rfi_supported {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Already invalidated a covering range for this loss event — no new driver calls needed,
|
match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
|
||||||
// no IDR. RE-ARM the anchor though: the client re-asking means the previous recovery
|
// Already invalidated a covering range for this loss event — no new driver calls
|
||||||
// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor
|
// needed, no IDR. RE-ARM the anchor though: the client re-asking means the previous
|
||||||
// (it too references only valid frames).
|
// recovery anchor AU may itself have been lost, and the next frame is just as clean a
|
||||||
if let Some((pf, pl)) = self.last_rfi_range {
|
// re-anchor (it too references only valid frames).
|
||||||
if first >= pf && last <= pl {
|
RangePlan::Covered => {
|
||||||
self.pending_anchor = true;
|
self.pending_anchor = true;
|
||||||
return true;
|
true
|
||||||
}
|
}
|
||||||
}
|
RangePlan::Decline => false,
|
||||||
// `frame_idx` is the NEXT timestamp to assign, so the last encoded frame is `frame_idx - 1`
|
RangePlan::Invalidate { first, last } => {
|
||||||
// and the DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`. A lost frame older than that
|
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the
|
||||||
// can't be invalidated, so the only correct recovery is an IDR.
|
// WIRE frame index the AU carries — so the client's lost-frame range maps 1:1 onto
|
||||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
// the timestamps NVENC invalidates here, and stays 1:1 across encoder
|
||||||
if first < oldest_in_dpb {
|
// rebuilds/resets (an internal counter would desync on the first adaptive-bitrate
|
||||||
return false;
|
// rebuild and RFI would then clamp every range into first > last, silently
|
||||||
}
|
// degrading to IDR-only forever).
|
||||||
// Clamp to frames we've actually encoded (don't invalidate a timestamp we never assigned).
|
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded
|
||||||
let last = last.min(self.frame_idx - 1);
|
// `EncodeApi` table. `self.encoder` was checked non-null at the top of this fn and
|
||||||
if first > last {
|
// is the live session; this runs on the encode thread (like submit/poll), so there
|
||||||
return false;
|
// is no concurrent NVENC use. The plan clamped each `ts` to
|
||||||
}
|
// `[oldest_in_dpb, frame_idx - 1]`, so it names a frame still in the session's
|
||||||
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the WIRE
|
// DPB; the call passes only that `u64` timestamp (no struct), so there is no
|
||||||
// frame index the AU carries — so the client's lost-frame range maps 1:1 onto the
|
// struct-size or lifetime concern.
|
||||||
// timestamps NVENC invalidates here, and stays 1:1 across encoder rebuilds/resets (an
|
|
||||||
// internal counter would desync on the first adaptive-bitrate rebuild and RFI would then
|
|
||||||
// clamp every range into first > last, silently degrading to IDR-only forever).
|
|
||||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
|
|
||||||
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
|
||||||
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
|
||||||
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
|
||||||
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
|
|
||||||
// lifetime concern.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
for ts in first..=last {
|
for ts in first..=last {
|
||||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||||
@@ -1453,13 +1721,15 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.last_rfi_range = Some((first, last));
|
self.last_rfi_range = Some((first, last));
|
||||||
// The next submitted frame is the first one encoded after the invalidation — the clean
|
// The next submitted frame is the first one encoded after the invalidation — the
|
||||||
// re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and the client
|
// clean re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and
|
||||||
// lifts its post-loss freeze on it (instead of waiting ~1 s for the cooldown-suppressed
|
// the client lifts its post-loss freeze on it (instead of waiting ~1 s for the
|
||||||
// IDR fallback).
|
// cooldown-suppressed IDR fallback).
|
||||||
self.pending_anchor = true;
|
self.pending_anchor = true;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
// Async mode: drain whatever the retrieve thread has finished (non-blocking) and hand out
|
// Async mode: drain whatever the retrieve thread has finished (non-blocking) and hand out
|
||||||
@@ -1552,6 +1822,15 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
self.bitrate_bps = bps;
|
self.bitrate_bps = bps;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||||
|
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||||
|
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||||
|
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||||
|
// [`Encoder::applied_bitrate_bps`].
|
||||||
|
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||||
|
Some(ceiling) => bps.min(ceiling),
|
||||||
|
None => bps,
|
||||||
|
};
|
||||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
|
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
|
||||||
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
|
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
|
||||||
// sanctioned side of the two-thread async split — the retrieve thread only ever locks
|
// sanctioned side of the two-thread async split — the retrieve thread only ever locks
|
||||||
@@ -1600,6 +1879,12 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||||
|
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||||
|
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||||
|
Some(self.bitrate_bps)
|
||||||
|
}
|
||||||
|
|
||||||
fn flush(&mut self) -> Result<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||||
}
|
}
|
||||||
@@ -1645,6 +1930,11 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
|||||||
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
||||||
/// capability that couldn't be confirmed.
|
/// capability that couldn't be confirmed.
|
||||||
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||||
|
// Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never
|
||||||
|
// overlap a zombie reap that could be destroying the very address the driver hands us.
|
||||||
|
let _gate = DRIVER_SESSION_GATE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|p| p.into_inner());
|
||||||
use windows::Win32::Foundation::HMODULE;
|
use windows::Win32::Foundation::HMODULE;
|
||||||
use windows::Win32::Graphics::Direct3D::{
|
use windows::Win32::Graphics::Direct3D::{
|
||||||
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
||||||
@@ -1723,6 +2013,9 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Availability probe, but a real session open all the same: it proves the driver accepted
|
||||||
|
// this build's version word, which is what rules a skew out later (see `nvenc_status`).
|
||||||
|
nvenc_status::note_session_opened();
|
||||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||||
capsToQuery: cap,
|
capsToQuery: cap,
|
||||||
|
|||||||
@@ -189,12 +189,15 @@ impl PyroWaveEncoder {
|
|||||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||||
}
|
}
|
||||||
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) {
|
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4 — see the Linux
|
||||||
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
|
// twin (`enc/linux/pyrowave.rs`) for the full rationale: the negotiator's 4:4:4 → 4:2:0
|
||||||
// slips through (e.g. the lab override) rather than wrap the RDO block index.
|
// downgrade hands oversized modes to this open AS 4:2:0, so the old `chroma444`-gated
|
||||||
|
// check was skipped exactly when it was needed.
|
||||||
|
if !crate::pyrowave_mode_fits_rdo(width, height, chroma444) {
|
||||||
bail!(
|
bail!(
|
||||||
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
|
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
|
||||||
block index (see pyrowave-sys patches/0002 note) — use 4:2:0 at this size"
|
index (see pyrowave-sys patches/0002 note) — lower the resolution",
|
||||||
|
if chroma444 { "4:4:4" } else { "4:2:0" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let fps = fps.max(1);
|
let fps = fps.max(1);
|
||||||
@@ -294,8 +297,10 @@ impl PyroWaveEncoder {
|
|||||||
/// Import one capturer plane D3D11 texture (`R8_UNORM` Y or `R8G8_UNORM` CbCr) into pyrowave's
|
/// Import one capturer plane D3D11 texture (`R8_UNORM` Y or `R8G8_UNORM` CbCr) into pyrowave's
|
||||||
/// Vulkan device. Creates a fresh shared NT handle from the texture (the capturer marked the ring
|
/// Vulkan device. Creates a fresh shared NT handle from the texture (the capturer marked the ring
|
||||||
/// `SHARED | SHARED_NTHANDLE`); `pyrowave_image_create` takes ownership of the handle and closes
|
/// `SHARED | SHARED_NTHANDLE`); `pyrowave_image_create` takes ownership of the handle and closes
|
||||||
/// it on import. Single/two-component textures import reliably on NVIDIA at any size — unlike a
|
/// it on SUCCESSFUL import only (pyrowave-sys patch 0006 — same contract as the fence import in
|
||||||
/// planar NV12 — so no MUTABLE_FORMAT / planar-layout workaround is involved.
|
/// [`Self::import_fence`]), so this fn closes it on every failure return. Single/two-component
|
||||||
|
/// textures import reliably on NVIDIA at any size — unlike a planar NV12 — so no
|
||||||
|
/// MUTABLE_FORMAT / planar-layout workaround is involved.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `texture` must be a live `ID3D11Texture2D` of format `vk_format`, sized `w`×`h`, created
|
/// `texture` must be a live `ID3D11Texture2D` of format `vk_format`, sized `w`×`h`, created
|
||||||
@@ -346,7 +351,11 @@ impl PyroWaveEncoder {
|
|||||||
};
|
};
|
||||||
let mut image: pw::pyrowave_image = std::ptr::null_mut();
|
let mut image: pw::pyrowave_image = std::ptr::null_mut();
|
||||||
if let Err(e) = pw_check(pw::pyrowave_image_create(&info, &mut image), "image_create") {
|
if let Err(e) = pw_check(pw::pyrowave_image_create(&info, &mut image), "image_create") {
|
||||||
// pyrowave only closes the handle on a SUCCESSFUL import — close it ourselves on failure.
|
// pyrowave consumes the handle ONLY on a successful import (pyrowave-sys patch 0006
|
||||||
|
// pinned this at the API's success boundary) — so on EVERY failure return the handle
|
||||||
|
// is still ours and this close is the single one. Before the patch, an
|
||||||
|
// allocate-stage failure inside Granite had already closed it and this was a double
|
||||||
|
// close of a possibly-recycled handle value.
|
||||||
let _ = CloseHandle(handle);
|
let _ = CloseHandle(handle);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
@@ -416,7 +425,9 @@ impl PyroWaveEncoder {
|
|||||||
pw::pyrowave_sync_object_create(&info, &mut sync),
|
pw::pyrowave_sync_object_create(&info, &mut sync),
|
||||||
"sync_object_create",
|
"sync_object_create",
|
||||||
) {
|
) {
|
||||||
// pyrowave only closes the handle on a SUCCESSFUL import — close the dup on failure.
|
// pyrowave only closes the handle on a SUCCESSFUL import (Granite's semaphore import
|
||||||
|
// has always had these semantics; patch 0006 made the image import match) — close
|
||||||
|
// the dup on failure.
|
||||||
let _ = CloseHandle(dup);
|
let _ = CloseHandle(dup);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
@@ -678,6 +689,8 @@ impl Encoder for PyroWaveEncoder {
|
|||||||
// after the caps() default was written — a hardcoded `default()` here mis-reports a 4:4:4
|
// after the caps() default was written — a hardcoded `default()` here mis-reports a 4:4:4
|
||||||
// open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn).
|
// open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn).
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
|
// The Windows capturer composites the pointer itself; this backend never reads it.
|
||||||
|
blends_cursor: false,
|
||||||
chroma_444: self.chroma444,
|
chroma_444: self.chroma444,
|
||||||
..EncoderCaps::default()
|
..EncoderCaps::default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,9 +519,20 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
|||||||
}
|
}
|
||||||
b.WhitePointX = m.white_point[0];
|
b.WhitePointX = m.white_point[0];
|
||||||
b.WhitePointY = m.white_point[1];
|
b.WhitePointY = m.white_point[1];
|
||||||
// Units diverge on the max: VPL wants whole cd/m² (HdrMeta carries 0.0001 cd/m²); the
|
// BOTH luminance fields are 0.0001 cd/m² here — do NOT scale the max.
|
||||||
// min is 0.0001 cd/m² on both sides.
|
//
|
||||||
b.MaxDisplayMasteringLuminance = m.max_display_mastering_luminance / 10_000;
|
// The `/10_000` this used to carry followed the VPL header's *video-processing* unit
|
||||||
|
// (whole cd/m², which is right for VPP), but on the ENCODE path the runtime passes these
|
||||||
|
// straight into the ITU-T H.265 Annex D mastering-display SEI, whose unit is 0.0001 cd/m².
|
||||||
|
// Dividing therefore under-reported peak brightness by 10,000x. Confirmed in a real
|
||||||
|
// bitstream on Intel UHD 750 (2026-07-25): SEI 137 carried
|
||||||
|
// max_display_mastering_luminance = 1000, i.e. **0.1 nits** for a 1000-nit display, while
|
||||||
|
// the undivided min = 500 (0.05 nits) was correct — the asymmetry was the tell. A client
|
||||||
|
// tone-mapping against 0.1 nits crushes the image.
|
||||||
|
//
|
||||||
|
// Unscaled also matches every sibling: `MinDisplayMasteringLuminance` right below,
|
||||||
|
// `pf_frame::hdr::hevc_mastering_display_sei`, and the AMF backend.
|
||||||
|
b.MaxDisplayMasteringLuminance = m.max_display_mastering_luminance;
|
||||||
b.MinDisplayMasteringLuminance = m.min_display_mastering_luminance;
|
b.MinDisplayMasteringLuminance = m.min_display_mastering_luminance;
|
||||||
b
|
b
|
||||||
});
|
});
|
||||||
@@ -690,13 +701,17 @@ impl Inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn take_bs(&mut self) -> Box<BsBuf> {
|
fn take_bs(&mut self) -> Box<BsBuf> {
|
||||||
match self.bs_pool.pop() {
|
// Pooled buffers were sized by whatever `bs_bytes` was when they were allocated. A bitrate
|
||||||
Some(mut b) => {
|
// retarget raises the driver's worst-case AU size, so a recycled buffer can be SMALLER than
|
||||||
|
// the current requirement — hand those back to the allocator instead of letting the runtime
|
||||||
|
// write an AU into a short buffer.
|
||||||
|
while let Some(mut b) = self.bs_pool.pop() {
|
||||||
|
if b.mfx.MaxLength as usize >= self.bs_bytes {
|
||||||
b.recycle();
|
b.recycle();
|
||||||
b
|
return b;
|
||||||
}
|
}
|
||||||
None => BsBuf::new(self.bs_bytes),
|
|
||||||
}
|
}
|
||||||
|
BsBuf::new(self.bs_bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +789,12 @@ impl QsvEncoder {
|
|||||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||||
bail!("this GPU/driver declined AV1 encode (DG2/Arc or MTL+ required) — QSV probe");
|
bail!("this GPU/driver declined AV1 encode (DG2/Arc or MTL+ required) — QSV probe");
|
||||||
}
|
}
|
||||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
// Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
|
||||||
|
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
|
||||||
|
// capture derived `expected = P010` and hit the bail below, ending the session at open —
|
||||||
|
// and taking the ffmpeg fallback with it, since that path had the same defect in a worse
|
||||||
|
// form (it accepted the open and then failed every frame).
|
||||||
|
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||||
if ten_bit && codec == Codec::H264 {
|
if ten_bit && codec == Codec::H264 {
|
||||||
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
|
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
|
||||||
}
|
}
|
||||||
@@ -878,7 +898,8 @@ impl QsvEncoder {
|
|||||||
if sts > vpl::MFX_ERR_NONE {
|
if sts > vpl::MFX_ERR_NONE {
|
||||||
tracing::debug!(status = sts_name(sts), "QSV Init returned a warning");
|
tracing::debug!(status = sts_name(sts), "QSV Init returned a warning");
|
||||||
}
|
}
|
||||||
let ir_active = cfg.intra_refresh && set.co2.is_some();
|
// Whether we ASKED for intra-refresh. Not the same as getting it — confirmed below.
|
||||||
|
let ir_requested = cfg.intra_refresh && set.co2.is_some();
|
||||||
// The driver's own answer for the worst-case AU size.
|
// The driver's own answer for the worst-case AU size.
|
||||||
// SAFETY: `session` is live; `got` and its (empty) ext chain outlive the call.
|
// SAFETY: `session` is live; `got` and its (empty) ext chain outlive the call.
|
||||||
let bs_bytes = unsafe {
|
let bs_bytes = unsafe {
|
||||||
@@ -892,6 +913,57 @@ impl QsvEncoder {
|
|||||||
let kb = enc_of(m).BufferSizeInKB as usize;
|
let kb = enc_of(m).BufferSizeInKB as usize;
|
||||||
(kb * mult * 1000).max(256 * 1024)
|
(kb * mult * 1000).max(256 * 1024)
|
||||||
};
|
};
|
||||||
|
// Intra-refresh HONESTY: ask the driver what it actually installed instead of trusting that
|
||||||
|
// it took what we sent. Both `Query` and `Init` can return MFX_WRN_INCOMPATIBLE_VIDEO_PARAM
|
||||||
|
// — a WARNING, which the code above accepts — while silently dropping the wave. Confirmed
|
||||||
|
// on Intel UHD 750 (2026-07-25): H.264 reports exactly that, `GetVideoParam` comes back with
|
||||||
|
// `IntRefType = 0`, and `ir_active` still claimed true. H.265 on the same GPU is genuinely
|
||||||
|
// active, so this is per-codec and cannot be decided statically.
|
||||||
|
//
|
||||||
|
// That lie is not cosmetic: `ir_active` feeds `EncoderCaps::intra_refresh`, so the session
|
||||||
|
// advertises gradual refresh to the client and then never emits it — the client stops
|
||||||
|
// requesting the IDRs it would otherwise have asked for on loss, and a lost frame is
|
||||||
|
// concealed instead of repaired.
|
||||||
|
//
|
||||||
|
// Deliberately a SEPARATE, best-effort query rather than a buffer chained onto the
|
||||||
|
// `BufferSizeInKB` call above (which is what the audit finding suggested): that value is
|
||||||
|
// load-bearing for every bitstream allocation, and a runtime that dislikes the chained
|
||||||
|
// buffer would take it down with the readback. A failure here costs only the verdict, and
|
||||||
|
// is resolved conservatively.
|
||||||
|
let ir_active = if ir_requested {
|
||||||
|
// SAFETY: `session` is live on this thread; `got` and `co2_out` (with `ptrs` holding the
|
||||||
|
// only reference to it) all outlive the synchronous call, and the runtime writes back
|
||||||
|
// only into the buffer whose header we stamped.
|
||||||
|
let confirmed = unsafe {
|
||||||
|
let mut got: vpl::mfxVideoParam = std::mem::zeroed();
|
||||||
|
let mut co2_out: vpl::mfxExtCodingOption2 = std::mem::zeroed();
|
||||||
|
co2_out.Header.BufferId = vpl::MFX_EXTBUFF_CODING_OPTION2 as u32;
|
||||||
|
co2_out.Header.BufferSz = std::mem::size_of::<vpl::mfxExtCodingOption2>() as u32;
|
||||||
|
let mut ptrs: [*mut vpl::mfxExtBuffer; 1] = [&mut co2_out.Header as *mut _];
|
||||||
|
got.ExtParam = ptrs.as_mut_ptr();
|
||||||
|
got.NumExtParam = 1;
|
||||||
|
let sts = vpl::MFXVideoENCODE_GetVideoParam(session, &mut got);
|
||||||
|
if sts < vpl::MFX_ERR_NONE {
|
||||||
|
tracing::debug!(
|
||||||
|
status = sts_name(sts),
|
||||||
|
"QSV: could not read back CodingOption2 — trusting the intra-refresh request"
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
co2_out.IntRefType != 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !confirmed {
|
||||||
|
tracing::warn!(
|
||||||
|
codec = ?cfg.codec,
|
||||||
|
"QSV silently dropped intra-refresh (GetVideoParam reports IntRefType=0) — \
|
||||||
|
advertising it OFF so the client keeps asking for IDRs on loss"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
confirmed
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
Ok((ltr_active, ir_active, bs_bytes))
|
Ok((ltr_active, ir_active, bs_bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1344,36 +1416,30 @@ impl Encoder for QsvEncoder {
|
|||||||
if !self.ltr_active || first < 0 || first > last {
|
if !self.ltr_active || first < 0 || first > last {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
// The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
|
||||||
// encoded inside the client's corrupt window — the client either never received it or
|
// shared with AMF and Vulkan Video). This backend's mechanism: distrust is a SEPARATE
|
||||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
// `ltr_tainted` flag, never a cleared mirror slot — `ltr_slots` mirrors the HARDWARE DPB,
|
||||||
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
|
// and nulling an entry issues no VPL call, so the frame stays marked long-term in the
|
||||||
// the soup — the sustained-loss field failure where the picture never healed. Dropped
|
// encoder. Clearing it made the rejection list below (which iterates the mirror and only
|
||||||
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
// names `Some` slots) silently SKIP the one entry the sweep exists to distrust, so the
|
||||||
//
|
// recovery frame could still predict from it. With two slots the "exactly one swept" case
|
||||||
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
|
// is the modal one, and it was the broken one. The `!ltr_tainted` view filter below is
|
||||||
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
|
// what persists that distrust across loss events (this call's taints are excluded from
|
||||||
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
|
// this call's anchor by the policy itself).
|
||||||
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
|
let view: Vec<(usize, i64)> = self
|
||||||
// could still predict from it. With two slots the "exactly one swept" case is the modal
|
.ltr_slots
|
||||||
// one, and it was the broken one.
|
.iter()
|
||||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
.enumerate()
|
||||||
if marked.is_some_and(|idx| idx >= first) {
|
.filter(|&(slot, _)| !self.ltr_tainted[slot])
|
||||||
self.ltr_tainted[slot] = true;
|
.filter_map(|(s, m)| m.map(|w| (s, w)))
|
||||||
|
.collect();
|
||||||
|
let plan = super::rfi::plan_slot_recovery(&view, first);
|
||||||
|
for (slot, tainted) in self.ltr_tainted.iter_mut().enumerate() {
|
||||||
|
if plan.tainted & (1 << slot) != 0 {
|
||||||
|
*tainted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut best: Option<(usize, i64)> = None;
|
match plan.anchor {
|
||||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
|
||||||
if self.ltr_tainted[slot] {
|
|
||||||
continue; // still in the DPB, but encoded inside the corrupt window
|
|
||||||
}
|
|
||||||
if let Some(idx) = *marked {
|
|
||||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
|
||||||
best = Some((slot, idx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match best {
|
|
||||||
Some((slot, ltr_frame)) => {
|
Some((slot, ltr_frame)) => {
|
||||||
self.pending_force = Some(slot);
|
self.pending_force = Some(slot);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -1402,6 +1468,8 @@ impl Encoder for QsvEncoder {
|
|||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
|
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||||
|
blends_cursor: false,
|
||||||
supports_rfi: self.ltr_active,
|
supports_rfi: self.ltr_active,
|
||||||
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
|
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
|
||||||
// are never HDR.
|
// are never HDR.
|
||||||
@@ -1576,6 +1644,38 @@ impl Encoder for QsvEncoder {
|
|||||||
self.bitrate_bps = old;
|
self.bitrate_bps = old;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Re-read the driver's worst-case AU size. `Reset` re-derives `BufferSizeInKB` from the new
|
||||||
|
// rate, and a step-up can raise it well above what `init_encode` computed — leaving
|
||||||
|
// `bs_bytes` (and every buffer already in the pool) sized for the OLD, lower bitrate. This
|
||||||
|
// mirrors `init_encode`, which asks the same question post-Init; `take_bs` drops any pooled
|
||||||
|
// buffer that is now too small.
|
||||||
|
// SAFETY: `session` is live on this thread and drained above; `got` and its (empty) ext
|
||||||
|
// chain outlive the synchronous call.
|
||||||
|
let refreshed = unsafe {
|
||||||
|
let mut got: vpl::mfxVideoParam = std::mem::zeroed();
|
||||||
|
let sts = vpl::MFXVideoENCODE_GetVideoParam(session, &mut got);
|
||||||
|
(sts >= vpl::MFX_ERR_NONE).then(|| {
|
||||||
|
let m = &mut got.__bindgen_anon_1.mfx;
|
||||||
|
let mult = m.BRCParamMultiplier.max(1) as usize;
|
||||||
|
let kb = enc_of(m).BufferSizeInKB as usize;
|
||||||
|
(kb * mult * 1000).max(256 * 1024)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match (refreshed, self.inner.as_mut()) {
|
||||||
|
(Some(bytes), Some(inner)) if bytes > inner.bs_bytes => {
|
||||||
|
tracing::debug!(
|
||||||
|
old_bytes = inner.bs_bytes,
|
||||||
|
new_bytes = bytes,
|
||||||
|
mbps = bps / 1_000_000,
|
||||||
|
"QSV retarget raised the worst-case AU size — resizing the bitstream pool"
|
||||||
|
);
|
||||||
|
inner.bs_bytes = bytes;
|
||||||
|
}
|
||||||
|
(None, _) => tracing::warn!(
|
||||||
|
"QSV retarget: GetVideoParam failed, keeping the previous AU buffer size"
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+462
-80
@@ -5,9 +5,11 @@
|
|||||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
|
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
|
||||||
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
|
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
|
||||||
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
|
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
|
||||||
// Scaffold: some backend paths + trait defaults are defined ahead of the per-feature build that
|
// NOTE: no crate-wide `#![allow(dead_code)]`. It was inherited from the pre-extraction host crate
|
||||||
// uses them (mirrors the host crate root's allow before the extraction).
|
// root as scaffolding for backend paths defined ahead of the build that used them, but a census
|
||||||
#![allow(dead_code)]
|
// across every feature combination on both platforms found it was hiding exactly two items — so it
|
||||||
|
// bought nothing and blinded the crate to future rot. Genuinely test-only helpers carry
|
||||||
|
// `#[cfg(test)]` instead.
|
||||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||||
// program). As a parent module this also covers the child modules (windows/linux backends).
|
// program). As a parent module this also covers the child modules (windows/linux backends).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
@@ -39,11 +41,14 @@ impl Codec {
|
|||||||
// per-session instead (the host `session_plan::SessionPlan::output_format`) — the EGL→CUDA
|
// per-session instead (the host `session_plan::SessionPlan::output_format`) — the EGL→CUDA
|
||||||
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
||||||
// keeps the bit off (no Vulkan device to open).
|
// keeps the bit off (no Vulkan device to open).
|
||||||
|
// Resolved ONCE for every gate below (pyro bit, software pin, vulkan ceiling, the
|
||||||
|
// zero-copy plane): this fn backs the codec advertisement on polled paths, and the
|
||||||
|
// resolver's auto arm samples live GPU-preference state — four samples per call was the
|
||||||
|
// probe-amplification the WP7.6 diff critic caught.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let backend = linux_resolved_backend();
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
let pyro = if !matches!(
|
let pyro = if backend != LinuxBackend::Software {
|
||||||
pf_host_config::config().encoder_pref.as_str(),
|
|
||||||
"software" | "sw" | "openh264"
|
|
||||||
) {
|
|
||||||
punktfunk_core::quic::CODEC_PYROWAVE
|
punktfunk_core::quic::CODEC_PYROWAVE
|
||||||
} else {
|
} else {
|
||||||
0u8
|
0u8
|
||||||
@@ -70,19 +75,41 @@ impl Codec {
|
|||||||
| punktfunk_core::quic::CODEC_AV1;
|
| punktfunk_core::quic::CODEC_AV1;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
if matches!(
|
if backend == LinuxBackend::Software {
|
||||||
pf_host_config::config().encoder_pref.as_str(),
|
|
||||||
"software" | "sw" | "openh264"
|
|
||||||
) {
|
|
||||||
return punktfunk_core::quic::CODEC_H264;
|
return punktfunk_core::quic::CODEC_H264;
|
||||||
}
|
}
|
||||||
if linux_zero_copy_is_vaapi() {
|
// A pref that FORCES the raw Vulkan Video backend can only ever serve what that
|
||||||
|
// backend encodes: `open_video_backend`'s `vulkan` arm bails outright for anything
|
||||||
|
// that is not HEVC/AV1 ("the Vulkan Video encoder supports HEVC + AV1; the session
|
||||||
|
// negotiated {codec:?}"). Advertising H.264 there let a client negotiate it and die
|
||||||
|
// at encoder open. Without the `vulkan-encode` feature that arm cannot open at all,
|
||||||
|
// so the pref advertises nothing.
|
||||||
|
//
|
||||||
|
// This is a CEILING intersected with the device probe below, never a replacement
|
||||||
|
// for it: pinning a static HEVC|AV1 would ADD AV1 on the AMD/Intel hosts whose probe
|
||||||
|
// currently withholds it (pre-RDNA3, pre-Arc), i.e. it would re-create this very bug
|
||||||
|
// for a different codec. Intersecting can only narrow.
|
||||||
|
let pref_ceiling: u8 = match backend {
|
||||||
|
// Feature-blindness rider (WP7.6 critics): the resolver knows the PREF is
|
||||||
|
// vulkan; only this cfg! knows the BUILD can open it. Dropping the inner
|
||||||
|
// branch would advertise HEVC|AV1 on a featureless build — the
|
||||||
|
// advertise-then-die-at-open bug this ceiling exists to prevent.
|
||||||
|
LinuxBackend::Vulkan => {
|
||||||
|
if cfg!(feature = "vulkan-encode") {
|
||||||
|
punktfunk_core::quic::CODEC_HEVC | punktfunk_core::quic::CODEC_AV1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => GPU_SUPERSET,
|
||||||
|
};
|
||||||
|
if linux_zero_copy_is_vaapi_for(backend) {
|
||||||
if let Some(m) = vaapi_codec_support().wire_mask() {
|
if let Some(m) = vaapi_codec_support().wire_mask() {
|
||||||
return m;
|
return m & pref_ceiling;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
||||||
GPU_SUPERSET
|
GPU_SUPERSET & pref_ceiling
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -175,6 +202,23 @@ pub fn open_video(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
||||||
|
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
|
||||||
|
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
|
||||||
|
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
|
||||||
|
//
|
||||||
|
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
|
||||||
|
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
|
||||||
|
// the only layer that can fall back to capturer-side compositing. This makes the condition
|
||||||
|
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
|
||||||
|
if cursor_blend && !inner.caps().blends_cursor {
|
||||||
|
tracing::warn!(
|
||||||
|
backend,
|
||||||
|
"session negotiated a composited cursor but this encode backend does not blend \
|
||||||
|
CapturedFrame::cursor — the pointer will be MISSING from the stream unless the \
|
||||||
|
capturer composites it"
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(Box::new(TrackedEncoder {
|
Ok(Box::new(TrackedEncoder {
|
||||||
inner,
|
inner,
|
||||||
_session: pf_gpu::session_begin(gpu),
|
_session: pf_gpu::session_begin(gpu),
|
||||||
@@ -241,6 +285,12 @@ impl Encoder for TrackedEncoder {
|
|||||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||||
self.inner.reconfigure_bitrate(bps)
|
self.inner.reconfigure_bitrate(bps)
|
||||||
}
|
}
|
||||||
|
// Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None`
|
||||||
|
// would tell the session loop "no encoder truth here" and it would keep pacing/acking the
|
||||||
|
// requested rate even where NVENC clamped to the codec-level ceiling.
|
||||||
|
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||||
|
self.inner.applied_bitrate_bps()
|
||||||
|
}
|
||||||
fn flush(&mut self) -> Result<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
self.inner.flush()
|
self.inner.flush()
|
||||||
}
|
}
|
||||||
@@ -253,12 +303,14 @@ impl Encoder for TrackedEncoder {
|
|||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||||
|
|
||||||
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
/// The Linux half of [`open_video_backend`], with the pref INJECTED — the seam exists so the
|
||||||
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
/// dispatch is testable without mutating process env (`set_var` races `getenv` in parallel
|
||||||
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
/// tests) or fighting the once-latched `pf_host_config::config()`. Production takes the
|
||||||
/// (e.g. Vulkan Video falling back to VAAPI).
|
/// config pref via the caller; shared validation (dims/fps/chroma degrade) stays there too.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn open_video_backend(
|
fn open_video_backend_linux(
|
||||||
|
pref: &str,
|
||||||
codec: Codec,
|
codec: Codec,
|
||||||
format: PixelFormat,
|
format: PixelFormat,
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -270,31 +322,6 @@ fn open_video_backend(
|
|||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
cursor_blend: bool,
|
cursor_blend: bool,
|
||||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||||
let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below
|
|
||||||
validate_dimensions(codec, width, height)?;
|
|
||||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
|
||||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
|
||||||
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
|
|
||||||
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
|
|
||||||
// regardless of which backend opens (security-review 2026-06-28 S5).
|
|
||||||
if fps == 0 || fps > 1000 {
|
|
||||||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
|
||||||
}
|
|
||||||
// 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another
|
|
||||||
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future
|
|
||||||
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request
|
|
||||||
// degrades to 4:2:0 with a warning.
|
|
||||||
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
|
|
||||||
tracing::warn!(
|
|
||||||
?codec,
|
|
||||||
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
|
||||||
);
|
|
||||||
ChromaFormat::Yuv420
|
|
||||||
} else {
|
|
||||||
chroma
|
|
||||||
};
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
||||||
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
||||||
if codec == Codec::PyroWave {
|
if codec == Codec::PyroWave {
|
||||||
@@ -313,7 +340,6 @@ fn open_video_backend(
|
|||||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||||
// its errors crisply instead of silently trying the other).
|
// its errors crisply instead of silently trying the other).
|
||||||
let pref = pf_host_config::config().encoder_pref.as_str();
|
|
||||||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||||||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||||||
@@ -398,11 +424,14 @@ fn open_video_backend(
|
|||||||
)
|
)
|
||||||
.map(|e| (e, "nvenc"))
|
.map(|e| (e, "nvenc"))
|
||||||
};
|
};
|
||||||
match pref {
|
// WP7.6: the dispatch consumes the SAME resolver the capability mirrors consult (the
|
||||||
"nvenc" | "nvidia" | "cuda" => open_nvidia(),
|
// Windows shape — `windows_resolved_backend` + `match backend` below) so the alias table
|
||||||
"vaapi" | "amd" | "intel" => open_amd_intel(),
|
// exists once. Arm bodies and their open-site labels are untouched.
|
||||||
|
match resolve_linux_backend(pref, linux_auto_is_vaapi, cuda) {
|
||||||
|
Some(LinuxBackend::Nvenc) => open_nvidia(),
|
||||||
|
Some(LinuxBackend::AmdIntel) => open_amd_intel(),
|
||||||
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
||||||
"vulkan" | "vulkan-video" => {
|
Some(LinuxBackend::Vulkan) => {
|
||||||
#[cfg(feature = "vulkan-encode")]
|
#[cfg(feature = "vulkan-encode")]
|
||||||
{
|
{
|
||||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||||
@@ -434,7 +463,7 @@ fn open_video_backend(
|
|||||||
// client can decode the stream yet, so this arm exists for host-side bring-up and
|
// client can decode the stream yet, so this arm exists for host-side bring-up and
|
||||||
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
|
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
|
||||||
// codec — every AU is an independently-decodable wavelet frame.
|
// codec — every AU is an independently-decodable wavelet frame.
|
||||||
"pyrowave" => {
|
Some(LinuxBackend::Pyrowave) => {
|
||||||
#[cfg(feature = "pyrowave")]
|
#[cfg(feature = "pyrowave")]
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -467,7 +496,7 @@ fn open_video_backend(
|
|||||||
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
||||||
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
||||||
// RGB frame, which the capturer delivers because the software backend resolves `gpu=false`.
|
// RGB frame, which the capturer delivers because the software backend resolves `gpu=false`.
|
||||||
"software" | "sw" | "openh264" => {
|
Some(LinuxBackend::Software) => {
|
||||||
if codec != Codec::H264 {
|
if codec != Codec::H264 {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"the software encoder emits H.264 only; the session negotiated {codec:?} \
|
"the software encoder emits H.264 only; the session negotiated {codec:?} \
|
||||||
@@ -484,20 +513,69 @@ fn open_video_backend(
|
|||||||
)
|
)
|
||||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||||
}
|
}
|
||||||
"auto" | "" => {
|
// The auto arm lives in the resolver now (a CUDA frame can only be consumed by
|
||||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
// NVENC; else the shared manual-preference/NVIDIA-presence decision).
|
||||||
// (manual web-console GPU preference, else the NVIDIA-presence probe) picks the
|
None => anyhow::bail!(
|
||||||
// backend — see `linux_auto_is_vaapi`.
|
"unknown PUNKTFUNK_ENCODER={pref:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
||||||
if cuda || !linux_auto_is_vaapi() {
|
|
||||||
open_nvidia()
|
|
||||||
} else {
|
|
||||||
open_amd_intel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => anyhow::bail!(
|
|
||||||
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
||||||
|
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
||||||
|
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
||||||
|
/// (e.g. Vulkan Video falling back to VAAPI).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn open_video_backend(
|
||||||
|
codec: Codec,
|
||||||
|
format: PixelFormat,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: u32,
|
||||||
|
bitrate_bps: u64,
|
||||||
|
cuda: bool,
|
||||||
|
bit_depth: u8,
|
||||||
|
chroma: ChromaFormat,
|
||||||
|
cursor_blend: bool,
|
||||||
|
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||||
|
let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below
|
||||||
|
validate_dimensions(codec, width, height)?;
|
||||||
|
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||||
|
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||||
|
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
|
||||||
|
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
|
||||||
|
// regardless of which backend opens (security-review 2026-06-28 S5).
|
||||||
|
if fps == 0 || fps > 1000 {
|
||||||
|
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||||||
|
}
|
||||||
|
// 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another
|
||||||
|
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future
|
||||||
|
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request
|
||||||
|
// degrades to 4:2:0 with a warning.
|
||||||
|
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
|
||||||
|
tracing::warn!(
|
||||||
|
?codec,
|
||||||
|
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
||||||
|
);
|
||||||
|
ChromaFormat::Yuv420
|
||||||
|
} else {
|
||||||
|
chroma
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
open_video_backend_linux(
|
||||||
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
bitrate_bps,
|
||||||
|
cuda,
|
||||||
|
bit_depth,
|
||||||
|
chroma,
|
||||||
|
cursor_blend,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -611,8 +689,17 @@ fn open_video_backend(
|
|||||||
// Phase 4 after the field-silence gate.
|
// Phase 4 after the field-silence gate.
|
||||||
#[cfg(feature = "qsv")]
|
#[cfg(feature = "qsv")]
|
||||||
{
|
{
|
||||||
let ffmpeg_forced = std::env::var("PUNKTFUNK_QSV_FFMPEG")
|
// Trimmed like every sibling knob, so `"1 "` from a shell script or a `.env`
|
||||||
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
|
// line takes effect instead of silently doing nothing. Case-insensitivity is
|
||||||
|
// kept deliberately — this knob already accepted `TRUE` via
|
||||||
|
// `eq_ignore_ascii_case`, and the bare house `matches!` would have dropped
|
||||||
|
// that spelling; widening to `yes`/`on` only adds accepted values.
|
||||||
|
let ffmpeg_forced = std::env::var("PUNKTFUNK_QSV_FFMPEG").is_ok_and(|v| {
|
||||||
|
matches!(
|
||||||
|
v.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on"
|
||||||
|
)
|
||||||
|
});
|
||||||
if !ffmpeg_forced {
|
if !ffmpeg_forced {
|
||||||
match qsv::QsvEncoder::open(
|
match qsv::QsvEncoder::open(
|
||||||
codec,
|
codec,
|
||||||
@@ -853,18 +940,31 @@ fn vulkan_encode_enabled() -> bool {
|
|||||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||||||
/// escapes at the capture gate).
|
/// escapes at the capture gate).
|
||||||
|
///
|
||||||
|
/// This verdict is load-bearing in a way the other capability helpers are not: once the producer
|
||||||
|
/// has been asked for two-plane NV12 there is **no fallback**. [`open_video`] deliberately makes a
|
||||||
|
/// failed Vulkan open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
|
||||||
|
/// would import that buffer as packed RGB and stream silent garbage. So a wrong `true` here does
|
||||||
|
/// not cost quality — it kills the session at its first frame. Hence the real device probe below,
|
||||||
|
/// and hence the conjuncts are ordered cheapest-first so it only runs when everything else already
|
||||||
|
/// said yes.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||||
#[cfg(feature = "vulkan-encode")]
|
#[cfg(feature = "vulkan-encode")]
|
||||||
{
|
{
|
||||||
matches!(codec, Codec::H265 | Codec::Av1)
|
matches!(codec, Codec::H265 | Codec::Av1)
|
||||||
&& vulkan_encode_enabled()
|
&& vulkan_encode_enabled()
|
||||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
// Which backend this host actually resolves to. This used to be a denylist of the
|
||||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
// EXPLICIT prefs that skip Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which
|
||||||
&& !matches!(
|
// silently missed the one that matters: the DEFAULT `encoder_pref` is `""`, and `""`
|
||||||
pf_host_config::config().encoder_pref.as_str(),
|
// resolves to `auto`, which on an NVIDIA box opens NVENC. So a stock NVIDIA host passed
|
||||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
// this gate. `linux_zero_copy_is_vaapi` layers the pref on top of the same auto decision
|
||||||
)
|
// `open_video` makes, which is exactly what the note on `linux_auto_is_vaapi` says a
|
||||||
|
// capability probe must consult — and it is what the downstream consumer of this very
|
||||||
|
// verdict already uses to pick its zero-copy path.
|
||||||
|
&& linux_zero_copy_is_vaapi()
|
||||||
|
// …and only then ask the GPU. Ordered last on purpose: it opens a Vulkan instance.
|
||||||
|
&& vulkan_encode_available(codec)
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "vulkan-encode"))]
|
#[cfg(not(feature = "vulkan-encode"))]
|
||||||
{
|
{
|
||||||
@@ -873,6 +973,42 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||||||
|
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||||||
|
///
|
||||||
|
/// Only [`linux_native_nv12_ok`] consults this, and only for the no-fallback decision described
|
||||||
|
/// there. It is deliberately NOT wired into [`open_video`]'s dispatch: that path already degrades
|
||||||
|
/// to VAAPI on a failed open for every non-NV12 capture, so making it pay for a probe would buy
|
||||||
|
/// nothing. Prediction and truth stay separate — the probe answers "would it open", the open itself
|
||||||
|
/// reports what actually did.
|
||||||
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
|
fn vulkan_encode_available(codec: Codec) -> bool {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||||
|
let key = (pf_gpu::selection_key(), codec.label());
|
||||||
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||||
|
return *v;
|
||||||
|
}
|
||||||
|
let verdict = vulkan_video::probe_encode_support(codec);
|
||||||
|
let ok = verdict.is_ok();
|
||||||
|
match &verdict {
|
||||||
|
Ok(()) => tracing::info!(
|
||||||
|
?codec,
|
||||||
|
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
|
||||||
|
),
|
||||||
|
Err(why) => tracing::info!(
|
||||||
|
?codec,
|
||||||
|
why = *why,
|
||||||
|
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
|
||||||
|
(the native-NV12 path has no VAAPI fallback)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cache.lock().unwrap().insert(key, ok);
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||||
@@ -904,6 +1040,67 @@ fn linux_auto_is_vaapi() -> bool {
|
|||||||
!nvidia_present()
|
!nvidia_present()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The resolved Linux encode backend — the Linux twin of [`WindowsBackend`] (WP7.6). ONE alias
|
||||||
|
/// table, consumed by BOTH [`open_video_backend`]'s dispatch and every capability/advertisement
|
||||||
|
/// mirror below, so the two can never drift again (the audit counted five partial hand-copies of
|
||||||
|
/// this decision). Labels still come from the open sites (the mgmt-record convention) — this
|
||||||
|
/// resolves which ARM runs, never what actually opened.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum LinuxBackend {
|
||||||
|
Nvenc,
|
||||||
|
AmdIntel,
|
||||||
|
Vulkan,
|
||||||
|
Pyrowave,
|
||||||
|
Software,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure core. `None` = the pref names no backend — [`open_video_backend`] BAILS on that,
|
||||||
|
/// while the capability mirrors historically resolved it as auto (via
|
||||||
|
/// [`linux_resolved_backend`]); that divergence is recorded, not accidental. `auto_is_vaapi` is
|
||||||
|
/// deliberately LAZY: explicit prefs must stay zero-probe — `/serverinfo` polls through the
|
||||||
|
/// mirrors, and `gamestream/serverinfo.rs` already litigated exactly that probe cost.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn resolve_linux_backend(
|
||||||
|
pref: &str,
|
||||||
|
auto_is_vaapi: impl FnOnce() -> bool,
|
||||||
|
cuda: bool,
|
||||||
|
) -> Option<LinuxBackend> {
|
||||||
|
Some(match pref {
|
||||||
|
"nvenc" | "nvidia" | "cuda" => LinuxBackend::Nvenc,
|
||||||
|
"vaapi" | "amd" | "intel" => LinuxBackend::AmdIntel,
|
||||||
|
"vulkan" | "vulkan-video" => LinuxBackend::Vulkan,
|
||||||
|
"pyrowave" => LinuxBackend::Pyrowave,
|
||||||
|
"software" | "sw" | "openh264" => LinuxBackend::Software,
|
||||||
|
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||||
|
// (manual web-console GPU preference, else the NVIDIA-presence probe) picks the backend —
|
||||||
|
// see `linux_auto_is_vaapi`.
|
||||||
|
"auto" | "" => {
|
||||||
|
if cuda || !auto_is_vaapi() {
|
||||||
|
LinuxBackend::Nvenc
|
||||||
|
} else {
|
||||||
|
LinuxBackend::AmdIntel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config-reading wrapper for the capability/advertisement mirrors: an unknown pref resolves as
|
||||||
|
/// auto — exactly what every mirror's old `_` arm did. `cuda = false`: the mirrors answer
|
||||||
|
/// pre-session questions (a CUDA payload exists only inside a live NVIDIA session).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_resolved_backend() -> LinuxBackend {
|
||||||
|
let pref = pf_host_config::config().encoder_pref.as_str();
|
||||||
|
resolve_linux_backend(pref, linux_auto_is_vaapi, false).unwrap_or_else(|| {
|
||||||
|
if linux_auto_is_vaapi() {
|
||||||
|
LinuxBackend::AmdIntel
|
||||||
|
} else {
|
||||||
|
LinuxBackend::Nvenc
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The dmabuf modifiers the PyroWave encoder's Vulkan device imports for the capture's
|
/// The dmabuf modifiers the PyroWave encoder's Vulkan device imports for the capture's
|
||||||
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
||||||
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
||||||
@@ -917,13 +1114,25 @@ pub fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
|||||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||||
match pf_host_config::config().encoder_pref.as_str() {
|
linux_zero_copy_is_vaapi_for(linux_resolved_backend())
|
||||||
"nvenc" | "nvidia" | "cuda" => false,
|
}
|
||||||
"vaapi" | "amd" | "intel" => true,
|
/// The zero-copy-plane decision for an ALREADY-resolved backend — so a caller that has just
|
||||||
|
/// resolved (e.g. `host_wire_caps`, which consults several gates per call on a POLLED endpoint)
|
||||||
|
/// pays for the resolution once instead of once per gate (the `serverinfo.rs` probe-cost class,
|
||||||
|
/// caught by the WP7.6 diff critic).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_zero_copy_is_vaapi_for(backend: LinuxBackend) -> bool {
|
||||||
|
match backend {
|
||||||
|
LinuxBackend::Nvenc => false,
|
||||||
|
LinuxBackend::AmdIntel => true,
|
||||||
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
||||||
// vendor — it must get the passthrough payload, never the EGL→CUDA import.
|
// vendor — it must get the passthrough payload, never the EGL→CUDA import.
|
||||||
"pyrowave" => true,
|
LinuxBackend::Pyrowave => true,
|
||||||
_ => linux_auto_is_vaapi(),
|
// EXACTLY the old `_` fallthrough for these two prefs — preserved, not endorsed. The
|
||||||
|
// vulkan-on-NVIDIA half is FILED (EGL→CUDA capture negotiated for a dmabuf-importing
|
||||||
|
// backend; reachable only via the explicit lab pref); the software half is latent (a
|
||||||
|
// software host pins H.264, so the GPU-backend probes this steers never negotiate).
|
||||||
|
LinuxBackend::Vulkan | LinuxBackend::Software => linux_auto_is_vaapi(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,9 +1425,17 @@ pub fn windows_resolved_backend() -> WindowsBackend {
|
|||||||
pub fn resolved_backend_is_gpu() -> bool {
|
pub fn resolved_backend_is_gpu() -> bool {
|
||||||
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
||||||
}
|
}
|
||||||
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
/// Linux: every backend but the GPU-less software encoder (openh264) is GPU-resident. Resolver-
|
||||||
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
/// backed (WP7.6); cross-crate mirrors still exist in `session_plan::resolve_encoder` and
|
||||||
#[cfg(not(target_os = "windows"))]
|
/// `gamestream/serverinfo.rs` — out of this crate's reach, listed in the WP7.6 design doc.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn resolved_backend_is_gpu() -> bool {
|
||||||
|
linux_resolved_backend() != LinuxBackend::Software
|
||||||
|
}
|
||||||
|
/// Other non-Windows targets (the macOS dev host): keep the string gate — no resolver exists
|
||||||
|
/// there. `not(any(...))`, NEVER a target list, so no exotic target loses the fn (the
|
||||||
|
/// `can_encode_444`/`can_encode_10bit` shape).
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
pub fn resolved_backend_is_gpu() -> bool {
|
pub fn resolved_backend_is_gpu() -> bool {
|
||||||
!matches!(
|
!matches!(
|
||||||
pf_host_config::config().encoder_pref.as_str(),
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
@@ -1393,7 +1610,9 @@ mod nvenc;
|
|||||||
#[path = "enc/linux/nvenc_cuda.rs"]
|
#[path = "enc/linux/nvenc_cuda.rs"]
|
||||||
mod nvenc_cuda;
|
mod nvenc_cuda;
|
||||||
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
||||||
// session open logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
|
// session open names its real cause instead of the old misleading "(no NVIDIA GPU?)" — including
|
||||||
|
// splitting the two opposite failures the driver reports as the SAME `INVALID_VERSION` (a genuine
|
||||||
|
// driver skew vs. this process's driver state going bad after a session already opened).
|
||||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||||
#[path = "enc/nvenc_status.rs"]
|
#[path = "enc/nvenc_status.rs"]
|
||||||
mod nvenc_status;
|
mod nvenc_status;
|
||||||
@@ -1402,6 +1621,20 @@ mod nvenc_status;
|
|||||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||||
#[path = "enc/nvenc_core.rs"]
|
#[path = "enc/nvenc_core.rs"]
|
||||||
mod nvenc_core;
|
mod nvenc_core;
|
||||||
|
// The slot-family RFI recovery policy (WP7.2) shared by native AMF, native QSV and Vulkan Video —
|
||||||
|
// the taint sweep + pre-loss anchor pick extracted from three hand-copies that had already
|
||||||
|
// diverged once (the fecbec2d sweep predates the Vulkan backend's carve-out and was never ported
|
||||||
|
// to it until a later fix). Policy only: every mechanism (LTR bitfield, RefListCtrl, DPB slot
|
||||||
|
// table) stays in its backend. cfg = the union of the callers': amf.rs is featureless on Windows,
|
||||||
|
// vulkan_video needs `vulkan-encode` on Linux — and each ITEM inside is live under the module's
|
||||||
|
// whole cfg because `plan_slot_recovery` delegates to `pick_anchor` (dead_code is an item lint;
|
||||||
|
// module-granular reasoning is how a Windows-dead helper reaches main).
|
||||||
|
#[cfg(any(
|
||||||
|
target_os = "windows",
|
||||||
|
all(target_os = "linux", feature = "vulkan-encode")
|
||||||
|
))]
|
||||||
|
#[path = "enc/rfi.rs"]
|
||||||
|
mod rfi;
|
||||||
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
||||||
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
||||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
||||||
@@ -1504,4 +1737,153 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(none.wire_mask(), None);
|
assert_eq!(none.wire_mask(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||||||
|
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
||||||
|
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
||||||
|
/// `set_pipelined`'s LN3 escalation, `applied_bitrate_bps`'s ABR truth), and every Phase 7
|
||||||
|
/// consolidation that adds a trait method re-arms it. Source-text parse, not reflection: both
|
||||||
|
/// blocks are top-level rustfmt items, so each ends at the first column-0 `}` and every method
|
||||||
|
/// name sits on a line starting `fn ` (a wrapped signature still carries the name there).
|
||||||
|
#[test]
|
||||||
|
fn tracked_encoder_forwards_every_trait_method() {
|
||||||
|
fn item_block<'a>(src: &'a str, marker: &str) -> &'a str {
|
||||||
|
let start = src
|
||||||
|
.find(marker)
|
||||||
|
.unwrap_or_else(|| panic!("marker {marker:?} not found — update this guard"));
|
||||||
|
let body = &src[start..];
|
||||||
|
let end = body
|
||||||
|
.find("\n}")
|
||||||
|
.unwrap_or_else(|| panic!("no column-0 close brace after {marker:?}"));
|
||||||
|
&body[..end]
|
||||||
|
}
|
||||||
|
fn fn_names(block: &str) -> std::collections::BTreeSet<&str> {
|
||||||
|
block
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_start)
|
||||||
|
.filter(|l| !l.starts_with("//"))
|
||||||
|
.filter_map(|l| l.strip_prefix("fn "))
|
||||||
|
.map(|rest| {
|
||||||
|
rest.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||||
|
.next()
|
||||||
|
.expect("split yields at least one item")
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
// `find` takes the FIRST occurrence: the real impl precedes this test's own copy of the
|
||||||
|
// marker string in the included source.
|
||||||
|
let trait_fns = fn_names(item_block(
|
||||||
|
include_str!("enc/codec.rs"),
|
||||||
|
"pub trait Encoder: Send {",
|
||||||
|
));
|
||||||
|
let impl_fns = fn_names(item_block(
|
||||||
|
include_str!("lib.rs"),
|
||||||
|
"impl Encoder for TrackedEncoder {",
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
trait_fns.len() >= 12,
|
||||||
|
"only {} trait methods parsed — the extraction markers have rotted, fix the parse \
|
||||||
|
before trusting this guard",
|
||||||
|
trait_fns.len()
|
||||||
|
);
|
||||||
|
let missing: Vec<_> = trait_fns.difference(&impl_fns).collect();
|
||||||
|
assert!(
|
||||||
|
missing.is_empty(),
|
||||||
|
"Encoder methods NOT forwarded by TrackedEncoder: {missing:?} — the host loop only \
|
||||||
|
ever holds the wrapped box, so an unforwarded default silently disables the feature \
|
||||||
|
for every session. Forward each one in `impl Encoder for TrackedEncoder`."
|
||||||
|
);
|
||||||
|
// The reverse direction (impl fn absent from the trait) is a compile error, so equality
|
||||||
|
// here is pure belt-and-braces against a parse regression.
|
||||||
|
assert_eq!(trait_fns, impl_fns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WP7.6: the resolver's full alias table, pinned. The panicking closure is the laziness
|
||||||
|
/// contract — an explicit pref must resolve WITHOUT running the auto probe (`/serverinfo`
|
||||||
|
/// polls through the mirrors; `gamestream/serverinfo.rs` litigated exactly that cost).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn linux_backend_resolver_table() {
|
||||||
|
use LinuxBackend::*;
|
||||||
|
let no_probe = || -> bool { panic!("explicit prefs must not run the auto probe") };
|
||||||
|
for pref in ["nvenc", "nvidia", "cuda"] {
|
||||||
|
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Nvenc));
|
||||||
|
}
|
||||||
|
for pref in ["vaapi", "amd", "intel"] {
|
||||||
|
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(AmdIntel));
|
||||||
|
}
|
||||||
|
for pref in ["vulkan", "vulkan-video"] {
|
||||||
|
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Vulkan));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
resolve_linux_backend("pyrowave", no_probe, false),
|
||||||
|
Some(Pyrowave)
|
||||||
|
);
|
||||||
|
for pref in ["software", "sw", "openh264"] {
|
||||||
|
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Software));
|
||||||
|
}
|
||||||
|
// auto (and the default empty pref): the probe decides…
|
||||||
|
assert_eq!(resolve_linux_backend("", || true, false), Some(AmdIntel));
|
||||||
|
assert_eq!(resolve_linux_backend("auto", || false, false), Some(Nvenc));
|
||||||
|
// …except a CUDA frame, which only NVENC can consume — and that short-circuits the
|
||||||
|
// probe too (`||` order).
|
||||||
|
assert_eq!(resolve_linux_backend("auto", no_probe, true), Some(Nvenc));
|
||||||
|
// Unknown prefs name no backend: the dispatch bails; the mirrors' wrapper maps this to
|
||||||
|
// auto (the historical `_`-arm behavior, recorded in the design doc).
|
||||||
|
assert_eq!(resolve_linux_backend("banana", no_probe, false), None);
|
||||||
|
// An explicit pref is never overridden by `cuda` (a CUDA payload cannot appear in a
|
||||||
|
// session whose pref forced a non-NVENC backend; the table must not mask such a bug).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_linux_backend("vaapi", no_probe, true),
|
||||||
|
Some(AmdIntel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WP7.6: the REAL dispatch through the resolver, GPU-free via the software arm (openh264 is
|
||||||
|
/// vendored + CPU-only; its own tests already run on every leg). This is the only in-CI
|
||||||
|
/// execution of the Linux dispatch — before this test it had zero test call sites. The pref
|
||||||
|
/// is INJECTED through `open_video_backend_linux` — the first draft mutated `PUNKTFUNK_ENCODER`
|
||||||
|
/// via `set_var`, the binary's first non-ignored env mutation, racing `getenv` in the sw/codec/
|
||||||
|
/// nvenc_core tests (diff-critic catch); the seam removes the race and the config-latch
|
||||||
|
/// coupling outright.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn open_video_backend_dispatches_software() {
|
||||||
|
let (enc, label) = open_video_backend_linux(
|
||||||
|
"software",
|
||||||
|
Codec::H264,
|
||||||
|
PixelFormat::Bgrx,
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
30,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
8,
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("software arm must open GPU-free");
|
||||||
|
assert_eq!(label, "software");
|
||||||
|
drop(enc);
|
||||||
|
// The software arm is codec-gated — a non-H.264 session must take the bail, not fall
|
||||||
|
// through to another backend.
|
||||||
|
let err = match open_video_backend_linux(
|
||||||
|
"software",
|
||||||
|
Codec::H265,
|
||||||
|
PixelFormat::Bgrx,
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
30,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
8,
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
false,
|
||||||
|
) {
|
||||||
|
// `expect_err` needs `Ok: Debug` and `Box<dyn Encoder>` isn't — match instead.
|
||||||
|
Ok(_) => panic!("software emits H.264 only; an H.265 session must be refused"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("H.264"), "{err:#}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,28 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
// identity and the session window gets the default-Wayland icon (the Linux analog of
|
// identity and the session window gets the default-Wayland icon (the Linux analog of
|
||||||
// the AppUserModelID adoption above).
|
// the AppUserModelID adoption above).
|
||||||
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
|
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
|
||||||
|
// `PUNKTFUNK_DRM_CARD=<n>` → SDL's KMSDRM device index, for a compositor-less (kiosk/embedded)
|
||||||
|
// run. SDL enumerates /dev/dri/card* and takes the first one it can open, which on a
|
||||||
|
// multi-GPU box is regularly the WRONG one: measured on a two-card machine it chose the card
|
||||||
|
// a live compositor already held DRM master on and failed at swapchain creation, while the
|
||||||
|
// idle card with the connected display sat unused. There is no reliable way to pick from
|
||||||
|
// inside the process (detecting "already mastered" needs the ioctl that taking master IS), so
|
||||||
|
// this stays an explicit operator choice rather than fragile auto-detection.
|
||||||
|
// Ignored unless the kmsdrm backend is actually in use.
|
||||||
|
if let Ok(card) = std::env::var("PUNKTFUNK_DRM_CARD") {
|
||||||
|
if card.chars().all(|c| c.is_ascii_digit()) && !card.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
card,
|
||||||
|
"PUNKTFUNK_DRM_CARD: pinning SDL's KMSDRM device index"
|
||||||
|
);
|
||||||
|
sdl3::hint::set("SDL_KMSDRM_DEVICE_INDEX", &card);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
card,
|
||||||
|
"PUNKTFUNK_DRM_CARD must be a card NUMBER (e.g. 0) — ignoring"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let sdl = sdl3::init().context("SDL init")?;
|
let sdl = sdl3::init().context("SDL init")?;
|
||||||
let video = sdl.video().context("SDL video")?;
|
let video = sdl.video().context("SDL video")?;
|
||||||
let events = sdl.event().context("SDL events")?;
|
let events = sdl.event().context("SDL events")?;
|
||||||
|
|||||||
@@ -3,9 +3,34 @@
|
|||||||
use super::setup::pick_formats;
|
use super::setup::pick_formats;
|
||||||
use super::{OverlayPipe, Presenter};
|
use super::{OverlayPipe, Presenter};
|
||||||
use crate::csc::CscPass;
|
use crate::csc::CscPass;
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{anyhow, Context as _, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
|
|
||||||
|
/// Extra guidance appended to a swapchain-creation failure when SDL is on the KMSDRM backend —
|
||||||
|
/// i.e. a compositor-less kiosk/embedded run, where the bare Vulkan error is close to useless.
|
||||||
|
///
|
||||||
|
/// Measured on an NVIDIA proprietary + KMSDRM box: SDL opens the card, Vulkan enumerates the GPU
|
||||||
|
/// *and* the display (`VK_KHR_display` reports the connected HDMI connector), then
|
||||||
|
/// `vkCreateSwapchainKHR` returns ERROR_INITIALIZATION_FAILED — as root, with `nvidia_drm.modeset`
|
||||||
|
/// on, on a card no compositor was using. So it is neither permissions nor DRM master: NVIDIA's
|
||||||
|
/// direct-to-display path wants the display leased (`vkAcquireDrmDisplayEXT`) and SDL's KMSDRM
|
||||||
|
/// surface path does not do that for it. Empty on every other backend, where the message would be
|
||||||
|
/// noise.
|
||||||
|
fn kmsdrm_swapchain_hint() -> String {
|
||||||
|
let kmsdrm = std::env::var("SDL_VIDEODRIVER").is_ok_and(|v| v.eq_ignore_ascii_case("kmsdrm"));
|
||||||
|
if !kmsdrm {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let card = std::env::var("PUNKTFUNK_DRM_CARD").unwrap_or_else(|_| "unset".into());
|
||||||
|
format!(
|
||||||
|
" — under SDL_VIDEODRIVER=kmsdrm (PUNKTFUNK_DRM_CARD={card}). Check, in order: the card \
|
||||||
|
has a CONNECTED connector (`cat /sys/class/drm/card*-*/status`); nothing else holds DRM \
|
||||||
|
master on it (a running compositor does — pin another card with PUNKTFUNK_DRM_CARD=<n>); \
|
||||||
|
and the driver is Mesa. NVIDIA's proprietary direct-display path is known to fail here \
|
||||||
|
even as root with a display Vulkan can enumerate."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
impl Presenter {
|
impl Presenter {
|
||||||
/// (Re)build the swapchain for the window's current pixel size. Also the resize path.
|
/// (Re)build the swapchain for the window's current pixel size. Also the resize path.
|
||||||
pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> {
|
pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> {
|
||||||
@@ -66,8 +91,8 @@ impl Presenter {
|
|||||||
.present_mode(self.present_mode)
|
.present_mode(self.present_mode)
|
||||||
.clipped(true)
|
.clipped(true)
|
||||||
.old_swapchain(old);
|
.old_swapchain(old);
|
||||||
let swapchain =
|
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }
|
||||||
unsafe { self.swap_d.create_swapchain(&info, None) }.context("vkCreateSwapchainKHR")?;
|
.map_err(|e| anyhow!("vkCreateSwapchainKHR: {e}{}", kmsdrm_swapchain_hint()))?;
|
||||||
// The old swapchain and everything tied to its images dies NOW: the fence
|
// The old swapchain and everything tied to its images dies NOW: the fence
|
||||||
// quiesce covered our own command buffers, the queue drain above covered the
|
// quiesce covered our own command buffers, the queue drain above covered the
|
||||||
// presentation engine's semaphore waits — nothing can still reference them.
|
// presentation engine's semaphore waits — nothing can still reference them.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::dmabuf;
|
|||||||
use anyhow::{anyhow, bail, Context as _, Result};
|
use anyhow::{anyhow, bail, Context as _, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
use ash::vk::Handle as _;
|
use ash::vk::Handle as _;
|
||||||
use std::ffi::CString;
|
use std::ffi::{c_char, CString};
|
||||||
|
|
||||||
impl Presenter {
|
impl Presenter {
|
||||||
/// Bring up instance → surface → device → swapchain over an SDL window.
|
/// Bring up instance → surface → device → swapchain over an SDL window.
|
||||||
@@ -40,7 +40,10 @@ impl Presenter {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|e| CString::new(e.as_str()).unwrap())
|
.map(|e| CString::new(e.as_str()).unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
let ext_ptrs: Vec<*const i8> = ext_cstrings.iter().map(|e| e.as_ptr()).collect();
|
// `c_char`, not `i8`: plain `char` is SIGNED on x86_64 but UNSIGNED on aarch64, so a
|
||||||
|
// hardcoded `*const i8` compiles on the desktop targets and fails to match ash's
|
||||||
|
// `&[*const c_char]` on ARM.
|
||||||
|
let ext_ptrs: Vec<*const c_char> = ext_cstrings.iter().map(|e| e.as_ptr()).collect();
|
||||||
let instance = unsafe {
|
let instance = unsafe {
|
||||||
entry.create_instance(
|
entry.create_instance(
|
||||||
&vk::InstanceCreateInfo::default()
|
&vk::InstanceCreateInfo::default()
|
||||||
|
|||||||
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
|
|||||||
dm == "sddm.service"
|
dm == "sddm.service"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
|
||||||
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
|
/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
|
||||||
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
|
/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
|
||||||
/// caller degrades to attach.
|
/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
|
||||||
fn try_stop_display_manager(dm: &str) -> bool {
|
/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
|
||||||
Command::new("systemctl")
|
/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
|
||||||
.args(["stop", dm])
|
/// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (its
|
||||||
|
/// PKGBUILD rewrites the annotation to match).
|
||||||
|
const DM_HELPER_PATHS: &[&str] = &[
|
||||||
|
"/usr/libexec/punktfunk/pf-dm-helper",
|
||||||
|
"/usr/lib/punktfunk/pf-dm-helper",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
|
||||||
|
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||||
|
fn dm_helper(verb: &str) -> bool {
|
||||||
|
let Some(helper) = DM_HELPER_PATHS
|
||||||
|
.iter()
|
||||||
|
.find(|p| std::path::Path::new(p).exists())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Command::new("pkexec")
|
||||||
|
.arg(helper)
|
||||||
|
.arg(verb)
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||||
|
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||||
|
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||||
|
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
|
||||||
|
/// attach.
|
||||||
|
fn try_stop_display_manager(dm: &str) -> bool {
|
||||||
|
let direct = Command::new("systemctl")
|
||||||
|
.args(["stop", dm])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
direct || dm_helper("stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||||
|
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
|
||||||
|
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
|
||||||
|
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
|
||||||
|
/// two steps as root.
|
||||||
|
fn restore_display_manager(dm: &str) -> bool {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["reset-failed", dm])
|
||||||
|
.status();
|
||||||
|
let direct = Command::new("systemctl")
|
||||||
|
.args(["restart", dm])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
direct || dm_helper("restore")
|
||||||
|
}
|
||||||
|
|
||||||
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||||
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
||||||
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
||||||
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
|
|||||||
clear_takeover();
|
clear_takeover();
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||||
let _ = Command::new("systemctl")
|
if !restore_display_manager(&dm) {
|
||||||
.args(["reset-failed", &dm])
|
tracing::warn!(
|
||||||
.status();
|
%dm,
|
||||||
let _ = Command::new("systemctl").args(["start", &dm]).status();
|
"gamescope: display-manager start was denied — the desktop switch may need a manual \
|
||||||
|
`systemctl restart` of the DM"
|
||||||
|
);
|
||||||
|
}
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
let active = Command::new("systemctl")
|
let active = Command::new("systemctl")
|
||||||
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
|
|||||||
if !try_stop_display_manager(&dm) {
|
if !try_stop_display_manager(&dm) {
|
||||||
bail!(
|
bail!(
|
||||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||||
session unit, and stopping it needs privilege — install the punktfunk \
|
session unit, and stopping it needs privilege — the packaged pf-dm-helper \
|
||||||
display-manager polkit rule (see docs) to enable the managed takeover"
|
polkit action is missing or was denied (reinstall the punktfunk package, or \
|
||||||
|
install the display-manager polkit rule from the docs) so the managed takeover \
|
||||||
|
is unavailable"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
|
|||||||
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||||
// session Exec starts the gamescope unit itself.
|
// session Exec starts the gamescope unit itself.
|
||||||
if let Some(dm) = dm {
|
if let Some(dm) = dm {
|
||||||
let _ = Command::new("systemctl")
|
let restart = restore_display_manager(&dm);
|
||||||
.args(["reset-failed", &dm])
|
|
||||||
.status();
|
|
||||||
let restart = Command::new("systemctl")
|
|
||||||
.args(["restart", &dm])
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if restart {
|
if restart {
|
||||||
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||||
} else if crate::try_recover_session() {
|
} else if crate::try_recover_session() {
|
||||||
|
|||||||
@@ -340,9 +340,19 @@ pub(crate) fn is_available() -> bool {
|
|||||||
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
||||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||||
|
|
||||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
/// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
|
||||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
/// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
|
||||||
/// gate. Returns the parsed version when it could read one.
|
/// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
|
||||||
|
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
|
||||||
|
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
|
||||||
|
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
|
||||||
|
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
|
||||||
|
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
|
||||||
|
|
||||||
|
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
|
||||||
|
/// unreliable) or than [`MIN_GAMESCOPE_OVERLAY`] (capture works but the Steam overlay can't reach
|
||||||
|
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
|
||||||
|
/// diagnostic, not a gate. Returns the parsed version when it could read one.
|
||||||
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||||
@@ -360,6 +370,18 @@ pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
|||||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||||
);
|
);
|
||||||
|
} else if ver < MIN_GAMESCOPE_OVERLAY {
|
||||||
|
// Capture is fine; the Steam overlay just won't be in the frame gamescope hands us.
|
||||||
|
tracing::warn!(
|
||||||
|
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||||
|
min = %format!(
|
||||||
|
"{}.{}.{}",
|
||||||
|
MIN_GAMESCOPE_OVERLAY.0, MIN_GAMESCOPE_OVERLAY.1, MIN_GAMESCOPE_OVERLAY.2
|
||||||
|
),
|
||||||
|
"gamescope is older than the first version that paints the Steam overlay (Shift+Tab / \
|
||||||
|
Quick Access Menu) into its PipeWire node — the overlay will be absent from the \
|
||||||
|
stream until you upgrade gamescope (the cursor is composited host-side regardless)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(ver)
|
Some(ver)
|
||||||
}
|
}
|
||||||
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE};
|
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_steam_appid_from_launch() {
|
fn parses_steam_appid_from_launch() {
|
||||||
@@ -425,4 +447,15 @@ mod tests {
|
|||||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlay_threshold_brackets_the_fix() {
|
||||||
|
// 3.16.22 captures fine but predates the overlay-in-pipewire paint — it sits in the
|
||||||
|
// "capture works, overlay absent" window `[MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY)`, which is
|
||||||
|
// exactly the `else if` warn arm; 3.16.23 is the first to include the overlay.
|
||||||
|
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||||
|
assert!(parse_version("gamescope version 3.16.22").unwrap() < MIN_GAMESCOPE_OVERLAY);
|
||||||
|
assert!(parse_version("gamescope version 3.16.23").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||||
|
assert!(parse_version("gamescope version 3.16.25").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ ash = "0.38"
|
|||||||
# (SCM_RIGHTS carries the fds; pixels never cross the socket).
|
# (SCM_RIGHTS carries the fds; pixels never cross the socket).
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Linux-only like the code under test: the render-node scan is exercised against a fixture tree.
|
||||||
|
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -34,6 +34,62 @@ const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: egl::Attrib = 0x3444;
|
|||||||
mod gl;
|
mod gl;
|
||||||
use gl::*;
|
use gl::*;
|
||||||
|
|
||||||
|
/// PCI vendor id of the only GPU this importer can drive — everything below it ends in a CUDA
|
||||||
|
/// context. Same constant the Vulkan bridge selects its physical device by.
|
||||||
|
const PCI_VENDOR_NVIDIA: u32 = 0x10de;
|
||||||
|
|
||||||
|
/// The NVIDIA DRM render node: `PUNKTFUNK_ZEROCOPY_RENDER_NODE` > the first `/dev/dri/renderD*`
|
||||||
|
/// whose sysfs PCI vendor is NVIDIA > `/dev/dri/renderD128`.
|
||||||
|
///
|
||||||
|
/// The fallback used to be the *whole* selection, which quietly assumed NVIDIA owns the first
|
||||||
|
/// render node. On a hybrid laptop it does not — the iGPU is bound first, so `renderD128` is
|
||||||
|
/// Intel, and this importer opened a Mesa GBM display, asked it for a pbuffer-capable OpenGL
|
||||||
|
/// config (Mesa's GBM platform only ever advertises window-capable ones), got none, and reported
|
||||||
|
/// "no EGL config for OpenGL" on a machine whose NVIDIA EGL stack was working perfectly. Pick the
|
||||||
|
/// device by what it *is*.
|
||||||
|
///
|
||||||
|
/// Deliberately a local scan rather than a `pf_gpu` dependency: this crate is a leaf (the import
|
||||||
|
/// worker is its own process, spawned for fault isolation), and `pf_gpu::linux_render_node` answers
|
||||||
|
/// a different question anyway — it follows the operator's VAAPI/GPU *preference*, which may well
|
||||||
|
/// name the iGPU. The answer needed here is not "which GPU should we use" but "where is CUDA".
|
||||||
|
fn nvidia_render_node() -> std::path::PathBuf {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
if let Some(p) = std::env::var_os("PUNKTFUNK_ZEROCOPY_RENDER_NODE").filter(|s| !s.is_empty()) {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
// No NVIDIA node found — sysfs may simply not be mounted (a container without /sys). Keep the
|
||||||
|
// historical guess; the CUDA context below is what actually fails if this host has no NVIDIA.
|
||||||
|
nvidia_render_node_in(Path::new("/dev/dri"), Path::new("/sys/class/drm"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scan half of [`nvidia_render_node`], parameterized on its two roots so a test can pin the
|
||||||
|
/// sysfs shape it depends on (`<sys_class_drm>/renderD129/device/vendor` holding `0x10de`). Nodes
|
||||||
|
/// are visited in name order so the choice is stable across boots.
|
||||||
|
fn nvidia_render_node_in(
|
||||||
|
dri: &std::path::Path,
|
||||||
|
sys_class_drm: &std::path::Path,
|
||||||
|
) -> Option<std::path::PathBuf> {
|
||||||
|
let mut nodes: Vec<std::ffi::OsString> = std::fs::read_dir(dri)
|
||||||
|
.map(|rd| {
|
||||||
|
rd.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name())
|
||||||
|
.filter(|n| n.as_encoded_bytes().starts_with(b"renderD"))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
nodes.sort();
|
||||||
|
nodes
|
||||||
|
.into_iter()
|
||||||
|
.find(|node| {
|
||||||
|
std::fs::read_to_string(sys_class_drm.join(node).join("device").join("vendor"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok())
|
||||||
|
== Some(PCI_VENDOR_NVIDIA)
|
||||||
|
})
|
||||||
|
.map(|node| dri.join(node))
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
|
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
|
||||||
struct GlBlit {
|
struct GlBlit {
|
||||||
program: u32,
|
program: u32,
|
||||||
@@ -528,13 +584,15 @@ impl EglImporter {
|
|||||||
// GBM platform on the NVIDIA render node: this ties the EGLDisplay (and its GL contexts)
|
// GBM platform on the NVIDIA render node: this ties the EGLDisplay (and its GL contexts)
|
||||||
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform
|
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform
|
||||||
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
||||||
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
|
let node = nvidia_render_node();
|
||||||
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
|
let path = std::ffi::CString::new(node.as_os_str().as_encoded_bytes())
|
||||||
|
.with_context(|| format!("render node path {} has an interior NUL", node.display()))?;
|
||||||
|
// SAFETY: `path` is a live local `CString` (its constructor rejected interior NULs, so it
|
||||||
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
|
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
|
||||||
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
|
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
|
||||||
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
|
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
|
||||||
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||||
ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
|
ensure!(render_fd >= 0, "open {} for GBM", node.display());
|
||||||
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
|
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
|
||||||
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
|
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
|
||||||
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
|
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
|
||||||
@@ -546,7 +604,7 @@ impl EglImporter {
|
|||||||
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
|
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
|
||||||
// the live `render_fd`, releasing it before the error return. No double-close.
|
// the live `render_fd`, releasing it before the error return. No double-close.
|
||||||
unsafe { libc::close(render_fd) };
|
unsafe { libc::close(render_fd) };
|
||||||
anyhow::bail!("gbm_create_device failed");
|
anyhow::bail!("gbm_create_device failed on {}", node.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
|
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
|
||||||
@@ -568,7 +626,7 @@ impl EglImporter {
|
|||||||
&[egl::ATTRIB_NONE],
|
&[egl::ATTRIB_NONE],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.context("eglGetPlatformDisplay(GBM) on the NVIDIA render node")?;
|
.with_context(|| format!("eglGetPlatformDisplay(GBM) on {}", node.display()))?;
|
||||||
egl.initialize(display).context("eglInitialize")?;
|
egl.initialize(display).context("eglInitialize")?;
|
||||||
|
|
||||||
let exts = egl
|
let exts = egl
|
||||||
@@ -590,20 +648,46 @@ impl EglImporter {
|
|||||||
egl.bind_api(egl::OPENGL_API)
|
egl.bind_api(egl::OPENGL_API)
|
||||||
.context("eglBindAPI(OpenGL)")?;
|
.context("eglBindAPI(OpenGL)")?;
|
||||||
// The default EGL_SURFACE_TYPE in eglChooseConfig is WINDOW_BIT, which a headless device
|
// The default EGL_SURFACE_TYPE in eglChooseConfig is WINDOW_BIT, which a headless device
|
||||||
// display has none of — request a pbuffer-capable config (we run surfaceless anyway).
|
// display has none of — ask for a pbuffer-capable config first (NVIDIA's GBM platform has
|
||||||
let config = egl
|
// them, and this is the request every working host has been served for a year).
|
||||||
.choose_first_config(
|
//
|
||||||
display,
|
// Then fall back to no surface-type constraint at all, because a config's surface type is
|
||||||
&[
|
// irrelevant to us: we never create an EGLSurface, we `eglMakeCurrent` surfaceless. Mesa's
|
||||||
|
// GBM platform advertises ONLY window-capable configs (gbm_surface is the whole point of
|
||||||
|
// the platform there), so the pbuffer request finds nothing on any Mesa device — which is
|
||||||
|
// how a hybrid host that landed on the iGPU reported "no EGL config for OpenGL" while its
|
||||||
|
// NVIDIA EGL stack was fine. The node is picked correctly now; this keeps the failure from
|
||||||
|
// being fatal (and unreadable) if some other driver makes the same choice Mesa did.
|
||||||
|
let want_pbuffer = [
|
||||||
egl::SURFACE_TYPE,
|
egl::SURFACE_TYPE,
|
||||||
egl::PBUFFER_BIT,
|
egl::PBUFFER_BIT,
|
||||||
egl::RENDERABLE_TYPE,
|
egl::RENDERABLE_TYPE,
|
||||||
egl::OPENGL_BIT,
|
egl::OPENGL_BIT,
|
||||||
egl::NONE,
|
egl::NONE,
|
||||||
],
|
];
|
||||||
)
|
let any_surface = [egl::RENDERABLE_TYPE, egl::OPENGL_BIT, egl::NONE];
|
||||||
|
let config = match egl
|
||||||
|
.choose_first_config(display, &want_pbuffer)
|
||||||
.context("eglChooseConfig")?
|
.context("eglChooseConfig")?
|
||||||
.context("no EGL config for OpenGL")?;
|
{
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
tracing::debug!(
|
||||||
|
node = %node.display(),
|
||||||
|
"no pbuffer-capable OpenGL EGL config — retrying without a surface-type \
|
||||||
|
constraint (we run surfaceless)"
|
||||||
|
);
|
||||||
|
egl.choose_first_config(display, &any_surface)
|
||||||
|
.context("eglChooseConfig (no surface-type constraint)")?
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no EGL config for OpenGL on {} — this display serves no \
|
||||||
|
OpenGL-renderable config at all",
|
||||||
|
node.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
let gl_ctx = egl
|
let gl_ctx = egl
|
||||||
.create_context(
|
.create_context(
|
||||||
display,
|
display,
|
||||||
@@ -637,6 +721,7 @@ impl EglImporter {
|
|||||||
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
|
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
|
||||||
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
node = %node.display(),
|
||||||
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
||||||
);
|
);
|
||||||
Ok(EglImporter {
|
Ok(EglImporter {
|
||||||
@@ -1087,3 +1172,69 @@ impl Drop for EglImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// A fake `/dev/dri` + `/sys/class/drm` pair: every `(node, vendor)` gets a device node file
|
||||||
|
/// and, when `vendor` is `Some`, the sysfs `device/vendor` that identifies it.
|
||||||
|
fn fixture(nodes: &[(&str, Option<&str>)]) -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dri = tmp.path().join("dev/dri");
|
||||||
|
let sys = tmp.path().join("sys/class/drm");
|
||||||
|
std::fs::create_dir_all(&dri).unwrap();
|
||||||
|
for (node, vendor) in nodes {
|
||||||
|
std::fs::write(dri.join(node), b"").unwrap();
|
||||||
|
if let Some(v) = vendor {
|
||||||
|
let dev = sys.join(node).join("device");
|
||||||
|
std::fs::create_dir_all(&dev).unwrap();
|
||||||
|
std::fs::write(dev.join("vendor"), v).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(tmp, dri, sys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hybrid-laptop case this selection exists for: the iGPU is bound first, so the NVIDIA
|
||||||
|
/// GPU is NOT renderD128 — which is what the old hardcoded path assumed.
|
||||||
|
#[test]
|
||||||
|
fn picks_the_nvidia_node_not_the_first_one() {
|
||||||
|
let (_t, dri, sys) = fixture(&[
|
||||||
|
("renderD128", Some("0x8086\n")),
|
||||||
|
("renderD129", Some("0x10de\n")),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
nvidia_render_node_in(&dri, &sys),
|
||||||
|
Some(dri.join("renderD129"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No NVIDIA GPU (or no readable sysfs) ⇒ no answer, and the caller keeps the historical
|
||||||
|
/// `/dev/dri/renderD128` guess rather than inventing one.
|
||||||
|
#[test]
|
||||||
|
fn no_nvidia_node_yields_nothing() {
|
||||||
|
let (_t, dri, sys) = fixture(&[("renderD128", Some("0x8086\n")), ("renderD129", None)]);
|
||||||
|
assert_eq!(nvidia_render_node_in(&dri, &sys), None);
|
||||||
|
// Missing roots entirely (a container without /sys, or without /dev/dri).
|
||||||
|
assert_eq!(
|
||||||
|
nvidia_render_node_in(Path::new("/nonexistent/dri"), &sys),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ignore card/control nodes, and visit render nodes in name order so a two-NVIDIA host picks
|
||||||
|
/// the same one every boot.
|
||||||
|
#[test]
|
||||||
|
fn scans_render_nodes_only_and_in_order() {
|
||||||
|
let (_t, dri, sys) = fixture(&[
|
||||||
|
("card0", Some("0x10de\n")),
|
||||||
|
("renderD130", Some("0x10de\n")),
|
||||||
|
("renderD129", Some("0x10de\n")),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
nvidia_render_node_in(&dri, &sys),
|
||||||
|
Some(dri.join("renderD129"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -232,6 +232,48 @@ pub fn gpu_import_disabled() -> bool {
|
|||||||
GPU_IMPORT_DISABLED.load(Ordering::Relaxed)
|
GPU_IMPORT_DISABLED.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same idea for the OTHER zero-copy half: consecutive failures to import a raw dmabuf in the
|
||||||
|
/// encoder (VAAPI's libva import, PyroWave's Vulkan one) with no successful frame in between.
|
||||||
|
///
|
||||||
|
/// That import can fail for reasons no retry can fix — a driver that will not take what the
|
||||||
|
/// compositor allocates. The encode-stall recovery above it cannot tell the difference, so it
|
||||||
|
/// rebuilt the identical failing encoder five times and then ended the video session, on every
|
||||||
|
/// connection, forever: a hybrid Intel/NVIDIA laptop reporting `vaCreateSurfaces` →
|
||||||
|
/// `VA_STATUS_ERROR_ALLOCATION_FAILED` on its first frame could not stream at all until its
|
||||||
|
/// operator found `PUNKTFUNK_ZEROCOPY=0` by hand. The host already knows how to encode that
|
||||||
|
/// machine — capture just has to stop handing it dmabufs. Latching here is what makes the next
|
||||||
|
/// session negotiate CPU frames on its own.
|
||||||
|
static RAW_DMABUF_FAILURE_STREAK: AtomicU32 = AtomicU32::new(0);
|
||||||
|
static RAW_DMABUF_DISABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// Below the encoder's own rebuild budget, so the latch is set before the session it doomed ends.
|
||||||
|
const RAW_DMABUF_FAILURE_LATCH: u32 = 3;
|
||||||
|
|
||||||
|
/// Record an encoder-side raw-dmabuf import failure. Latches the process-wide disable after
|
||||||
|
/// [`RAW_DMABUF_FAILURE_LATCH`] consecutive failures.
|
||||||
|
pub fn note_raw_dmabuf_import_failure(reason: &str) {
|
||||||
|
let streak = RAW_DMABUF_FAILURE_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
if streak >= RAW_DMABUF_FAILURE_LATCH && !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
|
||||||
|
tracing::error!(
|
||||||
|
streak,
|
||||||
|
reason,
|
||||||
|
"zero-copy raw-dmabuf passthrough disabled for this host process: the encoder failed \
|
||||||
|
to import the compositor's dmabuf {streak} times in a row — captures fall back to the \
|
||||||
|
CPU path (slower, but this host could not stream at all otherwise)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a raw dmabuf that imported and encoded — resets the failure streak.
|
||||||
|
pub fn note_raw_dmabuf_import_ok() {
|
||||||
|
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
||||||
|
/// [`note_raw_dmabuf_import_failure`]).
|
||||||
|
pub fn raw_dmabuf_import_disabled() -> bool {
|
||||||
|
RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||||||
const fn fourcc(c: &[u8; 4]) -> u32 {
|
const fn fourcc(c: &[u8; 4]) -> u32 {
|
||||||
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
||||||
|
|||||||
@@ -1813,9 +1813,11 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
|
|||||||
return PunktfunkStatus::InvalidArg;
|
return PunktfunkStatus::InvalidArg;
|
||||||
}
|
}
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out as *mut u8, cert.len());
|
// `.cast()`, not `as *mut u8`: `c_char` is i8 on x86_64 but u8 on aarch64, so the
|
||||||
|
// `as` form is a REQUIRED conversion on one and a no-op clippy rejects on the other.
|
||||||
|
std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out.cast::<u8>(), cert.len());
|
||||||
*cert_pem_out.add(cert.len()) = 0;
|
*cert_pem_out.add(cert.len()) = 0;
|
||||||
std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out as *mut u8, key.len());
|
std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out.cast::<u8>(), key.len());
|
||||||
*key_pem_out.add(key.len()) = 0;
|
*key_pem_out.add(key.len()) = 0;
|
||||||
}
|
}
|
||||||
PunktfunkStatus::Ok
|
PunktfunkStatus::Ok
|
||||||
|
|||||||
@@ -13,7 +13,13 @@
|
|||||||
//! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link
|
//! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link
|
||||||
//! (bufferbloat) — this is the early-warning signal loss-based control lacks;
|
//! (bufferbloat) — this is the early-warning signal loss-based control lacks;
|
||||||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||||||
//! evidence there is.
|
//! evidence there is;
|
||||||
|
//! - **host-encode-latency rise** — the host's per-AU 0xCF `encode_us` climbing above its rolling
|
||||||
|
//! baseline: the ENCODER falling behind its frame budget (the compute knee), the one failure a
|
||||||
|
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
|
||||||
|
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
|
||||||
|
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
|
||||||
|
//! encoder off a cliff the network could carry.
|
||||||
//!
|
//!
|
||||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||||
@@ -94,6 +100,25 @@ const UTILIZATION_DEN: u64 = 4;
|
|||||||
/// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock.
|
/// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock.
|
||||||
const PROVEN_HEADROOM_NUM: u32 = 3;
|
const PROVEN_HEADROOM_NUM: u32 = 3;
|
||||||
const PROVEN_HEADROOM_DEN: u32 = 2;
|
const PROVEN_HEADROOM_DEN: u32 = 2;
|
||||||
|
/// How far the window's mean HOST-ENCODE latency (the 0xCF `HostStages::encode_us` the host
|
||||||
|
/// already ships per AU) may rise above its rolling baseline before the window is bad. This is
|
||||||
|
/// the down-driver for the ENCODER's compute knee — the failure loss/OWD/decode are all blind
|
||||||
|
/// to: on a fat LAN the controller can climb to a rate the link carries fine but the ASIC
|
||||||
|
/// can't encode inside the frame budget (4K120 HEVC at ~800 Mbps ≈ 9.3 ms against 8.33), and
|
||||||
|
/// the only symptom is encode time. Baseline-RELATIVE on purpose: an escalated host reports
|
||||||
|
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
|
||||||
|
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
|
||||||
|
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
|
||||||
|
const ENCODE_RISE_US: i64 = 4_000;
|
||||||
|
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||||
|
/// queue is growing past the knee; skip the two-window confirmation.
|
||||||
|
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||||
|
/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before
|
||||||
|
/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent
|
||||||
|
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||||
|
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||||
|
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||||
|
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||||
const BASELINE_WINDOWS: usize = 40;
|
const BASELINE_WINDOWS: usize = 40;
|
||||||
@@ -121,6 +146,27 @@ pub(crate) struct BitrateController {
|
|||||||
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
|
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
|
||||||
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
||||||
decode_means: VecDeque<i64>,
|
decode_means: VecDeque<i64>,
|
||||||
|
/// Recent window mean host-encode latencies (µs, from the 0xCF datagrams); rolling-min
|
||||||
|
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
|
||||||
|
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
|
||||||
|
encode_means: VecDeque<i64>,
|
||||||
|
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
|
||||||
|
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||||
|
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||||
|
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
|
||||||
|
/// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever.
|
||||||
|
host_cap_kbps: Option<u32>,
|
||||||
|
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
|
||||||
|
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
|
||||||
|
last_requested_kbps: Option<u32>,
|
||||||
|
/// Consecutive short-ack streak: the value and how many times in a row it was acked. Two
|
||||||
|
/// identical short acks latch [`host_cap_kbps`](Self::host_cap_kbps) — one can be a
|
||||||
|
/// transient (a failed host rebuild keeping the old rate); the host's resolves are
|
||||||
|
/// deterministic min()s, so a persistent limit reproduces exactly.
|
||||||
|
short_ack_kbps: u32,
|
||||||
|
short_acks: u32,
|
||||||
|
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||||
|
cap_probe_windows: u32,
|
||||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||||
@@ -147,6 +193,12 @@ impl BitrateController {
|
|||||||
probing: true,
|
probing: true,
|
||||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||||
|
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||||
|
host_cap_kbps: None,
|
||||||
|
last_requested_kbps: None,
|
||||||
|
short_ack_kbps: 0,
|
||||||
|
short_acks: 0,
|
||||||
|
cap_probe_windows: 0,
|
||||||
proven_kbps: 0,
|
proven_kbps: 0,
|
||||||
bad_windows: 0,
|
bad_windows: 0,
|
||||||
clean_windows: 0,
|
clean_windows: 0,
|
||||||
@@ -168,22 +220,68 @@ impl BitrateController {
|
|||||||
|
|
||||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||||
|
///
|
||||||
|
/// A SHORT ack (below what we asked) is the host telling us about a limit the network
|
||||||
|
/// signals can't see — its encoder's codec-level ceiling, or a climb refusal while encode
|
||||||
|
/// can't hold cadence. Two consecutive short acks at the SAME value latch it as
|
||||||
|
/// [`host_cap_kbps`](Self::host_cap_kbps), stopping the AIMD sawtooth from re-poking a
|
||||||
|
/// limit the host already refused; ONE is not enough — a failed host rebuild also acks
|
||||||
|
/// short once, and latching a transient would cap the session on a hiccup.
|
||||||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||||||
if kbps > 0 {
|
if kbps > 0 {
|
||||||
|
if kbps < self.current_kbps {
|
||||||
|
// Our own decrease changes the encode-time regime (less work per frame; on an
|
||||||
|
// escalated host the queue offset shifts too) — judging the new regime against
|
||||||
|
// the old baseline would train-fire the encode down-driver. Re-seed it.
|
||||||
|
self.encode_means.clear();
|
||||||
|
}
|
||||||
|
if let Some(req) = self.last_requested_kbps.take() {
|
||||||
|
if kbps < req {
|
||||||
|
if self.short_ack_kbps == kbps {
|
||||||
|
self.short_acks += 1;
|
||||||
|
} else {
|
||||||
|
self.short_ack_kbps = kbps;
|
||||||
|
self.short_acks = 1;
|
||||||
|
}
|
||||||
|
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
|
||||||
|
tracing::info!(
|
||||||
|
cap_kbps = kbps,
|
||||||
|
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
|
||||||
|
refusal) — climbs stop here until it lifts"
|
||||||
|
);
|
||||||
|
self.host_cap_kbps = Some(kbps.max(self.floor_kbps));
|
||||||
|
self.cap_probe_windows = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.short_acks = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.current_kbps = kbps;
|
self.current_kbps = kbps;
|
||||||
}
|
}
|
||||||
self.unacked = 0;
|
self.unacked = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
|
||||||
|
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
|
||||||
|
/// probe-measured `ceiling_kbps` (a LINK property) survives.
|
||||||
|
pub(crate) fn on_mode_switch(&mut self) {
|
||||||
|
self.host_cap_kbps = None;
|
||||||
|
self.short_acks = 0;
|
||||||
|
self.cap_probe_windows = 0;
|
||||||
|
self.encode_means.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||||||
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
|
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
|
||||||
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
|
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
|
||||||
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
|
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
|
||||||
/// (`None` on an embedder that doesn't report it — the signal is then absent), `actual_kbps`
|
/// (`None` on an embedder that doesn't report it — the signal is then absent),
|
||||||
/// the window's ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline
|
/// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
|
||||||
/// really carried, as opposed to the target it was allowed; feeds the utilization climb gate
|
/// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
|
||||||
/// and the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in
|
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
|
||||||
/// the window.
|
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
|
||||||
|
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
|
||||||
|
/// window.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn on_window(
|
pub(crate) fn on_window(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -192,6 +290,7 @@ impl BitrateController {
|
|||||||
loss_ppm: u32,
|
loss_ppm: u32,
|
||||||
owd_mean_us: Option<i64>,
|
owd_mean_us: Option<i64>,
|
||||||
decode_mean_us: Option<i64>,
|
decode_mean_us: Option<i64>,
|
||||||
|
encode_mean_us: Option<i64>,
|
||||||
actual_kbps: u32,
|
actual_kbps: u32,
|
||||||
flushed: bool,
|
flushed: bool,
|
||||||
) -> Option<u32> {
|
) -> Option<u32> {
|
||||||
@@ -242,6 +341,23 @@ impl BitrateController {
|
|||||||
}
|
}
|
||||||
None => (false, false),
|
None => (false, false),
|
||||||
};
|
};
|
||||||
|
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
|
||||||
|
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
|
||||||
|
// signal that can push an already-too-high rate back under the knee: the host refuses
|
||||||
|
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
|
||||||
|
let (encode_bad, encode_severe) = match encode_mean_us {
|
||||||
|
Some(mean) => {
|
||||||
|
let base = self.encode_means.iter().min().copied();
|
||||||
|
let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US);
|
||||||
|
let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US);
|
||||||
|
if self.encode_means.len() == BASELINE_WINDOWS {
|
||||||
|
self.encode_means.pop_front();
|
||||||
|
}
|
||||||
|
self.encode_means.push_back(mean);
|
||||||
|
(bad, severe)
|
||||||
|
}
|
||||||
|
None => (false, false),
|
||||||
|
};
|
||||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||||
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
|
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
|
||||||
// the bytes that DID arrive still went through the decoder; what loss means for the rate
|
// the bytes that DID arrive still went through the decoder; what loss means for the rate
|
||||||
@@ -253,8 +369,9 @@ impl BitrateController {
|
|||||||
// deep decode-latency excursion) or loss far past any blip — one window is enough.
|
// deep decode-latency excursion) or loss far past any blip — one window is enough.
|
||||||
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
|
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
|
||||||
// still needs two consecutive windows.
|
// still needs two consecutive windows.
|
||||||
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe;
|
let severe =
|
||||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad;
|
dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
|
||||||
|
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
|
||||||
if bad {
|
if bad {
|
||||||
self.bad_windows += 1;
|
self.bad_windows += 1;
|
||||||
self.clean_windows = 0;
|
self.clean_windows = 0;
|
||||||
@@ -264,6 +381,29 @@ impl BitrateController {
|
|||||||
self.clean_windows += 1;
|
self.clean_windows += 1;
|
||||||
self.bad_windows = 0;
|
self.bad_windows = 0;
|
||||||
}
|
}
|
||||||
|
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean
|
||||||
|
// windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a
|
||||||
|
// scene-dependent refusal can't quietly cap the whole session — a still-standing limit
|
||||||
|
// just re-latches from the next pair of short acks, at zero encoder cost.
|
||||||
|
if let Some(cap) = self.host_cap_kbps {
|
||||||
|
if bad {
|
||||||
|
self.cap_probe_windows = 0;
|
||||||
|
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||||
|
self.cap_probe_windows += 1;
|
||||||
|
if self.cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||||
|
self.cap_probe_windows = 0;
|
||||||
|
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||||
|
if lifted > cap {
|
||||||
|
tracing::debug!(
|
||||||
|
from_kbps = cap,
|
||||||
|
to_kbps = lifted,
|
||||||
|
"adaptive bitrate: re-probing above the learned host cap"
|
||||||
|
);
|
||||||
|
self.host_cap_kbps = Some(lifted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let cooled = self
|
let cooled = self
|
||||||
.last_change
|
.last_change
|
||||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||||
@@ -284,10 +424,14 @@ impl BitrateController {
|
|||||||
// utilized window after a long-enough clean run climbs immediately.
|
// utilized window after a long-enough clean run climbs immediately.
|
||||||
let utilized =
|
let utilized =
|
||||||
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
||||||
let cap = self
|
// The effective ceiling folds in the host-taught cap: the probe measured the LINK, but
|
||||||
|
// the host's short acks measured the ENCODER — whichever binds first is the limit.
|
||||||
|
let eff_ceiling = self
|
||||||
.ceiling_kbps
|
.ceiling_kbps
|
||||||
|
.min(self.host_cap_kbps.unwrap_or(u32::MAX));
|
||||||
|
let cap = eff_ceiling
|
||||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||||
if self.current_kbps < self.ceiling_kbps && utilized && cap > self.current_kbps {
|
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||||
// Slow start: double on every cooled clean window until the first congestion signal
|
// Slow start: double on every cooled clean window until the first congestion signal
|
||||||
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||||||
// Congestion avoidance: +~6 % after a sustained clean run.
|
// Congestion avoidance: +~6 % after a sustained clean run.
|
||||||
@@ -308,6 +452,7 @@ impl BitrateController {
|
|||||||
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
|
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
|
||||||
self.last_change = Some(now);
|
self.last_change = Some(now);
|
||||||
self.unacked += 1;
|
self.unacked += 1;
|
||||||
|
self.last_requested_kbps = Some(kbps);
|
||||||
// `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored
|
// `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored
|
||||||
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
||||||
Some(kbps)
|
Some(kbps)
|
||||||
@@ -331,7 +476,16 @@ mod tests {
|
|||||||
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
||||||
let mut out = None;
|
let mut out = None;
|
||||||
for i in from..from + n {
|
for i in from..from + n {
|
||||||
out = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false);
|
out = c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
);
|
||||||
if out.is_some() {
|
if out.is_some() {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -345,7 +499,7 @@ mod tests {
|
|||||||
let mut c = BitrateController::new(0);
|
let mut c = BitrateController::new(0);
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(now, 5, 900_000, Some(500_000), None, 1_000_000, true),
|
c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -356,22 +510,58 @@ mod tests {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 0, 25_000, None, None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 0),
|
||||||
|
0,
|
||||||
|
25_000,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
// The second consecutive bad window backs off ×0.7.
|
// The second consecutive bad window backs off ×0.7.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 1), 0, 25_000, None, None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 1),
|
||||||
|
0,
|
||||||
|
25_000,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 6), 0, 25_000, None, None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 6),
|
||||||
|
0,
|
||||||
|
25_000,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
None
|
None
|
||||||
); // bad #1 again
|
); // bad #1 again
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 7), 0, 25_000, None, None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 7),
|
||||||
|
0,
|
||||||
|
25_000,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
Some(9_800)
|
Some(9_800)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,19 +572,28 @@ mod tests {
|
|||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
// …and so does a jump-to-live flush.
|
// …and so does a jump-to-live flush.
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 0, 0, None, None, 1_000_000, true),
|
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
// …and ≥6 % window loss.
|
// …and ≥6 % window loss.
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 0, 80_000, None, None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 0),
|
||||||
|
0,
|
||||||
|
80_000,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -404,18 +603,18 @@ mod tests {
|
|||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||||
// boundary (tick 2 = 1.5 s) it fires.
|
// boundary (tick 2 = 1.5 s) it fires.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 1), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 2), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
|
||||||
Some(9_800)
|
Some(9_800)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,17 +625,17 @@ mod tests {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||||
Some(5_000)
|
Some(5_000)
|
||||||
);
|
);
|
||||||
c.on_ack(5_000);
|
c.on_ack(5_000);
|
||||||
// At the floor, further bad windows request nothing.
|
// At the floor, further bad windows request nothing.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 6), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 7), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -446,7 +645,7 @@ mod tests {
|
|||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
@@ -469,9 +668,16 @@ mod tests {
|
|||||||
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||||||
let mut got = Vec::new();
|
let mut got = Vec::new();
|
||||||
for i in 0..14 {
|
for i in 0..14 {
|
||||||
if let Some(k) =
|
if let Some(k) = c.on_window(
|
||||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false)
|
ticks(start, i),
|
||||||
{
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
) {
|
||||||
c.on_ack(k);
|
c.on_ack(k);
|
||||||
got.push(k);
|
got.push(k);
|
||||||
}
|
}
|
||||||
@@ -485,20 +691,47 @@ mod tests {
|
|||||||
c.set_ceiling(300_000);
|
c.set_ceiling(300_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 0),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
Some(40_000)
|
Some(40_000)
|
||||||
);
|
);
|
||||||
c.on_ack(40_000);
|
c.on_ack(40_000);
|
||||||
// Severe window → immediate ×0.7, and slow start is over.
|
// Severe window → immediate ×0.7, and slow start is over.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 2), 1, 0, Some(10_000), None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 2),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
Some(28_000)
|
Some(28_000)
|
||||||
);
|
);
|
||||||
c.on_ack(28_000);
|
c.on_ack(28_000);
|
||||||
// Clean again — but the next climb is additive, after the 6-window clean run.
|
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||||||
let mut next = None;
|
let mut next = None;
|
||||||
for i in 3..12 {
|
for i in 3..12 {
|
||||||
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false);
|
next = c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
);
|
||||||
if next.is_some() {
|
if next.is_some() {
|
||||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||||
break;
|
break;
|
||||||
@@ -512,7 +745,7 @@ mod tests {
|
|||||||
let mut c = BitrateController::new(0);
|
let mut c = BitrateController::new(0);
|
||||||
c.set_ceiling(1_000_000);
|
c.set_ceiling(1_000_000);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(Instant::now(), 0, 0, None, None, 1_000_000, false),
|
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
@@ -527,17 +760,44 @@ mod tests {
|
|||||||
// Establish a ~10 ms baseline over a few clean windows.
|
// Establish a ~10 ms baseline over a few clean windows.
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 4), 0, 0, Some(50_000), None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 4),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(50_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 5), 0, 0, Some(52_000), None, 1_000_000, false),
|
c.on_window(
|
||||||
|
ticks(start, 5),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(52_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -557,6 +817,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -572,6 +833,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(38_000),
|
Some(38_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -584,6 +846,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(40_000),
|
Some(40_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -605,6 +868,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -620,6 +884,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(38_000),
|
Some(38_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -634,6 +899,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(40_000),
|
Some(40_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -657,6 +923,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
2_000,
|
2_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -673,6 +940,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
18_000,
|
18_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -696,6 +964,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
20_000,
|
20_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -710,6 +979,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
30_000,
|
30_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -732,6 +1002,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
20_000,
|
20_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -741,7 +1012,16 @@ mod tests {
|
|||||||
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
|
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
|
||||||
for i in 2..30 {
|
for i in 2..30 {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), Some(4_000), 600, false),
|
c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
Some(4_000),
|
||||||
|
None,
|
||||||
|
600,
|
||||||
|
false
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -761,6 +1041,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(8_000),
|
Some(8_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -776,6 +1057,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
Some(10_000),
|
Some(10_000),
|
||||||
Some(60_000),
|
Some(60_000),
|
||||||
|
None,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
@@ -783,6 +1065,235 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_identical_short_acks_latch_the_host_cap() {
|
||||||
|
// The 4K120 field failure: the encoder ceilings at ~794 Mbps while the link carries
|
||||||
|
// more — the host acks short. TWO identical short acks teach the cap; climbs then stop
|
||||||
|
// poking a limit the host already refused (the rebuild-storm driver).
|
||||||
|
let mut c = BitrateController::new(400_000);
|
||||||
|
c.set_ceiling(1_400_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||||
|
// First short ack: current follows (authoritative), but one short ack is not a cap.
|
||||||
|
c.on_ack(794_000);
|
||||||
|
assert!(c.host_cap_kbps.is_none());
|
||||||
|
// The next climb overshoots again and is short-acked at the SAME value: latch.
|
||||||
|
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||||
|
c.on_ack(794_000);
|
||||||
|
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||||
|
// Parked AT the learned cap, nothing left to climb to — no more requests.
|
||||||
|
assert_eq!(run_clean(&mut c, start, 20, 12), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_short_ack_is_a_transient_not_a_cap() {
|
||||||
|
// A failed host rebuild acks short once (it kept the old rate) — latching THAT would
|
||||||
|
// cap the session on a driver hiccup. The streak must survive only identical repeats.
|
||||||
|
let mut c = BitrateController::new(400_000);
|
||||||
|
c.set_ceiling(1_400_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||||
|
c.on_ack(400_000); // rebuild failed, host kept the old rate
|
||||||
|
assert!(c.host_cap_kbps.is_none());
|
||||||
|
// The retry applies fully: streak broken, still no cap, full authority kept.
|
||||||
|
assert_eq!(run_clean(&mut c, start, 10, 1), Some(800_000));
|
||||||
|
c.on_ack(800_000);
|
||||||
|
assert!(c.host_cap_kbps.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_switch_clears_the_learned_cap() {
|
||||||
|
let mut c = BitrateController::new(400_000);
|
||||||
|
c.set_ceiling(1_400_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||||
|
c.on_ack(794_000);
|
||||||
|
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||||
|
c.on_ack(794_000);
|
||||||
|
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||||
|
// 4K120's ceiling means nothing at the new mode — the cap must not survive the switch
|
||||||
|
// (the probe-measured link ceiling does).
|
||||||
|
c.on_mode_switch();
|
||||||
|
assert!(c.host_cap_kbps.is_none());
|
||||||
|
assert_eq!(c.ceiling_kbps, 1_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn learned_cap_reprobes_after_a_sustained_clean_run() {
|
||||||
|
// A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean
|
||||||
|
// at the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||||
|
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
|
||||||
|
let mut c = BitrateController::new(400_000);
|
||||||
|
c.set_ceiling(1_400_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||||
|
c.on_ack(794_000);
|
||||||
|
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||||
|
c.on_ack(794_000);
|
||||||
|
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||||
|
for i in 0..CAP_REPROBE_WINDOWS {
|
||||||
|
let _ = c.on_window(
|
||||||
|
ticks(start, 20 + i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_encode_latency_rise_backs_off() {
|
||||||
|
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
|
||||||
|
// (the 4K120 case: ~9.3 ms against an 8.33 ms budget shows up nowhere else). Two risen
|
||||||
|
// windows → ×0.7, exactly like an OWD/decode rise.
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(7_000),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, 4),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(11_500),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, 6),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(12_000),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
Some(14_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_encode_excursion_is_severe() {
|
||||||
|
// Encode time shooting ≈1.5 frame budgets over baseline = the queue is growing past
|
||||||
|
// the knee right now — no two-window confirmation.
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(7_000),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, 4),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(20_000),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
Some(14_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_decrease_rebases_the_encode_baseline() {
|
||||||
|
// After OUR OWN decrease the encode regime legitimately changes (less work per frame;
|
||||||
|
// an escalated host's reported encode_us also carries a queue offset) — the old
|
||||||
|
// baseline must not train-fire repeated backoffs down to the floor.
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
for i in 0..4 {
|
||||||
|
let _ = c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(7_000),
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = c.on_window(
|
||||||
|
ticks(start, 4),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(12_000),
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, 6),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(12_500),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
Some(14_000)
|
||||||
|
);
|
||||||
|
// The decrease applies → rebase. The new regime's ~15 ms means (an escalated host's
|
||||||
|
// queue offset) would be far over the OLD 7 ms baseline, but must now read clean.
|
||||||
|
c.on_ack(14_000);
|
||||||
|
for i in 8..11 {
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(
|
||||||
|
ticks(start, i),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(10_000),
|
||||||
|
None,
|
||||||
|
Some(15_000),
|
||||||
|
1_000_000,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ack_silence_disables_the_controller() {
|
fn ack_silence_disables_the_controller() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
@@ -791,7 +1302,7 @@ mod tests {
|
|||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||||||
while i < 60 {
|
while i < 60 {
|
||||||
if c.on_window(ticks(start, i), 1, 0, None, None, 1_000_000, false)
|
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
sent += 1;
|
sent += 1;
|
||||||
|
|||||||
@@ -224,6 +224,20 @@ pub(crate) struct DecodeLatAcc {
|
|||||||
pub(crate) count: u32,
|
pub(crate) count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host encode-stage latency accumulator — [`DecodeLatAcc`]'s mirror for the HOST side of the
|
||||||
|
/// pipeline. The datagram task adds one sample per 0xCF `HostStages::encode_us` (host encoder
|
||||||
|
/// submit → bitstream ready) and the pump drains a window mean into
|
||||||
|
/// [`crate::abr::BitrateController::on_window`]'s encode signal. Host encode time was measured,
|
||||||
|
/// shipped and drawn on the overlay, but never an ABR input — which is how a fat-LAN Automatic
|
||||||
|
/// session drove the encoder past its compute knee with nothing to stop it (§ABR overdrive).
|
||||||
|
/// Its own accumulator rather than the overlay's `host_timing` channel: that channel is a lossy
|
||||||
|
/// `try_send` the embedder may never drain, and the controller must not depend on it.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct EncodeLatAcc {
|
||||||
|
pub(crate) sum_us: u64,
|
||||||
|
pub(crate) count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
||||||
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
||||||
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
||||||
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
||||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||||
|
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
|
||||||
|
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
|
||||||
|
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
|
||||||
|
// Bumped by the control task on every accepted mode switch (the `clock_gen` pattern): the
|
||||||
|
// pump resets the controller's mode-scoped learned state (host cap, encode baseline).
|
||||||
|
let mode_gen = Arc::new(AtomicU32::new(0));
|
||||||
|
|
||||||
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
|
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
|
||||||
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
|
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
|
||||||
@@ -128,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
clock_gen: clock_gen.clone(),
|
clock_gen: clock_gen.clone(),
|
||||||
clip_event_tx: clip_event_tx.clone(),
|
clip_event_tx: clip_event_tx.clone(),
|
||||||
cursor_shape_tx,
|
cursor_shape_tx,
|
||||||
|
mode_gen: mode_gen.clone(),
|
||||||
}
|
}
|
||||||
.run(),
|
.run(),
|
||||||
);
|
);
|
||||||
@@ -141,6 +148,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
hidout_tx,
|
hidout_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
|
encode_lat.clone(),
|
||||||
cursor_state_tx,
|
cursor_state_tx,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -176,6 +184,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
clock_offset,
|
clock_offset,
|
||||||
clock_gen,
|
clock_gen,
|
||||||
decode_lat,
|
decode_lat,
|
||||||
|
encode_lat,
|
||||||
|
mode_gen,
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
fec_recovered,
|
fec_recovered,
|
||||||
bitrate_ack,
|
bitrate_ack,
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub(super) struct ControlTask {
|
|||||||
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
|
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
|
||||||
/// shape plane ([`NativeClient::next_cursor_shape`]).
|
/// shape plane ([`NativeClient::next_cursor_shape`]).
|
||||||
pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>,
|
pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>,
|
||||||
|
/// Bumped on every ACCEPTED mode switch (the `clock_gen` pattern): the pump watches it and
|
||||||
|
/// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute
|
||||||
|
/// knee it was taught belong to the OLD mode.
|
||||||
|
pub(super) mode_gen: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlTask {
|
impl ControlTask {
|
||||||
@@ -40,6 +44,7 @@ impl ControlTask {
|
|||||||
clock_gen,
|
clock_gen,
|
||||||
clip_event_tx,
|
clip_event_tx,
|
||||||
cursor_shape_tx,
|
cursor_shape_tx,
|
||||||
|
mode_gen,
|
||||||
} = self;
|
} = self;
|
||||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||||
@@ -88,6 +93,7 @@ impl ControlTask {
|
|||||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||||
if ack.accepted {
|
if ack.accepted {
|
||||||
*mode_slot.lock().unwrap() = ack.mode;
|
*mode_slot.lock().unwrap() = ack.mode;
|
||||||
|
mode_gen.fetch_add(1, Ordering::Relaxed);
|
||||||
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ pub(super) struct DataPump {
|
|||||||
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||||
pub(super) clock_gen: Arc<AtomicU32>,
|
pub(super) clock_gen: Arc<AtomicU32>,
|
||||||
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||||
|
/// Host encode-stage latency window accumulator (the ABR encode signal — see
|
||||||
|
/// [`super::super::frame_channel::EncodeLatAcc`]); fed by the datagram task.
|
||||||
|
pub(super) encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
|
||||||
|
/// Accepted-mode-switch generation (control task bumps): a change resets the controller's
|
||||||
|
/// mode-scoped learned state ([`BitrateController::on_mode_switch`]).
|
||||||
|
pub(super) mode_gen: Arc<AtomicU32>,
|
||||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||||
@@ -41,6 +47,8 @@ impl DataPump {
|
|||||||
clock_offset: pump_clock_offset,
|
clock_offset: pump_clock_offset,
|
||||||
clock_gen: pump_clock_gen,
|
clock_gen: pump_clock_gen,
|
||||||
decode_lat: pump_decode_lat,
|
decode_lat: pump_decode_lat,
|
||||||
|
encode_lat: pump_encode_lat,
|
||||||
|
mode_gen: pump_mode_gen,
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
fec_recovered,
|
fec_recovered,
|
||||||
bitrate_ack,
|
bitrate_ack,
|
||||||
@@ -127,6 +135,7 @@ impl DataPump {
|
|||||||
let mut clock_detector_armed = true;
|
let mut clock_detector_armed = true;
|
||||||
let mut resync_wanted = false;
|
let mut resync_wanted = false;
|
||||||
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||||
|
let mut seen_mode_gen = pump_mode_gen.load(Ordering::Relaxed);
|
||||||
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
|
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
|
||||||
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
||||||
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
|
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
|
||||||
@@ -338,9 +347,15 @@ impl DataPump {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
// Adaptive bitrate: an accepted mode switch first (it invalidates the
|
||||||
// feed the controller this window's congestion signals; a decision becomes a
|
// mode-scoped learned state), then drain any host ack (its clamp is
|
||||||
// SetBitrate on the control stream.
|
// authoritative), then feed the controller this window's congestion signals; a
|
||||||
|
// decision becomes a SetBitrate on the control stream.
|
||||||
|
let mg = pump_mode_gen.load(Ordering::Relaxed);
|
||||||
|
if mg != seen_mode_gen {
|
||||||
|
seen_mode_gen = mg;
|
||||||
|
abr.on_mode_switch();
|
||||||
|
}
|
||||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||||
abr.on_ack(acked);
|
abr.on_ack(acked);
|
||||||
}
|
}
|
||||||
@@ -356,6 +371,14 @@ impl DataPump {
|
|||||||
*acc = DecodeLatAcc::default();
|
*acc = DecodeLatAcc::default();
|
||||||
(count > 0).then(|| (sum / count as u64) as i64)
|
(count > 0).then(|| (sum / count as u64) as i64)
|
||||||
};
|
};
|
||||||
|
// Same drain for the host-encode window (0xCF `encode_us` via the datagram
|
||||||
|
// task) — `None` on an old host that doesn't send stage timings.
|
||||||
|
let encode_mean_us = {
|
||||||
|
let mut acc = pump_encode_lat.lock().unwrap();
|
||||||
|
let (sum, count) = (acc.sum_us, acc.count);
|
||||||
|
*acc = Default::default();
|
||||||
|
(count > 0).then(|| (sum / count as u64) as i64)
|
||||||
|
};
|
||||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||||
@@ -369,17 +392,19 @@ impl DataPump {
|
|||||||
loss_ppm,
|
loss_ppm,
|
||||||
owd_mean_us,
|
owd_mean_us,
|
||||||
decode_mean_us,
|
decode_mean_us,
|
||||||
|
encode_mean_us,
|
||||||
actual_kbps,
|
actual_kbps,
|
||||||
flush_in_window,
|
flush_in_window,
|
||||||
) {
|
) {
|
||||||
// Log the window's signals alongside the decision so an on-glass session can
|
// Log the window's signals alongside the decision so an on-glass session can
|
||||||
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
|
// tell a decode-/encode-driven re-target (the new signals — elevated with
|
||||||
// loss/OWD flat) from a network-driven one.
|
// loss/OWD flat) from a network-driven one.
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
kbps,
|
kbps,
|
||||||
loss_ppm,
|
loss_ppm,
|
||||||
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
||||||
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
||||||
|
encode_mean_us = encode_mean_us.unwrap_or(-1),
|
||||||
actual_kbps,
|
actual_kbps,
|
||||||
flushed = flush_in_window,
|
flushed = flush_in_window,
|
||||||
"adaptive bitrate: requesting encoder re-target"
|
"adaptive bitrate: requesting encoder re-target"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ pub(super) async fn run(
|
|||||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||||
|
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||||
|
// `host_timing_tx`: that channel is the overlay's, lossy and embedder-drained.
|
||||||
|
encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
|
||||||
cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>,
|
cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>,
|
||||||
) {
|
) {
|
||||||
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||||
@@ -74,6 +77,11 @@ pub(super) async fn run(
|
|||||||
}
|
}
|
||||||
Some(&crate::quic::HOST_TIMING_MAGIC) => {
|
Some(&crate::quic::HOST_TIMING_MAGIC) => {
|
||||||
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
|
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
|
||||||
|
if let Some(s) = &t.stages {
|
||||||
|
let mut acc = encode_lat.lock().unwrap();
|
||||||
|
acc.sum_us += s.encode_us as u64;
|
||||||
|
acc.count += 1;
|
||||||
|
}
|
||||||
let _ = host_timing_tx.try_send(t);
|
let _ = host_timing_tx.try_send(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ fn apply_hdr(base: u32, hdr: bool) -> u32 {
|
|||||||
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
|
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
|
||||||
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
||||||
fn base_codec_mode_support() -> u32 {
|
fn base_codec_mode_support() -> u32 {
|
||||||
|
// A GPU-less host encodes H.264 and nothing else (openh264), so advertising the superset made
|
||||||
|
// Moonlight negotiate HEVC/AV1 and the session then died at encoder open with "the software
|
||||||
|
// encoder emits H.264 only". `pf_encode::Codec::host_wire_caps` — the native plane's twin of
|
||||||
|
// this function — has gated on exactly this since it was written; this one never did.
|
||||||
|
//
|
||||||
|
// Deliberately a local gate rather than delegating wholesale to `host_wire_caps()`: that would
|
||||||
|
// be the drift-proof shape, but on Windows it re-runs the DXGI adapter enumeration several
|
||||||
|
// times per `/serverinfo` GET (the probe helpers each sample it), and this endpoint is polled.
|
||||||
|
// The software case is a plain config read, so it costs nothing here. (Follow-up worth doing:
|
||||||
|
// the static `MaxLumaPixelsHEVC` in the XML above still advertises an HEVC limit even when the
|
||||||
|
// mask drops HEVC — harmless, since Moonlight gates on the mask, but it is a second and now
|
||||||
|
// inconsistent advertisement.)
|
||||||
|
if matches!(
|
||||||
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
|
"software" | "sw" | "openh264"
|
||||||
|
) {
|
||||||
|
return super::SCM_H264;
|
||||||
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||||
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
||||||
|
|||||||
@@ -686,6 +686,13 @@ fn stream_body(
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.context("open video encoder for stream")?;
|
.context("open video encoder for stream")?;
|
||||||
|
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
||||||
|
// (Windows direct-NVENC, which encodes the capturer's textures with no CopyResource) bounds
|
||||||
|
// itself by an env cap instead of the ring it is actually reading, and the capturer rotates a
|
||||||
|
// texture out from under a live encode — torn/mixed frames, never an error. The backend now
|
||||||
|
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
|
||||||
|
// and a deeper ring is free pipelining the fallback would forfeit.
|
||||||
|
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -841,6 +848,8 @@ fn stream_body(
|
|||||||
true, // metadata-cursor capture — see the first open
|
true, // metadata-cursor capture — see the first open
|
||||||
)
|
)
|
||||||
.context("reopen encoder after rebuild")?;
|
.context("reopen encoder after rebuild")?;
|
||||||
|
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||||
|
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||||
supports_rfi = enc.caps().supports_rfi;
|
supports_rfi = enc.caps().supports_rfi;
|
||||||
enc.request_keyframe();
|
enc.request_keyframe();
|
||||||
last_keyframe = Some(Instant::now());
|
last_keyframe = Some(Instant::now());
|
||||||
|
|||||||
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
|
|||||||
pub(crate) struct LocalSummary {
|
pub(crate) struct LocalSummary {
|
||||||
/// Host version (mirrors `/health`).
|
/// Host version (mirrors `/health`).
|
||||||
version: String,
|
version: String,
|
||||||
/// True while the video stream thread is running.
|
/// True while video is streaming on EITHER plane: the GameStream media pipeline, or a live
|
||||||
|
/// native (punktfunk/1) session — the default plane, invisible in the GameStream flag alone.
|
||||||
video_streaming: bool,
|
video_streaming: bool,
|
||||||
/// True while the audio stream thread is running.
|
/// True while audio is streaming on either plane (same rule as `video_streaming`).
|
||||||
audio_streaming: bool,
|
audio_streaming: bool,
|
||||||
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
|
/// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
|
||||||
|
/// live native session. `null` when nothing is streaming.
|
||||||
session: Option<SessionInfo>,
|
session: Option<SessionInfo>,
|
||||||
/// Number of pinned (paired) GameStream client certificates.
|
/// Number of pinned (paired) GameStream client certificates.
|
||||||
paired_clients: u32,
|
paired_clients: u32,
|
||||||
@@ -391,22 +393,23 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||||
|
// Native punktfunk/1 plane (the DEFAULT plane; GameStream is opt-in) — read ONCE and used for
|
||||||
|
// both the session card and the streaming flags below.
|
||||||
|
let native = crate::session_status::snapshot();
|
||||||
// GameStream launch, else the first live native session — so the tray reflects a native session
|
// GameStream launch, else the first live native session — so the tray reflects a native session
|
||||||
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
||||||
let session = st
|
let session = st
|
||||||
.app
|
.app
|
||||||
.launch
|
.launch
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.map(|l| SessionInfo {
|
.map(|l| SessionInfo {
|
||||||
width: l.width,
|
width: l.width,
|
||||||
height: l.height,
|
height: l.height,
|
||||||
fps: l.fps,
|
fps: l.fps,
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
crate::session_status::snapshot()
|
native.first().map(|s| SessionInfo {
|
||||||
.first()
|
|
||||||
.map(|s| SessionInfo {
|
|
||||||
width: s.width,
|
width: s.width,
|
||||||
height: s.height,
|
height: s.height,
|
||||||
fps: s.fps,
|
fps: s.fps,
|
||||||
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
|||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
Json(LocalSummary {
|
Json(LocalSummary {
|
||||||
version: env!("PUNKTFUNK_VERSION").into(),
|
version: env!("PUNKTFUNK_VERSION").into(),
|
||||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
|
||||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
// "idle" (and wear the idle icon) through an entire native session.
|
||||||
|
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||||
|
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||||
session,
|
session,
|
||||||
paired_clients: st
|
paired_clients: st
|
||||||
.app
|
.app
|
||||||
|
|||||||
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
|
|||||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes the tests that read (or write) the process-global live-session registry
|
||||||
|
/// ([`crate::session_status`]): a session registered by one test would otherwise make a
|
||||||
|
/// concurrently running one see a stream it never started.
|
||||||
|
static SESSION_REGISTRY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
|
/// A `/local/summary` request from a loopback peer (the tray's own).
|
||||||
|
fn summary_req() -> axum::http::Request<Body> {
|
||||||
|
let mut req = get_req("/api/v1/local/summary");
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a stand-in live native session; the returned guard removes it on drop.
|
||||||
|
fn fake_native_session(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: u32,
|
||||||
|
) -> crate::session_status::LiveSessionGuard {
|
||||||
|
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
|
||||||
|
crate::session_status::register(
|
||||||
|
Arc::new(std::sync::atomic::AtomicU64::new(packed)),
|
||||||
|
Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
|
||||||
|
Codec::H265,
|
||||||
|
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
"test-client".into(),
|
||||||
|
false,
|
||||||
|
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||||
|
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
|
||||||
|
/// summary. The GameStream `streaming` flag stays false throughout such a session, and reading it
|
||||||
|
/// alone left the tray showing "idle" (with the idle icon) for the whole stream: exactly the blind
|
||||||
|
/// spot `/status` was fixed for in [`crate::session_status`], which `/local/summary` still had.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_summary_reports_a_native_session_as_streaming() {
|
||||||
|
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
let (status, body) = send(&app, summary_req()).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["video_streaming"], false);
|
||||||
|
assert_eq!(body["session"], serde_json::Value::Null);
|
||||||
|
|
||||||
|
let session = fake_native_session(3840, 2160, 120);
|
||||||
|
let (_, body) = send(&app, summary_req()).await;
|
||||||
|
assert_eq!(body["video_streaming"], true, "native session: {body}");
|
||||||
|
assert_eq!(body["audio_streaming"], true, "native session: {body}");
|
||||||
|
assert_eq!(body["session"]["width"], 3840);
|
||||||
|
assert_eq!(body["session"]["height"], 2160);
|
||||||
|
assert_eq!(body["session"]["fps"], 120);
|
||||||
|
|
||||||
|
// Session over → back to idle.
|
||||||
|
drop(session);
|
||||||
|
let (_, body) = send(&app, summary_req()).await;
|
||||||
|
assert_eq!(body["video_streaming"], false);
|
||||||
|
assert_eq!(body["session"], serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
||||||
/// rejected even though the route needs no bearer token, and the body never carries secret
|
/// rejected even though the route needs no bearer token, and the body never carries secret
|
||||||
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||||
|
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||||
let np = Arc::new(
|
let np = Arc::new(
|
||||||
crate::native_pairing::NativePairing::load_with(
|
crate::native_pairing::NativePairing::load_with(
|
||||||
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
|
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
|
||||||
@@ -600,6 +663,7 @@ async fn compositors_lists_all_backends_with_flags() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn status_reflects_runtime_state() {
|
async fn status_reflects_runtime_state() {
|
||||||
|
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let app = test_app(state.clone(), None);
|
let app = test_app(state.clone(), None);
|
||||||
|
|
||||||
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idr_requires_an_active_stream() {
|
async fn idr_requires_an_active_stream() {
|
||||||
|
// A live native session (registered by a sibling test) is an active stream to this route.
|
||||||
|
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let app = test_app(state.clone(), None);
|
let app = test_app(state.clone(), None);
|
||||||
let post = || {
|
let post = || {
|
||||||
|
|||||||
@@ -985,6 +985,18 @@ async fn serve_session(
|
|||||||
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
||||||
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
|
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
|
||||||
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
|
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
|
||||||
|
// Encoder-truth bridge, data plane → control task (§ABR overdrive). The encode loop publishes
|
||||||
|
// here; the control task reads at `SetBitrate`-resolve time, so the ack the client's
|
||||||
|
// controller climbs from tracks what the encoder ACTUALLY does, not what was asked:
|
||||||
|
// - `live_bitrate`: the encoder's applied rate (kbps) — also the send pacer's/console's view.
|
||||||
|
// - `encoder_ceiling_kbps`: the discovered codec-level ceiling (0 = none discovered yet);
|
||||||
|
// resolves land at min(policy clamp, ceiling), so overshoots stop costing rebuilds.
|
||||||
|
// - `cadence_degraded`: encode can't hold the frame cadence — a climb is refused (acked at
|
||||||
|
// the current rate); the network isn't the bottleneck, more bits are anti-medicine.
|
||||||
|
// Plain atomics, not a channel: only the freshest value matters, and only at resolve time.
|
||||||
|
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
|
||||||
|
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
|
||||||
|
let cadence_degraded = Arc::new(AtomicBool::new(false));
|
||||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||||
@@ -1036,6 +1048,9 @@ async fn serve_session(
|
|||||||
live_reconfig_ok,
|
live_reconfig_ok,
|
||||||
adaptive_fec,
|
adaptive_fec,
|
||||||
session_bitrate_kbps,
|
session_bitrate_kbps,
|
||||||
|
live_bitrate.clone(),
|
||||||
|
encoder_ceiling_kbps.clone(),
|
||||||
|
cadence_degraded.clone(),
|
||||||
fec_target_ctl,
|
fec_target_ctl,
|
||||||
reconfig_tx,
|
reconfig_tx,
|
||||||
keyframe_tx,
|
keyframe_tx,
|
||||||
@@ -1429,6 +1444,9 @@ async fn serve_session(
|
|||||||
bitrate_rx,
|
bitrate_rx,
|
||||||
compositor,
|
compositor,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
|
live_bitrate,
|
||||||
|
encoder_ceiling_kbps,
|
||||||
|
cadence_degraded,
|
||||||
bitrate_auto,
|
bitrate_auto,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
chroma,
|
chroma,
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ pub(super) async fn run(
|
|||||||
live_reconfig_ok: bool,
|
live_reconfig_ok: bool,
|
||||||
adaptive_fec: bool,
|
adaptive_fec: bool,
|
||||||
session_bitrate_kbps: u32,
|
session_bitrate_kbps: u32,
|
||||||
|
// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
|
||||||
|
// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
|
||||||
|
// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
|
||||||
|
// climbs from — never promises a rate the encoder won't run at.
|
||||||
|
live_bitrate: Arc<AtomicU32>,
|
||||||
|
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||||
|
cadence_degraded: Arc<AtomicBool>,
|
||||||
fec_target_ctl: Arc<AtomicU8>,
|
fec_target_ctl: Arc<AtomicU8>,
|
||||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||||
@@ -161,7 +168,31 @@ pub(super) async fn run(
|
|||||||
);
|
);
|
||||||
session_bitrate_kbps
|
session_bitrate_kbps
|
||||||
} else {
|
} else {
|
||||||
resolve_bitrate_kbps(req.bitrate_kbps)
|
let mut r = resolve_bitrate_kbps(req.bitrate_kbps);
|
||||||
|
// Encoder truth (§ABR overdrive): the ack below is the base the
|
||||||
|
// client's controller climbs from, so it must not promise past the
|
||||||
|
// encoder's discovered codec-level ceiling — the pre-fix path acked
|
||||||
|
// 1.01 Gbps while the ASIC ran 794 Mbps, and the controller climbed
|
||||||
|
// from the phantom number forever (a ~0.6 s rebuild + IDR per step).
|
||||||
|
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||||
|
if ceiling != 0 && r > ceiling {
|
||||||
|
r = ceiling;
|
||||||
|
}
|
||||||
|
// Climb refusal while encode can't hold cadence: on a fat LAN no
|
||||||
|
// network signal ever stops the climb, and past the compute knee more
|
||||||
|
// bits only deepen the miss. Resolve a CLIMB to the current applied
|
||||||
|
// rate (descents pass — they're the cure); the short ack teaches the
|
||||||
|
// client controller its ceiling.
|
||||||
|
let live = live_bitrate.load(Ordering::Relaxed);
|
||||||
|
if cadence_degraded.load(Ordering::Relaxed) && live != 0 && r > live {
|
||||||
|
tracing::info!(
|
||||||
|
requested_kbps = req.bitrate_kbps,
|
||||||
|
held_kbps = live,
|
||||||
|
"bitrate climb refused — encode is behind cadence"
|
||||||
|
);
|
||||||
|
r = live;
|
||||||
|
}
|
||||||
|
r
|
||||||
};
|
};
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
requested_kbps = req.bitrate_kbps,
|
requested_kbps = req.bitrate_kbps,
|
||||||
|
|||||||
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
|
|||||||
pub(super) compositor: crate::vdisplay::Compositor,
|
pub(super) compositor: crate::vdisplay::Compositor,
|
||||||
/// Negotiated encoder bitrate (kbps).
|
/// Negotiated encoder bitrate (kbps).
|
||||||
pub(super) bitrate_kbps: u32,
|
pub(super) bitrate_kbps: u32,
|
||||||
|
/// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the
|
||||||
|
/// mgmt registry AND the control task (which acks climbs against it). The encode loop stores
|
||||||
|
/// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks
|
||||||
|
/// what the ASIC really targets, not what was requested (§ABR overdrive).
|
||||||
|
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||||
|
/// The encoder's discovered codec-level bitrate ceiling (kbps; 0 = none discovered): written
|
||||||
|
/// when an apply comes back short, read by this loop (pre-clamp incoming requests — a
|
||||||
|
/// request already AT the ceiling then costs nothing) and by the control task (truthful
|
||||||
|
/// acks from the first post-discovery request).
|
||||||
|
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||||
|
/// "Encode can't hold the frame cadence" (the escalation leaky bucket is elevated, or the
|
||||||
|
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
|
||||||
|
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
|
||||||
|
pub(super) cadence_degraded: Arc<AtomicBool>,
|
||||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||||
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
bitrate_rx,
|
bitrate_rx,
|
||||||
compositor,
|
compositor,
|
||||||
mut bitrate_kbps,
|
mut bitrate_kbps,
|
||||||
|
live_bitrate,
|
||||||
|
encoder_ceiling_kbps,
|
||||||
|
cadence_degraded,
|
||||||
bitrate_auto,
|
bitrate_auto,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||||
@@ -1254,9 +1271,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
|
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
|
||||||
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
|
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
|
||||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
|
||||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
// `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
|
||||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
// control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
|
||||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
// encoder-APPLIED rate, so the console, pacer and climb-refusal acks all see the truth.
|
||||||
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
|
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
|
||||||
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the
|
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the
|
||||||
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
|
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
|
||||||
@@ -1436,6 +1453,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
const DEPTH_ESCALATE: u32 = 20;
|
const DEPTH_ESCALATE: u32 = 20;
|
||||||
const DEPTH_BEHIND_CAP: u32 = 60;
|
const DEPTH_BEHIND_CAP: u32 = 60;
|
||||||
const DEPTH_WARMUP_FRAMES: u64 = 60;
|
const DEPTH_WARMUP_FRAMES: u64 = 60;
|
||||||
|
// Half the escalate threshold: ~10 net behind-frames is already solid "the encoder, not the
|
||||||
|
// network, is the bottleneck" evidence — enough to flag `cadence_degraded` (the control task
|
||||||
|
// then refuses bitrate CLIMBS) well before the session pays a latency escalation for it.
|
||||||
|
const DEPTH_DEGRADE: u32 = 10;
|
||||||
|
// De-escalation (the escalate-and-hold v1's missing half): a sustained clean run at the
|
||||||
|
// escalated setting (~5 s at 120 fps, every frame on cadence) earns ONE attempt at winding
|
||||||
|
// back — reverse order of the escalation, pipelined retrieve first (its rebuild restores
|
||||||
|
// sub-frame streaming and the IO-stream binding), then capture depth back to 1. Each
|
||||||
|
// attempt costs the wind-back rebuild's IDR, so attempts are paced by an exponential
|
||||||
|
// backoff (1 → 5 → 25 min, capped) — a workload that genuinely needs the escalation
|
||||||
|
// converges to keeping it, but NEVER a permanent latch: a latch plus the ABR sawtooth
|
||||||
|
// pinned sessions at the floor with the escalation stuck.
|
||||||
|
const DEESCALATE_CLEAN_FRAMES: u32 = 600;
|
||||||
|
const DEESCALATE_BACKOFF_START: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
const DEESCALATE_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_secs(25 * 60);
|
||||||
|
let mut pipelined_active = false;
|
||||||
|
let mut deescalating = false;
|
||||||
|
let mut ahead_run: u32 = 0;
|
||||||
|
let mut deescalate_not_before: Option<std::time::Instant> = None;
|
||||||
|
let mut deescalate_backoff = DEESCALATE_BACKOFF_START;
|
||||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||||
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
||||||
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
||||||
@@ -1674,15 +1711,49 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
while let Ok(k) = bitrate_rx.try_recv() {
|
while let Ok(k) = bitrate_rx.try_recv() {
|
||||||
want_kbps = Some(k);
|
want_kbps = Some(k);
|
||||||
}
|
}
|
||||||
|
// Known-ceiling pre-clamp (§ABR overdrive): once the encoder's codec-level ceiling is
|
||||||
|
// known, resolve an over-asking request HERE — a request that clamps to the rate we're
|
||||||
|
// already at then skips the whole apply, where the pre-fix path bounced every overshoot
|
||||||
|
// off the driver into a full rebuild + IDR (~0.6 s each, four in one logged minute).
|
||||||
|
// (The control task clamps its acks from the same atomic; this covers requests already
|
||||||
|
// in flight when the ceiling was discovered.)
|
||||||
|
if let Some(k) = want_kbps.as_mut() {
|
||||||
|
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||||
|
if ceiling != 0 && *k > ceiling {
|
||||||
|
tracing::info!(
|
||||||
|
requested_kbps = *k,
|
||||||
|
ceiling_kbps = ceiling,
|
||||||
|
"bitrate request clamped to the known encoder ceiling"
|
||||||
|
);
|
||||||
|
*k = ceiling;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||||
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
||||||
|
// Adopt the encoder's post-clamp truth, not the request: it feeds the send
|
||||||
|
// pacer, the console/mgmt view and the control task's acks, and a short apply
|
||||||
|
// teaches the ceiling used above.
|
||||||
|
let applied_kbps = enc
|
||||||
|
.applied_bitrate_bps()
|
||||||
|
.map(|b| (b / 1000) as u32)
|
||||||
|
.filter(|&k| k > 0)
|
||||||
|
.unwrap_or(new_kbps);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
from_kbps = bitrate_kbps,
|
from_kbps = bitrate_kbps,
|
||||||
to_kbps = new_kbps,
|
to_kbps = applied_kbps,
|
||||||
|
requested_kbps = new_kbps,
|
||||||
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
|
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
|
||||||
);
|
);
|
||||||
bitrate_kbps = new_kbps;
|
if applied_kbps < new_kbps {
|
||||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if applied_kbps < bitrate_kbps {
|
||||||
|
// Down-step: the behind-cadence backlog was scored against the old,
|
||||||
|
// heavier rate — clean slate so it can't feed a false escalation.
|
||||||
|
behind_score = 0;
|
||||||
|
}
|
||||||
|
bitrate_kbps = applied_kbps;
|
||||||
|
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||||
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
|
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
|
||||||
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
||||||
} else {
|
} else {
|
||||||
@@ -1702,9 +1773,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
plan.cursor_blend,
|
plan.cursor_blend,
|
||||||
) {
|
) {
|
||||||
Ok(mut new_enc) => {
|
Ok(mut new_enc) => {
|
||||||
|
// The fresh encoder may have clamped to its codec-level ceiling —
|
||||||
|
// adopt (and record) ITS rate, not the request; see the in-place arm.
|
||||||
|
let applied_kbps = new_enc
|
||||||
|
.applied_bitrate_bps()
|
||||||
|
.map(|b| (b / 1000) as u32)
|
||||||
|
.filter(|&k| k > 0)
|
||||||
|
.unwrap_or(new_kbps);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
from_kbps = bitrate_kbps,
|
from_kbps = bitrate_kbps,
|
||||||
to_kbps = new_kbps,
|
to_kbps = applied_kbps,
|
||||||
|
requested_kbps = new_kbps,
|
||||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||||
);
|
);
|
||||||
if let Some(c) = plan.wire_chunk {
|
if let Some(c) = plan.wire_chunk {
|
||||||
@@ -1714,8 +1793,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// directly so an ABR rebuild re-establishes the bound immediately.)
|
// directly so an ABR rebuild re-establishes the bound immediately.)
|
||||||
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||||
enc = new_enc;
|
enc = new_enc;
|
||||||
bitrate_kbps = new_kbps;
|
if applied_kbps < new_kbps {
|
||||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
bitrate_kbps = applied_kbps;
|
||||||
|
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||||
// The owed AUs died with the old encoder — same bookkeeping as a
|
// The owed AUs died with the old encoder — same bookkeeping as a
|
||||||
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
||||||
// IDR cooldown too.
|
// IDR cooldown too.
|
||||||
@@ -1723,6 +1805,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
last_au_at = std::time::Instant::now();
|
last_au_at = std::time::Instant::now();
|
||||||
encoder_resets = 0;
|
encoder_resets = 0;
|
||||||
last_forced_idr = Some(std::time::Instant::now());
|
last_forced_idr = Some(std::time::Instant::now());
|
||||||
|
// The rebuild stall itself (~0.6 s ≈ 70 missed deadlines at 120 fps,
|
||||||
|
// 3.5× the escalate threshold) must not feed the contention
|
||||||
|
// escalation — clean slate + re-run the warmup before judging again.
|
||||||
|
behind_score = 0;
|
||||||
|
depth_frames = 0;
|
||||||
|
ahead_run = 0;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||||
@@ -2547,7 +2635,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
|
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
|
||||||
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
|
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
|
||||||
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
|
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
|
||||||
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
|
if idd_adaptive_enabled() {
|
||||||
depth_frames += 1;
|
depth_frames += 1;
|
||||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||||
let behind = std::time::Instant::now() >= next;
|
let behind = std::time::Instant::now() >= next;
|
||||||
@@ -2556,28 +2644,89 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
} else {
|
} else {
|
||||||
behind_score.saturating_sub(1)
|
behind_score.saturating_sub(1)
|
||||||
};
|
};
|
||||||
if behind_score >= DEPTH_ESCALATE {
|
let escalated = cur_depth > 1 || pipelined_active || deescalating;
|
||||||
|
// Export "encode can't hold cadence" for the control task's climb refusal.
|
||||||
|
// An escalated session stays flagged even with the bucket drained: its climb
|
||||||
|
// headroom is spent, and letting climbs resume would saw against the
|
||||||
|
// escalation and starve the de-escalation clean run below.
|
||||||
|
cadence_degraded.store(
|
||||||
|
escalated || behind_score >= DEPTH_DEGRADE,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
if deescalating {
|
||||||
|
// A requested wind-back completes at the encoder's drained safe point —
|
||||||
|
// poll it (the call is a cheap latch check until then).
|
||||||
|
if !enc.set_pipelined(false) {
|
||||||
|
deescalating = false;
|
||||||
|
pipelined_active = false;
|
||||||
|
// Re-arm the ask: a future sustained overrun may escalate again (the
|
||||||
|
// backoff below paces how soon another wind-back may follow it).
|
||||||
|
pipeline_asked = false;
|
||||||
|
tracing::info!(
|
||||||
|
"encoder pipelined retrieve de-escalated — sync retrieve (and \
|
||||||
|
sub-frame streaming, where armed) restored; re-monitoring cadence"
|
||||||
|
);
|
||||||
|
// The wind-back rebuild's own stall must not re-escalate on the spot.
|
||||||
|
behind_score = 0;
|
||||||
|
depth_frames = 0;
|
||||||
|
ahead_run = 0;
|
||||||
|
}
|
||||||
|
} else if behind_score >= DEPTH_ESCALATE
|
||||||
|
&& (cur_depth < max_depth || !pipeline_asked)
|
||||||
|
{
|
||||||
if cur_depth < max_depth {
|
if cur_depth < max_depth {
|
||||||
cur_depth = max_depth;
|
cur_depth = max_depth;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
depth = cur_depth,
|
depth = cur_depth,
|
||||||
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
||||||
(GPU contention); pipelining for the rest of the session (latency \
|
(GPU contention); pipelining until cadence holds clean (latency \
|
||||||
trade for throughput)"
|
trade for throughput)"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
pipeline_asked = true;
|
pipeline_asked = true;
|
||||||
if enc.set_pipelined(true) {
|
pipelined_active = enc.set_pipelined(true);
|
||||||
|
if pipelined_active {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"encoder pipelined retrieve escalated — encode can't hold \
|
"encoder pipelined retrieve escalated — encode can't hold \
|
||||||
cadence and the capturer has no depth to give; the encode wait \
|
cadence and the capturer has no depth to give; the encode wait \
|
||||||
moves off the loop for the rest of the session (latency trade \
|
moves off the loop until cadence holds clean (latency trade \
|
||||||
for throughput)"
|
for throughput)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Give the action time to take effect before judging again.
|
// Give the action time to take effect before judging again.
|
||||||
behind_score = 0;
|
behind_score = 0;
|
||||||
|
ahead_run = 0;
|
||||||
|
} else if escalated {
|
||||||
|
// De-escalation: a sustained every-frame-on-cadence run at the escalated
|
||||||
|
// setting is the evidence the contention passed (a lower ABR rate, the
|
||||||
|
// game scene lightened) — wind back in reverse order, paced by the
|
||||||
|
// exponential backoff (see the consts above).
|
||||||
|
ahead_run = if behind { 0 } else { ahead_run + 1 };
|
||||||
|
if ahead_run >= DEESCALATE_CLEAN_FRAMES
|
||||||
|
&& deescalate_not_before.is_none_or(|t| std::time::Instant::now() >= t)
|
||||||
|
{
|
||||||
|
ahead_run = 0;
|
||||||
|
deescalate_not_before =
|
||||||
|
Some(std::time::Instant::now() + deescalate_backoff);
|
||||||
|
deescalate_backoff = (deescalate_backoff * 5).min(DEESCALATE_BACKOFF_MAX);
|
||||||
|
if pipelined_active {
|
||||||
|
tracing::info!(
|
||||||
|
"cadence held clean while escalated — winding the pipelined \
|
||||||
|
retrieve back (latency recovery; costs one IDR)"
|
||||||
|
);
|
||||||
|
deescalating = true;
|
||||||
|
} else if cur_depth > 1 {
|
||||||
|
cur_depth = 1;
|
||||||
|
tracing::info!(
|
||||||
|
depth = cur_depth,
|
||||||
|
"IDD pipeline depth de-escalated — cadence held clean at the \
|
||||||
|
escalated depth (latency recovery)"
|
||||||
|
);
|
||||||
|
behind_score = 0;
|
||||||
|
depth_frames = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
|
|||||||
) {
|
) {
|
||||||
eprintln!("warning: could not add the firewall rule for TCP 47992");
|
eprintln!("warning: could not add the firewall rule for TCP 47992");
|
||||||
}
|
}
|
||||||
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
|
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
|
||||||
for _ in 0..30 {
|
//
|
||||||
if token_path.exists() {
|
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
|
||||||
break;
|
// host writes them in that order: the token during argument parsing (`main::parse_serve`,
|
||||||
|
// milliseconds after `serve` starts), the cert only once `serve` reaches
|
||||||
|
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
|
||||||
|
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
|
||||||
|
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
|
||||||
|
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
|
||||||
|
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
|
||||||
|
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
|
||||||
|
// files are on disk before the first start.
|
||||||
|
let cert_path = data_dir.join("cert.pem");
|
||||||
|
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: the host service has not written {} + {} yet - not starting the console now; \
|
||||||
|
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
|
||||||
|
token_path.display(),
|
||||||
|
cert_path.display()
|
||||||
|
);
|
||||||
|
println!("web console set up (https://<host-ip>:47992)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start_web_task() {
|
||||||
|
println!("web console set up + started (https://<host-ip>:47992)");
|
||||||
|
} else {
|
||||||
|
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
|
||||||
|
// console, so this warning is the only trace an operator gets at install time.
|
||||||
|
eprintln!(
|
||||||
|
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
|
||||||
|
Result in Task Scheduler; the console will be retried at the next boot"
|
||||||
|
);
|
||||||
|
println!("web console set up (https://<host-ip>:47992)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
|
||||||
|
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
|
||||||
|
for _ in 0..secs {
|
||||||
|
if paths.iter().all(|p| p.exists()) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
}
|
}
|
||||||
|
paths.iter().all(|p| p.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
|
||||||
|
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
|
||||||
|
/// up listening.
|
||||||
|
fn start_web_task() -> bool {
|
||||||
|
for _ in 0..3 {
|
||||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||||
println!("web console set up + started (https://<host-ip>:47992)");
|
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
|
||||||
Ok(())
|
for _ in 0..10 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
if !web_listener_pids().is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
|
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
|
||||||
@@ -505,30 +558,57 @@ fn random_password() -> String {
|
|||||||
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
|
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
|
||||||
fn stop_web_console() {
|
fn stop_web_console() {
|
||||||
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
||||||
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
|
for pid in web_listener_pids() {
|
||||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||||
if toks.len() >= 5
|
|
||||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
|
||||||
&& toks[1].ends_with(":47992")
|
|
||||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
|
|
||||||
{
|
|
||||||
let pid = toks[toks.len() - 1];
|
|
||||||
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
|
|
||||||
run_quiet("taskkill", &["/PID", pid, "/F"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`,
|
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
|
||||||
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
fn web_listener_pids() -> Vec<String> {
|
||||||
|
run_capture("netstat", &["-ano", "-p", "tcp"])
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
let listening = toks.len() >= 5
|
||||||
|
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||||
|
&& toks[1].ends_with(":47992")
|
||||||
|
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
|
||||||
|
let pid = *toks.last()?;
|
||||||
|
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
|
||||||
|
.then(|| pid.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
|
||||||
|
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||||
|
///
|
||||||
|
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
|
||||||
|
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
|
||||||
|
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
|
||||||
|
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
|
||||||
|
/// fail registration outright (no task at all is strictly worse than a boot-only task).
|
||||||
fn register_web_task(cmd: &Path) -> Result<()> {
|
fn register_web_task(cmd: &Path) -> Result<()> {
|
||||||
|
let cmd_xml = xml_escape(&cmd.to_string_lossy());
|
||||||
|
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
|
||||||
|
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
|
||||||
|
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
|
||||||
|
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
|
||||||
|
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("schtasks /create {WEB_TASK} failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
|
||||||
|
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
|
||||||
let xml = format!(
|
let xml = format!(
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||||
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||||
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
|
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
|
||||||
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\
|
<Triggers>{triggers}</Triggers>\n\
|
||||||
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
|
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
|
||||||
<Settings>\n\
|
<Settings>\n\
|
||||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||||
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
|||||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
||||||
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
||||||
</Settings>\n\
|
</Settings>\n\
|
||||||
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
|
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
|
||||||
</Task>",
|
</Task>"
|
||||||
xml_escape(&cmd.to_string_lossy())
|
|
||||||
);
|
);
|
||||||
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
|
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
|
||||||
write_utf16le_bom(&xml_path, &xml)?;
|
if write_utf16le_bom(&xml_path, &xml).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let ok = run_quiet(
|
let ok = run_quiet(
|
||||||
"schtasks",
|
"schtasks",
|
||||||
&[
|
&[
|
||||||
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_file(&xml_path);
|
let _ = std::fs::remove_file(&xml_path);
|
||||||
if ok {
|
ok
|
||||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
bail!("schtasks /create {WEB_TASK} failed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
|
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus};
|
|||||||
struct HostTray {
|
struct HostTray {
|
||||||
status: TrayStatus,
|
status: TrayStatus,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
/// The console answered the poller's live loopback probe — labels the (always present) "Open
|
||||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
/// web console" entry; it never hides it.
|
||||||
web_console: bool,
|
web_console: bool,
|
||||||
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
|
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
|
||||||
/// force an immediate re-poll instead of waiting out the cadence.
|
/// force an immediate re-poll instead of waiting out the cadence.
|
||||||
@@ -33,8 +33,10 @@ impl HostTray {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_console(&self) {
|
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
|
||||||
let url = format!("https://127.0.0.1:{}", self.web_port);
|
/// the menu entry promised — the pairing queue, the virtual displays.
|
||||||
|
fn open_console(&self, path: &str) {
|
||||||
|
let url = format!("https://127.0.0.1:{}/{path}", self.web_port);
|
||||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +53,7 @@ impl ksni::Tray for HostTray {
|
|||||||
fn status(&self) -> ksni::Status {
|
fn status(&self) -> ksni::Status {
|
||||||
match &self.status {
|
match &self.status {
|
||||||
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
|
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
|
||||||
s if s.pairing_attention() || s.has_conflicts() => ksni::Status::NeedsAttention,
|
s if s.pairing_attention() => ksni::Status::NeedsAttention,
|
||||||
_ => ksni::Status::Active,
|
_ => ksni::Status::Active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
|
|||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
MenuItem::Separator,
|
MenuItem::Separator,
|
||||||
|
// Always present — it is the reason most people open this menu. When the loopback
|
||||||
|
// probe says the console isn't answering, the label says so rather than the entry
|
||||||
|
// disappearing (a menu missing its main action reads as a broken tray).
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Open web console".into(),
|
label: if self.web_console {
|
||||||
visible: self.web_console,
|
"Open web console".to_string()
|
||||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
} else {
|
||||||
|
"Open web console (not responding)".to_string()
|
||||||
|
},
|
||||||
|
activate: Box::new(|t: &mut Self| t.open_console("")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Approve pairing request…".into(),
|
label: "Approve pairing request…".into(),
|
||||||
visible: self.web_console && self.status.pairing_attention(),
|
visible: self.status.pairing_attention(),
|
||||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
activate: Box::new(|t: &mut Self| t.open_console("pairing")),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
StandardItem {
|
||||||
|
label: match self.status.kept_displays() {
|
||||||
|
1 => "Release kept display…".to_string(),
|
||||||
|
n => format!("Release {n} kept displays…"),
|
||||||
|
},
|
||||||
|
visible: self.status.kept_displays() > 0,
|
||||||
|
activate: Box::new(|t: &mut Self| t.open_console("displays")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ pub struct Summary {
|
|||||||
/// host that doesn't send it deserializes as 0.
|
/// host that doesn't send it deserializes as 0.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kept_displays: u32,
|
pub kept_displays: u32,
|
||||||
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) the host detected on this machine at
|
|
||||||
/// startup — side-by-side use is unsupported. `#[serde(default)]` so an older host omitting it
|
|
||||||
/// deserializes as empty.
|
|
||||||
#[serde(default)]
|
|
||||||
pub conflicts: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||||
@@ -74,8 +69,7 @@ impl TrayStatus {
|
|||||||
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
||||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||||
TrayStatus::Running(s) => {
|
TrayStatus::Running(s) => match (&s.session, self.is_streaming()) {
|
||||||
let base = match (&s.session, s.video_streaming) {
|
|
||||||
(Some(sess), true) => format!(
|
(Some(sess), true) => format!(
|
||||||
"punktfunk host {} — streaming {}×{}@{}",
|
"punktfunk host {} — streaming {}×{}@{}",
|
||||||
s.version, sess.width, sess.height, sess.fps
|
s.version, sess.width, sess.height, sess.fps
|
||||||
@@ -91,30 +85,27 @@ impl TrayStatus {
|
|||||||
if s.kept_displays == 1 { "" } else { "s" }
|
if s.kept_displays == 1 { "" } else { "s" }
|
||||||
),
|
),
|
||||||
_ => format!("punktfunk host {} — idle", s.version),
|
_ => format!("punktfunk host {} — idle", s.version),
|
||||||
};
|
},
|
||||||
// A conflicting Moonlight host (Sunshine/Apollo/…) is the loudest thing to say —
|
|
||||||
// side-by-side use is unsupported, so lead the tooltip with it.
|
|
||||||
if s.conflicts.is_empty() {
|
|
||||||
base
|
|
||||||
} else {
|
|
||||||
format!("⚠ conflicting host: {} — {base}", s.conflicts.join(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
|
/// A client is streaming: the host's flag, OR a live session in the summary.
|
||||||
/// unsupported side-by-side. Drives the Linux (ksni) backend's `NeedsAttention` state; the
|
///
|
||||||
/// Windows backend surfaces the same conflict through the tooltip `headline()` instead (it has
|
/// The session is checked too because a host from before the `get_local_summary` fix raised
|
||||||
/// no distinct attention icon), so this accessor is unused there — allow it per-platform rather
|
/// `video_streaming` from the GameStream plane only — through a whole session on the native
|
||||||
/// than gate the shared API out.
|
/// (default) plane it said false while still reporting that session's mode, which is what left
|
||||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
/// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host.
|
||||||
pub fn has_conflicts(&self) -> bool {
|
|
||||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_streaming(&self) -> bool {
|
pub fn is_streaming(&self) -> bool {
|
||||||
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual displays held with no live session (lingering/pinned) — offered as a one-click
|
||||||
|
/// release in the menu, since holding one can also be keeping physical monitors dark.
|
||||||
|
pub fn kept_displays(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
TrayStatus::Running(s) => s.kept_displays,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
||||||
@@ -156,9 +147,11 @@ struct Shared {
|
|||||||
|
|
||||||
impl Poller {
|
impl Poller {
|
||||||
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
|
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
|
||||||
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` —
|
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
|
||||||
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
|
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
|
||||||
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
|
/// is the tray's most-wanted action, and a menu that silently drops it — because the console
|
||||||
|
/// was still starting, or the probe timed out — is indistinguishable from a tray that never
|
||||||
|
/// had one.
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
mgmt_addr: String,
|
mgmt_addr: String,
|
||||||
mgmt_port: u16,
|
mgmt_port: u16,
|
||||||
@@ -203,6 +196,10 @@ fn poll_loop(
|
|||||||
// When the summary became unreachable while the service was running (grace anchor).
|
// When the summary became unreachable while the service was running (grace anchor).
|
||||||
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||||
let mut unreachable_since: Option<Instant> = None;
|
let mut unreachable_since: Option<Instant> = None;
|
||||||
|
// Consecutive failed console probes. One miss is not "down": the console is a bun/Nitro SSR
|
||||||
|
// whose cold first render can outrun this agent's 2 s timeout, and a menu entry that changes
|
||||||
|
// its label every few seconds reads as broken.
|
||||||
|
let mut console_misses = 0u32;
|
||||||
loop {
|
loop {
|
||||||
let svc = probe_service();
|
let svc = probe_service();
|
||||||
let summary = if svc == ServiceState::Running {
|
let summary = if svc == ServiceState::Running {
|
||||||
@@ -219,7 +216,13 @@ fn poll_loop(
|
|||||||
};
|
};
|
||||||
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||||
let status = map_status(&svc, summary, grace_expired);
|
let status = map_status(&svc, summary, grace_expired);
|
||||||
let console_up = probe_console(&agent, &console_url);
|
let console_up = if probe_console(&agent, &console_url) {
|
||||||
|
console_misses = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
console_misses += 1;
|
||||||
|
console_misses < 2
|
||||||
|
};
|
||||||
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
||||||
on_change(status.clone(), console_up);
|
on_change(status.clone(), console_up);
|
||||||
last = Some((status, console_up));
|
last = Some((status, console_up));
|
||||||
@@ -406,7 +409,6 @@ mod tests {
|
|||||||
pin_pending: false,
|
pin_pending: false,
|
||||||
pending_approvals: 0,
|
pending_approvals: 0,
|
||||||
kept_displays: 0,
|
kept_displays: 0,
|
||||||
conflicts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,15 +451,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Conflicting-host detection is deliberately NOT a tray concern: "installed" is not
|
||||||
|
/// "running", and an always-on ⚠ over a merely-present Sunshine cried wolf. The host still
|
||||||
|
/// detects and reports it (startup log, `detect-conflicts`, the mgmt summary) — the tray just
|
||||||
|
/// ignores that field, which it must keep tolerating on the wire.
|
||||||
#[test]
|
#[test]
|
||||||
fn conflicts_drive_attention_and_lead_the_tooltip() {
|
fn a_summary_carrying_conflicts_still_deserializes_and_is_ignored() {
|
||||||
let mut s = summary(false);
|
let json = r#"{"version":"0.5.1","video_streaming":false,"audio_streaming":false,
|
||||||
assert!(!TrayStatus::Running(s.clone()).has_conflicts());
|
"session":null,"paired_clients":1,"native_paired_clients":2,"pin_pending":false,
|
||||||
s.conflicts = vec!["Sunshine (running)".into(), "Apollo".into()];
|
"pending_approvals":0,"kept_displays":0,"conflicts":["Sunshine (installed)"]}"#;
|
||||||
let st = TrayStatus::Running(s);
|
let s: Summary = serde_json::from_str(json).expect("unknown fields are ignored");
|
||||||
assert!(st.has_conflicts());
|
assert_eq!(
|
||||||
let head = st.headline();
|
TrayStatus::Running(s).headline(),
|
||||||
assert!(head.starts_with("⚠ conflicting host: Sunshine (running), Apollo"));
|
"punktfunk host 0.5.1 — idle"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -478,6 +485,32 @@ mod tests {
|
|||||||
.contains("status unavailable"));
|
.contains("status unavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A live session means streaming even if the host's flag says otherwise — a host from before
|
||||||
|
/// the `get_local_summary` fix only raised `video_streaming` for the GameStream plane, so a
|
||||||
|
/// native session showed as "idle" with its own mode printed next to it.
|
||||||
|
#[test]
|
||||||
|
fn a_live_session_reads_as_streaming_without_the_flag() {
|
||||||
|
let mut s = summary(true);
|
||||||
|
s.video_streaming = false; // pre-fix host, native session
|
||||||
|
let st = TrayStatus::Running(s);
|
||||||
|
assert!(st.is_streaming());
|
||||||
|
assert_eq!(
|
||||||
|
st.headline(),
|
||||||
|
"punktfunk host 0.5.1 — streaming 2560×1440@120"
|
||||||
|
);
|
||||||
|
// No session and no flag is still idle.
|
||||||
|
assert!(!TrayStatus::Running(summary(false)).is_streaming());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kept_displays_are_reported_for_the_release_action() {
|
||||||
|
assert_eq!(TrayStatus::Running(summary(false)).kept_displays(), 0);
|
||||||
|
let mut s = summary(false);
|
||||||
|
s.kept_displays = 2;
|
||||||
|
assert_eq!(TrayStatus::Running(s).kept_displays(), 2);
|
||||||
|
assert_eq!(TrayStatus::Degraded.kept_displays(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pairing_attention_flags() {
|
fn pairing_attention_flags() {
|
||||||
let mut s = summary(false);
|
let mut s = summary(false);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104;
|
|||||||
const IDM_LOGS: usize = 0x0105;
|
const IDM_LOGS: usize = 0x0105;
|
||||||
const IDM_EXIT: usize = 0x0106;
|
const IDM_EXIT: usize = 0x0106;
|
||||||
const IDM_PAIRING: usize = 0x0107;
|
const IDM_PAIRING: usize = 0x0107;
|
||||||
|
const IDM_DISPLAYS: usize = 0x0108;
|
||||||
|
|
||||||
/// Icon resource ordinals (embedded by build.rs).
|
/// Icon resource ordinals (embedded by build.rs).
|
||||||
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||||
@@ -73,8 +74,9 @@ struct App {
|
|||||||
taskbar_created: u32,
|
taskbar_created: u32,
|
||||||
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||||
host_exe: Option<std::path::PathBuf>,
|
host_exe: Option<std::path::PathBuf>,
|
||||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
/// The console answered the poller's live loopback probe. Drives the label of the (always
|
||||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
/// present) "Open web console" entry, and whether a left-click on the icon opens the console
|
||||||
|
/// or falls back to showing the menu.
|
||||||
web_console: AtomicBool,
|
web_console: AtomicBool,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
}
|
}
|
||||||
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
|
|||||||
};
|
};
|
||||||
add(IDM_HEADER, &status.headline(), true);
|
add(IDM_HEADER, &status.headline(), true);
|
||||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
|
// The console entry is ALWAYS here — it is the reason most people open this menu, and
|
||||||
|
// left-clicking the icon is not a discoverable substitute. When the loopback probe says
|
||||||
|
// the console isn't answering the label says so, rather than the entry vanishing.
|
||||||
if app().web_console.load(Ordering::SeqCst) {
|
if app().web_console.load(Ordering::SeqCst) {
|
||||||
add(IDM_OPEN_WEB, "Open web console", false);
|
add(IDM_OPEN_WEB, "Open web console", false);
|
||||||
|
} else {
|
||||||
|
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
|
||||||
|
}
|
||||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||||
if status.pairing_attention() {
|
if status.pairing_attention() {
|
||||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||||
}
|
}
|
||||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
match status.kept_displays() {
|
||||||
|
0 => {}
|
||||||
|
1 => add(IDM_DISPLAYS, "Release kept display…", false),
|
||||||
|
n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false),
|
||||||
}
|
}
|
||||||
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
if can_control {
|
if can_control {
|
||||||
if startable {
|
if startable {
|
||||||
add(IDM_START, "Start host", false);
|
add(IDM_START, "Start host", false);
|
||||||
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_web_console(hwnd: HWND) {
|
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
|
||||||
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
/// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard.
|
||||||
|
fn open_web_console(hwnd: HWND, path: &str) {
|
||||||
|
shell_open(
|
||||||
|
hwnd,
|
||||||
|
&format!("https://localhost:{}/{path}", app().web_port),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_logs(hwnd: HWND) {
|
fn open_logs(hwnd: HWND) {
|
||||||
@@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
|||||||
WM_CONTEXTMENU => show_menu(hwnd),
|
WM_CONTEXTMENU => show_menu(hwnd),
|
||||||
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||||
if app.web_console.load(Ordering::SeqCst) {
|
if app.web_console.load(Ordering::SeqCst) {
|
||||||
open_web_console(hwnd);
|
open_web_console(hwnd, "");
|
||||||
} else {
|
} else {
|
||||||
show_menu(hwnd);
|
show_menu(hwnd);
|
||||||
}
|
}
|
||||||
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
|||||||
}
|
}
|
||||||
WM_COMMAND => {
|
WM_COMMAND => {
|
||||||
match (wparam.0) & 0xffff {
|
match (wparam.0) & 0xffff {
|
||||||
IDM_OPEN_WEB => open_web_console(hwnd),
|
IDM_OPEN_WEB => open_web_console(hwnd, ""),
|
||||||
IDM_PAIRING => open_web_console(hwnd),
|
IDM_PAIRING => open_web_console(hwnd, "pairing"),
|
||||||
|
IDM_DISPLAYS => open_web_console(hwnd, "displays"),
|
||||||
IDM_START => elevate_service(hwnd, "start"),
|
IDM_START => elevate_service(hwnd, "start"),
|
||||||
IDM_STOP => elevate_service(hwnd, "stop"),
|
IDM_STOP => elevate_service(hwnd, "stop"),
|
||||||
IDM_RESTART => elevate_service(hwnd, "restart"),
|
IDM_RESTART => elevate_service(hwnd, "restart"),
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
NT-handle import: consume on SUCCESS only — PUNKTFUNK LOCAL PATCH.
|
||||||
|
|
||||||
|
Not upstream. Granite's DeviceAllocator::internal_allocate closed the imported
|
||||||
|
by-reference NT handle (OPAQUE_WIN32 / D3D11_TEXTURE / D3D12_RESOURCE — the
|
||||||
|
predicate excludes fd and KMT types) UNCONDITIONALLY after the FIRST
|
||||||
|
vkAllocateMemory — on success AND on failure — while every failure before the
|
||||||
|
allocator (c-shim validation, vkCreateImage, find_memory_type) left the handle
|
||||||
|
open. Both classes surface to pyrowave_image_create's caller as the same error
|
||||||
|
code, so the caller could not know whether to close: punktfunk's import_plane
|
||||||
|
closed on every failure and double-closed whenever the failure was at or after
|
||||||
|
the allocate — and a recycled handle value makes that close an unrelated live
|
||||||
|
handle's. The unconditional consume also broke the allocator's own
|
||||||
|
block-recycling retry loop, which re-ran vkAllocateMemory with import_info
|
||||||
|
still pointing at the just-closed handle.
|
||||||
|
|
||||||
|
Fixed by moving the consume to the API commit point: pyrowave_image_create
|
||||||
|
closes the by-reference handle exactly on its success return. This pins
|
||||||
|
pyrowave.h's documented contract ("take ownership and close the HANDLE on
|
||||||
|
import") to mean on SUCCESS, matching pyrowave_sync_object_create (Granite's
|
||||||
|
semaphore import already closes only after a successful vkImportSemaphore*).
|
||||||
|
NT-handle memory imports never transfer ownership at the Vulkan level (the
|
||||||
|
implementation holds its own reference), so the post-create close is legal.
|
||||||
|
|
||||||
|
Behavior note: Granite code that imports by-reference memory WITHOUT going
|
||||||
|
through pyrowave_image_create — in this vendored subset only wsi_dxgi.cpp's
|
||||||
|
interop swapchain, which the pyrowave C API never reaches — now keeps the
|
||||||
|
caller's handle open on allocate-stage failure instead of consuming it. That
|
||||||
|
matches the semaphore contract; those callers own their close-on-failure.
|
||||||
|
|
||||||
|
Correctness re-validated by pyrowave_win_smoke on real hardware after the
|
||||||
|
change.
|
||||||
|
|
||||||
|
diff --git a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp
|
||||||
|
index f33bdac3..fd035b66 100644
|
||||||
|
--- a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp
|
||||||
|
+++ b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp
|
||||||
|
@@ -732,16 +732,15 @@ bool DeviceAllocator::internal_allocate(
|
||||||
|
res = table->vkAllocateMemory(device->get_device(), &info, nullptr, &device_memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
- // If we're importing, make sure we consume the native handle.
|
||||||
|
- if (external && bool(*external) &&
|
||||||
|
- ExternalHandle::memory_handle_type_imports_by_reference(external->memory_handle_type))
|
||||||
|
- {
|
||||||
|
-#ifdef _WIN32
|
||||||
|
- ::CloseHandle(external->handle);
|
||||||
|
-#else
|
||||||
|
- ::close(external->handle);
|
||||||
|
-#endif
|
||||||
|
- }
|
||||||
|
+ // PUNKTFUNK (patch 0006): the consume of by-reference native handles used to happen HERE,
|
||||||
|
+ // unconditionally — success AND failure of the first vkAllocateMemory. That made the caller's
|
||||||
|
+ // failure contract ambiguous (an allocate-stage failure had already closed the handle while a
|
||||||
|
+ // create/find_memory_type failure had not), and the block-recycling retry loop below re-ran
|
||||||
|
+ // vkAllocateMemory with import_info still pointing at the just-closed handle. The consume now
|
||||||
|
+ // lives at the API commit point (pyrowave_c.cpp: pyrowave_image_create's success return), so
|
||||||
|
+ // the allocator never closes a caller's handle and retries import a still-open handle.
|
||||||
|
+ // (fd-type imports were never affected: memory_handle_type_imports_by_reference excludes
|
||||||
|
+ // OPAQUE_FD/DMA_BUF, whose ownership vkAllocateMemory itself transfers on success.)
|
||||||
|
|
||||||
|
if (res == VK_SUCCESS)
|
||||||
|
{
|
||||||
|
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||||
|
index eb917e64..985cd0a9 100644
|
||||||
|
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||||
|
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||||
|
@@ -592,6 +592,21 @@ pyrowave_result pyrowave_image_create(const pyrowave_image_create_info *info, py
|
||||||
|
if (!img)
|
||||||
|
return PYROWAVE_ERROR_FAILED_EXTERNAL_HANDLE;
|
||||||
|
|
||||||
|
+ // PUNKTFUNK (patch 0006): consume the imported by-reference NT handle exactly here, at the
|
||||||
|
+ // API's success boundary — the allocator no longer closes it (see memory_allocator.cpp).
|
||||||
|
+ // This pins pyrowave.h's documented contract ("take ownership and close the HANDLE on
|
||||||
|
+ // import") to mean ON SUCCESS, matching pyrowave_sync_object_create's semantics: on ANY
|
||||||
|
+ // failure return the caller still owns the handle and can close it unconditionally.
|
||||||
|
+ // By-reference NT-handle imports never transfer ownership at the Vulkan level (the
|
||||||
|
+ // implementation keeps its own reference), so a post-create close here is always legal.
|
||||||
|
+#ifdef _WIN32
|
||||||
|
+ if (info->external_handle &&
|
||||||
|
+ ExternalHandle::memory_handle_type_imports_by_reference(info->handle_type))
|
||||||
|
+ {
|
||||||
|
+ ::CloseHandle(reinterpret_cast<HANDLE>(info->external_handle));
|
||||||
|
+ }
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
auto *image = new pyrowave_image_opaque();
|
||||||
|
image->device = &device;
|
||||||
|
image->img = std::move(img);
|
||||||
@@ -732,16 +732,15 @@ bool DeviceAllocator::internal_allocate(
|
|||||||
res = table->vkAllocateMemory(device->get_device(), &info, nullptr, &device_memory);
|
res = table->vkAllocateMemory(device->get_device(), &info, nullptr, &device_memory);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're importing, make sure we consume the native handle.
|
// PUNKTFUNK (patch 0006): the consume of by-reference native handles used to happen HERE,
|
||||||
if (external && bool(*external) &&
|
// unconditionally — success AND failure of the first vkAllocateMemory. That made the caller's
|
||||||
ExternalHandle::memory_handle_type_imports_by_reference(external->memory_handle_type))
|
// failure contract ambiguous (an allocate-stage failure had already closed the handle while a
|
||||||
{
|
// create/find_memory_type failure had not), and the block-recycling retry loop below re-ran
|
||||||
#ifdef _WIN32
|
// vkAllocateMemory with import_info still pointing at the just-closed handle. The consume now
|
||||||
::CloseHandle(external->handle);
|
// lives at the API commit point (pyrowave_c.cpp: pyrowave_image_create's success return), so
|
||||||
#else
|
// the allocator never closes a caller's handle and retries import a still-open handle.
|
||||||
::close(external->handle);
|
// (fd-type imports were never affected: memory_handle_type_imports_by_reference excludes
|
||||||
#endif
|
// OPAQUE_FD/DMA_BUF, whose ownership vkAllocateMemory itself transfers on success.)
|
||||||
}
|
|
||||||
|
|
||||||
if (res == VK_SUCCESS)
|
if (res == VK_SUCCESS)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -592,6 +592,21 @@ pyrowave_result pyrowave_image_create(const pyrowave_image_create_info *info, py
|
|||||||
if (!img)
|
if (!img)
|
||||||
return PYROWAVE_ERROR_FAILED_EXTERNAL_HANDLE;
|
return PYROWAVE_ERROR_FAILED_EXTERNAL_HANDLE;
|
||||||
|
|
||||||
|
// PUNKTFUNK (patch 0006): consume the imported by-reference NT handle exactly here, at the
|
||||||
|
// API's success boundary — the allocator no longer closes it (see memory_allocator.cpp).
|
||||||
|
// This pins pyrowave.h's documented contract ("take ownership and close the HANDLE on
|
||||||
|
// import") to mean ON SUCCESS, matching pyrowave_sync_object_create's semantics: on ANY
|
||||||
|
// failure return the caller still owns the handle and can close it unconditionally.
|
||||||
|
// By-reference NT-handle imports never transfer ownership at the Vulkan level (the
|
||||||
|
// implementation keeps its own reference), so a post-create close here is always legal.
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (info->external_handle &&
|
||||||
|
ExternalHandle::memory_handle_type_imports_by_reference(info->handle_type))
|
||||||
|
{
|
||||||
|
::CloseHandle(reinterpret_cast<HANDLE>(info->external_handle));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
auto *image = new pyrowave_image_opaque();
|
auto *image = new pyrowave_image_opaque();
|
||||||
image->device = &device;
|
image->device = &device;
|
||||||
image->img = std::move(img);
|
image->img = std::move(img);
|
||||||
|
|||||||
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
|
|||||||
|
|
||||||
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
||||||
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
|
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
|
||||||
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it
|
itself for the length of the stream and restart it afterwards, which needs privilege. The
|
||||||
with a polkit rule (adjust the unit and user names to your box):
|
packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`) behind its
|
||||||
|
own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain
|
||||||
|
`systemctl` verbs are denied — no setup. The helper only stops/restores the unit the
|
||||||
|
`display-manager.service` symlink points at, the same class of local-seat operation these
|
||||||
|
distros already authorize for their own session switcher (Nobara's `os-session-select`).
|
||||||
|
|
||||||
|
Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy`
|
||||||
|
file and use a polkit rule scoped to your user instead (adjust the unit and user names to your
|
||||||
|
box) — the host tries the plain verbs first, so the rule takes precedence:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
|
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
|
||||||
@@ -54,15 +62,16 @@ depends on the display manager driving the autologin:
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session
|
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
|
||||||
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
|
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
|
||||||
display manager. If the display-manager restart ever loses its privilege mid-restore,
|
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
|
||||||
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
|
what happened; check the host log for "managed takeover unavailable"). If the display-manager
|
||||||
fallback.
|
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
|
||||||
|
[Configuration](/docs/configuration)) is fired as the fallback.
|
||||||
|
|
||||||
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's
|
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
|
||||||
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
|
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
|
||||||
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||||
|
|
||||||
## Session following
|
## Session following
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ the Windows specifics follow.
|
|||||||
|
|
||||||
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
|
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
|
||||||
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
||||||
**`https://<this-PC>:47992`**, starting at boot.
|
**`https://<this-PC>:47992`**, starting at boot and at sign-in.
|
||||||
|
|
||||||
#### Console login password
|
#### Console login password
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -19,13 +19,24 @@ pkgbase=punktfunk
|
|||||||
# host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web` / `%bcond_with
|
# host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web` / `%bcond_with
|
||||||
# scripting` (off by default). Set PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 to also build them (each
|
# scripting` (off by default). Set PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 to also build them (each
|
||||||
# appends its pkgname + bun to makedepends below).
|
# appends its pkgname + bun to makedepends below).
|
||||||
pkgname=('punktfunk-host' 'punktfunk-client')
|
# aarch64 (Arch Linux ARM) is CLIENT-ONLY — see the `arch` note below. Dropping the host from
|
||||||
|
# `pkgname` (rather than giving package_punktfunk-host a per-package arch) is what keeps makepkg
|
||||||
|
# from entering the host's build/package path at all, and mirrors how PF_WITH_WEB extends this
|
||||||
|
# array further down.
|
||||||
|
if [ "${CARCH:-x86_64}" = 'aarch64' ]; then
|
||||||
|
pkgname=('punktfunk-client')
|
||||||
|
else
|
||||||
|
pkgname=('punktfunk-host' 'punktfunk-client')
|
||||||
|
fi
|
||||||
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
|
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
|
||||||
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
|
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
|
||||||
# allows only digits+dots, so the run number carries the monotonic ordering).
|
# allows only digits+dots, so the run number carries the monotonic ordering).
|
||||||
pkgver="${PF_PKGVER:-0.7.0}"
|
pkgver="${PF_PKGVER:-0.7.0}"
|
||||||
pkgrel="${PF_PKGREL:-1}"
|
pkgrel="${PF_PKGREL:-1}"
|
||||||
arch=('x86_64')
|
# The CLIENT builds and runs on aarch64 (Arch Linux ARM); the HOST does not — its encode stack is
|
||||||
|
# NVENC/QSV/AMF, all x86. On aarch64 the split below drops punktfunk-host from `pkgname`, so
|
||||||
|
# makepkg builds only the client and never tries to compile the host crate.
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://git.unom.io/unom/punktfunk"
|
url="https://git.unom.io/unom/punktfunk"
|
||||||
license=('MIT OR Apache-2.0')
|
license=('MIT OR Apache-2.0')
|
||||||
# !lto: makepkg's `lto` option injects -flto=auto into CFLAGS; aws-lc-sys (rustls' crypto)
|
# !lto: makepkg's `lto` option injects -flto=auto into CFLAGS; aws-lc-sys (rustls' crypto)
|
||||||
@@ -83,6 +94,10 @@ build() {
|
|||||||
# real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md).
|
# real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md).
|
||||||
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
|
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
|
||||||
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
|
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
|
||||||
|
if [ "$CARCH" = 'aarch64' ]; then
|
||||||
|
# Client-only: no host crate, so none of the encode features below apply.
|
||||||
|
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
else
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
||||||
@@ -91,6 +106,7 @@ build() {
|
|||||||
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
|
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
|
||||||
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
|
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
|
||||||
cargo build --release --locked -p punktfunk-tray
|
cargo build --release --locked -p punktfunk-tray
|
||||||
|
fi
|
||||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||||
# built AND run with bun.
|
# built AND run with bun.
|
||||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
@@ -129,6 +145,14 @@ package_punktfunk-host() {
|
|||||||
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
||||||
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
|
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
|
||||||
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
|
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||||
|
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||||
|
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
|
||||||
|
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
|
||||||
|
install -Dm0755 "$R/scripts/pf-dm-helper" "$pkgdir/usr/lib/punktfunk/pf-dm-helper"
|
||||||
|
install -Dm0644 "$R/scripts/io.unom.punktfunk.dm-helper.policy" \
|
||||||
|
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||||
|
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
|
||||||
|
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
|
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
|
||||||
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
||||||
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ makepkg -si
|
|||||||
# …add the web console too (needs bun / bun-bin):
|
# …add the web console too (needs bun / bun-bin):
|
||||||
PF_WITH_WEB=1 PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
|
PF_WITH_WEB=1 PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### aarch64 (Arch Linux ARM) — the client
|
||||||
|
|
||||||
|
The PKGBUILD declares `arch=('x86_64' 'aarch64')`. On aarch64 it builds the **client only** —
|
||||||
|
`pkgname` drops `punktfunk-host`, so makepkg never enters the host's build or package path, and
|
||||||
|
`build()` skips the host/tray cargo invocations and their NVENC/Vulkan-encode features. The host
|
||||||
|
stays x86-only because its encode stack (NVENC/QSV/AMF) is.
|
||||||
|
|
||||||
|
Nothing else changes — run the same command on an Arch Linux ARM box:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packaging/arch
|
||||||
|
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
|
||||||
|
# -> punktfunk-client-<ver>-<rel>-aarch64.pkg.tar.zst (no punktfunk-host package)
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no cross-compile path here: makepkg builds for `CARCH`, so this wants a real aarch64
|
||||||
|
Arch machine (or an emulated Arch Linux ARM container, which is slow). Unlike the deb, it has
|
||||||
|
**not** been verified end to end yet — there is no official arm64 Arch container to test in.
|
||||||
Then the standard first-run (printed by the install scriptlet):
|
Then the standard first-run (printed by the install scriptlet):
|
||||||
```sh
|
```sh
|
||||||
sudo usermod -aG input "$USER" # virtual gamepads; re-login after
|
sudo usermod -aG input "$USER" # virtual gamepads; re-login after
|
||||||
|
|||||||
@@ -171,3 +171,25 @@ docker run --rm -v "$PWD:/src" -w /src pf-noble \
|
|||||||
|
|
||||||
`BUNDLE_FFMPEG=1` needs `patchelf` and an FFmpeg install at `FFMPEG_PREFIX` (default `/opt/ffmpeg`,
|
`BUNDLE_FFMPEG=1` needs `patchelf` and an FFmpeg install at `FFMPEG_PREFIX` (default `/opt/ffmpeg`,
|
||||||
which the noble image provides).
|
which the noble image provides).
|
||||||
|
|
||||||
|
### The arm64 client `.deb`
|
||||||
|
|
||||||
|
The **client** also ships for arm64 (`punktfunk-client_<version>_arm64.deb`, published to the same
|
||||||
|
apt distribution — the registry keys pool entries by architecture, so an arm64 box needs no extra
|
||||||
|
configuration). There is no arm64 **host** package: the Linux host encodes with NVENC/QSV/AMF, all
|
||||||
|
x86.
|
||||||
|
|
||||||
|
It is cross-compiled on an ordinary amd64 machine in `ci/rust-ci-arm64cross.Dockerfile` — the
|
||||||
|
rust-ci toolchain plus an Ubuntu ports arm64 multiarch sysroot. No arm64 runner is involved:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f ci/rust-ci-arm64cross.Dockerfile -t pf-arm64cross . # repo-root context
|
||||||
|
docker run --rm -v "$PWD:/w" -w /w pf-arm64cross \
|
||||||
|
bash -lc 'VERSION=0.0.1 ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
|
||||||
|
bash packaging/debian/build-client-deb.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
`TARGET` moves the binaries to `target/<triple>/release`; `ARCH` sets the package's
|
||||||
|
`Architecture:` field. Set both — one without the other builds an amd64 binary into a package
|
||||||
|
labelled arm64, or vice versa. `dpkg-shlibdeps` reads the arm64 sonames straight out of the
|
||||||
|
multiarch sysroot, so `Depends:` comes out right with no manual list.
|
||||||
|
|||||||
@@ -7,24 +7,40 @@
|
|||||||
# the package names the target boxes ship. The client links no NVIDIA libs — no filter
|
# the package names the target boxes ship. The client links no NVIDIA libs — no filter
|
||||||
# needed.
|
# needed.
|
||||||
#
|
#
|
||||||
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-client-deb.sh
|
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] [TARGET=<rust triple>] \
|
||||||
|
# bash packaging/debian/build-client-deb.sh
|
||||||
# Output: dist/punktfunk-client_<version>_<arch>.deb
|
# Output: dist/punktfunk-client_<version>_<arch>.deb
|
||||||
|
#
|
||||||
|
# TARGET cross-compiles (and moves the binaries under target/<triple>/release). Set it
|
||||||
|
# together with ARCH — e.g. ARCH=arm64 TARGET=aarch64-unknown-linux-gnu inside the
|
||||||
|
# ci/rust-ci-arm64cross.Dockerfile image, which carries the matching :arm64 sysroot.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||||
ARCH="${ARCH:-amd64}"
|
ARCH="${ARCH:-amd64}"
|
||||||
|
TARGET="${TARGET:-}"
|
||||||
PKG="punktfunk-client"
|
PKG="punktfunk-client"
|
||||||
CRATE="punktfunk-client-linux"
|
CRATE="punktfunk-client-linux"
|
||||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$ROOTDIR"
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
BIN="target/release/$PKG"
|
# Cargo drops a cross build under target/<triple>/release, a native one straight in
|
||||||
|
# target/release.
|
||||||
|
if [ -n "$TARGET" ]; then
|
||||||
|
OUTDIR="target/$TARGET/release"
|
||||||
|
CARGO_TARGET_ARGS=(--target "$TARGET")
|
||||||
|
else
|
||||||
|
OUTDIR="target/release"
|
||||||
|
CARGO_TARGET_ARGS=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
BIN="$OUTDIR/$PKG"
|
||||||
# The Vulkan/Skia session streamer the shell execs for a connect — shipped alongside the shell
|
# The Vulkan/Skia session streamer the shell execs for a connect — shipped alongside the shell
|
||||||
# (the shell resolves it as its /usr/bin sibling), or desktop streaming breaks.
|
# (the shell resolves it as its /usr/bin sibling), or desktop streaming breaks.
|
||||||
SESSION_BIN="target/release/punktfunk-session"
|
SESSION_BIN="$OUTDIR/punktfunk-session"
|
||||||
if [ ! -x "$BIN" ] || [ ! -x "$SESSION_BIN" ]; then
|
if [ ! -x "$BIN" ] || [ ! -x "$SESSION_BIN" ]; then
|
||||||
echo "==> building $CRATE + punktfunk-client-session (release)"
|
echo "==> building $CRATE + punktfunk-client-session (release${TARGET:+ for $TARGET})"
|
||||||
cargo build --release --locked -p "$CRATE" -p punktfunk-client-session
|
cargo build --release --locked "${CARGO_TARGET_ARGS[@]}" -p "$CRATE" -p punktfunk-client-session
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STAGE="$(mktemp -d)"
|
STAGE="$(mktemp -d)"
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
|
|||||||
# --- file layout (matches the RPM %install) ----------------------------------
|
# --- file layout (matches the RPM %install) ----------------------------------
|
||||||
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||||
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||||
|
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||||
|
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
|
||||||
|
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
|
||||||
|
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
|
||||||
|
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
|
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
|
||||||
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
|
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
|
||||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||||
|
|||||||
@@ -125,6 +125,32 @@ curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" \
|
|||||||
> home-worker-2 has no flatpak and no passwordless sudo to install it — so the Deck or the
|
> home-worker-2 has no flatpak and no passwordless sudo to install it — so the Deck or the
|
||||||
> privileged CI container are the only two viable build sites.
|
> privileged CI container are the only two viable build sites.
|
||||||
|
|
||||||
|
### aarch64
|
||||||
|
|
||||||
|
The manifest builds for aarch64 as well as x86_64. Two things are architecture-specific, and both
|
||||||
|
are now expressed properly rather than hardcoded:
|
||||||
|
|
||||||
|
* **`PKG_CONFIG_PATH`** contains the runtime's multiarch directory. flatpak-builder does *not*
|
||||||
|
shell-expand `env` values, so `${FLATPAK_ARCH}` would be taken literally — a `build-options.arch`
|
||||||
|
override supplies the aarch64 string instead, inheriting everything else.
|
||||||
|
* **The prebuilt Skia archive** is per-target and pinned by sha256. There are now two `type: file`
|
||||||
|
sources discriminated by `only-arches`, both landing on the same `dest-filename`, so
|
||||||
|
`SKIA_BINARIES_URL` stays one literal path. Upstream publishes the aarch64 archive under the
|
||||||
|
same skia commit hash and the same resolved-feature key (`pdf-textlayout-vulkan`), so on a
|
||||||
|
skia-safe bump update both URLs and both hashes together.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ARCH=aarch64 bash packaging/flatpak/build-flatpak.sh
|
||||||
|
# -> dist/punktfunk-client-<version>-aarch64.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
`ARCH` defaults to this machine's, and the bundle name now carries the architecture so an x86_64
|
||||||
|
and an aarch64 build can coexist in `dist/`. This is **not** a cross-compile: flatpak-builder runs
|
||||||
|
the build in a sandbox for the target arch, so building aarch64 anywhere but an arm64 machine
|
||||||
|
needs qemu binfmt and is very slow. Not yet verified end to end — the manifest is correct by
|
||||||
|
construction and the Skia hash was checked against the published archive, but no aarch64 flatpak
|
||||||
|
has been built.
|
||||||
|
|
||||||
## Manifest
|
## Manifest
|
||||||
|
|
||||||
[`io.unom.Punktfunk.yml`](io.unom.Punktfunk.yml). Runtime `org.gnome.Platform//50`
|
[`io.unom.Punktfunk.yml`](io.unom.Punktfunk.yml). Runtime `org.gnome.Platform//50`
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
# iteration, non-reproducible). Default: offline (regenerates cargo-sources).
|
# iteration, non-reproducible). Default: offline (regenerates cargo-sources).
|
||||||
# BUILDER=... override the flatpak-builder invocation (default: auto-detect host
|
# BUILDER=... override the flatpak-builder invocation (default: auto-detect host
|
||||||
# flatpak-builder, else `flatpak run org.flatpak.Builder`).
|
# flatpak-builder, else `flatpak run org.flatpak.Builder`).
|
||||||
|
# ARCH=... target architecture (default: this machine's). `aarch64` builds the arm64
|
||||||
|
# client — the manifest carries the per-arch PKG_CONFIG_PATH and the matching
|
||||||
|
# prebuilt Skia archive. NOTE this is not a cross-compile: flatpak-builder
|
||||||
|
# runs the build inside a sandbox for that arch, so building aarch64 on an
|
||||||
|
# x86_64 box needs qemu binfmt registered and is very slow. Run it on an
|
||||||
|
# arm64 machine.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
@@ -25,7 +31,11 @@ APP_ID="io.unom.Punktfunk"
|
|||||||
MANIFEST="packaging/flatpak/io.unom.Punktfunk.yml"
|
MANIFEST="packaging/flatpak/io.unom.Punktfunk.yml"
|
||||||
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo 0.0.1-dev)}"
|
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo 0.0.1-dev)}"
|
||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
BUNDLE="dist/punktfunk-client-${VERSION}.flatpak"
|
# `flatpak --default-arch` reports flatpak's own name for this machine (x86_64 / aarch64).
|
||||||
|
ARCH="${ARCH:-$(flatpak --default-arch 2>/dev/null || uname -m)}"
|
||||||
|
# Arch-suffixed so an x86_64 and an aarch64 bundle can sit in dist/ together instead of the
|
||||||
|
# second silently overwriting the first. (CI composes its own published filename.)
|
||||||
|
BUNDLE="dist/punktfunk-client-${VERSION}-${ARCH}.flatpak"
|
||||||
|
|
||||||
# --- pick a flatpak-builder (host binary, or the org.flatpak.Builder flatpak on the Deck) ---
|
# --- pick a flatpak-builder (host binary, or the org.flatpak.Builder flatpak on the Deck) ---
|
||||||
if [ -n "${BUILDER:-}" ]; then
|
if [ -n "${BUILDER:-}" ]; then
|
||||||
@@ -72,18 +82,19 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- build into a local ostree repo, then export a single-file bundle --------------------
|
# --- build into a local ostree repo, then export a single-file bundle --------------------
|
||||||
echo "==> flatpak-builder ($APP_ID, version $VERSION)"
|
echo "==> flatpak-builder ($APP_ID, version $VERSION, arch $ARCH)"
|
||||||
# --default-branch=stable matches CI / the hosted repo ref, so a locally-built install can also
|
# --default-branch=stable matches CI / the hosted repo ref, so a locally-built install can also
|
||||||
# track flatpak.unom.io. build-bundle must then be told the branch (else it defaults to `master`).
|
# track flatpak.unom.io. build-bundle must then be told the branch (else it defaults to `master`).
|
||||||
"${FPB[@]}" --user --force-clean --disable-rofiles-fuse \
|
"${FPB[@]}" --user --force-clean --disable-rofiles-fuse \
|
||||||
--default-branch=stable \
|
--default-branch=stable \
|
||||||
|
--arch="$ARCH" \
|
||||||
--install-deps-from=flathub \
|
--install-deps-from=flathub \
|
||||||
"${EXTRA_ARGS[@]}" \
|
"${EXTRA_ARGS[@]}" \
|
||||||
--repo="$ROOTDIR/.flatpak-repo" \
|
--repo="$ROOTDIR/.flatpak-repo" \
|
||||||
"$ROOTDIR/.flatpak-build" "$MANIFEST"
|
"$ROOTDIR/.flatpak-build" "$MANIFEST"
|
||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
flatpak build-bundle "$ROOTDIR/.flatpak-repo" "$BUNDLE" "$APP_ID" stable
|
flatpak build-bundle --arch="$ARCH" "$ROOTDIR/.flatpak-repo" "$BUNDLE" "$APP_ID" stable
|
||||||
echo "built $BUNDLE"
|
echo "built $BUNDLE"
|
||||||
ls -lh "$BUNDLE"
|
ls -lh "$BUNDLE"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ build-options:
|
|||||||
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
|
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
|
||||||
# mold (shipped in rust-stable) speeds the ~450-crate link on the Deck APU.
|
# mold (shipped in rust-stable) speeds the ~450-crate link on the Deck APU.
|
||||||
RUSTFLAGS: -C link-arg=-fuse-ld=mold
|
RUSTFLAGS: -C link-arg=-fuse-ld=mold
|
||||||
|
# The multiarch directory in PKG_CONFIG_PATH is per-architecture, and flatpak-builder does NOT
|
||||||
|
# shell-expand `env` values — so ${FLATPAK_ARCH} would be taken literally. An `arch` override is
|
||||||
|
# the supported way to vary it; everything else above is inherited. Only the runtime's own
|
||||||
|
# /usr/lib/<triple> changes, so aarch64 differs from x86_64 in exactly this one string.
|
||||||
|
arch:
|
||||||
|
aarch64:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/lib/pkgconfig
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
# ---------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------
|
||||||
@@ -240,7 +248,18 @@ modules:
|
|||||||
# crate's pinned skia commit + target + resolved features — when bumping skia-safe, build
|
# crate's pinned skia commit + target + resolved features — when bumping skia-safe, build
|
||||||
# the workspace once locally and take the new name from
|
# the workspace once locally and take the new name from
|
||||||
# target/release/build/skia-bindings-*/out/.cache/, then update url + sha256 together.
|
# target/release/build/skia-bindings-*/out/.cache/, then update url + sha256 together.
|
||||||
|
# One entry per architecture, selected by `only-arches`; both land on the SAME
|
||||||
|
# dest-filename, so SKIA_BINARIES_URL above stays a single literal path. The upstream
|
||||||
|
# release publishes the aarch64 archive under the same skia commit hash and the same
|
||||||
|
# resolved-feature key, so the two stay in lockstep on a skia-safe bump — take both new
|
||||||
|
# names and sha256s together.
|
||||||
- type: file
|
- type: file
|
||||||
|
only-arches: [x86_64]
|
||||||
url: https://github.com/rust-skia/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-x86_64-unknown-linux-gnu-pdf-textlayout-vulkan.tar.gz
|
url: https://github.com/rust-skia/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-x86_64-unknown-linux-gnu-pdf-textlayout-vulkan.tar.gz
|
||||||
sha256: b46e7061e6b9df792025acaf9b8b90180224c7cec63f4c3ce09af7ddddb8abfa
|
sha256: b46e7061e6b9df792025acaf9b8b90180224c7cec63f4c3ce09af7ddddb8abfa
|
||||||
dest-filename: skia-binaries.tar.gz
|
dest-filename: skia-binaries.tar.gz
|
||||||
|
- type: file
|
||||||
|
only-arches: [aarch64]
|
||||||
|
url: https://github.com/rust-skia/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-aarch64-unknown-linux-gnu-pdf-textlayout-vulkan.tar.gz
|
||||||
|
sha256: 6994fb993064d7d4fff00bf7c79544ffc881bb5441b6768b31cc088540f4bef7
|
||||||
|
dest-filename: skia-binaries.tar.gz
|
||||||
|
|||||||
@@ -123,3 +123,24 @@ docker run --rm -v "$PWD:/src" -w /src punktfunk-fedora-rpm \
|
|||||||
|
|
||||||
A plain `rpmbuild`/COPR build with no `pf_version`/`pf_release` defines produces `0.3.0-1` (the
|
A plain `rpmbuild`/COPR build with no `pf_version`/`pf_release` defines produces `0.3.0-1` (the
|
||||||
spec defaults).
|
spec defaults).
|
||||||
|
|
||||||
|
### aarch64 — the client RPM
|
||||||
|
|
||||||
|
The **client** builds for aarch64; the **host** does not (its encode stack is NVENC/QSV/AMF, all
|
||||||
|
x86). `PF_WITHOUT_HOST=1` drops the host binary, the tray, the headless-session data, the
|
||||||
|
firewalld services and the main package's `%files`, leaving exactly one RPM: `punktfunk-client`.
|
||||||
|
Omitting the main `%files` is what keeps rpm from emitting an empty `punktfunk` next to it.
|
||||||
|
|
||||||
|
This is **not** a cross-compile — `%build` runs cargo for the host architecture, so run it on an
|
||||||
|
arm64 machine (or an emulated arm64 container, which is very slow):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --platform linux/arm64 -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm-arm64 ci
|
||||||
|
docker run --rm --platform linux/arm64 -v "$PWD:/src" -w /src punktfunk-fedora-rpm-arm64 \
|
||||||
|
bash -lc 'git config --global --add safe.directory /src && \
|
||||||
|
PF_VERSION=0.0.1 PF_WITHOUT_HOST=1 bash packaging/rpm/build-rpm.sh'
|
||||||
|
# -> dist/punktfunk-client-0.0.1-1.fcNN.aarch64.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
`PF_WITHOUT_HOST=1` works on x86_64 too, if you only want the client RPM. The flag is orthogonal
|
||||||
|
to the architecture; it is just that aarch64 has no other option.
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ WEB_OPT=()
|
|||||||
# requirement as web; default off so a bare `rpmbuild`/COPR still works.
|
# requirement as web; default off so a bare `rpmbuild`/COPR still works.
|
||||||
SCRIPTING_OPT=()
|
SCRIPTING_OPT=()
|
||||||
[ "${PF_WITH_SCRIPTING:-0}" = "1" ] && SCRIPTING_OPT=(--with scripting)
|
[ "${PF_WITH_SCRIPTING:-0}" = "1" ] && SCRIPTING_OPT=(--with scripting)
|
||||||
|
# PF_WITHOUT_HOST=1 drops the host binary, the tray and the main package, leaving only
|
||||||
|
# punktfunk-client. This is what an aarch64 build uses: the client is portable, the host's encode
|
||||||
|
# stack (NVENC/QSV/AMF) is x86. Harmless on x86_64 too, if you only want the client RPM.
|
||||||
|
HOST_OPT=()
|
||||||
|
[ "${PF_WITHOUT_HOST:-0}" = "1" ] && HOST_OPT=(--without host)
|
||||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$ROOTDIR"
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
@@ -39,7 +44,7 @@ git archive --format=tar.gz --prefix="punktfunk-${PF_VERSION}/" \
|
|||||||
# undefined cuStreamCreateWithPriority/cuMemcpy2DAsync_v2/…; this regen supersedes it.) Defining
|
# undefined cuStreamCreateWithPriority/cuMemcpy2DAsync_v2/…; this regen supersedes it.) Defining
|
||||||
# extra unused symbols is harmless; a missing one fails the link. Only when /usr/lib64 is writable
|
# extra unused symbols is harmless; a missing one fails the link. Only when /usr/lib64 is writable
|
||||||
# (CI image runs as root) — COPR/mock provides the real cuda-cudart-devel stub instead.
|
# (CI image runs as root) — COPR/mock provides the real cuda-cudart-devel stub instead.
|
||||||
if [ "$(id -u)" = 0 ] && [ -d /usr/lib64 ]; then
|
if [ "${PF_WITHOUT_HOST:-0}" != "1" ] && [ "$(id -u)" = 0 ] && [ -d /usr/lib64 ]; then
|
||||||
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
||||||
if [ -n "$CU_SYMS" ]; then
|
if [ -n "$CU_SYMS" ]; then
|
||||||
STUB_C="$(mktemp --suffix=.c)"
|
STUB_C="$(mktemp --suffix=.c)"
|
||||||
@@ -55,7 +60,7 @@ fi
|
|||||||
# resolves them from RPMs. Our builder image provides the toolchain via rustup (so
|
# resolves them from RPMs. Our builder image provides the toolchain via rustup (so
|
||||||
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
|
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
|
||||||
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
|
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
|
||||||
rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" \
|
rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" "${HOST_OPT[@]}" \
|
||||||
--define "_topdir $TOP" \
|
--define "_topdir $TOP" \
|
||||||
--define "pf_version ${PF_VERSION}" \
|
--define "pf_version ${PF_VERSION}" \
|
||||||
--define "pf_release ${PF_RELEASE}" \
|
--define "pf_release ${PF_RELEASE}" \
|
||||||
@@ -63,6 +68,12 @@ rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" \
|
|||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} dist/ \;
|
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} dist/ \;
|
||||||
echo "== Requires (must NOT contain libcuda) =="
|
# The libcuda leak check applies to the HOST package (the only thing that links the driver); a
|
||||||
rpm -qp --requires dist/punktfunk-${PF_VERSION}-*.rpm 2>/dev/null | grep -iE 'cuda|nvidia' \
|
# client-only build has no such package to inspect.
|
||||||
|
if [ "${PF_WITHOUT_HOST:-0}" = "1" ]; then
|
||||||
|
echo "== client-only build: no host RPM, libcuda check not applicable =="
|
||||||
|
else
|
||||||
|
echo "== Requires (must NOT contain libcuda) =="
|
||||||
|
rpm -qp --requires dist/punktfunk-${PF_VERSION}-*.rpm 2>/dev/null | grep -iE 'cuda|nvidia' \
|
||||||
&& echo " !! NVIDIA/CUDA leak !!" || echo " clean"
|
&& echo " !! NVIDIA/CUDA leak !!" || echo " clean"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ URL: https://git.unom.io/unom/punktfunk
|
|||||||
# COPR SCM builds provide the checkout; for a tarball build, drop a git archive here:
|
# COPR SCM builds provide the checkout; for a tarball build, drop a git archive here:
|
||||||
Source0: %{name}-%{version}.tar.gz
|
Source0: %{name}-%{version}.tar.gz
|
||||||
|
|
||||||
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus. x86_64 only for now: encode
|
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus. The HOST is x86_64 only —
|
||||||
# is NVENC (desktop NVIDIA) and no aarch64 build is produced/published by CI — claiming aarch64
|
# its encode stack is NVENC/QSV/AMF — but the CLIENT builds and runs fine on aarch64, so the spec
|
||||||
# here would advertise an arch we never ship. Re-add aarch64 once there's an arm64 build leg.
|
# accepts both arches and `--without host` (below) selects the client-only build.
|
||||||
ExclusiveArch: x86_64
|
ExclusiveArch: x86_64 aarch64
|
||||||
|
|
||||||
# The zerocopy FFI links the NVIDIA driver's libcuda.so.1; rpm's auto-dep generator would turn
|
# The zerocopy FFI links the NVIDIA driver's libcuda.so.1; rpm's auto-dep generator would turn
|
||||||
# that into a hard Requires on libcuda.so.1 (and we never want to pin the driver — NVENC/EGL come
|
# that into a hard Requires on libcuda.so.1 (and we never want to pin the driver — NVENC/EGL come
|
||||||
@@ -55,6 +55,14 @@ ExclusiveArch: x86_64
|
|||||||
# Debian punktfunk-scripting .deb.
|
# Debian punktfunk-scripting .deb.
|
||||||
%bcond_with scripting
|
%bcond_with scripting
|
||||||
|
|
||||||
|
# The HOST half of this spec (the punktfunk package itself + the tray). ON by default, so an
|
||||||
|
# ordinary x86_64 build is unchanged. `--without host` drops the host binary, the tray, the
|
||||||
|
# headless-session data, the firewalld services and the main %%files section entirely, leaving
|
||||||
|
# only punktfunk-client — which is what an aarch64 build produces, since the host's encode stack
|
||||||
|
# (NVENC/QSV/AMF) is x86 and the client's is not. Omitting the main %%files is what stops rpm
|
||||||
|
# from emitting an empty `punktfunk` package alongside the client.
|
||||||
|
%bcond_without host
|
||||||
|
|
||||||
# --- Build toolchain ---------------------------------------------------------
|
# --- Build toolchain ---------------------------------------------------------
|
||||||
BuildRequires: cargo
|
BuildRequires: cargo
|
||||||
BuildRequires: rust
|
BuildRequires: rust
|
||||||
@@ -211,14 +219,21 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
|||||||
# with real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md).
|
# with real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md).
|
||||||
# Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts
|
# Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts
|
||||||
# back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully.
|
# back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully.
|
||||||
|
%if %{with host}
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
%else
|
||||||
|
# Client-only (aarch64): no host crate, so none of the encode features apply.
|
||||||
|
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
%endif
|
||||||
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
||||||
# across everything in one build, so co-building the tray with the host pulls the host's
|
# across everything in one build, so co-building the tray with the host pulls the host's
|
||||||
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
|
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
|
||||||
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
|
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
|
||||||
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
|
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
|
||||||
|
%if %{with host}
|
||||||
cargo build --release --locked -p punktfunk-tray
|
cargo build --release --locked -p punktfunk-tray
|
||||||
|
%endif
|
||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||||
@@ -243,12 +258,19 @@ fi
|
|||||||
%endif
|
%endif
|
||||||
|
|
||||||
%install
|
%install
|
||||||
|
%if %{with host}
|
||||||
# Binary
|
# Binary
|
||||||
install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host
|
install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host
|
||||||
|
|
||||||
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||||
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
||||||
|
|
||||||
|
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
|
||||||
|
# action let the host stop/restore the display manager for the stream without a hand-installed
|
||||||
|
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
|
||||||
|
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
|
||||||
|
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||||
|
|
||||||
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
|
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
|
||||||
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
|
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
|
||||||
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
|
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||||
@@ -288,6 +310,7 @@ for sz in 22x22 48x48; do
|
|||||||
install -Dm0644 "$png" %{buildroot}%{_datadir}/icons/hicolor/$sz/apps/"$(basename "$png")"
|
install -Dm0644 "$png" %{buildroot}%{_datadir}/icons/hicolor/$sz/apps/"$(basename "$png")"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
%endif
|
||||||
|
|
||||||
# --- client subpackage ---
|
# --- client subpackage ---
|
||||||
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
||||||
@@ -305,6 +328,7 @@ install -Dm0644 scripts/70-punktfunk-client.rules \
|
|||||||
install -Dm0644 scripts/99-punktfunk-client-net.conf \
|
install -Dm0644 scripts/99-punktfunk-client-net.conf \
|
||||||
%{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
|
%{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
|
||||||
|
|
||||||
|
%if %{with host}
|
||||||
# Headless session helpers + example config + OpenAPI doc (reference material).
|
# Headless session helpers + example config + OpenAPI doc (reference material).
|
||||||
install -d %{buildroot}%{_datadir}/%{name}/headless
|
install -d %{buildroot}%{_datadir}/%{name}/headless
|
||||||
install -Dm0755 scripts/headless/run-headless-kde.sh %{buildroot}%{_datadir}/%{name}/headless/run-headless-kde.sh
|
install -Dm0755 scripts/headless/run-headless-kde.sh %{buildroot}%{_datadir}/%{name}/headless/run-headless-kde.sh
|
||||||
@@ -338,6 +362,7 @@ install -Dm0644 packaging/linux/punktfunk-native.xml \
|
|||||||
# Web console opener (TCP 47992) — only meaningful with the web subpackage, opened deliberately.
|
# Web console opener (TCP 47992) — only meaningful with the web subpackage, opened deliberately.
|
||||||
install -Dm0644 packaging/linux/punktfunk-web.xml \
|
install -Dm0644 packaging/linux/punktfunk-web.xml \
|
||||||
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-web.xml
|
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-web.xml
|
||||||
|
%endif
|
||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
# --- web console subpackage (punktfunk-web) ---
|
# --- web console subpackage (punktfunk-web) ---
|
||||||
@@ -377,12 +402,16 @@ chmod 0755 %{buildroot}%{_bindir}/punktfunk-scripting
|
|||||||
install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/punktfunk-scripting.service
|
install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/punktfunk-scripting.service
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
%if %{with host}
|
||||||
%files
|
%files
|
||||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
%doc README.md packaging/README.md
|
%doc README.md packaging/README.md
|
||||||
%{_bindir}/punktfunk-host
|
%{_bindir}/punktfunk-host
|
||||||
%{_bindir}/punktfunk-tray
|
%{_bindir}/punktfunk-tray
|
||||||
%{_udevrulesdir}/60-punktfunk.rules
|
%{_udevrulesdir}/60-punktfunk.rules
|
||||||
|
%dir %{_libexecdir}/punktfunk
|
||||||
|
%{_libexecdir}/punktfunk/pf-dm-helper
|
||||||
|
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||||
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||||
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||||
@@ -398,6 +427,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
|||||||
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
|
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
|
||||||
%dir %{_datadir}/%{name}
|
%dir %{_datadir}/%{name}
|
||||||
%{_datadir}/%{name}/*
|
%{_datadir}/%{name}/*
|
||||||
|
%endif
|
||||||
|
|
||||||
%files client
|
%files client
|
||||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
@@ -441,6 +471,7 @@ udevadm trigger --subsystem-match=hidraw 2>/dev/null || :
|
|||||||
# rpm-ostree it takes effect on the next boot into the layered deployment).
|
# rpm-ostree it takes effect on the next boot into the layered deployment).
|
||||||
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf >/dev/null 2>&1 || :
|
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf >/dev/null 2>&1 || :
|
||||||
|
|
||||||
|
%if %{with host}
|
||||||
%post
|
%post
|
||||||
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
|
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
|
||||||
udevadm control --reload-rules 2>/dev/null || :
|
udevadm control --reload-rules 2>/dev/null || :
|
||||||
@@ -465,6 +496,7 @@ if command -v punktfunk-host >/dev/null 2>&1; then
|
|||||||
echo "$conflict"
|
echo "$conflict"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
%endif
|
||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
%post web
|
%post web
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE policyconfig PUBLIC
|
||||||
|
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||||
|
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
|
||||||
|
<policyconfig>
|
||||||
|
<vendor>Punktfunk</vendor>
|
||||||
|
<vendor_url>https://punktfunk.unom.io</vendor_url>
|
||||||
|
|
||||||
|
<!-- Stop/restore the box's display manager for a managed gamescope takeover. The helper derives
|
||||||
|
the unit from the display-manager.service symlink itself (no caller-chosen units), so this
|
||||||
|
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
|
||||||
|
these distros already authorize for their session switcher (e.g. Nobara's
|
||||||
|
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
|
||||||
|
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
|
||||||
|
<action id="io.unom.punktfunk.dm-helper">
|
||||||
|
<description>Stop or restore the display manager for a Punktfunk stream</description>
|
||||||
|
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
|
||||||
|
<defaults>
|
||||||
|
<allow_any>yes</allow_any>
|
||||||
|
<allow_inactive>yes</allow_inactive>
|
||||||
|
<allow_active>yes</allow_active>
|
||||||
|
</defaults>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/punktfunk/pf-dm-helper</annotate>
|
||||||
|
</action>
|
||||||
|
</policyconfig>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Privileged display-manager verbs for the punktfunk managed gamescope takeover.
|
||||||
|
#
|
||||||
|
# On DM-autologin boxes whose display manager does not survive a masked session unit (Nobara's
|
||||||
|
# plasmalogin, unknown DMs), taking the Gaming Mode session over means stopping the display
|
||||||
|
# manager for the length of the stream and restarting it afterwards — root-only operations. The
|
||||||
|
# host invokes this helper via pkexec under its own polkit action
|
||||||
|
# (io.unom.punktfunk.dm-helper.policy, installed by the packages), the same mechanism these
|
||||||
|
# distros use for their own session switching (Nobara's os-session-select).
|
||||||
|
#
|
||||||
|
# The unit is NEVER caller-controlled: it is derived here from the display-manager.service alias
|
||||||
|
# symlink, so the polkit grant's blast radius is exactly "the box's own display manager" — a
|
||||||
|
# local-seat operation, not arbitrary unit management.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
dm_unit() {
|
||||||
|
target=$(readlink /etc/systemd/system/display-manager.service) || {
|
||||||
|
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
basename "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1-}" in
|
||||||
|
stop)
|
||||||
|
exec systemctl stop "$(dm_unit)"
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
dm=$(dm_unit)
|
||||||
|
# reset-failed first: a relogin loop may have tripped the unit's start limit, and a plain
|
||||||
|
# restart would be refused until the accounting is cleared.
|
||||||
|
systemctl reset-failed "$dm" 2>/dev/null || :
|
||||||
|
exec systemctl restart "$dm"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: pf-dm-helper stop|restore" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -12,6 +12,11 @@ Description=punktfunk management web console
|
|||||||
# web-init generates the login password; the host writes the mgmt token. Order after both.
|
# web-init generates the login password; the host writes the mgmt token. Order after both.
|
||||||
After=punktfunk-web-init.service punktfunk-host.service
|
After=punktfunk-web-init.service punktfunk-host.service
|
||||||
Wants=punktfunk-web-init.service
|
Wants=punktfunk-web-init.service
|
||||||
|
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
|
||||||
|
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
|
||||||
|
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
|
||||||
|
# (the same defect the Windows PunktfunkWeb task had).
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem"
|
|||||||
set "KEYFILE=%PFDATA%\key.pem"
|
set "KEYFILE=%PFDATA%\key.pem"
|
||||||
|
|
||||||
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
|
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
|
||||||
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
|
rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
|
||||||
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
|
rem
|
||||||
rem silently downgrading to plain HTTP.
|
rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
|
||||||
if not exist "%TOKENFILE%" (
|
rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
|
||||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
rem before the host finished writing those files (the token lands at argument parse, the cert only
|
||||||
exit /b 1
|
rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up
|
||||||
)
|
rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever.
|
||||||
if not exist "%CERTFILE%" (
|
rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
|
||||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
set /a PFWAITS=0
|
||||||
|
:pfwait
|
||||||
|
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
|
||||||
|
if %PFWAITS% GEQ 150 (
|
||||||
|
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running?
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert...
|
||||||
|
set /a PFWAITS+=1
|
||||||
|
ping -n 3 127.0.0.1 >nul 2>&1
|
||||||
|
goto pfwait
|
||||||
|
:pfready
|
||||||
|
|
||||||
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
||||||
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
|
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "desc
|
|||||||
export type LayoutMode = "auto-row" | "manual"
|
export type LayoutMode = "auto-row" | "manual"
|
||||||
export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." })
|
export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." })
|
||||||
export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean }
|
export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean }
|
||||||
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." })
|
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." })
|
||||||
export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number }
|
export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number }
|
||||||
export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." })
|
export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." })
|
||||||
export type ModeConflict = "separate" | "steal" | "join" | "reject"
|
export type ModeConflict = "separate" | "steal" | "join" | "reject"
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ if (process.argv.includes("--list")) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Park the process instead of spinning it. `Effect.runFork` registers NOTHING with the event
|
||||||
|
// loop — the keep-alive timer lives in `Runtime.makeRunMain`, which we deliberately don't use
|
||||||
|
// (our shutdown is the bespoke two-signal one below) — and a bun process whose only pending work
|
||||||
|
// is an unresolved promise BUSY-SPINS rather than blocking. With units running there is usually a
|
||||||
|
// socket or timer to park on, but in the documented no-op state (no scripts, no plugins) there is
|
||||||
|
// nothing at all: field report 2026-07-25 had it pinning a full core indefinitely, `strace`
|
||||||
|
// showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix.
|
||||||
|
const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
||||||
const fiber = Effect.runFork(runner(options));
|
const fiber = Effect.runFork(runner(options));
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
const shutdown = (signal: string) => {
|
const shutdown = (signal: string) => {
|
||||||
@@ -161,3 +169,4 @@ process.on("SIGINT", () => shutdown("SIGINT"));
|
|||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
await Effect.runPromise(Fiber.await(fiber));
|
await Effect.runPromise(Fiber.await(fiber));
|
||||||
|
clearInterval(keepAlive); // every unit ended on its own — let the process exit
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// The shipped entry point's idle contract: `punktfunk-scripting` with nothing to run must PARK,
|
||||||
|
// not spin. `Effect.runFork` registers nothing with the event loop (the keep-alive lives in
|
||||||
|
// `Runtime.makeRunMain`, which runner-cli deliberately doesn't use), and a bun process whose only
|
||||||
|
// pending work is an unresolved promise busy-loops instead of blocking — so in the runner's own
|
||||||
|
// documented no-op state (no scripts, no plugins) it pinned a full core indefinitely until
|
||||||
|
// runner-cli started holding one idle handle. Measured against the real CLI rather than mocked:
|
||||||
|
// the regression is a property of the process, not of any function in it.
|
||||||
|
import { afterAll, expect, test } from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
const SDK = path.join(import.meta.dir, "..");
|
||||||
|
const ROOT = path.join(SDK, `.runner-cli-fixtures-${process.pid}`);
|
||||||
|
const SCRIPTS = path.join(ROOT, "scripts");
|
||||||
|
const PLUGINS = path.join(ROOT, "plugins");
|
||||||
|
fs.mkdirSync(SCRIPTS, { recursive: true });
|
||||||
|
fs.mkdirSync(PLUGINS, { recursive: true });
|
||||||
|
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
// Long enough that bun's startup (~0.3 s of CPU, and it does not grow with the window) is a small
|
||||||
|
// share of what we measure.
|
||||||
|
const WINDOW_MS = 3_000;
|
||||||
|
// A spinning runner burns a whole core — one window's worth of CPU in one window. A parked one
|
||||||
|
// burns its startup and nothing after. Half the window sits far from both, so a contended CI
|
||||||
|
// runner that only lets the spinner have half a core still fails.
|
||||||
|
const MAX_CPU_MS = WINDOW_MS / 2;
|
||||||
|
|
||||||
|
test(
|
||||||
|
"an idle runner parks instead of spinning",
|
||||||
|
async () => {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
"src/runner-cli.ts",
|
||||||
|
"--scripts",
|
||||||
|
SCRIPTS,
|
||||||
|
"--plugins",
|
||||||
|
PLUGINS,
|
||||||
|
],
|
||||||
|
{ cwd: SDK, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
await Bun.sleep(WINDOW_MS);
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
await proc.exited;
|
||||||
|
const out = await new Response(proc.stdout).text();
|
||||||
|
|
||||||
|
// A process that died on startup also burns no CPU — assert it really ran the runner and
|
||||||
|
// was still alive to catch the signal, so the CPU bound below can't pass vacuously.
|
||||||
|
expect(out).toContain("[runner] nothing to run");
|
||||||
|
expect(out).toContain("[runner] SIGTERM");
|
||||||
|
|
||||||
|
const cpuMs = Number(proc.resourceUsage()?.cpuTime.total ?? 0) / 1000;
|
||||||
|
expect(cpuMs).toBeLessThan(MAX_CPU_MS);
|
||||||
|
},
|
||||||
|
WINDOW_MS + 15_000,
|
||||||
|
);
|
||||||
+13
-8
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem"
|
|||||||
set "KEYFILE=%PFDATA%\key.pem"
|
set "KEYFILE=%PFDATA%\key.pem"
|
||||||
|
|
||||||
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
|
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
|
||||||
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the
|
rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
|
||||||
rem installed launcher / Linux unit) rather than silently serving plain HTTP.
|
rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
|
||||||
if not exist "%TOKENFILE%" (
|
rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
|
||||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
set /a PFWAITS=0
|
||||||
exit /b 1
|
:pfwait
|
||||||
)
|
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
|
||||||
if not exist "%CERTFILE%" (
|
if %PFWAITS% GEQ 150 (
|
||||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running?
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert...
|
||||||
|
set /a PFWAITS+=1
|
||||||
|
ping -n 3 127.0.0.1 >nul 2>&1
|
||||||
|
goto pfwait
|
||||||
|
:pfready
|
||||||
|
|
||||||
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
|
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
|
||||||
rem Split on the first '=' and import each into the environment.
|
rem Split on the first '=' and import each into the environment.
|
||||||
|
|||||||
Reference in New Issue
Block a user