Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a811ee49d7 |
@@ -1,4 +1,4 @@
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||
@@ -42,12 +42,11 @@ jobs:
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
|
||||
# the official binary.
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
@@ -106,7 +105,7 @@ jobs:
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
makepkg -f -d --holdver
|
||||
|
||||
+18
-121
@@ -1,18 +1,8 @@
|
||||
# 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
|
||||
# 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-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
|
||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||
#
|
||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
|
||||
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
||||
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
||||
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
||||
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
||||
# target boxes run.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
||||
# Box setup (once): see packaging/debian/README.md
|
||||
@@ -93,15 +83,22 @@ jobs:
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build release clients
|
||||
- name: Build release host + client
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
||||
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
@@ -131,12 +128,10 @@ jobs:
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
|
||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
@@ -171,101 +166,3 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
|
||||
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
|
||||
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
|
||||
# byte-identical so host and clients share a version line.
|
||||
build-publish-host:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Version + channel
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
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 "host package version $V -> apt distribution '$DIST'"
|
||||
|
||||
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
|
||||
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
|
||||
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
|
||||
- name: dpkg-dev + patchelf + python3
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev patchelf 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
|
||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||
# ci.yml's 26.04 target cache (mixing would poison both).
|
||||
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
||||
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
||||
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
||||
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-tray
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
|
||||
# defaults to /opt/ffmpeg (the image's ENV also sets it).
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
- 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 host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the host .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
|
||||
|
||||
@@ -39,12 +39,6 @@ jobs:
|
||||
- image: punktfunk-rust-ci
|
||||
dockerfile: ci/rust-ci.Dockerfile
|
||||
context: ci
|
||||
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
||||
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
context: ci
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
context: ci
|
||||
|
||||
@@ -92,10 +92,9 @@ jobs:
|
||||
echo "rpm $V-$R -> group '$GROUP'"
|
||||
|
||||
- name: Build RPM
|
||||
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
||||
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
||||
# Recommends both). Both need bun (ensured in Prep).
|
||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
|
||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
||||
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
|
||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh
|
||||
|
||||
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
||||
env:
|
||||
@@ -132,8 +131,7 @@ jobs:
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
||||
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
||||
# windows-amd64 runner
|
||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||
#
|
||||
@@ -53,7 +52,6 @@ on:
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
- 'sdk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-host.yml'
|
||||
@@ -145,18 +143,9 @@ jobs:
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
|
||||
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
|
||||
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
|
||||
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
|
||||
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
|
||||
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
|
||||
# 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.
|
||||
run: |
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
@@ -223,24 +212,6 @@ jobs:
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
shell: pwsh
|
||||
# `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
|
||||
# SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
|
||||
# it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
|
||||
# public npm (effect), so no @unom token is needed here.
|
||||
run: |
|
||||
$bun = $env:BUN_EXE
|
||||
Push-Location sdk
|
||||
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
|
||||
New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
|
||||
& $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
|
||||
Pop-Location
|
||||
if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
|
||||
throw "runner bundle missing the dynamic plugin import - wrong build"
|
||||
}
|
||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
|
||||
@@ -43,7 +43,3 @@ CLAUDE.md
|
||||
/result
|
||||
/result-*
|
||||
.direnv/
|
||||
|
||||
# Gradle build output inside the vendored pyrowave Granite Android platform (regenerated, never ours to commit)
|
||||
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/.gradle/
|
||||
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/build/
|
||||
|
||||
Generated
+64
-68
@@ -358,6 +358,28 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
@@ -740,12 +762,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -1012,6 +1028,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
@@ -1266,6 +1288,12 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -1482,16 +1510,6 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.22.6"
|
||||
@@ -1993,13 +2011,9 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2159,7 +2173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2264,7 +2278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2299,7 +2313,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2788,7 +2802,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2808,7 +2822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2832,7 +2846,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.14.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2850,7 +2864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2871,7 +2885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2894,7 +2908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2903,7 +2917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2915,7 +2929,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2929,11 +2943,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.14.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2961,14 +2975,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2983,7 +2997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.14.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3013,7 +3027,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3025,7 +3039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3221,7 +3235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3237,7 +3251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3253,7 +3267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3268,7 +3282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3287,7 +3301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3318,7 +3332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3400,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3414,7 +3428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3437,7 +3451,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -3636,6 +3650,7 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3864,6 +3879,7 @@ version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -3928,6 +3944,7 @@ version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -5252,12 +5269,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
@@ -6070,21 +6081,6 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-330
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
"version": "0.13.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1931,184 +1931,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "List registered plugins",
|
||||
"description": "The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry\nreports its id, title, optional version, and — for plugins that serve one — a UI descriptor\n(loopback port + icon). The console renders these as nav entries and proxies to the port; it\nfetches the secret separately, server-side.",
|
||||
"operationId": "listPlugins",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Live plugin registrations",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginSummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Register or renew a plugin",
|
||||
"description": "Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs\nthis every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console\nwill proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed\n(first registration, restart, or re-scan) — a pure renewal is silent.",
|
||||
"operationId": "registerPlugin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginRegistration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Registered / renewed"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid id or registration",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Deregister a plugin",
|
||||
"description": "The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls\nthis from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was\nremoved. Idempotent — deleting an unknown/expired id is a no-op `204`.",
|
||||
"operationId": "deregisterPlugin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The plugin id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Deregistered (or already absent)"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}/ui-credential": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Fetch a plugin UI's proxy credential",
|
||||
"description": "Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.\nBearer + loopback only (like every mutation), and additionally excluded from the console's browser\npassthrough: the secret never reaches a browser.",
|
||||
"operationId": "getPluginUiCredential",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The plugin id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The proxy credential",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UiCredential"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No live plugin with that id, or it serves no UI",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/session": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
@@ -3472,25 +3294,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"plugins.changed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -4296,116 +4099,6 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
},
|
||||
"ui": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginUi",
|
||||
"description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional plugin version, purely informational (≤32 chars)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginSummary": {
|
||||
"type": "object",
|
||||
"description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"ui": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginUiPublic"
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginUi": {
|
||||
"type": "object",
|
||||
"description": "A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request\nbody, never a response ([`PluginUiPublic`] is the secret-free view).",
|
||||
"required": [
|
||||
"port",
|
||||
"secret"
|
||||
],
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)."
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:<port>`; a registration can never carry a hostname.",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginUiPublic": {
|
||||
"type": "object",
|
||||
"description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.",
|
||||
"required": [
|
||||
"port"
|
||||
],
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PortMap": {
|
||||
"type": "object",
|
||||
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
||||
@@ -5017,24 +4710,6 @@
|
||||
"primary",
|
||||
"exclusive"
|
||||
]
|
||||
},
|
||||
"UiCredential": {
|
||||
"type": "object",
|
||||
"description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser.",
|
||||
"required": [
|
||||
"port",
|
||||
"secret"
|
||||
],
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -5097,10 +4772,6 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
|
||||
},
|
||||
{
|
||||
"name": "plugins",
|
||||
"description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
|
||||
#
|
||||
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
|
||||
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
|
||||
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
|
||||
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
|
||||
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
|
||||
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
|
||||
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
|
||||
# the package no longer depends on the distro's libav* at all. Everything else the host links
|
||||
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
|
||||
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
|
||||
#
|
||||
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
|
||||
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
|
||||
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
|
||||
# come from whatever driver the target runs, out of band.
|
||||
#
|
||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
|
||||
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
|
||||
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
|
||||
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
|
||||
FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
||||
dpkg-dev patchelf \
|
||||
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
||||
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
|
||||
nasm libva-dev libdrm-dev zlib1g-dev \
|
||||
# host link deps present on 24.04 with sonames compatible up to 26.04
|
||||
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
|
||||
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
|
||||
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
|
||||
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
|
||||
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
|
||||
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
|
||||
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
||||
ARG NVHDR_TAG=n12.2.72.0
|
||||
RUN set -eux; \
|
||||
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
||||
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
||||
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
||||
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
||||
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
||||
cd /tmp/ffmpeg; \
|
||||
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
||||
--prefix=/opt/ffmpeg \
|
||||
--enable-shared --disable-static \
|
||||
--disable-doc --disable-programs --disable-debug \
|
||||
--enable-nvenc --enable-vaapi \
|
||||
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
|
||||
make -j"$(nproc)"; make install; \
|
||||
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
|
||||
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
|
||||
test -e /opt/ffmpeg/lib/libavcodec.so.62
|
||||
|
||||
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
|
||||
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
|
||||
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
|
||||
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
|
||||
FFMPEG_PREFIX=/opt/ffmpeg
|
||||
|
||||
# Toolchain shared across CI users (jobs may run as different uids).
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
--component rustfmt,clippy \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo clippy --version && cargo fmt --version
|
||||
@@ -30,7 +30,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -292,7 +292,7 @@ struct ContentView: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { req in
|
||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
|
||||
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||
+ "pair with the 4-digit PIN it can display.")
|
||||
}
|
||||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
||||
@@ -335,7 +335,7 @@ struct ContentView: View {
|
||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||
} message: { req in
|
||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||
+ "console (port 47992 → Pairing). This device connects automatically once you "
|
||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||
|
||||
@@ -210,13 +210,7 @@ final class SessionModel: ObservableObject {
|
||||
// metadata we apply (Step 2) when it IS HDR.
|
||||
let displayHDR: Bool = {
|
||||
#if os(macOS)
|
||||
// POTENTIAL, not current, headroom: `maximumExtendedDynamicRangeColorComponentValue`
|
||||
// is the CURRENTLY-ALLOCATED headroom, which macOS hands out on demand — on an idle
|
||||
// SDR desktop it reads 1.0 even with HDR enabled and active (external HDR displays
|
||||
// like the Samsung G95SC allocate EDR only when content asks). Gating on it means an
|
||||
// HDR monitor never gets advertised at connect time. `maximumPotential…` is the
|
||||
// mode-independent capability (the macOS analogue of the tvOS/iOS gates below).
|
||||
return (NSScreen.main?.maximumPotentialExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
#elseif os(tvOS)
|
||||
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
||||
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||||
@@ -270,11 +264,14 @@ final class SessionModel: ObservableObject {
|
||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
||||
// advertises the bit and prefers it — the host never auto-selects it, and the
|
||||
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
||||
// every M-series Mac and the ATV 4K gen 3 pass). The decoder self-configures from
|
||||
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ — design/pyrowave-444-hdr.md),
|
||||
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1.
|
||||
// every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
|
||||
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
||||
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
||||
| PunktfunkConnection.videoCapHDR
|
||||
| PunktfunkConnection.videoCap444)
|
||||
}
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
@@ -338,7 +335,7 @@ final class SessionModel: ObservableObject {
|
||||
// operator didn't approve it before the host's park window elapsed (or
|
||||
// the host was unreachable).
|
||||
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||
+ "Approve it in the host's web console (port 47992 → Pairing), then "
|
||||
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||
+ "request access again — the request expires after a few minutes."
|
||||
} else {
|
||||
self.errorMessage = pin != nil
|
||||
|
||||
@@ -72,14 +72,6 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
||||
// Toggle the window's fullscreen. ⌃⌘F is the macOS-standard fullscreen combo; here it's
|
||||
// explicit so it's discoverable AND survives capture — while streaming the stream view
|
||||
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
|
||||
// notification the key window's FullscreenController observes.
|
||||
Button("Toggle Fullscreen") {
|
||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: [.control, .command])
|
||||
#endif
|
||||
Divider()
|
||||
Button("Disconnect") { session?.disconnect() }
|
||||
|
||||
@@ -50,14 +50,14 @@ enum SettingsOptions {
|
||||
return options
|
||||
}
|
||||
|
||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS and
|
||||
/// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
|
||||
/// this so an untouched picker shows what actually runs.
|
||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
||||
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
||||
/// an untouched picker shows what actually runs.
|
||||
static var presenterDefault: String {
|
||||
#if os(macOS)
|
||||
"stage2"
|
||||
#else
|
||||
#if os(tvOS)
|
||||
"stage3"
|
||||
#else
|
||||
"stage2"
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
renderScaleRow
|
||||
bitrateRows
|
||||
#endif
|
||||
} header: {
|
||||
@@ -55,50 +54,6 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
|
||||
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
||||
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
||||
@ViewBuilder var renderScaleRow: some View {
|
||||
Picker("Render scale", selection: $renderScale) {
|
||||
ForEach(RenderScale.presets, id: \.self) { scale in
|
||||
Text(RenderScale.label(scale)).tag(scale)
|
||||
}
|
||||
}
|
||||
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
|
||||
// (match-window derives the base from the live window, not these fields).
|
||||
if renderScale != 1.0, !matchWindow {
|
||||
let mode = RenderScale.apply(
|
||||
baseWidth: width, baseHeight: height,
|
||||
scale: renderScale,
|
||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream
|
||||
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||
@ViewBuilder var inputSection: some View {
|
||||
Section {
|
||||
Picker("Modifier keys", selection: $modifierLayout) {
|
||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||
Text(layout.label).tag(layout.rawValue)
|
||||
}
|
||||
}
|
||||
Toggle("Invert scroll direction", isOn: $invertScroll)
|
||||
} header: {
|
||||
Text("Keyboard & mouse")
|
||||
} footer: {
|
||||
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
|
||||
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
@@ -412,28 +367,27 @@ extension SettingsView {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) vs stage-3 (the same
|
||||
// pipeline with glass-gated present pacing). The default is per-platform — glass on iOS/tvOS,
|
||||
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
|
||||
// the queue-saturation rationale) — so the "(default)" marker rides presenterDefault instead
|
||||
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
|
||||
// DEBUG-only diagnostic — it freezes hard on a lost HEVC reference.
|
||||
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
||||
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
||||
// freezes hard on a lost HEVC reference.
|
||||
@ViewBuilder var presenterSection: some View {
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
ForEach(SettingsOptions.presenters, id: \.tag) { option in
|
||||
Text(option.tag == SettingsOptions.presenterDefault
|
||||
? "\(option.label) (default)" : option.label)
|
||||
.tag(option.tag)
|
||||
}
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 3 (experimental)").tag("stage3")
|
||||
#if DEBUG
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
#endif
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
|
||||
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
||||
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
|
||||
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
||||
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||
+ "display time to compare. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
|
||||
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
||||
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
@@ -26,10 +25,6 @@ struct SettingsView: View {
|
||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
|
||||
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
|
||||
// link). 1.0 = Native (the prior behaviour).
|
||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@@ -56,12 +51,6 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
#if !os(tvOS)
|
||||
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
|
||||
// both wheel axes; modifier-layout relocates the ⌥/⌘ → Alt/Super roles by physical position.
|
||||
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
|
||||
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
@@ -123,7 +112,6 @@ struct SettingsView: View {
|
||||
TabView {
|
||||
Form {
|
||||
streamModeSection
|
||||
inputSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
}
|
||||
@@ -254,7 +242,6 @@ struct SettingsView: View {
|
||||
Form {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
inputSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
@@ -347,10 +334,6 @@ struct SettingsView: View {
|
||||
return ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||
TVSelectionRow(
|
||||
title: "Render scale",
|
||||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||
selection: $renderScale)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 47992 →
|
||||
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000 →
|
||||
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
|
||||
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
||||
// attacker exactly one online guess — for the user a typo just means "try again" (the
|
||||
@@ -45,7 +45,7 @@ struct PairSheet: View {
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 24) {
|
||||
Text("The PIN is shown in the host's web console "
|
||||
+ "(https://<host>:47992 → Pairing). "
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||
@@ -118,7 +118,7 @@ struct PairSheet: View {
|
||||
.foregroundStyle(.tint)
|
||||
} footer: {
|
||||
Text("The PIN is shown in the host's web console "
|
||||
+ "(https://<host>:47992 → Pairing). "
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
@@ -210,7 +210,7 @@ struct PairSheet: View {
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's web console (port 47992) "
|
||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||
+ "and try again."
|
||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||
// The host answered and said why (not armed / rate-limited / armed for
|
||||
|
||||
@@ -29,11 +29,6 @@ public final class ClipboardSync: NSObject {
|
||||
("text/rtf", .rtf),
|
||||
("text/html", .html),
|
||||
("image/png", .png),
|
||||
// Original image formats pass through VERBATIM beside the PNG floor — a copied JPEG
|
||||
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
|
||||
// kind it can place.
|
||||
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
|
||||
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
|
||||
]
|
||||
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
||||
/// with these — see nspasteboard.org).
|
||||
@@ -210,14 +205,11 @@ public final class ClipboardSync: NSObject {
|
||||
var kinds = Self.wireToPasteboard
|
||||
.filter { types.contains($0.type) }
|
||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
||||
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
|
||||
// — native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
|
||||
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
|
||||
// fallback costs nothing unless a peer actually pastes it.
|
||||
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots
|
||||
// TIFF) and only sometimes PNG — announce the portable `image/png` whenever ANY
|
||||
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5).
|
||||
if !kinds.contains(where: { $0.mime == "image/png" }),
|
||||
types.contains(.tiff)
|
||||
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|
||||
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
|
||||
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic"))
|
||||
{
|
||||
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
|
||||
}
|
||||
@@ -393,8 +385,6 @@ private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider
|
||||
case .rtf: return "text/rtf"
|
||||
case .html: return "text/html"
|
||||
case .png: return "image/png"
|
||||
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
|
||||
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ import UIKit
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import os
|
||||
|
||||
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
|
||||
@@ -126,12 +125,6 @@ public final class InputCapture {
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||
/// menu handles it. Main queue.
|
||||
public var onToggleFullscreen: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
@@ -280,14 +273,6 @@ public final class InputCapture {
|
||||
break
|
||||
}
|
||||
}
|
||||
// ⌃⌘F toggles the streaming window's fullscreen. Intercepted only while forwarding (the
|
||||
// captured stream view swallows the menu's identical equivalent); the F is latched so its
|
||||
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
|
||||
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
|
||||
self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC
|
||||
self.onToggleFullscreen?()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
#endif
|
||||
@@ -333,7 +318,7 @@ public final class InputCapture {
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
for vk in pressedVKs {
|
||||
emitKey(vk, down: false)
|
||||
connection.send(.key(vk, down: false))
|
||||
}
|
||||
for button in pressedButtons {
|
||||
connection.send(.mouseButton(button, down: false))
|
||||
@@ -346,15 +331,6 @@ public final class InputCapture {
|
||||
residualScrollY = 0
|
||||
}
|
||||
|
||||
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
|
||||
/// active location-based modifier layout is applied in exactly one place while all internal
|
||||
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
|
||||
/// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next
|
||||
/// key without re-arming capture. Non-modifier VKs pass through untouched.
|
||||
private func emitKey(_ vk: UInt32, down: Bool) {
|
||||
connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down))
|
||||
}
|
||||
|
||||
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
||||
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
|
||||
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
|
||||
@@ -423,7 +399,7 @@ public final class InputCapture {
|
||||
inputLog.debug(
|
||||
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
|
||||
}
|
||||
emitKey(vk, down: down)
|
||||
connection.send(.key(vk, down: down))
|
||||
}
|
||||
|
||||
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
||||
@@ -590,15 +566,8 @@ public final class InputCapture {
|
||||
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery
|
||||
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
|
||||
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
|
||||
public func sendScroll(dx rawDx: Float, dy rawDy: Float) {
|
||||
public func sendScroll(dx: Float, dy: Float) {
|
||||
guard forwarding else { return }
|
||||
// Optionally invert both axes (read live). This is the ONE scroll sink for every platform —
|
||||
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here — so the toggle
|
||||
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
|
||||
// between events doesn't strand a fractional remainder of the old sign.
|
||||
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
|
||||
let dx = invert ? -rawDx : rawDx
|
||||
let dy = invert ? -rawDy : rawDy
|
||||
let fy = dy + residualScrollY
|
||||
let fx = dx + residualScrollX
|
||||
let iy = fy.rounded(.towardZero)
|
||||
@@ -674,7 +643,7 @@ public final class InputCapture {
|
||||
} else {
|
||||
self.pressedVKs.remove(vk)
|
||||
}
|
||||
self.emitKey(vk, down: pressed)
|
||||
self.connection.send(.key(vk, down: pressed))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all
|
||||
// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path).
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
extension InputCapture {
|
||||
/// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on
|
||||
/// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs
|
||||
/// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows
|
||||
/// user's `⌘ = Alt, ⌥ = Win` muscle memory lands correctly:
|
||||
/// L Command 0x5B ↔ L Alt 0xA4 · R Command 0x5C ↔ R Alt 0xA5.
|
||||
/// It's applied ONLY at the send boundary (`InputCapture.emitKey`) — all press/release
|
||||
/// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local
|
||||
/// ⌘-based shortcuts are untouched. The swap is its own inverse: a key that went down remapped
|
||||
/// goes up remapped, so the host never sees a stuck modifier.
|
||||
static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 {
|
||||
guard layout == .windows else { return vk }
|
||||
switch vk {
|
||||
case 0x5B: return 0xA4 // L Command → L Alt (VK_LWIN → VK_LMENU)
|
||||
case 0x5C: return 0xA5 // R Command → R Alt (VK_RWIN → VK_RMENU)
|
||||
case 0xA4: return 0x5B // L Option → L Win (VK_LMENU → VK_LWIN)
|
||||
case 0xA5: return 0x5C // R Option → R Win (VK_RMENU → VK_RWIN)
|
||||
default: return vk
|
||||
}
|
||||
}
|
||||
/// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted
|
||||
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
||||
static let hidToVK: [Int: UInt32] = {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
// from looping request → rollback → request.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||
@@ -52,11 +51,6 @@ public final class MatchWindowFollower {
|
||||
private weak var connection: PunktfunkConnection?
|
||||
private let debounce: TimeInterval
|
||||
private let minSpacing: TimeInterval
|
||||
/// Render-scale multiplier applied to the live window size before it's requested, so the
|
||||
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
|
||||
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
|
||||
private let renderScale: Double
|
||||
private let maxDimension: Int
|
||||
private var enabled: Bool
|
||||
|
||||
private var work: DispatchWorkItem?
|
||||
@@ -80,28 +74,15 @@ public final class MatchWindowFollower {
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
enabled: Bool,
|
||||
renderScale: Double = 1.0,
|
||||
maxDimension: Int = 8192,
|
||||
debounce: TimeInterval = 0.4,
|
||||
minSpacing: TimeInterval = 1.0
|
||||
) {
|
||||
self.connection = connection
|
||||
self.enabled = enabled
|
||||
self.renderScale = RenderScale.sanitize(renderScale)
|
||||
self.maxDimension = maxDimension
|
||||
self.debounce = debounce
|
||||
self.minSpacing = minSpacing
|
||||
}
|
||||
|
||||
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
|
||||
/// aspect-preserved, even, and clamped to the codec ceiling — the match-window twin of
|
||||
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
|
||||
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||
RenderScale.apply(
|
||||
baseWidth: widthPx, baseHeight: heightPx,
|
||||
scale: renderScale, maxDimension: maxDimension)
|
||||
}
|
||||
|
||||
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||
public func setEnabled(_ on: Bool) {
|
||||
enabled = on
|
||||
@@ -128,7 +109,7 @@ public final class MatchWindowFollower {
|
||||
/// mode yet → nothing to compare against, skip.
|
||||
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||
guard let connection else { return }
|
||||
let target = targetMode(widthPx: widthPx, heightPx: heightPx)
|
||||
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0 else { return }
|
||||
if target.width == mode.width, target.height == mode.height {
|
||||
@@ -156,7 +137,7 @@ public final class MatchWindowFollower {
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
let target = targetMode(widthPx: size.width, heightPx: size.height)
|
||||
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
||||
let mode = connection.currentMode()
|
||||
pendingSize = nil
|
||||
guard let req = MatchWindow.request(
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import CoreGraphics
|
||||
import CoreVideo
|
||||
#if os(macOS)
|
||||
import IOSurface
|
||||
#endif
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
@@ -196,11 +193,13 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
// display-referred SDR. (When the display IS in an HDR mode — requested per session via
|
||||
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
|
||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
||||
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
||||
static inline float3 pqToSdr(float3 pq) {
|
||||
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr).
|
||||
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
|
||||
// ST 2084 EOTF: PQ code value → linear light, 1.0 = 10,000 nits.
|
||||
const float m1 = 2610.0/16384.0;
|
||||
const float m2 = 78.84375;
|
||||
const float c1 = 3424.0/4096.0;
|
||||
@@ -208,49 +207,20 @@ static inline float3 pqToSdr(float3 pq) {
|
||||
const float c3 = 18.6875;
|
||||
float3 p = pow(pq, 1.0/m2);
|
||||
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
||||
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
|
||||
float3 t = lin * (10000.0/203.0);
|
||||
// BT.2020 → BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
|
||||
float3 t709 = float3(
|
||||
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
||||
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
||||
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
||||
t709 = max(t709, 0.0);
|
||||
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
|
||||
const float w = 1000.0/203.0;
|
||||
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
||||
// BT.709 OETF — the same encoding the SDR stream arrives in, so both paths present alike.
|
||||
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
||||
return e;
|
||||
}
|
||||
|
||||
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr), then the tail.
|
||||
return float4(pqToSdr(sampleRgb(lumaTex, chromaTex, in.uv, csc)), 1.0);
|
||||
}
|
||||
|
||||
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
||||
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
||||
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> cbTex [[texture(1)]],
|
||||
texture2d<float> crTex [[texture(2)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
#ifdef PF_BILINEAR_LUMA
|
||||
float lumaY = lumaTex.sample(s, in.uv).r;
|
||||
#else
|
||||
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
|
||||
#endif
|
||||
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
|
||||
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
|
||||
float3 pq = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||
return float4(pqToSdr(pq), 1.0);
|
||||
return float4(e, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -258,51 +228,6 @@ public final class MetalVideoPresenter {
|
||||
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
||||
public let layer: CAMetalLayer
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||
///
|
||||
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||
public let surfaceLayer: CALayer = {
|
||||
let l = CALayer()
|
||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||
l.isOpaque = true
|
||||
l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()]
|
||||
return l
|
||||
}()
|
||||
|
||||
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||
private struct SurfaceSlot {
|
||||
let surface: IOSurfaceRef
|
||||
let texture: MTLTexture
|
||||
/// Monotonic use stamp — the reuse picker takes the least-recently-rendered free slot.
|
||||
var seq: UInt64 = 0
|
||||
}
|
||||
|
||||
private var surfacePool: [SurfaceSlot] = []
|
||||
private var surfacePoolSize: CGSize = .zero
|
||||
private var surfaceSeq: UInt64 = 0
|
||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||
private var lastHandedOff: Int?
|
||||
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||
private var surfacePresentsStaged = false
|
||||
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
|
||||
private let device: MTLDevice
|
||||
private let queue: MTLCommandQueue
|
||||
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||
@@ -314,11 +239,6 @@ public final class MetalVideoPresenter {
|
||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
||||
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
|
||||
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
||||
@@ -369,8 +289,6 @@ public final class MetalVideoPresenter {
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
let pipelinePlanar: MTLRenderPipelineState
|
||||
let pipelinePlanarHDR: MTLRenderPipelineState
|
||||
let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||
do {
|
||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||
@@ -408,18 +326,8 @@ public final class MetalVideoPresenter {
|
||||
let planar = MTLRenderPipelineDescriptor()
|
||||
planar.vertexFunction = vtx
|
||||
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
|
||||
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
||||
let planarHdr = MTLRenderPipelineDescriptor()
|
||||
planarHdr.vertexFunction = vtx
|
||||
planarHdr.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||
planarHdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
|
||||
pipelinePlanarHDR = try device.makeRenderPipelineState(descriptor: planarHdr)
|
||||
let planarTm = MTLRenderPipelineDescriptor()
|
||||
planarTm.vertexFunction = vtx
|
||||
planarTm.fragmentFunction = library.makeFunction(name: "pf_frag_planar_tm")
|
||||
planarTm.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||
pipelinePlanarToneMap = try device.makeRenderPipelineState(descriptor: planarTm)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -460,7 +368,6 @@ public final class MetalVideoPresenter {
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||
pipelinePlanarHDR: pipelinePlanarHDR, pipelinePlanarToneMap: pipelinePlanarToneMap,
|
||||
textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
@@ -468,8 +375,6 @@ public final class MetalVideoPresenter {
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||
pipelinePlanar: MTLRenderPipelineState,
|
||||
pipelinePlanarHDR: MTLRenderPipelineState,
|
||||
pipelinePlanarToneMap: MTLRenderPipelineState,
|
||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
@@ -478,8 +383,6 @@ public final class MetalVideoPresenter {
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||
self.pipelinePlanar = pipelinePlanar
|
||||
self.pipelinePlanarHDR = pipelinePlanarHDR
|
||||
self.pipelinePlanarToneMap = pipelinePlanarToneMap
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
@@ -590,18 +493,6 @@ public final class MetalVideoPresenter {
|
||||
stagingLock.unlock()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
stagingLock.lock()
|
||||
surfacePresentsStaged = on
|
||||
stagingLock.unlock()
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
||||
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
||||
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||
@@ -697,51 +588,12 @@ public final class MetalVideoPresenter {
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
#if os(macOS)
|
||||
let surfaceMode = surfacePresentsStaged
|
||||
#endif
|
||||
stagingLock.unlock()
|
||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
||||
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
||||
#if os(macOS)
|
||||
configure(hdr: planes.pq && !surfaceMode)
|
||||
#else
|
||||
configure(hdr: planes.pq)
|
||||
#endif
|
||||
configure(hdr: false)
|
||||
var csc = planes.csc
|
||||
#if os(macOS)
|
||||
if surfaceMode != surfacePresentsActive {
|
||||
surfacePresentsActive = surfaceMode
|
||||
presenterLog.info(
|
||||
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||
if !surfaceMode {
|
||||
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||
// and re-entering windowed mode rebuilds it in one frame.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
}
|
||||
if surfaceMode {
|
||||
return renderPlanarToSurface(
|
||||
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||
}
|
||||
#endif
|
||||
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||
#if os(tvOS)
|
||||
let planarPassthrough = hdrActive && hdrPassthroughActive
|
||||
#else
|
||||
let planarPassthrough = hdrActive
|
||||
#endif
|
||||
let planarPipeline: MTLRenderPipelineState =
|
||||
planes.pq
|
||||
? (planarPassthrough ? pipelinePlanarHDR : pipelinePlanarToneMap)
|
||||
: pipelinePlanar
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||
@@ -754,118 +606,6 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||
private func renderPlanarToSurface(
|
||||
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||
onPresented: ((Int64?) -> Void)?
|
||||
) -> Bool {
|
||||
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
ensureSurfacePool(size: targetSize)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
onPresented?(
|
||||
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||
private func ensureSurfacePool(size: CGSize) {
|
||||
guard size != surfacePoolSize else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: 4,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
|
||||
@@ -47,9 +47,6 @@ struct WaveletLayout {
|
||||
|
||||
let width: Int
|
||||
let height: Int
|
||||
/// Full-res chroma (4:4:4): chroma components get the full band set including level 0,
|
||||
/// exactly like luma — upstream `init_block_meta` with `Chroma444`.
|
||||
let chroma444: Bool
|
||||
let alignedWidth: Int
|
||||
let alignedHeight: Int
|
||||
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||
@@ -62,10 +59,9 @@ struct WaveletLayout {
|
||||
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
||||
|
||||
init(width: Int, height: Int, chroma444: Bool) {
|
||||
init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.chroma444 = chroma444
|
||||
let align = { (v: Int) in
|
||||
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||
}
|
||||
@@ -82,7 +78,7 @@ struct WaveletLayout {
|
||||
let ah = alignedHeight
|
||||
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 && !chroma444 { continue } // 4:2:0: no top-level chroma
|
||||
if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
|
||||
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let levelW = (aw / 2) >> level
|
||||
let levelH = (ah / 2) >> level
|
||||
@@ -112,9 +108,6 @@ struct ParsedWaveletFrame {
|
||||
var decodedBlocks: Int
|
||||
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||
var bt2020: Bool
|
||||
/// PQ transfer ⇒ HDR session: 16-bit studio-code planes + EDR present (the host stamps
|
||||
/// this bit iff the session negotiated 10-bit — the depth is coupled to the transfer).
|
||||
var pq: Bool
|
||||
var fullRange: Bool
|
||||
|
||||
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
||||
@@ -138,12 +131,6 @@ enum WaveletBitstream {
|
||||
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||
var state = ParseState()
|
||||
// Reserve the coefficient buffer ONCE, up front. Every packet's payload is a slice of the
|
||||
// AU, so `au.count / 4` words is a tight upper bound — reserving it here lets the per-packet
|
||||
// appends stay amortized O(1). (Reserving per packet forces Swift to allocate the exact new
|
||||
// size each time, turning the walk O(n²) — invisible on the tiny golden fixtures, but ~5 ms
|
||||
// per 1.4 MB frame on a real 5120x1440 stream.)
|
||||
state.payload.reserveCapacity(au.count / 4)
|
||||
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return false
|
||||
@@ -216,7 +203,6 @@ enum WaveletBitstream {
|
||||
var totalBlocks = 0
|
||||
var decodedBlocks = 0
|
||||
var bt2020 = false
|
||||
var pq = false
|
||||
var fullRange = false
|
||||
var sawSOF = false
|
||||
|
||||
@@ -234,27 +220,22 @@ enum WaveletBitstream {
|
||||
// siting[31].
|
||||
let code = (word1 >> 24) & 0x3
|
||||
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
||||
let chroma444 = (word1 >> 26) & 1 != 0
|
||||
let chromaRes = (word1 >> 26) & 1
|
||||
guard chromaRes == 0 else { return false } // host contract: 4:2:0
|
||||
let w = Int(word0 & 0x3fff) + 1
|
||||
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||
guard w >= 2, h >= 2, chroma444 || (w % 2 == 0 && h % 2 == 0) else {
|
||||
return false
|
||||
}
|
||||
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
||||
if sawSOF {
|
||||
// One frame, one geometry — a second SOF must agree.
|
||||
guard layout?.width == w, layout?.height == h,
|
||||
layout?.chroma444 == chroma444
|
||||
else { return false }
|
||||
guard layout?.width == w, layout?.height == h else { return false }
|
||||
} else {
|
||||
sawSOF = true
|
||||
let l = WaveletLayout(width: w, height: h, chroma444: chroma444)
|
||||
let l = WaveletLayout(width: w, height: h)
|
||||
layout = l
|
||||
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||
payload.reserveCapacity(64 * 1024 / 4)
|
||||
totalBlocks = Int(word1 & 0xff_ffff)
|
||||
bt2020 = (word1 >> 29) & 1 != 0
|
||||
// transfer_function bit: PQ ⇒ an HDR session (16-bit studio-code
|
||||
// planes by the negotiated coupling — design/pyrowave-444-hdr.md).
|
||||
pq = (word1 >> 28) & 1 != 0
|
||||
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||
}
|
||||
pos += 8
|
||||
@@ -271,15 +252,9 @@ enum WaveletBitstream {
|
||||
if offsets[blockIndex] == .max {
|
||||
offsets[blockIndex] = UInt32(payload.count)
|
||||
decodedBlocks += 1
|
||||
// Bulk-copy the packet's coefficient words in one memcpy rather than
|
||||
// word-by-word. All Apple platforms are little-endian, so the wire's LE
|
||||
// u32s land in the [UInt32] buffer verbatim; memcpy has no alignment
|
||||
// requirement, so a non-word-aligned `base + pos` is fine. `reserveCapacity`
|
||||
// up in `parse` keeps the grow amortized O(1).
|
||||
let dstWord = payload.count
|
||||
payload.append(contentsOf: repeatElement(0, count: payloadWords))
|
||||
payload.withUnsafeMutableBytes { dst in
|
||||
_ = memcpy(dst.baseAddress! + dstWord * 4, base + pos, payloadWords * 4)
|
||||
payload.reserveCapacity(payload.count + payloadWords)
|
||||
for w in 0..<payloadWords {
|
||||
payload.append(loadWord(base, pos + w * 4))
|
||||
}
|
||||
}
|
||||
} else if layout != nil {
|
||||
@@ -305,7 +280,7 @@ enum WaveletBitstream {
|
||||
return ParsedWaveletFrame(
|
||||
layout: layout, offsets: offsets, payload: payload,
|
||||
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
||||
bt2020: bt2020, pq: pq, fullRange: fullRange)
|
||||
bt2020: bt2020, fullRange: fullRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,8 +293,6 @@ public struct WaveletPlanes: @unchecked Sendable {
|
||||
public let cb: MTLTexture
|
||||
public let cr: MTLTexture
|
||||
public let csc: CscUniform
|
||||
/// PQ (HDR) stream: the presenter picks the HDR/tone-map planar pipeline + EDR config.
|
||||
public let pq: Bool
|
||||
public var width: Int { y.width }
|
||||
public var height: Int { y.height }
|
||||
}
|
||||
@@ -378,8 +351,6 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
private var slots: [Slot] = []
|
||||
private var nextSlot = 0
|
||||
/// The ring's plane format facts (from the last SOF): PQ ⇒ 16-bit UNORM planes.
|
||||
private var hdr16 = false
|
||||
|
||||
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
||||
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
||||
@@ -438,9 +409,8 @@ public final class MetalWaveletDecoder {
|
||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||
else { return false }
|
||||
|
||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height
|
||||
|| layout?.chroma444 != frame.layout.chroma444 || hdr16 != frame.pq {
|
||||
guard rebuild(layout: frame.layout, hdr16: frame.pq) else { return false }
|
||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
||||
guard rebuild(layout: frame.layout) else { return false }
|
||||
}
|
||||
guard let layout, !slots.isEmpty else { return false }
|
||||
|
||||
@@ -480,7 +450,7 @@ public final class MetalWaveletDecoder {
|
||||
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||
for level in 0..<WaveletLayout.decompositionLevels {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 && !layout.chroma444 { continue } // 4:2:0
|
||||
if level == 0 && component != 0 { continue } // 4:2:0
|
||||
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let meta = layout.blockMeta[component][level][band]
|
||||
let w = layout.levelWidth(level)
|
||||
@@ -519,20 +489,15 @@ public final class MetalWaveletDecoder {
|
||||
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||
if inputLevel == 0 {
|
||||
// Final full-res pass: luma only in 4:2:0 (chroma finished at level 1); all
|
||||
// three components in 4:4:4 (chroma runs the full pyramid like luma).
|
||||
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
let components = layout.chroma444 ? 3 : 1
|
||||
for component in 0..<components {
|
||||
idwt.setTexture(coefficients[component][0], index: 0)
|
||||
let out = component == 0 ? slot.y : (component == 1 ? slot.cb : slot.cr)
|
||||
idwt.setTexture(out, index: 1)
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
}
|
||||
idwt.setTexture(coefficients[0][0], index: 0)
|
||||
idwt.setTexture(slot.y, index: 1)
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
} else {
|
||||
for component in 0..<3 {
|
||||
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
||||
if component != 0 && inputLevel == 1 && !layout.chroma444 {
|
||||
if component != 0 && inputLevel == 1 {
|
||||
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||
@@ -548,9 +513,7 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
let planes = WaveletPlanes(
|
||||
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||
csc: CscRows.rows(
|
||||
frame.cscSignal, depth: frame.pq ? 10 : 8, msbPacked: frame.pq),
|
||||
pq: frame.pq)
|
||||
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
||||
cmd.addCompletedHandler { buffer in
|
||||
completion(buffer.error == nil ? planes : nil)
|
||||
}
|
||||
@@ -561,9 +524,9 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
||||
/// resize path: a Reconfigure shows up here as new SOF dims.
|
||||
private func rebuild(layout newLayout: WaveletLayout, hdr16 newHdr16: Bool) -> Bool {
|
||||
private func rebuild(layout newLayout: WaveletLayout) -> Bool {
|
||||
waveletLog.info(
|
||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks, \(newLayout.chroma444 ? "4:4:4" : "4:2:0", privacy: .public)\(newHdr16 ? " HDR16" : "", privacy: .public))")
|
||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
|
||||
var coeff: [[MTLTexture]] = []
|
||||
var lls: [[MTLTexture]] = []
|
||||
for component in 0..<3 {
|
||||
@@ -597,22 +560,19 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
var newSlots: [Slot] = []
|
||||
for i in 0..<Self.ringDepth {
|
||||
let planeFormat: MTLPixelFormat = newHdr16 ? .r16Unorm : .r8Unorm
|
||||
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: planeFormat, width: w, height: h, mipmapped: false)
|
||||
pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.shaderRead, .shaderWrite]
|
||||
desc.storageMode = .private
|
||||
let t = self.device.makeTexture(descriptor: desc)
|
||||
t?.label = name
|
||||
return t
|
||||
}
|
||||
let cw = newLayout.chroma444 ? newLayout.width : newLayout.width / 2
|
||||
let ch = newLayout.chroma444 ? newLayout.height : newLayout.height / 2
|
||||
guard
|
||||
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||
let cb = plane(cw, ch, "pyrowave Cb[\(i)]"),
|
||||
let cr = plane(cw, ch, "pyrowave Cr[\(i)]"),
|
||||
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
||||
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
||||
let offsets = device.makeBuffer(
|
||||
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||
@@ -625,7 +585,6 @@ public final class MetalWaveletDecoder {
|
||||
slots = newSlots
|
||||
nextSlot = 0
|
||||
layout = newLayout
|
||||
hdr16 = newHdr16
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
|
||||
// (explicit VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's
|
||||
// CADisplayLink) is the default — glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
|
||||
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump → AVSampleBufferDisplayLayer)
|
||||
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits — capture,
|
||||
// window/scale tracking, and constructing the display link — and delegate the shared presenter
|
||||
// lifecycle here.
|
||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit
|
||||
// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the
|
||||
// default; stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG
|
||||
// fallback. The views own the platform bits — capture, window/scale tracking, and constructing the
|
||||
// display link — and delegate the shared presenter lifecycle here.
|
||||
//
|
||||
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
||||
|
||||
@@ -44,35 +42,22 @@ enum PresenterChoice: Equatable {
|
||||
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||
/// freeze-prone fallback.
|
||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||
explicit(setting: setting, env: env, allowStage1: allowStage1) ?? platformDefault
|
||||
}
|
||||
|
||||
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
||||
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
|
||||
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
|
||||
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
|
||||
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : nil
|
||||
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||
case "stage2": return .stage2
|
||||
case "stage3": return .stage3
|
||||
default: return nil
|
||||
default: return platformDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
|
||||
/// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
|
||||
/// near the stream rate — an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
|
||||
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
|
||||
/// makes the COMMON case — arrival pacing pins the queue at ~`maximumDrawableCount` and every
|
||||
/// frame rides ~2–3 refreshes of it (measured: ~50 ms on Apple TV; 23–30 ms at 120 Hz on
|
||||
/// ProMotion iPads — the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
|
||||
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
|
||||
/// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
|
||||
/// so arrival is genuinely lowest-latency there.
|
||||
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
||||
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
||||
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||
static var platformDefault: PresenterChoice {
|
||||
#if os(tvOS) || os(iOS)
|
||||
#if os(tvOS)
|
||||
.stage3
|
||||
#else
|
||||
.stage2
|
||||
@@ -81,69 +66,10 @@ enum PresenterChoice: Equatable {
|
||||
}
|
||||
|
||||
final class SessionPresenter {
|
||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||
/// of stage-2 defaults there predate any panic report.
|
||||
static func pacing(
|
||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||
) -> PresentPacing {
|
||||
if choice == .stage3 { return .glass }
|
||||
#if os(macOS)
|
||||
if explicit == nil, codec == .pyrowave { return .glass }
|
||||
#endif
|
||||
return .arrival
|
||||
}
|
||||
|
||||
/// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
|
||||
///
|
||||
/// - iOS/iPadOS: 2 — one flip scanning out plus one queued for the next latch. A decoded
|
||||
/// frame presents immediately (the gate is open in steady state) and latches the very next
|
||||
/// vsync, so the present cadence never serializes on the on-glass callback's own latency —
|
||||
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
|
||||
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
|
||||
/// FIFO saturation (23–30 ms) stays gone.
|
||||
/// - tvOS: 1 — the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
|
||||
/// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
|
||||
/// - macOS: pinned to 1, env ignored — glass pacing exists there as the DCP swapID
|
||||
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
|
||||
///
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) overrides on iOS/tvOS for on-device A/B, mirroring the other
|
||||
/// presenter env levers. Internal (not private) for unit tests.
|
||||
static func gateDepth(env: String?) -> Int {
|
||||
#if os(macOS)
|
||||
return 1
|
||||
#else
|
||||
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
||||
#if os(tvOS)
|
||||
return 1
|
||||
#else
|
||||
return 2
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private var pump: StreamPump?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||
private var surfaceLayer: CALayer?
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||
@@ -175,43 +101,31 @@ final class SessionPresenter {
|
||||
stop()
|
||||
self.connection = connection
|
||||
|
||||
// Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
|
||||
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
|
||||
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
|
||||
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
|
||||
// on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B.
|
||||
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
|
||||
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
||||
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
let explicit = PresenterChoice.explicit(
|
||||
let choice = PresenterChoice.resolve(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||
allowStage1: allowStage1)
|
||||
let choice = explicit ?? PresenterChoice.platformDefault
|
||||
if choice != .stage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
pacing: Self.pacing(
|
||||
for: choice, explicit: explicit, codec: connection.videoCodec),
|
||||
gateDepth: Self.gateDepth(
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
|
||||
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
baseLayer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
#if os(macOS)
|
||||
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||
// contents) while the metal path presents, covering it while surface presents run.
|
||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||
surfaceLayer = pipeline.surfaceLayer
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
stage2 = pipeline
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||
@@ -310,12 +224,6 @@ final class SessionPresenter {
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = contentsScale
|
||||
metalLayer.frame = snapped
|
||||
#if os(macOS)
|
||||
// The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces
|
||||
// are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too.
|
||||
surfaceLayer?.contentsScale = contentsScale
|
||||
surfaceLayer?.frame = snapped
|
||||
#endif
|
||||
CATransaction.commit()
|
||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||
@@ -343,31 +251,6 @@ final class SessionPresenter {
|
||||
contentSize = size
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||
func setComposited(_ composited: Bool) {
|
||||
guard let stage2, let connection else { return }
|
||||
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||
guard wantsSurface != surfacePresentsActive else { return }
|
||||
surfacePresentsActive = wantsSurface
|
||||
stage2.setSurfacePresents(wantsSurface)
|
||||
if !wantsSurface {
|
||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||
// entry shows the previous frame until the next present — no black flash).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer?.contents = nil
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||
/// Idempotent.
|
||||
@@ -381,11 +264,6 @@ final class SessionPresenter {
|
||||
stage2 = nil
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
#if os(macOS)
|
||||
surfaceLayer?.removeFromSuperlayer()
|
||||
surfaceLayer = nil
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
// schedule can never sit far in the future holding drawables hostage.
|
||||
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents to a bounded number of undisplayed
|
||||
// drawables (the gate depth — see PresentPacing + SessionPresenter.gateDepth) so the layer's
|
||||
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||
//
|
||||
@@ -108,84 +107,63 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||
/// half, same newest-wins ring; only the present cadence differs.
|
||||
///
|
||||
/// - `arrival` (stage-2, the macOS default): present the moment a frame is decoded. Lowest latency
|
||||
/// while the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable
|
||||
/// per refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
|
||||
/// undisplayed drawables in flight (`PresentGate`; the depth — 1 or 2 — is per-platform, see
|
||||
/// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
|
||||
/// drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||
/// explicit, correct frame drops.
|
||||
///
|
||||
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
|
||||
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
||||
/// "mismatched swapID's" KERNEL PANIC, and the one-in-flight gate removes that pattern — see
|
||||
/// `SessionPresenter.pacing` for the full rationale.
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
case glass
|
||||
}
|
||||
|
||||
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
|
||||
/// The render thread `tryAcquire`s before taking a frame; the drawable's presented handler
|
||||
/// `release`s and re-signals the render thread. Depth 1 fully serializes presents on the on-glass
|
||||
/// callback — which costs a refresh whenever the callback's own latency pushes the next present
|
||||
/// past a vsync; depth 2 keeps one flip queued behind the one scanning out, so a decoded frame
|
||||
/// presents immediately and latches the very next vsync while the queue still can't build (see
|
||||
/// `SessionPresenter.gateDepth` for the per-platform choice). `staleAfter` is insurance against a
|
||||
/// present whose handler never fires (the macOS "out-of-band presents aren't damage" hazard class
|
||||
/// — see MetalVideoPresenter's init post-mortem): rather than freezing the stream, a full gate
|
||||
/// force-opens a slot 100 ms after its oldest present, a visible ~10 fps degradation that
|
||||
/// PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0 on healthy systems). Internal
|
||||
/// (not private) for unit tests. Sendable; lock-guarded — the releaser runs on a Metal callback
|
||||
/// thread.
|
||||
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||
/// releaser runs on a Metal callback thread.
|
||||
final class PresentGate: @unchecked Sendable {
|
||||
/// How long one pending present may hold its slot before it's presumed lost.
|
||||
/// How long one pending present may hold the gate before it's presumed lost.
|
||||
static let staleAfter: CFTimeInterval = 0.1
|
||||
|
||||
private let lock = NSLock()
|
||||
private let capacity: Int
|
||||
/// Arm instants of the in-flight presents, oldest first (≤ `capacity` entries).
|
||||
private var armed: [CFTimeInterval] = []
|
||||
private var pending = false
|
||||
private var armedAt: CFTimeInterval = 0
|
||||
private var forced = 0
|
||||
|
||||
/// `capacity` = the in-flight present budget (clamped to ≥ 1) — see the type doc.
|
||||
init(capacity: Int = 1) {
|
||||
self.capacity = max(1, capacity)
|
||||
}
|
||||
|
||||
/// Arm the gate for one present. False = the gate is full of live presents (none stale) —
|
||||
/// leave the frame in the ring; a presented handler's release/re-signal (or the next
|
||||
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||
/// display-link tick) retries with the freshest frame then.
|
||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if armed.count >= capacity {
|
||||
// Full: reopen only by presuming the OLDEST in-flight present lost (its handler
|
||||
// never fired) rather than stalling the stream.
|
||||
guard let oldest = armed.first, now - oldest > Self.staleAfter else { return false }
|
||||
armed.removeFirst()
|
||||
forced += 1
|
||||
if pending {
|
||||
guard now - armedAt > Self.staleAfter else { return false }
|
||||
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||
}
|
||||
armed.append(now)
|
||||
pending = true
|
||||
armedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// One in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — free the oldest slot. A release with nothing in flight is a no-op; a
|
||||
/// lost present's handler firing late after its stale force-open can transiently over-admit
|
||||
/// one flip, which the next glass callback corrects.
|
||||
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||
func release() {
|
||||
lock.lock()
|
||||
if !armed.isEmpty { armed.removeFirst() }
|
||||
pending = false
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@@ -212,7 +190,7 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
private var glassDeltasMs: [Double] = []
|
||||
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
||||
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
|
||||
@@ -302,9 +280,6 @@ public final class Stage2Pipeline {
|
||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||
private let gateDepth: Int
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
@@ -348,19 +323,16 @@ public final class Stage2Pipeline {
|
||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||
/// (glass-gated) present cadence — see PresentPacing; `gateDepth` is the glass gate's
|
||||
/// in-flight present budget (see `SessionPresenter.gateDepth`).
|
||||
/// (glass-gated) present cadence — see PresentPacing.
|
||||
public init?(
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival,
|
||||
gateDepth: Int = 1
|
||||
pacing: PresentPacing = .arrival
|
||||
) {
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.gateDepth = gateDepth
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
@@ -557,9 +529,9 @@ public final class Stage2Pipeline {
|
||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||
let vsyncClock = vsyncClock
|
||||
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
||||
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
||||
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||
let renderThread = Thread {
|
||||
defer { renderStopped.signal() }
|
||||
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||
@@ -653,18 +625,6 @@ public final class Stage2Pipeline {
|
||||
presenter.setDrawableTarget(size)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
|
||||
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
presenter.setSurfacePresents(on)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||
|
||||
@@ -621,12 +621,6 @@ public final class StreamLayerView: NSView {
|
||||
guard let self, self.window?.isKeyWindow == true else { return }
|
||||
self.onDisconnectRequest?()
|
||||
}
|
||||
capture.onToggleFullscreen = { [weak self] in
|
||||
// App-level window action: post to the key window's FullscreenController (same routing as
|
||||
// the Stream menu's ⌃⌘F item, so captured and released states hit one code path).
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||
}
|
||||
capture.onCycleStats = { [weak self] in
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||
@@ -677,10 +671,7 @@ public final class StreamLayerView: NSView {
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||
maxDimension: RenderScale.maxDimension(
|
||||
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
@@ -692,11 +683,6 @@ public final class StreamLayerView: NSView {
|
||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||
// not yet in a window counts as composited (the safe default).
|
||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||
|
||||
@@ -385,10 +385,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||
maxDimension: RenderScale.maxDimension(
|
||||
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Location-based modifier mapping — which Windows VK each PHYSICAL modifier position forwards to
|
||||
// the host. A Mac keyboard's bottom row is `⌃ Control / ⌥ Option / ⌘ Command / space`; a Windows
|
||||
// keyboard's is `Ctrl / ⊞ Super / Alt / space`. So the key NEAREST the space bar is Command on a
|
||||
// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who
|
||||
// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that
|
||||
// muscle memory back WITHOUT relabelling keycaps — it remaps by physical position, not by a blunt
|
||||
// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction.
|
||||
//
|
||||
// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and
|
||||
// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows`
|
||||
// the ⌘ position emits Alt (VK_L/RMENU) and the ⌥ position emits the Windows key (VK_L/RWIN), while
|
||||
// left stays left and right stays right. Control and Shift are in the same place on both keyboards,
|
||||
// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and
|
||||
// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How the physical ⌥ Option / ⌘ Command keys map to host modifiers. The raw values are stable on
|
||||
/// disk — rename the cases freely, never the strings.
|
||||
public enum ModifierLayout: String, CaseIterable, Sendable {
|
||||
/// Apple positions (default): ⌥ Option → Alt, ⌘ Command → Super/Windows. The current behaviour.
|
||||
case mac
|
||||
/// Windows positions: the key nearest the space bar (⌘ Command) → Alt, the next one (⌥ Option)
|
||||
/// → the Windows/Super key. Side (left/right) is preserved.
|
||||
case windows
|
||||
|
||||
/// User-facing label (Settings picker).
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .mac: return "Mac (⌥ Alt · ⌘ Super)"
|
||||
case .windows: return "Windows (⌘ Alt · ⌥ Super)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line explanation for the setting's footer.
|
||||
public var detail: String {
|
||||
switch self {
|
||||
case .mac:
|
||||
return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout."
|
||||
case .windows:
|
||||
return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key."
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted layout (default `.mac` when unset).
|
||||
public static var current: ModifierLayout {
|
||||
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
|
||||
return .mac
|
||||
}
|
||||
return ModifierLayout(rawValue: raw) ?? .mac
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) — the wire-boundary
|
||||
/// swap that relocates the Alt/Super role between the physical ⌥/⌘ keys per side, without touching
|
||||
/// Control/Shift or the physical detection upstream.
|
||||
final class ModifierLayoutMappingTests: XCTestCase {
|
||||
// The four physical modifier VKs the remap can touch, and everything else it must not.
|
||||
private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C
|
||||
private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5
|
||||
|
||||
func testMacLayoutIsIdentity() {
|
||||
for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] {
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk)
|
||||
}
|
||||
}
|
||||
|
||||
func testWindowsLayoutSwapsAltAndSuperPerSide() {
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L ⌘ → L Alt
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R ⌘ → R Alt
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L ⌥ → L Win
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R ⌥ → R Win
|
||||
}
|
||||
|
||||
func testWindowsLayoutKeepsSideNeverCrossesLeftRight() {
|
||||
// A left key never becomes a right VK or vice-versa.
|
||||
XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt)
|
||||
XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt)
|
||||
}
|
||||
|
||||
func testControlShiftAndRegularKeysNeverMove() {
|
||||
for vk: UInt32 in [
|
||||
0xA0, 0xA1, // L/R Shift
|
||||
0xA2, 0xA3, // L/R Control
|
||||
0x41, 0x5A, // A, Z
|
||||
0x1B, 0x0D, // Esc, Return
|
||||
] {
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk)
|
||||
}
|
||||
}
|
||||
|
||||
func testWindowsRemapIsItsOwnInverse() {
|
||||
// Down-remapped then the same key up-remapped land on the same host VK — no stuck modifier.
|
||||
for vk: UInt32 in [lWin, rWin, lAlt, rAlt] {
|
||||
let once = InputCapture.applyModifierLayout(vk, .windows)
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,13 @@ import XCTest
|
||||
import QuartzCore
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Stage-3 present pacing: the bounded in-flight `PresentGate` (depth 1 + depth 2), the
|
||||
/// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
|
||||
/// release-build stage-1 gate), and the per-platform glass-gate depth.
|
||||
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
|
||||
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
|
||||
final class PresentPacingTests: XCTestCase {
|
||||
// MARK: - PresentGate
|
||||
|
||||
/// The depth-1 invariant: one present in flight. A second acquire while pending must fail
|
||||
/// (the frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
/// The core invariant: one present in flight. A second acquire while pending must fail (the
|
||||
/// frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
func testGateAdmitsOneInFlightPresent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||
@@ -21,32 +20,6 @@ final class PresentPacingTests: XCTestCase {
|
||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||
}
|
||||
|
||||
/// Depth 2 (the iOS default): a second present may queue behind the flip scanning out — the
|
||||
/// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
|
||||
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.001), "depth 2 must admit a queued second flip")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.002), "the third present must wait for glass")
|
||||
gate.release()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.003), "one glass callback frees one slot")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.004))
|
||||
XCTAssertEqual(gate.drainForced(), 0)
|
||||
}
|
||||
|
||||
/// Depth 2 staleness anchors to the OLDEST in-flight present: a full gate stays closed while
|
||||
/// the oldest is live, force-opens once it ages out, and the younger present keeps its slot.
|
||||
func testGateDepthTwoForceOpensOnTheOldestStalePresent() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10.05))
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
|
||||
XCTAssertEqual(gate.drainForced(), 1)
|
||||
// The 10.05 present is still live, so the gate is full again right after the force-open.
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.02))
|
||||
}
|
||||
|
||||
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
|
||||
/// aren't damage" hazard class) must not freeze the stream — past `staleAfter` the gate
|
||||
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
|
||||
@@ -74,21 +47,11 @@ final class PresentPacingTests: XCTestCase {
|
||||
|
||||
// MARK: - PresenterChoice
|
||||
|
||||
/// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
|
||||
/// tvOS — arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
|
||||
/// (sync-off presents don't queue). No selection / garbage falls back to it.
|
||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||
#else
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
|
||||
#endif
|
||||
func testPresenterChoiceDefaultsToStage2() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||||
PresenterChoice.platformDefault)
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true),
|
||||
PresenterChoice.platformDefault)
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
}
|
||||
@@ -107,80 +70,14 @@ final class PresentPacingTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
|
||||
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
|
||||
/// builds); a leftover "stage1" value in a release build maps back to stage-2.
|
||||
func testPresenterChoiceGatesStage1() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false),
|
||||
PresenterChoice.platformDefault)
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false),
|
||||
PresenterChoice.platformDefault)
|
||||
}
|
||||
|
||||
/// `explicit` is nil exactly when `resolve` would fall back to the platform default — the
|
||||
/// distinction the codec-conditional pacing default rides on.
|
||||
func testPresenterChoiceExplicitIsNilWithoutASelection() {
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: nil, env: nil, allowStage1: true))
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: "garbage", env: nil, allowStage1: true))
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: "stage1", env: nil, allowStage1: false))
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.explicit(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.explicit(setting: nil, env: "stage3", allowStage1: true), .stage3)
|
||||
}
|
||||
|
||||
// MARK: - Session pacing (the macOS PyroWave swapID-panic mitigation)
|
||||
|
||||
/// macOS PyroWave sessions under the DEFAULT stage-2 choice must get glass pacing (the
|
||||
/// one-in-flight gate is the "mismatched swapID's" kernel-panic mitigation); an EXPLICIT
|
||||
/// stage-2 pick must stay a faithful arrival-pacing A/B. Elsewhere the default is unchanged.
|
||||
func testPacingDefaultsPyroWaveToGlassOnMacOS() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .glass,
|
||||
"defaulted macOS PyroWave must serialize presents (swapID-panic mitigation)")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: .stage2, codec: .pyrowave), .arrival,
|
||||
"an explicit stage-2 pick must keep arrival pacing (honest A/B)")
|
||||
#else
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .arrival)
|
||||
#endif
|
||||
// Non-PyroWave defaults keep arrival pacing under stage-2 everywhere.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .hevc), .arrival)
|
||||
// Stage-3 means glass regardless of codec or how it was chosen.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
||||
}
|
||||
|
||||
// MARK: - Glass-gate depth
|
||||
|
||||
/// The per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
|
||||
/// queued — the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
|
||||
/// there is the swapID-panic mitigation — strict serialization is its point, so the env
|
||||
/// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
|
||||
/// values are ignored.
|
||||
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
||||
#elseif os(tvOS)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device A/B lever")
|
||||
#else
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
||||
"out-of-range env values fall back to the platform depth")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "garbage"), SessionPresenter.gateDepth(env: nil))
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -57,7 +57,7 @@ final class PyroWaveParserTests: XCTestCase {
|
||||
func testLayoutMatchesUpstreamBlockSpace() {
|
||||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
XCTAssertEqual(layout.alignedWidth, 256)
|
||||
XCTAssertEqual(layout.alignedHeight, 160)
|
||||
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||
@@ -85,7 +85,7 @@ final class PyroWaveParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
var au = sof(totalBlocks: 4)
|
||||
au += packet(blockIndex: 0)
|
||||
au += packet(blockIndex: 3)
|
||||
@@ -273,17 +273,6 @@ final class PyroWaveGoldenTests: XCTestCase {
|
||||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||||
}
|
||||
|
||||
/// 4:4:4: the chroma components run the full pyramid like luma (no level-0 skip, no
|
||||
/// early half-res emit) — the layout + dispatch structure Phase 4 added
|
||||
/// (design/pyrowave-444-hdr.md). The fixture comes from the 4:4:4 host encoder; the
|
||||
/// reference is upstream's own 4:4:4 decode (full-res chroma planes).
|
||||
func testDense444GoldenFrame() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
let au = try fixture("au-dense444")
|
||||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||||
try assertMatchesReference(decoded, prefix: "ref-dense444")
|
||||
}
|
||||
|
||||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||
/// localized blur, not garbage).
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,82 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
/// Pins the render-scale geometry (`RenderScale`) — the multiply → aspect-preserve → even-floor →
|
||||
/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp
|
||||
/// helpers the UI and the connect share.
|
||||
final class RenderScaleTests: XCTestCase {
|
||||
func testSanitizeClampsToRangeAndDefaultsMissing() {
|
||||
XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero → Native
|
||||
XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense → Native
|
||||
XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor
|
||||
XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling
|
||||
XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched
|
||||
}
|
||||
|
||||
func testMaxDimensionIsCodecAware() {
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096)
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192)
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192)
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192)
|
||||
}
|
||||
|
||||
func testNativeScaleIsIdentity() {
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width, 1920)
|
||||
XCTAssertEqual(m.height, 1080)
|
||||
}
|
||||
|
||||
func testSupersampleDoubles() {
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width, 3840)
|
||||
XCTAssertEqual(m.height, 2160)
|
||||
}
|
||||
|
||||
func testUnderRenderHalves() {
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width, 960)
|
||||
XCTAssertEqual(m.height, 540)
|
||||
}
|
||||
|
||||
func testResultsAreAlwaysEven() {
|
||||
// 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 → 2048×1152.
|
||||
let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width % 2, 0)
|
||||
XCTAssertEqual(m.height % 2, 0)
|
||||
XCTAssertEqual(m.width, 2048)
|
||||
XCTAssertEqual(m.height, 1152)
|
||||
}
|
||||
|
||||
func testOverCeilingClampsUniformlyPreservingAspect() {
|
||||
// 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap
|
||||
// and the ratio is kept (16:9 → 8192×4608, even).
|
||||
let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192)
|
||||
XCTAssertLessThanOrEqual(m.width, 8192)
|
||||
XCTAssertLessThanOrEqual(m.height, 8192)
|
||||
XCTAssertEqual(m.width, 8192)
|
||||
XCTAssertEqual(m.height, 4608)
|
||||
// 16:9 preserved to within the even-floor rounding.
|
||||
XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testH264CeilingIsTighter() {
|
||||
// 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept.
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096)
|
||||
XCTAssertEqual(m.width, 4096)
|
||||
XCTAssertEqual(m.height, 2304)
|
||||
}
|
||||
|
||||
func testMinimumFloorHonoured() {
|
||||
// A tiny base under a small scale can't fall below 320×200.
|
||||
let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192)
|
||||
XCTAssertGreaterThanOrEqual(m.width, 320)
|
||||
XCTAssertGreaterThanOrEqual(m.height, 200)
|
||||
}
|
||||
|
||||
func testLabels() {
|
||||
XCTAssertEqual(RenderScale.label(1.0), "Native (1×)")
|
||||
XCTAssertEqual(RenderScale.label(2.0), "2× · supersample")
|
||||
XCTAssertEqual(RenderScale.label(0.5), "0.5×")
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,6 @@ const CODECS: &[(&str, &str)] = &[
|
||||
("hevc", "HEVC (H.265)"),
|
||||
("h264", "H.264 (AVC)"),
|
||||
("av1", "AV1"),
|
||||
// Preference-only by design: `resolve_codec` never auto-picks PyroWave, and asking for
|
||||
// it on a host or device that can't do it simply falls back down the ladder to HEVC.
|
||||
("pyrowave", "PyroWave (wired LAN)"),
|
||||
];
|
||||
/// Virtual-pad presets: `(stored value, display label)` — the pad the HOST creates. Same set the
|
||||
/// GTK client offers; "Automatic" resolves from the physical controller at connect.
|
||||
@@ -281,7 +278,7 @@ pub(crate) fn settings_page(
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"A soft preference \u{2014} the host falls back to the best codec both sides support. PyroWave is the low-latency wavelet codec for a WIRED link: it trades bitrate (hundreds of Mb/s) for near-zero decode time, so it needs gigabit Ethernet.",
|
||||
"A soft preference \u{2014} the host falls back to the best codec both sides support.",
|
||||
);
|
||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||
// round-trips exactly.
|
||||
|
||||
+10
-35
@@ -41,41 +41,16 @@ fn all_adapters() -> Vec<IDXGIAdapter> {
|
||||
|
||||
/// Descriptions of the real (hardware, non-WARP) GPUs — the Settings GPU picker's option list.
|
||||
/// The picker only shows when this has more than one entry.
|
||||
///
|
||||
/// **Deduplicated by description**, because the description IS the identity everywhere
|
||||
/// downstream: the pick is persisted as that string (`Settings::adapter`) and matched by
|
||||
/// name in the session binary (`PUNKTFUNK_VK_ADAPTER`). So two entries with the same name
|
||||
/// are one selectable choice however many times DXGI enumerates them — listing it twice
|
||||
/// only offers the user a meaningless coin flip. Seen live on an Intel Arc laptop
|
||||
/// (2026-07-19), whose Vulkan ICD likewise enumerates the one physical iGPU twice.
|
||||
pub fn adapter_names() -> Vec<String> {
|
||||
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for a in all_adapters() {
|
||||
let desc1 = a
|
||||
.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.ok();
|
||||
let name = adapter_name(&a);
|
||||
// Forensics for the next duplicate/oddity report — which adapters DXGI actually
|
||||
// returned, and whether the repeats share a LUID (one adapter enumerated twice)
|
||||
// or are distinct devices that merely present the same description.
|
||||
if let Some(d) = &desc1 {
|
||||
tracing::debug!(
|
||||
name = %name,
|
||||
luid = format!("{:08x}-{:08x}", d.AdapterLuid.HighPart, d.AdapterLuid.LowPart),
|
||||
vendor = format_args!("{:#06x}", d.VendorId),
|
||||
device = format_args!("{:#06x}", d.DeviceId),
|
||||
flags = d.Flags,
|
||||
"DXGI adapter"
|
||||
);
|
||||
}
|
||||
if desc1.is_some_and(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE != 0) {
|
||||
continue; // WARP / software renderer — never a streaming target
|
||||
}
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
all_adapters()
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(adapter_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -254,55 +254,6 @@ pub struct ZeroCopyPolicy {
|
||||
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the **native-plane** capturer (a compositor virtual output) can deliver an HDR (10-bit
|
||||
/// PQ/BT.2020) source on this platform — the capture-side gate the punktfunk/1 handshake consults
|
||||
/// before negotiating 10-bit (mirroring [`capturer_supports_444`]).
|
||||
///
|
||||
/// Linux: `false`. GNOME 50 added HDR **screen sharing** for *monitor* streams only — Mutter's
|
||||
/// `RecordVirtual` virtual-monitor streams advertise 8-bit BGRx/BGRA exclusively (still true on
|
||||
/// the GNOME 51 dev branch), and virtual outputs report no BT2020/PQ colour capabilities, so they
|
||||
/// can't be flipped into HDR mode via DisplayConfig either. The Linux HDR path that DOES exist —
|
||||
/// the GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — is gated
|
||||
/// separately by the GameStream plane (`host_hdr_capable` + the live monitor colour-mode probe).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capturer_supports_hdr() -> bool {
|
||||
false
|
||||
}
|
||||
/// Windows: the IDD-push capturer proactively enables advanced colour and delivers P010/Rgb10a2.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capturer_supports_hdr() -> bool {
|
||||
true
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn capturer_supports_hdr() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Process-wide latch: a `want_hdr` portal capture failed to negotiate the HDR (10-bit PQ) offer —
|
||||
/// the compositor never accepted it (monitor left HDR mode between the probe and the negotiation,
|
||||
/// NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter…). Later sessions consult
|
||||
/// [`hdr_capture_failed`] and fall back to the SDR offer instead of re-running the same doomed
|
||||
/// 10-second negotiation timeout on every reconnect. Sticky until host restart (matching the
|
||||
/// zero-copy downgrade latches); the log line at latch time says so.
|
||||
#[cfg(target_os = "linux")]
|
||||
static HDR_CAPTURE_FAILED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn hdr_capture_failed() -> bool {
|
||||
HDR_CAPTURE_FAILED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn note_hdr_capture_failed() {
|
||||
if !HDR_CAPTURE_FAILED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"HDR capture negotiation failed — this host will offer SDR capture for the rest of \
|
||||
the process lifetime (restart the host after fixing the monitor's HDR mode to retry)"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
|
||||
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
|
||||
@@ -365,28 +316,16 @@ pub use idd_push::verify_is_wudfhost;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/mod.rs"]
|
||||
mod linux;
|
||||
// The GNOME BT.2100 colour-mode probe — the host's capture-side gate for offering HDR on the
|
||||
// portal monitor path (see `open_portal_monitor`'s `want_hdr`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::gnome_hdr_monitor_active;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/synthetic_nv12.rs"]
|
||||
pub mod synthetic_nv12;
|
||||
|
||||
/// Open the Linux xdg-ScreenCast portal capturer for a client-sized monitor. `anchored` drives
|
||||
/// ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits that grant headlessly.
|
||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
||||
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
||||
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
||||
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||
/// ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits that grant headlessly. The
|
||||
/// [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor(
|
||||
anchored: bool,
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
pub fn open_portal_monitor(anchored: bool, policy: ZeroCopyPolicy) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(anchored, policy).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
|
||||
@@ -426,18 +365,9 @@ pub fn open_idd_push(
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: FrameChannelSender,
|
||||
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||
idd_push::IddPushCapturer::open(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
keepalive,
|
||||
sender,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -62,13 +62,6 @@ pub struct PortalCapturer {
|
||||
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
|
||||
/// rebuild retries on the CPU offer instead of failing identically forever.
|
||||
vaapi_dmabuf: bool,
|
||||
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
|
||||
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
|
||||
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
|
||||
hdr_offer: bool,
|
||||
/// Set once the stream negotiated one of the 10-bit PQ formats (`param_changed`), i.e. frames
|
||||
/// really are PQ/BT.2020 — drives [`hdr_meta`](Capturer::hdr_meta).
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
||||
node_id: u32,
|
||||
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
|
||||
@@ -87,9 +80,8 @@ pub struct PortalCapturer {
|
||||
impl PortalCapturer {
|
||||
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
|
||||
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
||||
/// ScreenCast session (wlroots, which has no RemoteDesktop portal). `want_hdr` offers the
|
||||
/// GNOME 50+ HDR formats (10-bit PQ/BT.2020, dmabuf-only) instead of the SDR set.
|
||||
pub fn open(anchored: bool, want_hdr: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
|
||||
/// ScreenCast session (wlroots, which has no RemoteDesktop portal).
|
||||
pub fn open(anchored: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
|
||||
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
thread::Builder::new()
|
||||
@@ -110,12 +102,11 @@ impl PortalCapturer {
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
want_hdr,
|
||||
"ScreenCast portal session started; connecting PipeWire"
|
||||
);
|
||||
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
||||
Ok(
|
||||
spawn_pipewire(Some(fd), node_id, None, true, false, want_hdr, policy)?
|
||||
spawn_pipewire(Some(fd), node_id, None, true, false, policy)?
|
||||
.into_capturer(node_id, None),
|
||||
)
|
||||
}
|
||||
@@ -144,15 +135,12 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
|
||||
// BGRx/BGRA exclusively, GNOME 50 and 51-dev alike) — never run the HDR offer here.
|
||||
Ok(spawn_pipewire(
|
||||
remote_fd,
|
||||
node_id,
|
||||
preferred_mode,
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
false,
|
||||
policy,
|
||||
)?
|
||||
.into_capturer(node_id, Some(keepalive)))
|
||||
@@ -172,10 +160,6 @@ struct PwHandles {
|
||||
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
||||
/// [`PortalCapturer::vaapi_dmabuf`]).
|
||||
vaapi_dmabuf: bool,
|
||||
/// This capture ran the HDR offer (see [`PortalCapturer::hdr_offer`]).
|
||||
hdr_offer: bool,
|
||||
/// See [`PortalCapturer::hdr_negotiated`].
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
quit: ::pipewire::channel::Sender<()>,
|
||||
join: thread::JoinHandle<()>,
|
||||
}
|
||||
@@ -193,8 +177,6 @@ impl PwHandles {
|
||||
broken: self.broken,
|
||||
stall_since: None,
|
||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||
hdr_offer: self.hdr_offer,
|
||||
hdr_negotiated: self.hdr_negotiated,
|
||||
node_id,
|
||||
quit: Some(self.quit),
|
||||
join: Some(self.join),
|
||||
@@ -217,10 +199,6 @@ fn spawn_pipewire(
|
||||
// 4:4:4 session: tiled dmabufs convert to planar YUV444 on the GPU (`ImportKind::Tiled444`)
|
||||
// instead of NV12/RGB, so the session stays zero-copy at full chroma.
|
||||
want_444: bool,
|
||||
// HDR session (GNOME 50+ monitor mirror): offer ONLY the 10-bit PQ/BT.2020 formats as
|
||||
// LINEAR dmabufs (SHM can't carry them — Mutter's SHM record path paints 8-bit ARGB32
|
||||
// regardless of the negotiated format, and the tiled EGL de-tile blit is 8-bit).
|
||||
want_hdr: bool,
|
||||
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||
// capture→encode edge (plan §W6).
|
||||
policy: ZeroCopyPolicy,
|
||||
@@ -235,29 +213,17 @@ fn spawn_pipewire(
|
||||
let streaming_cb = streaming.clone();
|
||||
let broken = Arc::new(AtomicBool::new(false));
|
||||
let broken_cb = broken.clone();
|
||||
let hdr_negotiated = Arc::new(AtomicBool::new(false));
|
||||
let hdr_negotiated_cb = hdr_negotiated.clone();
|
||||
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
||||
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||
// inner `mod pipewire` shadows the crate name at this scope.
|
||||
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
||||
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
|
||||
// HDR cannot ride the SHM path (see `want_hdr` above): under PUNKTFUNK_FORCE_SHM the HDR
|
||||
// offer is dropped — SDR capture, loudly.
|
||||
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
||||
let want_hdr = if want_hdr && force_shm {
|
||||
tracing::warn!(
|
||||
"HDR capture requested but PUNKTFUNK_FORCE_SHM=1 — the SHM path is 8-bit only; \
|
||||
offering SDR"
|
||||
);
|
||||
false
|
||||
} else {
|
||||
want_hdr
|
||||
};
|
||||
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
||||
// backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
|
||||
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
|
||||
let vaapi_dmabuf = zerocopy && !force_shm && policy.backend_is_vaapi;
|
||||
let vaapi_dmabuf = zerocopy
|
||||
&& std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() != Ok("1")
|
||||
&& policy.backend_is_vaapi;
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-pipewire".into())
|
||||
.spawn(move || {
|
||||
@@ -269,10 +235,8 @@ fn spawn_pipewire(
|
||||
negotiated_cb,
|
||||
streaming_cb,
|
||||
broken_cb,
|
||||
hdr_negotiated_cb,
|
||||
zerocopy,
|
||||
want_444,
|
||||
want_hdr,
|
||||
preferred,
|
||||
quit_rx,
|
||||
policy,
|
||||
@@ -288,8 +252,6 @@ fn spawn_pipewire(
|
||||
streaming,
|
||||
broken,
|
||||
vaapi_dmabuf,
|
||||
hdr_offer: want_hdr,
|
||||
hdr_negotiated,
|
||||
quit: quit_tx,
|
||||
join,
|
||||
})
|
||||
@@ -392,26 +354,6 @@ impl Capturer for PortalCapturer {
|
||||
fn set_active(&self, active: bool) {
|
||||
self.active.store(active, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format. Mutter
|
||||
/// exposes no per-monitor mastering volume through the screencast, so this is the standard
|
||||
/// HDR10 default block (BT.2020 primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the
|
||||
/// same fallback Windows uses when a display reports nothing. The native stream loop prefers
|
||||
/// the client display's own volume when the client sent one (`Hello::display_hdr`).
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
if !self.hdr_negotiated.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
Some(punktfunk_core::quic::HdrMeta {
|
||||
// ST.2086 order G, B, R; (x, y) chromaticity in 1/50000 units.
|
||||
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
||||
white_point: [15635, 16450], // D65
|
||||
max_display_mastering_luminance: 10_000_000, // 1000 cd/m² (0.0001 units)
|
||||
min_display_mastering_luminance: 50, // 0.005 cd/m²
|
||||
max_cll: 0,
|
||||
max_fall: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PortalCapturer {
|
||||
@@ -430,20 +372,6 @@ impl PortalCapturer {
|
||||
or capture never started)",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.hdr_offer {
|
||||
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
|
||||
// mode between the probe and the negotiation, the compositor pre-dates the
|
||||
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
|
||||
// Latch the process-wide SDR downgrade so the next session (Moonlight
|
||||
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
||||
super::note_hdr_capture_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||
the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored monitor in \
|
||||
HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \
|
||||
to stream SDR",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
||||
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
||||
@@ -488,87 +416,6 @@ impl Drop for PortalCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
|
||||
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
|
||||
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
|
||||
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
|
||||
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
|
||||
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
|
||||
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
|
||||
pub fn gnome_hdr_monitor_active() -> bool {
|
||||
use ashpd::zbus;
|
||||
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
|
||||
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
|
||||
// properties.
|
||||
type Mode = (
|
||||
String,
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
f64,
|
||||
Vec<f64>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type Monitor = (
|
||||
(String, String, String, String),
|
||||
Vec<Mode>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type LogicalMonitor = (
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
u32,
|
||||
bool,
|
||||
Vec<(String, String, String, String)>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type State = (
|
||||
u32,
|
||||
Vec<Monitor>,
|
||||
Vec<LogicalMonitor>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
let probe = || -> Result<bool> {
|
||||
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
|
||||
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build tokio runtime")?;
|
||||
rt.block_on(async {
|
||||
let conn = zbus::Connection::session().await.context("session bus")?;
|
||||
let reply = conn
|
||||
.call_method(
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"GetCurrentState",
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
.context("DisplayConfig.GetCurrentState")?;
|
||||
let (_serial, monitors, _logical, _props): State = reply
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("parse GetCurrentState")?;
|
||||
Ok(monitors.iter().any(|(_spec, _modes, props)| {
|
||||
props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
|
||||
}))
|
||||
})
|
||||
};
|
||||
match probe() {
|
||||
Ok(hdr) => hdr,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`),
|
||||
/// preferring **cursor-as-metadata**: the compositor keeps its cheap hardware cursor plane and
|
||||
/// ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + an occasional bitmap),
|
||||
@@ -822,10 +669,6 @@ mod pipewire {
|
||||
VideoFormat::RGBA => PixelFormat::Rgba,
|
||||
VideoFormat::RGB => PixelFormat::Rgb,
|
||||
VideoFormat::BGR => PixelFormat::Bgr,
|
||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
|
||||
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
|
||||
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
||||
VideoFormat::xBGR_210LE => PixelFormat::X2Bgr10,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -889,9 +732,6 @@ mod pipewire {
|
||||
/// irrecoverably gone for this stream — the import worker died, or tiled imports failed
|
||||
/// [`IMPORT_FAIL_POISON`] times in a row.
|
||||
broken: Arc<AtomicBool>,
|
||||
/// Set when the negotiated format is one of the 10-bit PQ formats (`param_changed`) —
|
||||
/// read by [`PortalCapturer::hdr_meta`](super::PortalCapturer).
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
|
||||
import_fail_streak: u32,
|
||||
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
|
||||
@@ -908,9 +748,6 @@ mod pipewire {
|
||||
/// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over
|
||||
/// `nv12` (a 4:4:4 session must never subsample).
|
||||
yuv444: bool,
|
||||
/// The LINEAR (gamescope) NV12 compute CSC failed once — RGB for the rest of the stream
|
||||
/// (T2.5b's per-stream fallback latch; cleared by the next stream's fresh `Ud`).
|
||||
linear_nv12_failed: bool,
|
||||
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
|
||||
dbg_log_n: u64,
|
||||
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
|
||||
@@ -1046,91 +883,6 @@ mod pipewire {
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
|
||||
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
|
||||
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
|
||||
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
|
||||
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
|
||||
///
|
||||
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
|
||||
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
|
||||
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
|
||||
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
|
||||
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
|
||||
/// regardless of the negotiated format.
|
||||
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
|
||||
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
|
||||
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
|
||||
/// then emits no such constant and the host fails to compile there, even though the code never
|
||||
/// runs on those systems (the HDR path needs GNOME 50+).
|
||||
///
|
||||
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
|
||||
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
|
||||
/// together, so the value is identical on every libspa that has the symbol at all. On one that
|
||||
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
|
||||
/// SDR — the same outcome as not offering HDR.
|
||||
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
|
||||
|
||||
fn build_hdr_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
|
||||
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
|
||||
fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
|
||||
@@ -1402,54 +1154,6 @@ mod pipewire {
|
||||
})
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
|
||||
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
|
||||
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
|
||||
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
|
||||
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
|
||||
fn composite_cursor_rgb10(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
r_shift: u32,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
|
||||
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
|
||||
let (sr, sg, sb) = (
|
||||
up10(cursor.rgba[s]),
|
||||
up10(cursor.rgba[s + 1]),
|
||||
up10(cursor.rgba[s + 2]),
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * 4;
|
||||
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
|
||||
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
|
||||
let dr = blend((px >> r_shift) & 0x3ff, sr);
|
||||
let dg = blend((px >> 10) & 0x3ff, sg);
|
||||
let db = blend((px >> b_shift) & 0x3ff, sb);
|
||||
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
|
||||
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
|
||||
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
|
||||
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||
@@ -1463,12 +1167,6 @@ mod pipewire {
|
||||
if !cursor.visible || cursor.rgba.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
|
||||
match fmt {
|
||||
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
|
||||
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
|
||||
_ => {}
|
||||
}
|
||||
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||
return;
|
||||
};
|
||||
@@ -1643,10 +1341,7 @@ mod pipewire {
|
||||
// through to the shm de-pad copy below.
|
||||
let mut gpu_import_broken = false;
|
||||
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
||||
// Defense-in-depth: the 10-bit PQ formats must never enter the EGL→CUDA import (its
|
||||
// de-tile blit is 8-bit RGBA8 — silent depth loss). An HDR offer never builds the
|
||||
// importer, so this gate only matters if those invariants ever drift apart.
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !fmt.is_hdr_rgb10() {
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||
let plane = pf_zerocopy::DmabufPlane {
|
||||
fd: datas[0].fd(),
|
||||
offset: datas[0].chunk().offset(),
|
||||
@@ -1657,17 +1352,15 @@ mod pipewire {
|
||||
// sample LINEAR).
|
||||
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
||||
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
|
||||
// GPU converts: a 4:4:4 session gets the planar-YUV444 convert on the tiled
|
||||
// EGL/GL path (full chroma, takes precedence over NV12 — 4:4:4 must never
|
||||
// subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — tiled via the EGL/GL
|
||||
// blit, LINEAR/gamescope via the Vulkan bridge's compute CSC (latency plan
|
||||
// T2.5b) — so NVENC encodes native YUV and skips its internal RGB→YUV CSC on
|
||||
// the contended SM. A 4:4:4 session on LINEAR frames has no convert and
|
||||
// stays RGB, falling to the encoder's clear-error path (`want_444` with an
|
||||
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
|
||||
// failure latches RGB for the stream (mid-frame fallback, no drop).
|
||||
// GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4
|
||||
// session gets the planar-YUV444 convert (full chroma, takes precedence over
|
||||
// NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 —
|
||||
// both feed NVENC native YUV so it skips its internal RGB→YUV CSC. The
|
||||
// LINEAR/Vulkan (gamescope) path stays RGB — its converts aren't wired here;
|
||||
// a 4:4:4 session on LINEAR frames falls to the encoder's clear-error path
|
||||
// (`want_444` with an RGB CUDA payload) rather than silently subsampling.
|
||||
let yuv444 = ud.yuv444 && modifier.is_some();
|
||||
let mut nv12 = ud.nv12 && !ud.yuv444;
|
||||
let nv12 = ud.nv12 && !yuv444 && modifier.is_some();
|
||||
let imported = if let Some(m) = modifier {
|
||||
if yuv444 {
|
||||
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
|
||||
@@ -1676,20 +1369,7 @@ mod pipewire {
|
||||
} else {
|
||||
importer.import(&plane, w as u32, h as u32, fourcc, Some(m))
|
||||
}
|
||||
} else if nv12 && !ud.linear_nv12_failed {
|
||||
match importer.import_linear_nv12(&plane, w as u32, h as u32) {
|
||||
Ok(buf) => Ok(buf),
|
||||
Err(e) => {
|
||||
ud.linear_nv12_failed = true;
|
||||
nv12 = false;
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"LINEAR NV12 compute CSC failed — RGB for the rest of this \
|
||||
stream (NVENC does the CSC internally)");
|
||||
importer.import_linear(&plane, w as u32, h as u32)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nv12 = false;
|
||||
importer.import_linear(&plane, w as u32, h as u32)
|
||||
};
|
||||
match imported {
|
||||
@@ -1906,13 +1586,9 @@ mod pipewire {
|
||||
negotiated: Arc<AtomicBool>,
|
||||
streaming: Arc<AtomicBool>,
|
||||
broken: Arc<AtomicBool>,
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
zerocopy: bool,
|
||||
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
|
||||
want_444: bool,
|
||||
// HDR session: offer ONLY the 10-bit PQ/BT.2020 formats as LINEAR dmabufs (see
|
||||
// `build_hdr_dmabuf_format`); the SDR offers are not built at all.
|
||||
want_hdr: bool,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
quit_rx: pw::channel::Receiver<()>,
|
||||
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||
@@ -1951,10 +1627,7 @@ mod pipewire {
|
||||
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
|
||||
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
||||
let backend_is_vaapi = policy.backend_is_vaapi;
|
||||
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
|
||||
// which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path
|
||||
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
|
||||
let mut importer = if zerocopy && !backend_is_vaapi && !want_hdr {
|
||||
let mut importer = if zerocopy && !backend_is_vaapi {
|
||||
if pf_zerocopy::gpu_import_disabled() {
|
||||
tracing::warn!(
|
||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||
@@ -2064,13 +1737,11 @@ mod pipewire {
|
||||
negotiated,
|
||||
streaming,
|
||||
broken,
|
||||
hdr_negotiated,
|
||||
import_fail_streak: 0,
|
||||
importer,
|
||||
vaapi_passthrough,
|
||||
nv12: pf_zerocopy::nv12_enabled(),
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
};
|
||||
@@ -2132,20 +1803,12 @@ mod pipewire {
|
||||
let sz = ud.info.size();
|
||||
ud.format = map_format(ud.info.format());
|
||||
ud.modifier = ud.info.modifier();
|
||||
// HDR: the 10-bit PQ formats are only ever offered with MANDATORY BT.2020/PQ
|
||||
// colorimetry props, so a 10-bit negotiation IS an HDR negotiation — but log
|
||||
// what the producer actually fixated for diagnosis.
|
||||
let hdr = ud.format.is_some_and(|f| f.is_hdr_rgb10());
|
||||
ud.hdr_negotiated.store(hdr, Ordering::Relaxed);
|
||||
tracing::info!(
|
||||
width = sz.width,
|
||||
height = sz.height,
|
||||
spa_format = ?ud.info.format(),
|
||||
mapped = ?ud.format,
|
||||
modifier = ud.modifier,
|
||||
hdr,
|
||||
transfer_function = ud.info.transfer_function(),
|
||||
color_primaries = ud.info.color_primaries(),
|
||||
"pipewire format negotiated"
|
||||
);
|
||||
if ud.format.is_none() {
|
||||
@@ -2347,36 +2010,20 @@ mod pipewire {
|
||||
// (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
|
||||
// `param_changed` (the two-step DMA-BUF handshake). Otherwise offer the multi-format shm
|
||||
// pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit
|
||||
// PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering
|
||||
// SDR alongside would make the producer pick its earlier-listed SDR format, and the
|
||||
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
|
||||
let format_pods: Vec<Vec<u8>> = if want_hdr {
|
||||
tracing::info!(
|
||||
"HDR capture: offering xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \
|
||||
BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)"
|
||||
);
|
||||
vec![
|
||||
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?,
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
||||
]
|
||||
} else if want_dmabuf {
|
||||
vec![build_dmabuf_format(&modifiers, preferred)?]
|
||||
} else {
|
||||
vec![serialize_pod(obj)?]
|
||||
};
|
||||
let buffers_values = if want_hdr || want_dmabuf {
|
||||
// Dmabuf-only. For HDR this is load-bearing beyond zero-copy: Mutter's SHM record
|
||||
// path paints 8-bit ARGB32 regardless of the negotiated format, so a MemFd buffer
|
||||
// under a 10-bit format would carry mislabeled bytes.
|
||||
Some(build_dmabuf_buffers()?)
|
||||
// pod and let MAP_BUFFERS map it.
|
||||
let shm_values = serialize_pod(obj)?;
|
||||
let (dmabuf_values, buffers_values) = if want_dmabuf {
|
||||
(
|
||||
Some(build_dmabuf_format(&modifiers, preferred)?),
|
||||
Some(build_dmabuf_buffers()?),
|
||||
)
|
||||
} else if force_shm {
|
||||
// True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render).
|
||||
Some(build_shm_only_buffers()?)
|
||||
(None, Some(build_shm_only_buffers()?))
|
||||
} else {
|
||||
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
|
||||
// modifier-bearing format pod wins the intersection).
|
||||
Some(build_mappable_buffers()?)
|
||||
(None, Some(build_mappable_buffers()?))
|
||||
};
|
||||
|
||||
// Ask for cursor-as-metadata on every path (harmless if the producer can't supply it): the
|
||||
@@ -2384,8 +2031,9 @@ mod pipewire {
|
||||
// compositor keeps its cheap hardware cursor plane (see `choose_cursor_mode`).
|
||||
let cursor_meta = build_cursor_meta_param()?;
|
||||
let mut byte_slices: Vec<&[u8]> = Vec::new();
|
||||
for pod in &format_pods {
|
||||
byte_slices.push(pod);
|
||||
match &dmabuf_values {
|
||||
Some(d) => byte_slices.push(d),
|
||||
None => byte_slices.push(&shm_values),
|
||||
}
|
||||
if let Some(b) = &buffers_values {
|
||||
byte_slices.push(b);
|
||||
@@ -2409,24 +2057,4 @@ mod pipewire {
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
|
||||
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
|
||||
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
|
||||
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
|
||||
/// the HDR offer with the wrong transfer function.
|
||||
///
|
||||
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
|
||||
/// so this never reintroduces the compile failure it exists to prevent.
|
||||
#[test]
|
||||
fn pq_transfer_id_matches_libspa() {
|
||||
assert_eq!(
|
||||
super::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, WinCaptureTarget};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::c_void;
|
||||
@@ -466,229 +466,6 @@ impl HdrP010Converter {
|
||||
}
|
||||
}
|
||||
|
||||
/// PyroWave LUMA pass PS — full-res, writes Y′ to a separate `R8_UNORM` texture. BT.709 limited from
|
||||
/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the
|
||||
/// wavelet client — whose golden fixtures come from that shader — decodes the same colours). `Load`
|
||||
/// (texelFetch) reads the exact source texel: RTV pixel (x,y) → source texel (x,y).
|
||||
const PYRO_Y_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
float main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb;
|
||||
return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b;
|
||||
}
|
||||
";
|
||||
|
||||
/// PyroWave CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to a separate `R8G8_UNORM` texture.
|
||||
/// **2×2 box average** (centre-sited) of the four luma-block RGB texels, then BT.709 limited Cb/Cr —
|
||||
/// BYTE-IDENTICAL to `rgb2yuv.comp` (which averages `(c00+c10+c01+c11)*0.25` then U/V), so the chroma
|
||||
/// siting matches the client's decoder. Even dimensions guarantee the 2×2 block is in-bounds.
|
||||
const PYRO_UV_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
int2 p = int2(pos.xy) * 2;
|
||||
float3 c00 = tx.Load(int3(p, 0)).rgb;
|
||||
float3 c10 = tx.Load(int3(p + int2(1,0), 0)).rgb;
|
||||
float3 c01 = tx.Load(int3(p + int2(0,1), 0)).rgb;
|
||||
float3 c11 = tx.Load(int3(p + int2(1,1), 0)).rgb;
|
||||
float3 a = (c00 + c10 + c01 + c11) * 0.25;
|
||||
float u = 128.0/255.0 - 0.1006*a.r - 0.3386*a.g + 0.4392*a.b;
|
||||
float v = 128.0/255.0 + 0.4392*a.r - 0.3989*a.g - 0.0403*a.b;
|
||||
return float2(u, v);
|
||||
}
|
||||
";
|
||||
|
||||
/// PyroWave 4:4:4 CHROMA pass PS — FULL-res, per-pixel (no box filter, no siting), the Windows twin
|
||||
/// of the Linux `rgb2yuv444.comp` chroma math.
|
||||
const PYRO_UV444_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb;
|
||||
float u = 128.0/255.0 - 0.1006*c.r - 0.3386*c.g + 0.4392*c.b;
|
||||
float v = 128.0/255.0 + 0.4392*c.r - 0.3989*c.g - 0.0403*c.b;
|
||||
return float2(u, v);
|
||||
}
|
||||
";
|
||||
|
||||
/// Shared HLSL for the PyroWave **HDR** passes: scRGB FP16 → PQ-encoded BT.2020 → 10-bit studio
|
||||
/// codes MSB-packed into 16-bit UNORM — the SAME colour math as [`HDR_P010_COMMON`] (verified by
|
||||
/// `hdr_p010_selftest`), restated over `Load`ed texels so the pyrowave passes stay texel-exact like
|
||||
/// their SDR twins. The wavelet client decodes these planes with the same CSC rows as the P010 path.
|
||||
const PYRO_HDR_COMMON: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
static const float3x3 BT709_TO_BT2020 = {
|
||||
0.627403914, 0.329283038, 0.043313048,
|
||||
0.069097292, 0.919540405, 0.011362303,
|
||||
0.016391439, 0.088013308, 0.895595253
|
||||
};
|
||||
float3 pq_oetf(float3 L) {
|
||||
const float m1 = 0.1593017578125;
|
||||
const float m2 = 78.84375;
|
||||
const float c1 = 0.8359375;
|
||||
const float c2 = 18.8515625;
|
||||
const float c3 = 18.6875;
|
||||
float3 Lp = pow(saturate(L), m1);
|
||||
return pow((c1 + c2 * Lp) / (1.0 + c3 * Lp), m2);
|
||||
}
|
||||
float3 scrgb_to_pq2020_rgb(float3 scrgb) {
|
||||
float3 nits = max(scrgb, 0.0) * 80.0;
|
||||
return pq_oetf(mul(BT709_TO_BT2020, nits) / 10000.0);
|
||||
}
|
||||
static const float KR = 0.2627;
|
||||
static const float KG = 0.6780;
|
||||
static const float KB = 0.0593;
|
||||
float y_unorm(float3 pq) {
|
||||
float y = KR * pq.r + KG * pq.g + KB * pq.b;
|
||||
float code = clamp(64.0 + 876.0 * y, 64.0, 940.0);
|
||||
return (code * 64.0) / 65535.0;
|
||||
}
|
||||
float2 cbcr_unorm(float3 pq) {
|
||||
float y = KR * pq.r + KG * pq.g + KB * pq.b;
|
||||
float cbc = clamp(512.0 + 896.0 * (pq.b - y) / 1.8814, 64.0, 960.0);
|
||||
float crc = clamp(512.0 + 896.0 * (pq.r - y) / 1.4746, 64.0, 960.0);
|
||||
return float2((cbc * 64.0) / 65535.0, (crc * 64.0) / 65535.0);
|
||||
}
|
||||
";
|
||||
|
||||
/// PyroWave HDR LUMA pass PS — full-res, writes PQ Y′ studio codes to an `R16_UNORM` texture.
|
||||
const PYRO_HDR_Y_PS: &str = r"
|
||||
#include_common
|
||||
float main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb);
|
||||
return y_unorm(pq);
|
||||
}
|
||||
";
|
||||
|
||||
/// PyroWave HDR 4:2:0 CHROMA pass PS — half-res, centre-sited 2×2 box in scRGB-LINEAR space (the
|
||||
/// pyrowave family's siting, matching the SDR pass + `rgb2yuv.comp`, NOT the P010 path's
|
||||
/// left-cositing), then PQ + studio Cb/Cr into an `R16G16_UNORM` texture.
|
||||
const PYRO_HDR_UV_PS: &str = r"
|
||||
#include_common
|
||||
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
int2 p = int2(pos.xy) * 2;
|
||||
float3 a = max(tx.Load(int3(p, 0)).rgb, 0.0);
|
||||
float3 b = max(tx.Load(int3(p + int2(1,0), 0)).rgb, 0.0);
|
||||
float3 c = max(tx.Load(int3(p + int2(0,1), 0)).rgb, 0.0);
|
||||
float3 d = max(tx.Load(int3(p + int2(1,1), 0)).rgb, 0.0);
|
||||
float3 pq = scrgb_to_pq2020_rgb((a + b + c + d) * 0.25);
|
||||
return cbcr_unorm(pq);
|
||||
}
|
||||
";
|
||||
|
||||
/// PyroWave HDR 4:4:4 CHROMA pass PS — full-res, per-pixel.
|
||||
const PYRO_HDR_UV444_PS: &str = r"
|
||||
#include_common
|
||||
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb);
|
||||
return cbcr_unorm(pq);
|
||||
}
|
||||
";
|
||||
|
||||
/// scRGB/BGRA → **separate** YUV planes for the PyroWave wavelet encoder: a full-res Y texture + a
|
||||
/// (half- or full-res) interleaved CbCr texture (design/pyrowave-windows-host-zerocopy.md +
|
||||
/// design/pyrowave-444-hdr.md). SDR mode reads the BGRA slot and writes BT.709-limited 8-bit planes
|
||||
/// (`R8_UNORM`/`R8G8_UNORM`), byte-identical to the Linux `rgb2yuv(444).comp`; HDR mode reads the
|
||||
/// scRGB FP16 slot and writes P010-style 10-bit studio codes MSB-packed into 16-bit planes
|
||||
/// (`R16_UNORM`/`R16G16_UNORM`), colour math identical to [`HdrP010Converter`]. The wavelet encoder
|
||||
/// imports the two SEPARATE textures into its own Vulkan device — the NVIDIA D3D11→Vulkan import of
|
||||
/// a single *planar* NV12 texture is unreliable at arbitrary sizes, whereas simple single/
|
||||
/// two-component textures import reliably. The caller owns the two textures + their RTVs (shareable,
|
||||
/// per out-ring slot); this only records the passes.
|
||||
pub(crate) struct BgraToYuvPlanes {
|
||||
vs: ID3D11VertexShader,
|
||||
ps_y: ID3D11PixelShader,
|
||||
ps_uv: ID3D11PixelShader,
|
||||
/// Full-res chroma pass (4:4:4) — the chroma viewport skips the /2.
|
||||
chroma444: bool,
|
||||
}
|
||||
|
||||
impl BgraToYuvPlanes {
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
||||
let (y_src, uv_src) = match (hdr, chroma444) {
|
||||
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
|
||||
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
|
||||
(true, false) => (
|
||||
PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||
PYRO_HDR_UV_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||
),
|
||||
(true, true) => (
|
||||
PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||
PYRO_HDR_UV444_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||
),
|
||||
};
|
||||
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let yb = compile_shader(&y_src, s!("main"), s!("ps_5_0"))?;
|
||||
let uvb = compile_shader(&uv_src, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||
let mut ps_y = None;
|
||||
device.CreatePixelShader(&yb, None, Some(&mut ps_y))?;
|
||||
let mut ps_uv = None;
|
||||
device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("pyro vs")?,
|
||||
ps_y: ps_y.context("pyro y ps")?,
|
||||
ps_uv: ps_uv.context("pyro uv ps")?,
|
||||
chroma444,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
|
||||
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
||||
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) unsafe fn convert(
|
||||
&self,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
src_srv: &ID3D11ShaderResourceView,
|
||||
y_rtv: &ID3D11RenderTargetView,
|
||||
cbcr_rtv: &ID3D11RenderTargetView,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<()> {
|
||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
|
||||
ctx.IASetInputLayout(None);
|
||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
|
||||
// LUMA pass: full-res → the R8 Y texture.
|
||||
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: w as f32,
|
||||
Height: h as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
}]));
|
||||
ctx.OMSetRenderTargets(Some(&[Some(y_rtv.clone())]), None);
|
||||
ctx.PSSetShader(&self.ps_y, None);
|
||||
ctx.Draw(3, 0);
|
||||
ctx.OMSetRenderTargets(Some(&[None]), None);
|
||||
|
||||
// CHROMA pass: half-res (4:2:0) or full-res (4:4:4) → the CbCr texture.
|
||||
let (cw, ch) = if self.chroma444 {
|
||||
(w, h)
|
||||
} else {
|
||||
(w / 2, h / 2)
|
||||
};
|
||||
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: cw as f32,
|
||||
Height: ch as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
}]));
|
||||
ctx.OMSetRenderTargets(Some(&[Some(cbcr_rtv.clone())]), None);
|
||||
ctx.PSSetShader(&self.ps_uv, None);
|
||||
ctx.Draw(3, 0);
|
||||
|
||||
ctx.OMSetRenderTargets(Some(&[None]), None);
|
||||
ctx.PSSetShaderResources(0, Some(&[None]));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||
@@ -1052,7 +829,8 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_RATIONAL,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
DXGI_RATIONAL,
|
||||
};
|
||||
|
||||
/// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
|
||||
@@ -1068,17 +846,12 @@ pub(crate) struct VideoConverter {
|
||||
}
|
||||
|
||||
impl VideoConverter {
|
||||
/// A BGRA/FP16-RGB → **NV12 (BT.709 limited SDR)** video-engine converter. `scrgb_input` picks
|
||||
/// the input colour space: `false` = 8-bit sRGB `BGRA` (the SDR ring); `true` = FP16 scRGB
|
||||
/// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the
|
||||
/// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR
|
||||
/// path is [`HdrP010Converter`]'s job, never this one.
|
||||
pub(crate) unsafe fn new(
|
||||
device: &ID3D11Device,
|
||||
context: &ID3D11DeviceContext,
|
||||
width: u32,
|
||||
height: u32,
|
||||
scrgb_input: bool,
|
||||
hdr: bool,
|
||||
) -> Result<Self> {
|
||||
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
||||
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
|
||||
@@ -1103,15 +876,19 @@ impl VideoConverter {
|
||||
.CreateVideoProcessor(&enumr, 0)
|
||||
.context("CreateVideoProcessor")?;
|
||||
|
||||
// Full-range RGB in → studio-range BT.709 NV12 out. Input gamma follows the ring format:
|
||||
// scRGB linear (G10) for the FP16 HDR ring, sRGB (G22) for the 8-bit BGRA SDR ring. The
|
||||
// output is always BT.709 SDR (the video processor tone-maps the scRGB case).
|
||||
let in_cs = if scrgb_input {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709
|
||||
// Full-range RGB in → studio-range YUV out. HDR: scRGB linear (G10) → BT.2020 PQ (G2084).
|
||||
// SDR: sRGB (G22) → BT.709 (G22).
|
||||
let (in_cs, out_cs) = if hdr {
|
||||
(
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||
)
|
||||
} else {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||
(
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
)
|
||||
};
|
||||
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
|
||||
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
|
||||
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
||||
// One frame in, one frame out — no interpolation/auto-processing.
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
|
||||
WinCaptureTarget,
|
||||
};
|
||||
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use pf_driver_proto::{control, frame};
|
||||
@@ -36,16 +33,13 @@ use windows::Win32::Foundation::{
|
||||
HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence,
|
||||
ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
|
||||
D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, D3D11_RESOURCE_MISC_SHARED,
|
||||
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
||||
D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM,
|
||||
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
||||
@@ -148,18 +142,6 @@ struct HostSlot {
|
||||
srv: ID3D11ShaderResourceView,
|
||||
}
|
||||
|
||||
/// One PyroWave output-ring slot: the two SEPARATE shareable plane textures the wavelet encoder
|
||||
/// imports (design/pyrowave-windows-host-zerocopy.md) plus their RTVs (the [`BgraToYuvPlanes`] CSC
|
||||
/// renders into them). Y is full-res `R8_UNORM`, CbCr is half-res `R8G8_UNORM`; both are
|
||||
/// `SHARED | SHARED_NTHANDLE`. Rotated per frame like `out_ring` so encode N and convert N+1 touch
|
||||
/// different textures.
|
||||
struct PyroOutSlot {
|
||||
y: ID3D11Texture2D,
|
||||
y_rtv: ID3D11RenderTargetView,
|
||||
cbcr: ID3D11Texture2D,
|
||||
cbcr_rtv: ID3D11RenderTargetView,
|
||||
}
|
||||
|
||||
/// RAII guard over an [`IDXGIKeyedMutex`]: [`acquire`](Self::acquire) does `AcquireSync(key, timeout)`,
|
||||
/// `Drop` does `ReleaseSync(key)`. So the lock is released even if the work between acquire and the end
|
||||
/// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on
|
||||
@@ -393,12 +375,10 @@ pub struct IddPushCapturer {
|
||||
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
|
||||
/// (and so stale-ring publishes are rejected).
|
||||
generation: u32,
|
||||
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Gates the
|
||||
/// composition depth: a 10-bit client PROACTIVELY enables advanced color at `open` (HDR without a
|
||||
/// manual toggle); an SDR-only client forces it OFF and the descriptor poller PINS it there, so a
|
||||
/// client that advertised SDR ("HDR off") is never handed the in-band PQ upgrade the pixel-format-
|
||||
/// driven encoder would otherwise stamp from an HDR composition. (An HDR-negotiated H.26x session
|
||||
/// still follows a host-side "Use HDR" flip; all clients decode Main10 + auto-detect PQ from the VUI.)
|
||||
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open`
|
||||
/// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it
|
||||
/// does NOT gate the per-frame conversion — that follows the display, like the WGC path (clients
|
||||
/// under-report 10-bit yet all decode Main10 + auto-detect PQ from the VUI).
|
||||
client_10bit: bool,
|
||||
/// The DISPLAY's CURRENT HDR state (from `advanced_color_enabled`) — the user can flip "Use HDR" in
|
||||
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
|
||||
@@ -411,32 +391,6 @@ pub struct IddPushCapturer {
|
||||
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
|
||||
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
|
||||
want_444: bool,
|
||||
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md +
|
||||
/// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring`
|
||||
/// (shareable Y + CbCr textures the mode-aware [`BgraToYuvPlanes`] CSC writes) and a **shared
|
||||
/// fence** is signalled after each convert, so the pyrowave encoder zero-copy-imports the two
|
||||
/// textures into its own Vulkan device ordered after the D3D11 convert. The composition is
|
||||
/// PINNED to the negotiated depth: SDR sessions force advanced color OFF (8-bit BGRA → R8
|
||||
/// planes), 10-bit sessions enable it like H.26x (scRGB FP16 → R16 studio-code planes);
|
||||
/// `want_444` sizes the chroma plane full-res.
|
||||
pyrowave: bool,
|
||||
/// PyroWave: the shared D3D11 timeline fence (created lazily on the first frame, `SHARED` flag).
|
||||
/// The capturer `Signal`s it after each frame's GPU convert; the encoder's Vulkan side waits it.
|
||||
pyro_fence: Option<ID3D11Fence>,
|
||||
/// PyroWave: the fence's persistent shared NT handle (raw), passed on EVERY frame. The encoder
|
||||
/// DUPLICATEs + imports it as a Vulkan timeline semaphore whenever it has none (first frame or
|
||||
/// after an encoder rebuild), so this original stays valid across rebuilds.
|
||||
pyro_fence_handle: Option<isize>,
|
||||
/// PyroWave: the monotonically increasing fence value (one `Signal` per emitted frame).
|
||||
pyro_fence_value: u64,
|
||||
/// PyroWave: the separate-plane output ring (Y R8 + CbCr R8G8 shareable textures + RTVs), used
|
||||
/// INSTEAD of `out_ring` for a pyrowave session. Built lazily; rebuilt on a mode change.
|
||||
pyro_ring: Vec<PyroOutSlot>,
|
||||
/// PyroWave: the BGRA→YUV-planes CSC (BT.709 limited, matching `rgb2yuv.comp`). Built lazily.
|
||||
pyro_conv: Option<BgraToYuvPlanes>,
|
||||
/// PyroWave: the last presented (Y, CbCr) textures — the repeat source (analogue of
|
||||
/// `last_present` for the two-plane path).
|
||||
pyro_last: Option<(ID3D11Texture2D, ID3D11Texture2D)>,
|
||||
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
|
||||
/// its snapshot instead of running CCD queries inline on the frame path.
|
||||
desc_poller: DescriptorPoller,
|
||||
@@ -602,20 +556,18 @@ impl IddPushCapturer {
|
||||
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||
pf_win_display::display_events::spawn_once();
|
||||
match Self::open_inner(target, preferred, client_10bit, want_444, pyrowave, sender) {
|
||||
match Self::open_inner(target, preferred, client_10bit, want_444, sender) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
Ok(me)
|
||||
@@ -624,13 +576,11 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> Result<Self> {
|
||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||
@@ -651,7 +601,6 @@ impl IddPushCapturer {
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
luid,
|
||||
sender.clone(),
|
||||
) {
|
||||
@@ -679,27 +628,17 @@ impl IddPushCapturer {
|
||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||
driver's reported adapter"
|
||||
);
|
||||
Self::open_on(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
drv,
|
||||
sender,
|
||||
)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
Self::open_on(target, preferred, client_10bit, want_444, drv, sender)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
luid: LUID,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> Result<Self> {
|
||||
@@ -724,12 +663,12 @@ impl IddPushCapturer {
|
||||
}
|
||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
|
||||
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
|
||||
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
|
||||
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
|
||||
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
|
||||
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
|
||||
// the ring format must TRACK the display's ACTUAL mode (the driver's format-guard drops a
|
||||
// mismatch). We poll the live state here and on every recreate. For a 10-bit-capable client we
|
||||
// PROACTIVELY enable advanced color so HDR streams without the user toggling anything; an
|
||||
// SDR-only client leaves the display alone (and still gets a tone-mapped picture, never a freeze,
|
||||
// if the user does enable HDR).
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
@@ -752,49 +691,6 @@ impl IddPushCapturer {
|
||||
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||
unsafe {
|
||||
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
|
||||
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
|
||||
// session on a reused/lingering monitor, the driver's default, or the host's global
|
||||
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
|
||||
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
|
||||
// the FP16 ring at all.
|
||||
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
|
||||
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
|
||||
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
|
||||
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
|
||||
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
|
||||
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
|
||||
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
|
||||
if !client_10bit {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||
let settle = Instant::now();
|
||||
while settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
|
||||
virtual display (a physical display forcing HDR?) — PyroWave will likely fail \
|
||||
its first frame; H.26x would emit PQ the SDR-only client never asked for"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
settle_ms = settle.elapsed().as_millis() as u64,
|
||||
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
@@ -825,14 +721,9 @@ impl IddPushCapturer {
|
||||
}
|
||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
|
||||
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
||||
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
||||
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
|
||||
let display_hdr = client_10bit
|
||||
&& (enabled_hdr
|
||||
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
.unwrap_or(false));
|
||||
let display_hdr = enabled_hdr
|
||||
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
.unwrap_or(false);
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
@@ -962,13 +853,6 @@ impl IddPushCapturer {
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
want_444,
|
||||
pyrowave,
|
||||
pyro_fence: None,
|
||||
pyro_fence_handle: None,
|
||||
pyro_fence_value: 0,
|
||||
pyro_ring: Vec::new(),
|
||||
pyro_conv: None,
|
||||
pyro_last: None,
|
||||
desc_poller: DescriptorPoller::spawn(
|
||||
target.target_id,
|
||||
DisplayDescriptor {
|
||||
@@ -1244,16 +1128,6 @@ impl IddPushCapturer {
|
||||
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
|
||||
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
|
||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
||||
// PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the
|
||||
// format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR
|
||||
// (negotiated 10-bit) sessions P010 — matching the studio-code planes the pyro CSC writes.
|
||||
if self.pyrowave {
|
||||
return if self.display_hdr {
|
||||
(DXGI_FORMAT_P010, PixelFormat::P010)
|
||||
} else {
|
||||
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
|
||||
};
|
||||
}
|
||||
if self.display_hdr {
|
||||
if self.want_444 {
|
||||
warn_444_hdr_downgrade_once();
|
||||
@@ -1341,8 +1215,6 @@ impl IddPushCapturer {
|
||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||
self.hdr_p010_conv = None;
|
||||
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
|
||||
self.pyro_last = None;
|
||||
self.out_idx = 0;
|
||||
self.last_present = None;
|
||||
Ok(())
|
||||
@@ -1356,31 +1228,11 @@ impl IddPushCapturer {
|
||||
/// only when TWO consecutive samples agree on the same new descriptor (~½ s), so a
|
||||
/// single-sample transient during a topology re-probe never costs a ring recreate.
|
||||
fn poll_display_hdr(&mut self) {
|
||||
let (mut now, seq) = self.desc_poller.snapshot();
|
||||
let (now, seq) = self.desc_poller.snapshot();
|
||||
if seq == self.desc_seq {
|
||||
return; // no new sample since last consume
|
||||
}
|
||||
self.desc_seq = seq;
|
||||
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
|
||||
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
|
||||
// is never recreated at the wrong format):
|
||||
// - a PyroWave session: its encoder was opened for fixed plane formats (R8 SDR / R16 HDR),
|
||||
// so it can't follow a flip the way H.26x re-inits do;
|
||||
// - ANY SDR-negotiated session (`!client_10bit`, either codec): a host-side flip to HDR
|
||||
// must not promote the stream to P010 PQ behind a client that advertised SDR-only.
|
||||
// An HDR-negotiated H.26x session is NOT pinned — it still follows a host "Use HDR" flip in
|
||||
// either direction (its encoder re-inits on the depth change).
|
||||
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
|
||||
// SAFETY: `set_advanced_color` is `unsafe` (CCD DisplayConfig calls); it takes a plain
|
||||
// `u32` target id + bool, forms no lasting borrow, and returns a bool.
|
||||
unsafe {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(
|
||||
self.target_id,
|
||||
self.client_10bit,
|
||||
);
|
||||
}
|
||||
now.hdr = self.client_10bit;
|
||||
}
|
||||
let current = DisplayDescriptor {
|
||||
hdr: self.display_hdr,
|
||||
width: self.width,
|
||||
@@ -1429,8 +1281,7 @@ impl IddPushCapturer {
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
|
||||
// NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own
|
||||
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
|
||||
// NVENC registers it as encode input — matching the WGC YUV ring.
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
@@ -1451,91 +1302,6 @@ impl IddPushCapturer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PyroWave: build the separate-plane output ring (`OUT_RING` × {full-res R8 Y, half-res R8G8
|
||||
/// CbCr}, both `SHARED | SHARED_NTHANDLE` + RTV) if not yet built. The wavelet encoder imports the
|
||||
/// two SEPARATE textures (a single planar NV12 import is unreliable on NVIDIA); the
|
||||
/// [`BgraToYuvPlanes`] CSC renders into their RTVs.
|
||||
fn ensure_pyro_ring(&mut self) -> Result<()> {
|
||||
if !self.pyro_ring.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (w, h) = (self.width, self.height);
|
||||
// SAFETY: all D3D11 calls target `self.device`; every `&desc` is a fully-initialized stack
|
||||
// struct and every `Some(&mut _)` a live out-param; `?` rejects a failed HRESULT before use.
|
||||
// The created textures/RTVs belong to `self.device`.
|
||||
unsafe {
|
||||
let make = |dev: &ID3D11Device,
|
||||
fmt: DXGI_FORMAT,
|
||||
w: u32,
|
||||
h: u32|
|
||||
-> Result<(ID3D11Texture2D, ID3D11RenderTargetView)> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: fmt,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
|
||||
| D3D11_RESOURCE_MISC_SHARED.0) as u32,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
dev.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(pyro plane)")?;
|
||||
let tex = tex.context("null pyro plane texture")?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
dev.CreateRenderTargetView(&tex, None, Some(&mut rtv))
|
||||
.context("CreateRenderTargetView(pyro plane)")?;
|
||||
Ok((tex, rtv.context("null pyro plane rtv")?))
|
||||
};
|
||||
// Plane formats/geometry follow the negotiated session: 16-bit UNORM planes for an
|
||||
// HDR (10-bit) session (P010-style studio codes from the pyro HDR CSC), full-res
|
||||
// chroma for 4:4:4 (design/pyrowave-444-hdr.md Phase 3).
|
||||
let (yf, cf) = if self.display_hdr {
|
||||
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
|
||||
} else {
|
||||
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
|
||||
};
|
||||
let (cw, ch) = if self.want_444 {
|
||||
(w, h)
|
||||
} else {
|
||||
(w / 2, h / 2)
|
||||
};
|
||||
for _ in 0..OUT_RING {
|
||||
let (y, y_rtv) = make(&self.device, yf, w, h)?;
|
||||
let (cbcr, cbcr_rtv) = make(&self.device, cf, cw, ch)?;
|
||||
self.pyro_ring.push(PyroOutSlot {
|
||||
y,
|
||||
y_rtv,
|
||||
cbcr,
|
||||
cbcr_rtv,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PyroWave: build the (mode-aware) RGB→YUV-planes CSC if not yet built. The mode is
|
||||
/// session-fixed: SDR/BGRA vs HDR/scRGB input, half- vs full-res chroma — the composition
|
||||
/// is pinned to the negotiated depth (`poll_display_hdr`), so the converter never needs a
|
||||
/// mid-session mode swap.
|
||||
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
||||
if self.pyro_conv.is_none() {
|
||||
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
|
||||
// failure before it is stored.
|
||||
self.pyro_conv = Some(unsafe {
|
||||
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
|
||||
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
|
||||
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
|
||||
@@ -1561,61 +1327,6 @@ impl IddPushCapturer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PyroWave: after this frame's GPU convert, `Signal` the shared fence and return the fence
|
||||
/// `(handle, value)` for the encoder — the persistent shared handle EVERY frame (the encoder
|
||||
/// imports it whenever it has no timeline yet, e.g. after a mode-switch rebuild) + the
|
||||
/// incrementing value. `None` for a non-PyroWave session. The fence + its shared handle are
|
||||
/// created lazily on the first call. `Flush` submits the queued convert + signal so the encoder's
|
||||
/// cross-API Vulkan timeline wait resolves promptly instead of blocking on a still-unsubmitted
|
||||
/// signal. The caller pairs the returned fence with the frame's CbCr texture into a
|
||||
/// [`PyroFrameShare`].
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the owning capture/encode thread that holds the immediate context; forms no lasting
|
||||
/// borrow of `self`'s COM objects.
|
||||
unsafe fn pyro_fence_signal(&mut self) -> Result<Option<(Option<isize>, u64)>> {
|
||||
if !self.pyrowave {
|
||||
return Ok(None);
|
||||
}
|
||||
if self.pyro_fence.is_none() {
|
||||
let dev5: ID3D11Device5 = self
|
||||
.device
|
||||
.cast()
|
||||
.context("ID3D11Device -> ID3D11Device5 (shared fence)")?;
|
||||
// windows-rs returns COM interfaces via an out-param (unlike the HANDLE-returning
|
||||
// CreateSharedHandle below).
|
||||
let mut fence_out: Option<ID3D11Fence> = None;
|
||||
dev5.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence_out)
|
||||
.context("CreateFence(D3D11_FENCE_FLAG_SHARED)")?;
|
||||
let fence = fence_out.context("null D3D11 fence")?;
|
||||
// GENERIC_ALL (0x1000_0000) — the access the pyrowave interop test hands the handle.
|
||||
let handle: HANDLE = fence
|
||||
.CreateSharedHandle(None, 0x1000_0000, PCWSTR::null())
|
||||
.context("ID3D11Fence::CreateSharedHandle")?;
|
||||
self.pyro_fence = Some(fence);
|
||||
self.pyro_fence_handle = Some(handle.0 as isize);
|
||||
self.pyro_fence_value = 0;
|
||||
}
|
||||
self.pyro_fence_value += 1;
|
||||
let value = self.pyro_fence_value;
|
||||
let ctx4: ID3D11DeviceContext4 = self
|
||||
.context
|
||||
.cast()
|
||||
.context("ID3D11DeviceContext -> ID3D11DeviceContext4 (fence signal)")?;
|
||||
{
|
||||
let fence = self.pyro_fence.as_ref().expect("fence just created");
|
||||
ctx4.Signal(fence, value)
|
||||
.context("ID3D11 fence Signal after convert")?;
|
||||
}
|
||||
// Submit the queued convert + signal so the encoder's Vulkan timeline wait can resolve.
|
||||
self.context.Flush();
|
||||
// Pass the persistent shared handle EVERY frame (not once): the encoder can be rebuilt on a
|
||||
// client mode-switch, and a rebuilt encoder needs to re-import the fence into its fresh Vulkan
|
||||
// device. The encoder imports only when it has no timeline yet (and DUPLICATES the handle so
|
||||
// this original stays valid for the next rebuild).
|
||||
Ok(Some((self.pyro_fence_handle, value)))
|
||||
}
|
||||
|
||||
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
self.log_driver_status_once();
|
||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||
@@ -1680,34 +1391,13 @@ impl IddPushCapturer {
|
||||
if seq == self.last_seq || slot >= self.slots.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release
|
||||
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring.
|
||||
self.ensure_out_ring()?;
|
||||
// Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can
|
||||
// `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||
self.ensure_converter()?;
|
||||
let i = self.out_idx;
|
||||
let (out, pyro_slot) = if self.pyrowave {
|
||||
self.ensure_pyro_ring()?;
|
||||
self.ensure_pyro_conv()?;
|
||||
let s = &self.pyro_ring[i];
|
||||
(
|
||||
None,
|
||||
Some((
|
||||
s.y.clone(),
|
||||
s.y_rtv.clone(),
|
||||
s.cbcr.clone(),
|
||||
s.cbcr_rtv.clone(),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
self.ensure_out_ring()?;
|
||||
self.ensure_converter()?;
|
||||
(Some(self.out_ring[i].clone()), None)
|
||||
};
|
||||
let out = self.out_ring[i].clone();
|
||||
let (_, pf) = self.out_format();
|
||||
let ring_len = if self.pyrowave {
|
||||
self.pyro_ring.len()
|
||||
} else {
|
||||
self.out_ring.len()
|
||||
};
|
||||
|
||||
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
|
||||
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
||||
@@ -1724,30 +1414,14 @@ impl IddPushCapturer {
|
||||
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
||||
// the slot back to the driver.
|
||||
unsafe {
|
||||
if self.pyrowave {
|
||||
// PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate
|
||||
// plane textures via the mode-aware CSC; the shared fence signalled just after
|
||||
// (`pyro_fence_signal`) orders the encoder's cross-device Vulkan read after this
|
||||
// convert. The composition format is pinned to the negotiated depth.
|
||||
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
|
||||
if let Some(conv) = self.pyro_conv.as_ref() {
|
||||
conv.convert(
|
||||
&self.context,
|
||||
&s.srv,
|
||||
y_rtv,
|
||||
cbcr_rtv,
|
||||
self.width,
|
||||
self.height,
|
||||
)?;
|
||||
}
|
||||
} else if self.display_hdr {
|
||||
if self.display_hdr {
|
||||
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
||||
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
||||
conv.convert(
|
||||
&self.device,
|
||||
&self.context,
|
||||
&s.srv,
|
||||
out.as_ref().expect("out ring"),
|
||||
&out,
|
||||
self.width,
|
||||
self.height,
|
||||
)?;
|
||||
@@ -1756,24 +1430,19 @@ impl IddPushCapturer {
|
||||
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
||||
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
|
||||
// copy-engine move; the slot releases back to the driver immediately.
|
||||
self.context
|
||||
.CopyResource(out.as_ref().expect("out ring"), &s.tex);
|
||||
self.context.CopyResource(&out, &s.tex);
|
||||
} else {
|
||||
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
||||
if let Some(conv) = self.video_conv.as_ref() {
|
||||
conv.convert(&s.tex, out.as_ref().expect("out ring"))?;
|
||||
conv.convert(&s.tex, &out)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `_lock` drops here → `ReleaseSync(0)`.
|
||||
}
|
||||
self.out_idx = (i + 1) % ring_len;
|
||||
self.out_idx = (i + 1) % self.out_ring.len();
|
||||
self.last_seq = seq;
|
||||
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() {
|
||||
self.pyro_last = Some((y.clone(), cbcr.clone()));
|
||||
} else {
|
||||
self.last_present = Some((out.as_ref().expect("out ring").clone(), pf));
|
||||
}
|
||||
self.last_present = Some((out.clone(), pf));
|
||||
let now = Instant::now();
|
||||
if self.recovering_since.take().is_some() {
|
||||
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
||||
@@ -1848,33 +1517,14 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
self.last_fresh = now; // feeds the driver-death watch
|
||||
// Build the frame. For PyroWave the encode input is the Y plane
|
||||
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
||||
// after the convert above. SAFETY: on the owning capture/encode thread.
|
||||
let (texture, pyro) = if let Some((y, _, cbcr, _)) = pyro_slot {
|
||||
// SAFETY: on the owning capture/encode thread holding the immediate context.
|
||||
let (fence_handle, fence_value) =
|
||||
unsafe { self.pyro_fence_signal() }?.expect("pyrowave session signals its fence");
|
||||
(
|
||||
y,
|
||||
Some(PyroFrameShare {
|
||||
cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(out.expect("out ring texture"), None)
|
||||
};
|
||||
Ok(Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns: now_ns(),
|
||||
format: pf,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture,
|
||||
texture: out,
|
||||
device: self.device.clone(),
|
||||
pyro,
|
||||
}),
|
||||
cursor: None,
|
||||
}))
|
||||
@@ -1885,46 +1535,8 @@ impl IddPushCapturer {
|
||||
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the
|
||||
// out-ring rotation IS the texture-ownership contract, and repeats must honor it too (audit §5.3).
|
||||
// OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight.
|
||||
let i = self.out_idx;
|
||||
// PyroWave: copy the last Y+CbCr into a fresh two-plane slot; texture = Y, CbCr + fence in `pyro`.
|
||||
if self.pyrowave {
|
||||
let (src_y, src_cbcr) = self.pyro_last.clone()?;
|
||||
let slot = self.pyro_ring.get(i)?;
|
||||
let (dst_y, dst_cbcr) = (slot.y.clone(), slot.cbcr.clone());
|
||||
// SAFETY: GPU copies on the owning thread's immediate context; src/dst are our own pyro-ring
|
||||
// plane textures of identical format/size.
|
||||
unsafe {
|
||||
self.context.CopyResource(&dst_y, &src_y);
|
||||
self.context.CopyResource(&dst_cbcr, &src_cbcr);
|
||||
}
|
||||
self.out_idx = (i + 1) % self.pyro_ring.len();
|
||||
self.pyro_last = Some((dst_y.clone(), dst_cbcr.clone()));
|
||||
// Fence the copies above so the encoder reads completed textures. SAFETY: owning thread.
|
||||
let (fence_handle, fence_value) = match unsafe { self.pyro_fence_signal() } {
|
||||
Ok(Some(f)) => f,
|
||||
_ => {
|
||||
tracing::warn!("pyrowave: fence signal failed on a repeat frame — dropping it");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
return Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns: now_ns(),
|
||||
format: self.out_format().1,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: dst_y,
|
||||
device: self.device.clone(),
|
||||
pyro: Some(PyroFrameShare {
|
||||
cbcr: dst_cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
}),
|
||||
}),
|
||||
cursor: None,
|
||||
});
|
||||
}
|
||||
let (src, pf) = self.last_present.clone()?;
|
||||
let i = self.out_idx;
|
||||
let dst = self.out_ring.get(i)?.clone();
|
||||
// SAFETY: GPU copy on the owning thread's immediate context; src/dst are our out-ring textures of
|
||||
// identical format/size (src is a previous out-ring slot; dst the next).
|
||||
@@ -1941,7 +1553,6 @@ impl IddPushCapturer {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: dst,
|
||||
device: self.device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
})
|
||||
|
||||
@@ -127,7 +127,6 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: self.default_tex.clone(),
|
||||
device: self.device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
})
|
||||
|
||||
@@ -24,19 +24,11 @@ ffmpeg-next = "8"
|
||||
opus = "0.3"
|
||||
|
||||
mdns-sd = "0.20"
|
||||
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device, so it builds wherever the
|
||||
# spawned Vulkan session presenter runs: Linux AND Windows (pyrowave-sys covers both; it
|
||||
# is an empty stub elsewhere). `ash` only wraps the presenter's existing raw handles
|
||||
# (same pinned version as pf-presenter).
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
ash = { version = "0.38", optional = true }
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
ureq = "2"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
@@ -48,6 +40,11 @@ tracing = "0.1"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pipewire = "0.9"
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device. `ash` only wraps the
|
||||
# presenter's existing raw handles (same pinned version as pf-presenter).
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
ash = { version = "0.38", optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
|
||||
@@ -41,14 +41,11 @@ mod video_software;
|
||||
mod video_vaapi;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod video_vulkan;
|
||||
// PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase).
|
||||
// Windows joined once its client moved to the SAME spawned Vulkan session presenter as
|
||||
// Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no
|
||||
// dmabuf, no D3D11 interop), so the old "Windows present-path decision" that gated it
|
||||
// resolved itself — the present path is now literally the same code.
|
||||
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
||||
// present-path decision and the Apple Metal port are their own phases).
|
||||
#[cfg(windows)]
|
||||
pub mod video_d3d11;
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub mod video_pyrowave;
|
||||
|
||||
pub mod wol;
|
||||
|
||||
@@ -227,7 +227,7 @@ fn pump(
|
||||
// the plan-§3 contract: the host only ever picks PyroWave when the client names it.
|
||||
#[allow(unused_mut)]
|
||||
let mut preferred = params.preferred_codec;
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if std::env::var("PUNKTFUNK_PREFER_PYROWAVE").as_deref() == Ok("1") {
|
||||
if params.vulkan.as_ref().is_some_and(|v| v.pyrowave_decode) {
|
||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
@@ -296,27 +296,15 @@ fn pump(
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
let mode = connector.mode();
|
||||
// The wavelet bitstream has no VUI: the negotiated Welcome colour signalling IS
|
||||
// the session's colour contract (BT.709 limited SDR today, BT.2020 PQ once the
|
||||
// HDR leg lands), and the chroma the host resolved sizes the plane ring.
|
||||
let color = crate::video::ColorDesc {
|
||||
primaries: connector.color.primaries,
|
||||
transfer: connector.color.transfer,
|
||||
matrix: connector.color.matrix,
|
||||
full_range: connector.color.full_range != 0,
|
||||
};
|
||||
match params.vulkan.as_ref() {
|
||||
Some(vk) => Decoder::new_pyrowave(
|
||||
vk,
|
||||
mode.width,
|
||||
mode.height,
|
||||
connector.shard_payload as usize,
|
||||
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||
color,
|
||||
connector.bit_depth >= 10,
|
||||
),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"pyrowave session without a presenter device"
|
||||
@@ -325,7 +313,7 @@ fn pump(
|
||||
} else {
|
||||
Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref())
|
||||
};
|
||||
#[cfg(not(all(any(target_os = "linux", windows), feature = "pyrowave")))]
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let built = Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref());
|
||||
let mut decoder = match built {
|
||||
Ok(d) => d,
|
||||
@@ -528,7 +516,7 @@ fn pump(
|
||||
DecodedImage::VkFrame(_) => "vulkan",
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(_) => "d3d11va",
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(_) => "pyrowave",
|
||||
};
|
||||
if total_frames == 1 {
|
||||
@@ -539,10 +527,7 @@ fn pump(
|
||||
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
|
||||
#[cfg(all(
|
||||
any(target_os = "linux", windows),
|
||||
feature = "pyrowave"
|
||||
))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height, "pyrowave"),
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||
//!
|
||||
//! Three backends, picked at session start (auto is vendor-ordered on BOTH desktop OSes —
|
||||
//! see [`VulkanDecodeDevice::prefer_vulkan_first`]. Linux: vaapi → vulkan → software on
|
||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh. Windows: d3d11va → vulkan → software on
|
||||
//! Intel/unknown, vulkan first on NVIDIA/AMD.
|
||||
//! Override: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`):
|
||||
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
|
||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
|
||||
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
|
||||
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||
//!
|
||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||
//! (its handles arrive via [`VulkanDecodeDevice`]) — the decoded VkImage feeds the
|
||||
@@ -23,12 +22,9 @@
|
||||
//! B-frames, in-band parameter sets on every IDR), so decode is strictly one-in/one-out.
|
||||
//!
|
||||
//! On Windows the VAAPI/dmabuf backend does not exist (DRM-PRIME is a Linux concept); the
|
||||
//! hardware pair there is Vulkan Video and **D3D11VA** (`crate::video_d3d11` — the
|
||||
//! vendor-agnostic DXVA path every Windows video player exercises), ordered per vendor:
|
||||
//! Intel's driver DOES advertise Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan
|
||||
//! on it strobes and burns the frame budget (B580 field report, 2026-07) where D3D11VA
|
||||
//! streams clean — so Intel/unknown take D3D11VA first and NVIDIA/AMD keep Vulkan first.
|
||||
//! Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
|
||||
//! chain there is Vulkan → **D3D11VA** (`crate::video_d3d11` — the vendor-agnostic DXVA
|
||||
//! path, which is how Intel's Windows driver gets hardware decode without Vulkan Video)
|
||||
//! → software. Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
|
||||
|
||||
// bindgen's C-enum repr is target-dependent (u32 on Linux/clang, i32 on MSVC), so the
|
||||
// pf-ffvk Vulkan flag/enum casts below are required on one platform and no-ops on the
|
||||
@@ -77,7 +73,7 @@ pub enum DecodedImage {
|
||||
/// PyroWave planar output: three R8 plane views on the presenter's own device,
|
||||
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
||||
/// samples them directly (BT.709 limited, the codec's fixed colour contract).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(crate::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
@@ -155,7 +151,7 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => f.keyframe,
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => f.keyframe,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => f.keyframe,
|
||||
}
|
||||
}
|
||||
@@ -171,7 +167,7 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => (f.width, f.height),
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height),
|
||||
}
|
||||
}
|
||||
@@ -238,10 +234,9 @@ enum Backend {
|
||||
#[cfg(windows)]
|
||||
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
||||
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
||||
/// no FFmpeg involvement (Linux + Windows — same Vulkan presenter on both). No demotion
|
||||
/// rung — there is no other decoder for it.
|
||||
/// no FFmpeg involvement. No demotion rung — there is no other decoder for it.
|
||||
/// Boxed: the decoder (pinned create-info hold + plane ring) dwarfs the other variants.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(Box<crate::video_pyrowave::PyroWaveDecoder>),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
@@ -255,42 +250,16 @@ pub struct Decoder {
|
||||
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
||||
/// session its hardware decoder.
|
||||
vaapi_fails: u32,
|
||||
/// When the current error streak started. Demotion needs the streak to be OLD as well
|
||||
/// as long: one startup loss burst produces 3+ consecutive failing AUs within
|
||||
/// milliseconds — demoting on count alone (live-hit: Intel iGPU, 2026-07-19, three
|
||||
/// errors in 20 ms → software forever) never gives the IDR requested on the FIRST
|
||||
/// error (~100–300 ms round trip) a chance to rescue the hardware decoder.
|
||||
first_fail: Option<std::time::Instant>,
|
||||
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
||||
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
|
||||
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
|
||||
want_keyframe: bool,
|
||||
/// The presenter has the win32 external-memory import path, so D3D11VA frames can reach
|
||||
/// the screen — kept for the mid-session Vulkan→D3D11VA demotion rung (the Windows
|
||||
/// analog of Linux's Vulkan→VAAPI rung).
|
||||
#[cfg(windows)]
|
||||
d3d11_import: bool,
|
||||
/// The presenter adapter's LUID (see [`VulkanDecodeDevice::adapter_luid`]) so a demotion
|
||||
/// rebuild lands on the SAME GPU.
|
||||
#[cfg(windows)]
|
||||
adapter_luid: Option<[u8; 8]>,
|
||||
/// [`VulkanDecodeDevice::d3d11_hdr10`], for the same demotion rebuild.
|
||||
#[cfg(windows)]
|
||||
d3d11_hdr10: bool,
|
||||
}
|
||||
|
||||
/// Demote a hardware backend (Vulkan→VAAPI/D3D11VA, VAAPI/D3D11VA→software) only after
|
||||
/// this many consecutive decode errors; a lone transient error just re-requests an IDR
|
||||
/// and keeps the hardware decoder.
|
||||
/// Demote VAAPI→software only after this many consecutive hardware decode errors; a lone
|
||||
/// transient error just re-requests an IDR and keeps the hardware decoder.
|
||||
const VAAPI_DEMOTE_AFTER: u32 = 3;
|
||||
|
||||
/// ...AND only when the streak has lasted this long. Every error re-requests an IDR, and
|
||||
/// one arriving + decoding resets the streak — so a genuinely broken driver (errors keep
|
||||
/// flowing through multiple IDR cycles) still demotes ~a second in, while a burst of
|
||||
/// consecutive bad AUs from a single loss event no longer strands the session on
|
||||
/// software before the first requested IDR could even arrive.
|
||||
const HW_DEMOTE_MIN_STREAK: std::time::Duration = std::time::Duration::from_millis(1000);
|
||||
|
||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
match wire {
|
||||
@@ -323,11 +292,11 @@ pub fn decodable_codecs() -> u8 {
|
||||
/// under its explicit opt-in.
|
||||
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
|
||||
let bits = decodable_codecs();
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if vk.map(|v| v.pyrowave_decode).unwrap_or(false) {
|
||||
return bits | punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
}
|
||||
#[cfg(not(all(any(target_os = "linux", windows), feature = "pyrowave")))]
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let _ = vk;
|
||||
bits
|
||||
}
|
||||
@@ -359,12 +328,10 @@ impl Decoder {
|
||||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||
/// hatch, and the documented knob), then the setting; both default to auto.
|
||||
/// Auto's hardware order depends on the device on BOTH desktop OSes
|
||||
/// ([`VulkanDecodeDevice::prefer_vulkan_first`]). Linux: VAAPI → Vulkan → software on
|
||||
/// Auto's hardware order on Linux depends on the device
|
||||
/// ([`VulkanDecodeDevice::prefer_vulkan_over_vaapi`]): VAAPI → Vulkan → software on
|
||||
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
|
||||
/// VanGogh. Windows (no VAAPI there): Vulkan → D3D11VA → software on NVIDIA/AMD,
|
||||
/// D3D11VA → Vulkan → software on Intel/unknown (Intel's driver advertises Vulkan
|
||||
/// Video, but FFmpeg-Vulkan on it strobes/overruns the budget — B580 field report).
|
||||
/// VanGogh. Windows is Vulkan → D3D11VA → software (no VAAPI there).
|
||||
pub fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
pref: &str,
|
||||
@@ -376,25 +343,12 @@ impl Decoder {
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| pref.to_string());
|
||||
#[cfg(windows)]
|
||||
let (d3d11_import, adapter_luid, d3d11_hdr10) = (
|
||||
vk.is_some_and(|v| v.d3d11_import),
|
||||
vk.and_then(|v| v.adapter_luid),
|
||||
vk.is_some_and(|v| v.d3d11_hdr10),
|
||||
);
|
||||
let done = |backend| {
|
||||
Ok(Decoder {
|
||||
backend,
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
first_fail: None,
|
||||
want_keyframe: false,
|
||||
#[cfg(windows)]
|
||||
d3d11_import,
|
||||
#[cfg(windows)]
|
||||
adapter_luid,
|
||||
#[cfg(windows)]
|
||||
d3d11_hdr10,
|
||||
})
|
||||
};
|
||||
// Linux `auto`: try VAAPI FIRST unless this device is one where Vulkan Video is
|
||||
@@ -409,7 +363,7 @@ impl Decoder {
|
||||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||
&& !vk
|
||||
.filter(|v| v.video_decode)
|
||||
.is_some_and(|v| v.prefer_vulkan_first())
|
||||
.is_some_and(|v| v.prefer_vulkan_over_vaapi())
|
||||
{
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
@@ -422,44 +376,6 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Windows `auto`: D3D11VA FIRST unless this device is one where Vulkan Video is
|
||||
// the established right answer (NVIDIA/AMD). Intel's Windows driver advertises
|
||||
// Vulkan Video (Arc drivers since 2023) so the capability gate alone no longer
|
||||
// keeps Intel off FFmpeg-Vulkan — and that combination is field-broken (B580,
|
||||
// 2026-07: strobing between clean anchors and corrupt inter frames that never
|
||||
// trips the error-streak demotion, 7 ms p50 decodes blowing the 120 Hz budget)
|
||||
// where D3D11VA — the DXVA path every Windows video player exercises, and what
|
||||
// this backend was built for — streams clean. Vulkan stays reachable below by
|
||||
// explicit preference and as auto's fallback when D3D11VA can't be built.
|
||||
#[cfg(windows)]
|
||||
let mut d3d11_tried = false;
|
||||
#[cfg(windows)]
|
||||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||
&& !vk
|
||||
.filter(|v| v.video_decode)
|
||||
.is_some_and(|v| v.prefer_vulkan_first())
|
||||
{
|
||||
if let Some(v) = vk.filter(|v| v.d3d11_import) {
|
||||
d3d11_tried = true;
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||||
codec_id,
|
||||
v.adapter_luid,
|
||||
v.d3d11_hdr10,
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(reason = %format!("{e:#}"),
|
||||
"D3D11VA unavailable — trying Vulkan Video");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
||||
// handle bundle even when the device has no decode queue (Windows D3D11 interop
|
||||
@@ -510,20 +426,14 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Windows: D3D11VA as the fallback rung for NVIDIA/AMD auto (Vulkan Video missing
|
||||
// or failed to open) and the explicit `d3d11va` preference — gated on the presenter
|
||||
// having the win32 external-memory import path, else its frames could never reach
|
||||
// the screen. (On Intel/unknown auto it was already tried above — `d3d11_tried`
|
||||
// skips the repeat.)
|
||||
// Windows: D3D11VA is the vendor-agnostic DXVA fallback when Vulkan Video isn't
|
||||
// available (Intel's Windows driver foremost) — gated on the presenter having the
|
||||
// win32 external-memory import path, else its frames could never reach the screen.
|
||||
#[cfg(windows)]
|
||||
if choice != "software" && choice != "vulkan" && !d3d11_tried {
|
||||
if choice != "software" && choice != "vulkan" {
|
||||
match vk.filter(|v| v.d3d11_import) {
|
||||
Some(v) => {
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||||
codec_id,
|
||||
v.adapter_luid,
|
||||
v.d3d11_hdr10,
|
||||
) {
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
@@ -573,15 +483,12 @@ impl Decoder {
|
||||
/// Open a PyroWave decoder for a `CODEC_PYROWAVE` session (plan §4.5): pyrowave
|
||||
/// compute on the presenter's device, no FFmpeg. `codec_id` is irrelevant (kept as
|
||||
/// HEVC so an — impossible — demotion path stays well-formed).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub fn new_pyrowave(
|
||||
vk: &VulkanDecodeDevice,
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
chroma444: bool,
|
||||
color: ColorDesc,
|
||||
hdr16: bool,
|
||||
) -> Result<Decoder> {
|
||||
Ok(Decoder {
|
||||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||||
@@ -589,23 +496,10 @@ impl Decoder {
|
||||
width,
|
||||
height,
|
||||
shard_payload,
|
||||
chroma444,
|
||||
color,
|
||||
hdr16,
|
||||
)?)),
|
||||
codec_id: ffmpeg::codec::Id::HEVC,
|
||||
vaapi_fails: 0,
|
||||
first_fail: None,
|
||||
want_keyframe: false,
|
||||
// A PyroWave session never demotes (nothing else decodes it — a failure
|
||||
// renegotiates the codec instead), so the D3D11VA rebuild facts are unused
|
||||
// here; keep them well-formed rather than plumbing them in for nothing.
|
||||
#[cfg(windows)]
|
||||
d3d11_import: false,
|
||||
#[cfg(windows)]
|
||||
adapter_luid: None,
|
||||
#[cfg(windows)]
|
||||
d3d11_hdr10: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -624,7 +518,6 @@ impl Decoder {
|
||||
tracing::warn!("presenter can't display hardware frames — demoting to software decode");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
self.want_keyframe = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -649,7 +542,7 @@ impl Decoder {
|
||||
au: &[u8],
|
||||
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
||||
#[cfg_attr(
|
||||
not(all(any(target_os = "linux", windows), feature = "pyrowave")),
|
||||
not(all(target_os = "linux", feature = "pyrowave")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
user_flags: u32,
|
||||
@@ -667,7 +560,7 @@ impl Decoder {
|
||||
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
||||
// error; the pump surfaces it and the session falls back to HEVC by
|
||||
// renegotiation (plan §4.6), not by decoder swap.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
Backend::PyroWave(p) => {
|
||||
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
||||
return Ok(p
|
||||
@@ -679,7 +572,6 @@ impl Decoder {
|
||||
match result {
|
||||
Ok(f) => {
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
Ok(f)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -691,9 +583,7 @@ impl Decoder {
|
||||
};
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
let first = *self.first_fail.get_or_insert_with(std::time::Instant::now);
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER && first.elapsed() >= HW_DEMOTE_MIN_STREAK
|
||||
{
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
// A failing Vulkan backend still has a hardware rung below it on
|
||||
// Linux — demote to VAAPI first (user-reported: FFmpeg-Vulkan-on-Mesa
|
||||
// error-streaking where VAAPI streams perfectly); only when that
|
||||
@@ -706,39 +596,16 @@ impl Decoder {
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(va) => tracing::info!(reason = %va,
|
||||
"VAAPI unavailable for demotion — software decode"),
|
||||
}
|
||||
}
|
||||
// Windows' hardware rung below Vulkan is D3D11VA (a 4K120 stream is
|
||||
// not survivable on software) — same-GPU rebuild via the stashed LUID.
|
||||
#[cfg(windows)]
|
||||
if matches!(self.backend, Backend::Vulkan(_)) && self.d3d11_import {
|
||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||||
self.codec_id,
|
||||
self.adapter_luid,
|
||||
self.d3d11_hdr10,
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"Vulkan Video decode failing repeatedly — demoting to D3D11VA");
|
||||
self.backend = Backend::D3d11va(d);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(dx) => tracing::info!(reason = %dx,
|
||||
"D3D11VA unavailable for demotion — software decode"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"{which} decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
self.first_fail = None;
|
||||
} else {
|
||||
tracing::debug!(backend = which, error = %e,
|
||||
"decode error — requesting keyframe, keeping hardware decode");
|
||||
@@ -845,10 +712,10 @@ pub struct VulkanDecodeDevice {
|
||||
pub physical_device: usize,
|
||||
pub device: usize,
|
||||
/// PCI vendor of the presenter's physical device (0x10DE NVIDIA, 0x1002 AMD,
|
||||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_first`].
|
||||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_over_vaapi`].
|
||||
pub vendor_id: u32,
|
||||
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
|
||||
/// detection for [`Self::prefer_vulkan_first`].
|
||||
/// detection for [`Self::prefer_vulkan_over_vaapi`].
|
||||
pub device_name: String,
|
||||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||
pub graphics_qf: u32,
|
||||
@@ -891,10 +758,6 @@ pub struct VulkanDecodeDevice {
|
||||
/// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`:
|
||||
/// D3D11 shared-texture frames can reach the screen. Always `false` off Windows.
|
||||
pub d3d11_import: bool,
|
||||
/// The presenter can also import the RGB10A2 hand-off texture AND offers an HDR10
|
||||
/// swapchain — the D3D11VA backend emits its HDR (RGB10 PQ pass-through) ring flavor
|
||||
/// for PQ streams instead of tone-mapping to sRGB. Always `false` off Windows.
|
||||
pub d3d11_hdr10: bool,
|
||||
/// `VkPhysicalDeviceIDProperties::deviceLUID` when the driver reports one — the D3D11VA
|
||||
/// backend creates its decode device on the SAME adapter so shared textures never cross
|
||||
/// GPUs. `None` when not reported (or off Windows, where it's unused).
|
||||
@@ -906,11 +769,9 @@ pub struct VulkanDecodeDevice {
|
||||
}
|
||||
|
||||
impl VulkanDecodeDevice {
|
||||
/// Should `auto` try Vulkan Video BEFORE the platform's other hardware path (VAAPI on
|
||||
/// Linux, D3D11VA on Windows) on this device?
|
||||
/// * **NVIDIA** — Vulkan Video is the proven path (on Linux the only one: no usable
|
||||
/// VAAPI — the nvidia-vaapi-driver is broken for this, Moonlight blacklists it;
|
||||
/// on Windows it's the validated zero-copy default, 4K@144 with 0.1 ms decode).
|
||||
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device?
|
||||
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
|
||||
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
|
||||
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
||||
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
||||
/// additionally shows chroma fringing; the session binary opts RADV into
|
||||
@@ -918,11 +779,10 @@ impl VulkanDecodeDevice {
|
||||
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
|
||||
/// so a broken Mesa Vulkan path still lands on the working driver.
|
||||
///
|
||||
/// Intel and unknown vendors take the battle-tested path first: VAAPI on Linux (ANV's
|
||||
/// Vulkan Video is the least-proven Mesa path), D3D11VA on Windows — Intel's Windows
|
||||
/// driver advertises Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan on it is
|
||||
/// field-broken (B580, 2026-07: strobing + ~7 ms decodes) where DXVA streams clean.
|
||||
pub fn prefer_vulkan_first(&self) -> bool {
|
||||
/// Intel (ANV) and unknown vendors keep the battle-tested zero-copy VAAPI first —
|
||||
/// ANV's Vulkan Video is the least-proven Mesa path and VAAPI is what every other
|
||||
/// Linux client uses there.
|
||||
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
|
||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||
const VENDOR_AMD: u32 = 0x1002;
|
||||
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
||||
@@ -979,33 +839,30 @@ mod tests {
|
||||
pyrowave_decode: false,
|
||||
video_decode: true,
|
||||
d3d11_import: false,
|
||||
d3d11_hdr10: false,
|
||||
adapter_luid: None,
|
||||
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto's hardware order (both OSes): Vulkan-first on NVIDIA (on Linux: no usable
|
||||
/// VAAPI) and ALL AMD (Vulkan decode outperforms VAAPI on RADV — on-glass verdict;
|
||||
/// VanGogh additionally chroma-fringes over VAAPI); Intel/unknown take the proven
|
||||
/// path first — VAAPI on Linux (ANV's Vulkan Video is the least-proven Mesa path),
|
||||
/// D3D11VA on Windows (Intel's driver advertises Vulkan Video since 2023, but
|
||||
/// FFmpeg-Vulkan on it strobes — B580 field report). A Vulkan failure streak still
|
||||
/// demotes to hardware (VAAPI/D3D11VA), so Vulkan-first can never strand a box on
|
||||
/// software decode.
|
||||
/// Auto's Linux hardware order: Vulkan-first on NVIDIA (no usable VAAPI) and ALL AMD
|
||||
/// (Vulkan decode outperforms VAAPI on RADV — on-glass verdict; VanGogh additionally
|
||||
/// chroma-fringes over VAAPI); Intel/unknown keep VAAPI first (ANV's Vulkan Video is
|
||||
/// the least-proven Mesa path). A Vulkan failure streak still demotes to VAAPI, so
|
||||
/// Vulkan-first can never strand a box on software decode.
|
||||
#[test]
|
||||
fn vulkan_first_on_nvidia_and_amd_only() {
|
||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_first());
|
||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_first());
|
||||
assert!(decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_first());
|
||||
assert!(decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_first());
|
||||
fn vulkan_over_vaapi_on_nvidia_and_amd() {
|
||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
|
||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
|
||||
assert!(
|
||||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)").prefer_vulkan_first()
|
||||
decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)")
|
||||
.prefer_vulkan_over_vaapi()
|
||||
);
|
||||
// The Windows-side motivation: discrete Arc advertises Vulkan Video and must
|
||||
// still land on D3D11VA in auto.
|
||||
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) B580 Graphics").prefer_vulkan_first());
|
||||
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) Pro Graphics").prefer_vulkan_first());
|
||||
}
|
||||
|
||||
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! D3D11VA hardware decode (Windows) for the Vulkan presenter — the vendor-agnostic DXVA
|
||||
//! path, and auto's FIRST choice on Intel/unknown vendors. Intel's Windows driver DOES
|
||||
//! advertise Vulkan Video (Arc drivers since 2023 — don't trust the capability gate to
|
||||
//! keep Intel off it), but FFmpeg-Vulkan on it is field-broken (B580, 2026-07: strobing +
|
||||
//! ~7 ms decodes) where this path streams clean; on NVIDIA/AMD it is the fallback rung
|
||||
//! below Vulkan Video, in `auto` and via mid-session demotion.
|
||||
//! path that covers what Vulkan Video can't (Intel's Windows driver foremost, which has no
|
||||
//! video-decode queue and previously landed on CPU decode).
|
||||
//!
|
||||
//! Ported from the retired in-process WinUI presenter's decoder (`clients/windows/src/video.rs`)
|
||||
//! with one structural change: that presenter sampled D3D11 textures directly, while ours draws
|
||||
@@ -27,11 +24,9 @@
|
||||
//! (`VK_KHR_win32_keyed_mutex`); both sides take and release it with **key 0**: a frame the
|
||||
//! presenter drops (arrival-paced, newest wins) is simply never acquired, which a
|
||||
//! key-ping-pong protocol would deadlock on.
|
||||
//! * An HDR (PQ/BT.2020) stream passes through when the presenter can take it (RGB10A2
|
||||
//! import + an HDR10 swapchain — [`crate::video::VulkanDecodeDevice::d3d11_hdr10`]): the
|
||||
//! video processor converts YCbCr G2084 → RGB G2084 into an RGB10A2 ring, colorspace
|
||||
//! only, no tone mapping. On an SDR-only path it tone-maps to sRGB instead (input
|
||||
//! `G2084_P2020`, output sRGB) — correct picture, no HDR presentation.
|
||||
//! * An HDR (PQ/BT.2020) stream is tone-mapped to SDR by the video processor (input colour
|
||||
//! space `G2084_P2020`, output sRGB): correct picture, no HDR presentation on this backend —
|
||||
//! its targets (Intel iGPU laptops) are SDR panels; HDR-first boxes take Vulkan Video.
|
||||
//!
|
||||
//! The decode device is created on the **presenter's adapter** (matched by the Vulkan device's
|
||||
//! LUID) so the shared textures never cross GPUs on a multi-adapter box.
|
||||
@@ -42,7 +37,7 @@ use ffmpeg_next as ffmpeg;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use windows::core::{Interface, GUID};
|
||||
use windows::Win32::Foundation::{HANDLE, RECT};
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
||||
@@ -57,12 +52,11 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_RATIONAL,
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
|
||||
DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
@@ -101,14 +95,10 @@ const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59
|
||||
pub struct D3d11Frame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// What the ring slot actually CONTAINS after the video processor's conversion:
|
||||
/// sRGB BT.709 full-range RGB normally (a PQ stream was tone-mapped), or PQ BT.2020
|
||||
/// full-range RGB when the HDR pass-through ring is active (`rgb10`) — the presenter
|
||||
/// keys its SDR/HDR handling off this.
|
||||
/// What the ring slot actually CONTAINS after the video processor's conversion: sRGB
|
||||
/// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was
|
||||
/// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR.
|
||||
pub color: ColorDesc,
|
||||
/// The ring slot's texture format: `false` = BGRA8, `true` = RGB10A2 (the HDR PQ
|
||||
/// pass-through flavor) — the presenter's Vulkan import must match it exactly.
|
||||
pub rgb10: bool,
|
||||
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See
|
||||
/// `crate::video::VkVideoFrame`.
|
||||
pub keyframe: bool,
|
||||
@@ -341,9 +331,6 @@ struct SharedRing {
|
||||
height: u32,
|
||||
next: usize,
|
||||
generation: u32,
|
||||
/// HDR flavor: RGB10A2 slots the processor fills with PQ BT.2020 RGB (colorspace
|
||||
/// conversion only — both sides G2084, no tone mapping). `false` = BGRA8 sRGB.
|
||||
pq_out: bool,
|
||||
}
|
||||
|
||||
impl SharedRing {
|
||||
@@ -353,7 +340,6 @@ impl SharedRing {
|
||||
width: u32,
|
||||
height: u32,
|
||||
generation: u32,
|
||||
pq_out: bool,
|
||||
) -> Result<SharedRing> {
|
||||
// The video processor: NV12/P010 in, BGRA8 out, 1:1 (no scaling — the Vulkan side
|
||||
// scales at composite time like every other path). Frame rates are advisory.
|
||||
@@ -383,15 +369,10 @@ impl SharedRing {
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
// Single-plane RGB: the ONLY hand-off family whose Vulkan import is a
|
||||
// Single-plane BGRA8: the ONLY hand-off format whose Vulkan import is a
|
||||
// universally exercised driver path (see the module docs — NV12 import TDRs
|
||||
// on NVIDIA despite being advertised). RGB10A2 for the HDR pass-through
|
||||
// flavor (gated on the presenter's probe), BGRA8 otherwise.
|
||||
Format: if pq_out {
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
},
|
||||
// on NVIDIA despite being advertised).
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
@@ -449,8 +430,7 @@ impl SharedRing {
|
||||
height,
|
||||
slots = RING_SLOTS,
|
||||
generation,
|
||||
hdr = pq_out,
|
||||
"D3D11 shared hand-off ring built (VideoProcessor → RGB)"
|
||||
"D3D11 shared hand-off ring built (VideoProcessor → BGRA8)"
|
||||
);
|
||||
Ok(SharedRing {
|
||||
slots,
|
||||
@@ -460,7 +440,6 @@ impl SharedRing {
|
||||
height,
|
||||
next: 0,
|
||||
generation,
|
||||
pq_out,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -478,10 +457,6 @@ pub(crate) struct D3d11vaDecoder {
|
||||
/// setters (Win10 1703+, universally present — init fails to software without it).
|
||||
video_context1: ID3D11VideoContext1,
|
||||
ring: Option<SharedRing>,
|
||||
/// The presenter can import RGB10A2 AND offers an HDR10 swapchain
|
||||
/// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR
|
||||
/// pass-through ring; without it they keep the tonemap-to-sRGB ring.
|
||||
hdr10_out: bool,
|
||||
}
|
||||
|
||||
// Single-owner pointers + COM interfaces, only touched from the session pump thread (the
|
||||
@@ -492,7 +467,6 @@ impl D3d11vaDecoder {
|
||||
pub(crate) fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
luid: Option<[u8; 8]>,
|
||||
hdr10_out: bool,
|
||||
) -> Result<D3d11vaDecoder> {
|
||||
use ffmpeg::ffi;
|
||||
let (device, context) = create_device(luid)?;
|
||||
@@ -563,7 +537,6 @@ impl D3d11vaDecoder {
|
||||
video_device,
|
||||
video_context1,
|
||||
ring: None,
|
||||
hdr10_out,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -618,15 +591,12 @@ impl D3d11vaDecoder {
|
||||
let video_device = self.video_device.clone();
|
||||
let video_context1 = self.video_context1.clone();
|
||||
let context = self.context.clone();
|
||||
// (Re)build the ring + video processor on first use, a stream size change, or a
|
||||
// flavor change (the host flips PQ in-band; SDR↔HDR swaps the slot format, so
|
||||
// it rebuilds like a resize — bit DEPTH alone still never rebuilds: an SDR
|
||||
// 10-bit stream and an 8-bit one share the same output flavor).
|
||||
let pq_out = self.hdr10_out && color.is_pq();
|
||||
// (Re)build the ring + video processor on first use or a stream size change (the
|
||||
// hand-off is BGRA8 regardless of the stream's bit depth, so depth never rebuilds).
|
||||
let rebuild = self
|
||||
.ring
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.width != width || r.height != height || r.pq_out != pq_out);
|
||||
.is_none_or(|r| r.width != width || r.height != height);
|
||||
if rebuild {
|
||||
let generation = self.ring.as_ref().map_or(0, |r| r.generation + 1);
|
||||
self.ring = Some(SharedRing::build(
|
||||
@@ -635,7 +605,6 @@ impl D3d11vaDecoder {
|
||||
width,
|
||||
height,
|
||||
generation,
|
||||
pq_out,
|
||||
)?);
|
||||
}
|
||||
let ring = self.ring.as_mut().expect("ring built above");
|
||||
@@ -678,36 +647,10 @@ impl D3d11vaDecoder {
|
||||
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
};
|
||||
// The DECODE surface is DXVA-aligned (height rounded up to the profile's
|
||||
// macroblock/tile alignment — 128 for HEVC/AV1), so it is TALLER than the
|
||||
// frame: a 2400-line stream decodes into a 2432-line texture. Without an
|
||||
// explicit source rect the processor blits the WHOLE surface — the padding
|
||||
// rows (uninitialized NV12: Y=0,U=V=0, which converts to vivid green) land at
|
||||
// the bottom of the output and the picture is squashed to fit. Clamp the
|
||||
// source to the real frame; the dest stays the whole (frame-sized) slot.
|
||||
// Live-hit on Intel 3840x2400 as a ~32 px green bar (2026-07-19).
|
||||
video_context1.VideoProcessorSetStreamSourceRect(
|
||||
&ring.vp,
|
||||
0,
|
||||
true,
|
||||
Some(&RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width as i32,
|
||||
bottom: height as i32,
|
||||
}),
|
||||
);
|
||||
video_context1.VideoProcessorSetStreamColorSpace1(&ring.vp, 0, in_cs);
|
||||
video_context1.VideoProcessorSetOutputColorSpace1(
|
||||
&ring.vp,
|
||||
// HDR ring: PQ in, PQ out — a pure colorspace conversion (YCbCr→RGB),
|
||||
// no tone mapping; the presenter passes the values through to its HDR10
|
||||
// swapchain. SDR ring: sRGB out (a PQ stream is tone-mapped here).
|
||||
if ring.pq_out {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
|
||||
} else {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||
},
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
);
|
||||
|
||||
let stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
||||
@@ -741,38 +684,17 @@ impl D3d11vaDecoder {
|
||||
// completion, and an unflushed deferred batch would add a driver-decided delay.
|
||||
context.Flush();
|
||||
|
||||
let mut src_desc = D3D11_TEXTURE2D_DESC::default();
|
||||
src.GetDesc(&mut src_desc);
|
||||
log_layout_once(
|
||||
width,
|
||||
height,
|
||||
src_desc.Width,
|
||||
src_desc.Height,
|
||||
index,
|
||||
color.is_pq(),
|
||||
);
|
||||
log_layout_once(width, height, index, color.is_pq());
|
||||
Ok(D3d11Frame {
|
||||
width,
|
||||
height,
|
||||
// What the slot now CONTAINS. HDR ring: PQ BT.2020 full-range RGB (the
|
||||
// presenter reads is_pq() and flips its HDR10 swapchain). SDR ring: sRGB
|
||||
// BT.709 full-range RGB (PQ was tone-mapped above).
|
||||
color: if ring.pq_out {
|
||||
ColorDesc {
|
||||
primaries: 9,
|
||||
transfer: 16, // PQ / SMPTE ST.2084
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
}
|
||||
} else {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 13, // sRGB (H.273)
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
}
|
||||
// What the slot now CONTAINS: sRGB BT.709 full-range RGB (PQ was tone-mapped).
|
||||
color: ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 13, // sRGB (H.273)
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
},
|
||||
rgb10: ring.pq_out,
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame for this call.
|
||||
keyframe: crate::video::frame_is_keyframe(self.frame),
|
||||
handle,
|
||||
@@ -798,20 +720,10 @@ impl Drop for D3d11vaDecoder {
|
||||
}
|
||||
|
||||
/// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver.
|
||||
/// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the
|
||||
/// stream source rect excludes.
|
||||
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
|
||||
fn log_layout_once(width: u32, height: u32, index: u32, pq: bool) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
tex_w,
|
||||
tex_h,
|
||||
slice = index,
|
||||
pq,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
tracing::info!(width, height, slice = index, pq, "D3D11VA first frame");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,12 +258,11 @@ unsafe fn make_plane(
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
w: u32,
|
||||
h: u32,
|
||||
fmt: vk::Format,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||
let img = device.create_image(
|
||||
&vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.format(vk::Format::R8_UNORM)
|
||||
.extent(vk::Extent3D {
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -307,7 +306,7 @@ unsafe fn make_plane(
|
||||
&vk::ImageViewCreateInfo::default()
|
||||
.image(img)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.format(vk::Format::R8_UNORM)
|
||||
.subresource_range(vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
base_mip_level: 0,
|
||||
@@ -348,21 +347,12 @@ unsafe fn build_ring(
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
width: u32,
|
||||
height: u32,
|
||||
chroma444: bool,
|
||||
fmt: vk::Format,
|
||||
) -> Result<Vec<PlaneSet>> {
|
||||
// 4:2:0 = half-res chroma; 4:4:4 = full-res. The presenter's planar CSC samples with
|
||||
// normalized UVs, so the chroma plane resolution is transparent to it.
|
||||
let (cw, ch) = if chroma444 {
|
||||
(width, height)
|
||||
} else {
|
||||
(width / 2, height / 2)
|
||||
};
|
||||
let mut ring: Vec<PlaneSet> = Vec::with_capacity(RING);
|
||||
for _ in 0..RING {
|
||||
let built = (|| -> Result<PlaneSet> {
|
||||
let (y, ym, yv) = make_plane(device, mem_props, width, height, fmt)?;
|
||||
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch, fmt) {
|
||||
let (y, ym, yv) = make_plane(device, mem_props, width, height)?;
|
||||
let (cb, cbm, cbv) = match make_plane(device, mem_props, width / 2, height / 2) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
device.destroy_image_view(yv, None);
|
||||
@@ -371,7 +361,7 @@ unsafe fn build_ring(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch, fmt) {
|
||||
let (cr, crm, crv) = match make_plane(device, mem_props, width / 2, height / 2) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
|
||||
@@ -419,16 +409,6 @@ pub struct PyroWaveDecoder {
|
||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Session-fixed negotiated chroma ([`Welcome::chroma_format`]): 4:4:4 = full-res
|
||||
/// chroma planes + `Chroma444` pyrowave decoders (the seq-header bit is
|
||||
/// decoder-enforced upstream, so a mismatch fails loudly, never silently).
|
||||
chroma444: bool,
|
||||
/// Session colour signalling ([`Welcome::color`]): the wavelet bitstream has no VUI,
|
||||
/// so the negotiated `ColorInfo` is the contract the presenter CSC configures from.
|
||||
color: ColorDesc,
|
||||
/// Session-fixed negotiated depth ≥10: the planes are `R16_UNORM` carrying the host's
|
||||
/// P010-style studio codes (the presenter samples them with depth-10 MSB-packed rows).
|
||||
hdr16: bool,
|
||||
/// The wire shard payload — the parse-window size for chunk-aligned AUs (§4.4): each
|
||||
/// window holds whole self-delimiting codec packets, zero-padded to the window.
|
||||
wire_window: usize,
|
||||
@@ -444,20 +424,17 @@ impl PyroWaveDecoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
chroma444: bool,
|
||||
color: ColorDesc,
|
||||
hdr16: bool,
|
||||
) -> Result<PyroWaveDecoder> {
|
||||
if !vkd.pyrowave_decode {
|
||||
bail!("presenter device lacks the PyroWave compute feature set");
|
||||
}
|
||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
// SAFETY: the handles in `vkd` are the presenter's live instance/device (it
|
||||
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
|
||||
// `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime.
|
||||
unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color, hdr16) }
|
||||
unsafe { Self::new_inner(vkd, width, height, shard_payload) }
|
||||
}
|
||||
|
||||
unsafe fn new_inner(
|
||||
@@ -465,9 +442,6 @@ impl PyroWaveDecoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
chroma444: bool,
|
||||
color: ColorDesc,
|
||||
hdr16: bool,
|
||||
) -> Result<PyroWaveDecoder> {
|
||||
let static_fn = ash::StaticFn {
|
||||
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
||||
@@ -522,11 +496,7 @@ impl PyroWaveDecoder {
|
||||
device: pw_dev,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
chroma: if chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
// The fragment-iDWT path is for Mali/Adreno-class mobile GPUs only.
|
||||
fragment_path: false,
|
||||
};
|
||||
@@ -543,27 +513,7 @@ impl PyroWaveDecoder {
|
||||
let mem_props = instance.get_physical_device_memory_properties(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
);
|
||||
// 16-bit sessions decode into R16_UNORM storage planes; STORAGE_IMAGE support for
|
||||
// R16_UNORM is optional in Vulkan (universal on desktop) — probe it so an exotic
|
||||
// device fails with a clear message instead of a validation error.
|
||||
let plane_fmt = if hdr16 {
|
||||
let props = instance.get_physical_device_format_properties(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
vk::Format::R16_UNORM,
|
||||
);
|
||||
if !props
|
||||
.optimal_tiling_features
|
||||
.contains(vk::FormatFeatureFlags::STORAGE_IMAGE)
|
||||
{
|
||||
pw::pyrowave_decoder_destroy(pw_dec);
|
||||
pw::pyrowave_device_destroy(pw_dev);
|
||||
bail!("this GPU lacks R16_UNORM STORAGE_IMAGE — cannot decode a 10-bit PyroWave session");
|
||||
}
|
||||
vk::Format::R16_UNORM
|
||||
} else {
|
||||
vk::Format::R8_UNORM
|
||||
};
|
||||
let ring = match build_ring(&device, &mem_props, width, height, chroma444, plane_fmt) {
|
||||
let ring = match build_ring(&device, &mem_props, width, height) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
pw::pyrowave_decoder_destroy(pw_dec);
|
||||
@@ -607,9 +557,6 @@ impl PyroWaveDecoder {
|
||||
mem_props,
|
||||
width,
|
||||
height,
|
||||
chroma444,
|
||||
color,
|
||||
hdr16,
|
||||
wire_window: shard_payload.max(64),
|
||||
})
|
||||
}
|
||||
@@ -622,19 +569,14 @@ impl PyroWaveDecoder {
|
||||
/// The old ring is RETIRED, not destroyed: the presenter / frame channel may still
|
||||
/// reference its views (see [`RETIRE_HANDOVERS`]).
|
||||
unsafe fn reconfigure(&mut self, width: u32, height: u32) -> Result<()> {
|
||||
if !self.chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (resize to {width}x{height})");
|
||||
}
|
||||
let dinfo = pw::pyrowave_decoder_create_info {
|
||||
device: self.pw_dev,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
// Chroma is session-fixed (negotiated); a resize never changes it.
|
||||
chroma: if self.chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
fragment_path: false,
|
||||
};
|
||||
let mut new_dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||
@@ -642,18 +584,7 @@ impl PyroWaveDecoder {
|
||||
pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
|
||||
"decoder_create (mid-stream resize)",
|
||||
)?;
|
||||
let new_ring = match build_ring(
|
||||
&self.device,
|
||||
&self.mem_props,
|
||||
width,
|
||||
height,
|
||||
self.chroma444,
|
||||
if self.hdr16 {
|
||||
vk::Format::R16_UNORM
|
||||
} else {
|
||||
vk::Format::R8_UNORM
|
||||
},
|
||||
) {
|
||||
let new_ring = match build_ring(&self.device, &self.mem_props, width, height) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
pw::pyrowave_decoder_destroy(new_dec);
|
||||
@@ -955,10 +886,15 @@ impl PyroWaveDecoder {
|
||||
],
|
||||
width: w,
|
||||
height: h,
|
||||
// No VUI in the bitstream: the negotiated Welcome `ColorInfo` is the contract
|
||||
// with the host's CSC (BT.709 limited for SDR sessions; BT.2020 PQ once the
|
||||
// HDR leg lands — design/pyrowave-444-hdr.md).
|
||||
color: self.color,
|
||||
// No VUI in the bitstream: BT.709 limited is the fixed contract with the
|
||||
// host's CSC (plan §4.7 CscRows note; sequence-header signaling is a
|
||||
// follow-up once the C API exposes it).
|
||||
color: ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 1,
|
||||
matrix: 1,
|
||||
full_range: false,
|
||||
},
|
||||
keyframe: true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# `start`), so it stays free of platform cfg.
|
||||
[package]
|
||||
name = "pf-clipboard"
|
||||
version.workspace = true
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -22,7 +22,7 @@ quinn = "0.11"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
|
||||
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "bmp", "jpeg", "gif"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
|
||||
|
||||
@@ -235,12 +235,6 @@ pub const WIRE_HTML: &str = "text/html";
|
||||
pub const WIRE_RTF: &str = "text/rtf";
|
||||
/// Wire MIME for a PNG image.
|
||||
pub const WIRE_PNG: &str = "image/png";
|
||||
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
|
||||
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
|
||||
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
|
||||
pub const WIRE_JPEG: &str = "image/jpeg";
|
||||
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
|
||||
pub const WIRE_GIF: &str = "image/gif";
|
||||
|
||||
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
|
||||
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
|
||||
@@ -254,8 +248,6 @@ pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
|
||||
"text/html" => Some(WIRE_HTML),
|
||||
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
|
||||
"image/png" => Some(WIRE_PNG),
|
||||
"image/jpeg" => Some(WIRE_JPEG),
|
||||
"image/gif" => Some(WIRE_GIF),
|
||||
_ => match base {
|
||||
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
|
||||
_ => None,
|
||||
@@ -278,8 +270,6 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
|
||||
WIRE_HTML => &["text/html"],
|
||||
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
|
||||
WIRE_PNG => &["image/png"],
|
||||
WIRE_JPEG => &["image/jpeg"],
|
||||
WIRE_GIF => &["image/gif"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -340,8 +330,6 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
|
||||
push("text/rtf");
|
||||
}
|
||||
WIRE_PNG => push("image/png"),
|
||||
WIRE_JPEG => push("image/jpeg"),
|
||||
WIRE_GIF => push("image/gif"),
|
||||
other => push(other),
|
||||
}
|
||||
}
|
||||
@@ -367,13 +355,10 @@ mod tests {
|
||||
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
|
||||
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
|
||||
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
|
||||
// Original image formats now map to their own wire kinds (verbatim pass-through).
|
||||
assert_eq!(wayland_to_wire("image/jpeg"), Some(WIRE_JPEG));
|
||||
assert_eq!(wayland_to_wire("image/gif"), Some(WIRE_GIF));
|
||||
// Internal targets and unsupported formats are dropped.
|
||||
assert_eq!(wayland_to_wire("TARGETS"), None);
|
||||
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
|
||||
assert_eq!(wayland_to_wire("image/webp"), None);
|
||||
assert_eq!(wayland_to_wire("image/jpeg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -48,9 +48,7 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
|
||||
};
|
||||
|
||||
use super::winfmt;
|
||||
use super::{
|
||||
ClipEvent, PasteResponder, WIRE_GIF, WIRE_HTML, WIRE_JPEG, WIRE_PNG, WIRE_RTF, WIRE_TEXT,
|
||||
};
|
||||
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
|
||||
|
||||
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
|
||||
const WM_APP_CMD: u32 = WM_APP + 1;
|
||||
@@ -100,13 +98,6 @@ struct WinClip {
|
||||
fmt_html: u32,
|
||||
fmt_rtf: u32,
|
||||
fmt_png: u32,
|
||||
/// Registered `"JFIF"` — the conventional raw-JPEG clipboard format (Office/browsers).
|
||||
fmt_jfif: u32,
|
||||
/// Registered `"GIF"` — raw GIF bytes (animation preserved by verbatim pass-through).
|
||||
fmt_gif: u32,
|
||||
/// The wire MIMEs of the offer currently promised via delayed rendering — `WM_RENDERFORMAT`
|
||||
/// for the synthesized `CF_DIB` picks the richest image kind among them to fetch + convert.
|
||||
offered_wires: RefCell<Vec<String>>,
|
||||
/// Our own message window — used for the owner-check and clipboard opens.
|
||||
own_hwnd: HWND,
|
||||
}
|
||||
@@ -146,15 +137,6 @@ impl WinClip {
|
||||
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
|
||||
out.push(WIRE_PNG.to_string());
|
||||
}
|
||||
// Original lossy/animated formats offered VERBATIM beside the PNG floor — the client
|
||||
// picks the richest kind it can place, so a copied JPEG never balloons into PNG and a
|
||||
// GIF keeps its animation.
|
||||
if avail(self.fmt_jfif) {
|
||||
out.push(WIRE_JPEG.to_string());
|
||||
}
|
||||
if avail(self.fmt_gif) {
|
||||
out.push(WIRE_GIF.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -210,7 +192,6 @@ impl WinClip {
|
||||
}
|
||||
}
|
||||
*self.offered.borrow_mut() = fmts;
|
||||
*self.offered_wires.borrow_mut() = wire.to_vec();
|
||||
}
|
||||
|
||||
/// Drop the selection we own (empty the clipboard iff we're still its owner).
|
||||
@@ -284,23 +265,8 @@ impl WinClip {
|
||||
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
|
||||
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
|
||||
fn on_render_format(&self, fmt: u32) {
|
||||
// The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
|
||||
// kind the client offered (PNG first — lossless with alpha — then JPEG, then GIF's first
|
||||
// frame) and convert. Every other format maps 1:1.
|
||||
let wire: &str = if fmt == CF_DIB.0 as u32 {
|
||||
let offered = self.offered_wires.borrow();
|
||||
match [WIRE_PNG, WIRE_JPEG, WIRE_GIF]
|
||||
.into_iter()
|
||||
.find(|w| offered.iter().any(|o| o == w))
|
||||
{
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
}
|
||||
} else {
|
||||
match self.wire_for_format(fmt) {
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
}
|
||||
let Some(wire) = self.wire_for_format(fmt) else {
|
||||
return;
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let ev = ClipEvent::Paste {
|
||||
@@ -315,9 +281,9 @@ impl WinClip {
|
||||
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
||||
};
|
||||
let win_bytes = if fmt == CF_DIB.0 as u32 {
|
||||
// The app asked for a bitmap: convert whatever image kind the client served. Bytes
|
||||
// that don't decode leave the format unrendered (empty paste), like the timeout path.
|
||||
match winfmt::image_to_dib(&bytes) {
|
||||
// The app asked for a bitmap: the client served PNG — convert. A PNG that doesn't
|
||||
// decode leaves the format unrendered (empty paste), matching the timeout path.
|
||||
match winfmt::png_to_dib(&bytes) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
}
|
||||
@@ -344,8 +310,6 @@ impl WinClip {
|
||||
WIRE_HTML => Some(self.fmt_html),
|
||||
WIRE_RTF => Some(self.fmt_rtf),
|
||||
WIRE_PNG => Some(self.fmt_png),
|
||||
WIRE_JPEG => Some(self.fmt_jfif),
|
||||
WIRE_GIF => Some(self.fmt_gif),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -358,12 +322,8 @@ impl WinClip {
|
||||
Some(WIRE_HTML)
|
||||
} else if fmt == self.fmt_rtf {
|
||||
Some(WIRE_RTF)
|
||||
} else if fmt == self.fmt_png {
|
||||
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 {
|
||||
Some(WIRE_PNG)
|
||||
} else if fmt == self.fmt_jfif {
|
||||
Some(WIRE_JPEG)
|
||||
} else if fmt == self.fmt_gif {
|
||||
Some(WIRE_GIF)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -380,12 +340,9 @@ impl WinClip {
|
||||
}
|
||||
}
|
||||
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
|
||||
// ask for the bitmap family, not the registered image formats; Windows synthesizes
|
||||
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
|
||||
// offered image kind and converts on demand.
|
||||
if matches!(w.as_str(), WIRE_PNG | WIRE_JPEG | WIRE_GIF)
|
||||
&& !out.contains(&(CF_DIB.0 as u32))
|
||||
{
|
||||
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes
|
||||
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand.
|
||||
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) {
|
||||
out.push(CF_DIB.0 as u32);
|
||||
}
|
||||
}
|
||||
@@ -419,18 +376,12 @@ impl WindowsClipboard {
|
||||
let fmt_html = register_format(w!("HTML Format"))?;
|
||||
let fmt_rtf = register_format(w!("Rich Text Format"))?;
|
||||
let fmt_png = register_format(w!("PNG"))?;
|
||||
let fmt_jfif = register_format(w!("JFIF"))?;
|
||||
let fmt_gif = register_format(w!("GIF"))?;
|
||||
|
||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
|
||||
let cw = Arc::clone(¤t_wire);
|
||||
let join = std::thread::Builder::new()
|
||||
.name("punktfunk-clipboard-win".into())
|
||||
.spawn(move || {
|
||||
pump_thread(
|
||||
clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, fmt_jfif, fmt_gif, ready_tx,
|
||||
)
|
||||
})
|
||||
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
|
||||
.context("spawn windows clipboard thread")?;
|
||||
|
||||
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
|
||||
@@ -622,7 +573,6 @@ fn create_window() -> anyhow::Result<HWND> {
|
||||
}
|
||||
|
||||
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pump_thread(
|
||||
clip_tx: ClipTx,
|
||||
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
|
||||
@@ -630,8 +580,6 @@ fn pump_thread(
|
||||
fmt_html: u32,
|
||||
fmt_rtf: u32,
|
||||
fmt_png: u32,
|
||||
fmt_jfif: u32,
|
||||
fmt_gif: u32,
|
||||
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
|
||||
) {
|
||||
let hwnd = match create_window() {
|
||||
@@ -650,12 +598,9 @@ fn pump_thread(
|
||||
current_wire,
|
||||
cmd_rx: RefCell::new(cmd_rx),
|
||||
offered: RefCell::new(Vec::new()),
|
||||
offered_wires: RefCell::new(Vec::new()),
|
||||
fmt_html,
|
||||
fmt_rtf,
|
||||
fmt_png,
|
||||
fmt_jfif,
|
||||
fmt_gif,
|
||||
own_hwnd: hwnd,
|
||||
});
|
||||
let ptr = Box::into_raw(state);
|
||||
|
||||
@@ -158,89 +158,6 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
|
||||
//
|
||||
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
|
||||
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
|
||||
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
|
||||
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
|
||||
|
||||
/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL
|
||||
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
|
||||
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
|
||||
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(40 + w * h * 4);
|
||||
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
|
||||
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
|
||||
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
|
||||
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
|
||||
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
|
||||
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
|
||||
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
|
||||
for row in rgba.rows().rev() {
|
||||
for px in row {
|
||||
let [r, g, b, a] = px.0;
|
||||
out.extend_from_slice(&[b, g, r, a]);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
|
||||
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
|
||||
/// any malformed input — the caller declines the fetch.
|
||||
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
if dib.len() < 40 {
|
||||
return None;
|
||||
}
|
||||
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
|
||||
if hdr_size < 40 || hdr_size > dib.len() {
|
||||
return None;
|
||||
}
|
||||
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
|
||||
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
|
||||
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
|
||||
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
|
||||
let palette = if bit_count <= 8 {
|
||||
(if clr_used != 0 {
|
||||
clr_used
|
||||
} else {
|
||||
1 << bit_count
|
||||
}) * 4
|
||||
} else if compression == 3 {
|
||||
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
|
||||
// V4/V5 headers already contain the masks within hdr_size).
|
||||
if hdr_size == 40 {
|
||||
12
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pixel_offset = 14 + hdr_size + palette;
|
||||
let mut bmp = Vec::with_capacity(14 + dib.len());
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
|
||||
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.ok()?;
|
||||
Some(png)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -348,7 +265,7 @@ mod tests {
|
||||
image::DynamicImage::ImageRgba8(img.clone())
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let dib = image_to_dib(&png).expect("png -> dib");
|
||||
let dib = png_to_dib(&png).expect("png -> dib");
|
||||
// BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp.
|
||||
assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40);
|
||||
assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3);
|
||||
@@ -365,3 +282,85 @@ mod tests {
|
||||
assert!(dib_to_png(&[0xFFu8; 200]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
|
||||
//
|
||||
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
|
||||
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
|
||||
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
|
||||
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
|
||||
|
||||
/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up).
|
||||
/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste).
|
||||
pub fn png_to_dib(png: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(40 + w * h * 4);
|
||||
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
|
||||
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
|
||||
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
|
||||
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
|
||||
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
|
||||
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
|
||||
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
|
||||
for row in rgba.rows().rev() {
|
||||
for px in row {
|
||||
let [r, g, b, a] = px.0;
|
||||
out.extend_from_slice(&[b, g, r, a]);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
|
||||
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
|
||||
/// any malformed input — the caller declines the fetch.
|
||||
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
if dib.len() < 40 {
|
||||
return None;
|
||||
}
|
||||
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
|
||||
if hdr_size < 40 || hdr_size > dib.len() {
|
||||
return None;
|
||||
}
|
||||
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
|
||||
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
|
||||
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
|
||||
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
|
||||
let palette = if bit_count <= 8 {
|
||||
(if clr_used != 0 {
|
||||
clr_used
|
||||
} else {
|
||||
1 << bit_count
|
||||
}) * 4
|
||||
} else if compression == 3 {
|
||||
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
|
||||
// V4/V5 headers already contain the masks within hdr_size).
|
||||
if hdr_size == 40 {
|
||||
12
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pixel_offset = 14 + hdr_size + palette;
|
||||
let mut bmp = Vec::with_capacity(14 + dib.len());
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
|
||||
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.ok()?;
|
||||
Some(png)
|
||||
}
|
||||
|
||||
@@ -53,23 +53,15 @@ ffmpeg-next = { version = "8", optional = true }
|
||||
libloading = "0.8"
|
||||
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
|
||||
libvpl-sys = { path = "../libvpl-sys", optional = true }
|
||||
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under
|
||||
# `pyrowave`. The Windows backend is the NV12 zero-copy D3D11→Vulkan encoder; same crate as Linux.
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
# SECURITY_ATTRIBUTES — the PyroWave backend's IDXGIResource1::CreateSharedHandle signature.
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
# D3DKMTSetProcessSchedulingPriorityClass — raise the host's WDDM GPU scheduling priority
|
||||
# above a running game so PyroWave's compute-shader encode isn't starved (enc/windows/pyrowave.rs).
|
||||
"Wdk_Graphics_Direct3D",
|
||||
] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -104,14 +104,13 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit;
|
||||
/// PyroWave rides 16-bit UNORM planes carrying P010-style studio codes — the wavelet is
|
||||
/// depth-agnostic, design/pyrowave-444-hdr.md). H.264 is always 8-bit (High10 is neither an
|
||||
/// NVENC nor a VCN encode mode — negotiation never asks). `true` here is only the
|
||||
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
|
||||
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
||||
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
||||
/// *codec-level* gate: the active GPU/backend must still pass
|
||||
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
|
||||
pub fn supports_10bit(self) -> bool {
|
||||
matches!(self, Codec::H265 | Codec::Av1 | Codec::PyroWave)
|
||||
matches!(self, Codec::H265 | Codec::Av1)
|
||||
}
|
||||
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
|
||||
@@ -27,8 +27,8 @@ use super::libav::{
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not
|
||||
/// the NVENC-padded `*0` form). Used by the CPU conversion paths: 4:4:4 RGB→YUV444P, and HDR
|
||||
/// X2RGB10/X2BGR10→P010. Mirrors the VAAPI CPU-input mapping; YUV inputs can't feed this path.
|
||||
/// the NVENC-padded `*0` form). Used by the 4:4:4 RGB→YUV444P conversion path. Mirrors the VAAPI
|
||||
/// CPU-input mapping; YUV/10-bit inputs can't feed this path (the 4:4:4 session forces packed RGB).
|
||||
fn sws_src_pixel(format: PixelFormat) -> Result<Pixel> {
|
||||
Ok(match format {
|
||||
PixelFormat::Bgrx => Pixel::BGRZ, // bgr0
|
||||
@@ -37,12 +37,8 @@ fn sws_src_pixel(format: PixelFormat) -> Result<Pixel> {
|
||||
PixelFormat::Rgba => Pixel::RGBA,
|
||||
PixelFormat::Rgb => Pixel::RGB24,
|
||||
PixelFormat::Bgr => Pixel::BGR24,
|
||||
// The GNOME 50+ HDR capture formats (PQ/BT.2020 packed 2:10:10:10) — the HDR CPU path's
|
||||
// swscale source for the X2RGB10→P010 conversion.
|
||||
PixelFormat::X2Rgb10 => Pixel::X2RGB10LE,
|
||||
PixelFormat::X2Bgr10 => Pixel::X2BGR10LE,
|
||||
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
|
||||
bail!("NVENC CPU-input conversion supports packed RGB/BGR only; got {format:?}")
|
||||
bail!("NVENC 4:4:4 CPU-input path supports packed RGB/BGR only; got {format:?}")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -140,9 +136,6 @@ fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
|
||||
// the Windows paths; the Linux capturer never emits them. Map to BGRA so the match is
|
||||
// exhaustive — unreachable here.
|
||||
PixelFormat::Rgb10a2 | PixelFormat::P010 => (Pixel::BGRA, false),
|
||||
// The Linux HDR capture formats never take the RGB-passthrough input: `open` intercepts
|
||||
// them onto the X2RGB10→P010 swscale path before consulting this mapping (like 4:4:4).
|
||||
PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10 => (Pixel::BGRA, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,12 +164,11 @@ pub struct NvencEncoder {
|
||||
frame: Option<VideoFrame>,
|
||||
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
|
||||
cuda: Option<CudaHw>,
|
||||
/// CPU CSC paths only: swscale context converting the captured packed source into
|
||||
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
|
||||
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
|
||||
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
|
||||
/// worker's GPU convert delivers ready CUDA frames). Freed in `Drop`.
|
||||
sws_csc: Option<*mut ffi::SwsContext>,
|
||||
/// 4:4:4 CPU path only: swscale context converting the captured packed RGB/BGR → planar
|
||||
/// YUV444P into [`Self::frame`], because `hevc_nvenc` only emits 4:4:4 from a YUV444 *input*
|
||||
/// (RGB-in is always 4:2:0). `None` on the 4:2:0 paths AND on the zero-copy 4:4:4 path (the
|
||||
/// worker's GPU convert delivers YUV444 CUDA frames). Freed in `Drop`.
|
||||
sws_444: Option<*mut ffi::SwsContext>,
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
@@ -199,7 +191,7 @@ pub struct NvencEncoder {
|
||||
args: OpenArgs,
|
||||
}
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` a raw `SwsContext`; the encoder lives on a single
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_444` a raw `SwsContext`; the encoder lives on a single
|
||||
// thread. The CPU encoder is already `Send` via ffmpeg-next; assert it for the raw fields too.
|
||||
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||
// holding raw `AVBufferRef`s and an optional raw `SwsContext`, none of which are `Send` by default.
|
||||
@@ -255,27 +247,14 @@ impl NvencEncoder {
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// HDR / 10-bit (GNOME 50+ HDR screencast): a 10-bit session whose capture negotiated a
|
||||
// packed 2:10:10:10 PQ/BT.2020 format (`X2Rgb10`/`X2Bgr10`) encodes HEVC Main10 / 10-bit
|
||||
// AV1 from a P010 input frame we produce by swscale (BT.2020 limited; the PQ transfer
|
||||
// rides through per-channel — BT.2020 NCL Y'CbCr *is* derived from the PQ-encoded R'G'B').
|
||||
// A 10-bit request whose capture stayed SDR (HDR offer downgraded) honestly encodes 8-bit.
|
||||
let want_hdr10 = bit_depth == 10 && format.is_hdr_rgb10() && codec.supports_10bit();
|
||||
if bit_depth == 10 && !want_hdr10 {
|
||||
// TODO(hdr): Linux 10-bit parity. Unlike the Windows raw-SDK path (which upconverts 8-bit
|
||||
// ARGB → Main10 via pixelBitDepthMinus8), libavcodec hevc_nvenc needs a 10-bit input pixel
|
||||
// format (p010) for Main10, so it's a bigger change; deferred until a Linux GPU box is
|
||||
// available to validate. The Linux host stays 8-bit for now.
|
||||
if bit_depth != 8 {
|
||||
tracing::warn!(
|
||||
bit_depth,
|
||||
?format,
|
||||
codec = codec.nvenc_name(),
|
||||
"10-bit requested but the capture format/codec has no 10-bit path — encoding 8-bit"
|
||||
);
|
||||
}
|
||||
if format.is_hdr_rgb10() && !want_hdr10 {
|
||||
// A 10-bit PQ capture on an 8-bit session would be encoded with a BT.709 VUI and
|
||||
// garbage bit-packing — never silently; the session must renegotiate.
|
||||
bail!(
|
||||
"captured 10-bit HDR frames ({format:?}) on an 8-bit/{} session — refusing to \
|
||||
mislabel PQ content",
|
||||
codec.nvenc_name()
|
||||
"Linux NVENC 10-bit not yet wired — encoding 8-bit"
|
||||
);
|
||||
}
|
||||
// Full-chroma 4:4:4 (HEVC Range Extensions). `hevc_nvenc` only emits 4:4:4 from a YUV444
|
||||
@@ -284,11 +263,6 @@ impl NvencEncoder {
|
||||
// (planar-YUV444 CUDA frames — `cuda` true), or the CPU path's swscale RGB→YUV444P. Both
|
||||
// feed `profile=rext`; the range follows `PUNKTFUNK_444_FULLRANGE` in both.
|
||||
let want_444 = chroma.is_444() && codec == Codec::H265;
|
||||
if want_444 && want_hdr10 {
|
||||
// The handshake resolves 4:4:4∧10-bit down to 8-bit on Linux, so this can't happen —
|
||||
// fail loudly if it ever does rather than picking one silently.
|
||||
bail!("4:4:4 + 10-bit HDR is not a supported Linux NVENC combination");
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
@@ -300,13 +274,10 @@ impl NvencEncoder {
|
||||
let av_codec = encoder::find_by_name(name)
|
||||
.ok_or_else(|| anyhow!("{name} not built into libavcodec"))?;
|
||||
let (rgb_pixel, rgb_expand) = nvenc_input(format);
|
||||
// 4:4:4 feeds NVENC a planar YUV444P frame we produce by swscale; HDR feeds it a P010
|
||||
// frame likewise; the ordinary path feeds the captured RGB straight in and lets NVENC's
|
||||
// internal CSC subsample to 4:2:0.
|
||||
// 4:4:4 feeds NVENC a planar YUV444P frame we produce by swscale; the ordinary path feeds the
|
||||
// captured RGB straight in and lets NVENC's internal CSC subsample to 4:2:0.
|
||||
let (nvenc_pixel, expand) = if want_444 {
|
||||
(Pixel::YUV444P, false)
|
||||
} else if want_hdr10 {
|
||||
(Pixel::P010LE, false)
|
||||
} else {
|
||||
(rgb_pixel, rgb_expand)
|
||||
};
|
||||
@@ -354,21 +325,7 @@ impl NvencEncoder {
|
||||
// visible win. Linux-only: the Windows path's NVENC-internal CSC range is unmeasured.
|
||||
let full_range_444 =
|
||||
want_444 && std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
|
||||
if want_hdr10 {
|
||||
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the
|
||||
// swscale BT.2020 CSC below and the Windows paths' signalling. The client decoder
|
||||
// auto-detects PQ from the VUI; static mastering metadata rides out-of-band.
|
||||
// SAFETY: `raw = video.as_mut_ptr()` is the non-null, properly-aligned, sole-owned,
|
||||
// not-yet-opened `AVCodecContext`; we set its four VUI colour enum fields to valid
|
||||
// variants before `open_with`. Sole owner → no aliasing; synchronous writes.
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
}
|
||||
} else if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||
if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||
@@ -413,20 +370,17 @@ 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 {
|
||||
// 4:4:4 CPU path: build the RGB→YUV444P swscale (BT.709, range per the flag; no rescale).
|
||||
// Mirrors the VAAPI CPU path's RGB→NV12 scaler, but the dst is full-chroma planar 4:4:4.
|
||||
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers
|
||||
// planar YUV444 CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_444 = if want_444 && !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.
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated, packed-RGB-only source), the
|
||||
// dst is YUV444P. 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,
|
||||
@@ -434,7 +388,7 @@ impl NvencEncoder {
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_YUV444P,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
@@ -442,22 +396,17 @@ impl NvencEncoder {
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
bail!("sws_getContext(RGB→YUV444P) 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.
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
|
||||
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
|
||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
|
||||
// CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let cs709 = ffi::sws_getCoefficients(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);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
@@ -483,12 +432,6 @@ impl NvencEncoder {
|
||||
// dropped on a future libavcodec.
|
||||
opts.set("profile", "rext");
|
||||
}
|
||||
if want_hdr10 && codec == Codec::H265 {
|
||||
// HEVC Main10. `hevc_nvenc` auto-selects it from the P010 input, but pin it explicitly
|
||||
// so the depth is never silently dropped on a future libavcodec. (10-bit AV1 needs no
|
||||
// profile — AV1 Main carries 10-bit, driven by the input format.)
|
||||
opts.set("profile", "main10");
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -558,7 +501,7 @@ impl NvencEncoder {
|
||||
enc,
|
||||
frame,
|
||||
cuda: cuda_hw,
|
||||
sws_csc,
|
||||
sws_444,
|
||||
want_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
@@ -697,7 +640,7 @@ impl NvencEncoder {
|
||||
);
|
||||
// 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.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
if let Some(sws) = self.sws_444 {
|
||||
let frame = self
|
||||
.frame
|
||||
.as_mut()
|
||||
@@ -867,7 +810,7 @@ impl NvencEncoder {
|
||||
|
||||
impl Drop for NvencEncoder {
|
||||
fn drop(&mut self) {
|
||||
if let Some(sws) = self.sws_csc.take() {
|
||||
if let Some(sws) = self.sws_444.take() {
|
||||
// SAFETY: `sws` is the non-null `SwsContext` allocated by `sws_getContext` in `open` and
|
||||
// owned exclusively by this encoder (taken out of the field so it can't be freed twice).
|
||||
// `sws_freeContext` frees it; nothing else references it after this single-threaded drop.
|
||||
@@ -912,105 +855,3 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
||||
/// 10-bit AV1) from a P010 input — the exact path [`NvencEncoder::open`] takes for a live HDR
|
||||
/// stream (a tiny X2RGB10-sourced, P010-input open). The result is cached by the caller
|
||||
/// ([`crate::can_encode_10bit`]); a GPU/driver/ffmpeg without the 10-bit encode fails the open
|
||||
/// here, so the host resolves the session to 8-bit SDR before the Welcome (honest downgrade).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
if ffmpeg::init().is_err() {
|
||||
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(
|
||||
codec,
|
||||
PixelFormat::X2Rgb10,
|
||||
640,
|
||||
480,
|
||||
30,
|
||||
2_000_000,
|
||||
false, // CPU input (the HDR swscale path)
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_tests {
|
||||
use super::*;
|
||||
|
||||
/// The Linux HDR (GNOME 50 portal) encode path end-to-end on a real NVIDIA GPU: a synthetic
|
||||
/// PQ-ish X2RGB10 CPU frame → swscale BT.2020 → P010 → `hevc_nvenc` Main10, drained to a real
|
||||
/// AU. `#[ignore]`d (needs NVENC):
|
||||
/// `cargo test -p pf-encode nvenc_hdr10_smoke -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn nvenc_hdr10_smoke() {
|
||||
let (w, h) = (640u32, 480u32);
|
||||
let mut enc = NvencEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::X2Rgb10,
|
||||
w,
|
||||
h,
|
||||
30,
|
||||
2_000_000,
|
||||
false,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open hevc_nvenc Main10 (P010 input)");
|
||||
// Packed x:R:G:B 2:10:10:10 gradient (values are treated as PQ-encoded — fine for a smoke).
|
||||
let mut bytes = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let r = (x * 1023 / w.max(1)) & 0x3ff;
|
||||
let g = (y * 1023 / h.max(1)) & 0x3ff;
|
||||
let b = ((x + y) * 1023 / (w + h)) & 0x3ff;
|
||||
let px: u32 = (r << 20) | (g << 10) | b;
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
bytes[i..i + 4].copy_from_slice(&px.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let frame = CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns: 0,
|
||||
format: PixelFormat::X2Rgb10,
|
||||
payload: FramePayload::Cpu(bytes),
|
||||
cursor: None,
|
||||
};
|
||||
let mut au = None;
|
||||
for _ in 0..30 {
|
||||
enc.submit(&frame).expect("submit X2Rgb10 frame");
|
||||
if let Some(a) = enc.poll().expect("poll") {
|
||||
au = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let au = au.expect("no AU produced within 30 frames");
|
||||
assert!(!au.data.is_empty(), "empty AU");
|
||||
assert!(au.keyframe, "first AU should be the IDR");
|
||||
println!("HDR10 smoke: first AU {} bytes (IDR)", au.data.len());
|
||||
// PF_HDR_SMOKE_DUMP=/path.h265: write the Annex-B AU for external inspection —
|
||||
// `ffprobe -show_streams` should report Main 10, bt2020nc/smpte2084/bt2020 colours.
|
||||
if let Ok(path) = std::env::var("PF_HDR_SMOKE_DUMP") {
|
||||
std::fs::write(&path, &au.data).expect("dump AU");
|
||||
println!("HDR10 smoke: AU written to {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,9 @@
|
||||
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
|
||||
//! zero regression versus today; true zero-copy input registration is a follow-up.
|
||||
//!
|
||||
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
|
||||
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
|
||||
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
|
||||
//! but the NVENC guide's threading model still applies: the main thread should only *submit*
|
||||
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
|
||||
//! internal retrieve thread owns exactly that blocking lock (+ copy + unlock); `submit` returns
|
||||
//! after `encode_picture` and `poll` drains finished AUs without blocking, so under a
|
||||
//! GPU-saturating game completed frames queue instead of serializing capture on the scheduler
|
||||
//! wait. All input-resource calls (register/map/unmap) and every other session call stay on the
|
||||
//! encode thread. Backpressure: `submit` blocks on the oldest completion at
|
||||
//! `PUNKTFUNK_NVENC_ASYNC_DEPTH` (default 4) in-flight encodes. Without the flag, `poll` does
|
||||
//! the blocking `lock_bitstream` on the encode thread, exactly like the libav path (unchanged
|
||||
//! default). Caveat shared with the sync path: a driver wedge that hangs `lock_bitstream` hangs
|
||||
//! the retrieve thread the same way it would hang the encode thread today (Linux has no
|
||||
//! event-timeout escape) — no regression, just no new watchdog either.
|
||||
//! **Sync-only.** NVENC async mode (`enableEncodeAsync` + Win32 completion events) is Windows-only,
|
||||
//! so the whole two-thread async-retrieve subsystem of the Windows backend is absent here: `poll`
|
||||
//! does the blocking `lock_bitstream`, exactly like the libav path.
|
||||
//!
|
||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
|
||||
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
|
||||
@@ -54,7 +42,6 @@ use pf_zerocopy::cuda::{self, InputSurface};
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
|
||||
@@ -218,135 +205,11 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. Must
|
||||
/// stay ≥ the two-thread retrieve's in-flight cap ([`async_inflight_cap`], ≤ `POOL - 1`) so a
|
||||
/// bitstream/ring slot is never reused mid-encode.
|
||||
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. The host
|
||||
/// loop deep-pipelines (submits several frames before locking the oldest) so this must be ≥ the
|
||||
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
||||
const POOL: usize = 8;
|
||||
|
||||
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the
|
||||
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in
|
||||
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync
|
||||
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting
|
||||
/// config to fail the open.
|
||||
fn async_retrieve_requested() -> bool {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
|
||||
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
|
||||
/// the session it belongs to is destroyed).
|
||||
struct RetrieveJob {
|
||||
bs: usize,
|
||||
}
|
||||
|
||||
/// A finished retrieve: the locked-and-copied AU (or the retrieve-side error) for the oldest
|
||||
/// in-flight bitstream. `bs` lets the encode thread cross-check FIFO pairing with `pending`.
|
||||
struct RetrieveDone {
|
||||
bs: usize,
|
||||
result: std::result::Result<(Vec<u8>, bool), String>,
|
||||
}
|
||||
|
||||
/// The two-thread-retrieve runtime: the job channel feeding the retrieve thread, the completion
|
||||
/// channel back, the thread handle (joined in `teardown` BEFORE the session is destroyed), and
|
||||
/// AUs already absorbed by backpressure that `poll` hands out first.
|
||||
struct AsyncRetrieve {
|
||||
work_tx: Option<mpsc::SyncSender<RetrieveJob>>,
|
||||
done_rx: mpsc::Receiver<RetrieveDone>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
ready: VecDeque<EncodedFrame>,
|
||||
}
|
||||
|
||||
impl AsyncRetrieve {
|
||||
fn spawn(enc: usize) -> Self {
|
||||
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
||||
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-nvenc-out".into())
|
||||
.spawn(move || retrieve_loop(enc, work_rx, done_tx))
|
||||
.expect("spawn pf-nvenc-out");
|
||||
AsyncRetrieve {
|
||||
work_tx: Some(work_tx),
|
||||
done_rx,
|
||||
join: Some(join),
|
||||
ready: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The retrieve-thread body (latency plan T2.2, the Linux half of gpu-contention §5.B): for each
|
||||
/// submitted frame, BLOCKING-lock the bitstream (sync-mode `nvEncLockBitstream` returns when the
|
||||
/// encode completes — the guide's sanctioned secondary-thread surface), copy the AU out, unlock,
|
||||
/// and send it back. Exits when the job channel closes (teardown drops the sender and joins
|
||||
/// BEFORE destroying the session, so `enc` and every `bs` outlive their uses here).
|
||||
fn retrieve_loop(
|
||||
enc: usize,
|
||||
work_rx: mpsc::Receiver<RetrieveJob>,
|
||||
done_tx: mpsc::Sender<RetrieveDone>,
|
||||
) {
|
||||
pf_frame::thread_qos::boost_thread_priority(false);
|
||||
// The session is bound to the shared process-wide CUDA context; make it current here the
|
||||
// same way the encode thread does before its own NVENC calls.
|
||||
if let Err(e) = cuda::make_current() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
||||
}
|
||||
while let Ok(job) = work_rx.recv() {
|
||||
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
|
||||
// targeted; both it and the session stay valid until `teardown`, which joins this thread
|
||||
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
|
||||
// call) BLOCKS until that encode finishes, then yields a CPU-readable
|
||||
// `bitstreamBufferPtr`/`bitstreamSizeInBytes` valid until `unlock_bitstream`; the slice
|
||||
// is copied (`to_vec`) before the unlock on the same buffer. Lock/unlock from a
|
||||
// secondary thread while the encode thread submits is the NVENC guide's documented
|
||||
// threading model.
|
||||
let result = unsafe {
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
outputBitstream: job.bs as *mut c_void,
|
||||
..Default::default()
|
||||
};
|
||||
match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
|
||||
Ok(()) => {
|
||||
let data = std::slice::from_raw_parts(
|
||||
lock.bitstreamBufferPtr as *const u8,
|
||||
lock.bitstreamSizeInBytes as usize,
|
||||
)
|
||||
.to_vec();
|
||||
let keyframe = matches!(
|
||||
lock.pictureType,
|
||||
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
|
||||
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
||||
);
|
||||
let _ = (api().unlock_bitstream)(
|
||||
enc as *mut c_void,
|
||||
job.bs as nv::NV_ENC_OUTPUT_PTR,
|
||||
);
|
||||
Ok((data, keyframe))
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"lock_bitstream (retrieve thread): {e:?} — {}",
|
||||
nvenc_status::explain(e)
|
||||
)),
|
||||
}
|
||||
};
|
||||
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
||||
break; // encoder side gone (teardown drains us via join)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
|
||||
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
|
||||
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
|
||||
@@ -437,9 +300,6 @@ pub struct NvencCudaEncoder {
|
||||
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
|
||||
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
|
||||
diagnosed: bool,
|
||||
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
||||
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
||||
async_rt: Option<AsyncRetrieve>,
|
||||
}
|
||||
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||
@@ -474,13 +334,9 @@ impl NvencCudaEncoder {
|
||||
// clear reason instead of an opaque session error on the first frame.
|
||||
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
|
||||
if bit_depth >= 10 {
|
||||
// An HDR (GNOME 50 portal) session never reaches this backend: its X2RGB10 frames ride
|
||||
// the CPU/dmabuf paths (no CUDA import for the 10-bit formats yet), so the dispatcher
|
||||
// opens the libav P010 path instead. Reaching here 10-bit means a CUDA capture payload
|
||||
// on a 10-bit session — not wired; encode 8-bit rather than mislabel.
|
||||
tracing::warn!(
|
||||
"Linux direct-NVENC: 10-bit requested but the CUDA capture path has no 10-bit \
|
||||
import yet (HDR rides the libav P010 path) — encoding 8-bit SDR"
|
||||
"Linux direct-NVENC: 10-bit requested but no P010 capture path exists yet \
|
||||
(Phase 5.1) — encoding 8-bit SDR"
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
@@ -517,7 +373,6 @@ impl NvencCudaEncoder {
|
||||
custom_vbv: false,
|
||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
last_rfi_range: None,
|
||||
async_rt: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -526,15 +381,6 @@ impl NvencCudaEncoder {
|
||||
if self.encoder.is_null() {
|
||||
return;
|
||||
}
|
||||
// Stop the retrieve thread FIRST: close its job channel and join. Any in-flight blocking
|
||||
// lock returns once its encode completes (≤ a frame time on a live driver), so the join
|
||||
// is bounded; after it no other thread can touch the session the code below destroys.
|
||||
if let Some(mut rt) = self.async_rt.take() {
|
||||
rt.work_tx.take();
|
||||
if let Some(j) = rt.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
|
||||
for (_, map, _, _) in &self.pending {
|
||||
if !map.is_null() {
|
||||
@@ -958,15 +804,6 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
|
||||
self.inited = true;
|
||||
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
||||
// session parameter differs — teardown/rebuild always stops it before destroy.
|
||||
if async_retrieve_requested() {
|
||||
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
||||
tracing::info!(
|
||||
depth = async_inflight_cap(),
|
||||
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||
bit_depth = self.bit_depth,
|
||||
@@ -1008,40 +845,6 @@ impl NvencCudaEncoder {
|
||||
_ => cuda::copy_device_to_device(buf, base, pitch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one retrieve-thread completion into `ready` (two-thread mode only): pop the oldest
|
||||
/// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the
|
||||
/// retrieve thread never touches input resources), and queue the finished AU.
|
||||
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
|
||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||
bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)");
|
||||
};
|
||||
if bs as usize != done.bs {
|
||||
bail!("NVENC retrieve: completion out of order (pairing bug)");
|
||||
}
|
||||
// SAFETY: `map` is the mapped input `submit` recorded for exactly this now-completed
|
||||
// encode; the session is live (`async_rt` exists only between `init_session` and
|
||||
// `teardown`) and this runs on the encode thread — the single unmap here mirrors the
|
||||
// sync path's poll-side unmap, exactly once per mapping.
|
||||
unsafe {
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||
}
|
||||
}
|
||||
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
|
||||
self.async_rt
|
||||
.as_mut()
|
||||
.expect("absorb_done is only reachable in two-thread mode")
|
||||
.ready
|
||||
.push_back(EncodedFrame {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: anchor,
|
||||
chunk_aligned: false,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder for NvencCudaEncoder {
|
||||
@@ -1088,19 +891,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
// output slot counter (`teardown` zeroes it), NOT `pts`: `submit_indexed` pins pts to the
|
||||
// wire frame index, non-zero on a mid-session rebuild's first frame.
|
||||
let opening = self.next == 0;
|
||||
// Two-thread backpressure: never more than the cap in flight — block on the OLDEST
|
||||
// completion first, absorbing its AU into `ready` for `poll`. Bounds the added latency
|
||||
// exactly like the sync path's blocking poll, just `cap` deep instead of 1, and keeps
|
||||
// this slot's bitstream/input surface free before they're reused below.
|
||||
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
|
||||
let done = {
|
||||
let rt = self.async_rt.as_mut().expect("checked in loop condition");
|
||||
rt.done_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|_| anyhow!("NVENC retrieve stalled (5s) — encoder wedged?"))?
|
||||
};
|
||||
self.absorb_done(done)?;
|
||||
}
|
||||
let slot = self.next % POOL;
|
||||
self.next += 1;
|
||||
|
||||
@@ -1267,15 +1057,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
anchor,
|
||||
));
|
||||
}
|
||||
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
|
||||
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
|
||||
if let Some(rt) = &self.async_rt {
|
||||
if let Some(tx) = &rt.work_tx {
|
||||
let _ = tx.send(RetrieveJob {
|
||||
bs: self.bitstreams[slot] as usize,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1349,26 +1130,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
// Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and
|
||||
// hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps
|
||||
// the frame in flight and re-polls next tick; capture never blocks on the encode wait.
|
||||
if self.async_rt.is_some() {
|
||||
while let Ok(done) = self
|
||||
.async_rt
|
||||
.as_mut()
|
||||
.expect("checked just above")
|
||||
.done_rx
|
||||
.try_recv()
|
||||
{
|
||||
self.absorb_done(done)?;
|
||||
}
|
||||
return Ok(self
|
||||
.async_rt
|
||||
.as_mut()
|
||||
.expect("checked just above")
|
||||
.ready
|
||||
.pop_front());
|
||||
}
|
||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -38,9 +38,6 @@ use std::os::raw::c_char;
|
||||
/// uses. PyroWave carries no VUI, so the colour contract is fixed by this shader: the Phase-2
|
||||
/// client CSC must assume BT.709 limited range.
|
||||
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
|
||||
/// The 4:4:4 twin (`rgb2yuv444.comp`): one invocation per pixel, full-res interleaved CbCr,
|
||||
/// same BT.709-limited coefficients byte-for-byte.
|
||||
const CSC444_SPV: &[u8] = include_bytes!("rgb2yuv444.spv");
|
||||
/// Fixed cursor-overlay texture size (px) — mirrors `vulkan_video.rs`; the shared CSC shader bounds
|
||||
/// sampling by its push constant, so one allocation fits every pointer bitmap.
|
||||
const CURSOR_MAX: u32 = 256;
|
||||
@@ -49,6 +46,13 @@ const IMPORT_CACHE_CAP: usize = 16;
|
||||
/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta;
|
||||
/// the rate controller itself never exceeds the budget).
|
||||
const BS_SLACK: usize = 256 * 1024;
|
||||
/// Chunked-mode window framing (§4.4): 4-byte prefix per shard-sized window.
|
||||
const WINDOW_PREFIX: usize = 4;
|
||||
/// Window kinds: whole packets / an oversized packet's fragments.
|
||||
const WIN_PACKED: u16 = 0;
|
||||
const WIN_FRAG_FIRST: u16 = 1;
|
||||
const WIN_FRAG_CONT: u16 = 2;
|
||||
const WIN_FRAG_LAST: u16 = 3;
|
||||
|
||||
/// The DRM modifiers the PyroWave device can import as a SAMPLED image of the capture's
|
||||
/// packed-RGB format. The capture advertises these for the pyrowave passthrough instead of
|
||||
@@ -193,9 +197,6 @@ pub struct PyroWaveEncoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
/// Session-fixed negotiated chroma: 4:4:4 = full-res RG8 chroma plane + per-pixel CSC
|
||||
/// (`rgb2yuv444.comp`) + `Chroma444` pyrowave objects.
|
||||
chroma444: bool,
|
||||
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary and pad every codec
|
||||
@@ -217,39 +218,17 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
|
||||
}
|
||||
|
||||
impl PyroWaveEncoder {
|
||||
pub fn open(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
chroma: crate::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
|
||||
pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
if chroma.is_444() && !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.
|
||||
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"
|
||||
);
|
||||
}
|
||||
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
|
||||
// establishes itself (valid instance/device, correctly-chained create-infos that
|
||||
// `DeviceHold` keeps alive); all handles are freshly created and owned by the result.
|
||||
unsafe {
|
||||
Self::open_inner(
|
||||
width,
|
||||
height,
|
||||
fps.max(1),
|
||||
bitrate_bps.max(1_000_000),
|
||||
chroma.is_444(),
|
||||
)
|
||||
}
|
||||
unsafe { Self::open_inner(width, height, fps.max(1), bitrate_bps.max(1_000_000)) }
|
||||
}
|
||||
|
||||
unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64, chroma444: bool) -> Result<Self> {
|
||||
unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64) -> Result<Self> {
|
||||
let entry = ash::Entry::load().context("load vulkan loader")?;
|
||||
|
||||
let mut hold = DeviceHold {
|
||||
@@ -402,11 +381,7 @@ impl PyroWaveEncoder {
|
||||
device: pw_dev,
|
||||
width: w as i32,
|
||||
height: h as i32,
|
||||
chroma: if chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
};
|
||||
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
if let Err(e) = pw_check(
|
||||
@@ -417,10 +392,8 @@ impl PyroWaveEncoder {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// ---- CSC planes: full-res R8 luma + RG8 chroma (half-res for 4:2:0, full-res for
|
||||
// 4:4:4), storage-written by the CSC and sampled directly by pyrowave (R/G view
|
||||
// swizzles synthesize Cb/Cr) ----
|
||||
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
|
||||
// ---- CSC planes: full-res R8 luma + half-res RG8 chroma, storage-written by the CSC
|
||||
// and sampled directly by pyrowave (R/G view swizzles synthesize Cb/Cr) ----
|
||||
let (y_img, y_mem, y_view) = make_plain_image(
|
||||
&device,
|
||||
&mem_props,
|
||||
@@ -433,8 +406,8 @@ impl PyroWaveEncoder {
|
||||
&device,
|
||||
&mem_props,
|
||||
vk::Format::R8G8_UNORM,
|
||||
cw,
|
||||
ch,
|
||||
w / 2,
|
||||
h / 2,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::SAMPLED,
|
||||
)?;
|
||||
|
||||
@@ -447,11 +420,7 @@ impl PyroWaveEncoder {
|
||||
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
||||
None,
|
||||
)?;
|
||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(if chroma444 {
|
||||
CSC444_SPV
|
||||
} else {
|
||||
CSC_SPV
|
||||
}))?;
|
||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
|
||||
let shader =
|
||||
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
|
||||
let sb = |b: u32, t: vk::DescriptorType| {
|
||||
@@ -597,8 +566,7 @@ impl PyroWaveEncoder {
|
||||
gpu = %props.device_name_as_c_str().unwrap_or(c"?").to_string_lossy(),
|
||||
mode = %format!("{w}x{h}@{fps}"),
|
||||
budget_kib = frame_budget / 1024,
|
||||
chroma = if chroma444 { "4:4:4" } else { "4:2:0" },
|
||||
"PyroWave encoder open (intra-only wavelet, BT.709 limited)"
|
||||
"PyroWave encoder open (intra-only wavelet, BT.709 limited 4:2:0)"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
@@ -640,7 +608,6 @@ impl PyroWaveEncoder {
|
||||
width: w,
|
||||
height: h,
|
||||
fps,
|
||||
chroma444,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
bitstream: Vec::new(),
|
||||
@@ -1004,12 +971,7 @@ impl PyroWaveEncoder {
|
||||
0,
|
||||
&pc_bytes,
|
||||
);
|
||||
// 4:2:0: one invocation per 2x2 luma block (per chroma sample); 4:4:4: per pixel.
|
||||
if self.chroma444 {
|
||||
dev.cmd_dispatch(self.cmd, w.div_ceil(8), h.div_ceil(8), 1);
|
||||
} else {
|
||||
dev.cmd_dispatch(self.cmd, (w / 2).div_ceil(8), (h / 2).div_ceil(8), 1);
|
||||
}
|
||||
dev.cmd_dispatch(self.cmd, (w / 2).div_ceil(8), (h / 2).div_ceil(8), 1);
|
||||
|
||||
// CSC storage writes -> pyrowave's sampled reads (images stay GENERAL — the layout
|
||||
// pyrowave's GPU-buffer contract accepts without transitions).
|
||||
@@ -1062,19 +1024,17 @@ impl PyroWaveEncoder {
|
||||
),
|
||||
// Two-component chroma image: view swizzles R/G synthesize the Cb/Cr planes
|
||||
// (the documented NV12-style hand-off, pyrowave.h `pyrowave_gpu_buffers`).
|
||||
// The view extent is the chroma IMAGE's own mip0 extent (it's a separate
|
||||
// image, not a planar aspect): half-res for 4:2:0, full-res for 4:4:4.
|
||||
plane(
|
||||
self.uv_img,
|
||||
if self.chroma444 { w } else { w / 2 },
|
||||
if self.chroma444 { h } else { h / 2 },
|
||||
w / 2,
|
||||
h / 2,
|
||||
rg8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
||||
),
|
||||
plane(
|
||||
self.uv_img,
|
||||
if self.chroma444 { w } else { w / 2 },
|
||||
if self.chroma444 { h } else { h / 2 },
|
||||
w / 2,
|
||||
h / 2,
|
||||
rg8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
||||
),
|
||||
@@ -1117,8 +1077,8 @@ impl PyroWaveEncoder {
|
||||
// boundary by design.
|
||||
let cap = self.frame_budget + BS_SLACK;
|
||||
self.bitstream.resize(cap, 0);
|
||||
// Chunked mode reserves the 4-byte window prefix from the packetize boundary (shared helper).
|
||||
let boundary = crate::pyrowave_wire::packet_boundary(self.wire_chunk, cap);
|
||||
// Chunked mode reserves 4 bytes per window for the framing prefix.
|
||||
let boundary = self.wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(cap);
|
||||
let mut n: usize = 0;
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n),
|
||||
@@ -1141,16 +1101,67 @@ impl PyroWaveEncoder {
|
||||
"packetize",
|
||||
)?;
|
||||
packets.truncate(out_n.max(1));
|
||||
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
||||
// emits BT.709 LIMITED — patch the bits HONEST so VUI-honoring clients don't wash out
|
||||
// blacks. (Linux capture has no HDR path, so this side never stamps BT.2020/PQ.)
|
||||
if let Some(p) = packets.first() {
|
||||
crate::pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, false);
|
||||
}
|
||||
// Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense
|
||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
let au = if let Some(chunk) = self.wire_chunk {
|
||||
// Window framing (§4.4): each `chunk`-sized window opens with a 4-byte prefix
|
||||
// (u16 used-length + u16 kind) and carries either WHOLE self-delimiting codec
|
||||
// packets (PACKED — several small ones share a window) or one fragment of an
|
||||
// oversized packet (FRAG chain — pyrowave 32×32 blocks are atomic and may
|
||||
// exceed a shard). A lost shard zeroes its window (used = 0) — the receiver
|
||||
// skips it and drops any fragment chain it interrupts.
|
||||
let payload_max = chunk - WINDOW_PREFIX;
|
||||
let mut au: Vec<u8> = Vec::with_capacity((packets.len() + 1) * chunk);
|
||||
// The currently-open PACKED window: (start offset of its prefix, bytes used).
|
||||
let mut open: Option<(usize, usize)> = None;
|
||||
let close = |au: &mut Vec<u8>, open: &mut Option<(usize, usize)>, chunk: usize| {
|
||||
if let Some((start, used)) = open.take() {
|
||||
au[start..start + 2].copy_from_slice(&(used as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&WIN_PACKED.to_le_bytes());
|
||||
au.resize(start + chunk, 0);
|
||||
}
|
||||
};
|
||||
for p in &packets {
|
||||
let bytes = &self.bitstream[p.offset..p.offset + p.size];
|
||||
if p.size <= payload_max {
|
||||
let fits = open.is_some_and(|(_, used)| used + p.size <= payload_max);
|
||||
if !fits {
|
||||
close(&mut au, &mut open, chunk);
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
open = Some((start, 0));
|
||||
}
|
||||
au.extend_from_slice(bytes);
|
||||
if let Some((_, used)) = open.as_mut() {
|
||||
*used += p.size;
|
||||
}
|
||||
} else {
|
||||
// Oversized packet: its own FRAG chain of full windows.
|
||||
close(&mut au, &mut open, chunk);
|
||||
let mut off = 0usize;
|
||||
while off < p.size {
|
||||
let take = (p.size - off).min(payload_max);
|
||||
let kind = if off == 0 {
|
||||
WIN_FRAG_FIRST
|
||||
} else if off + take == p.size {
|
||||
WIN_FRAG_LAST
|
||||
} else {
|
||||
WIN_FRAG_CONT
|
||||
};
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
au[start..start + 2].copy_from_slice(&(take as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
au.extend_from_slice(&bytes[off..off + take]);
|
||||
au.resize(start + chunk, 0);
|
||||
off += take;
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut au, &mut open, chunk);
|
||||
au
|
||||
} else {
|
||||
let p = &packets[0];
|
||||
self.bitstream[p.offset..p.offset + p.size].to_vec()
|
||||
};
|
||||
self.frame_count += 1;
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
@@ -1173,14 +1184,9 @@ impl Encoder for PyroWaveEncoder {
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// No RFI / no intra-refresh wave (every frame is intra). Report the real opened chroma so
|
||||
// the session glue's post-open cross-check stays quiet on a genuine 4:4:4 session — 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 {
|
||||
chroma_444: self.chroma444,
|
||||
..EncoderCaps::default()
|
||||
}
|
||||
// All defaults: no RFI (meaningless — every frame is intra), no HDR (8-bit SDR codec),
|
||||
// no intra-refresh wave (ditto). 4:2:0 only until the 4:4:4 ride-along (plan §6).
|
||||
EncoderCaps::default()
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
@@ -1199,11 +1205,7 @@ impl Encoder for PyroWaveEncoder {
|
||||
device: self.pw_dev,
|
||||
width: self.width as i32,
|
||||
height: self.height as i32,
|
||||
chroma: if self.chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
};
|
||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||
@@ -1325,16 +1327,10 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode an AU with a standalone pyrowave decoder and return the full planar YUV
|
||||
/// (half-res chroma for 4:2:0, full-res for 4:4:4). This is the golden oracle for the
|
||||
/// smoke checks (plane means) and the Apple Metal port's committed PSNR fixtures
|
||||
/// (`pyrowave_dump_golden`).
|
||||
unsafe fn decode_planes_chroma(
|
||||
w: u32,
|
||||
h: u32,
|
||||
au: &[u8],
|
||||
chroma444: bool,
|
||||
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
/// Decode an AU with a standalone pyrowave decoder and return the full YUV420P planes.
|
||||
/// This is the golden oracle for both the Phase-1 smoke check (plane means) and the Apple
|
||||
/// Metal port's committed PSNR fixtures (`pyrowave_dump_golden`).
|
||||
unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let mut dev: pw::pyrowave_device = std::ptr::null_mut();
|
||||
assert_eq!(
|
||||
pw::pyrowave_create_default_device(&mut dev),
|
||||
@@ -1344,11 +1340,7 @@ mod tests {
|
||||
device: dev,
|
||||
width: w as i32,
|
||||
height: h as i32,
|
||||
chroma: if chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
fragment_path: false,
|
||||
};
|
||||
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||
@@ -1362,16 +1354,11 @@ mod tests {
|
||||
);
|
||||
assert!(pw::pyrowave_decoder_decode_is_ready(dec, false));
|
||||
|
||||
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
|
||||
let mut y = vec![0u8; (w * h) as usize];
|
||||
let mut cb = vec![0u8; (cw * ch) as usize];
|
||||
let mut cr = vec![0u8; (cw * ch) as usize];
|
||||
let mut cb = vec![0u8; (w * h / 4) as usize];
|
||||
let mut cr = vec![0u8; (w * h / 4) as usize];
|
||||
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
||||
buf.format = if chroma444 {
|
||||
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV444P
|
||||
} else {
|
||||
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P
|
||||
};
|
||||
buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P;
|
||||
buf.width = w as i32;
|
||||
buf.height = h as i32;
|
||||
buf.data = [
|
||||
@@ -1379,7 +1366,7 @@ mod tests {
|
||||
cb.as_mut_ptr() as *mut _,
|
||||
cr.as_mut_ptr() as *mut _,
|
||||
];
|
||||
buf.row_stride_in_bytes = [w as usize, cw as usize, cw as usize];
|
||||
buf.row_stride_in_bytes = [w as usize, (w / 2) as usize, (w / 2) as usize];
|
||||
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
|
||||
assert_eq!(
|
||||
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
||||
@@ -1390,15 +1377,10 @@ mod tests {
|
||||
(y, cb, cr)
|
||||
}
|
||||
|
||||
unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
/// Plane means of an upstream-decoded AU — the Phase-1 smoke assertion.
|
||||
unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8]) -> (f64, f64, f64) {
|
||||
// SAFETY: forwarded — same contract as the caller.
|
||||
unsafe { decode_planes_chroma(w, h, au, false) }
|
||||
}
|
||||
|
||||
/// Plane means of an upstream-decoded AU — the smoke assertion.
|
||||
unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8], chroma444: bool) -> (f64, f64, f64) {
|
||||
// SAFETY: forwarded — same contract as the caller.
|
||||
let (y, cb, cr) = unsafe { decode_planes_chroma(w, h, au, chroma444) };
|
||||
let (y, cb, cr) = unsafe { decode_planes(w, h, au) };
|
||||
let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64;
|
||||
(mean(&y), mean(&cb), mean(&cr))
|
||||
}
|
||||
@@ -1412,8 +1394,7 @@ mod tests {
|
||||
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
|
||||
fn pyrowave_smoke() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv420).expect("open");
|
||||
let mut enc = PyroWaveEncoder::open(w, h, 60, 40_000_000).expect("open");
|
||||
assert!(!enc.caps().supports_rfi);
|
||||
|
||||
let colors = [
|
||||
@@ -1433,7 +1414,7 @@ mod tests {
|
||||
"AU exceeds rate budget"
|
||||
);
|
||||
// SAFETY: test-only FFI into the vendored decoder with locally-owned buffers.
|
||||
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data, false) };
|
||||
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data) };
|
||||
let (ye, cbe, cre) = bt709(*c);
|
||||
assert!(
|
||||
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
|
||||
@@ -1527,68 +1508,6 @@ mod tests {
|
||||
assert!(enc.poll().expect("poll").is_some());
|
||||
}
|
||||
|
||||
/// The 4:4:4 twin of `pyrowave_smoke`: per-pixel CSC into full-res RG8 chroma +
|
||||
/// `Chroma444` pyrowave objects, verified by upstream's own 4:4:4 CPU decode. The
|
||||
/// busy-card leg then drives the rate controller at the ~2.6 bpp operating point —
|
||||
/// exactly the regime that overran upstream's 4:2:0-sized payload staging before
|
||||
/// `patches/0001-payload-data-444-sizing.patch` (the Phase-0 finding): it must stay
|
||||
/// within budget, decode, and be run-to-run deterministic (the overrun was not).
|
||||
#[test]
|
||||
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
|
||||
fn pyrowave_smoke_444() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv444).expect("open");
|
||||
let colors = [
|
||||
[40u8, 40, 200, 255],
|
||||
[40, 200, 40, 255],
|
||||
[200, 40, 40, 255],
|
||||
[128, 128, 128, 255],
|
||||
];
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
enc.submit(&cpu_frame(w, h, i as u64 * 16_666_667, *c))
|
||||
.expect("submit");
|
||||
let au = enc.poll().expect("poll").expect("one AU per frame");
|
||||
assert!(au.keyframe);
|
||||
assert!(
|
||||
au.data.len() <= enc.frame_budget + BS_SLACK,
|
||||
"AU exceeds rate budget"
|
||||
);
|
||||
// SAFETY: test-only FFI into the vendored decoder with locally-owned buffers.
|
||||
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data, true) };
|
||||
let (ye, cbe, cre) = bt709(*c);
|
||||
assert!(
|
||||
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
|
||||
"frame {i}: decoded plane means (Y {ym:.1}, Cb {cbm:.1}, Cr {crm:.1}) vs \
|
||||
expected (Y {ye:.1}, Cb {cbe:.1}, Cr {cre:.1})"
|
||||
);
|
||||
}
|
||||
|
||||
// Busy content at the 4:4:4 operating point (~2.6 bpp).
|
||||
let budget_bps = w as u64 * h as u64 * 60 * 26 / 10;
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, budget_bps, crate::ChromaFormat::Yuv444).expect("open");
|
||||
let mut sizes = Vec::new();
|
||||
for _ in 0..3 {
|
||||
enc.submit(&test_card(w, h, 7)).expect("busy submit");
|
||||
let au = enc.poll().expect("poll").expect("busy AU");
|
||||
assert!(
|
||||
au.data.len() <= enc.frame_budget + BS_SLACK,
|
||||
"busy 4:4:4 AU exceeds rate budget ({} > {})",
|
||||
au.data.len(),
|
||||
enc.frame_budget + BS_SLACK
|
||||
);
|
||||
// Upstream's own decoder accepts it (a corrupt stream errors or garbles).
|
||||
// SAFETY: test-only FFI with locally-owned buffers.
|
||||
let _ = unsafe { decode_planes_chroma(w, h, &au.data, true) };
|
||||
sizes.push(au.data.len());
|
||||
}
|
||||
assert!(
|
||||
sizes.windows(2).all(|s| s[0] == s[1]),
|
||||
"identical input produced varying AU sizes (the Phase-0 overrun signature): {sizes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A deterministic busy BGRA test card (gradients + checker + LCG noise) — flat fills
|
||||
/// exercise almost none of the entropy decoder, this hits every subband.
|
||||
fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame {
|
||||
@@ -1639,8 +1558,7 @@ mod tests {
|
||||
// Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the
|
||||
// block-grid overhang. ~1.6 bpp at 60 fps.
|
||||
let (w, h) = (256u32, 144u32);
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 4_000_000, crate::ChromaFormat::Yuv420).expect("open");
|
||||
let mut enc = PyroWaveEncoder::open(w, h, 60, 4_000_000).expect("open");
|
||||
|
||||
let dump = |name: &str, bytes: &[u8]| {
|
||||
std::fs::write(dir.join(name), bytes).expect("write fixture");
|
||||
@@ -1692,19 +1610,5 @@ mod tests {
|
||||
dump("ref-chunked-y.bin", &y);
|
||||
dump("ref-chunked-cb.bin", &cb);
|
||||
dump("ref-chunked-cr.bin", &cr);
|
||||
|
||||
// 4:4:4 dense AU + its reference (full-res chroma planes) — the Apple 4:4:4 layout's
|
||||
// golden (design/pyrowave-444-hdr.md Phase 4). Same odd-block geometry.
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 6_500_000, crate::ChromaFormat::Yuv444).expect("open");
|
||||
enc.submit(&test_card(w, h, 13)).expect("444 submit");
|
||||
let au = enc.poll().expect("poll").expect("444 AU");
|
||||
assert!(!au.chunk_aligned);
|
||||
dump("au-dense444.bin", &au.data);
|
||||
// SAFETY: test-only FFI with locally-owned buffers.
|
||||
let (y, cb, cr) = unsafe { decode_planes_chroma(w, h, &au.data, true) };
|
||||
dump("ref-dense444-y.bin", &y);
|
||||
dump("ref-dense444-cb.bin", &cb);
|
||||
dump("ref-dense444-cr.bin", &cr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#version 450
|
||||
// RGB(A) -> full-res Y + FULL-res interleaved CbCr (BT.709 limited range): the 4:4:4 twin of
|
||||
// rgb2yuv.comp — one invocation per pixel, no chroma box filter, no siting. Same coefficients
|
||||
// byte-for-byte (the wavelet clients' planar CSC decodes both layouts identically), same
|
||||
// cursor-as-metadata blend, same source-edge clamp for the 32-aligned coded extent.
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(binding = 0) uniform sampler2D rgb; // packed RGB input (sampled; BGRA import ok)
|
||||
layout(binding = 1, r8) uniform writeonly image2D yImg; // full-res Y
|
||||
layout(binding = 2, rg8) uniform writeonly image2D uvImg; // full-res UV (interleaved)
|
||||
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
|
||||
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
|
||||
} pc;
|
||||
|
||||
float lumaY(vec3 c) { return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b; }
|
||||
|
||||
vec3 withCursor(ivec2 p, vec3 col) {
|
||||
if (pc.curSize.x <= 0) return col;
|
||||
ivec2 cp = p - pc.curOrigin;
|
||||
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
|
||||
vec4 c = texelFetch(cursorTex, cp, 0);
|
||||
return mix(col, c.rgb, c.a);
|
||||
}
|
||||
|
||||
void main() {
|
||||
ivec2 sz = imageSize(yImg);
|
||||
ivec2 rmax = textureSize(rgb, 0) - 1;
|
||||
ivec2 p = ivec2(gl_GlobalInvocationID.xy);
|
||||
if (p.x >= sz.x || p.y >= sz.y) return;
|
||||
vec3 c = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
|
||||
imageStore(yImg, p, vec4(lumaY(c), 0, 0, 1));
|
||||
float U = 128.0/255.0 - 0.1006*c.r - 0.3386*c.g + 0.4392*c.b;
|
||||
float V = 128.0/255.0 + 0.4392*c.r - 0.3989*c.g - 0.0403*c.b;
|
||||
imageStore(uvImg, p, vec4(U, V, 0, 1));
|
||||
}
|
||||
Binary file not shown.
@@ -61,10 +61,6 @@ fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
PixelFormat::Rgba => Pixel::RGBA,
|
||||
PixelFormat::Rgb => Pixel::RGB24,
|
||||
PixelFormat::Bgr => Pixel::BGR24,
|
||||
// The GNOME 50+ HDR capture formats (PQ/BT.2020 packed 2:10:10:10) — the HDR CPU path's
|
||||
// swscale source for the X2RGB10→P010 conversion.
|
||||
PixelFormat::X2Rgb10 => Pixel::X2RGB10LE,
|
||||
PixelFormat::X2Bgr10 => Pixel::X2BGR10LE,
|
||||
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
|
||||
bail!("VAAPI CPU-input path supports packed RGB/BGR only; got {format:?}")
|
||||
}
|
||||
@@ -105,7 +101,6 @@ fn low_power_override() -> Option<bool> {
|
||||
/// default on those kernels). AMD keeps its first-try full-feature open byte-for-byte unchanged.
|
||||
/// The resolved mode is cached per codec; `PUNKTFUNK_VAAPI_LOW_POWER` pins it.
|
||||
/// Safety contract is [`open_vaapi_encoder_mode`]'s (borrowed `device_ref`/`frames_ref`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn open_vaapi_encoder(
|
||||
codec: Codec,
|
||||
width: u32,
|
||||
@@ -114,7 +109,6 @@ unsafe fn open_vaapi_encoder(
|
||||
bitrate_bps: u64,
|
||||
device_ref: *mut ffi::AVBufferRef,
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
ten_bit: bool,
|
||||
) -> Result<encoder::video::Encoder> {
|
||||
let idx = lp_idx(codec);
|
||||
let modes: &[bool] = match low_power_override() {
|
||||
@@ -136,7 +130,6 @@ unsafe fn open_vaapi_encoder(
|
||||
bitrate_bps,
|
||||
device_ref,
|
||||
frames_ref,
|
||||
ten_bit,
|
||||
lp,
|
||||
) {
|
||||
Ok(enc) => {
|
||||
@@ -165,9 +158,8 @@ unsafe fn open_vaapi_encoder(
|
||||
}
|
||||
|
||||
/// Build the FFmpeg encoder context (shared by both inner paths): name, mode, low-latency RC,
|
||||
/// infinite GOP, the VUI (BT.709 limited SDR, or BT.2020 PQ limited for `ten_bit` HDR),
|
||||
/// `pix_fmt=VAAPI`, and the given hw device + frames contexts. Returns the opened encoder.
|
||||
/// `device_ref`/`frames_ref` are borrowed (ref'd into the context).
|
||||
/// infinite GOP, BT.709-limited VUI, `pix_fmt=VAAPI`, and the given hw device + frames contexts.
|
||||
/// Returns the opened encoder. `device_ref`/`frames_ref` are borrowed (ref'd into the context).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn open_vaapi_encoder_mode(
|
||||
codec: Codec,
|
||||
@@ -177,7 +169,6 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
bitrate_bps: u64,
|
||||
device_ref: *mut ffi::AVBufferRef,
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
ten_bit: bool,
|
||||
low_power: bool,
|
||||
) -> Result<encoder::video::Encoder> {
|
||||
let name = codec.vaapi_name();
|
||||
@@ -190,39 +181,23 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
.context("alloc video encoder")?;
|
||||
video.set_width(width);
|
||||
video.set_height(height);
|
||||
// sw view (pix_fmt overridden to VAAPI below): NV12, or P010 for the 10-bit HDR session.
|
||||
video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 });
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
if ten_bit {
|
||||
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
|
||||
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
|
||||
// zero-copy path). The client decoder auto-detects PQ from the VUI.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
} else {
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
}
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
|
||||
let mut opts = Dictionary::new();
|
||||
if ten_bit && codec == Codec::H265 {
|
||||
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so
|
||||
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
|
||||
opts.set("profile", "main10");
|
||||
}
|
||||
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
|
||||
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
|
||||
// = 8.3 ms end-to-end p50 vs depth 2 = 18 ms, because with depth ≥ 2 frame N's packet only
|
||||
@@ -267,59 +242,10 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
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) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
480,
|
||||
30,
|
||||
2_000_000,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
false,
|
||||
)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
ffi::av_log_set_level(prev);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether the active VAAPI GPU can encode **10-bit** (HEVC Main10 / 10-bit AV1) from P010
|
||||
/// surfaces — the exact shape a live HDR session opens (P010 pool + Main10 profile + PQ VUI). The
|
||||
/// driver rejects what the video engine can't do; the result is cached by the caller
|
||||
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
|
||||
/// the Welcome (honest downgrade).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() || codec == Codec::PyroWave {
|
||||
return false;
|
||||
}
|
||||
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
|
||||
// `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) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
480,
|
||||
30,
|
||||
2_000_000,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
true,
|
||||
)
|
||||
.is_ok(),
|
||||
Ok(hw) => {
|
||||
open_vaapi_encoder(codec, 640, 480, 30, 2_000_000, hw.device_ref, hw.frames_ref)
|
||||
.is_ok()
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
ffi::av_log_set_level(prev);
|
||||
@@ -422,21 +348,12 @@ impl CpuInner {
|
||||
bitrate_bps: u64,
|
||||
) -> Result<Self> {
|
||||
let src_pixel = vaapi_sws_src(format)?;
|
||||
// A 10-bit HDR capture (X2RGB10/X2BGR10, PQ/BT.2020) uploads P010 and encodes Main10; the
|
||||
// 8-bit paths keep NV12/BT.709 byte-for-byte unchanged.
|
||||
let ten_bit = format.is_hdr_rgb10();
|
||||
let staging_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||
};
|
||||
const POOL: c_int = 16;
|
||||
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
||||
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
|
||||
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive
|
||||
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
|
||||
// on drop.
|
||||
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
|
||||
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
|
||||
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
|
||||
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
|
||||
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
||||
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
|
||||
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
|
||||
@@ -451,19 +368,16 @@ impl CpuInner {
|
||||
bitrate_bps,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
ten_bit,
|
||||
)?
|
||||
};
|
||||
// swscale RGB→NV12 (BT.709 limited) or X2RGB10→P010 (BT.2020 limited, HDR) — matches the
|
||||
// VUI; no rescale.
|
||||
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
|
||||
let src_av = pixel_to_av(src_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
|
||||
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
|
||||
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
|
||||
// `src_pixel`), the dst is NV12/P010. The three trailing pointers (srcFilter, dstFilter,
|
||||
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
|
||||
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
|
||||
// just below.
|
||||
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
|
||||
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
|
||||
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
@@ -471,7 +385,7 @@ impl CpuInner {
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
staging_av,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
@@ -479,54 +393,45 @@ impl CpuInner {
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!(
|
||||
"sws_getContext(RGB→{})",
|
||||
if ten_bit { "P010" } else { "NV12" }
|
||||
);
|
||||
bail!("sws_getContext(RGB→NV12) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||
// check immediately preceding returned false). The coefficient table from
|
||||
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
|
||||
// libswscale static const valid for the whole process, reused here for both the inverse
|
||||
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
|
||||
// writes scalar CSC settings into `sws`; the table pointer outlives the synchronous call and
|
||||
// no Rust memory is passed.
|
||||
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
|
||||
// pointer into a libswscale static const coefficient table valid for the whole process, reused
|
||||
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
|
||||
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
|
||||
// synchronous call and no Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if ten_bit {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
|
||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
||||
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
|
||||
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
|
||||
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
|
||||
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a
|
||||
// unique fresh pointer, so none of these writes alias anything.
|
||||
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
|
||||
// fresh pointer, so none of these writes alias anything.
|
||||
let nv12 = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
ffi::sws_freeContext(sws);
|
||||
bail!("av_frame_alloc(staging) failed");
|
||||
bail!("av_frame_alloc(NV12) failed");
|
||||
}
|
||||
(*f).format = staging_av as c_int;
|
||||
(*f).format = ffi::AVPixelFormat::AV_PIX_FMT_NV12 as c_int;
|
||||
(*f).width = width as c_int;
|
||||
(*f).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
||||
let mut f = f;
|
||||
ffi::av_frame_free(&mut f);
|
||||
ffi::sws_freeContext(sws);
|
||||
bail!("av_frame_get_buffer(staging) failed");
|
||||
bail!("av_frame_get_buffer(NV12) failed");
|
||||
}
|
||||
f
|
||||
};
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
|
||||
if ten_bit { "P010 (HDR10)" } else { "NV12" }
|
||||
"VAAPI encode active ({width}x{height}@{fps}, CPU→NV12 upload path)"
|
||||
);
|
||||
Ok(CpuInner {
|
||||
enc,
|
||||
@@ -658,15 +563,6 @@ impl DmabufInner {
|
||||
) -> Result<Self> {
|
||||
let drm_fourcc = pf_frame::drm_fourcc(format)
|
||||
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
||||
// A 10-bit HDR capture (X2RGB10/X2BGR10 dmabufs, PQ/BT.2020) maps + CSCs to P010 and
|
||||
// encodes Main10; the 8-bit paths keep the NV12/BT.709 graph byte-for-byte unchanged.
|
||||
let ten_bit = format.is_hdr_rgb10();
|
||||
let sw_format = match format {
|
||||
PixelFormat::X2Rgb10 => ffi::AVPixelFormat::AV_PIX_FMT_X2RGB10LE,
|
||||
PixelFormat::X2Bgr10 => ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
|
||||
// The 8-bit capture formats are all XR24-shaped packed RGB (the historical BGR0 view).
|
||||
_ => ffi::AVPixelFormat::AV_PIX_FMT_BGR0,
|
||||
};
|
||||
let node = render_node();
|
||||
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
||||
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
|
||||
@@ -732,7 +628,7 @@ impl DmabufInner {
|
||||
}
|
||||
let fc = (*drm_frames).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME;
|
||||
(*fc).sw_format = sw_format; // packed XR24 RGB plane, or XR30/XB30 for HDR
|
||||
(*fc).sw_format = ffi::AVPixelFormat::AV_PIX_FMT_BGR0; // packed XR24 RGB plane
|
||||
(*fc).width = width as c_int;
|
||||
(*fc).height = height as c_int;
|
||||
if ffi::av_hwframe_ctx_init(drm_frames) < 0 {
|
||||
@@ -819,24 +715,14 @@ impl DmabufInner {
|
||||
}
|
||||
init!(src, ptr::null(), "buffer");
|
||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
||||
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR,
|
||||
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
|
||||
// the matrix untouched). Without the explicit options the conversion matrix is
|
||||
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
|
||||
// shift against the signaled VUI.
|
||||
if ten_bit {
|
||||
init!(
|
||||
scale,
|
||||
c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
} else {
|
||||
init!(
|
||||
scale,
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
}
|
||||
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited).
|
||||
// Without the explicit options the conversion matrix is whatever the driver defaults
|
||||
// to for an unspecified output (Mesa: BT.601) — a hue shift against the signaled VUI.
|
||||
init!(
|
||||
scale,
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
init!(sink, ptr::null(), "buffersink");
|
||||
|
||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||
@@ -880,7 +766,6 @@ impl DmabufInner {
|
||||
bitrate_bps,
|
||||
vaapi_device,
|
||||
nv12_ctx,
|
||||
ten_bit,
|
||||
) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
@@ -894,8 +779,7 @@ impl DmabufInner {
|
||||
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
"VAAPI encode active ({width}x{height}@{fps}, zero-copy dmabuf → GPU {})",
|
||||
if ten_bit { "P010 (HDR10)" } else { "NV12" }
|
||||
"VAAPI encode active ({width}x{height}@{fps}, zero-copy dmabuf → GPU NV12)"
|
||||
);
|
||||
Ok(DmabufInner {
|
||||
enc,
|
||||
@@ -1103,22 +987,8 @@ impl VaapiEncoder {
|
||||
bit_depth: u8,
|
||||
chroma: super::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
|
||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
|
||||
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an
|
||||
// 8-bit session) is refused so PQ content is never mislabeled BT.709.
|
||||
if format.is_hdr_rgb10() && bit_depth != 10 {
|
||||
bail!(
|
||||
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
|
||||
refusing to mislabel PQ content"
|
||||
);
|
||||
}
|
||||
if bit_depth == 10 && !format.is_hdr_rgb10() {
|
||||
tracing::warn!(
|
||||
bit_depth,
|
||||
?format,
|
||||
"10-bit requested but the capture stayed SDR — encoding 8-bit"
|
||||
);
|
||||
if bit_depth != 8 {
|
||||
tracing::warn!(bit_depth, "VAAPI 10-bit not yet wired — encoding 8-bit");
|
||||
}
|
||||
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
||||
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Shared PyroWave AU wire-framing (design/pyrowave-codec-plan.md §4.4) — the single source of
|
||||
//! truth for the on-wire access-unit shape, used by BOTH the Linux (dmabuf/CSC) and Windows (NV12
|
||||
//! zero-copy) host encoders. It turns pyrowave's packetized bitstream into either the **dense**
|
||||
//! single-packet AU or the **datagram-aligned** windowed AU. Pure (no GPU/FFI) so it is unit-tested
|
||||
//! on any platform and both encoders emit byte-identical framing — the clients parse this exact
|
||||
//! layout, so it must stay in ONE place.
|
||||
//!
|
||||
//! Datagram-aligned AU: each `chunk`-sized window opens with a 4-byte prefix (`u16` used-length +
|
||||
//! `u16` kind) and carries either WHOLE self-delimiting codec packets (`WIN_PACKED` — several small
|
||||
//! ones share a window) or one fragment of an oversized ATOMIC packet (a `FRAG` chain — pyrowave's
|
||||
//! 32×32 blocks are atomic and can exceed a shard). A lost shard zeroes its window (`used = 0`) so
|
||||
//! the receiver skips it and drops any fragment chain it interrupts. Padding after `used` is zeroed.
|
||||
|
||||
/// The 4-byte per-window framing prefix (`u16` used-length + `u16` kind).
|
||||
pub(crate) const WINDOW_PREFIX: usize = 4;
|
||||
/// Window kinds: whole packets / an oversized packet's fragments.
|
||||
const WIN_PACKED: u16 = 0;
|
||||
const WIN_FRAG_FIRST: u16 = 1;
|
||||
const WIN_FRAG_CONT: u16 = 2;
|
||||
const WIN_FRAG_LAST: u16 = 3;
|
||||
|
||||
/// The packetize boundary to request from pyrowave: for a `wire_chunk` shard it is the shard payload
|
||||
/// minus the 4-byte window prefix (so a whole codec packet + its prefix fits one shard); for the
|
||||
/// dense case it is the whole-bitstream cap (one packet per AU).
|
||||
pub(crate) fn packet_boundary(wire_chunk: Option<usize>, dense_cap: usize) -> usize {
|
||||
wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(dense_cap)
|
||||
}
|
||||
|
||||
/// Patch the frame's `BitstreamSequenceHeader` to signal `ycbcr_range = LIMITED`. pyrowave's C API
|
||||
/// fills the header with `= {}` (all VUI fields zeroed) and offers NO way to set colour/range, so it
|
||||
/// signals `ycbcr_range = 0 = YCBCR_RANGE_FULL` — but BOTH host CSCs (`rgb2yuv.comp` on Linux, the
|
||||
/// D3D11 `BgraToYuvPlanes` on Windows) always emit BT.709 **LIMITED** Y′CbCr (black = Y′16). A client
|
||||
/// that honours the VUI (the Apple wavelet decoder reads `(word1 >> 30) & 1`) then skips the
|
||||
/// limited→full expansion and shows washed-out, raised blacks. Patching the bit makes the bitstream
|
||||
/// HONEST for every client — clients that hardcode limited (the Vulkan `video_pyrowave` path) are
|
||||
/// unaffected, and pyrowave's own decode ignores the flag (it reconstructs raw Y′CbCr). The other
|
||||
/// zeroed VUI fields (BT.709 primaries / transform / transfer) are already correct.
|
||||
///
|
||||
/// `seq_offset` is the byte offset of the frame's 8-byte `BitstreamSequenceHeader` in `bitstream` —
|
||||
/// the SOF packet's offset. The colour bits live in the little-endian second word's top byte
|
||||
/// (`seq_offset + 7`): `color_primaries` bit 27 (`0x08`), `transfer_function` bit 28 (`0x10`),
|
||||
/// `ycbcr_transform` bit 29 (`0x20`), `ycbcr_range` bit 30 (`0x40`); `chroma_siting` bit 31 stays 0
|
||||
/// (CENTER — the pyrowave CSCs use the centre-sited 2×2 box, unlike the left-cosited P010 path).
|
||||
/// Range is ALWAYS stamped LIMITED (both CSCs emit studio range); `bt2020_pq` additionally stamps
|
||||
/// BT.2020 primaries + PQ transfer + BT.2020 matrix — upstream's own enum semantics
|
||||
/// (`pyrowave_common.hpp`), matching the session's negotiated `ColorInfo`.
|
||||
pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_pq: bool) {
|
||||
if let Some(b) = bitstream.get_mut(seq_offset + 7) {
|
||||
*b |= 0x40;
|
||||
if bt2020_pq {
|
||||
*b |= 0x08 | 0x10 | 0x20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wavelet block space's total 32x32-block count for a mode — the exact counting walk of
|
||||
/// upstream `WaveletBuffers::init_block_meta` (also ported to the Apple `WaveletLayout`, whose
|
||||
/// golden tests pin it against real host AUs). Needed because the vendored RDO pass packs the
|
||||
/// block index into 16 bits (`RDOperation.block_offset_saving` — see
|
||||
/// `patches/0002-rdo-saving-clamp.patch`): a mode whose count exceeds `u16::MAX` would wrap
|
||||
/// inside the rate controller, so the host guards such modes out (≈8K 4:4:4 territory).
|
||||
pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32 {
|
||||
const LEVELS: u32 = 5;
|
||||
let align = |v: u32| ((v + 31) & !31).max(128);
|
||||
let (aw, ah) = (align(width), align(height));
|
||||
let mut count = 0u32;
|
||||
for level in (0..LEVELS).rev() {
|
||||
let lw = (aw / 2) >> level;
|
||||
let lh = (ah / 2) >> level;
|
||||
let blocks_x8 = lw.div_ceil(8);
|
||||
let blocks_y8 = lh.div_ceil(8);
|
||||
let per_band = blocks_x8.div_ceil(4) * blocks_y8.div_ceil(4);
|
||||
let bands = if level == LEVELS - 1 { 4 } else { 3 };
|
||||
for component in 0..3u32 {
|
||||
if level == 0 && component != 0 && !chroma444 {
|
||||
continue;
|
||||
}
|
||||
count += per_band * bands;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
|
||||
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
|
||||
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
|
||||
pub(crate) fn build_au(
|
||||
packets: &[(usize, usize)],
|
||||
bitstream: &[u8],
|
||||
wire_chunk: Option<usize>,
|
||||
) -> Vec<u8> {
|
||||
let Some(chunk) = wire_chunk else {
|
||||
// Dense (default): boundary == whole buffer → the AU is exactly one pyrowave packet.
|
||||
let (off, size) = packets[0];
|
||||
return bitstream[off..off + size].to_vec();
|
||||
};
|
||||
let payload_max = chunk - WINDOW_PREFIX;
|
||||
let mut au: Vec<u8> = Vec::with_capacity((packets.len() + 1) * chunk);
|
||||
// The currently-open PACKED window: (start offset of its prefix, bytes used).
|
||||
let mut open: Option<(usize, usize)> = None;
|
||||
let close = |au: &mut Vec<u8>, open: &mut Option<(usize, usize)>, chunk: usize| {
|
||||
if let Some((start, used)) = open.take() {
|
||||
au[start..start + 2].copy_from_slice(&(used as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&WIN_PACKED.to_le_bytes());
|
||||
au.resize(start + chunk, 0);
|
||||
}
|
||||
};
|
||||
for &(off, size) in packets {
|
||||
let bytes = &bitstream[off..off + size];
|
||||
if size <= payload_max {
|
||||
let fits = open.is_some_and(|(_, used)| used + size <= payload_max);
|
||||
if !fits {
|
||||
close(&mut au, &mut open, chunk);
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
open = Some((start, 0));
|
||||
}
|
||||
au.extend_from_slice(bytes);
|
||||
if let Some((_, used)) = open.as_mut() {
|
||||
*used += size;
|
||||
}
|
||||
} else {
|
||||
// Oversized packet: its own FRAG chain of full windows.
|
||||
close(&mut au, &mut open, chunk);
|
||||
let mut o = 0usize;
|
||||
while o < size {
|
||||
let take = (size - o).min(payload_max);
|
||||
let kind = if o == 0 {
|
||||
WIN_FRAG_FIRST
|
||||
} else if o + take == size {
|
||||
WIN_FRAG_LAST
|
||||
} else {
|
||||
WIN_FRAG_CONT
|
||||
};
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
au[start..start + 2].copy_from_slice(&(take as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
au.extend_from_slice(&bytes[o..o + take]);
|
||||
au.resize(start + chunk, 0);
|
||||
o += take;
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut au, &mut open, chunk);
|
||||
au
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Walk a windowed AU back into the flat codec-packet stream (the client's parse), asserting the
|
||||
/// framing invariants the encoder promises: whole windows, in-bounds `used`, zeroed padding.
|
||||
fn walk(au: &[u8], chunk: usize) -> Vec<u8> {
|
||||
assert_eq!(au.len() % chunk, 0, "AU is a whole number of windows");
|
||||
let mut out = Vec::new();
|
||||
let mut frag: Vec<u8> = Vec::new();
|
||||
for win in au.chunks(chunk) {
|
||||
let used = u16::from_le_bytes([win[0], win[1]]) as usize;
|
||||
let kind = u16::from_le_bytes([win[2], win[3]]);
|
||||
assert!(WINDOW_PREFIX + used <= win.len(), "window overrun");
|
||||
assert!(
|
||||
win[WINDOW_PREFIX + used..].iter().all(|&b| b == 0),
|
||||
"non-zero padding after used"
|
||||
);
|
||||
let body = &win[WINDOW_PREFIX..WINDOW_PREFIX + used];
|
||||
match kind {
|
||||
0 => out.extend_from_slice(body),
|
||||
1 => frag = body.to_vec(),
|
||||
2 => frag.extend_from_slice(body),
|
||||
3 => {
|
||||
frag.extend_from_slice(body);
|
||||
out.extend_from_slice(&frag);
|
||||
frag.clear();
|
||||
}
|
||||
k => panic!("unknown window kind {k}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_is_the_single_packet() {
|
||||
let bs = (0u8..=200).collect::<Vec<u8>>();
|
||||
let au = build_au(&[(10, 50)], &bs, None);
|
||||
assert_eq!(au, bs[10..60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_windows_pack_small_packets_and_reconstruct() {
|
||||
// Three small packets that share windows; walking must reproduce them concatenated in order.
|
||||
let bs: Vec<u8> = (0..255u32).map(|i| i as u8).collect();
|
||||
let packets = [(0, 20), (20, 20), (40, 100)];
|
||||
let chunk = 64; // payload_max = 60
|
||||
let au = build_au(&packets, &bs, Some(chunk));
|
||||
let flat = walk(&au, chunk);
|
||||
let mut expect = Vec::new();
|
||||
for &(o, s) in &packets {
|
||||
expect.extend_from_slice(&bs[o..o + s]);
|
||||
}
|
||||
assert_eq!(flat, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_packet_fragments_and_reassembles() {
|
||||
// One atomic packet larger than a window → a FRAG chain the walk reassembles exactly.
|
||||
let bs: Vec<u8> = (0..1000u32).map(|i| i as u8).collect();
|
||||
let chunk = 64; // payload_max = 60
|
||||
let au = build_au(&[(0, 500)], &bs, Some(chunk));
|
||||
assert_eq!(walk(&au, chunk), bs[0..500]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_reserves_the_window_prefix() {
|
||||
assert_eq!(packet_boundary(Some(1408), 999_999), 1404);
|
||||
assert_eq!(packet_boundary(None, 777), 777);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_count_matches_the_apple_layout_invariant() {
|
||||
// 256x144 (the golden-fixture geometry, aligned 256x160): recompute via the same walk
|
||||
// the validated Apple WaveletLayout uses and pin a few mode-level facts.
|
||||
let manual = |w: u32, h: u32, c444: bool| {
|
||||
let align = |v: u32| ((v + 31) & !31).max(128);
|
||||
let (aw, ah) = (align(w), align(h));
|
||||
let mut n = 0u32;
|
||||
for level in (0..5u32).rev() {
|
||||
let per = (((aw / 2) >> level).div_ceil(8).div_ceil(4))
|
||||
* (((ah / 2) >> level).div_ceil(8).div_ceil(4));
|
||||
let bands = if level == 4 { 4 } else { 3 };
|
||||
for c in 0..3 {
|
||||
if level == 0 && c != 0 && !c444 {
|
||||
continue;
|
||||
}
|
||||
n += per * bands;
|
||||
}
|
||||
}
|
||||
n
|
||||
};
|
||||
for (w, h) in [(256, 144), (1920, 1080), (3840, 2160), (7680, 4320)] {
|
||||
assert_eq!(block_count_32x32(w, h, false), manual(w, h, false));
|
||||
assert_eq!(block_count_32x32(w, h, true), manual(w, h, true));
|
||||
}
|
||||
// 4:4:4 fits comfortably at 4K; the 16-bit RDO block index wraps around 8K 4:4:4.
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_color_bits_sets_range_and_hdr_bits() {
|
||||
let mut bs = vec![0u8; 16];
|
||||
stamp_color_bits(&mut bs, 0, false);
|
||||
// ycbcr_range = bit 30 of the LE second word = bit 6 of byte 7 (0x40); nothing else touched.
|
||||
assert_eq!(bs[7], 0x40);
|
||||
assert!(bs[..7].iter().all(|&b| b == 0));
|
||||
assert!(bs[8..].iter().all(|&b| b == 0));
|
||||
// Idempotent; an out-of-range offset is a silent no-op (never panics).
|
||||
stamp_color_bits(&mut bs, 0, false);
|
||||
assert_eq!(bs[7], 0x40);
|
||||
stamp_color_bits(&mut bs, 100, false);
|
||||
// HDR adds BT.2020 primaries (0x08) + PQ transfer (0x10) + BT.2020 matrix (0x20);
|
||||
// chroma_siting (0x80) stays CENTER.
|
||||
stamp_color_bits(&mut bs, 0, true);
|
||||
assert_eq!(bs[7], 0x78);
|
||||
}
|
||||
}
|
||||
@@ -181,13 +181,10 @@ impl Encoder for OpenH264Encoder {
|
||||
PixelFormat::Bgr => (3, 2, 1, 0),
|
||||
PixelFormat::Rgba | PixelFormat::Rgbx => (4, 0, 1, 2),
|
||||
PixelFormat::Bgra | PixelFormat::Bgrx => (4, 2, 1, 0),
|
||||
// 10-bit HDR comes only from the GPU paths; the software 8-bit H.264 encoder can't
|
||||
// represent it (and never receives it — HDR is never negotiated on a software host).
|
||||
PixelFormat::Rgb10a2 | PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10 => {
|
||||
anyhow::bail!(
|
||||
"software H.264 encoder cannot encode 10-bit HDR ({:?})",
|
||||
self.src_format
|
||||
)
|
||||
// 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder
|
||||
// can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC).
|
||||
PixelFormat::Rgb10a2 => {
|
||||
anyhow::bail!("software H.264 encoder cannot encode 10-bit HDR (Rgb10a2)")
|
||||
}
|
||||
// NV12/P010 are GPU-resident video-processor outputs for the NVENC path; the software
|
||||
// encoder never receives them (it only gets CPU RGB frames).
|
||||
|
||||
@@ -2788,7 +2788,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -2974,7 +2973,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -3116,7 +3114,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -3264,7 +3261,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
@@ -136,15 +136,7 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
PixelFormat::Rgba => Pixel::RGBA,
|
||||
PixelFormat::Rgb => Pixel::RGB24,
|
||||
PixelFormat::Bgr => Pixel::BGR24,
|
||||
// X2Rgb10/X2Bgr10 are the Linux GNOME 50 HDR screencast formats — the Windows HDR path
|
||||
// stays Rgb10a2/P010, so they can't reach this capture-side conversion. Listed explicitly
|
||||
// (not via `_`) so the next PixelFormat addition breaks this match again on purpose.
|
||||
PixelFormat::Nv12
|
||||
| PixelFormat::P010
|
||||
| PixelFormat::Rgb10a2
|
||||
| PixelFormat::Yuv444
|
||||
| PixelFormat::X2Rgb10
|
||||
| PixelFormat::X2Bgr10 => {
|
||||
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
|
||||
bail!("ffmpeg_win swscale path supports packed RGB/BGR only; got {format:?}")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1811,7 +1811,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -1914,7 +1913,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1746,7 +1746,6 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
+19
-111
@@ -48,19 +48,7 @@ impl Codec {
|
||||
} else {
|
||||
0u8
|
||||
};
|
||||
// Windows: the wavelet encoder rides on top of whatever GPU backend the box has (NVENC/AMF/
|
||||
// QSV) — it opens its OWN Vulkan device by the render GPU's vendor/device-id and
|
||||
// zero-copy-imports the capturer's NV12 D3D11 texture, so the H.26x backend is irrelevant to
|
||||
// it. Only a software/GPU-less host keeps the bit off (no Vulkan GPU to open). Whether the
|
||||
// Session-0 external-memory import actually works is confirmed at encoder open
|
||||
// (`pyrowave_device_confirm_interop_support`); a failed open renegotiates to HEVC.
|
||||
#[cfg(all(target_os = "windows", feature = "pyrowave"))]
|
||||
let pyro = if windows_resolved_backend() != WindowsBackend::Software {
|
||||
punktfunk_core::quic::CODEC_PYROWAVE
|
||||
} else {
|
||||
0u8
|
||||
};
|
||||
#[cfg(not(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave")))]
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let pyro = 0u8;
|
||||
let base = (|| {
|
||||
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
||||
@@ -248,11 +236,10 @@ fn open_video_backend(
|
||||
if fps == 0 || fps > 1000 {
|
||||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||||
}
|
||||
// 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another
|
||||
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future
|
||||
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request
|
||||
// degrades to 4:2:0 with a warning.
|
||||
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
|
||||
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
||||
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
||||
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
||||
let chroma = if chroma.is_444() && codec != Codec::H265 {
|
||||
tracing::warn!(
|
||||
?codec,
|
||||
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
||||
@@ -268,7 +255,7 @@ fn open_video_backend(
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
@@ -288,14 +275,8 @@ fn open_video_backend(
|
||||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
||||
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
|
||||
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
||||
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
||||
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||
{
|
||||
if matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() {
|
||||
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||
{
|
||||
Ok(e) => {
|
||||
@@ -376,17 +357,8 @@ fn open_video_backend(
|
||||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||||
normal sessions negotiate it instead)"
|
||||
);
|
||||
// The lab override forces the wavelet stream onto a session negotiated for
|
||||
// another codec — that session's chroma may be HEVC-4:4:4, which the
|
||||
// pyrowave encoder doesn't do yet, so pin the override to 4:2:0.
|
||||
pyrowave::PyroWaveEncoder::open(
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
{
|
||||
@@ -427,29 +399,10 @@ fn open_video_backend(
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// A NEGOTIATED PyroWave session (client advertised + preferred it) routes straight to the
|
||||
// NV12 zero-copy wavelet backend (design/pyrowave-windows-host-zerocopy.md) — placed FIRST,
|
||||
// like the Linux branch. It opens its own Vulkan device by the render GPU's vendor/device-id
|
||||
// and imports the capturer's shared NV12 texture; the H.26x backend selection below is moot.
|
||||
// The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3);
|
||||
// host_wire_caps never advertises the bit here, so this only guards a forged preference.
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
let _ = (format, cuda);
|
||||
return pyrowave::PyroWaveEncoder::open(
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
chroma,
|
||||
bit_depth,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
anyhow::bail!(
|
||||
"session negotiated PyroWave but this host was built without --features \
|
||||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||||
);
|
||||
anyhow::bail!("PyroWave host encode is not available on Windows yet");
|
||||
}
|
||||
let _ = cuda; // always false on Windows (no Cuda payload)
|
||||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||||
@@ -882,13 +835,6 @@ pub fn vaapi_codec_support() -> CodecSupport {
|
||||
pub fn can_encode_444(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
if codec == Codec::PyroWave {
|
||||
// PyroWave does its own RGB→YCbCr CSC (capture always hands it a full-chroma source),
|
||||
// so 4:4:4 needs no GPU encode probe — only the full-res-chroma CSC variant:
|
||||
// `rgb2yuv444.comp` on Linux (Phase 2) and the mode-aware `BgraToYuvPlanes` on
|
||||
// Windows (Phase 3) — both landed (design/pyrowave-444-hdr.md).
|
||||
return true;
|
||||
}
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
@@ -962,10 +908,11 @@ pub fn can_encode_444(_codec: Codec) -> bool {
|
||||
/// Backend truth: Windows **NVENC** queries the per-codec `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` cap;
|
||||
/// native **AMF** `Init`s a tiny P010 encoder with the 10-bit profile props (the driver rejects
|
||||
/// what the VCN can't do). **QSV** stays `false` until validated on Intel glass — the libavcodec
|
||||
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. **Linux**
|
||||
/// probes a tiny real Main10 open on the auto-resolved backend — libav NVENC (the HDR X2RGB10→
|
||||
/// P010 swscale path) or VAAPI (P010 pool + Main10) — for the GNOME 50+ HDR portal capture;
|
||||
/// the direct-SDK CUDA path and Vulkan-video stay 8-bit and a 10-bit session routes around them.
|
||||
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. Every
|
||||
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
|
||||
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
|
||||
/// VAAPI 10-bit isn't wired, and Vulkan-video hardcodes 8-bit — so Linux hosts honestly negotiate
|
||||
/// 8-bit SDR.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
@@ -973,12 +920,6 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
if codec == Codec::PyroWave {
|
||||
// PyroWave needs no GPU encode probe (the wavelet is depth-agnostic) — only the HDR
|
||||
// capture CSC (scRGB FP16 → 16-bit studio-code planes), which exists on the Windows
|
||||
// IDD-push path only (design/pyrowave-444-hdr.md Phase 3; Linux capture has no HDR).
|
||||
return cfg!(target_os = "windows");
|
||||
}
|
||||
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
||||
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||
@@ -990,18 +931,8 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
let supported = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// NVENC (libav, the HDR P010 swscale path) or VAAPI (P010 upload / dmabuf graph),
|
||||
// probed by opening a tiny real Main10 encoder — the same honesty contract as
|
||||
// `can_encode_444`. Vulkan-video and the direct-SDK CUDA path stay 8-bit; a 10-bit
|
||||
// session routes around them (see `open_video_backend`). NOTE: encode capability is
|
||||
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
|
||||
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
|
||||
// honor), since this probe can't know what the compositor will negotiate.
|
||||
if linux_auto_is_vaapi() {
|
||||
vaapi::probe_can_encode_10bit(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_10bit(codec)
|
||||
}
|
||||
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
|
||||
false
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -1329,29 +1260,6 @@ mod vk_util;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[path = "enc/linux/pyrowave.rs"]
|
||||
mod pyrowave;
|
||||
// The Windows PyroWave encoder — NV12 zero-copy D3D11→Vulkan via pyrowave's own compat device
|
||||
// (design/pyrowave-windows-host-zerocopy.md). Same module name as the Linux one (per-platform
|
||||
// `#[path]`, mutually-exclusive cfg) so `crate::pyrowave::*` is flat on both.
|
||||
#[cfg(all(target_os = "windows", feature = "pyrowave"))]
|
||||
#[path = "enc/windows/pyrowave.rs"]
|
||||
mod pyrowave;
|
||||
// Shared PyroWave AU wire-framing (§4.4) — the single source of truth both platform backends emit,
|
||||
// so the on-wire access-unit layout the clients parse can never drift between Linux and Windows.
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave"))]
|
||||
#[path = "enc/pyrowave_wire.rs"]
|
||||
mod pyrowave_wire;
|
||||
|
||||
/// Whether a PyroWave mode fits the vendored rate controller's packed 16-bit block index
|
||||
/// (`patches/0002-rdo-saving-clamp.patch` note): false ≈ 8K-class 4:4:4. The negotiator
|
||||
/// downgrades such a session to 4:2:0 before the Welcome; the encoders also refuse outright.
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave"))]
|
||||
pub fn pyrowave_mode_fits_rdo(width: u32, height: u32, chroma444: bool) -> bool {
|
||||
pyrowave_wire::block_count_32x32(width, height, chroma444) <= u16::MAX as u32
|
||||
}
|
||||
#[cfg(not(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave")))]
|
||||
pub fn pyrowave_mode_fits_rdo(_width: u32, _height: u32, _chroma444: bool) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -34,35 +34,10 @@ pub struct WinCaptureTarget {
|
||||
pub wudf_pid: u32,
|
||||
}
|
||||
|
||||
/// The PyroWave (Windows) zero-copy sharing payload attached to a captured frame: the SECOND plane
|
||||
/// texture + the cross-device fence the wavelet encoder needs (design/pyrowave-windows-host-
|
||||
/// zerocopy.md). The wavelet encoder ingests **two SEPARATE** shareable plane textures — the full-res
|
||||
/// `R8_UNORM` **Y** rides [`D3d11Frame::texture`], and the half-res `R8G8_UNORM` **CbCr** rides
|
||||
/// [`cbcr`](Self::cbcr) — because importing a single *planar* NV12 texture into Vulkan is unreliable
|
||||
/// on NVIDIA at arbitrary sizes; separate single/two-component textures import reliably. `None` on
|
||||
/// every non-PyroWave frame (NVENC/AMF/QSV encode the in-place NV12/BGRA and need no cross-device
|
||||
/// fence). The encoder makes each texture's shared handle on demand.
|
||||
pub struct PyroFrameShare {
|
||||
/// The half-res `R8G8_UNORM` interleaved CbCr plane (created `SHARED | SHARED_NTHANDLE`). The
|
||||
/// full-res Y plane is [`D3d11Frame::texture`].
|
||||
pub cbcr: ID3D11Texture2D,
|
||||
/// The shared D3D11/D3D12 **fence** NT handle (raw), passed on EVERY frame; the encoder imports
|
||||
/// it (duplicating) whenever it has no timeline yet (first frame or after an encoder rebuild).
|
||||
pub fence_handle: Option<isize>,
|
||||
/// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan
|
||||
/// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC.
|
||||
pub fence_value: u64,
|
||||
}
|
||||
|
||||
/// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place;
|
||||
/// the PyroWave backend imports it — plus the second plane in [`pyro`](Self::pyro) — into its own
|
||||
/// Vulkan device). For a PyroWave frame, `texture` is the full-res `R8_UNORM` Y plane.
|
||||
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
||||
pub struct D3d11Frame {
|
||||
pub texture: ID3D11Texture2D,
|
||||
pub device: ID3D11Device,
|
||||
/// PyroWave zero-copy sharing info (the CbCr plane + fence); `None` unless this is a PyroWave
|
||||
/// session. See [`PyroFrameShare`].
|
||||
pub pyro: Option<PyroFrameShare>,
|
||||
}
|
||||
// SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
|
||||
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
|
||||
|
||||
@@ -56,19 +56,6 @@ pub enum PixelFormat {
|
||||
/// `DeviceBuffer::yuv444` — three full-res planes stacked in one allocation); NVENC encodes
|
||||
/// it natively under the Range-Extensions profile. Never a CPU payload.
|
||||
Yuv444,
|
||||
/// 10-bit RGB packed `x:R:G:B 2:10:10:10` little-endian (SPA `xRGB_210LE`, DRM `XRGB2101010` /
|
||||
/// `XR30`, ffmpeg `x2rgb10le`, NVENC `ARGB10`) — as an LE u32: B in bits 0-9, G 10-19, R 20-29.
|
||||
/// The Linux GNOME 50+ HDR screencast source format: Mutter advertises it (with BT.2020
|
||||
/// primaries + SMPTE ST.2084 PQ transfer) for a monitor in HDR mode, so the samples are
|
||||
/// PQ-encoded BT.2020 RGB. Linux-only; the Windows HDR path stays `Rgb10a2`/`P010`.
|
||||
X2Rgb10,
|
||||
/// 10-bit RGB packed `x:B:G:R 2:10:10:10` little-endian (SPA `xBGR_210LE`, DRM `XBGR2101010` /
|
||||
/// `XB30`, ffmpeg `x2bgr10le`, NVENC `ABGR10`) — as an LE u32: R in bits 0-9, G 10-19, B 20-29;
|
||||
/// the same memory layout as the Windows [`Rgb10a2`](Self::Rgb10a2) (DXGI `R10G10B10A2`). The
|
||||
/// second GNOME 50+ HDR screencast format (same PQ/BT.2020 colorimetry as
|
||||
/// [`X2Rgb10`](Self::X2Rgb10)); kept separate from `Rgb10a2` so the Linux and Windows HDR
|
||||
/// paths stay independently greppable.
|
||||
X2Bgr10,
|
||||
}
|
||||
|
||||
impl PixelFormat {
|
||||
@@ -80,12 +67,6 @@ impl PixelFormat {
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the packed 10-bit RGB layouts a Linux HDR (BT.2020 PQ) capture negotiates —
|
||||
/// the formats that make a session's encode bit depth 10 (HEVC Main10 / 10-bit AV1).
|
||||
pub fn is_hdr_rgb10(self) -> bool {
|
||||
matches!(self, PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10)
|
||||
}
|
||||
}
|
||||
|
||||
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||||
@@ -105,9 +86,6 @@ pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
|
||||
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
|
||||
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
||||
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
|
||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
|
||||
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
|
||||
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
|
||||
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
|
||||
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
|
||||
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
|
||||
@@ -137,13 +115,6 @@ pub struct OutputFormat {
|
||||
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
|
||||
/// 4:2:0 session.
|
||||
pub chroma_444: bool,
|
||||
/// A PyroWave (wavelet) session on Windows: the IDD-push capturer must make its NV12 out-ring
|
||||
/// **shareable** (`SHARED | SHARED_NTHANDLE`) and signal a **shared fence** after each convert,
|
||||
/// so the pyrowave encoder can zero-copy-import the texture into its own Vulkan device
|
||||
/// (design/pyrowave-windows-host-zerocopy.md). Also forces the NV12 4:2:0 SDR convert branch
|
||||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
||||
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||||
pub pyrowave: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
@@ -159,8 +130,6 @@ impl OutputFormat {
|
||||
hdr,
|
||||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||
chroma_444: false,
|
||||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||||
pyrowave: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,16 +76,10 @@ pub struct HostConfig {
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
/// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam
|
||||
/// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its
|
||||
/// own regardless of this knob; it exists to force it on for non-Steam launches too. Managed
|
||||
/// gamescope-session-plus/SteamOS sessions own their own flags and do not consult this.
|
||||
/// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam
|
||||
/// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their
|
||||
/// own flags and do not consult this.
|
||||
pub gamescope_steam: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_GRAB_CURSOR` — add `--force-grab-cursor` to the bare headless gamescope
|
||||
/// spawn for an actual game launch, forcing relative-mouse capture so FPS mouselook works over the
|
||||
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
|
||||
/// and menus, so it's opt-in per host until validated on-glass.
|
||||
pub gamescope_grab_cursor: bool,
|
||||
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||
@@ -158,12 +152,6 @@ impl HostConfig {
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}),
|
||||
gamescope_grab_cursor: val("PUNKTFUNK_GAMESCOPE_GRAB_CURSOR").is_some_and(|s| {
|
||||
matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# gamescope-EI socket path is the shared pf-paths contract, not a vdisplay reach-in).
|
||||
[package]
|
||||
name = "pf-inject"
|
||||
version.workspace = true
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! D3D11 shared-texture → Vulkan import (Windows): the presenter half of the D3D11VA
|
||||
//! decode path (`pf_client_core::video_d3d11`). Each decoded frame arrives as the NT
|
||||
//! handle of a shareable single-plane RGB texture — **BGRA8** sRGB normally, **RGB10A2**
|
||||
//! PQ for the HDR pass-through flavor (the decoder's VideoProcessor already did
|
||||
//! handle of a shareable **BGRA8** texture (the decoder's VideoProcessor already did
|
||||
//! YUV→RGB); we import it as a single-plane VkImage (`VK_KHR_external_memory_win32`,
|
||||
//! dedicated allocation) and the presenter blits it straight into its video image — no
|
||||
//! CSC pass. Single-plane RGBA is deliberate: importing the earlier multiplanar NV12
|
||||
@@ -29,41 +28,31 @@ pub const DEVICE_EXTENSIONS: [&std::ffi::CStr; 2] = [
|
||||
ash::khr::win32_keyed_mutex::NAME,
|
||||
];
|
||||
|
||||
/// Can this device import a D3D11 texture of `format` as a blit source? The spec-required
|
||||
/// Can this device import a D3D11 BGRA8 texture as a blit source? The spec-required
|
||||
/// capability probe for the exact image the import path creates — creating an external
|
||||
/// image the driver doesn't support is undefined behavior (observed as
|
||||
/// `VK_ERROR_DEVICE_LOST` at the first submits with the old NV12 hand-off).
|
||||
fn format_importable(
|
||||
instance: &ash::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
format: vk::Format,
|
||||
) -> bool {
|
||||
pub fn import_supported(instance: &ash::Instance, pdev: vk::PhysicalDevice) -> bool {
|
||||
let mut ext_info = vk::PhysicalDeviceExternalImageFormatInfo::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE);
|
||||
let fmt_info = vk::PhysicalDeviceImageFormatInfo2::default()
|
||||
.format(format)
|
||||
.format(vk::Format::B8G8R8A8_UNORM)
|
||||
.ty(vk::ImageType::TYPE_2D)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(vk::ImageUsageFlags::TRANSFER_SRC)
|
||||
.push_next(&mut ext_info);
|
||||
let mut ext_props = vk::ExternalImageFormatProperties::default();
|
||||
let mut props = vk::ImageFormatProperties2::default().push_next(&mut ext_props);
|
||||
unsafe { instance.get_physical_device_image_format_properties2(pdev, &fmt_info, &mut props) }
|
||||
.is_ok()
|
||||
let ok = unsafe {
|
||||
instance.get_physical_device_image_format_properties2(pdev, &fmt_info, &mut props)
|
||||
}
|
||||
.is_ok()
|
||||
&& ext_props
|
||||
.external_memory_properties
|
||||
.external_memory_features
|
||||
.contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE)
|
||||
}
|
||||
|
||||
/// The two hand-off flavors' import support: `.0` = BGRA8 (the SDR ring — gates the whole
|
||||
/// D3D11VA path), `.1` = RGB10A2 (the HDR PQ ring — gates only the pass-through flavor;
|
||||
/// without it a PQ stream keeps the decoder-side tonemap to BGRA8).
|
||||
pub fn import_supported(instance: &ash::Instance, pdev: vk::PhysicalDevice) -> (bool, bool) {
|
||||
let bgra8 = format_importable(instance, pdev, vk::Format::B8G8R8A8_UNORM);
|
||||
let rgb10 = format_importable(instance, pdev, vk::Format::A2B10G10R10_UNORM_PACK32);
|
||||
tracing::info!(bgra8, rgb10, "D3D11 texture → Vulkan import support");
|
||||
(bgra8, rgb10)
|
||||
.contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE);
|
||||
tracing::info!(bgra8 = ok, "D3D11 texture → Vulkan import support");
|
||||
ok
|
||||
}
|
||||
|
||||
/// One imported frame: the BGRA8 image over the shared texture and its imported
|
||||
@@ -109,13 +98,7 @@ pub fn import(
|
||||
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
|
||||
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
|
||||
}
|
||||
// DXGI R10G10B10A2 and Vulkan A2B10G10R10_PACK32 are the same bit layout (R in the
|
||||
// low bits) — the standard interop pairing, same as BGRA8 ↔ B8G8R8A8.
|
||||
let mp_format = if frame.rgb10 {
|
||||
vk::Format::A2B10G10R10_UNORM_PACK32
|
||||
} else {
|
||||
vk::Format::B8G8R8A8_UNORM
|
||||
};
|
||||
let mp_format = vk::Format::B8G8R8A8_UNORM;
|
||||
let handle_type = vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE;
|
||||
|
||||
// One single-plane image over the whole texture, transfer-source only — the blit is
|
||||
|
||||
@@ -194,7 +194,7 @@ struct StreamState {
|
||||
/// PyroWave present has no demote rung (nothing else decodes the codec), so a
|
||||
/// persistent non-device-lost present failure would warn on every frame. Latch it:
|
||||
/// warn on the first failure of a streak, then stay quiet until a present succeeds.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pyro_present_warned: bool,
|
||||
hw_fails: u32,
|
||||
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||
@@ -267,7 +267,7 @@ impl StreamState {
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
dmabuf_demoted: false,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pyro_present_warned: false,
|
||||
hw_fails: 0,
|
||||
osd_text: String::new(),
|
||||
@@ -1007,12 +1007,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// PyroWave planar frames: already on the presenter's device and
|
||||
// fence-complete — a present failure has no demote rung (nothing
|
||||
// else decodes the codec); only device loss ends the session.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => {
|
||||
// The wavelet stream carries the negotiated ColorInfo (no VUI): an
|
||||
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
|
||||
// like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3).
|
||||
st.hdr = f.color.is_pq();
|
||||
st.hdr = false; // 8-bit SDR codec
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::PyroWave(f),
|
||||
|
||||
@@ -47,7 +47,7 @@ pub enum FrameInput<'a> {
|
||||
D3d11(pf_client_core::video::D3d11Frame),
|
||||
/// PyroWave planar output — three R8 plane views already on THIS device, decode
|
||||
/// fence-complete, GENERAL layout (`pf_client_core::video_pyrowave`).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(pf_client_core::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ pub struct Presenter {
|
||||
csc: CscPass,
|
||||
/// The planar (3-plane) CSC variant for PyroWave frames; built only when the device
|
||||
/// passed the pyrowave probe.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
csc_planar: Option<CscPass>,
|
||||
/// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it.
|
||||
video_export: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
@@ -304,7 +304,7 @@ impl Drop for Presenter {
|
||||
#[cfg(target_os = "linux")]
|
||||
self.hw.take();
|
||||
self.csc.destroy(&self.device);
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let Some(p) = &self.csc_planar {
|
||||
p.destroy(&self.device);
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ impl Presenter {
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
#[cfg(windows)]
|
||||
FrameInput::D3d11(d) => Some(d.color.is_pq()),
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => Some(f.color.is_pq()),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today
|
||||
};
|
||||
if let Some(pq) = frame_pq {
|
||||
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
|
||||
@@ -68,7 +68,7 @@ impl Presenter {
|
||||
#[cfg(windows)]
|
||||
let mut win_frame: Option<crate::d3d11::HwFrame> = None;
|
||||
let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None;
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let mut pyro_frame: Option<pf_client_core::video_pyrowave::PyroWavePlanarFrame> = None;
|
||||
let cpu_frame = match input {
|
||||
FrameInput::Redraw => None,
|
||||
@@ -96,7 +96,7 @@ impl Presenter {
|
||||
vk_frame = Some((v, views));
|
||||
None
|
||||
}
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => {
|
||||
pyro_frame = Some(f);
|
||||
None
|
||||
@@ -155,7 +155,7 @@ impl Presenter {
|
||||
}
|
||||
self.csc.bind_planes(&self.device, views[0], views[1]);
|
||||
}
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let Some(f) = &pyro_frame {
|
||||
if self
|
||||
.video
|
||||
@@ -239,12 +239,11 @@ impl Presenter {
|
||||
);
|
||||
}
|
||||
|
||||
// D3D11 frame: acquire the imported RGB texture from the external "queue
|
||||
// D3D11 frame: acquire the imported BGRA texture from the external "queue
|
||||
// family" (the keyed mutex on the submit is the actual cross-API sync) and
|
||||
// blit it into the video image — the frame arrives as ready RGB from the
|
||||
// decoder's VideoProcessor (sRGB BGRA8, or PQ RGB10A2 on the HDR ring —
|
||||
// matching the HDR-mode video image), so there is no CSC pass; the blit
|
||||
// converts component order. Same layout dance as the CPU staging path.
|
||||
// blit it into the video image — the frame arrives as ready sRGB from the
|
||||
// decoder's VideoProcessor, so there is no CSC pass; the blit converts the
|
||||
// BGRA→RGBA component order. Same layout dance as the CPU staging path.
|
||||
#[cfg(windows)]
|
||||
if let (Some(f), Some(v)) = (&win_frame, &self.video) {
|
||||
external_acquire_barrier(&self.device, self.cmd_buf, f.image(), self.qfi);
|
||||
@@ -317,7 +316,7 @@ impl Presenter {
|
||||
// PyroWave frame: the planes are already on THIS device, decode
|
||||
// fence-complete and barriered to fragment sampling (GENERAL) by the
|
||||
// decoder — no acquire needed, just the planar CSC pass.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let (Some(f), Some(v)) = (&pyro_frame, &self.video) {
|
||||
let extent = vk::Extent2D {
|
||||
width: v.width,
|
||||
@@ -695,7 +694,7 @@ impl Presenter {
|
||||
}
|
||||
|
||||
/// [`record_csc`] over the planar (PyroWave) pass — always 8-bit, no MSB packing.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
unsafe fn record_csc_planar(
|
||||
&self,
|
||||
framebuffer: vk::Framebuffer,
|
||||
@@ -751,31 +750,11 @@ impl Presenter {
|
||||
&[planar.desc_set],
|
||||
&[],
|
||||
);
|
||||
// An HDR (PQ) pyrowave session carries P010-style 10-bit studio codes MSB-packed
|
||||
// into 16-bit planes (design/pyrowave-444-hdr.md §2.2) — same sampling scale as
|
||||
// the P010 path; SDR sessions are plain 8-bit BT.709 limited. Depth follows the
|
||||
// colour contract (negotiation couples 10-bit ⟺ PQ for this codec).
|
||||
let (depth, msb_packed) = if color.is_pq() {
|
||||
(10, true)
|
||||
} else {
|
||||
(8, false)
|
||||
};
|
||||
let rows = csc_rows(color, depth, msb_packed);
|
||||
// Mode 1 = PQ→SDR tonemap (PQ stream without an HDR10 surface); mode 0 passes
|
||||
// the transfer through — identical to the NV12 arm above.
|
||||
let mode = if color.is_pq() && !self.hdr_active {
|
||||
1.0f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
|
||||
let rows = csc_rows(color, 8, false);
|
||||
let mut pc = [0f32; 16];
|
||||
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
||||
pc[12] = mode;
|
||||
pc[13] = peak;
|
||||
pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path
|
||||
pc[13] = 0.0;
|
||||
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd_buf,
|
||||
|
||||
@@ -203,15 +203,11 @@ impl Presenter {
|
||||
vk::Format::R8G8B8A8_UNORM
|
||||
};
|
||||
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
|
||||
self.csc = CscPass::new(&self.device, self.video_format)?;
|
||||
// The planar (PyroWave) pass renders to the same intermediate — rebuild it at the
|
||||
// new format too (an HDR pyrowave session needs the 10-bit intermediate exactly
|
||||
// like the H.26x path; 8-bit PQ bands visibly).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if let Some(p) = self.csc_planar.take() {
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let Some(p) = &self.csc_planar {
|
||||
p.destroy(&self.device);
|
||||
self.csc_planar = Some(CscPass::new_planar(&self.device, self.video_format)?);
|
||||
}
|
||||
self.csc = CscPass::new(&self.device, self.video_format)?;
|
||||
if let Some(v) = self.video.take() {
|
||||
unsafe {
|
||||
self.device.destroy_framebuffer(v.framebuffer, None);
|
||||
|
||||
@@ -94,9 +94,8 @@ impl Presenter {
|
||||
// (vkGetPhysicalDeviceImageFormatProperties2 — creating an unsupported external
|
||||
// image is UB, observed as VK_ERROR_DEVICE_LOST at the first submits on NVIDIA).
|
||||
#[cfg(windows)]
|
||||
let (import_bgra8, import_rgb10) = crate::d3d11::import_supported(&instance, pdev);
|
||||
#[cfg(windows)]
|
||||
let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n)) && import_bgra8;
|
||||
let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n))
|
||||
&& crate::d3d11::import_supported(&instance, pdev);
|
||||
#[cfg(windows)]
|
||||
if win_capable {
|
||||
dev_exts.extend(crate::d3d11::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr()));
|
||||
@@ -316,9 +315,8 @@ impl Presenter {
|
||||
ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device),
|
||||
});
|
||||
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
|
||||
// Starts SDR like `csc`; an HDR (PQ) pyrowave session rebuilds it at the 10-bit
|
||||
// intermediate via `set_hdr_mode`, exactly like the H.26x pass.
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
// PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let csc_planar = if pyrowave_ok {
|
||||
Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)
|
||||
} else {
|
||||
@@ -393,25 +391,14 @@ impl Presenter {
|
||||
d3d11_import: win_capable,
|
||||
#[cfg(not(windows))]
|
||||
d3d11_import: false,
|
||||
// Filled in below — the HDR10 surface facts arrive with pick_formats.
|
||||
d3d11_hdr10: false,
|
||||
adapter_luid,
|
||||
queue_lock: queue_lock.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut video_export = video_export;
|
||||
|
||||
let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
|
||||
// The D3D11VA backend may emit its HDR (RGB10 PQ) ring only when this device can
|
||||
// import the 10-bit texture AND the surface offers an HDR10 swapchain to pass it
|
||||
// through to; otherwise a PQ stream keeps the decoder-side tonemap to sRGB.
|
||||
#[cfg(windows)]
|
||||
if let Some(v) = video_export.as_mut() {
|
||||
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
|
||||
}
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
|
||||
tracing::info!(
|
||||
?format,
|
||||
@@ -463,7 +450,7 @@ impl Presenter {
|
||||
#[cfg(windows)]
|
||||
hw_win,
|
||||
csc,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
csc_planar,
|
||||
video_export,
|
||||
overlay_pipe,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# lifecycle events invert to a host-registered sink).
|
||||
[package]
|
||||
name = "pf-vdisplay"
|
||||
version.workspace = true
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -78,7 +78,7 @@ pub use routing::{
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session,
|
||||
launch_is_nested, steam_appid_from_launch, watch_steam_game_exit,
|
||||
launch_is_nested,
|
||||
};
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
|
||||
@@ -25,10 +25,7 @@ use discovery::{
|
||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
||||
gamescope_node_present, poll_managed_node, wait_for_node,
|
||||
};
|
||||
pub(crate) use discovery::{
|
||||
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
|
||||
SteamGameWatch,
|
||||
};
|
||||
pub(crate) use discovery::{game_session_exited, is_available};
|
||||
|
||||
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
||||
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
||||
@@ -1372,14 +1369,7 @@ fn shape_dedicated_command(app: &str) -> String {
|
||||
/// Add the compositor-side arguments shared by every bare gamescope spawn. `steam_mode` belongs
|
||||
/// before the `--` terminator; [`PUNKTFUNK_GAMESCOPE_APP`](spawn) configures the nested command
|
||||
/// after it and therefore cannot enable gamescope's Steam integration itself.
|
||||
fn add_bare_gamescope_args(
|
||||
command: &mut Command,
|
||||
w: u32,
|
||||
h: u32,
|
||||
hz: u32,
|
||||
steam_mode: bool,
|
||||
grab_cursor: bool,
|
||||
) {
|
||||
fn add_bare_gamescope_args(command: &mut Command, w: u32, h: u32, hz: u32, steam_mode: bool) {
|
||||
command
|
||||
.args(["--backend", "headless"])
|
||||
.args(["-W", &w.to_string()])
|
||||
@@ -1388,9 +1378,6 @@ fn add_bare_gamescope_args(
|
||||
if steam_mode {
|
||||
command.arg("--steam");
|
||||
}
|
||||
if grab_cursor {
|
||||
command.arg("--force-grab-cursor");
|
||||
}
|
||||
command.args(["--xwayland-count", "1", "--"]);
|
||||
}
|
||||
|
||||
@@ -1411,27 +1398,16 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
||||
// Read the env fallback under the shared env lock so it can't race a concurrent session's
|
||||
// `set_var` of the same key (security-review 2026-06-28 #7).
|
||||
.or_else(|| crate::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()))
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
// A real app was requested (vs. the `sleep infinity` keep-alive) — used to scope the game-only
|
||||
// cursor-grab flag below.
|
||||
let game_launch = app.is_some();
|
||||
let app = app.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
|
||||
// gamescope focus with no Steam client window to navigate.
|
||||
let app = shape_dedicated_command(&app);
|
||||
let relay = ei_socket_file();
|
||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||
// Enable gamescope's Steam integration (`--steam`: in-game overlay, Steam+X shortcuts, gamepad-UI
|
||||
// navigation) whenever we're launching Steam — the operator no longer has to set the global
|
||||
// PUNKTFUNK_GAMESCOPE_STEAM knob for a Steam title. The knob still forces it on for every spawn.
|
||||
let steam_mode = pf_host_config::config().gamescope_steam || is_steam_launch(&app);
|
||||
// Opt-in relative-mouse capture for a nested game (`PUNKTFUNK_GAMESCOPE_GRAB_CURSOR`): the client
|
||||
// already sends relative motion, but gamescope only enters relative mode when the app hides the
|
||||
// cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook.
|
||||
// Default OFF (it forces relative mode, which would break absolute-pointer games/menus).
|
||||
let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor;
|
||||
let steam_mode = pf_host_config::config().gamescope_steam;
|
||||
let mut cmd = Command::new("gamescope");
|
||||
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
|
||||
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode);
|
||||
cmd.args([
|
||||
"sh",
|
||||
"-c",
|
||||
|
||||
@@ -29,122 +29,6 @@ pub(crate) fn game_session_exited(node_id: u32) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of watching a dedicated Steam launch's game lifetime ([`wait_for_steam_game_exit`]).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum SteamGameWatch {
|
||||
/// The game was seen running and has now exited — the session should end (APP_EXITED).
|
||||
Exited,
|
||||
/// The watch was cancelled (the session ended for another reason) or the game never started
|
||||
/// within the startup grace — leave the session as-is.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Parse the Steam appid a dedicated launch targets from its resolved command
|
||||
/// (`steam [-silent] steam://rungameid/<appid>`). `None` unless the first token is `steam` and a
|
||||
/// `steam://rungameid/<digits>` URI is present — the trailing digits are the appid, which is exactly
|
||||
/// what Steam's launch reaper carries as `AppId=<appid>` (gameid == appid for a plain library title,
|
||||
/// the only kind the host ever resolves to this shape).
|
||||
pub(crate) fn steam_appid_from_launch(cmd: &str) -> Option<u32> {
|
||||
if cmd.split_whitespace().next() != Some("steam") {
|
||||
return None;
|
||||
}
|
||||
const MARKER: &str = "steam://rungameid/";
|
||||
let tail = &cmd[cmd.find(MARKER)? + MARKER.len()..];
|
||||
let digits: String = tail
|
||||
.chars()
|
||||
.take_while(|c: &char| c.is_ascii_digit())
|
||||
.collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
/// Block until the dedicated Steam game `appid` has started and then exited, `cancel` is set, or the
|
||||
/// game never appears within the startup grace. Same-uid `/proc` scan keyed on Steam's launch reaper
|
||||
/// (`SteamLaunch AppId=<appid>`), whose lifetime is exactly the game's. Returns
|
||||
/// [`SteamGameWatch::Exited`] only after the game was actually seen running and then stayed gone
|
||||
/// across a short confirmation window — so a cold Steam boot / shader precompile (game not up yet) or
|
||||
/// a transient scan miss can't end the stream early. Runs on the host's per-session watch thread.
|
||||
pub(crate) fn wait_for_steam_game_exit(
|
||||
appid: u32,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> SteamGameWatch {
|
||||
use std::sync::atomic::Ordering;
|
||||
// Cold Steam boot + first-launch shader precompile can delay the game window by minutes; give it a
|
||||
// generous window to appear. A game that never starts leaves the session up (the Steam client is
|
||||
// still streamed, and the node-death path still covers the Steam client itself dying).
|
||||
const START_GRACE: Duration = Duration::from_secs(300);
|
||||
const POLL: Duration = Duration::from_secs(1);
|
||||
// Require the reaper gone across this window (a few polls) so a brief process swap can't fire early.
|
||||
const EXIT_CONFIRM: Duration = Duration::from_secs(3);
|
||||
|
||||
let start_deadline = Instant::now() + START_GRACE;
|
||||
// Phase 1: wait for the game's reaper to appear.
|
||||
while !steam_game_running(appid) {
|
||||
if cancel.load(Ordering::Relaxed) || Instant::now() >= start_deadline {
|
||||
return SteamGameWatch::Cancelled;
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
// Phase 2: the game is up — wait for its reaper to disappear (confirmed across the window).
|
||||
let mut gone_since: Option<Instant> = None;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return SteamGameWatch::Cancelled;
|
||||
}
|
||||
if steam_game_running(appid) {
|
||||
gone_since = None;
|
||||
} else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM {
|
||||
return SteamGameWatch::Exited;
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is Steam's launch reaper for appid `appid` alive right now (same uid as the host)? Steam wraps
|
||||
/// every game launch — native or Proton — in `…/reaper SteamLaunch AppId=<appid> -- <game>`, and the
|
||||
/// reaper lives for the game's whole lifetime, so its presence is a precise "the game is running"
|
||||
/// signal. Matched on the `SteamLaunch` + `AppId=<appid>` argv tokens together (exact-match, so
|
||||
/// `AppId=57` never matches appid 570) — specific to the game reaper, so Steam's own shader-precompile
|
||||
/// step (not reaper-wrapped) can't be mistaken for the game.
|
||||
fn steam_game_running(appid: u32) -> bool {
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let appid_tok = format!("AppId={appid}");
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return false;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
let Some(pid_str) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = std::fs::metadata(e.path()) else {
|
||||
continue;
|
||||
};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let Ok(cmdline) = std::fs::read(e.path().join("cmdline")) else {
|
||||
continue;
|
||||
};
|
||||
let (mut launch, mut appid_match) = (false, false);
|
||||
for arg in cmdline.split(|&b| b == 0) {
|
||||
if arg == b"SteamLaunch" {
|
||||
launch = true;
|
||||
} else if arg == appid_tok.as_bytes() {
|
||||
appid_match = true;
|
||||
}
|
||||
}
|
||||
if launch && appid_match {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
||||
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||
@@ -379,29 +263,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE};
|
||||
|
||||
#[test]
|
||||
fn parses_steam_appid_from_launch() {
|
||||
// The resolved dedicated-launch command (pre- or post-`-silent` shaping) → the appid.
|
||||
assert_eq!(
|
||||
steam_appid_from_launch("steam steam://rungameid/570"),
|
||||
Some(570)
|
||||
);
|
||||
assert_eq!(
|
||||
steam_appid_from_launch("steam -silent steam://rungameid/1091500"),
|
||||
Some(1091500)
|
||||
);
|
||||
// Non-Steam launches / bare Steam with no rungameid URI → no appid (no game-exit watch).
|
||||
assert_eq!(steam_appid_from_launch("lutris lutris:rungameid/42"), None);
|
||||
assert_eq!(steam_appid_from_launch("steam -gamepadui"), None);
|
||||
assert_eq!(steam_appid_from_launch("vkcube"), None);
|
||||
// A steam:// URI that isn't the first `steam` token (a custom command) is not treated as one.
|
||||
assert_eq!(
|
||||
steam_appid_from_launch("firefox steam://rungameid/570"),
|
||||
None
|
||||
);
|
||||
}
|
||||
use super::{parse_version, MIN_GAMESCOPE};
|
||||
|
||||
#[test]
|
||||
fn parses_version_banner() {
|
||||
|
||||
@@ -176,28 +176,6 @@ pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The Steam appid a dedicated launch targets (`steam … steam://rungameid/<appid>`), for the
|
||||
/// game-exit watcher. `None` for a non-Steam launch (those are covered by the node-death path
|
||||
/// [`dedicated_game_exited`] — gamescope's nested child IS the game). See
|
||||
/// [`gamescope::steam_appid_from_launch`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn steam_appid_from_launch(cmd: &str) -> Option<u32> {
|
||||
gamescope::steam_appid_from_launch(cmd)
|
||||
}
|
||||
|
||||
/// Block until the dedicated Steam game `appid` has started and then exited — returns `true` when the
|
||||
/// session should end cleanly (APP_EXITED). Returns `false` if `cancel` is set (the session ended for
|
||||
/// another reason) or the game never started within the startup grace (leave the session up). Runs on
|
||||
/// the host's per-session watch thread; `cancel` is the session's stop flag. See
|
||||
/// [`gamescope::wait_for_steam_game_exit`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn watch_steam_game_exit(appid: u32, cancel: &std::sync::atomic::AtomicBool) -> bool {
|
||||
matches!(
|
||||
gamescope::wait_for_steam_game_exit(appid, cancel),
|
||||
gamescope::SteamGameWatch::Exited
|
||||
)
|
||||
}
|
||||
|
||||
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn cancel_pending_tv_restore() {
|
||||
|
||||
@@ -320,17 +320,6 @@ impl RemoteImporter {
|
||||
self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
|
||||
}
|
||||
|
||||
/// Mirror of [`super::egl::EglImporter::import_linear_nv12`] (LINEAR dmabuf → Vulkan-bridge
|
||||
/// compute CSC → two-plane NV12 buffer, latency plan T2.5b).
|
||||
pub fn import_linear_nv12(
|
||||
&mut self,
|
||||
plane: &DmabufPlane,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<DeviceBuffer> {
|
||||
self.import_impl(plane, ImportKind::LinearNv12, width, height, 0, None)
|
||||
}
|
||||
|
||||
fn import_impl(
|
||||
&mut self,
|
||||
plane: &DmabufPlane,
|
||||
|
||||
@@ -1386,50 +1386,3 @@ pub fn copy_pitched_to_buffer(
|
||||
// within both. Wrapper → live table.
|
||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(ext->dev)") }
|
||||
}
|
||||
|
||||
/// De-stride an NV12 pair from an external mapping (the Vulkan bridge's exportable buffer after
|
||||
/// its compute CSC — latency plan T2.5b) into a pooled two-plane NV12 [`DeviceBuffer`]: the Y
|
||||
/// plane (`width` bytes × `height` rows) and the interleaved UV plane (`width` bytes × ⌈h/2⌉
|
||||
/// rows), each de-strided from `src_pitch` to the pool's own plane pitches. Same contract as
|
||||
/// [`copy_pitched_to_buffer`]: the shared context must be current.
|
||||
pub fn copy_pitched_nv12_to_buffer(
|
||||
y_src: CUdeviceptr,
|
||||
uv_src: CUdeviceptr,
|
||||
src_pitch: usize,
|
||||
dst: &DeviceBuffer,
|
||||
) -> Result<()> {
|
||||
let Some((uv_ptr, uv_pitch)) = dst.uv else {
|
||||
anyhow::bail!("copy_pitched_nv12_to_buffer: destination is not an NV12 buffer");
|
||||
};
|
||||
let y = CUDA_MEMCPY2D {
|
||||
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||
srcDevice: y_src,
|
||||
srcPitch: src_pitch,
|
||||
dstMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||
dstDevice: dst.ptr,
|
||||
dstPitch: dst.pitch,
|
||||
WidthInBytes: dst.width as usize,
|
||||
Height: dst.height as usize,
|
||||
..Default::default()
|
||||
};
|
||||
let uv = CUDA_MEMCPY2D {
|
||||
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||
srcDevice: uv_src,
|
||||
srcPitch: src_pitch,
|
||||
dstMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||
dstDevice: uv_ptr,
|
||||
dstPitch: uv_pitch,
|
||||
// W/2 interleaved UV samples × 2 bytes = `width` bytes per row.
|
||||
WidthInBytes: dst.width as usize,
|
||||
Height: dst.height.div_ceil(2) as usize,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: same contract as `copy_pitched_to_buffer` — the caller holds the shared context
|
||||
// current; both `CUDA_MEMCPY2D`s are live locals describing spans inside the caller's live
|
||||
// external mapping (`y_src`/`uv_src` at `src_pitch`) and `dst`'s live pooled planes; each
|
||||
// `copy_blocking` synchronizes before returning.
|
||||
unsafe {
|
||||
copy_blocking(&y, "cuMemcpy2DAsync_v2(ext->dev nv12 Y)")?;
|
||||
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,9 +504,6 @@ pub struct EglImporter {
|
||||
/// created lazily on the first LINEAR frame, + the destination pool.
|
||||
vk: Option<super::vulkan::VkBridge>,
|
||||
linear_pool: Option<cuda::BufferPool>,
|
||||
/// NV12 twin of [`linear_pool`](Self::linear_pool) for the bridge's compute-CSC output
|
||||
/// (T2.5b) — separate pools because a session may fall back RGB mid-stream.
|
||||
linear_nv12_pool: Option<cuda::BufferPool>,
|
||||
gbm: *mut c_void,
|
||||
render_fd: c_int,
|
||||
}
|
||||
@@ -650,7 +647,6 @@ impl EglImporter {
|
||||
yuv444_blit: None,
|
||||
vk: None,
|
||||
linear_pool: None,
|
||||
linear_nv12_pool: None,
|
||||
gbm,
|
||||
render_fd,
|
||||
})
|
||||
@@ -681,38 +677,6 @@ impl EglImporter {
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`import_linear`](Self::import_linear), but the bridge's compute CSC converts to a
|
||||
/// two-plane **NV12** buffer (latency plan T2.5b) — the gamescope/LINEAR analogue of
|
||||
/// [`import_nv12`](Self::import_nv12), so NVENC encodes native YUV on the dedicated-session
|
||||
/// path too instead of paying its internal RGB→YUV CSC on the contended SM.
|
||||
pub fn import_linear_nv12(
|
||||
&mut self,
|
||||
plane: &DmabufPlane,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<DeviceBuffer> {
|
||||
cuda::make_current()?;
|
||||
if self
|
||||
.linear_nv12_pool
|
||||
.as_ref()
|
||||
.map(|p| (p.width(), p.height()))
|
||||
!= Some((width, height))
|
||||
{
|
||||
self.linear_nv12_pool = Some(cuda::BufferPool::new_nv12(width, height)?);
|
||||
}
|
||||
if self.vk.is_none() {
|
||||
self.vk = Some(super::vulkan::VkBridge::new()?);
|
||||
}
|
||||
self.vk.as_mut().unwrap().import_linear_nv12(
|
||||
plane.fd,
|
||||
plane.offset,
|
||||
plane.stride,
|
||||
width,
|
||||
height,
|
||||
self.linear_nv12_pool.as_ref().unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]).
|
||||
/// No-op when the bridge hasn't been built (tiled-only captures).
|
||||
pub fn forget_linear_fd(&mut self, fd: i32) {
|
||||
|
||||
@@ -166,20 +166,6 @@ impl Importer {
|
||||
}
|
||||
}
|
||||
|
||||
/// LINEAR dmabuf → Vulkan-bridge compute CSC → two-plane NV12 buffer (latency plan T2.5b —
|
||||
/// the gamescope analogue of [`import_nv12`](Self::import_nv12)).
|
||||
pub fn import_linear_nv12(
|
||||
&mut self,
|
||||
plane: &DmabufPlane,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> anyhow::Result<DeviceBuffer> {
|
||||
match self {
|
||||
Importer::Remote(r) => r.import_linear_nv12(plane, width, height),
|
||||
Importer::InProc(i) => i.import_linear_nv12(plane, width, height),
|
||||
}
|
||||
}
|
||||
|
||||
/// True once the worker process is gone/wedged (every further call fails fast). Always
|
||||
/// `false` in-process — an in-process driver fault doesn't return.
|
||||
pub fn dead(&self) -> bool {
|
||||
|
||||
@@ -41,10 +41,6 @@ pub enum ImportKind {
|
||||
/// variants' wire tags must never shift — an old worker receiving this fails the decode and
|
||||
/// the import-fail machinery handles it like any other worker error.
|
||||
Tiled444,
|
||||
/// LINEAR dmabuf → Vulkan-bridge compute CSC → two-plane NV12 CUDA buffer (latency plan
|
||||
/// T2.5b — the gamescope analogue of [`TiledNv12`](Self::TiledNv12)). Appended last, same
|
||||
/// wire-tag rule as [`Tiled444`](Self::Tiled444).
|
||||
LinearNv12,
|
||||
}
|
||||
|
||||
/// host → worker.
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#version 450
|
||||
// LINEAR BGRx dmabuf bytes -> NV12 (BT.709 limited range), BUFFER to BUFFER — the Vulkan-bridge
|
||||
// CSC for the gamescope path (latency plan T2.5b). The bridge deals in VkBuffers (NVIDIA's EGL
|
||||
// can't sample LINEAR and CUDA rejects raw dmabuf fds), so unlike `pf-encode`'s image-based
|
||||
// `rgb2yuv.comp` sibling this reads packed texels straight out of the imported dmabuf buffer and
|
||||
// writes the two NV12 planes into the exportable buffer CUDA reads. Same BT.709 coefficients as
|
||||
// the sibling shader — keep the two in sync.
|
||||
//
|
||||
// One invocation converts a 4x2 luma block: 4 Y bytes = exactly one u32 word per row, and its
|
||||
// two chroma samples = one u32 word — so every store is a whole word and no two invocations
|
||||
// touch the same word (an 8-bit-storage-free way to write byte planes race-free). All pitches
|
||||
// and the UV offset are in WORDS and must be word-aligned (the Rust side sizes them so).
|
||||
//
|
||||
// Rebuild: glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
|
||||
layout(std430, binding = 0) readonly buffer Src {
|
||||
uint spx[]; // packed 4-byte texels, byte order B,G,R,X (little-endian XRGB8888/ARGB8888)
|
||||
};
|
||||
layout(std430, binding = 1) writeonly buffer Dst {
|
||||
uint dw[]; // [0, uv_off_w): Y rows at y_pitch_w; [uv_off_w, ...): interleaved UV rows
|
||||
};
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
uint width; // source pixels
|
||||
uint height;
|
||||
uint src_off_w; // dmabuf plane offset, in words (offset bytes / 4)
|
||||
uint src_pitch_w; // dmabuf row stride, in words
|
||||
uint y_pitch_w; // dst Y row pitch, in words
|
||||
uint uv_off_w; // dst UV plane offset, in words
|
||||
uint uv_pitch_w; // dst UV row pitch, in words
|
||||
} pc;
|
||||
|
||||
// Edge-clamped fetch: alignment padding (x >= width from the 4-wide block, odd-height chroma)
|
||||
// duplicates the last real texel instead of reading out of bounds.
|
||||
vec3 texel(uint x, uint y) {
|
||||
x = min(x, pc.width - 1u);
|
||||
y = min(y, pc.height - 1u);
|
||||
uint t = spx[pc.src_off_w + y * pc.src_pitch_w + x];
|
||||
// B,G,R,X byte order: r = bits 16..23, g = 8..15, b = 0..7.
|
||||
return vec3(float((t >> 16) & 0xFFu), float((t >> 8) & 0xFFu), float(t & 0xFFu)) / 255.0;
|
||||
}
|
||||
|
||||
// BT.709 limited range — numerically identical to rgb2yuv.comp's lumaY/U/V, scaled to bytes.
|
||||
uint lumaB(vec3 c) {
|
||||
return uint(clamp(16.0 + 255.0 * (0.1826 * c.r + 0.6142 * c.g + 0.0620 * c.b) + 0.5, 0.0, 255.0));
|
||||
}
|
||||
|
||||
void main() {
|
||||
uint bx = gl_GlobalInvocationID.x; // 4-px column block
|
||||
uint by = gl_GlobalInvocationID.y; // 2-row block
|
||||
uint x0 = bx * 4u;
|
||||
uint y0 = by * 2u;
|
||||
if (x0 >= pc.width || y0 >= pc.height) {
|
||||
return;
|
||||
}
|
||||
// Two Y words (4 bytes each, rows y0 and y0+1).
|
||||
for (uint row = 0u; row < 2u; row++) {
|
||||
uint y = y0 + row;
|
||||
uint w = lumaB(texel(x0, y))
|
||||
| (lumaB(texel(x0 + 1u, y)) << 8)
|
||||
| (lumaB(texel(x0 + 2u, y)) << 16)
|
||||
| (lumaB(texel(x0 + 3u, y)) << 24);
|
||||
dw[y * pc.y_pitch_w + bx] = w;
|
||||
}
|
||||
// One UV word: the block's two chroma samples (each a 2x2 average).
|
||||
uint uvw = 0u;
|
||||
for (uint s = 0u; s < 2u; s++) {
|
||||
uint cx = x0 + s * 2u;
|
||||
vec3 a = (texel(cx, y0) + texel(cx + 1u, y0) + texel(cx, y0 + 1u) + texel(cx + 1u, y0 + 1u))
|
||||
* 0.25;
|
||||
uint U = uint(clamp(128.0 + 255.0 * (-0.1006 * a.r - 0.3386 * a.g + 0.4392 * a.b) + 0.5, 0.0, 255.0));
|
||||
uint V = uint(clamp(128.0 + 255.0 * (0.4392 * a.r - 0.3989 * a.g - 0.0403 * a.b) + 0.5, 0.0, 255.0));
|
||||
uvw |= (U | (V << 8)) << (16u * s);
|
||||
}
|
||||
dw[pc.uv_off_w + by * pc.uv_pitch_w + bx] = uvw;
|
||||
}
|
||||
Binary file not shown.
@@ -40,20 +40,6 @@ struct DstBuf {
|
||||
cuda: cuda::ExternalDmabuf,
|
||||
}
|
||||
|
||||
/// The lazy compute-CSC pipeline (`rgb2nv12_buf.comp`) for [`VkBridge::import_linear_nv12`].
|
||||
struct Csc {
|
||||
module: vk::ShaderModule,
|
||||
dset_layout: vk::DescriptorSetLayout,
|
||||
playout: vk::PipelineLayout,
|
||||
pipeline: vk::Pipeline,
|
||||
dpool: vk::DescriptorPool,
|
||||
dset: vk::DescriptorSet,
|
||||
}
|
||||
|
||||
/// The buffer-to-buffer RGB→NV12 compute shader (see `rgb2nv12_buf.comp` beside this file;
|
||||
/// rebuild with `glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv`).
|
||||
const CSC_SPV: &[u8] = include_bytes!("rgb2nv12_buf.spv");
|
||||
|
||||
pub struct VkBridge {
|
||||
_entry: ash::Entry,
|
||||
instance: ash::Instance,
|
||||
@@ -66,9 +52,6 @@ pub struct VkBridge {
|
||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||
src_cache: HashMap<i32, SrcBuf>,
|
||||
dst: Option<DstBuf>,
|
||||
/// Built on the first [`import_linear_nv12`](Self::import_linear_nv12); RGB-only bridges
|
||||
/// never pay for it.
|
||||
csc: Option<Csc>,
|
||||
}
|
||||
|
||||
// SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
|
||||
@@ -111,15 +94,18 @@ impl VkBridge {
|
||||
.ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?;
|
||||
let mem_props = instance.get_physical_device_memory_properties(phys);
|
||||
|
||||
// A COMPUTE-capable family (compute implies transfer): the copy path only needs
|
||||
// transfer, but the NV12 CSC dispatch (T2.5b) needs compute — on every NVIDIA
|
||||
// device family 0 is graphics+compute+transfer, so this picks the same family the
|
||||
// old transfer-only predicate did.
|
||||
// Any queue family supporting transfer (graphics/compute imply it).
|
||||
let qf = instance
|
||||
.get_physical_device_queue_family_properties(phys)
|
||||
.iter()
|
||||
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
|
||||
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
||||
.position(|q| {
|
||||
q.queue_flags.intersects(
|
||||
vk::QueueFlags::TRANSFER
|
||||
| vk::QueueFlags::GRAPHICS
|
||||
| vk::QueueFlags::COMPUTE,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("no transfer-capable queue family"))?
|
||||
as u32;
|
||||
|
||||
let exts = [
|
||||
@@ -175,7 +161,6 @@ impl VkBridge {
|
||||
mem_props,
|
||||
src_cache: HashMap::new(),
|
||||
dst: None,
|
||||
csc: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -204,11 +189,7 @@ impl VkBridge {
|
||||
.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(size)
|
||||
// STORAGE so the NV12 compute CSC can read it as an SSBO (T2.5b); harmless
|
||||
// for the plain copy path.
|
||||
.usage(
|
||||
vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||
)
|
||||
.usage(vk::BufferUsageFlags::TRANSFER_SRC)
|
||||
.push_next(&mut ext_info),
|
||||
None,
|
||||
)
|
||||
@@ -275,10 +256,7 @@ impl VkBridge {
|
||||
.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(size)
|
||||
// STORAGE so the NV12 compute CSC can write it as an SSBO (T2.5b).
|
||||
.usage(
|
||||
vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||
)
|
||||
.usage(vk::BufferUsageFlags::TRANSFER_DST)
|
||||
.push_next(&mut ext_info),
|
||||
None,
|
||||
)
|
||||
@@ -324,246 +302,6 @@ impl VkBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the RGB→NV12 compute pipeline once (T2.5b): two-SSBO descriptor set + a 28-byte
|
||||
/// push-constant block matching `rgb2nv12_buf.comp`'s `Push`.
|
||||
unsafe fn ensure_csc(&mut self) -> Result<()> {
|
||||
if self.csc.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let words: Vec<u32> = CSC_SPV
|
||||
.chunks_exact(4)
|
||||
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
|
||||
.collect();
|
||||
let module = self
|
||||
.device
|
||||
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
|
||||
.context("create CSC shader module")?;
|
||||
let bindings = [
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
];
|
||||
let dset_layout = self
|
||||
.device
|
||||
.create_descriptor_set_layout(
|
||||
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
||||
None,
|
||||
)
|
||||
.context("create CSC dset layout")?;
|
||||
let pc = [vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE)
|
||||
.size(28)];
|
||||
let layouts = [dset_layout];
|
||||
let playout = self
|
||||
.device
|
||||
.create_pipeline_layout(
|
||||
&vk::PipelineLayoutCreateInfo::default()
|
||||
.set_layouts(&layouts)
|
||||
.push_constant_ranges(&pc),
|
||||
None,
|
||||
)
|
||||
.context("create CSC pipeline layout")?;
|
||||
let entry = c"main";
|
||||
let stage = vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::COMPUTE)
|
||||
.module(module)
|
||||
.name(entry);
|
||||
let pipeline = self
|
||||
.device
|
||||
.create_compute_pipelines(
|
||||
vk::PipelineCache::null(),
|
||||
&[vk::ComputePipelineCreateInfo::default()
|
||||
.stage(stage)
|
||||
.layout(playout)],
|
||||
None,
|
||||
)
|
||||
.map_err(|(_, e)| anyhow!("create CSC pipeline: {e}"))?[0];
|
||||
let sizes = [vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(2)];
|
||||
let dpool = self
|
||||
.device
|
||||
.create_descriptor_pool(
|
||||
&vk::DescriptorPoolCreateInfo::default()
|
||||
.max_sets(1)
|
||||
.pool_sizes(&sizes),
|
||||
None,
|
||||
)
|
||||
.context("create CSC descriptor pool")?;
|
||||
let dset = self
|
||||
.device
|
||||
.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(dpool)
|
||||
.set_layouts(&layouts),
|
||||
)
|
||||
.context("allocate CSC descriptor set")?[0];
|
||||
self.csc = Some(Csc {
|
||||
module,
|
||||
dset_layout,
|
||||
playout,
|
||||
pipeline,
|
||||
dpool,
|
||||
dset,
|
||||
});
|
||||
tracing::info!("Vulkan-bridge NV12 compute CSC ready (LINEAR path feeds NVENC native YUV)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bridge one LINEAR dmabuf frame into a pooled NV12 CUDA buffer (latency plan T2.5b):
|
||||
/// instead of the plain byte copy, the compute CSC reads the imported RGB texels and writes
|
||||
/// both NV12 planes into the exportable buffer, so NVENC on the gamescope path encodes
|
||||
/// native YUV (its internal RGB→YUV CSC on the contended SM disappears). `pool` must be an
|
||||
/// NV12 pool ([`cuda::BufferPool::new_nv12`]).
|
||||
pub fn import_linear_nv12(
|
||||
&mut self,
|
||||
fd: i32,
|
||||
offset: u32,
|
||||
stride: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pool: &cuda::BufferPool,
|
||||
) -> Result<DeviceBuffer> {
|
||||
anyhow::ensure!(
|
||||
offset % 4 == 0 && stride % 4 == 0,
|
||||
"LINEAR dmabuf offset/stride not word-aligned ({offset}/{stride})"
|
||||
);
|
||||
// Exportable-buffer NV12 layout the shader writes: 4-aligned Y pitch, UV plane (⌈h/2⌉
|
||||
// rows at the same pitch) directly after the Y plane.
|
||||
let y_pitch = (width as u64 + 3) & !3;
|
||||
let uv_off = y_pitch * height as u64;
|
||||
let dst_size = uv_off + y_pitch * height.div_ceil(2) as u64;
|
||||
// SAFETY: same structure and proofs as `import_linear` — `fd` is the caller's live dmabuf
|
||||
// (dup'd by `import_src`), sizes are checked (`import_src` asserts the fd covers
|
||||
// `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at least
|
||||
// the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
|
||||
// update binds the live cached src buffer and the live dst buffer WHOLE_SIZE; every
|
||||
// `*Info`/array is a local outliving its synchronous call; `cmd`/`queue`/`fence` are this
|
||||
// bridge's own single-thread handles. The dispatch covers ⌈w/32⌉×⌈h/16⌉ groups of 8×8
|
||||
// invocations, each writing only whole words inside the proven dst range (shader
|
||||
// contract). The host `wait_for_fences` retires the compute pass (with a shader-write →
|
||||
// memory barrier recorded before end) BEFORE CUDA reads the shared memory.
|
||||
unsafe {
|
||||
let span = offset as u64 + stride as u64 * height as u64;
|
||||
if !self.src_cache.contains_key(&fd) {
|
||||
let size = libc::lseek(fd, 0, libc::SEEK_END);
|
||||
anyhow::ensure!(size > 0, "lseek(dmabuf)");
|
||||
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
|
||||
self.import_src(fd, size as u64)?;
|
||||
}
|
||||
let src_buffer = self.src_cache[&fd].buffer;
|
||||
self.ensure_dst(dst_size)?;
|
||||
self.ensure_csc()?;
|
||||
let (dst_buffer, dst_cuda_ptr) = {
|
||||
let d = self.dst.as_ref().unwrap();
|
||||
(d.buffer, d.cuda.ptr)
|
||||
};
|
||||
let csc = self.csc.as_ref().unwrap();
|
||||
|
||||
let src_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(src_buffer)
|
||||
.range(vk::WHOLE_SIZE)];
|
||||
let dst_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(dst_buffer)
|
||||
.range(vk::WHOLE_SIZE)];
|
||||
let writes = [
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(csc.dset)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&src_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(csc.dset)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&dst_info),
|
||||
];
|
||||
self.device.update_descriptor_sets(&writes, &[]);
|
||||
|
||||
self.device
|
||||
.begin_command_buffer(
|
||||
self.cmd,
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)
|
||||
.context("begin cmd")?;
|
||||
self.device
|
||||
.cmd_bind_pipeline(self.cmd, vk::PipelineBindPoint::COMPUTE, csc.pipeline);
|
||||
self.device.cmd_bind_descriptor_sets(
|
||||
self.cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
csc.playout,
|
||||
0,
|
||||
&[csc.dset],
|
||||
&[],
|
||||
);
|
||||
let push: [u32; 7] = [
|
||||
width,
|
||||
height,
|
||||
offset / 4,
|
||||
stride / 4,
|
||||
(y_pitch / 4) as u32,
|
||||
(uv_off / 4) as u32,
|
||||
(y_pitch / 4) as u32,
|
||||
];
|
||||
let push_bytes: &[u8] = std::slice::from_raw_parts(push.as_ptr().cast(), 28);
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd,
|
||||
csc.playout,
|
||||
vk::ShaderStageFlags::COMPUTE,
|
||||
0,
|
||||
push_bytes,
|
||||
);
|
||||
self.device
|
||||
.cmd_dispatch(self.cmd, width.div_ceil(32), height.div_ceil(16), 1);
|
||||
// Make the shader writes available before the external (CUDA) read.
|
||||
let barrier = vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::MEMORY_READ);
|
||||
self.device.cmd_pipeline_barrier(
|
||||
self.cmd,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[barrier],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
self.device
|
||||
.end_command_buffer(self.cmd)
|
||||
.context("end cmd")?;
|
||||
let cmds = [self.cmd];
|
||||
let submit = vk::SubmitInfo::default().command_buffers(&cmds);
|
||||
self.device
|
||||
.queue_submit(self.queue, &[submit], self.fence)
|
||||
.context("queue submit")?;
|
||||
self.device
|
||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||
.context("fence wait")?;
|
||||
self.device
|
||||
.reset_fences(&[self.fence])
|
||||
.context("reset fence")?;
|
||||
|
||||
// De-stride both NV12 planes from the CUDA view into a pooled two-plane buffer.
|
||||
cuda::make_current()?;
|
||||
let out = pool.get()?;
|
||||
cuda::copy_pitched_nv12_to_buffer(
|
||||
dst_cuda_ptr,
|
||||
dst_cuda_ptr + uv_off,
|
||||
y_pitch as usize,
|
||||
&out,
|
||||
)?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle /
|
||||
/// renegotiation — or the caller is about to store a different dmabuf under the same slot).
|
||||
/// Without this the cache could serve a stale imported buffer for a reused fd number, or
|
||||
@@ -676,14 +414,6 @@ impl Drop for VkBridge {
|
||||
self.device.destroy_buffer(d.buffer, None);
|
||||
self.device.free_memory(d.memory, None);
|
||||
}
|
||||
if let Some(c) = self.csc.take() {
|
||||
self.device.destroy_pipeline(c.pipeline, None);
|
||||
self.device.destroy_pipeline_layout(c.playout, None);
|
||||
self.device.destroy_descriptor_pool(c.dpool, None); // frees `c.dset` with it
|
||||
self.device
|
||||
.destroy_descriptor_set_layout(c.dset_layout, None);
|
||||
self.device.destroy_shader_module(c.module, None);
|
||||
}
|
||||
self.device.destroy_fence(self.fence, None);
|
||||
self.device.destroy_command_pool(self.cmd_pool, None);
|
||||
self.device.destroy_device(None);
|
||||
|
||||
@@ -299,9 +299,6 @@ impl EglBackend {
|
||||
req.modifier,
|
||||
)?,
|
||||
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
|
||||
ImportKind::LinearNv12 => self
|
||||
.importer
|
||||
.import_linear_nv12(&plane, req.width, req.height)?,
|
||||
};
|
||||
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
|
||||
cuda::make_current()?;
|
||||
|
||||
@@ -45,20 +45,12 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
|
||||
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
|
||||
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
||||
/// are droppable for the same reason a stale ICMP is.
|
||||
/// - Windows `WSAENOBUFS` (10055): the exact analogue of unix `ENOBUFS` — a high-bitrate keyframe
|
||||
/// burst (one `WSASendMsg` USO super-buffer is up to ~512 segments ≈ 700 KB) momentarily exhausts
|
||||
/// the socket send buffer / AFD non-paged pool, and Winsock reports `WSAENOBUFS`, which Rust maps
|
||||
/// to `ErrorKind::Uncategorized` (so the `WouldBlock` arm misses it, exactly like unix `ENOBUFS`).
|
||||
/// Without treating it as transient a Windows host tears the whole session down under load
|
||||
/// (observed live: `native::stream` "send failed — stopping stream" on a paced video burst). Same
|
||||
/// lossy-drop contract; FEC + the next frame recover. The `WSAENET*`/`WSAEHOST*` family is the
|
||||
/// Windows counterpart of the droppable unix network-path blips above.
|
||||
fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
||||
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
||||
return true;
|
||||
}
|
||||
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno.
|
||||
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
matches!(
|
||||
@@ -70,20 +62,7 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
| Some(libc::EHOSTDOWN)
|
||||
)
|
||||
}
|
||||
// Windows Winsock codes (WSAE*), raw like the sibling `uso_unsupported`. WSAEWOULDBLOCK (10035)
|
||||
// already maps to `ErrorKind::WouldBlock` above, so it isn't repeated here.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
matches!(
|
||||
e.raw_os_error(),
|
||||
Some(10055) // WSAENOBUFS — tx queue / send buffer full (the dominant high-bitrate drop)
|
||||
| Some(10051) // WSAENETUNREACH
|
||||
| Some(10065) // WSAEHOSTUNREACH
|
||||
| Some(10050) // WSAENETDOWN
|
||||
| Some(10064) // WSAEHOSTDOWN
|
||||
)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
@@ -345,53 +324,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw-errno tx-queue-full / network-blip codes have no stable `ErrorKind` (they surface as
|
||||
/// `Uncategorized`), so they only get caught by the platform `raw_os_error()` arms. A burst that
|
||||
/// momentarily exhausts the send buffer must stay a lossy drop, never a teardown — this is the
|
||||
/// regression guard for the Windows `WSAENOBUFS` (10055) session crash and the unix `ENOBUFS`
|
||||
/// wlan-driver case. Gated per platform because a code is only classified on its own OS.
|
||||
#[test]
|
||||
fn transient_io_covers_raw_tx_queue_and_path_codes() {
|
||||
use std::io::Error;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
for code in [
|
||||
libc::ENOBUFS,
|
||||
libc::ENETUNREACH,
|
||||
libc::EHOSTUNREACH,
|
||||
libc::ENETDOWN,
|
||||
libc::EHOSTDOWN,
|
||||
] {
|
||||
assert!(
|
||||
is_transient_io(&Error::from_raw_os_error(code)),
|
||||
"unix errno {code} should be transient"
|
||||
);
|
||||
}
|
||||
// A genuine failure with no stable ErrorKind must still tear down.
|
||||
assert!(
|
||||
!is_transient_io(&Error::from_raw_os_error(libc::EACCES)),
|
||||
"EACCES must stay fatal"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// WSAENOBUFS / WSAENETUNREACH / WSAEHOSTUNREACH / WSAENETDOWN / WSAEHOSTDOWN.
|
||||
for code in [10055, 10051, 10065, 10050, 10064] {
|
||||
assert!(
|
||||
is_transient_io(&Error::from_raw_os_error(code)),
|
||||
"WSA code {code} should be transient"
|
||||
);
|
||||
}
|
||||
// WSAEACCES (10013) — a real failure that must stay fatal.
|
||||
assert!(
|
||||
!is_transient_io(&Error::from_raw_os_error(10013)),
|
||||
"WSAEACCES must stay fatal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
|
||||
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
|
||||
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
|
||||
|
||||
@@ -54,9 +54,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# back to the real module path, so the console's target column and the ring's noise gate see
|
||||
# `mdns_sd::…` instead of "log".
|
||||
tracing-log = "0.2"
|
||||
# `log::LevelFilter` for the bridge's max-level in `log_capture::install_global` (which inits the
|
||||
# tracing-log bridge with `ignore_crate("wasapi")`). Already in the tree transitively.
|
||||
log = "0.4"
|
||||
axum = "0.8"
|
||||
mdns-sd = "0.20"
|
||||
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
|
||||
@@ -80,20 +77,17 @@ base64 = "0.22"
|
||||
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||
ureq = "2"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
|
||||
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
|
||||
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
|
||||
axum-server = "0.8"
|
||||
# Ring backend, NOT the (default) aws-lc-rs one — matches punktfunk-core + the client so the whole
|
||||
# tree stays ring-only (no aws-lc-sys: a heavy C dep that fails to build on the Windows CI runner).
|
||||
# Keep `tls12` for GameStream/Moonlight clients that negotiate TLS 1.2.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls = "0.23"
|
||||
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
||||
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
||||
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
tokio-rustls = "0.26"
|
||||
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -120,6 +114,8 @@ tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
|
||||
tempfile = "3"
|
||||
# Emit `log`-crate records through the tracing-log bridge in the log_capture tests.
|
||||
log = "0.4"
|
||||
|
||||
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
|
||||
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
|
||||
|
||||
@@ -12,10 +12,7 @@ use anyhow::Result;
|
||||
// `crate::capture::*` (the capture mechanics that used the rest moved into pf-capture).
|
||||
pub use pf_frame::{CapturedFrame, OutputFormat, PixelFormat};
|
||||
// The capturer types + trait + synthetics live in `pf-capture`; re-export them at the old paths.
|
||||
pub use pf_capture::{
|
||||
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
|
||||
SyntheticCapturer,
|
||||
};
|
||||
pub use pf_capture::{capturer_supports_444, Capturer, FastSyntheticCapturer, SyntheticCapturer};
|
||||
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (main.rs subcommands) and
|
||||
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -48,20 +45,18 @@ fn zero_copy_policy() -> pf_capture::ZeroCopyPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
|
||||
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
|
||||
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
|
||||
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
||||
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
||||
// so use a plain ScreenCast session there.
|
||||
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy())
|
||||
pf_capture::open_portal_monitor(anchored, zero_copy_policy())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn open_portal_monitor(_want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
||||
}
|
||||
|
||||
@@ -74,13 +69,11 @@ pub fn capture_virtual_output(
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The Linux NATIVE plane stays 8-bit (Mutter's virtual-monitor streams are SDR-only upstream;
|
||||
// the GNOME 50+ HDR path is monitor-mirror only — `open_portal_monitor`) and the portal
|
||||
// negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the capture
|
||||
// backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch) and
|
||||
// `want.chroma_444` selects the worker's planar-YUV444 GPU convert. `gpu = false` (4:4:4
|
||||
// without zero-copy) forces the CPU mmap path so the encoder gets CPU-resident RGB to swscale
|
||||
// into YUV444P.
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own pixel
|
||||
// format, so `want.gpu` gates GPU zero-copy capture (the capture backend is always the portal —
|
||||
// the `CaptureBackend` arg is a Windows-only dispatch) and `want.chroma_444` selects the
|
||||
// worker's planar-YUV444 GPU convert. `gpu = false` (4:4:4 without zero-copy) forces the CPU
|
||||
// mmap path so the encoder gets CPU-resident RGB to swscale into YUV444P.
|
||||
pf_capture::open_virtual_output(
|
||||
vout.remote_fd,
|
||||
vout.node_id,
|
||||
@@ -138,16 +131,8 @@ pub fn capture_virtual_output(
|
||||
// proactively enables advanced color and selects the per-frame conversion. There is NO fallback:
|
||||
// if it can't open or the driver doesn't attach, the session fails cleanly and the client
|
||||
// reconnects.
|
||||
pf_capture::open_idd_push(
|
||||
target,
|
||||
pref,
|
||||
want.hdr,
|
||||
want.chroma_444,
|
||||
want.pyrowave,
|
||||
keep,
|
||||
sender,
|
||||
)
|
||||
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
||||
pf_capture::open_idd_push(target, pref, want.hdr, want.chroma_444, keep, sender)
|
||||
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -164,12 +164,6 @@ pub enum EventKind {
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
#[serde(rename = "plugins.changed")]
|
||||
PluginsChanged {
|
||||
/// The plugin whose registration changed (registered, restarted, deregistered, or
|
||||
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
|
||||
id: String,
|
||||
},
|
||||
#[serde(rename = "host.started")]
|
||||
HostStarted {
|
||||
version: String,
|
||||
@@ -196,7 +190,6 @@ impl EventKind {
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
EventKind::HostStopping => "host.stopping",
|
||||
}
|
||||
@@ -502,19 +495,6 @@ mod tests {
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
||||
);
|
||||
|
||||
let ev = HostEvent {
|
||||
seq: 3,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::PluginsChanged {
|
||||
id: "rom-manager".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use pf_paths::config_dir;
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||
use rsa::pkcs8::DecodePrivateKey;
|
||||
use rsa::RsaPrivateKey;
|
||||
use sha2::Sha256;
|
||||
use std::fs;
|
||||
@@ -70,20 +70,7 @@ impl ServerIdentity {
|
||||
}
|
||||
|
||||
fn generate() -> Result<(String, String)> {
|
||||
// The workspace is ring-only (aws-lc-sys breaks Windows CI — see the rustls/rcgen pins), and
|
||||
// `ring` can *sign* with an existing RSA key but cannot *generate* one: rcgen's ring backend
|
||||
// returns `KeyGenerationUnavailable` for `generate_for(&PKCS_RSA_SHA256)`. Moonlight requires an
|
||||
// RSA-2048 identity, so generate the key with the pure-Rust `rsa` crate (already a dep for the
|
||||
// pairing signer) and hand the PKCS#8 PEM to rcgen, whose ring backend *can* load + self-sign
|
||||
// with it. Returning that same PEM keeps it byte-identical to what `from_pems` re-parses.
|
||||
let mut rng = rand::thread_rng();
|
||||
let priv_key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 host key")?;
|
||||
let key_pem = priv_key
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.context("encode host key as PKCS#8 PEM")?
|
||||
.to_string();
|
||||
let key = rcgen::KeyPair::from_pkcs8_pem_and_sign_algo(&key_pem, &rcgen::PKCS_RSA_SHA256)
|
||||
.context("load RSA host key into rcgen")?;
|
||||
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?;
|
||||
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
|
||||
params
|
||||
.distinguished_name
|
||||
@@ -91,7 +78,7 @@ fn generate() -> Result<(String, String)> {
|
||||
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
||||
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
||||
let cert = params.self_signed(&key).context("self-sign cert")?;
|
||||
Ok((cert.pem(), key_pem))
|
||||
Ok((cert.pem(), key.serialize_pem()))
|
||||
}
|
||||
|
||||
/// Extract the X.509 `signatureValue` bytes from a cert PEM.
|
||||
|
||||
@@ -50,45 +50,21 @@ pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
|
||||
/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
|
||||
/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
|
||||
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
||||
/// host (a host without the `PUNKTFUNK_10BIT` opt-in, or a Linux host whose video source / encoder
|
||||
/// can't do Main10) must not invite a client into an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
||||
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
||||
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
||||
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely on GameStream: stock Moonlight is 4:2:0 —
|
||||
/// full-chroma is a punktfunk/1-native negotiation only (`crate::capture::capturer_supports_444`).
|
||||
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
||||
|
||||
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
|
||||
/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and (together
|
||||
/// with the live capture-side check at RTSP time) for honoring a client's `dynamicRangeMode`
|
||||
/// request. Behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate the native
|
||||
/// punktfunk/1 plane honors — on both OSes.
|
||||
///
|
||||
/// **Windows**: the IDD-push capturer streams HEVC Main10 PQ whenever the desktop is HDR, and a
|
||||
/// client HDR request proactively enables advanced color on the per-session virtual display so PQ
|
||||
/// flows even from an SDR desktop.
|
||||
///
|
||||
/// **Linux**: the GNOME 50+ portal **monitor mirror** (`video_source=portal`) can negotiate the
|
||||
/// 10-bit PQ formats while the mirrored monitor is in HDR mode, and the NVENC/VAAPI encoders have
|
||||
/// a probed Main10 path ([`crate::encode::can_encode_10bit`]). The virtual-output source stays SDR
|
||||
/// (Mutter's RecordVirtual streams are 8-bit-only upstream), so this is `false` for it. Whether
|
||||
/// the monitor is ACTUALLY in HDR mode right now is checked live at RTSP honor time
|
||||
/// ([`pf_capture::gnome_hdr_monitor_active`]) — this fn is the static serverinfo capability.
|
||||
/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and for honoring a
|
||||
/// client's `dynamicRangeMode` request. HDR capture+encode is **Windows-only** (the Linux host is
|
||||
/// 8-bit, blocked upstream) and behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate
|
||||
/// the native punktfunk/1 plane honors. When this is true the IDD-push capturer streams HEVC Main10 PQ
|
||||
/// whenever the desktop is HDR, and a client HDR request makes the GameStream video path proactively
|
||||
/// enable advanced color on the per-session virtual display so PQ flows even from an SDR desktop.
|
||||
pub fn host_hdr_capable() -> bool {
|
||||
if !pf_host_config::config().ten_bit {
|
||||
return false;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
pf_host_config::config().video_source.as_deref() == Some("portal")
|
||||
&& crate::encode::can_encode_10bit(crate::encode::Codec::H265)
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
cfg!(target_os = "windows") && pf_host_config::config().ten_bit
|
||||
}
|
||||
|
||||
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
|
||||
@@ -165,11 +141,8 @@ pub struct AppState {
|
||||
pub rfi_range: std::sync::Arc<std::sync::Mutex<Option<(i64, i64)>>>,
|
||||
/// Persistent screen capturer, reused across streams so reconnects don't spawn a second
|
||||
/// (conflicting) screencast session. The video thread borrows it for the stream's duration
|
||||
/// and returns it; `set_active` gates its cost while idle. The slot's `bool` records whether
|
||||
/// it was opened with the HDR (10-bit PQ) offer — a stream whose negotiated `hdr` differs
|
||||
/// drops the pooled capturer and opens a fresh screencast session at the right depth
|
||||
/// (mirroring the audio capturer's channel-count reuse gate).
|
||||
pub video_cap: stream::CapturerSlot,
|
||||
/// and returns it; `set_active` gates its cost while idle.
|
||||
pub video_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::capture::Capturer>>>>,
|
||||
/// Persistent audio capturer, reused across streams when the channel count still matches
|
||||
/// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is
|
||||
/// sent, dropped + reopened when a session negotiates a different channel count.
|
||||
@@ -266,7 +239,7 @@ pub fn serve(
|
||||
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
||||
rt.block_on(async move {
|
||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let native_opts = crate::native::native_serve_opts(&native);
|
||||
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
|
||||
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user