Compare commits
69
Commits
+6
-3
@@ -1,9 +1,12 @@
|
||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||
# out of the context upload.
|
||||
# The root build context is used by web/Dockerfile (which needs web/ and
|
||||
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
|
||||
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
|
||||
# context upload.
|
||||
*
|
||||
!web
|
||||
!api/openapi.json
|
||||
!rust-toolchain.toml
|
||||
!ci/pf-host-cc
|
||||
web/node_modules
|
||||
web/.output
|
||||
web/dist
|
||||
|
||||
@@ -61,6 +61,43 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
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)
|
||||
run: bash crates/punktfunk-core/tests/c/run.sh
|
||||
|
||||
@@ -71,6 +108,53 @@ jobs:
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (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:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# 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:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# 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
|
||||
# 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
|
||||
@@ -269,3 +273,94 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
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-docs — documentation site (docs-site/Dockerfile)
|
||||
# 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)
|
||||
# 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"
|
||||
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
|
||||
# (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
|
||||
|
||||
@@ -50,6 +50,23 @@ on:
|
||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||
- 'crates/pf-encode/**'
|
||||
- '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/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -154,8 +171,23 @@ jobs:
|
||||
# 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
|
||||
# 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: |
|
||||
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" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
|
||||
Generated
+1
@@ -3073,6 +3073,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
||||
+3
-3
@@ -5106,7 +5106,7 @@
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"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": {
|
||||
"type": "array",
|
||||
@@ -5150,7 +5150,7 @@
|
||||
},
|
||||
{
|
||||
"$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": {
|
||||
"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.
|
||||
|
||||
use crate::app::AppModel;
|
||||
use crate::trust::{KnownHost, KnownHosts};
|
||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
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
|
||||
/// 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
|
||||
|
||||
@@ -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 --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)
|
||||
@@ -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).
|
||||
|
||||
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).
|
||||
|
||||
`--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,
|
||||
`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
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
};
|
||||
|
||||
let opts = ConsoleOptions {
|
||||
device_name: device_name(),
|
||||
device_name: trust::device_name(),
|
||||
deck: is_steam_deck(),
|
||||
};
|
||||
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 {
|
||||
if name.trim().is_empty() {
|
||||
addr.to_string()
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||
//! `--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
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
|
||||
//! via the shell's headless `--pair`) makes this binary connect silently.
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||
//! 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
|
||||
//! 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()?))
|
||||
}
|
||||
|
||||
/// `--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.
|
||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||
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
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
@@ -369,12 +425,14 @@ mod session_main {
|
||||
eprintln!(
|
||||
"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 --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||
\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\
|
||||
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\
|
||||
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;
|
||||
};
|
||||
@@ -406,7 +464,7 @@ mod session_main {
|
||||
"error",
|
||||
&format!(
|
||||
"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),
|
||||
);
|
||||
|
||||
@@ -2191,7 +2191,15 @@ mod pipewire {
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -2249,6 +2257,12 @@ mod pipewire {
|
||||
tracing::info!(
|
||||
"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 {
|
||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||
@@ -2652,6 +2666,18 @@ mod pipewire {
|
||||
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
|
||||
// (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
|
||||
|
||||
@@ -212,6 +212,40 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
||||
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
|
||||
/// 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.
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
//! queue under the device's [`QueueLock`], fence-waited (sub-ms — Phase-0 measured
|
||||
//! 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
|
||||
//! requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||
//! RG8 trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||
//! with its planar CSC variant (BT.709 limited — the codec's fixed colour contract,
|
||||
//! there is no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
||||
//! Output: three separate single-component planes (Y full-res; Cb/Cr half-res, or
|
||||
//! full-res on a 4:4:4 session) — R8, or R16 UNORM on a 10-bit session — the decode
|
||||
//! path requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||
//! RG trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||
//! 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 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() }
|
||||
}
|
||||
|
||||
/// One decoded PyroWave frame: three R8 plane images on the presenter's device, GENERAL
|
||||
/// layout, decode-complete (the decoder fence-waits before handing it over). `slot`
|
||||
/// identifies the ring entry; the images/views live as long as the decoder.
|
||||
/// One decoded PyroWave frame: three single-component plane images (R8, or R16 on a
|
||||
/// 10-bit session) on the presenter's device, GENERAL layout, decode-complete (the
|
||||
/// decoder fence-waits before handing it over). `slot` identifies the ring entry; the
|
||||
/// images/views live as long as the decoder.
|
||||
pub struct PyroWavePlanarFrame {
|
||||
/// Raw `VkImageView`s (Y, Cb, Cr) for the presenter's planar CSC sampling.
|
||||
pub views: [u64; 3],
|
||||
@@ -252,7 +254,8 @@ struct RetiredRing {
|
||||
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(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -539,7 +542,8 @@ impl PyroWaveDecoder {
|
||||
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(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
);
|
||||
@@ -873,12 +877,26 @@ impl PyroWaveDecoder {
|
||||
&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 {
|
||||
image: img.as_raw() as usize as pw::VkImage,
|
||||
width: w,
|
||||
height: h,
|
||||
image_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
view_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
image_format: fmt,
|
||||
view_format: fmt,
|
||||
mip_level: 0,
|
||||
layer: 0,
|
||||
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
@@ -886,11 +904,16 @@ impl PyroWaveDecoder {
|
||||
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
||||
};
|
||||
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 {
|
||||
planes: [
|
||||
plane(self.ring[slot].imgs[0], w, h),
|
||||
plane(self.ring[slot].imgs[1], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[2], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[1], cw, ch),
|
||||
plane(self.ring[slot].imgs[2], cw, ch),
|
||||
],
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
|
||||
@@ -7,6 +7,45 @@
|
||||
use anyhow::Result;
|
||||
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.
|
||||
/// `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
|
||||
@@ -73,7 +112,7 @@ impl AuChunk {
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
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
|
||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||
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.
|
||||
@@ -313,8 +365,15 @@ pub trait Encoder: Send {
|
||||
/// 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
|
||||
/// 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 =
|
||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
||||
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
|
||||
/// `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 {
|
||||
false
|
||||
}
|
||||
@@ -360,6 +419,16 @@ pub trait Encoder: Send {
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
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
|
||||
/// 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
|
||||
@@ -379,6 +448,20 @@ pub trait Encoder: Send {
|
||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
||||
/// 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`.
|
||||
///
|
||||
/// **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<()>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
@@ -426,6 +520,35 @@ pub(crate) fn vbv_frames_env() -> f64 {
|
||||
.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
|
||||
/// 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
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -461,6 +604,31 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
mod tests {
|
||||
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]
|
||||
fn rejects_zero_and_odd_dimensions() {
|
||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||
@@ -477,6 +645,26 @@ mod tests {
|
||||
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]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
|
||||
@@ -66,6 +66,11 @@ struct CudaHw {
|
||||
|
||||
impl CudaHw {
|
||||
/// Build a CUDA hwdevice wrapping `cu_ctx` and a frames pool (`sw_format` = `pixel`).
|
||||
///
|
||||
/// The `bail!`s below format raw AVERROR ints eagerly BY DESIGN — do not convert them to
|
||||
/// typed errors: `open_nvenc_probed`'s bitrate ladder steps down on a typed EINVAL
|
||||
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
|
||||
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
|
||||
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
|
||||
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
|
||||
if device_ref.is_null() {
|
||||
@@ -180,7 +185,6 @@ pub struct NvencEncoder {
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
expand: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
@@ -413,57 +417,6 @@ impl NvencEncoder {
|
||||
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).
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("preset", "p1"); // fastest
|
||||
@@ -491,15 +444,29 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
// 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,
|
||||
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
|
||||
// (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).
|
||||
// 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 split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
||||
match split.as_deref() {
|
||||
Some(mode) => opts.set("split_encode_mode", mode),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
||||
// The operator arm gains the codec gate the auto arm always had (Phase 8): split
|
||||
// "is not applicable to H264" per nvEncodeAPI.h, and h264_nvenc has no such AVOption
|
||||
// — setting it would fail the open on a leftover dict entry.
|
||||
Some(mode) if matches!(codec, Codec::H265 | Codec::Av1) => {
|
||||
opts.set("split_encode_mode", mode)
|
||||
}
|
||||
Some(_) => tracing::warn!(
|
||||
codec = codec.nvenc_name(),
|
||||
"PUNKTFUNK_SPLIT_ENCODE ignored — split encoding is not applicable to H.264 \
|
||||
(nvEncodeAPI.h)"
|
||||
),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||
{
|
||||
opts.set("split_encode_mode", "2");
|
||||
tracing::info!(
|
||||
pix_rate,
|
||||
@@ -516,7 +483,15 @@ impl NvencEncoder {
|
||||
// sessions) and reopen this session without intra-refresh; any other failure — and
|
||||
// any failure when IR wasn't requested — propagates untouched (the bitrate probe
|
||||
// keys on EINVAL, which must not trip the latch).
|
||||
Err(e) if intra_refresh && format!("{e:#}").contains("Function not implemented") => {
|
||||
Err(e)
|
||||
if intra_refresh
|
||||
&& matches!(
|
||||
e,
|
||||
ffmpeg::Error::Other {
|
||||
errno: ffmpeg::util::error::ENOSYS
|
||||
}
|
||||
) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
encoder = name,
|
||||
"NVENC intra-refresh not supported by this GPU — falling back to IDR-only \
|
||||
@@ -549,6 +524,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 {
|
||||
None
|
||||
} else {
|
||||
@@ -561,7 +614,6 @@ impl NvencEncoder {
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -591,6 +643,9 @@ impl NvencEncoder {
|
||||
impl Encoder for NvencEncoder {
|
||||
fn caps(&self) -> 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
|
||||
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
||||
chroma_444: self.want_444,
|
||||
@@ -695,8 +750,10 @@ impl NvencEncoder {
|
||||
bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
||||
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
|
||||
// swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
|
||||
// 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 {
|
||||
let frame = self
|
||||
.frame
|
||||
@@ -705,10 +762,13 @@ impl NvencEncoder {
|
||||
// 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`
|
||||
// (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
|
||||
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
|
||||
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
|
||||
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
|
||||
// `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
|
||||
// `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
|
||||
// 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 {
|
||||
let dst_av = frame.as_mut_ptr();
|
||||
let src_data: [*const u8; 4] =
|
||||
@@ -724,7 +784,7 @@ impl NvencEncoder {
|
||||
(*dst_av).linesize.as_ptr(),
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
||||
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
|
||||
}
|
||||
}
|
||||
frame.set_pts(Some(pts));
|
||||
@@ -733,7 +793,7 @@ impl NvencEncoder {
|
||||
} else {
|
||||
ffmpeg::picture::Type::None
|
||||
});
|
||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
||||
self.enc.send_frame(frame).context("send_frame(swscale)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let frame = self
|
||||
@@ -742,18 +802,9 @@ impl NvencEncoder {
|
||||
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
||||
let stride = frame.stride(0); // dst is 4-bpp, aligned
|
||||
let dst = frame.data_mut(0);
|
||||
if self.expand {
|
||||
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
|
||||
for y in 0..h {
|
||||
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
|
||||
{
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
|
||||
// to live here as a per-pixel loop is now swscale's job (see the branch above).
|
||||
for y in 0..h {
|
||||
dst[y * stride..y * stride + src_row]
|
||||
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
||||
@@ -876,6 +927,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
|
||||
/// 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
|
||||
@@ -889,14 +992,9 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
return false;
|
||||
}
|
||||
// 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
|
||||
// (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
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::Bgra,
|
||||
640,
|
||||
@@ -907,10 +1005,7 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
||||
@@ -926,14 +1021,9 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
return false;
|
||||
}
|
||||
// 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
|
||||
// (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
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::X2Rgb10,
|
||||
640,
|
||||
@@ -944,10 +1034,7 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
|
||||
subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
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
|
||||
/// `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 {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*CAP.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
||||
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
|
||||
/// 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
|
||||
@@ -616,6 +629,10 @@ pub struct NvencCudaEncoder {
|
||||
/// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the
|
||||
/// open and the in-place reconfigure present identical init params.
|
||||
subframe_on: bool,
|
||||
/// Whether `PUNKTFUNK_NVENC_SUBFRAME=1` was EXPLICITLY forced — latched with `subframe_on`
|
||||
/// (same invariant: open and reconfigure must present identical init params, so no env
|
||||
/// re-reads after `query_caps`). Only consumed by [`resolve_split_subframe`]'s log severity.
|
||||
subframe_forced: bool,
|
||||
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
|
||||
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
|
||||
subframe_chunks: bool,
|
||||
@@ -702,11 +719,13 @@ impl NvencCudaEncoder {
|
||||
last_rfi_range: None,
|
||||
async_rt: None,
|
||||
want_async: false,
|
||||
want_sync: false,
|
||||
io_stream: ptr::null_mut(),
|
||||
stream_ordered: false,
|
||||
slices: 1,
|
||||
subframe_cap: false,
|
||||
subframe_on: false,
|
||||
subframe_forced: false,
|
||||
subframe_chunks: false,
|
||||
chunk: None,
|
||||
})
|
||||
@@ -735,6 +754,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.
|
||||
unsafe fn teardown(&mut self) {
|
||||
if self.encoder.is_null() {
|
||||
@@ -839,6 +881,9 @@ impl NvencCudaEncoder {
|
||||
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 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);
|
||||
@@ -881,6 +926,7 @@ impl NvencCudaEncoder {
|
||||
// escapes.
|
||||
self.slices = resolve_slices(self.codec, 4);
|
||||
self.subframe_on = resolve_subframe(self.subframe_cap);
|
||||
self.subframe_forced = subframe_env_forced();
|
||||
tracing::info!(
|
||||
rfi = self.rfi_supported,
|
||||
custom_vbv = self.custom_vbv,
|
||||
@@ -1000,6 +1046,23 @@ impl NvencCudaEncoder {
|
||||
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`.
|
||||
/// 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> {
|
||||
@@ -1019,6 +1082,7 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
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) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -1074,58 +1138,81 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
||||
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see
|
||||
// [`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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
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,
|
||||
};
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
// Split × sub-frame arbitration (Phase 8) BEFORE the ladder, the ceiling key and the
|
||||
// chunked-poll latch — all three must see the post-arbitration truth (a drop inside
|
||||
// build_init_params would leave poll_chunk busy-polling its whole budget per AU).
|
||||
let (split_mode, subframe_on) = resolve_split_subframe(
|
||||
self.codec,
|
||||
split_mode,
|
||||
self.subframe_on,
|
||||
self.subframe_forced,
|
||||
);
|
||||
self.subframe_on = subframe_on;
|
||||
const CLAMP_TOL_BPS: u64 = 20_000_000;
|
||||
|
||||
let mut probe = self.try_open_session(requested_bps, split_mode);
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
|
||||
// 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 (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 =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
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!(
|
||||
"NVENC (Linux): split-encode rejected by codec/config — disabled"
|
||||
);
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
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(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
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_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(mid, split_mode) {
|
||||
match self.try_open_session(mid, used_split) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -1134,18 +1221,30 @@ impl NvencCudaEncoder {
|
||||
best_bps = 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() {
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(FLOOR_BPS, split_mode)
|
||||
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
best = match self.try_open_session(FLOOR_BPS, used_split) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self.try_open_session(FLOOR_BPS, no_split).context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -1153,15 +1252,13 @@ impl NvencCudaEncoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"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;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// (Best effort: the floor fallback above may have succeeded split-disabled without
|
||||
// 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;
|
||||
self.split_mode = used_split;
|
||||
|
||||
// Output bitstream pool.
|
||||
for _ in 0..POOL {
|
||||
@@ -1345,6 +1442,10 @@ impl NvencCudaEncoder {
|
||||
mbps = self.bitrate_bps / 1_000_000,
|
||||
codec = ?self.codec_guid,
|
||||
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"
|
||||
);
|
||||
Ok(())
|
||||
@@ -1426,9 +1527,10 @@ impl Encoder for NvencCudaEncoder {
|
||||
"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
|
||||
// (nothing in flight after the previous poll drained).
|
||||
// A pending pipelined-retrieve escalation — or de-escalation — engages here, at the
|
||||
// submit-side safe point (nothing in flight after the previous poll drained).
|
||||
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
|
||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||
let new_fmt = buffer_format(buf);
|
||||
@@ -1560,25 +1662,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
self.cursor_blend_warned = true;
|
||||
@@ -1736,12 +1819,25 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||
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();
|
||||
}
|
||||
if async_retrieve_env() == Some(false) {
|
||||
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() {
|
||||
self.want_async = true;
|
||||
self.maybe_engage_async();
|
||||
@@ -1751,6 +1847,8 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
fn caps(&self) -> 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_hdr_metadata: self.hdr,
|
||||
chroma_444: self.chroma_444,
|
||||
@@ -1765,48 +1863,46 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Already invalidated a covering range for this loss event — re-arm the anchor (the previous
|
||||
// anchor AU may itself have been lost) but skip the driver calls.
|
||||
if let Some((pf, pl)) = self.last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
|
||||
// Already invalidated a covering range for this loss event — re-arm the anchor (the
|
||||
// previous anchor AU may itself have been lost) but skip the driver calls.
|
||||
RangePlan::Covered => {
|
||||
self.pending_anchor = true;
|
||||
return true;
|
||||
true
|
||||
}
|
||||
}
|
||||
// The DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`; a lost frame older than that can't be
|
||||
// invalidated, so the only correct recovery is an IDR.
|
||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
||||
if first < oldest_in_dpb {
|
||||
return false;
|
||||
}
|
||||
let last = last.min(self.frame_idx - 1);
|
||||
if first > last {
|
||||
return false;
|
||||
}
|
||||
// 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 {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
RangePlan::Decline => false,
|
||||
RangePlan::Invalidate { first, last } => {
|
||||
// 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). The plan clamped each `ts` to
|
||||
// `[oldest_in_dpb, frame_idx - 1]`, naming a frame still in the DPB; the call
|
||||
// passes only that `u64` (no struct).
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships
|
||||
// with `recovery_anchor` and the client lifts its post-loss freeze on it.
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships with
|
||||
// `recovery_anchor` and the client lifts its post-loss freeze on it.
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
@@ -2061,6 +2157,15 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.bitrate_bps = bps;
|
||||
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
|
||||
// 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
|
||||
@@ -2108,6 +2213,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<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -2165,6 +2276,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2241,6 +2353,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA 4:4:4 session");
|
||||
|
||||
@@ -2286,6 +2399,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2343,6 +2457,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
) else {
|
||||
eprintln!(
|
||||
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
|
||||
@@ -2369,6 +2484,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA encoder")
|
||||
}
|
||||
@@ -2403,6 +2519,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open");
|
||||
for f in 0..4u32 {
|
||||
@@ -2540,6 +2657,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
@@ -2581,6 +2699,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
// Steady sync frames first (stream-ordered mode).
|
||||
@@ -2660,6 +2779,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2765,6 +2885,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.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_next as ffmpeg;
|
||||
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
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
|
||||
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
||||
/// 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`]):
|
||||
/// 0 = unknown, 1 = default (full-feature `EncSlice`), 2 = low-power (`EncSliceLP`/VDEnc).
|
||||
/// Modern Intel (Gen12+/Arc) removed the full-feature encode entrypoints, so the default open
|
||||
/// fails there and only `low_power=1` works; AMD (radeonsi) is the reverse. Caching the resolved
|
||||
/// mode lets later sessions/probes skip the known-failing attempt (and its libav error spew).
|
||||
static LP_MODE: [AtomicU8; 3] = [AtomicU8::new(0), AtomicU8::new(0), AtomicU8::new(0)];
|
||||
/// Which VAAPI entrypoint mode opened successfully: 1 = default (full-feature `EncSlice`),
|
||||
/// 2 = low-power (`EncSliceLP`/VDEnc). Modern Intel (Gen12+/Arc) removed the full-feature encode
|
||||
/// entrypoints, so the default open fails there and only `low_power=1` works; AMD (radeonsi) is the
|
||||
/// reverse. Caching the resolved mode lets later sessions/probes skip the known-failing attempt
|
||||
/// (and its libav error spew).
|
||||
///
|
||||
/// 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 {
|
||||
match codec {
|
||||
Codec::H264 => 0,
|
||||
Codec::H265 => 1,
|
||||
Codec::Av1 => 2,
|
||||
// Guarded by the open_video dispatch: PyroWave never opens the VAAPI backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no VAAPI encoder"),
|
||||
}
|
||||
/// (render-node path, codec label, 10-bit) — see [`LP_MODE`].
|
||||
type LpKey = (String, &'static str, bool);
|
||||
|
||||
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
|
||||
/// so a GPU-preference change is picked up on the next open.
|
||||
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
|
||||
(
|
||||
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
|
||||
@@ -116,11 +129,16 @@ unsafe fn open_vaapi_encoder(
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
ten_bit: bool,
|
||||
) -> 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() {
|
||||
Some(true) => &[true],
|
||||
Some(false) => &[false],
|
||||
None => match LP_MODE[idx].load(Ordering::Relaxed) {
|
||||
None => match cached {
|
||||
1 => &[false],
|
||||
2 => &[true],
|
||||
_ => &[false, true],
|
||||
@@ -140,7 +158,9 @@ unsafe fn open_vaapi_encoder(
|
||||
lp,
|
||||
) {
|
||||
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 {
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
@@ -253,20 +273,20 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
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.
|
||||
// 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. Held until the function returns, so the
|
||||
// level is restored after the open either way. Shares one lock with the NVENC probes, which
|
||||
// race this one on the same libav global (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 + 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
|
||||
// ffmpeg's "No VA display found" 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_NV12, 640, 480, 2) {
|
||||
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
@@ -279,9 +299,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
)
|
||||
.is_ok(),
|
||||
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() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_{get,set}_level`
|
||||
// only read/write libav's global integer log level (no pointer args). `VaapiHw::new` (an
|
||||
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
|
||||
// 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
|
||||
// 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
|
||||
// `av_buffer_ref`s them into the probe encoder. Both `hw` and the encoder drop (RAII) at arm end.
|
||||
unsafe {
|
||||
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
|
||||
// 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) {
|
||||
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
@@ -321,9 +338,7 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
)
|
||||
.is_ok(),
|
||||
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.
|
||||
// * `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
|
||||
// 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
|
||||
// `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] =
|
||||
@@ -950,7 +966,17 @@ impl DmabufInner {
|
||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||
desc.nb_objects = 1;
|
||||
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.nb_layers = 1;
|
||||
desc.layers[0].format = self.fourcc;
|
||||
@@ -999,8 +1025,18 @@ impl DmabufInner {
|
||||
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
|
||||
);
|
||||
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 {
|
||||
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();
|
||||
let mut nv12 = ffi::av_frame_alloc();
|
||||
@@ -1010,8 +1046,11 @@ impl DmabufInner {
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
||||
if r < 0 {
|
||||
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;
|
||||
(*nv12).pts = pts;
|
||||
(*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).
|
||||
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`
|
||||
pub const PREDICTION_MODE_INTRA_ONLY: i32 = 0;
|
||||
pub const PREDICTION_MODE_SINGLE_REFERENCE: i32 = 1;
|
||||
@@ -498,3 +507,366 @@ pub struct StdVideoEncodeAV1OperatingPointInfo {
|
||||
pub fn stype(raw: i32) -> vk::StructureType {
|
||||
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))
|
||||
}
|
||||
@@ -7,6 +7,18 @@ use anyhow::Result;
|
||||
use ash::vk;
|
||||
use pf_frame::PixelFormat;
|
||||
|
||||
/// Whether a device extension is in an enumerated properties list — the gate both Vulkan encode
|
||||
/// backends use before enabling `VK_EXT_queue_family_foreign` (Phase 8: the FOREIGN queue-family
|
||||
/// barriers were used without the extension ever being enabled; `pf-presenter/dmabuf.rs` is the
|
||||
/// in-repo precedent that enables it).
|
||||
pub(super) fn ext_advertised(exts: &[vk::ExtensionProperties], name: &std::ffi::CStr) -> bool {
|
||||
exts.iter().any(|e| {
|
||||
// SAFETY: `extension_name` is a spec-guaranteed NUL-terminated UTF-8 byte array inside
|
||||
// the driver-filled `VkExtensionProperties` (VK_MAX_EXTENSION_NAME_SIZE bound).
|
||||
unsafe { std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == name }
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
|
||||
vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
@@ -54,6 +66,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(
|
||||
device: &ash::Device,
|
||||
image: vk::Image,
|
||||
@@ -70,6 +135,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
|
||||
/// three returned handles. Extracted verbatim from `vulkan_video.rs`'s import path.
|
||||
pub(crate) unsafe fn import_rgb_dmabuf(
|
||||
@@ -108,9 +188,15 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||
use anyhow::Context;
|
||||
use std::os::fd::IntoRawFd;
|
||||
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||
let fmt = fourcc_to_vk(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 (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,
|
||||
@@ -155,14 +241,16 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
ci = ci.push_next(pl);
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
||||
// Unwind discipline below mirrors `make_plain_image`: every failure destroys what this call
|
||||
// created (and ONLY that — the caller's `DmabufFrame` fd stays theirs).
|
||||
let fd_props = {
|
||||
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)(
|
||||
device.handle(),
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dup,
|
||||
dup.as_raw_fd(),
|
||||
&mut p,
|
||||
);
|
||||
p.memory_type_bits
|
||||
@@ -181,27 +269,87 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
|
||||
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||
.fd(dup);
|
||||
let mem = device.allocate_memory(
|
||||
.fd(dup.as_raw_fd());
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(ti)
|
||||
.push_next(&mut ded)
|
||||
.push_next(&mut import),
|
||||
None,
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
let view = device.create_image_view(
|
||||
) {
|
||||
Ok(mem) => {
|
||||
// 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()
|
||||
.image(img)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.subresource_range(color_range(0)),
|
||||
None,
|
||||
)?;
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
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(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -259,3 +407,98 @@ pub(crate) unsafe fn make_plain_image(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn ext_advertised_matches_exact_name() {
|
||||
let mut e = ash::vk::ExtensionProperties::default();
|
||||
let name = b"VK_EXT_queue_family_foreign\0";
|
||||
for (i, b) in name.iter().enumerate() {
|
||||
e.extension_name[i] = *b as std::ffi::c_char;
|
||||
}
|
||||
let exts = [ash::vk::ExtensionProperties::default(), e];
|
||||
assert!(super::ext_advertised(
|
||||
&exts,
|
||||
ash::ext::queue_family_foreign::NAME
|
||||
));
|
||||
assert!(!super::ext_advertised(
|
||||
&exts[..1],
|
||||
ash::ext::queue_family_foreign::NAME
|
||||
));
|
||||
}
|
||||
|
||||
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 {
|
||||
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,520 @@ 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
|
||||
}
|
||||
|
||||
/// Whether the operator EXPLICITLY forced sub-frame readback on (`PUNKTFUNK_NVENC_SUBFRAME=1`)
|
||||
/// — the log-severity input to [`resolve_split_subframe`]: a forced knob being overridden
|
||||
/// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their
|
||||
/// resolved subframe state (an env re-read at reconfigure would violate the "open and
|
||||
/// reconfigure present identical init params" invariant).
|
||||
/// Linux-cfg'd like its ONLY caller (the `nvenc_cuda` query_caps latch) — Windows sessions have
|
||||
/// `subframe == forced` by construction (env opt-in only) and never consult this; without the
|
||||
/// cfg it is dead code on every Windows leg (item-level dead_code, the recurring trap).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn subframe_env_forced() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref(),
|
||||
Ok("1")
|
||||
)
|
||||
}
|
||||
|
||||
/// The split-encode × sub-frame arbitration (Phase 8; verified against `nvEncodeAPI.h`'s own
|
||||
/// `splitEncodeMode` doc, not folklore):
|
||||
/// - **H.264**: split "is not applicable" — hard-DISABLE the mode so the written config, the
|
||||
/// ceiling-cache key, the split diagnostic log and the rejection-retry all stay truthful (the
|
||||
/// retry used to re-open a byte-identical session after an H.264 "split rejection").
|
||||
/// - **HEVC**: split is "not supported if … subframe mode" — when WE force split
|
||||
/// (TWO/THREE/AUTO_FORCED, e.g. the 4K120 throughput requirement), sub-frame yields. Under
|
||||
/// plain AUTO the driver arbitrates — the shipped fleet state (1080p–1440p240 all run
|
||||
/// AUTO+subframe); keying on `!= DISABLE` here would have disarmed the Phase-3 chunked-poll
|
||||
/// feature fleet-wide.
|
||||
/// - **AV1**: both legal (sub-frame is per-tile; split is constrained only by
|
||||
/// output-into-vidmem, which we never use) — untouched.
|
||||
///
|
||||
/// Returns the `(split_mode, subframe)` to ACTUALLY configure. The caller must store BOTH back
|
||||
/// (the chunked-poll latch and `CeilingKey` key on them) — a silent in-params drop would leave
|
||||
/// `poll_chunk` busy-polling its full budget every AU (`numSlices` stays 0 without
|
||||
/// `reportSliceOffsets`, so neither loop exit ever fires).
|
||||
pub(super) fn resolve_split_subframe(
|
||||
codec: Codec,
|
||||
split_mode: u32,
|
||||
subframe: bool,
|
||||
subframe_forced: bool,
|
||||
) -> (u32, bool) {
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
if codec == Codec::H264 {
|
||||
return (M::NV_ENC_SPLIT_DISABLE_MODE as u32, subframe);
|
||||
}
|
||||
let split_forced = split_mode == M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
|| split_mode == M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32
|
||||
|| split_mode == M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32;
|
||||
if codec == Codec::H265 && split_forced && subframe {
|
||||
if subframe_forced {
|
||||
tracing::warn!(
|
||||
split_mode,
|
||||
"HEVC forced split-encode and PUNKTFUNK_NVENC_SUBFRAME=1 are mutually \
|
||||
unsupported (nvEncodeAPI.h) — sub-frame readback disabled for this session; \
|
||||
set PUNKTFUNK_SPLIT_ENCODE=0 to choose sub-frame instead"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
split_mode,
|
||||
"HEVC forced split-encode supersedes default-on sub-frame readback (mutually \
|
||||
unsupported per nvEncodeAPI.h; split is the 4K120 throughput lever) — set \
|
||||
PUNKTFUNK_SPLIT_ENCODE=0 to choose sub-frame instead"
|
||||
);
|
||||
}
|
||||
return (split_mode, false);
|
||||
}
|
||||
(split_mode, subframe)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod split_subframe_tests {
|
||||
use super::{resolve_split_subframe, Codec};
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
|
||||
const AUTO: u32 = M::NV_ENC_SPLIT_AUTO_MODE as u32;
|
||||
const TWO: u32 = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
const AUTO_F: u32 = M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32;
|
||||
const DISABLE: u32 = M::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
|
||||
/// THE FLEET CASE: plain AUTO + default-on sub-frame must pass through untouched — the
|
||||
/// driver arbitrates. Keying the rule on `!= DISABLE` would disarm sub-frame on every
|
||||
/// default Linux HEVC session (AUTO == 0 is the resolver's fallthrough).
|
||||
#[test]
|
||||
fn hevc_auto_keeps_subframe() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, AUTO, true, false),
|
||||
(AUTO, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_forced_split_drops_subframe() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, TWO, true, false),
|
||||
(TWO, false)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, AUTO_F, true, true),
|
||||
(AUTO_F, false)
|
||||
);
|
||||
// No sub-frame to drop → nothing changes.
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, TWO, false, false),
|
||||
(TWO, false)
|
||||
);
|
||||
// Explicitly disabled split → sub-frame kept (the documented escape).
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, DISABLE, true, true),
|
||||
(DISABLE, true)
|
||||
);
|
||||
}
|
||||
|
||||
/// H.264: split "is not applicable" (nvEncodeAPI.h) — hard-DISABLE regardless of the
|
||||
/// resolved mode; sub-frame (H.264 slices) is unaffected.
|
||||
#[test]
|
||||
fn h264_split_hard_disabled() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H264, TWO, true, false),
|
||||
(DISABLE, true)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H264, AUTO, false, false),
|
||||
(DISABLE, false)
|
||||
);
|
||||
}
|
||||
|
||||
/// AV1: both features are legal together (per-tile sub-frame; split constrained only by
|
||||
/// output-into-vidmem) — the arbitration must not touch it.
|
||||
#[test]
|
||||
fn av1_untouched() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::Av1, TWO, true, true),
|
||||
(TWO, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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`
|
||||
/// paths check against (`frame_idx - RFI_DPB` = the oldest frame still in the DPB).
|
||||
/// P-frame single-reference for low latency. Also the window [`plan_range_recovery`] checks against
|
||||
/// (`next_ts - RFI_DPB` = the oldest frame still in the DPB).
|
||||
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
|
||||
/// 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
|
||||
@@ -219,7 +727,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-
|
||||
// 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.
|
||||
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.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
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
|
||||
//! construction, so every downstream `{e:#}` log (the encode-recovery loop, session teardown) says
|
||||
//! 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;
|
||||
|
||||
/// 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 —
|
||||
/// callers print that alongside (see [`call_err`]). Public for the few sites that build a
|
||||
/// `String`/`format!` error instead of an `anyhow::Error`.
|
||||
pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||
match status {
|
||||
// The one this whole module exists for: a version word the driver rejects. 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.
|
||||
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_INVALID_VERSION => {
|
||||
invalid_version(SESSION_OPENED.load(Ordering::Relaxed))
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_NO_ENCODE_DEVICE => {
|
||||
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
|
||||
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
|
||||
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
|
||||
/// (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?)". The
|
||||
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
|
||||
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(7680, 4320, true) > 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]
|
||||
|
||||
@@ -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.
|
||||
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 {
|
||||
pub fn open(
|
||||
format: PixelFormat,
|
||||
@@ -59,7 +77,16 @@ impl OpenH264Encoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
) -> 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 cfg = EncoderConfig::new()
|
||||
.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);
|
||||
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;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
// The hand-mirrored AMF C ABI lives in `amf_sys.rs` (WP7.4): pure FFI surface, no policy —
|
||||
// isolating the unsafe vtable mirror from the encoder logic is the crate's stated review
|
||||
// goal, and the `#[path]` keeps the module name (`sys::`) and every call site unchanged.
|
||||
#[path = "amf_sys.rs"]
|
||||
mod sys;
|
||||
|
||||
use sys::{result_name, AmfVariant};
|
||||
|
||||
@@ -1272,7 +858,11 @@ impl AmfEncoder {
|
||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||
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
|
||||
// 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
|
||||
@@ -2369,31 +1959,27 @@ impl Encoder for AmfEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||
// encoded inside the client's corrupt window — the client either never received it or
|
||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
|
||||
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||
for marked in self.ltr_slots.iter_mut() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
// The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
|
||||
// shared with QSV and Vulkan Video); this backend's mechanism is: distrust = clear the
|
||||
// mirror slot (dropped slots stay dropped; the cadence re-marks a clean frame within
|
||||
// ~1/4 s). `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 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;
|
||||
}
|
||||
}
|
||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||
// 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 {
|
||||
match plan.anchor {
|
||||
Some((slot, ltr_frame)) => {
|
||||
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
||||
self.pending_force = Some(slot);
|
||||
@@ -2422,6 +2008,8 @@ impl Encoder for AmfEncoder {
|
||||
|
||||
fn caps(&self) -> 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
|
||||
// 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.
|
||||
@@ -2760,6 +2348,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if samples.is_empty() {
|
||||
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
|
||||
/// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced
|
||||
/// submit→AU.
|
||||
/// Gated like its only caller (see [`percentile`]).
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_and_measure(
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
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,
|
||||
Qsv,
|
||||
}
|
||||
@@ -127,6 +134,44 @@ fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||
.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).
|
||||
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
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)?
|
||||
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,
|
||||
@@ -271,6 +322,11 @@ pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
||||
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 {
|
||||
// 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
|
||||
@@ -350,7 +406,7 @@ impl SystemInner {
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
) -> 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 {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -472,7 +528,10 @@ impl SystemInner {
|
||||
pts: i64,
|
||||
idr: bool,
|
||||
) -> 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!(
|
||||
fmt_10 == self.ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
@@ -851,7 +910,7 @@ impl ZeroCopyInner {
|
||||
bit_depth: u8,
|
||||
device: &ID3D11Device,
|
||||
) -> 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 {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -1311,11 +1370,7 @@ impl Encoder for FfmpegWinEncoder {
|
||||
Some(Inner::ZeroCopy(z)) => &mut z.enc,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let cap_us = std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
||||
.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 cap_us = poll_spin_cap_us();
|
||||
let deadline = (cap_us > 0 && self.in_flight > 0)
|
||||
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
|
||||
loop {
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
|
||||
CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
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).
|
||||
/// 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
|
||||
@@ -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
|
||||
/// frame mid-encode: visual corruption, not UB). IDD-push rings are sized around
|
||||
/// `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 {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*CAP.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
) {
|
||||
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() {
|
||||
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
|
||||
// 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
|
||||
@@ -334,9 +469,13 @@ fn retrieve_loop(
|
||||
// Lock/unlock from a secondary thread while the encode thread submits is the NVENC
|
||||
// guide's documented threading model.
|
||||
let result = unsafe {
|
||||
if WaitForSingleObject(HANDLE(job.event as *mut c_void), 5000) != WAIT_OBJECT_0 {
|
||||
Err("NVENC completion event timeout (5s) — encoder wedged?".to_string())
|
||||
if WaitForSingleObject(HANDLE(job.event as *mut c_void), wait_ms) != WAIT_OBJECT_0 {
|
||||
wedged = true;
|
||||
Err(format!(
|
||||
"NVENC completion event timeout ({wait_ms} ms) — encoder wedged?"
|
||||
))
|
||||
} else {
|
||||
wedged = false;
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
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
|
||||
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
|
||||
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
|
||||
/// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
|
||||
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
|
||||
/// (HDR can toggle mid-session); a change re-inits the session.
|
||||
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
|
||||
/// 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
|
||||
@@ -447,6 +603,11 @@ pub struct NvencD3d11Encoder {
|
||||
/// `reconfigure_bitrate` must present the SAME init params as the open (only the config's
|
||||
/// rate fields may move). Meaningless while `inited` is false.
|
||||
split_mode: u32,
|
||||
/// The session's arbitrated sub-frame state ([`resolve_split_subframe`] at open) — recorded
|
||||
/// so reconfigure presents EXACTLY the init params the session opened with (an env re-read
|
||||
/// there could flip `enableSubFrameWrite` mid-session, and would re-log the arbitration on
|
||||
/// every ABR retarget).
|
||||
subframe_on: bool,
|
||||
session_async: bool,
|
||||
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
|
||||
/// loss event (the client resends until it sees recovery).
|
||||
@@ -455,6 +616,11 @@ pub struct NvencD3d11Encoder {
|
||||
/// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
|
||||
/// tear down and re-init NVENC against it.
|
||||
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
|
||||
/// 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`.
|
||||
@@ -504,6 +670,9 @@ impl NvencD3d11Encoder {
|
||||
bit_depth,
|
||||
// 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_requested: chroma.is_444() && codec == Codec::H265,
|
||||
hdr_requested: false,
|
||||
hdr_unsupported: false,
|
||||
yuv444_supported: false,
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
@@ -522,9 +691,11 @@ impl NvencD3d11Encoder {
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
subframe_on: false,
|
||||
session_async: false,
|
||||
last_rfi_range: None,
|
||||
init_device: ptr::null_mut(),
|
||||
init_device_com: None,
|
||||
session_units: 0,
|
||||
})
|
||||
}
|
||||
@@ -571,18 +742,41 @@ impl NvencD3d11Encoder {
|
||||
for &bs in &self.bitstreams {
|
||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||
}
|
||||
// A destroy failure means the driver may still hold this session's slot (the concurrent-
|
||||
// session cap is per process and only a restart clears a leak) — make it visible instead
|
||||
// of silently discarding the status.
|
||||
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
|
||||
tracing::warn!(
|
||||
status = ?e,
|
||||
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
|
||||
session's slot toward the concurrent-session cap"
|
||||
);
|
||||
// Session-budget truth (see LIVE_SESSION_UNITS): refund only on PROOF the driver released
|
||||
// the slot — a successful destroy, or a failure whose status says the session/device no
|
||||
// longer exists on the driver side (a TDR reclaims every session with the context). The
|
||||
// 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!(
|
||||
status = ?e,
|
||||
"NVENC destroy_encoder failed, but the status proves the driver holds no \
|
||||
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.regs.clear(); // drops the texture clones, releasing our refs
|
||||
self.bitstreams.clear();
|
||||
@@ -641,6 +835,9 @@ impl NvencD3d11Encoder {
|
||||
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 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);
|
||||
@@ -668,7 +865,12 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
// Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
|
||||
if self.bit_depth >= 10 && ten_bit == 0 {
|
||||
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
||||
if !self.hdr_unsupported {
|
||||
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.hdr = false;
|
||||
}
|
||||
@@ -754,6 +956,27 @@ impl NvencD3d11Encoder {
|
||||
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
|
||||
/// 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.
|
||||
@@ -763,6 +986,7 @@ impl NvencD3d11Encoder {
|
||||
bitrate: u64,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
subframe: bool,
|
||||
) -> Result<*mut c_void> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
@@ -780,6 +1004,7 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
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) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -796,9 +1021,10 @@ impl NvencD3d11Encoder {
|
||||
&mut cfg,
|
||||
split_mode,
|
||||
enable_async,
|
||||
// Windows: env opt-in only ("1"), never a default — and build_init_params
|
||||
// additionally refuses it on an async session.
|
||||
resolve_subframe(false),
|
||||
// Windows: env opt-in only ("1"), never a default; arbitrated against split by the
|
||||
// caller (resolve_split_subframe) — and build_init_params additionally refuses it
|
||||
// on an async session.
|
||||
subframe,
|
||||
);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
@@ -812,6 +1038,15 @@ impl NvencD3d11Encoder {
|
||||
|
||||
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
||||
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
|
||||
// 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`,
|
||||
@@ -834,43 +1069,19 @@ impl NvencD3d11Encoder {
|
||||
// gets the highest the GPU can actually do, not a coarse fraction of it.
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 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
|
||||
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
|
||||
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
|
||||
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
|
||||
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
|
||||
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
|
||||
// A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
|
||||
// (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
|
||||
// both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
|
||||
// precedence (env override / the measured Main10 don't-split rule / pixel rate).
|
||||
// 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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
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"
|
||||
);
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
// Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. On Windows
|
||||
// sub-frame is env-opt-in only, so resolved == forced by construction.
|
||||
let subframe_req = resolve_subframe(false);
|
||||
let (split_mode, subframe_req) =
|
||||
resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_req);
|
||||
// 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
|
||||
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
|
||||
@@ -884,39 +1095,84 @@ impl NvencD3d11Encoder {
|
||||
// built in the right mode from the start.
|
||||
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, subframe_req);
|
||||
// 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,
|
||||
subframe_req,
|
||||
);
|
||||
}
|
||||
// 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.
|
||||
// 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,
|
||||
// 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 =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
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, subframe_req)
|
||||
{
|
||||
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
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(_) => {
|
||||
// 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
|
||||
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
|
||||
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_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
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,
|
||||
subframe_req,
|
||||
) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -925,7 +1181,15 @@ impl NvencD3d11Encoder {
|
||||
best_bps = 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() {
|
||||
@@ -933,14 +1197,30 @@ impl NvencD3d11Encoder {
|
||||
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
|
||||
.or_else(|_| {
|
||||
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
})
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
best = match self.try_open_session(
|
||||
device,
|
||||
FLOOR_BPS,
|
||||
used_split,
|
||||
use_async,
|
||||
subframe_req,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self
|
||||
.try_open_session(
|
||||
device,
|
||||
FLOOR_BPS,
|
||||
no_split,
|
||||
use_async,
|
||||
subframe_req,
|
||||
)
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -948,21 +1228,23 @@ impl NvencD3d11Encoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"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;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
|
||||
// effort: the floor fallback above may have succeeded split-disabled without updating
|
||||
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
|
||||
// the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
// Pin the device for the session's lifetime — and for a parked afterlife if the
|
||||
// eventual destroy fails (see `ParkedSession::_device`).
|
||||
self.init_device_com = Some(device.clone());
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim.
|
||||
self.split_mode = used_split;
|
||||
self.subframe_on = subframe_req;
|
||||
self.session_async = use_async;
|
||||
// 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
|
||||
// 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);
|
||||
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||
// info line for the same event here.)
|
||||
@@ -986,6 +1268,12 @@ impl NvencD3d11Encoder {
|
||||
for _ in 0..POOL {
|
||||
let ev = CreateEventW(None, false, false, PCWSTR::null())
|
||||
.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 {
|
||||
version: nv::NV_ENC_EVENT_PARAMS_VER,
|
||||
completionEvent: ev.0,
|
||||
@@ -994,7 +1282,6 @@ impl NvencD3d11Encoder {
|
||||
(api().register_async_event)(enc, &mut ep)
|
||||
.nv_ok()
|
||||
.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 (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
|
||||
@@ -1086,7 +1373,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
let dev_raw = frame.device.as_raw();
|
||||
let size_changed =
|
||||
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) {
|
||||
tracing::info!(
|
||||
device_changed = self.init_device != dev_raw,
|
||||
@@ -1107,7 +1398,13 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// Adopt the current frame size + colour so the encoder always matches the capturer output.
|
||||
self.width = captured.width;
|
||||
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;
|
||||
// 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
|
||||
// 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
|
||||
@@ -1133,9 +1430,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
};
|
||||
// 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
|
||||
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
|
||||
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
|
||||
// the stream really carries instead of silently claiming full chroma.
|
||||
// native YUV despite a 4:4:4 negotiation, THIS session encodes 4:2:0 — clear the
|
||||
// effective flag now so `caps().chroma_444` (and native's post-open cross-check)
|
||||
// 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
|
||||
&& !matches!(
|
||||
self.buffer_fmt,
|
||||
@@ -1181,9 +1480,23 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// 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
|
||||
// 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 {
|
||||
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 {
|
||||
let done = {
|
||||
@@ -1380,6 +1693,8 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// 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.
|
||||
EncoderCaps {
|
||||
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
supports_rfi: self.rfi_supported,
|
||||
// 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
|
||||
@@ -1404,61 +1719,56 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
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.
|
||||
// (NVENC handles are single-threaded; this runs on the encode thread, like submit/poll.)
|
||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
||||
// No live session or the GPU can't invalidate → caller forces a full IDR. (NVENC handles
|
||||
// are single-threaded; this runs on the encode thread, like submit/poll.) Everything else
|
||||
// — 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;
|
||||
}
|
||||
// Already invalidated a covering range for this loss event — no new driver calls needed,
|
||||
// no IDR. RE-ARM the anchor though: the client re-asking means the previous recovery
|
||||
// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor
|
||||
// (it too references only valid frames).
|
||||
if let Some((pf, pl)) = self.last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
|
||||
// Already invalidated a covering range for this loss event — no new driver calls
|
||||
// needed, no IDR. RE-ARM the anchor though: the client re-asking means the previous
|
||||
// recovery anchor AU may itself have been lost, and the next frame is just as clean a
|
||||
// re-anchor (it too references only valid frames).
|
||||
RangePlan::Covered => {
|
||||
self.pending_anchor = true;
|
||||
return true;
|
||||
true
|
||||
}
|
||||
}
|
||||
// `frame_idx` is the NEXT timestamp to assign, so the last encoded frame is `frame_idx - 1`
|
||||
// and the DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`. A lost frame older than that
|
||||
// can't be invalidated, so the only correct recovery is an IDR.
|
||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
||||
if first < oldest_in_dpb {
|
||||
return false;
|
||||
}
|
||||
// Clamp to frames we've actually encoded (don't invalidate a timestamp we never assigned).
|
||||
let last = last.min(self.frame_idx - 1);
|
||||
if first > last {
|
||||
return false;
|
||||
}
|
||||
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the WIRE
|
||||
// frame index the AU carries — so the client's lost-frame range maps 1:1 onto the
|
||||
// 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 {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false; // any failure → fall back to IDR
|
||||
RangePlan::Decline => false,
|
||||
RangePlan::Invalidate { first, last } => {
|
||||
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the
|
||||
// WIRE frame index the AU carries — so the client's lost-frame range maps 1:1 onto
|
||||
// the 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. The plan clamped each `ts` to
|
||||
// `[oldest_in_dpb, frame_idx - 1]`, 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 {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false; // any failure → fall back to IDR
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the first one encoded after the invalidation — the
|
||||
// clean re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and
|
||||
// the client lifts its post-loss freeze on it (instead of waiting ~1 s for the
|
||||
// cooldown-suppressed IDR fallback).
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the first one encoded after the invalidation — the clean
|
||||
// re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and the client
|
||||
// lifts its post-loss freeze on it (instead of waiting ~1 s for the cooldown-suppressed
|
||||
// IDR fallback).
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
@@ -1552,6 +1862,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.bitrate_bps = bps;
|
||||
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
|
||||
// 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
|
||||
@@ -1576,7 +1895,10 @@ impl Encoder for NvencD3d11Encoder {
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
self.session_async,
|
||||
resolve_subframe(false),
|
||||
// The SESSION's recorded state, not a fresh env read: reconfigure must
|
||||
// present exactly the init params the open arbitrated (an env re-read could
|
||||
// flip enableSubFrameWrite mid-session — the Linux latch invariant).
|
||||
self.subframe_on,
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1600,6 +1922,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<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -1645,6 +1973,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
|
||||
/// capability that couldn't be confirmed.
|
||||
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::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
||||
@@ -1723,6 +2056,9 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
}
|
||||
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 {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: cap,
|
||||
|
||||
@@ -189,12 +189,15 @@ impl PyroWaveEncoder {
|
||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) {
|
||||
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
|
||||
// slips through (e.g. the lab override) rather than wrap the RDO block index.
|
||||
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4 — see the Linux
|
||||
// twin (`enc/linux/pyrowave.rs`) for the full rationale: the negotiator's 4:4:4 → 4:2:0
|
||||
// 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!(
|
||||
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
|
||||
block index (see pyrowave-sys patches/0002 note) — use 4:2:0 at this size"
|
||||
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
|
||||
index (see pyrowave-sys patches/0002 note) — lower the resolution",
|
||||
if chroma444 { "4:4:4" } else { "4:2:0" }
|
||||
);
|
||||
}
|
||||
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
|
||||
/// 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
|
||||
/// it on import. Single/two-component textures import reliably on NVIDIA at any size — unlike a
|
||||
/// planar NV12 — so no MUTABLE_FORMAT / planar-layout workaround is involved.
|
||||
/// it on SUCCESSFUL import only (pyrowave-sys patch 0006 — same contract as the fence import in
|
||||
/// [`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
|
||||
/// `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();
|
||||
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);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -416,7 +425,9 @@ impl PyroWaveEncoder {
|
||||
pw::pyrowave_sync_object_create(&info, &mut sync),
|
||||
"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);
|
||||
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
|
||||
// open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn).
|
||||
EncoderCaps {
|
||||
// The Windows capturer composites the pointer itself; this backend never reads it.
|
||||
blends_cursor: false,
|
||||
chroma_444: self.chroma444,
|
||||
..EncoderCaps::default()
|
||||
}
|
||||
|
||||
@@ -519,9 +519,20 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
}
|
||||
b.WhitePointX = m.white_point[0];
|
||||
b.WhitePointY = m.white_point[1];
|
||||
// Units diverge on the max: VPL wants whole cd/m² (HdrMeta carries 0.0001 cd/m²); the
|
||||
// min is 0.0001 cd/m² on both sides.
|
||||
b.MaxDisplayMasteringLuminance = m.max_display_mastering_luminance / 10_000;
|
||||
// BOTH luminance fields are 0.0001 cd/m² here — do NOT scale the max.
|
||||
//
|
||||
// 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
|
||||
});
|
||||
@@ -690,13 +701,17 @@ impl Inner {
|
||||
}
|
||||
|
||||
fn take_bs(&mut self) -> Box<BsBuf> {
|
||||
match self.bs_pool.pop() {
|
||||
Some(mut b) => {
|
||||
// Pooled buffers were sized by whatever `bs_bytes` was when they were allocated. A bitrate
|
||||
// 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
|
||||
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) {
|
||||
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 {
|
||||
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 {
|
||||
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.
|
||||
// SAFETY: `session` is live; `got` and its (empty) ext chain outlive the call.
|
||||
let bs_bytes = unsafe {
|
||||
@@ -892,6 +913,57 @@ impl QsvEncoder {
|
||||
let kb = enc_of(m).BufferSizeInKB as usize;
|
||||
(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))
|
||||
}
|
||||
|
||||
@@ -1344,36 +1416,30 @@ impl Encoder for QsvEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||
// encoded inside the client's corrupt window — the client either never received it or
|
||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
|
||||
// the soup — the sustained-loss field failure where the picture never healed. Dropped
|
||||
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||
//
|
||||
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
|
||||
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
|
||||
// made the rejection list below (which iterates the post-sweep mirror and only 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 is the modal
|
||||
// one, and it was the broken one.
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
self.ltr_tainted[slot] = true;
|
||||
// The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
|
||||
// shared with AMF and Vulkan Video). This backend's mechanism: distrust is a SEPARATE
|
||||
// `ltr_tainted` flag, never a cleared mirror slot — `ltr_slots` mirrors the HARDWARE DPB,
|
||||
// and nulling an entry issues no VPL call, so the frame stays marked long-term in the
|
||||
// encoder. Clearing it made the rejection list below (which iterates the mirror and only
|
||||
// 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
|
||||
// is the modal one, and it was the broken one. The `!ltr_tainted` view filter below is
|
||||
// what persists that distrust across loss events (this call's taints are excluded from
|
||||
// this call's anchor by the policy itself).
|
||||
let view: Vec<(usize, i64)> = self
|
||||
.ltr_slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(slot, _)| !self.ltr_tainted[slot])
|
||||
.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;
|
||||
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 {
|
||||
match plan.anchor {
|
||||
Some((slot, ltr_frame)) => {
|
||||
self.pending_force = Some(slot);
|
||||
tracing::info!(
|
||||
@@ -1402,6 +1468,8 @@ impl Encoder for QsvEncoder {
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
|
||||
// are never HDR.
|
||||
@@ -1576,6 +1644,38 @@ impl Encoder for QsvEncoder {
|
||||
self.bitrate_bps = old;
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+665
-235
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
// the AppUserModelID adoption above).
|
||||
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 video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
|
||||
@@ -3,9 +3,34 @@
|
||||
use super::setup::pick_formats;
|
||||
use super::{OverlayPipe, Presenter};
|
||||
use crate::csc::CscPass;
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
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 {
|
||||
/// (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<()> {
|
||||
@@ -66,8 +91,8 @@ impl Presenter {
|
||||
.present_mode(self.present_mode)
|
||||
.clipped(true)
|
||||
.old_swapchain(old);
|
||||
let swapchain =
|
||||
unsafe { self.swap_d.create_swapchain(&info, None) }.context("vkCreateSwapchainKHR")?;
|
||||
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }
|
||||
.map_err(|e| anyhow!("vkCreateSwapchainKHR: {e}{}", kmsdrm_swapchain_hint()))?;
|
||||
// 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
|
||||
// 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 ash::vk;
|
||||
use ash::vk::Handle as _;
|
||||
use std::ffi::CString;
|
||||
use std::ffi::{c_char, CString};
|
||||
|
||||
impl Presenter {
|
||||
/// Bring up instance → surface → device → swapchain over an SDL window.
|
||||
@@ -40,7 +40,10 @@ impl Presenter {
|
||||
.iter()
|
||||
.map(|e| CString::new(e.as_str()).unwrap())
|
||||
.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 {
|
||||
entry.create_instance(
|
||||
&vk::InstanceCreateInfo::default()
|
||||
|
||||
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||
dm == "sddm.service"
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
|
||||
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
|
||||
/// caller degrades to attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
|
||||
/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
|
||||
/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
|
||||
/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
|
||||
/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
|
||||
/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
|
||||
/// 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()
|
||||
.map(|s| s.success())
|
||||
.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
|
||||
/// 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
|
||||
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
|
||||
clear_takeover();
|
||||
*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
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", &dm])
|
||||
.status();
|
||||
let _ = Command::new("systemctl").args(["start", &dm]).status();
|
||||
if !restore_display_manager(&dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"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);
|
||||
while Instant::now() < deadline {
|
||||
let active = Command::new("systemctl")
|
||||
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
if !try_stop_display_manager(&dm) {
|
||||
bail!(
|
||||
"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 \
|
||||
display-manager polkit rule (see docs) to enable the managed takeover"
|
||||
session unit, and stopping it needs privilege — the packaged pf-dm-helper \
|
||||
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!(
|
||||
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
|
||||
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||
// session Exec starts the gamescope unit itself.
|
||||
if let Some(dm) = dm {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", &dm])
|
||||
.status();
|
||||
let restart = Command::new("systemctl")
|
||||
.args(["restart", &dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let restart = restore_display_manager(&dm);
|
||||
if restart {
|
||||
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||
} 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.
|
||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||
|
||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. 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.
|
||||
/// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
|
||||
/// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
|
||||
/// 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)> {
|
||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||
// 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); \
|
||||
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)
|
||||
}
|
||||
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
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.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).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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;
|
||||
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.
|
||||
struct GlBlit {
|
||||
program: u32,
|
||||
@@ -528,13 +584,15 @@ impl EglImporter {
|
||||
// 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
|
||||
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
||||
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
|
||||
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
|
||||
let node = nvidia_render_node();
|
||||
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
|
||||
// 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.
|
||||
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
|
||||
// `>= 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,
|
||||
@@ -546,7 +604,7 @@ impl EglImporter {
|
||||
// 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.
|
||||
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,
|
||||
@@ -568,7 +626,7 @@ impl EglImporter {
|
||||
&[egl::ATTRIB_NONE],
|
||||
)
|
||||
}
|
||||
.context("eglGetPlatformDisplay(GBM) on the NVIDIA render node")?;
|
||||
.with_context(|| format!("eglGetPlatformDisplay(GBM) on {}", node.display()))?;
|
||||
egl.initialize(display).context("eglInitialize")?;
|
||||
|
||||
let exts = egl
|
||||
@@ -590,20 +648,46 @@ impl EglImporter {
|
||||
egl.bind_api(egl::OPENGL_API)
|
||||
.context("eglBindAPI(OpenGL)")?;
|
||||
// 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).
|
||||
let config = egl
|
||||
.choose_first_config(
|
||||
display,
|
||||
&[
|
||||
egl::SURFACE_TYPE,
|
||||
egl::PBUFFER_BIT,
|
||||
egl::RENDERABLE_TYPE,
|
||||
egl::OPENGL_BIT,
|
||||
egl::NONE,
|
||||
],
|
||||
)
|
||||
// display has none of — ask for a pbuffer-capable config first (NVIDIA's GBM platform has
|
||||
// them, and this is the request every working host has been served for a year).
|
||||
//
|
||||
// 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::PBUFFER_BIT,
|
||||
egl::RENDERABLE_TYPE,
|
||||
egl::OPENGL_BIT,
|
||||
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("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
|
||||
.create_context(
|
||||
display,
|
||||
@@ -637,6 +721,7 @@ impl EglImporter {
|
||||
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
|
||||
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
||||
tracing::info!(
|
||||
node = %node.display(),
|
||||
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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"`).
|
||||
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)
|
||||
|
||||
@@ -1813,9 +1813,11 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
|
||||
return PunktfunkStatus::InvalidArg;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
//! 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;
|
||||
//! - **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
|
||||
//! 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.
|
||||
const PROVEN_HEADROOM_NUM: u32 = 3;
|
||||
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.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
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
|
||||
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
||||
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
|
||||
/// 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
|
||||
@@ -147,6 +193,12 @@ impl BitrateController {
|
||||
probing: true,
|
||||
owd_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,
|
||||
bad_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
|
||||
/// 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) {
|
||||
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.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
|
||||
/// 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`
|
||||
/// 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`
|
||||
/// the window's ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline
|
||||
/// really 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.
|
||||
/// (`None` on an embedder that doesn't report it — the signal is then absent),
|
||||
/// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
|
||||
/// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
|
||||
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
|
||||
/// 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)]
|
||||
pub(crate) fn on_window(
|
||||
&mut self,
|
||||
@@ -192,6 +290,7 @@ impl BitrateController {
|
||||
loss_ppm: u32,
|
||||
owd_mean_us: Option<i64>,
|
||||
decode_mean_us: Option<i64>,
|
||||
encode_mean_us: Option<i64>,
|
||||
actual_kbps: u32,
|
||||
flushed: bool,
|
||||
) -> Option<u32> {
|
||||
@@ -242,6 +341,23 @@ impl BitrateController {
|
||||
}
|
||||
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
|
||||
// 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
|
||||
@@ -253,8 +369,9 @@ impl BitrateController {
|
||||
// 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)
|
||||
// still needs two consecutive windows.
|
||||
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad;
|
||||
let severe =
|
||||
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 {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -264,6 +381,29 @@ impl BitrateController {
|
||||
self.clean_windows += 1;
|
||||
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
|
||||
.last_change
|
||||
.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.
|
||||
let utilized =
|
||||
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
|
||||
.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);
|
||||
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
|
||||
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||||
// Congestion avoidance: +~6 % after a sustained clean run.
|
||||
@@ -308,6 +452,7 @@ impl BitrateController {
|
||||
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
|
||||
self.last_change = Some(now);
|
||||
self.unacked += 1;
|
||||
self.last_requested_kbps = Some(kbps);
|
||||
// `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).
|
||||
Some(kbps)
|
||||
@@ -331,7 +476,16 @@ mod tests {
|
||||
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
||||
let mut out = None;
|
||||
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() {
|
||||
return out;
|
||||
}
|
||||
@@ -345,7 +499,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
let now = Instant::now();
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -356,22 +510,58 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||
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
|
||||
);
|
||||
// The second consecutive bad window backs off ×0.7.
|
||||
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)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||
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
|
||||
); // bad #1 again
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -382,19 +572,28 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
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)
|
||||
);
|
||||
// …and so does a jump-to-live flush.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
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)
|
||||
);
|
||||
// …and ≥6 % window loss.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -404,18 +603,18 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
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)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// 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.
|
||||
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
|
||||
);
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -426,17 +625,17 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||
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)
|
||||
);
|
||||
c.on_ack(5_000);
|
||||
// At the floor, further bad windows request nothing.
|
||||
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
|
||||
);
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -446,7 +645,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
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)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
@@ -469,9 +668,16 @@ mod tests {
|
||||
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||||
let mut got = Vec::new();
|
||||
for i in 0..14 {
|
||||
if let Some(k) =
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false)
|
||||
{
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
got.push(k);
|
||||
}
|
||||
@@ -485,20 +691,47 @@ mod tests {
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
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)
|
||||
);
|
||||
c.on_ack(40_000);
|
||||
// Severe window → immediate ×0.7, and slow start is over.
|
||||
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)
|
||||
);
|
||||
c.on_ack(28_000);
|
||||
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||||
let mut next = None;
|
||||
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() {
|
||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||
break;
|
||||
@@ -512,7 +745,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
c.set_ceiling(1_000_000);
|
||||
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
|
||||
);
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -527,17 +760,44 @@ mod tests {
|
||||
// Establish a ~10 ms baseline over a few clean windows.
|
||||
for i in 0..4 {
|
||||
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
|
||||
);
|
||||
}
|
||||
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
||||
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
|
||||
);
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -557,6 +817,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -572,6 +833,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -584,6 +846,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -605,6 +868,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -620,6 +884,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -634,6 +899,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -657,6 +923,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false
|
||||
),
|
||||
@@ -673,6 +940,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
18_000,
|
||||
false
|
||||
),
|
||||
@@ -696,6 +964,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
),
|
||||
@@ -710,6 +979,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
30_000,
|
||||
false
|
||||
),
|
||||
@@ -732,6 +1002,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
),
|
||||
@@ -741,7 +1012,16 @@ mod tests {
|
||||
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
|
||||
for i in 2..30 {
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -761,6 +1041,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -776,6 +1057,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
1_000_000,
|
||||
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]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -791,7 +1302,7 @@ mod tests {
|
||||
let mut i = 0;
|
||||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||||
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()
|
||||
{
|
||||
sent += 1;
|
||||
|
||||
@@ -224,6 +224,20 @@ pub(crate) struct DecodeLatAcc {
|
||||
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
|
||||
/// (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
|
||||
|
||||
@@ -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
|
||||
// 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));
|
||||
// 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
|
||||
// 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(),
|
||||
clip_event_tx: clip_event_tx.clone(),
|
||||
cursor_shape_tx,
|
||||
mode_gen: mode_gen.clone(),
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
@@ -141,6 +148,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
cursor_state_tx,
|
||||
));
|
||||
|
||||
@@ -176,6 +184,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clock_offset,
|
||||
clock_gen,
|
||||
decode_lat,
|
||||
encode_lat,
|
||||
mode_gen,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
|
||||
@@ -24,6 +24,10 @@ pub(super) struct ControlTask {
|
||||
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
|
||||
/// shape plane ([`NativeClient::next_cursor_shape`]).
|
||||
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 {
|
||||
@@ -40,6 +44,7 @@ impl ControlTask {
|
||||
clock_gen,
|
||||
clip_event_tx,
|
||||
cursor_shape_tx,
|
||||
mode_gen,
|
||||
} = self;
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// 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 ack.accepted {
|
||||
*mode_slot.lock().unwrap() = ack.mode;
|
||||
mode_gen.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||
} else {
|
||||
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_gen: Arc<AtomicU32>,
|
||||
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) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
@@ -41,6 +47,8 @@ impl DataPump {
|
||||
clock_offset: pump_clock_offset,
|
||||
clock_gen: pump_clock_gen,
|
||||
decode_lat: pump_decode_lat,
|
||||
encode_lat: pump_encode_lat,
|
||||
mode_gen: pump_mode_gen,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
@@ -127,6 +135,7 @@ impl DataPump {
|
||||
let mut clock_detector_armed = true;
|
||||
let mut resync_wanted = false;
|
||||
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,
|
||||
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
||||
// 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
|
||||
// feed the controller this window's congestion signals; a decision becomes a
|
||||
// SetBitrate on the control stream.
|
||||
// Adaptive bitrate: an accepted mode switch first (it invalidates the
|
||||
// mode-scoped learned state), then drain any host ack (its clamp is
|
||||
// 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() {
|
||||
abr.on_ack(acked);
|
||||
}
|
||||
@@ -356,6 +371,14 @@ impl DataPump {
|
||||
*acc = DecodeLatAcc::default();
|
||||
(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 target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
@@ -369,17 +392,19 @@ impl DataPump {
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
) {
|
||||
// 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.
|
||||
tracing::info!(
|
||||
kbps,
|
||||
loss_ppm,
|
||||
owd_mean_us = owd_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,
|
||||
flushed = flush_in_window,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
|
||||
@@ -14,6 +14,9 @@ pub(super) async fn run(
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
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>,
|
||||
) {
|
||||
// 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) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
||||
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")]
|
||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
||||
|
||||
@@ -686,6 +686,13 @@ fn stream_body(
|
||||
true,
|
||||
)
|
||||
.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).
|
||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||
.ok()
|
||||
@@ -750,6 +757,16 @@ fn stream_body(
|
||||
// dead source can't loop forever — it ends the stream after the cap, falling back to a reconnect.
|
||||
const MAX_REBUILDS: u32 = 5;
|
||||
let mut rebuilds: u32 = 0;
|
||||
// Encode-stall recovery, the GameStream twin of the native path's ladder (native/stream.rs,
|
||||
// `reset_stalled_encoder`): a submit/poll failure or a silent stall rebuilds the encoder in
|
||||
// place — bounded — instead of ending the stream. The backends deliberately turn a wedged
|
||||
// GPU into a bounded error so the caller can do exactly this; without the ladder here, every
|
||||
// such error cost a Moonlight client a full disconnect/reconnect. `last_au_at` feeds the
|
||||
// silent-wedge watchdog below (backends whose non-blocking poll returns `None` forever
|
||||
// instead of erroring); every received AU clears the reset budget.
|
||||
const MAX_ENCODER_RESETS: u32 = 5;
|
||||
let mut encoder_resets: u32 = 0;
|
||||
let mut last_au_at = Instant::now();
|
||||
|
||||
// Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without
|
||||
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
|
||||
@@ -841,6 +858,8 @@ fn stream_body(
|
||||
true, // metadata-cursor capture — see the first open
|
||||
)
|
||||
.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;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
@@ -857,9 +876,10 @@ fn stream_body(
|
||||
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
// A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see below).
|
||||
// A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see
|
||||
// below). Consumed only when the keyframe is actually EMITTED (in the coalesce gate) —
|
||||
// read-and-clear here let the gate swallow the request for good.
|
||||
let mut want_keyframe = recover_after_drop;
|
||||
recover_after_drop = false;
|
||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
|
||||
@@ -889,12 +909,48 @@ fn stream_body(
|
||||
if emit {
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(now);
|
||||
// A drop-recovery request is satisfied by an EMITTED keyframe, not by being
|
||||
// read: coalesced away it would be lost — never retried — leaving duplicate wire
|
||||
// indices in the encoder's reference table for a later RFI to anchor on (the
|
||||
// stale-anchor case rfi.rs exists to prevent). Keep it armed until this point.
|
||||
recover_after_drop = false;
|
||||
} else {
|
||||
tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
|
||||
}
|
||||
}
|
||||
enc.submit_indexed(&frame, au_seq.wrapping_add(enc_inflight))
|
||||
.context("encoder submit")?;
|
||||
if let Err(e) = enc.submit_indexed(&frame, au_seq.wrapping_add(enc_inflight)) {
|
||||
// The input half of an encode stall (see native/stream.rs): rebuild the encoder in
|
||||
// place instead of ending the stream. A backend without an in-place rebuild
|
||||
// (`reset` = false) or an exhausted budget still fails the session, with the cause.
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS || !enc.reset() {
|
||||
tracing::error!(
|
||||
error = %format!("{e:#}"),
|
||||
resets = encoder_resets,
|
||||
"encoder did not recover after repeated in-place rebuilds — ending the \
|
||||
stream (see the error above for the cause)"
|
||||
);
|
||||
return Err(e).context("encoder submit");
|
||||
}
|
||||
// The owed AUs died with the discarded encoder state; numbering restarts at `au_seq`,
|
||||
// and the rebuilt encoder's reference state is empty so the reused predictions meet
|
||||
// no stale bookkeeping (same reasoning as the capture rebuild above). The IDR
|
||||
// bypasses the coalesce gate: a rebuilt encoder MUST resync the client.
|
||||
enc_inflight = 0;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
last_au_at = Instant::now();
|
||||
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||
max = MAX_ENCODER_RESETS,
|
||||
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
|
||||
// Real backoff between attempts, not a frame period: five instant retries burn out
|
||||
// inside one driver hiccup (the native ladder's 2026-07 field lesson).
|
||||
let backoff =
|
||||
frame_interval.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
|
||||
next_frame = Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
enc_inflight = enc_inflight.wrapping_add(1);
|
||||
let t_enc = tick.elapsed();
|
||||
|
||||
@@ -905,7 +961,19 @@ fn stream_body(
|
||||
// stamped with its wire frameIndex here (`au_seq + position`); the numbering only
|
||||
// ADVANCES if the batch is actually enqueued below (a dropped batch consumes none).
|
||||
let mut aus: Vec<(Vec<u8>, FrameType, u32)> = Vec::new();
|
||||
while let Some(au) = enc.poll().context("encoder poll")? {
|
||||
// A poll error is the output half of an encode stall (e.g. a bounded fence timeout from
|
||||
// a wedged GPU) — carry it to the shared stall recovery below, after the AUs already
|
||||
// drained are handed off, instead of killing the session outright.
|
||||
let mut poll_err: Option<anyhow::Error> = None;
|
||||
loop {
|
||||
let au = match enc.poll() {
|
||||
Ok(Some(au)) => au,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
poll_err = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let ft = if au.keyframe {
|
||||
FrameType::Idr
|
||||
} else {
|
||||
@@ -914,6 +982,9 @@ fn stream_body(
|
||||
let idx = au_seq.wrapping_add(aus.len() as u32);
|
||||
aus.push((au.data, ft, idx));
|
||||
enc_inflight = enc_inflight.saturating_sub(1);
|
||||
// Every AU proves the encoder is alive.
|
||||
last_au_at = Instant::now();
|
||||
encoder_resets = 0;
|
||||
}
|
||||
let t_pkt = tick.elapsed();
|
||||
|
||||
@@ -942,6 +1013,37 @@ fn stream_body(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Encode-stall recovery, the poll half (mirrors the native path's watchdog): an explicit
|
||||
// poll error, or no AU within the window while frames are owed — the silent wedge, where
|
||||
// a non-blocking poll returns `None` forever and nothing else ever errors. The window
|
||||
// scales with the frame interval so low-fps modes can't false-trip.
|
||||
let stall_window = Duration::from_secs(2).max(frame_interval * 8);
|
||||
if poll_err.is_some() || (enc_inflight > 0 && last_au_at.elapsed() >= stall_window) {
|
||||
let why = match &poll_err {
|
||||
Some(e) => format!("poll failed: {e:#}"),
|
||||
None => format!(
|
||||
"no AU for {} ms with {} frame(s) owed",
|
||||
last_au_at.elapsed().as_millis(),
|
||||
enc_inflight
|
||||
),
|
||||
};
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS || !enc.reset() {
|
||||
return Err(poll_err.unwrap_or_else(|| anyhow::anyhow!("{why}")))
|
||||
.context("encoder stalled — in-place rebuild unavailable or exhausted");
|
||||
}
|
||||
enc_inflight = 0;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
last_au_at = Instant::now();
|
||||
tracing::warn!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
|
||||
"encode stall detected — encoder rebuilt in place, forcing an IDR");
|
||||
let backoff =
|
||||
frame_interval.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
|
||||
next_frame = Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
if measure {
|
||||
let t_send = tick.elapsed();
|
||||
let cap_us = t_cap.as_micros();
|
||||
|
||||
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
|
||||
pub(crate) struct LocalSummary {
|
||||
/// Host version (mirrors `/health`).
|
||||
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,
|
||||
/// True while the audio stream thread is running.
|
||||
/// True while audio is streaming on either plane (same rule as `video_streaming`).
|
||||
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>,
|
||||
/// Number of pinned (paired) GameStream client certificates.
|
||||
paired_clients: u32,
|
||||
@@ -391,26 +393,27 @@ 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> {
|
||||
// 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
|
||||
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
||||
let session = st
|
||||
.app
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.map(|l| SessionInfo {
|
||||
width: l.width,
|
||||
height: l.height,
|
||||
fps: l.fps,
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::session_status::snapshot()
|
||||
.first()
|
||||
.map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
})
|
||||
native.first().map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
})
|
||||
});
|
||||
let (native_paired_clients, pending_approvals) = st
|
||||
.native
|
||||
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
||||
.unwrap_or((0, 0));
|
||||
Json(LocalSummary {
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
|
||||
// "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,
|
||||
paired_clients: st
|
||||
.app
|
||||
|
||||
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
|
||||
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
|
||||
/// 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).
|
||||
#[tokio::test]
|
||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
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]
|
||||
async fn status_reflects_runtime_state() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
|
||||
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
|
||||
|
||||
#[tokio::test]
|
||||
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 app = test_app(state.clone(), None);
|
||||
let post = || {
|
||||
|
||||
@@ -985,6 +985,18 @@ async fn serve_session(
|
||||
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
||||
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, 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_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
|
||||
@@ -1036,6 +1048,9 @@ async fn serve_session(
|
||||
live_reconfig_ok,
|
||||
adaptive_fec,
|
||||
session_bitrate_kbps,
|
||||
live_bitrate.clone(),
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
fec_target_ctl,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
@@ -1429,6 +1444,9 @@ async fn serve_session(
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
|
||||
@@ -22,6 +22,13 @@ pub(super) async fn run(
|
||||
live_reconfig_ok: bool,
|
||||
adaptive_fec: bool,
|
||||
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>,
|
||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
@@ -161,7 +168,31 @@ pub(super) async fn run(
|
||||
);
|
||||
session_bitrate_kbps
|
||||
} 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!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
|
||||
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
|
||||
pub(super) compositor: crate::vdisplay::Compositor,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
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 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
|
||||
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
mut bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// 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,
|
||||
// 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);
|
||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
||||
// `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
|
||||
// control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
|
||||
// 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
|
||||
// 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
|
||||
@@ -1436,6 +1453,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
const DEPTH_ESCALATE: u32 = 20;
|
||||
const DEPTH_BEHIND_CAP: u32 = 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 {
|
||||
// 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
|
||||
@@ -1674,15 +1711,49 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
while let Ok(k) = bitrate_rx.try_recv() {
|
||||
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 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!(
|
||||
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)"
|
||||
);
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
if applied_kbps < new_kbps {
|
||||
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
|
||||
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
||||
} else {
|
||||
@@ -1702,9 +1773,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan.cursor_blend,
|
||||
) {
|
||||
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!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
to_kbps = applied_kbps,
|
||||
requested_kbps = new_kbps,
|
||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||
);
|
||||
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.)
|
||||
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
enc = new_enc;
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
if applied_kbps < new_kbps {
|
||||
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
|
||||
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
||||
// IDR cooldown too.
|
||||
@@ -1723,6 +1805,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
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) => {
|
||||
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,
|
||||
// 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.
|
||||
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
|
||||
if idd_adaptive_enabled() {
|
||||
depth_frames += 1;
|
||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||
let behind = std::time::Instant::now() >= next;
|
||||
@@ -2556,28 +2644,89 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
} else {
|
||||
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 {
|
||||
cur_depth = max_depth;
|
||||
tracing::info!(
|
||||
depth = cur_depth,
|
||||
"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)"
|
||||
);
|
||||
} else {
|
||||
pipeline_asked = true;
|
||||
if enc.set_pipelined(true) {
|
||||
pipelined_active = enc.set_pipelined(true);
|
||||
if pipelined_active {
|
||||
tracing::info!(
|
||||
"encoder pipelined retrieve escalated — encode can't hold \
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Give the action time to take effect before judging again.
|
||||
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");
|
||||
}
|
||||
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
|
||||
for _ in 0..30 {
|
||||
if token_path.exists() {
|
||||
break;
|
||||
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
|
||||
//
|
||||
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
|
||||
// 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));
|
||||
}
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
Ok(())
|
||||
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]);
|
||||
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
|
||||
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.
|
||||
@@ -505,30 +558,57 @@ fn random_password() -> String {
|
||||
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
|
||||
fn stop_web_console() {
|
||||
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
||||
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
for pid in web_listener_pids() {
|
||||
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||
}
|
||||
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`,
|
||||
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
|
||||
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<()> {
|
||||
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!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\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\
|
||||
<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\
|
||||
<Settings>\n\
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
||||
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
||||
</Settings>\n\
|
||||
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
|
||||
</Task>",
|
||||
xml_escape(&cmd.to_string_lossy())
|
||||
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
|
||||
</Task>"
|
||||
);
|
||||
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(
|
||||
"schtasks",
|
||||
&[
|
||||
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
],
|
||||
);
|
||||
let _ = std::fs::remove_file(&xml_path);
|
||||
if ok {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus};
|
||||
struct HostTray {
|
||||
status: TrayStatus,
|
||||
web_port: u16,
|
||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||
/// The console answered the poller's live loopback probe — labels the (always present) "Open
|
||||
/// web console" entry; it never hides it.
|
||||
web_console: bool,
|
||||
/// 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.
|
||||
@@ -33,8 +33,10 @@ impl HostTray {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_console(&self) {
|
||||
let url = format!("https://127.0.0.1:{}", self.web_port);
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -51,7 +53,7 @@ impl ksni::Tray for HostTray {
|
||||
fn status(&self) -> ksni::Status {
|
||||
match &self.status {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
|
||||
}
|
||||
.into(),
|
||||
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 {
|
||||
label: "Open web console".into(),
|
||||
visible: self.web_console,
|
||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||
label: if self.web_console {
|
||||
"Open web console".to_string()
|
||||
} else {
|
||||
"Open web console (not responding)".to_string()
|
||||
},
|
||||
activate: Box::new(|t: &mut Self| t.open_console("")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Approve pairing request…".into(),
|
||||
visible: self.web_console && self.status.pairing_attention(),
|
||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||
visible: self.status.pairing_attention(),
|
||||
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()
|
||||
}
|
||||
.into(),
|
||||
|
||||
@@ -37,11 +37,6 @@ pub struct Summary {
|
||||
/// host that doesn't send it deserializes as 0.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -74,47 +69,43 @@ impl TrayStatus {
|
||||
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||
TrayStatus::Running(s) => {
|
||||
let base = match (&s.session, s.video_streaming) {
|
||||
(Some(sess), true) => format!(
|
||||
"punktfunk host {} — streaming {}×{}@{}",
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
),
|
||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
|
||||
// exclusive topology, your physical monitors — is being held. Release it from
|
||||
// the console.
|
||||
_ if s.kept_displays > 0 => format!(
|
||||
"punktfunk host {} — idle · {} display{} kept",
|
||||
s.version,
|
||||
s.kept_displays,
|
||||
if s.kept_displays == 1 { "" } else { "s" }
|
||||
),
|
||||
_ => 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(", "))
|
||||
}
|
||||
}
|
||||
TrayStatus::Running(s) => match (&s.session, self.is_streaming()) {
|
||||
(Some(sess), true) => format!(
|
||||
"punktfunk host {} — streaming {}×{}@{}",
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
),
|
||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
|
||||
// exclusive topology, your physical monitors — is being held. Release it from
|
||||
// the console.
|
||||
_ if s.kept_displays > 0 => format!(
|
||||
"punktfunk host {} — idle · {} display{} kept",
|
||||
s.version,
|
||||
s.kept_displays,
|
||||
if s.kept_displays == 1 { "" } else { "s" }
|
||||
),
|
||||
_ => format!("punktfunk host {} — idle", s.version),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
|
||||
/// 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
|
||||
/// no distinct attention icon), so this accessor is unused there — allow it per-platform rather
|
||||
/// than gate the shared API out.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn has_conflicts(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
||||
/// A client is streaming: the host's flag, OR a live session in the summary.
|
||||
///
|
||||
/// The session is checked too because a host from before the `get_local_summary` fix raised
|
||||
/// `video_streaming` from the GameStream plane only — through a whole session on the native
|
||||
/// (default) plane it said false while still reporting that session's mode, which is what left
|
||||
/// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host.
|
||||
pub fn is_streaming(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some())
|
||||
}
|
||||
|
||||
pub fn is_streaming(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
||||
/// 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).
|
||||
@@ -156,9 +147,11 @@ struct Shared {
|
||||
|
||||
impl Poller {
|
||||
/// 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` —
|
||||
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
|
||||
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
|
||||
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
|
||||
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
|
||||
/// 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(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
@@ -203,6 +196,10 @@ fn poll_loop(
|
||||
// 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).
|
||||
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 {
|
||||
let svc = probe_service();
|
||||
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 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)) {
|
||||
on_change(status.clone(), console_up);
|
||||
last = Some((status, console_up));
|
||||
@@ -406,7 +409,6 @@ mod tests {
|
||||
pin_pending: false,
|
||||
pending_approvals: 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]
|
||||
fn conflicts_drive_attention_and_lead_the_tooltip() {
|
||||
let mut s = summary(false);
|
||||
assert!(!TrayStatus::Running(s.clone()).has_conflicts());
|
||||
s.conflicts = vec!["Sunshine (running)".into(), "Apollo".into()];
|
||||
let st = TrayStatus::Running(s);
|
||||
assert!(st.has_conflicts());
|
||||
let head = st.headline();
|
||||
assert!(head.starts_with("⚠ conflicting host: Sunshine (running), Apollo"));
|
||||
fn a_summary_carrying_conflicts_still_deserializes_and_is_ignored() {
|
||||
let json = r#"{"version":"0.5.1","video_streaming":false,"audio_streaming":false,
|
||||
"session":null,"paired_clients":1,"native_paired_clients":2,"pin_pending":false,
|
||||
"pending_approvals":0,"kept_displays":0,"conflicts":["Sunshine (installed)"]}"#;
|
||||
let s: Summary = serde_json::from_str(json).expect("unknown fields are ignored");
|
||||
assert_eq!(
|
||||
TrayStatus::Running(s).headline(),
|
||||
"punktfunk host 0.5.1 — idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,6 +485,32 @@ mod tests {
|
||||
.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]
|
||||
fn pairing_attention_flags() {
|
||||
let mut s = summary(false);
|
||||
|
||||
@@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104;
|
||||
const IDM_LOGS: usize = 0x0105;
|
||||
const IDM_EXIT: usize = 0x0106;
|
||||
const IDM_PAIRING: usize = 0x0107;
|
||||
const IDM_DISPLAYS: usize = 0x0108;
|
||||
|
||||
/// Icon resource ordinals (embedded by build.rs).
|
||||
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||
@@ -73,8 +74,9 @@ struct App {
|
||||
taskbar_created: u32,
|
||||
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||
host_exe: Option<std::path::PathBuf>,
|
||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||
/// The console answered the poller's live loopback probe. Drives the label of the (always
|
||||
/// 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_port: u16,
|
||||
}
|
||||
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
|
||||
};
|
||||
add(IDM_HEADER, &status.headline(), true);
|
||||
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) {
|
||||
add(IDM_OPEN_WEB, "Open web console", false);
|
||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||
if status.pairing_attention() {
|
||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||
}
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
} else {
|
||||
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
|
||||
}
|
||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||
if status.pairing_attention() {
|
||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||
}
|
||||
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 startable {
|
||||
add(IDM_START, "Start host", false);
|
||||
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_web_console(hwnd: HWND) {
|
||||
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
|
||||
/// 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) {
|
||||
@@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
WM_CONTEXTMENU => show_menu(hwnd),
|
||||
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||
if app.web_console.load(Ordering::SeqCst) {
|
||||
open_web_console(hwnd);
|
||||
open_web_console(hwnd, "");
|
||||
} else {
|
||||
show_menu(hwnd);
|
||||
}
|
||||
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
}
|
||||
WM_COMMAND => {
|
||||
match (wparam.0) & 0xffff {
|
||||
IDM_OPEN_WEB => open_web_console(hwnd),
|
||||
IDM_PAIRING => open_web_console(hwnd),
|
||||
IDM_OPEN_WEB => 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_STOP => elevate_service(hwnd, "stop"),
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -143,7 +143,8 @@ notes for context.
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
|
||||
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
|
||||
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. H.264 never splits (not applicable per the SDK); on HEVC a *forced* split disables sub-frame readback (mutually unsupported) — set `0` to choose sub-frame instead. |
|
||||
| `PUNKTFUNK_NVENC_SUBFRAME` | `0` · `1` | NVENC sub-frame (slice-level) readback for lower latency on sync sessions. Default: on where the GPU supports it (Linux direct NVENC). `0` = never; `1` = force. On HEVC it yields to a forced split-encode (the SDK documents the pair unsupported). |
|
||||
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||
|
||||
|
||||
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
|
||||
|
||||
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
||||
- **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
|
||||
with a polkit rule (adjust the unit and user names to your box):
|
||||
itself for the length of the stream and restart it afterwards, which needs privilege. The
|
||||
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
|
||||
// /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
|
||||
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
|
||||
display manager. If the display-manager restart ever loses its privilege mid-restore,
|
||||
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
|
||||
fallback.
|
||||
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
|
||||
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
|
||||
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
|
||||
what happened; check the host log for "managed takeover unavailable"). If the display-manager
|
||||
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
|
||||
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
|
||||
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
|
||||
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
|
||||
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
|
||||
+26
-2
@@ -19,13 +19,24 @@ pkgbase=punktfunk
|
||||
# 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
|
||||
# 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 ->
|
||||
# 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).
|
||||
pkgver="${PF_PKGVER:-0.7.0}"
|
||||
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"
|
||||
license=('MIT OR Apache-2.0')
|
||||
# !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).
|
||||
# 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.
|
||||
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 \
|
||||
-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
|
||||
@@ -91,6 +106,7 @@ build() {
|
||||
# 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.)
|
||||
cargo build --release --locked -p punktfunk-tray
|
||||
fi
|
||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||
# built AND run with bun.
|
||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||
@@ -129,6 +145,14 @@ package_punktfunk-host() {
|
||||
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
||||
# /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"
|
||||
# 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)
|
||||
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)
|
||||
|
||||
@@ -70,6 +70,25 @@ makepkg -si
|
||||
# …add the web console too (needs bun / bun-bin):
|
||||
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):
|
||||
```sh
|
||||
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`,
|
||||
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
|
||||
# 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
|
||||
#
|
||||
# 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
|
||||
|
||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
TARGET="${TARGET:-}"
|
||||
PKG="punktfunk-client"
|
||||
CRATE="punktfunk-client-linux"
|
||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
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 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
|
||||
echo "==> building $CRATE + punktfunk-client-session (release)"
|
||||
cargo build --release --locked -p "$CRATE" -p punktfunk-client-session
|
||||
echo "==> building $CRATE + punktfunk-client-session (release${TARGET:+ for $TARGET})"
|
||||
cargo build --release --locked "${CARGO_TARGET_ARGS[@]}" -p "$CRATE" -p punktfunk-client-session
|
||||
fi
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
|
||||
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
|
||||
# --- file layout (matches the RPM %install) ----------------------------------
|
||||
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||
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).
|
||||
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
|
||||
|
||||
@@ -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
|
||||
> 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
|
||||
|
||||
[`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).
|
||||
# BUILDER=... override the flatpak-builder invocation (default: auto-detect host
|
||||
# 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
|
||||
|
||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
@@ -25,7 +31,11 @@ APP_ID="io.unom.Punktfunk"
|
||||
MANIFEST="packaging/flatpak/io.unom.Punktfunk.yml"
|
||||
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo 0.0.1-dev)}"
|
||||
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) ---
|
||||
if [ -n "${BUILDER:-}" ]; then
|
||||
@@ -72,18 +82,19 @@ else
|
||||
fi
|
||||
|
||||
# --- 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
|
||||
# track flatpak.unom.io. build-bundle must then be told the branch (else it defaults to `master`).
|
||||
"${FPB[@]}" --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch=stable \
|
||||
--arch="$ARCH" \
|
||||
--install-deps-from=flathub \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
--repo="$ROOTDIR/.flatpak-repo" \
|
||||
"$ROOTDIR/.flatpak-build" "$MANIFEST"
|
||||
|
||||
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"
|
||||
ls -lh "$BUNDLE"
|
||||
echo
|
||||
|
||||
@@ -121,6 +121,14 @@ build-options:
|
||||
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
|
||||
# mold (shipped in rust-stable) speeds the ~450-crate link on the Deck APU.
|
||||
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:
|
||||
# ---------------------------------------------------------------------------------------
|
||||
@@ -240,7 +248,18 @@ modules:
|
||||
# crate's pinned skia commit + target + resolved features — when bumping skia-safe, build
|
||||
# the workspace once locally and take the new name from
|
||||
# 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
|
||||
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
|
||||
sha256: b46e7061e6b9df792025acaf9b8b90180224c7cec63f4c3ce09af7ddddb8abfa
|
||||
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
|
||||
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.
|
||||
SCRIPTING_OPT=()
|
||||
[ "${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)"
|
||||
cd "$ROOTDIR"
|
||||
|
||||
@@ -39,7 +44,7 @@ git archive --format=tar.gz --prefix="punktfunk-${PF_VERSION}/" \
|
||||
# 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
|
||||
# (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)"
|
||||
if [ -n "$CU_SYMS" ]; then
|
||||
STUB_C="$(mktemp --suffix=.c)"
|
||||
@@ -55,7 +60,7 @@ fi
|
||||
# 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
|
||||
# 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 "pf_version ${PF_VERSION}" \
|
||||
--define "pf_release ${PF_RELEASE}" \
|
||||
@@ -63,6 +68,12 @@ rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" \
|
||||
|
||||
mkdir -p dist
|
||||
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} dist/ \;
|
||||
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"
|
||||
# The libcuda leak check applies to the HOST package (the only thing that links the driver); a
|
||||
# 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"
|
||||
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:
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
|
||||
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus. x86_64 only for now: encode
|
||||
# is NVENC (desktop NVIDIA) and no aarch64 build is produced/published by CI — claiming aarch64
|
||||
# here would advertise an arch we never ship. Re-add aarch64 once there's an arm64 build leg.
|
||||
ExclusiveArch: x86_64
|
||||
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus. The HOST is x86_64 only —
|
||||
# its encode stack is NVENC/QSV/AMF — but the CLIENT builds and runs fine on aarch64, so the spec
|
||||
# accepts both arches and `--without host` (below) selects the client-only build.
|
||||
ExclusiveArch: x86_64 aarch64
|
||||
|
||||
# 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
|
||||
@@ -55,6 +55,14 @@ ExclusiveArch: x86_64
|
||||
# Debian punktfunk-scripting .deb.
|
||||
%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 ---------------------------------------------------------
|
||||
BuildRequires: cargo
|
||||
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).
|
||||
# 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.
|
||||
%if %{with host}
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-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
|
||||
# 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
|
||||
# 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.)
|
||||
%if %{with host}
|
||||
cargo build --release --locked -p punktfunk-tray
|
||||
%endif
|
||||
|
||||
%if %{with web}
|
||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||
@@ -243,12 +258,19 @@ fi
|
||||
%endif
|
||||
|
||||
%install
|
||||
%if %{with host}
|
||||
# Binary
|
||||
install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host
|
||||
|
||||
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||
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
|
||||
# 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
|
||||
@@ -288,6 +310,7 @@ for sz in 22x22 48x48; do
|
||||
install -Dm0644 "$png" %{buildroot}%{_datadir}/icons/hicolor/$sz/apps/"$(basename "$png")"
|
||||
done
|
||||
done
|
||||
%endif
|
||||
|
||||
# --- client subpackage ---
|
||||
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 \
|
||||
%{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
|
||||
|
||||
%if %{with host}
|
||||
# Headless session helpers + example config + OpenAPI doc (reference material).
|
||||
install -d %{buildroot}%{_datadir}/%{name}/headless
|
||||
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.
|
||||
install -Dm0644 packaging/linux/punktfunk-web.xml \
|
||||
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-web.xml
|
||||
%endif
|
||||
|
||||
%if %{with 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
|
||||
%endif
|
||||
|
||||
%if %{with host}
|
||||
%files
|
||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||
%doc README.md packaging/README.md
|
||||
%{_bindir}/punktfunk-host
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_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/sysctl.d/99-punktfunk-net.conf
|
||||
%{_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
|
||||
%dir %{_datadir}/%{name}
|
||||
%{_datadir}/%{name}/*
|
||||
%endif
|
||||
|
||||
%files client
|
||||
%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).
|
||||
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf >/dev/null 2>&1 || :
|
||||
|
||||
%if %{with host}
|
||||
%post
|
||||
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
|
||||
udevadm control --reload-rules 2>/dev/null || :
|
||||
@@ -465,6 +496,7 @@ if command -v punktfunk-host >/dev/null 2>&1; then
|
||||
echo "$conflict"
|
||||
fi
|
||||
fi
|
||||
%endif
|
||||
|
||||
%if %{with 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.
|
||||
After=punktfunk-web-init.service punktfunk-host.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]
|
||||
Type=simple
|
||||
|
||||
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.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 we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
|
||||
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
|
||||
rem silently downgrading to plain HTTP.
|
||||
if not exist "%TOKENFILE%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
|
||||
rem
|
||||
rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
|
||||
rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
|
||||
rem before the host finished writing those files (the token lands at argument parse, the cert only
|
||||
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.
|
||||
rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
|
||||
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
|
||||
)
|
||||
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 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 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 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 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"
|
||||
|
||||
@@ -149,6 +149,14 @@ if (process.argv.includes("--list")) {
|
||||
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));
|
||||
let stopping = false;
|
||||
const shutdown = (signal: string) => {
|
||||
@@ -161,3 +169,4 @@ process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
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"
|
||||
|
||||
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 installed launcher / Linux unit) rather than silently serving plain HTTP.
|
||||
if not exist "%TOKENFILE%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
|
||||
rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
|
||||
rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
|
||||
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 running?
|
||||
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 Split on the first '=' and import each into the environment.
|
||||
|
||||
Reference in New Issue
Block a user