Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f9a06d51f |
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Install build + runtime-dev deps
|
- name: Install build + runtime-dev deps
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm --needed \
|
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 \
|
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||||
mesa libglvnd unzip libarchive
|
mesa libglvnd unzip libarchive
|
||||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||||
|
|||||||
@@ -73,34 +73,8 @@ jobs:
|
|||||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||||
# there, right before the first `flatpak` network call.
|
# there, right before the first `flatpak` network call.
|
||||||
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
|
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||||
run: |
|
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
|
||||||
# Resolve over TCP instead of UDP. The documented root cause of the flathub
|
|
||||||
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
|
|
||||||
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
|
|
||||||
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
|
|
||||||
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
|
|
||||||
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
|
|
||||||
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
|
|
||||||
# each needing a manual re-run.
|
|
||||||
#
|
|
||||||
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
|
|
||||||
# silently lost under load. Same resolver, same search path — only the transport
|
|
||||||
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
|
|
||||||
# NO extra nameservers, which would risk answering an internal name from a public
|
|
||||||
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
|
|
||||||
# retry.sh stays as the backstop for genuine upstream blips.
|
|
||||||
#
|
|
||||||
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
|
|
||||||
# DNS tuning that cannot be applied must not be what fails the release build — that
|
|
||||||
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
|
|
||||||
# today's behaviour, which retry.sh already covers.
|
|
||||||
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
|
|
||||||
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|
|
||||||
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
|
|
||||||
fi
|
|
||||||
cat /etc/resolv.conf || true
|
|
||||||
|
|
||||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
|
|
||||||
# (https://git.unom.io/api/packages/unom/npm/).
|
|
||||||
#
|
|
||||||
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
|
|
||||||
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
|
|
||||||
#
|
|
||||||
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
|
|
||||||
# built BEFORE the kit's `bun install` copies it.
|
|
||||||
#
|
|
||||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
|
|
||||||
name: plugin-kit-publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['plugin-kit-v*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
container:
|
|
||||||
image: oven/bun:1
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
|
||||||
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
|
||||||
- name: Install git + node + CA certs
|
|
||||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build the SDK (file:../sdk dependency source)
|
|
||||||
working-directory: sdk
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile --ignore-scripts
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun run typecheck
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun test
|
|
||||||
|
|
||||||
- name: Build (dist/ JS + .d.ts + theme.css)
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Tag matches package version
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: |
|
|
||||||
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
|
|
||||||
PKG="$(node -p "require('./package.json').version")"
|
|
||||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
|
||||||
working-directory: plugin-kit
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
|
||||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
|
||||||
bun publish
|
|
||||||
@@ -149,26 +149,6 @@ jobs:
|
|||||||
# inherits this from the env during the xcframework build).
|
# inherits this from the env during the xcframework build).
|
||||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Pin + prune Xcode DerivedData
|
|
||||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
|
||||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
|
||||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
|
||||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
|
||||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
|
||||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
|
||||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
|
||||||
run: |
|
|
||||||
DD="$HOME/ci/derived-data/release"
|
|
||||||
mkdir -p "$DD"
|
|
||||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
|
||||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
|
||||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
|
||||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
|
||||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
|
||||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
@@ -196,7 +176,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
@@ -294,7 +273,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
@@ -358,7 +336,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
@@ -417,7 +394,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
|
|||||||
@@ -104,9 +104,8 @@ jobs:
|
|||||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||||
|
|
||||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
|
||||||
- name: Build (release)
|
- name: Build (release)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||||
|
|||||||
Generated
+27
-27
@@ -2159,7 +2159,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2264,7 +2264,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2832,7 +2832,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.16.0"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2850,7 +2850,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2894,7 +2894,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2903,7 +2903,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2915,7 +2915,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2929,11 +2929,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.16.0"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2961,14 +2961,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2983,7 +2983,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.16.0"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3013,7 +3013,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3025,7 +3025,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3221,7 +3221,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3237,7 +3237,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3268,7 +3268,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3287,7 +3287,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3318,7 +3318,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3400,7 +3400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3414,7 +3414,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.16.0"
|
version = "0.13.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-150
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.16.0"
|
"version": "0.13.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -1348,117 +1348,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/library/scanners": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"library"
|
|
||||||
],
|
|
||||||
"summary": "List the library scanners",
|
|
||||||
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
|
|
||||||
"operationId": "listLibraryScanners",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "This host's scanners with their enable state",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/ScannerInfo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Missing or invalid bearer token",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/library/scanners/{id}": {
|
|
||||||
"put": {
|
|
||||||
"tags": [
|
|
||||||
"library"
|
|
||||||
],
|
|
||||||
"summary": "Enable or disable a library scanner",
|
|
||||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
|
|
||||||
"operationId": "setLibraryScanner",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"in": "path",
|
|
||||||
"description": "The scanner id (e.g. `steam`)",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ScannerToggle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Toggle stored; the full scanner list",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/ScannerInfo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Missing or invalid bearer token",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "No such scanner on this platform",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Could not persist the settings",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/local/summary": {
|
"/api/v1/local/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4782,44 +4671,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ScannerInfo": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"label",
|
|
||||||
"enabled"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether this host runs the scanner (default true)."
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
|
||||||
"example": "steam"
|
|
||||||
},
|
|
||||||
"label": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Human-facing name for the console toggle.",
|
|
||||||
"example": "Steam"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ScannerToggle": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Request body for `setLibraryScanner`.",
|
|
||||||
"required": [
|
|
||||||
"enabled"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether the scanner should run on this host."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"SessionInfo": {
|
"SessionInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Client-requested launch parameters (key material is never exposed here).",
|
"description": "Client-requested launch parameters (key material is never exposed here).",
|
||||||
|
|||||||
@@ -404,14 +404,7 @@ fn feeder_loop(
|
|||||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||||
if stats.enabled() || measure_decode {
|
if stats.enabled() || measure_decode {
|
||||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
let received_ns = now_realtime_ns();
|
||||||
// here would fold the hand-off queue wait into the network latency figure
|
|
||||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns as i128
|
|
||||||
} else {
|
|
||||||
now_realtime_ns()
|
|
||||||
};
|
|
||||||
{
|
{
|
||||||
let mut g = in_flight
|
let mut g = in_flight
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
|
|||||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||||
if stats.enabled() || measure_decode {
|
if stats.enabled() || measure_decode {
|
||||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
let received_ns = now_realtime_ns();
|
||||||
// async_loop: a pull stamp folds hand-off queue wait into "network".
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns as i128
|
|
||||||
} else {
|
|
||||||
now_realtime_ns()
|
|
||||||
};
|
|
||||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
if in_flight.len() > IN_FLIGHT_CAP {
|
if in_flight.len() > IN_FLIGHT_CAP {
|
||||||
in_flight.pop_front(); // stale — codec never echoed it back
|
in_flight.pop_front(); // stale — codec never echoed it back
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||||
|
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -504,7 +504,6 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -541,7 +540,6 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -721,7 +719,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -766,7 +764,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -863,15 +861,15 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PunktfunkKit;
|
productName = PunktfunkKit;
|
||||||
};
|
};
|
||||||
|
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PunktfunkShared;
|
||||||
|
};
|
||||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||||
productName = SwiftUINavigationTransitions;
|
productName = SwiftUINavigationTransitions;
|
||||||
};
|
};
|
||||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = PunktfunkShared;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = AA000000000000000000000D /* Project object */;
|
rootObject = AA000000000000000000000D /* Project object */;
|
||||||
|
|||||||
@@ -55,11 +55,6 @@
|
|||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
|
||||||
key = "MTL_HUD_ENABLED"
|
|
||||||
value = "1"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(host.displayName)
|
Text(host.displayName)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Punktfunk")
|
Text("Punktfunk")
|
||||||
.font(.caption).bold()
|
.font(.caption).bold()
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
ForEach(hosts.prefix(4)) { host in
|
ForEach(hosts.prefix(4)) { host in
|
||||||
Link(destination: connectURL(host)) {
|
Link(destination: connectURL(host)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
Text(host.displayName)
|
Text(host.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -184,47 +184,3 @@ private struct EmptyHostView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews (Xcode canvas)
|
|
||||||
//
|
|
||||||
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩). The widget
|
|
||||||
// `#Preview(as:widget:timeline:)` form feeds sample entries directly — the App-Group store is
|
|
||||||
// never read, so the canvas works without a paired device or saved hosts. The small preview's
|
|
||||||
// second entry shows the empty state one timeline click away.
|
|
||||||
|
|
||||||
private let previewHosts: [StoredHost] = [
|
|
||||||
StoredHost(
|
|
||||||
name: "Studio", address: "192.168.1.20",
|
|
||||||
lastConnected: .now.addingTimeInterval(-40 * 60)),
|
|
||||||
StoredHost(
|
|
||||||
name: "Living Room", address: "192.168.1.30",
|
|
||||||
lastConnected: .now.addingTimeInterval(-26 * 3600)),
|
|
||||||
StoredHost(
|
|
||||||
name: "Workstation", address: "10.0.0.5",
|
|
||||||
lastConnected: .now.addingTimeInterval(-6 * 86400)),
|
|
||||||
]
|
|
||||||
|
|
||||||
#Preview("Small", as: .systemSmall) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
HostsEntry(date: .now, hosts: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Medium", as: .systemMedium) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,69 +19,43 @@ struct PunktfunkSessionLiveActivity: Widget {
|
|||||||
LockScreenView(context: context)
|
LockScreenView(context: context)
|
||||||
.activitySystemActionForegroundColor(.white)
|
.activitySystemActionForegroundColor(.white)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
|
|
||||||
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
|
|
||||||
// region one purposeful row — session status + the live numbers on the left, the
|
|
||||||
// End action on the right. COMPACT shows the one number worth a glance: live
|
|
||||||
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
|
|
||||||
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
|
|
||||||
// everywhere `.tint` used to leak system blue.
|
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
HStack(spacing: 6) {
|
Label {
|
||||||
|
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||||
|
} icon: {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
Text(context.attributes.hostName)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.foregroundStyle(.tint)
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
ElapsedClock(startedAt: context.state.startedAt)
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
.font(.subheadline)
|
.font(.caption).monospacedDigit()
|
||||||
|
.frame(maxWidth: 56)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: 64, alignment: .trailing)
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.center) {
|
DynamicIslandExpandedRegion(.center) {
|
||||||
if let title = context.attributes.launchTitle {
|
if let title = context.attributes.launchTitle {
|
||||||
Text(title)
|
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
// The expanded island's height is there to be used: one info row (status
|
VStack(spacing: 6) {
|
||||||
// leading, live numbers trailing — the mode string stays on the Lock
|
Text(context.state.modeLine)
|
||||||
// Screen), then the platform-conventional LARGE full-width action button.
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||||
VStack(spacing: 10) {
|
StageLine(state: context.state)
|
||||||
HStack {
|
EndButton()
|
||||||
StatusLine(state: context.state)
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
StatsLine(state: context.state, showMode: false)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0) // any slack height goes here — button hugs the bottom
|
|
||||||
EndButton(fullWidth: true)
|
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
CompactReadout(state: context.state)
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(maxWidth: 44)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
}
|
}
|
||||||
.keylineTint(Color.brand)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,20 +69,21 @@ private struct LockScreenView: View {
|
|||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
ElapsedClock(startedAt: context.state.startedAt)
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
.font(.subheadline)
|
.font(.subheadline).monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let title = context.attributes.launchTitle {
|
if let title = context.attributes.launchTitle {
|
||||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
StatusLine(state: context.state)
|
Text(context.state.modeLine)
|
||||||
StatsLine(state: context.state)
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
StageLine(state: context.state)
|
||||||
}
|
}
|
||||||
if context.state.stage == .background {
|
if context.state.stage == .background {
|
||||||
EndButton()
|
EndButton()
|
||||||
@@ -120,237 +95,46 @@ private struct LockScreenView: View {
|
|||||||
|
|
||||||
// MARK: - Shared pieces
|
// MARK: - Shared pieces
|
||||||
|
|
||||||
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||||
private struct ElapsedClock: View {
|
private struct StageLine: View {
|
||||||
let startedAt: Date
|
|
||||||
var body: some View {
|
|
||||||
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
|
|
||||||
.monospacedDigit()
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
|
|
||||||
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
|
|
||||||
private struct StatusLine: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 5) {
|
|
||||||
Circle()
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
label
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var color: Color {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming: return .green
|
|
||||||
case .background: return .orange
|
|
||||||
case .reconnecting: return .yellow
|
|
||||||
case .ending: return .secondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var label: some View {
|
|
||||||
switch state.stage {
|
switch state.stage {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
Text("Streaming")
|
EmptyView()
|
||||||
case .background:
|
case .background:
|
||||||
if let deadline = state.backgroundDeadline {
|
if let deadline = state.backgroundDeadline {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text("Background · ends in")
|
Text("Keeps running for")
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text("Running in background")
|
|
||||||
}
|
|
||||||
case .reconnecting:
|
|
||||||
Text("Reconnecting…")
|
|
||||||
case .ending:
|
|
||||||
Text("Session ended")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
|
|
||||||
/// mode (`showMode` false on the island, where the row shares space with the status line).
|
|
||||||
/// Anything unreported simply doesn't appear.
|
|
||||||
private struct StatsLine: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
var showMode = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if let ms = state.latencyMs {
|
|
||||||
stat("bolt.fill", "\(ms) ms")
|
|
||||||
}
|
|
||||||
if let mbps = state.mbps {
|
|
||||||
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
|
|
||||||
}
|
|
||||||
if showMode {
|
|
||||||
Text(state.modeLine)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stat(_ icon: String, _ text: String) -> some View {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 8, weight: .semibold))
|
|
||||||
Text(text)
|
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.monospacedDigit()
|
.foregroundStyle(.secondary)
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The compact trailing readout — the island's one glanceable number. Streaming: the live
|
|
||||||
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
|
|
||||||
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
|
|
||||||
private struct CompactReadout: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming:
|
|
||||||
if let ms = state.latencyMs {
|
|
||||||
Text("\(ms) ms")
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
} else {
|
} else {
|
||||||
ElapsedClock(startedAt: state.startedAt)
|
badge("Running in background", .orange)
|
||||||
.font(.caption2)
|
|
||||||
.frame(maxWidth: 48)
|
|
||||||
}
|
|
||||||
case .background:
|
|
||||||
if let deadline = state.backgroundDeadline {
|
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
|
||||||
.font(.caption2)
|
|
||||||
.monospacedDigit()
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: 48)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
|
||||||
}
|
}
|
||||||
case .reconnecting:
|
case .reconnecting:
|
||||||
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
badge("Reconnecting…", .yellow)
|
||||||
case .ending:
|
case .ending:
|
||||||
Image(systemName: "stop.fill").foregroundStyle(.secondary)
|
badge("Session ended", .secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func badge(_ text: String, _ color: Color) -> some View {
|
||||||
|
Text(text).font(.caption2).foregroundStyle(color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||||
/// `fullWidth` is the expanded island's large bottom action (the platform convention there);
|
|
||||||
/// the compact form remains the Lock Screen banner's trailing button while backgrounded.
|
|
||||||
private struct EndButton: View {
|
private struct EndButton: View {
|
||||||
var fullWidth = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if fullWidth {
|
Button(intent: EndStreamIntent()) {
|
||||||
Button(intent: EndStreamIntent()) {
|
Label("End", systemImage: "stop.fill")
|
||||||
Label("End Session", systemImage: "stop.fill")
|
.font(.caption).bold()
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
} else {
|
|
||||||
Button(intent: EndStreamIntent()) {
|
|
||||||
Label("End", systemImage: "stop.fill")
|
|
||||||
.font(.caption).bold()
|
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
}
|
||||||
|
.tint(.red)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews (Xcode canvas)
|
|
||||||
//
|
|
||||||
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩) — the activity
|
|
||||||
// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real
|
|
||||||
// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)`
|
|
||||||
// the island states (canvas device must be a Dynamic Island phone for those). Each listed
|
|
||||||
// content state becomes a frame in the canvas timeline strip, so all four session stages are one
|
|
||||||
// click apart. Sample state lives here (fileprivate), never in PunktfunkShared.
|
|
||||||
|
|
||||||
extension PunktfunkSessionAttributes {
|
|
||||||
fileprivate static var preview: PunktfunkSessionAttributes {
|
|
||||||
PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PunktfunkSessionAttributes.ContentState {
|
|
||||||
fileprivate static var streaming: Self {
|
|
||||||
.init(
|
|
||||||
stage: .streaming, startedAt: .now.addingTimeInterval(-754),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2)
|
|
||||||
}
|
|
||||||
fileprivate static var backgrounded: Self {
|
|
||||||
.init(
|
|
||||||
stage: .background, startedAt: .now.addingTimeInterval(-1975),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR",
|
|
||||||
backgroundDeadline: .now.addingTimeInterval(9 * 60))
|
|
||||||
}
|
|
||||||
fileprivate static var reconnecting: Self {
|
|
||||||
.init(
|
|
||||||
stage: .reconnecting, startedAt: .now.addingTimeInterval(-754),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR")
|
|
||||||
}
|
|
||||||
fileprivate static var ended: Self {
|
|
||||||
.init(
|
|
||||||
stage: .ending, startedAt: .now.addingTimeInterval(-3541),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
PunktfunkSessionAttributes.ContentState.backgrounded
|
|
||||||
PunktfunkSessionAttributes.ContentState.reconnecting
|
|
||||||
PunktfunkSessionAttributes.ContentState.ended
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island expanded", as: .dynamicIsland(.expanded),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
PunktfunkSessionAttributes.ContentState.backgrounded
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island compact", as: .dynamicIsland(.compact),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island minimal", as: .dynamicIsland(.minimal),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ struct ContentView: View {
|
|||||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||||
@State private var showShortcutHint = false
|
@State private var showShortcutHint = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
|
||||||
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
|
||||||
@State private var showTouchExit = false
|
|
||||||
#endif
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -198,9 +193,6 @@ struct ContentView: View {
|
|||||||
#if os(macOS) || os(tvOS)
|
#if os(macOS) || os(tvOS)
|
||||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
|
||||||
#endif
|
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
guard let host = model.activeHost else { break }
|
guard let host = model.activeHost else { break }
|
||||||
@@ -300,7 +292,7 @@ struct ContentView: View {
|
|||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: { req in
|
} message: { req in
|
||||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
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.")
|
+ "pair with the 4-digit PIN it can display.")
|
||||||
}
|
}
|
||||||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
||||||
@@ -343,7 +335,7 @@ struct ContentView: View {
|
|||||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||||
} message: { req in
|
} message: { req in
|
||||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
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.")
|
+ "approve it — no need to reconnect.")
|
||||||
}
|
}
|
||||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||||
@@ -500,19 +492,12 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||||
// steering and the next click still reaches the stream. Mounted ONLY while a
|
// steering and the next click still reaches the stream.
|
||||||
// resize is live: resident structure above the CAMetalLayer is what the stage-4
|
|
||||||
// direct-to-display hunt is eliminating — composited presents reach glass a full
|
|
||||||
// refresh later. The enter/exit fade rides the call-site transition + the
|
|
||||||
// .animation(value: resizing) below (the view's internal `if active` fade can't
|
|
||||||
// run when the whole view unmounts).
|
|
||||||
.overlay {
|
.overlay {
|
||||||
if pendingFingerprint == nil, model.resizing {
|
if pendingFingerprint == nil {
|
||||||
ResizeIndicatorView(active: true)
|
ResizeIndicatorView(active: model.resizing)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
|
||||||
if let fp = pendingFingerprint {
|
if let fp = pendingFingerprint {
|
||||||
TrustCardView(
|
TrustCardView(
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -579,21 +564,13 @@ struct ContentView: View {
|
|||||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||||
},
|
},
|
||||||
onFrame: { [meter = model.meter, latency = model.latency,
|
onFrame: { [meter = model.meter, latency = model.latency,
|
||||||
split = model.latencySplit, queue = model.clientQueue,
|
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||||
offset = conn.clockOffsetNs] au in
|
|
||||||
meter.note(byteCount: au.data.count)
|
meter.note(byteCount: au.data.count)
|
||||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
// host/network split — drained by the 1 s stats tick).
|
||||||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
|
||||||
// longer contains the client-queue wait...
|
|
||||||
split.recordReceipt(
|
split.recordReceipt(
|
||||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||||
// ...which is measured as its own term instead (receipt→pull, both
|
|
||||||
// client-local).
|
|
||||||
queue.record(
|
|
||||||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
|
||||||
offsetNs: 0)
|
|
||||||
},
|
},
|
||||||
onSessionEnd: { [weak model] in
|
onSessionEnd: { [weak model] in
|
||||||
Task { @MainActor in model?.sessionEnded() }
|
Task { @MainActor in model?.sessionEnded() }
|
||||||
@@ -610,8 +587,7 @@ struct ContentView: View {
|
|||||||
},
|
},
|
||||||
endToEndMeter: model.endToEnd,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage,
|
displayMeter: model.displayStage
|
||||||
presentFloorMeter: model.presentFloor
|
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||||
@@ -660,27 +636,18 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||||
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||||
// HUD) so the glyph stays legible over a bright frame.
|
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||||
//
|
// sole touch disconnect path in those tiers.
|
||||||
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
|
|
||||||
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
|
|
||||||
// above the stream — a glass one doubly so, its blur SAMPLES the video layer —
|
|
||||||
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
|
|
||||||
// latency and blocking direct-to-display promotion. Off is the immersive/
|
|
||||||
// measurement tier; after the fade, touch-only exits are backgrounding the app
|
|
||||||
// or re-enabling the stats overlay. Compact keeps its disc permanently — that
|
|
||||||
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
|
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if captureEnabled,
|
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
|
||||||
Button { model.disconnect() } label: {
|
Button { model.disconnect() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
// Floating glass disc over the frame (26+, material fallback).
|
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||||
// interactive: the disc IS the tap target, so the glass reacts
|
// over the frame (26+, material fallback). interactive: the disc
|
||||||
// to press.
|
// IS the tap target, so the glass reacts to press.
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
// Match the hit region to the visible disc so every tap also
|
// Match the hit region to the visible disc so every tap also
|
||||||
// triggers the interactive-glass press highlight.
|
// triggers the interactive-glass press highlight.
|
||||||
@@ -689,12 +656,6 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.accessibilityLabel("Disconnect")
|
.accessibilityLabel("Disconnect")
|
||||||
.transition(.opacity)
|
|
||||||
.task {
|
|
||||||
guard statsVerbosity == .off else { return }
|
|
||||||
try? await Task.sleep(for: .seconds(8))
|
|
||||||
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -102,26 +102,6 @@ final class SessionModel: ObservableObject {
|
|||||||
@Published var decodeValid = false
|
@Published var decodeValid = false
|
||||||
@Published var displayP50Ms = 0.0
|
@Published var displayP50Ms = 0.0
|
||||||
@Published var displayValid = false
|
@Published var displayValid = false
|
||||||
/// Client-queue wait: core reassembly receipt → the pump's pull (`AccessUnit.pulledNs −
|
|
||||||
/// receivedNs`, ABI v9 receipt split — the 2026-07 two-pair investigation). ~0 on a healthy
|
|
||||||
/// stream; a persistent value is a client-side standing backlog that used to hide inside
|
|
||||||
/// "network". Shown in the detailed tier only when it says something (≥ ~2 ms).
|
|
||||||
@Published var clientQueueP50Ms = 0.0
|
|
||||||
@Published var clientQueueValid = false
|
|
||||||
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
|
||||||
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
|
||||||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
|
||||||
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
|
|
||||||
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off ≈ no floor)
|
|
||||||
/// and under stage-1.
|
|
||||||
@Published var osFloorP50Ms = 0.0
|
|
||||||
@Published var osFloorValid = false
|
|
||||||
|
|
||||||
/// The floor-shaved values every HUD tier displays (raw − floor, never below 0). Identical
|
|
||||||
/// to the raw values whenever no floor is measured.
|
|
||||||
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
||||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||||
@Published var lostFrames = 0
|
@Published var lostFrames = 0
|
||||||
@@ -153,12 +133,6 @@ final class SessionModel: ObservableObject {
|
|||||||
let endToEnd = LatencyMeter()
|
let endToEnd = LatencyMeter()
|
||||||
let decodeStage = LatencyMeter()
|
let decodeStage = LatencyMeter()
|
||||||
let displayStage = LatencyMeter()
|
let displayStage = LatencyMeter()
|
||||||
/// Client-queue sampler (see `clientQueueP50Ms`) — fed per AU by the stream view's onFrame,
|
|
||||||
/// drained by the same 1 s tick as the stage meters.
|
|
||||||
let clientQueue = LatencyMeter()
|
|
||||||
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
|
||||||
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
|
||||||
let presentFloor = LatencyMeter()
|
|
||||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||||
private var lastFramesDropped: UInt64 = 0
|
private var lastFramesDropped: UInt64 = 0
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
@@ -236,13 +210,7 @@ final class SessionModel: ObservableObject {
|
|||||||
// metadata we apply (Step 2) when it IS HDR.
|
// metadata we apply (Step 2) when it IS HDR.
|
||||||
let displayHDR: Bool = {
|
let displayHDR: Bool = {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// POTENTIAL, not current, headroom: `maximumExtendedDynamicRangeColorComponentValue`
|
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||||
// 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
|
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
// 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
|
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||||||
@@ -296,11 +264,14 @@ final class SessionModel: ObservableObject {
|
|||||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
// 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
|
// 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;
|
// 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
|
// every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
|
||||||
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ — design/pyrowave-444-hdr.md),
|
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
||||||
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1.
|
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
||||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||||
|
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
||||||
|
| PunktfunkConnection.videoCapHDR
|
||||||
|
| PunktfunkConnection.videoCap444)
|
||||||
}
|
}
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
@@ -364,7 +335,7 @@ final class SessionModel: ObservableObject {
|
|||||||
// operator didn't approve it before the host's park window elapsed (or
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
// the host was unreachable).
|
// the host was unreachable).
|
||||||
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
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."
|
+ "request access again — the request expires after a few minutes."
|
||||||
} else {
|
} else {
|
||||||
self.errorMessage = pin != nil
|
self.errorMessage = pin != nil
|
||||||
@@ -498,8 +469,6 @@ final class SessionModel: ObservableObject {
|
|||||||
endToEndValid = false
|
endToEndValid = false
|
||||||
decodeValid = false
|
decodeValid = false
|
||||||
displayValid = false
|
displayValid = false
|
||||||
clientQueueValid = false
|
|
||||||
osFloorValid = false
|
|
||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
mouseCaptured = false
|
||||||
@@ -683,31 +652,15 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
self.displayValid = false
|
||||||
}
|
}
|
||||||
if let f = self.presentFloor.drain() {
|
|
||||||
self.osFloorP50Ms = f.p50Ms
|
|
||||||
self.osFloorValid = true
|
|
||||||
} else {
|
|
||||||
self.osFloorValid = false
|
|
||||||
}
|
|
||||||
if let q = self.clientQueue.drain() {
|
|
||||||
self.clientQueueP50Ms = q.p50Ms
|
|
||||||
self.clientQueueValid = true
|
|
||||||
} else {
|
|
||||||
self.clientQueueValid = false
|
|
||||||
}
|
|
||||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||||
// upstream (host capture/encode or the network).
|
// upstream (host capture/encode or the network).
|
||||||
if frames > 0 {
|
if frames > 0 {
|
||||||
// The classic fields stay RAW (cross-session comparability with every log
|
|
||||||
// captured before the 2026-07 floor policy); the appended trio carries the
|
|
||||||
// measured OS present floor and the floor-shaved values the HUD displays.
|
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
|
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
||||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
|
||||||
frames,
|
frames,
|
||||||
displayWindow?.count ?? 0,
|
displayWindow?.count ?? 0,
|
||||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||||
@@ -715,11 +668,7 @@ final class SessionModel: ObservableObject {
|
|||||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||||
self.decodeValid ? self.decodeP50Ms : -1,
|
self.decodeValid ? self.decodeP50Ms : -1,
|
||||||
self.displayValid ? self.displayP50Ms : -1,
|
self.displayValid ? self.displayP50Ms : -1,
|
||||||
lost,
|
lost)
|
||||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
|
||||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
|
||||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
|
||||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
|
||||||
statsLog.info("\(line, privacy: .public)")
|
statsLog.info("\(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ struct StreamHUDView: View {
|
|||||||
private var compactLine: String {
|
private var compactLine: String {
|
||||||
var parts = ["\(model.fps) fps"]
|
var parts = ["\(model.fps) fps"]
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||||
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
|
||||||
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||||
}
|
}
|
||||||
@@ -88,46 +86,24 @@ struct StreamHUDView: View {
|
|||||||
}
|
}
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
||||||
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||||
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
|
|
||||||
// latency; the detailed tier shows the excluded floor as its own line, and the
|
|
||||||
// stats log keeps the raw values.
|
|
||||||
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation (detailed tier only): the stages tiling the headline interval
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// (per-window p50s — they only approximately sum to the directly-measured
|
// (per-window p50s — they only approximately sum to the directly-measured
|
||||||
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||||
// into host + network (phase 2); an old host keeps the combined term. The
|
// into host + network (phase 2); an old host keeps the combined term.
|
||||||
// display term is floor-shaved like the headline, so the equation still sums.
|
|
||||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
if model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if model.osFloorValid {
|
|
||||||
// The excluded OS term, kept visible for honesty: display-pipeline
|
|
||||||
// minimum no client can pace under (~2 refresh intervals composited).
|
|
||||||
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
|
||||||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
|
||||||
// is a client-side standing backlog that pre-split builds displayed as
|
|
||||||
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
|
|
||||||
// bleed logs alongside when it acts on the same state.
|
|
||||||
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
|
|
||||||
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||||
SettingsOptions.presentPriorityDefault
|
|
||||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||||
#endif
|
#endif
|
||||||
@@ -290,19 +288,11 @@ struct GamepadSettingsView: View {
|
|||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
value: $enable444),
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||||
+ "from the next session.",
|
options: SettingsOptions.presenters, current: presenter
|
||||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
) { presenter = $0 },
|
||||||
) { presentPriority = $0 },
|
|
||||||
choiceRow(
|
|
||||||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
|
||||||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
|
||||||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
|
||||||
+ "when prioritizing smoothness.",
|
|
||||||
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
|
|
||||||
) { smoothBuffer = $0 },
|
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import SwiftUI
|
|||||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||||
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
|
case general, display, audio, controllers, advanced, about
|
||||||
// picture (resolution, quality, presentation, host output), Input = touch/keyboard/mouse.
|
|
||||||
// The old Advanced tab dissolved (its lone game-library toggle lives in General now).
|
|
||||||
case general, display, input, audio, controllers, about
|
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
|
|
||||||
@@ -19,9 +16,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "General"
|
case .general: return "General"
|
||||||
case .display: return "Display"
|
case .display: return "Display"
|
||||||
case .input: return "Input"
|
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .controllers: return "Controllers"
|
case .controllers: return "Controllers"
|
||||||
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,9 +27,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "gearshape"
|
case .general: return "gearshape"
|
||||||
case .display: return "display"
|
case .display: return "display"
|
||||||
case .input: return "keyboard"
|
|
||||||
case .audio: return "speaker.wave.2"
|
case .audio: return "speaker.wave.2"
|
||||||
case .controllers: return "gamecontroller"
|
case .controllers: return "gamecontroller"
|
||||||
|
case .advanced: return "slider.horizontal.3"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,31 +37,28 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
static var presenters: [(label: String, tag: String)] {
|
||||||
/// PUNKTFUNK_PRESENTER debug env lever.
|
var options: [(label: String, tag: String)] = [
|
||||||
static let presentPriorities: [(label: String, tag: String)] = [
|
("Stage 2", "stage2"),
|
||||||
("Lowest latency", "latency"),
|
("Stage 3", "stage3"),
|
||||||
("Smoothness", "smooth"),
|
|
||||||
]
|
|
||||||
static let presentPriorityDefault = "latency"
|
|
||||||
|
|
||||||
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
|
|
||||||
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
|
|
||||||
/// about one refresh interval of display latency and absorbs about one interval of arrival
|
|
||||||
/// jitter.
|
|
||||||
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
|
|
||||||
let periodMs = 1000.0 / Double(max(24, refreshHz))
|
|
||||||
func hint(_ frames: Int) -> String {
|
|
||||||
String(format: "+%.0f ms", Double(frames) * periodMs)
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
("Automatic", 0),
|
|
||||||
("1 frame (\(hint(1)))", 1),
|
|
||||||
("2 frames (\(hint(2)))", 2),
|
|
||||||
("3 frames (\(hint(3)))", 3),
|
|
||||||
]
|
]
|
||||||
|
#if DEBUG
|
||||||
|
options.append(("Stage 1 (debug)", "stage1"))
|
||||||
|
#endif
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(tvOS)
|
||||||
|
"stage3"
|
||||||
|
#else
|
||||||
|
"stage2"
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
//
|
|
||||||
// 2026-07 settings revamp: every field carries its explanation DIRECTLY under it in the same
|
|
||||||
// cell (the `described` helper in SettingsView+Support) — the old per-section footer paragraphs
|
|
||||||
// collected several fields' explanations into one blob nobody could match back to its row.
|
|
||||||
// Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize),
|
|
||||||
// the description is DYNAMIC — it explains the current choice. The only footers left are the
|
|
||||||
// one-line "Applies from the next session." form notes.
|
|
||||||
//
|
|
||||||
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
|
|
||||||
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import CoreHaptics
|
import CoreHaptics
|
||||||
@@ -18,23 +8,18 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Display: Resolution
|
// MARK: - Sections (shared)
|
||||||
|
|
||||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||||||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||||||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||||
@ViewBuilder var resolutionSection: some View {
|
@ViewBuilder var streamModeSection: some View {
|
||||||
Section("Resolution") {
|
Section {
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||||
described(matchWindow
|
Toggle("Match window", isOn: $matchWindow)
|
||||||
? "The host resizes its output to follow this window — the picture stays "
|
|
||||||
+ "pixel-exact (1:1) through every resize."
|
|
||||||
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
|
|
||||||
Toggle("Match window", isOn: $matchWindow)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
iosResolutionWheel
|
iosResolutionWheel
|
||||||
@@ -47,19 +32,75 @@ extension SettingsView {
|
|||||||
TextField("", value: $height, format: .number.grouping(.never))
|
TextField("", value: $height, format: .number.grouping(.never))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
described("The host drives a real virtual output at exactly this size and refresh — "
|
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||||
+ "true pixels, no scaling.") {
|
|
||||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
|
||||||
}
|
|
||||||
LabeledContent("") {
|
LabeledContent("") {
|
||||||
Button("Use this display's mode") { fillFromMainScreen() }
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
renderScaleRow
|
||||||
|
bitrateRows
|
||||||
|
#endif
|
||||||
|
} header: {
|
||||||
|
Text("Stream mode")
|
||||||
|
} footer: {
|
||||||
|
Text(matchWindow
|
||||||
|
? "The stream follows this window — the host resizes its virtual output to match "
|
||||||
|
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||||
|
+ "\(Self.bitrateFooter)"
|
||||||
|
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||||
|
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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)
|
#if os(iOS)
|
||||||
// MARK: - Display: Resolution (iOS wheel)
|
// MARK: - Stream mode (iOS wheel)
|
||||||
|
|
||||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
||||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||||
@@ -78,11 +119,6 @@ extension SettingsView {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.wheel)
|
.pickerStyle(.wheel)
|
||||||
.frame(maxHeight: 140)
|
.frame(maxHeight: 140)
|
||||||
Text("The host drives a real output at exactly this mode — true pixels, no scaling.")
|
|
||||||
.font(.geist(13, relativeTo: .footnote))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.frame(maxWidth: 360, alignment: .leading) // match the described-row caption cap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,69 +210,10 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Display: Quality
|
|
||||||
|
|
||||||
@ViewBuilder var qualitySection: some View {
|
|
||||||
Section("Quality") {
|
|
||||||
#if !os(tvOS)
|
|
||||||
renderScaleRow
|
|
||||||
bitrateRows
|
|
||||||
#endif
|
|
||||||
described("A preference — the host falls back if it can't encode it.") {
|
|
||||||
Picker("Video codec", selection: $codec) {
|
|
||||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
described("HDR10, when the host has HDR content and this display supports it. "
|
|
||||||
+ "HEVC only; otherwise the stream stays SDR.") {
|
|
||||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
|
||||||
}
|
|
||||||
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
|
|
||||||
+ "bits are better spent at 4:2:0. HEVC only.") {
|
|
||||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
#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 {
|
|
||||||
described(renderScaleDescription) {
|
|
||||||
Picker("Render scale", selection: $renderScale) {
|
|
||||||
ForEach(RenderScale.presets, id: \.self) { scale in
|
|
||||||
Text(RenderScale.label(scale)).tag(scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render scale explained, with the CONCRETE host resolution when it applies — the cost made
|
|
||||||
/// legible. Only the explicit mode can show it (match-window derives the base from the live
|
|
||||||
/// window, not these fields).
|
|
||||||
private var renderScaleDescription: String {
|
|
||||||
var text = "Above native supersamples for sharpness; below renders lighter on the host "
|
|
||||||
+ "and the link."
|
|
||||||
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 scales "
|
|
||||||
+ "it to your display."
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||||
@ViewBuilder private var bitrateRows: some View {
|
@ViewBuilder private var bitrateRows: some View {
|
||||||
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
|
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||||
+ "fixed rate — a host card's context menu has a network speed test.") {
|
|
||||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
|
||||||
}
|
|
||||||
if bitrateKbps != 0 {
|
if bitrateKbps != 0 {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Slider(value: bitrateSlider, in: 0...1) {
|
Slider(value: bitrateSlider, in: 0...1) {
|
||||||
@@ -256,228 +233,26 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Display: Presentation
|
|
||||||
|
|
||||||
// The presentation intent (design/apple-presentation-rebuild.md — replaced the visible
|
|
||||||
// stage picker): latency (newest-wins, zero queue) vs smoothness (a small deliberate jitter
|
|
||||||
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
|
|
||||||
@ViewBuilder var presentationSection: some View {
|
|
||||||
Section("Presentation") {
|
|
||||||
described(presentPriority == "smooth"
|
|
||||||
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
|
|
||||||
+ "added display latency."
|
|
||||||
: "Every frame shows the moment the display can take it — a network hiccup is "
|
|
||||||
+ "an occasional repeated or skipped frame.") {
|
|
||||||
Picker("Prioritize", selection: $presentPriority) {
|
|
||||||
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
|
|
||||||
Text(option.tag == SettingsOptions.presentPriorityDefault
|
|
||||||
? "\(option.label) (default)" : option.label)
|
|
||||||
.tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if presentPriority == "smooth" {
|
|
||||||
described("Frames held back — each absorbs about one refresh of jitter and "
|
|
||||||
+ "adds one refresh of delay.") {
|
|
||||||
Picker("Buffer", selection: $smoothBuffer) {
|
|
||||||
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
|
|
||||||
#if !os(tvOS)
|
|
||||||
described("A ProMotion or adaptive-sync display follows the stream's rate — "
|
|
||||||
+ "smoother motion. No effect on fixed-refresh displays.") {
|
|
||||||
Toggle("Allow VRR", isOn: $allowVRR)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
|
|
||||||
// only exists on the Mac (the layer's own sync stays off — see MetalVideoPresenter).
|
|
||||||
#if os(macOS)
|
|
||||||
described("Flips align to the display's refresh — even pacing, up to one refresh "
|
|
||||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
|
||||||
Toggle("V-Sync", isOn: $vsync)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display: Host output
|
|
||||||
|
|
||||||
@ViewBuilder var hostOutputSection: some View {
|
|
||||||
Section {
|
|
||||||
described("The backend the host uses for its virtual output. A specific choice "
|
|
||||||
+ "falls back to auto-detection when that backend isn't available.") {
|
|
||||||
Picker("Compositor", selection: $compositor) {
|
|
||||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Host output")
|
|
||||||
} footer: {
|
|
||||||
// The one form-level note (deliberately not repeated on every row above).
|
|
||||||
Text("Display changes apply from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Session
|
|
||||||
|
|
||||||
@ViewBuilder var sessionSection: some View {
|
|
||||||
Section("Session") {
|
|
||||||
#if os(macOS)
|
|
||||||
described("Go fullscreen when a session starts; return to a window on the host "
|
|
||||||
+ "list.") {
|
|
||||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
|
|
||||||
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
|
|
||||||
+ "aren't.") {
|
|
||||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
described("Audio and the connection stay live after you switch away; video pauses "
|
|
||||||
+ "to save power and resumes instantly when you return. Off, backgrounding "
|
|
||||||
+ "freezes the session.") {
|
|
||||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
|
||||||
}
|
|
||||||
if backgroundKeepAlive {
|
|
||||||
described("Ends a backgrounded session so it can't run down the battery.") {
|
|
||||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
|
||||||
Text("1 minute").tag(1)
|
|
||||||
Text("5 minutes").tag(5)
|
|
||||||
Text("10 minutes").tag(10)
|
|
||||||
Text("30 minutes").tag(30)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Statistics overlay
|
|
||||||
|
|
||||||
@ViewBuilder var overlaySection: some View {
|
|
||||||
Section("Statistics") {
|
|
||||||
described(Self.statisticsDescription) {
|
|
||||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
|
||||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
|
||||||
Text(tier.label).tag(tier.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("Position", selection: $hudPlacement) {
|
|
||||||
ForEach(HUDPlacement.allCases) { placement in
|
|
||||||
Text(placement.label).tag(placement.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Library
|
|
||||||
|
|
||||||
@ViewBuilder var librarySection: some View {
|
|
||||||
Section("Library") {
|
|
||||||
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
|
|
||||||
+ "games and launch one directly. No extra host setup.") {
|
|
||||||
Toggle("Show game library", isOn: $libraryEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Input
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
|
||||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
|
||||||
@ViewBuilder var pointerSection: some View {
|
|
||||||
Section("Touch & pointer") {
|
|
||||||
described(touchModeDescription) {
|
|
||||||
Picker("Touch input", selection: $touchMode) {
|
|
||||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
|
||||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
|
||||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
described("Locks a hardware mouse for relative mouse-look in games; off sends "
|
|
||||||
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
|
|
||||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED touch mode explained — dynamic, so the caption always describes what the
|
|
||||||
/// picker currently does instead of narrating all three modes at once.
|
|
||||||
private var touchModeDescription: String {
|
|
||||||
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
|
|
||||||
case .trackpad:
|
|
||||||
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
|
|
||||||
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
|
|
||||||
case .pointer:
|
|
||||||
return "The host cursor jumps to wherever you touch — tap is a click at that spot."
|
|
||||||
case .touch:
|
|
||||||
return "Real multi-touch reaches the host — for touch-native apps and games."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
/// 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("Keyboard & mouse") {
|
|
||||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
|
||||||
Picker("Modifier keys", selection: $modifierLayout) {
|
|
||||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
|
||||||
Text(layout.label).tag(layout.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
|
|
||||||
Toggle("Invert scroll direction", isOn: $invertScroll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Audio
|
|
||||||
|
|
||||||
@ViewBuilder var audioSection: some View {
|
@ViewBuilder var audioSection: some View {
|
||||||
Section {
|
Section {
|
||||||
described("The speaker layout requested from the host.") {
|
Picker("Audio channels", selection: $audioChannels) {
|
||||||
Picker("Audio channels", selection: $audioChannels) {
|
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
Text(option.label).tag(option.tag)
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
described("Host audio plays through this device; System default follows your "
|
Picker("Speaker", selection: $speakerUID) {
|
||||||
+ "Mac's output changes.") {
|
Text("System default").tag("")
|
||||||
Picker("Speaker", selection: $speakerUID) {
|
ForEach(outputDevices) { device in
|
||||||
Text("System default").tag("")
|
Text(device.name).tag(device.uid)
|
||||||
ForEach(outputDevices) { device in
|
}
|
||||||
Text(device.name).tag(device.uid)
|
if !speakerUID.isEmpty,
|
||||||
}
|
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||||
if !speakerUID.isEmpty,
|
Text("Unavailable device").tag(speakerUID)
|
||||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
|
||||||
Text("Unavailable device").tag(speakerUID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
described("This device's microphone feeds the host's virtual mic.") {
|
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Picker("Microphone", selection: $micUID) {
|
Picker("Microphone", selection: $micUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
@@ -493,27 +268,264 @@ extension SettingsView {
|
|||||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||||
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||||
if micChannelCount > 1 {
|
if micChannelCount > 1 {
|
||||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
Picker("Microphone channel", selection: $micChannel) {
|
||||||
Picker("Microphone channel", selection: $micChannel) {
|
Text("Auto (all channels)").tag(0)
|
||||||
Text("Auto (all channels)").tag(0)
|
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
Text("Channel \(ch)").tag(ch)
|
||||||
Text("Channel \(ch)").tag(ch)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.disabled(!micEnabled)
|
|
||||||
}
|
}
|
||||||
|
.disabled(!micEnabled)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Applies from the next session.")
|
Text(Self.audioFooter)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
#if os(iOS)
|
||||||
|
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||||
|
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||||
|
@ViewBuilder var pointerSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Touch input", selection: $touchMode) {
|
||||||
|
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||||
|
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||||
|
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||||
|
}
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Touch & pointer")
|
||||||
|
} footer: {
|
||||||
|
Text(pointerFooterText)
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
||||||
|
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||||
|
/// type-checker budget and was what actually broke the iOS archive.
|
||||||
|
private var pointerFooterText: String {
|
||||||
|
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
|
||||||
|
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
|
||||||
|
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
|
||||||
|
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
|
||||||
|
text += "the host. Applies from the next touch."
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
|
||||||
|
text += "absolute positions. Needs the stream full-screen and frontmost."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@ViewBuilder var compositorSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Compositor", selection: $compositor) {
|
||||||
|
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Host compositor")
|
||||||
|
} footer: {
|
||||||
|
Text("Which compositor drives the virtual output on the host. A specific "
|
||||||
|
+ "choice is honored only if that backend is available there — "
|
||||||
|
+ "otherwise the host falls back to auto-detection.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||||
|
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||||
|
@ViewBuilder var wakeSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Wake-on-LAN")
|
||||||
|
} footer: {
|
||||||
|
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||||
|
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||||
|
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||||
|
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||||
|
+ "way.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var windowSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||||
|
} header: {
|
||||||
|
Text("Window")
|
||||||
|
} footer: {
|
||||||
|
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||||
|
+ "list, so only the stream is fullscreen — not the picker.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
|
||||||
|
@ViewBuilder var vrrSection: some View {
|
||||||
|
#if !os(tvOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Allow VRR", isOn: $allowVRR)
|
||||||
|
} header: {
|
||||||
|
Text("Variable refresh rate")
|
||||||
|
} footer: {
|
||||||
|
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
|
||||||
|
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
|
||||||
|
+ "Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
|
||||||
|
// exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter).
|
||||||
|
@ViewBuilder var vsyncSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("V-Sync", isOn: $vsync)
|
||||||
|
} header: {
|
||||||
|
Text("Presentation")
|
||||||
|
} footer: {
|
||||||
|
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
|
||||||
|
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
|
||||||
|
+ "in step with the display's refresh — evenly paced, up to one refresh of "
|
||||||
|
+ "added latency. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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 — 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 — 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))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var hdrSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Video codec", selection: $codec) {
|
||||||
|
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||||
|
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||||
|
} header: {
|
||||||
|
Text("Video quality")
|
||||||
|
} footer: {
|
||||||
|
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
||||||
|
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
||||||
|
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
||||||
|
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
||||||
|
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
||||||
|
+ "session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var statisticsSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||||
|
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||||
|
Text(tier.label).tag(tier.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("Position", selection: $hudPlacement) {
|
||||||
|
ForEach(HUDPlacement.allCases) { placement in
|
||||||
|
Text(placement.label).tag(placement.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||||
|
} header: {
|
||||||
|
Text("Statistics")
|
||||||
|
} footer: {
|
||||||
|
Text(Self.statisticsFooter)
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
|
||||||
|
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
|
||||||
|
/// detail can reference it unconditionally.
|
||||||
|
@ViewBuilder var keepAliveSection: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||||
|
if backgroundKeepAlive {
|
||||||
|
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||||
|
Text("1 minute").tag(1)
|
||||||
|
Text("5 minutes").tag(5)
|
||||||
|
Text("10 minutes").tag(10)
|
||||||
|
Text("30 minutes").tag(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Background")
|
||||||
|
} footer: {
|
||||||
|
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
|
||||||
|
+ "playing and the connection stays live (video is dropped to save power) after you "
|
||||||
|
+ "switch away — and the session auto-disconnects after the time above so it can't "
|
||||||
|
+ "run down your battery. Returning to the app resumes video instantly.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var experimentalSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Show game library", isOn: $libraryEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Experimental")
|
||||||
|
} footer: {
|
||||||
|
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||||
|
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
|
||||||
|
+ "extra host setup.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var controllersSection: some View {
|
@ViewBuilder var controllersSection: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -525,37 +537,24 @@ extension SettingsView {
|
|||||||
controllerRow(controller)
|
controllerRow(controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||||
+ "recently connected.") {
|
ForEach(controllerOptions, id: \.tag) { option in
|
||||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
Text(option.label).tag(option.tag)
|
||||||
ForEach(controllerOptions, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described("The virtual pad created on the host. Automatic matches your controller "
|
Picker("Controller type", selection: $gamepadType) {
|
||||||
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
|
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||||
Picker("Controller type", selection: $gamepadType) {
|
Text(option.label).tag(option.tag)
|
||||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
|
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||||
+ "clip-on controllers without motors of their own.") {
|
|
||||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
described("With a controller connected, the host list and library switch to a "
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
|
||||||
+ "browser.") {
|
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
Button("Test Controller…") { showControllerTest = true }
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
@@ -565,9 +564,22 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Applies from the next session.")
|
// The gamepad-UI blurb is appended here, not merged into the shared
|
||||||
.font(.geist(12, relativeTo: .caption))
|
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
||||||
.foregroundStyle(.secondary)
|
// for its own footer and has no such toggle to describe.
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(Self.controllersFooter)
|
||||||
|
#if os(iOS)
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Text(Self.deviceRumbleFooter)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
Text(Self.gamepadUIFooter)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,6 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Described rows (the 2026-07 revamp's field idiom)
|
|
||||||
|
|
||||||
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
|
|
||||||
/// directly under it. This replaced the per-section footer paragraphs — a description the eye
|
|
||||||
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
|
|
||||||
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
|
|
||||||
/// choice.
|
|
||||||
@ViewBuilder
|
|
||||||
func described<Content: View>(
|
|
||||||
_ caption: String, @ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
content()
|
|
||||||
Text(caption)
|
|
||||||
.font(.geist(13, relativeTo: .footnote))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
|
|
||||||
// Cap the caption's line length well short of the cell: a full-width caption runs
|
|
||||||
// its text right up to the control column (toggles especially), reading as one
|
|
||||||
// colliding block. ~46 chars/line also just measures better.
|
|
||||||
.frame(maxWidth: 360, alignment: .leading)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bitrate
|
// MARK: - Bitrate
|
||||||
|
|
||||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||||
@@ -42,7 +17,6 @@ extension SettingsView {
|
|||||||
private static let minSliderKbps = 2_000.0
|
private static let minSliderKbps = 2_000.0
|
||||||
private static let maxSliderKbps = 3_000_000.0
|
private static let maxSliderKbps = 3_000_000.0
|
||||||
|
|
||||||
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
|
|
||||||
static let bitrateFooter =
|
static let bitrateFooter =
|
||||||
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
||||||
+ "to its supported range. Run a speed test from a host card's context menu to "
|
+ "to its supported range. Run a speed test from a host card's context menu to "
|
||||||
@@ -79,27 +53,55 @@ extension SettingsView {
|
|||||||
|
|
||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsDescription: String {
|
static var statisticsFooter: String {
|
||||||
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||||
+ "Detailed adds the latency stage breakdown."
|
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||||
|
+ "breakdown."
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return base + " ⌃⌥⇧S cycles the tiers any time."
|
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
|
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||||
|
+ "any time."
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio
|
||||||
|
|
||||||
|
static var audioFooter: String {
|
||||||
|
#if os(macOS)
|
||||||
|
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
|
||||||
|
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
|
||||||
|
+ "next session."
|
||||||
|
#else
|
||||||
|
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
|
||||||
|
+ "Applies from the next session."
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
// MARK: - Controllers
|
||||||
|
|
||||||
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
|
||||||
static let controllersFooter =
|
static let controllersFooter =
|
||||||
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||||
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "Applies from the next session."
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let deviceRumbleFooter =
|
||||||
|
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
||||||
|
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
||||||
|
+ "from the next session."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
static let gamepadUIFooter =
|
||||||
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
|
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
|
||||||
|
+ "off to always use the standard layout."
|
||||||
|
#endif
|
||||||
|
|
||||||
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
||||||
var controllerOptions: [(label: String, tag: String)] {
|
var controllerOptions: [(label: String, tag: String)] {
|
||||||
SettingsOptions.controllerOptions(gamepads)
|
SettingsOptions.controllerOptions(gamepads)
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
// 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).
|
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||||||
//
|
//
|
||||||
// Navigation differs per platform, but all three follow the same category map (General =
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
// About — see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
|
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||||
// adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
|
||||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||||
// focus-native pushed-picker layout in the same order. The individual sections
|
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||||
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||||||
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||||||
// per-field `described` caption idiom) in SettingsView+Support.swift.
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -35,9 +33,7 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||||
SettingsOptions.presentPriorityDefault
|
|
||||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
#endif
|
#endif
|
||||||
@@ -124,32 +120,27 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var macBody: some View {
|
private var macBody: some View {
|
||||||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
|
||||||
// picture (resolution lives here), Input = keyboard & mouse.
|
|
||||||
TabView {
|
TabView {
|
||||||
Form {
|
Form {
|
||||||
sessionSection
|
streamModeSection
|
||||||
overlaySection
|
inputSection
|
||||||
librarySection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
resolutionSection
|
presenterSection
|
||||||
qualitySection
|
hdrSection
|
||||||
presentationSection
|
vrrSection
|
||||||
hostOutputSection
|
vsyncSection
|
||||||
|
windowSection
|
||||||
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("Display", systemImage: "display") }
|
.tabItem { Label("Display", systemImage: "display") }
|
||||||
|
|
||||||
Form {
|
|
||||||
inputSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.tabItem { Label("Input", systemImage: "keyboard") }
|
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
audioSection
|
audioSection
|
||||||
}
|
}
|
||||||
@@ -177,10 +168,16 @@ struct SettingsView: View {
|
|||||||
.onDisappear { gamepads.stopDiscovery() }
|
.onDisappear { gamepads.stopDiscovery() }
|
||||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||||
|
|
||||||
|
Form {
|
||||||
|
experimentalSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||||
|
|
||||||
AcknowledgementsView()
|
AcknowledgementsView()
|
||||||
.tabItem { Label("About", systemImage: "info.circle") }
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.frame(width: 500, height: 520)
|
.frame(width: 480, height: 460)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -255,31 +252,26 @@ struct SettingsView: View {
|
|||||||
switch category {
|
switch category {
|
||||||
case .general:
|
case .general:
|
||||||
Form {
|
Form {
|
||||||
sessionSection
|
streamModeSection
|
||||||
overlaySection
|
pointerSection
|
||||||
librarySection
|
inputSection
|
||||||
|
compositorSection
|
||||||
|
wakeSection
|
||||||
|
keepAliveSection // iOS-only content; empty on tvOS
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .display:
|
case .display:
|
||||||
Form {
|
Form {
|
||||||
resolutionSection
|
presenterSection
|
||||||
qualitySection
|
hdrSection
|
||||||
presentationSection
|
vrrSection
|
||||||
hostOutputSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Display")
|
.navigationTitle("Display")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .input:
|
|
||||||
Form {
|
|
||||||
pointerSection
|
|
||||||
inputSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.navigationTitle("Input")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
case .audio:
|
case .audio:
|
||||||
Form { audioSection }
|
Form { audioSection }
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -290,6 +282,11 @@ struct SettingsView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Controllers")
|
.navigationTitle("Controllers")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .advanced:
|
||||||
|
Form { experimentalSection }
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Advanced")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .about:
|
case .about:
|
||||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||||
@@ -335,16 +332,6 @@ struct SettingsView: View {
|
|||||||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One cluster caption, TV-legible — the 10-foot analogue of the touch/desktop per-row
|
|
||||||
/// `described` captions (per-row text doesn't scale to TV type sizes).
|
|
||||||
private func tvCaption(_ text: String) -> some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.geist(20, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -357,9 +344,6 @@ struct SettingsView: View {
|
|||||||
if !options.contains(where: { $0.tag == currentTag }) {
|
if !options.contains(where: { $0.tag == currentTag }) {
|
||||||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||||||
}
|
}
|
||||||
// Row order mirrors the touch/desktop category map: Display (mode → quality →
|
|
||||||
// presentation → host output), then Audio, General, Statistics, Controllers — with one
|
|
||||||
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
|
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||||
@@ -371,41 +355,37 @@ struct SettingsView: View {
|
|||||||
title: "Bitrate",
|
title: "Bitrate",
|
||||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||||
selection: $bitrateKbps)
|
selection: $bitrateKbps)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Audio channels",
|
||||||
|
options: SettingsOptions.audioChannels,
|
||||||
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Compositor", options: SettingsOptions.compositors,
|
||||||
|
selection: $compositor)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Presenter",
|
||||||
|
options: SettingsOptions.presenters,
|
||||||
|
selection: $presenter)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
TVSelectionRow(
|
|
||||||
title: "Prioritize",
|
|
||||||
options: SettingsOptions.presentPriorities,
|
|
||||||
selection: $presentPriority)
|
|
||||||
if presentPriority == "smooth" {
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Smoothness buffer",
|
|
||||||
options: SettingsOptions.smoothBuffers(refreshHz: hz),
|
|
||||||
selection: $smoothBuffer)
|
|
||||||
}
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Compositor", options: SettingsOptions.compositors,
|
|
||||||
selection: $compositor)
|
|
||||||
tvCaption("The host drives a real output at exactly the chosen mode. "
|
|
||||||
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
|
|
||||||
+ "Smoothness buffers a few to even out network hiccups. A specific "
|
|
||||||
+ "compositor is honored only if available on the host.")
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Audio channels",
|
|
||||||
options: SettingsOptions.audioChannels,
|
|
||||||
selection: $audioChannels)
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Auto-wake on connect",
|
title: "Auto-wake on connect",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "it before streaming.")
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
|
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||||
|
+ "sleeping saved host and waits for it before streaming.")
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics overlay",
|
title: "Statistics overlay",
|
||||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||||
@@ -425,7 +405,11 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Gamepad-optimized browsing",
|
title: "Gamepad-optimized browsing",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
tvCaption(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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)
|
#if os(tvOS)
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Text("The PIN is shown in the host's web console "
|
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 "
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
+ "needed.")
|
+ "needed.")
|
||||||
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||||
@@ -118,7 +118,7 @@ struct PairSheet: View {
|
|||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The PIN is shown in the host's web console "
|
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 "
|
+ "Pairing verifies both sides at once — no fingerprint "
|
||||||
+ "comparison needed.")
|
+ "comparison needed.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
@@ -210,7 +210,7 @@ struct PairSheet: View {
|
|||||||
onPaired(fingerprint)
|
onPaired(fingerprint)
|
||||||
dismiss()
|
dismiss()
|
||||||
case .failure(PunktfunkClientError.wrongPIN):
|
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."
|
+ "and try again."
|
||||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||||
// The host answered and said why (not armed / rate-limited / armed for
|
// The host answered and said why (not armed / rate-limited / armed for
|
||||||
|
|||||||
@@ -35,31 +35,10 @@ public struct AccessUnit: Sendable {
|
|||||||
public let ptsNs: UInt64
|
public let ptsNs: UInt64
|
||||||
public let frameIndex: UInt32
|
public let frameIndex: UInt32
|
||||||
public let flags: UInt32
|
public let flags: UInt32
|
||||||
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC,
|
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||||
/// decrypted — `PunktfunkFrame.received_ns`, ABI v9) — the **received** measurement point of
|
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||||
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the
|
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||||
/// pre-decode hand-off wait into the network term, which is how the 2026-07 two-pair
|
|
||||||
/// standing-latency plateau hid as "network". The decode stage is `decodedNs - receivedNs`,
|
|
||||||
/// both client-local (no skew offset applies).
|
|
||||||
public let receivedNs: Int64
|
public let receivedNs: Int64
|
||||||
/// Client `CLOCK_REALTIME` instant this pull returned. `pulledNs - receivedNs` is the
|
|
||||||
/// client-queue wait (kernel hand-off + FrameChannel dwell) — the term the HUD splits out
|
|
||||||
/// so a client-side standing backlog can never masquerade as network latency again.
|
|
||||||
public let pulledNs: Int64
|
|
||||||
|
|
||||||
/// `pulledNs` defaults to `receivedNs` (zero queue wait) for callers with no pull instant —
|
|
||||||
/// the synthetic probe AUs and decode tests, where the split is meaningless.
|
|
||||||
public init(
|
|
||||||
data: Data, ptsNs: UInt64, frameIndex: UInt32, flags: UInt32,
|
|
||||||
receivedNs: Int64, pulledNs: Int64? = nil
|
|
||||||
) {
|
|
||||||
self.data = data
|
|
||||||
self.ptsNs = ptsNs
|
|
||||||
self.frameIndex = frameIndex
|
|
||||||
self.flags = flags
|
|
||||||
self.receivedNs = receivedNs
|
|
||||||
self.pulledNs = pulledNs ?? receivedNs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||||
@@ -683,16 +662,11 @@ public final class PunktfunkConnection {
|
|||||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||||
var ts = timespec()
|
var ts = timespec()
|
||||||
clock_gettime(CLOCK_REALTIME, &ts)
|
clock_gettime(CLOCK_REALTIME, &ts)
|
||||||
let pulledNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||||
// Receipt = the core's reassembly-completion stamp (ABI v9); the pull instant is
|
|
||||||
// kept separately so the client-queue wait is its own measured term. 0 would mean a
|
|
||||||
// pre-v9 core — impossible here (core and Kit ship in one binary), but fall back to
|
|
||||||
// the pull instant rather than record a 1970 receipt.
|
|
||||||
let receivedNs = frame.received_ns > 0 ? Int64(frame.received_ns) : pulledNs
|
|
||||||
return AccessUnit(
|
return AccessUnit(
|
||||||
data: data, ptsNs: frame.pts_ns,
|
data: data, ptsNs: frame.pts_ns,
|
||||||
frameIndex: frame.frame_index, flags: frame.flags,
|
frameIndex: frame.frame_index, flags: frame.flags,
|
||||||
receivedNs: receivedNs, pulledNs: pulledNs)
|
receivedNs: receivedNs)
|
||||||
case statusNoFrame:
|
case statusNoFrame:
|
||||||
return nil
|
return nil
|
||||||
case statusClosed:
|
case statusClosed:
|
||||||
|
|||||||
@@ -57,8 +57,30 @@ public enum BrandFont {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
public extension Color {
|
||||||
// extension links Shared alone and must render the same purple.
|
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||||
|
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||||
|
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||||
|
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||||
|
static let brand: Color = {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
return Color(UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
return Color(NSColor(name: nil) { appearance in
|
||||||
|
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||||
|
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#else
|
||||||
|
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||||
|
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
public extension Font {
|
public extension Font {
|
||||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||||
|
|||||||
@@ -14,9 +14,6 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import CoreVideo
|
import CoreVideo
|
||||||
#if os(macOS)
|
|
||||||
import IOSurface
|
|
||||||
#endif
|
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import os
|
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
|
// 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:
|
// 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.)
|
// 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
|
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
texture2d<float> chromaTex [[texture(1)]],
|
||||||
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
static inline float3 pqToSdr(float3 pq) {
|
// 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 m1 = 2610.0/16384.0;
|
||||||
const float m2 = 78.84375;
|
const float m2 = 78.84375;
|
||||||
const float c1 = 3424.0/4096.0;
|
const float c1 = 3424.0/4096.0;
|
||||||
@@ -208,49 +207,20 @@ static inline float3 pqToSdr(float3 pq) {
|
|||||||
const float c3 = 18.6875;
|
const float c3 = 18.6875;
|
||||||
float3 p = pow(pq, 1.0/m2);
|
float3 p = pow(pq, 1.0/m2);
|
||||||
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
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);
|
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(
|
float3 t709 = float3(
|
||||||
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
||||||
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
||||||
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
||||||
t709 = max(t709, 0.0);
|
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;
|
const float w = 1000.0/203.0;
|
||||||
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
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);
|
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
||||||
return e;
|
return float4(e, 1.0);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -258,51 +228,6 @@ public final class MetalVideoPresenter {
|
|||||||
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
||||||
public let layer: CAMetalLayer
|
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 device: MTLDevice
|
||||||
private let queue: MTLCommandQueue
|
private let queue: MTLCommandQueue
|
||||||
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
/// 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?
|
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||||
private let pipelinePlanar: MTLRenderPipelineState
|
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?
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
/// 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 pipelineHDR: MTLRenderPipelineState
|
||||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
let pipelinePlanar: MTLRenderPipelineState
|
let pipelinePlanar: MTLRenderPipelineState
|
||||||
let pipelinePlanarHDR: MTLRenderPipelineState
|
|
||||||
let pipelinePlanarToneMap: MTLRenderPipelineState
|
|
||||||
do {
|
do {
|
||||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
// 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
|
// (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()
|
let planar = MTLRenderPipelineDescriptor()
|
||||||
planar.vertexFunction = vtx
|
planar.vertexFunction = vtx
|
||||||
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
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)
|
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 {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -460,7 +368,6 @@ public final class MetalVideoPresenter {
|
|||||||
return MetalVideoPresenter(
|
return MetalVideoPresenter(
|
||||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||||
pipelinePlanarHDR: pipelinePlanarHDR, pipelinePlanarToneMap: pipelinePlanarToneMap,
|
|
||||||
textureCache: textureCache, layer: layer)
|
textureCache: textureCache, layer: layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,8 +375,6 @@ public final class MetalVideoPresenter {
|
|||||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||||
pipelinePlanar: MTLRenderPipelineState,
|
pipelinePlanar: MTLRenderPipelineState,
|
||||||
pipelinePlanarHDR: MTLRenderPipelineState,
|
|
||||||
pipelinePlanarToneMap: MTLRenderPipelineState,
|
|
||||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
) {
|
) {
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -478,8 +383,6 @@ public final class MetalVideoPresenter {
|
|||||||
self.pipelineHDR = pipelineHDR
|
self.pipelineHDR = pipelineHDR
|
||||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||||
self.pipelinePlanar = pipelinePlanar
|
self.pipelinePlanar = pipelinePlanar
|
||||||
self.pipelinePlanarHDR = pipelinePlanarHDR
|
|
||||||
self.pipelinePlanarToneMap = pipelinePlanarToneMap
|
|
||||||
self.textureCache = textureCache
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
@@ -590,48 +493,6 @@ public final class MetalVideoPresenter {
|
|||||||
stagingLock.unlock()
|
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
|
|
||||||
|
|
||||||
/// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a
|
|
||||||
/// drawable exists. The link vends from the layer's CURRENT config, and the layer starts
|
|
||||||
/// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's
|
|
||||||
/// frame isn't even laid out when the link spins up) — so leaving all reconciliation to the
|
|
||||||
/// render path (which needs a frame AND a vended drawable) deadlocks at session start:
|
|
||||||
/// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and
|
|
||||||
/// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable]
|
|
||||||
/// returning nil because allocation failed" every refresh). Called on EVERY frame arrival:
|
|
||||||
/// drains the same staging the render path drains (both are idempotent about it) and
|
|
||||||
/// applies size + HDR config, so the next vend always matches the frame about to present —
|
|
||||||
/// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting
|
|
||||||
/// for a paired present to retag the layer.
|
|
||||||
func reconcileLayer(decodedSize: CGSize, isHDR: Bool) {
|
|
||||||
stagingLock.lock()
|
|
||||||
let targetFromLayout = drawableTarget
|
|
||||||
let newHdrMeta = pendingHdrMeta
|
|
||||||
pendingHdrMeta = nil
|
|
||||||
stagingLock.unlock()
|
|
||||||
configure(hdr: isHDR)
|
|
||||||
if let newHdrMeta {
|
|
||||||
self.lastHdrMeta = newHdrMeta
|
|
||||||
#if !os(tvOS)
|
|
||||||
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
|
||||||
? targetFromLayout : decodedSize
|
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
/// 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`
|
/// `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
|
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||||
@@ -647,15 +508,10 @@ public final class MetalVideoPresenter {
|
|||||||
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
||||||
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
||||||
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
||||||
///
|
|
||||||
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
|
|
||||||
/// render into instead of calling `nextDrawable()` — see `encodePresent` for the format
|
|
||||||
/// guard that skips a vend the layer's config outran.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func render(
|
public func render(
|
||||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||||
presentAtMediaTime: CFTimeInterval? = nil,
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
into drawable: CAMetalDrawable? = nil,
|
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||||
@@ -709,8 +565,7 @@ public final class MetalVideoPresenter {
|
|||||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
return encodePresent(
|
return encodePresent(
|
||||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
onPresented: onPresented,
|
|
||||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
keepAlive: [luma, chroma, pixelBuffer]
|
keepAlive: [luma, chroma, pixelBuffer]
|
||||||
@@ -729,58 +584,17 @@ public final class MetalVideoPresenter {
|
|||||||
func renderPlanar(
|
func renderPlanar(
|
||||||
_ planes: WaveletPlanes,
|
_ planes: WaveletPlanes,
|
||||||
presentAtMediaTime: CFTimeInterval? = nil,
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
into drawable: CAMetalDrawable? = nil,
|
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
stagingLock.lock()
|
stagingLock.lock()
|
||||||
let targetFromLayout = drawableTarget
|
let targetFromLayout = drawableTarget
|
||||||
#if os(macOS)
|
|
||||||
let surfaceMode = surfacePresentsStaged
|
|
||||||
#endif
|
|
||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
configure(hdr: false)
|
||||||
// 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
|
|
||||||
var csc = planes.csc
|
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(
|
return encodePresent(
|
||||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
onPresented: onPresented,
|
|
||||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
// 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).
|
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||||
@@ -792,132 +606,12 @@ 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
|
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||||
/// the present and the on-glass callback.
|
/// the present and the on-glass callback.
|
||||||
///
|
|
||||||
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
|
|
||||||
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
|
|
||||||
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
|
|
||||||
/// layer) its pixel format can lag the pipeline's attachment format — encoding would be a
|
|
||||||
/// Metal validation failure. The guard returns false instead: the drawable drops back to
|
|
||||||
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
|
|
||||||
private func encodePresent(
|
private func encodePresent(
|
||||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||||
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||||
onPresented: ((Int64?) -> Void)?,
|
|
||||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||||
@@ -930,17 +624,11 @@ public final class MetalVideoPresenter {
|
|||||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
? targetFromLayout : decodedSize
|
? targetFromLayout : decodedSize
|
||||||
// Under a provided (link-vended) drawable this sizes the NEXT vend — the one in hand
|
|
||||||
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
|
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||||
#endif
|
#endif
|
||||||
if let providedDrawable,
|
guard let drawable = layer.nextDrawable(),
|
||||||
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
|
||||||
return false // config outran the vend (HDR flip) — next vend has the new format
|
|
||||||
}
|
|
||||||
guard let drawable = providedDrawable ?? layer.nextDrawable(),
|
|
||||||
let commandBuffer = queue.makeCommandBuffer()
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ struct WaveletLayout {
|
|||||||
|
|
||||||
let width: Int
|
let width: Int
|
||||||
let height: 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 alignedWidth: Int
|
||||||
let alignedHeight: Int
|
let alignedHeight: Int
|
||||||
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||||
@@ -62,10 +59,9 @@ struct WaveletLayout {
|
|||||||
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||||
func levelHeight(_ level: Int) -> Int { (alignedHeight / 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.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
self.chroma444 = chroma444
|
|
||||||
let align = { (v: Int) in
|
let align = { (v: Int) in
|
||||||
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||||
}
|
}
|
||||||
@@ -82,7 +78,7 @@ struct WaveletLayout {
|
|||||||
let ah = alignedHeight
|
let ah = alignedHeight
|
||||||
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||||
for component in 0..<3 {
|
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 {
|
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||||
let levelW = (aw / 2) >> level
|
let levelW = (aw / 2) >> level
|
||||||
let levelH = (ah / 2) >> level
|
let levelH = (ah / 2) >> level
|
||||||
@@ -112,9 +108,6 @@ struct ParsedWaveletFrame {
|
|||||||
var decodedBlocks: Int
|
var decodedBlocks: Int
|
||||||
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||||
var bt2020: Bool
|
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
|
var fullRange: Bool
|
||||||
|
|
||||||
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
/// 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).
|
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||||
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||||
var state = ParseState()
|
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
|
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||||
return false
|
return false
|
||||||
@@ -216,7 +203,6 @@ enum WaveletBitstream {
|
|||||||
var totalBlocks = 0
|
var totalBlocks = 0
|
||||||
var decodedBlocks = 0
|
var decodedBlocks = 0
|
||||||
var bt2020 = false
|
var bt2020 = false
|
||||||
var pq = false
|
|
||||||
var fullRange = false
|
var fullRange = false
|
||||||
var sawSOF = false
|
var sawSOF = false
|
||||||
|
|
||||||
@@ -234,27 +220,22 @@ enum WaveletBitstream {
|
|||||||
// siting[31].
|
// siting[31].
|
||||||
let code = (word1 >> 24) & 0x3
|
let code = (word1 >> 24) & 0x3
|
||||||
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
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 w = Int(word0 & 0x3fff) + 1
|
||||||
let h = Int((word0 >> 14) & 0x3fff) + 1
|
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||||
guard w >= 2, h >= 2, chroma444 || (w % 2 == 0 && h % 2 == 0) else {
|
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
||||||
return false
|
|
||||||
}
|
|
||||||
if sawSOF {
|
if sawSOF {
|
||||||
// One frame, one geometry — a second SOF must agree.
|
// One frame, one geometry — a second SOF must agree.
|
||||||
guard layout?.width == w, layout?.height == h,
|
guard layout?.width == w, layout?.height == h else { return false }
|
||||||
layout?.chroma444 == chroma444
|
|
||||||
else { return false }
|
|
||||||
} else {
|
} else {
|
||||||
sawSOF = true
|
sawSOF = true
|
||||||
let l = WaveletLayout(width: w, height: h, chroma444: chroma444)
|
let l = WaveletLayout(width: w, height: h)
|
||||||
layout = l
|
layout = l
|
||||||
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||||
|
payload.reserveCapacity(64 * 1024 / 4)
|
||||||
totalBlocks = Int(word1 & 0xff_ffff)
|
totalBlocks = Int(word1 & 0xff_ffff)
|
||||||
bt2020 = (word1 >> 29) & 1 != 0
|
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
|
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||||
}
|
}
|
||||||
pos += 8
|
pos += 8
|
||||||
@@ -271,15 +252,9 @@ enum WaveletBitstream {
|
|||||||
if offsets[blockIndex] == .max {
|
if offsets[blockIndex] == .max {
|
||||||
offsets[blockIndex] = UInt32(payload.count)
|
offsets[blockIndex] = UInt32(payload.count)
|
||||||
decodedBlocks += 1
|
decodedBlocks += 1
|
||||||
// Bulk-copy the packet's coefficient words in one memcpy rather than
|
payload.reserveCapacity(payload.count + payloadWords)
|
||||||
// word-by-word. All Apple platforms are little-endian, so the wire's LE
|
for w in 0..<payloadWords {
|
||||||
// u32s land in the [UInt32] buffer verbatim; memcpy has no alignment
|
payload.append(loadWord(base, pos + w * 4))
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if layout != nil {
|
} else if layout != nil {
|
||||||
@@ -305,7 +280,7 @@ enum WaveletBitstream {
|
|||||||
return ParsedWaveletFrame(
|
return ParsedWaveletFrame(
|
||||||
layout: layout, offsets: offsets, payload: payload,
|
layout: layout, offsets: offsets, payload: payload,
|
||||||
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
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 cb: MTLTexture
|
||||||
public let cr: MTLTexture
|
public let cr: MTLTexture
|
||||||
public let csc: CscUniform
|
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 width: Int { y.width }
|
||||||
public var height: Int { y.height }
|
public var height: Int { y.height }
|
||||||
}
|
}
|
||||||
@@ -378,8 +351,6 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
private var slots: [Slot] = []
|
private var slots: [Slot] = []
|
||||||
private var nextSlot = 0
|
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
|
/// 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.
|
/// 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)
|
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height
|
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
||||||
|| layout?.chroma444 != frame.layout.chroma444 || hdr16 != frame.pq {
|
guard rebuild(layout: frame.layout) else { return false }
|
||||||
guard rebuild(layout: frame.layout, hdr16: frame.pq) else { return false }
|
|
||||||
}
|
}
|
||||||
guard let layout, !slots.isEmpty 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)
|
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||||
for level in 0..<WaveletLayout.decompositionLevels {
|
for level in 0..<WaveletLayout.decompositionLevels {
|
||||||
for component in 0..<3 {
|
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 {
|
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||||
let meta = layout.blockMeta[component][level][band]
|
let meta = layout.blockMeta[component][level][band]
|
||||||
let w = layout.levelWidth(level)
|
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 grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||||
let group = MTLSize(width: 64, height: 1, depth: 1)
|
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||||
if inputLevel == 0 {
|
if inputLevel == 0 {
|
||||||
// Final full-res pass: luma only in 4:2:0 (chroma finished at level 1); all
|
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
||||||
// three components in 4:4:4 (chroma runs the full pyramid like luma).
|
|
||||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||||
let components = layout.chroma444 ? 3 : 1
|
idwt.setTexture(coefficients[0][0], index: 0)
|
||||||
for component in 0..<components {
|
idwt.setTexture(slot.y, index: 1)
|
||||||
idwt.setTexture(coefficients[component][0], index: 0)
|
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||||
let out = component == 0 ? slot.y : (component == 1 ? slot.cb : slot.cr)
|
|
||||||
idwt.setTexture(out, index: 1)
|
|
||||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
for component in 0..<3 {
|
for component in 0..<3 {
|
||||||
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
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.
|
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||||
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||||
@@ -548,9 +513,7 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
let planes = WaveletPlanes(
|
let planes = WaveletPlanes(
|
||||||
y: slot.y, cb: slot.cb, cr: slot.cr,
|
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||||
csc: CscRows.rows(
|
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
||||||
frame.cscSignal, depth: frame.pq ? 10 : 8, msbPacked: frame.pq),
|
|
||||||
pq: frame.pq)
|
|
||||||
cmd.addCompletedHandler { buffer in
|
cmd.addCompletedHandler { buffer in
|
||||||
completion(buffer.error == nil ? planes : nil)
|
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
|
/// (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.
|
/// 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(
|
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 coeff: [[MTLTexture]] = []
|
||||||
var lls: [[MTLTexture]] = []
|
var lls: [[MTLTexture]] = []
|
||||||
for component in 0..<3 {
|
for component in 0..<3 {
|
||||||
@@ -597,22 +560,19 @@ public final class MetalWaveletDecoder {
|
|||||||
|
|
||||||
var newSlots: [Slot] = []
|
var newSlots: [Slot] = []
|
||||||
for i in 0..<Self.ringDepth {
|
for i in 0..<Self.ringDepth {
|
||||||
let planeFormat: MTLPixelFormat = newHdr16 ? .r16Unorm : .r8Unorm
|
|
||||||
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
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.usage = [.shaderRead, .shaderWrite]
|
||||||
desc.storageMode = .private
|
desc.storageMode = .private
|
||||||
let t = self.device.makeTexture(descriptor: desc)
|
let t = self.device.makeTexture(descriptor: desc)
|
||||||
t?.label = name
|
t?.label = name
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
let cw = newLayout.chroma444 ? newLayout.width : newLayout.width / 2
|
|
||||||
let ch = newLayout.chroma444 ? newLayout.height : newLayout.height / 2
|
|
||||||
guard
|
guard
|
||||||
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||||
let cb = plane(cw, ch, "pyrowave Cb[\(i)]"),
|
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
||||||
let cr = plane(cw, ch, "pyrowave Cr[\(i)]"),
|
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
||||||
let offsets = device.makeBuffer(
|
let offsets = device.makeBuffer(
|
||||||
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||||
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||||
@@ -625,7 +585,6 @@ public final class MetalWaveletDecoder {
|
|||||||
slots = newSlots
|
slots = newSlots
|
||||||
nextSlot = 0
|
nextSlot = 0
|
||||||
layout = newLayout
|
layout = newLayout
|
||||||
hdr16 = newHdr16
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
|
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit
|
||||||
// (explicit VTDecompressionSession decode → CAMetalLayer) is the default — deadline-paced
|
// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the
|
||||||
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
// default; stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG
|
||||||
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
// fallback. The views own the platform bits — capture, window/scale tracking, and constructing the
|
||||||
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
// display link — and delegate the shared presenter lifecycle here.
|
||||||
// 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 (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink) —
|
|
||||||
// and delegate the shared presenter lifecycle here.
|
|
||||||
//
|
//
|
||||||
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
||||||
|
|
||||||
@@ -30,17 +26,15 @@ public final class DisplayLinkProxy: NSObject {
|
|||||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
|
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||||
/// pacing (`PresentPacing` — see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
|
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||||
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
|
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||||
/// CAMetalDisplayLink-vended drawables (deadline pacing — iOS/tvOS only; see `PresentPacing`'s
|
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||||
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
|
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||||
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
|
|
||||||
enum PresenterChoice: Equatable {
|
enum PresenterChoice: Equatable {
|
||||||
case stage1
|
case stage1
|
||||||
case stage2
|
case stage2
|
||||||
case stage3
|
case stage3
|
||||||
case stage4
|
|
||||||
|
|
||||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||||
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||||
@@ -48,159 +42,34 @@ enum PresenterChoice: Equatable {
|
|||||||
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||||
/// freeze-prone fallback.
|
/// freeze-prone fallback.
|
||||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
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. "stage4" resolves only on
|
|
||||||
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
|
|
||||||
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
|
|
||||||
/// deliberately validated there — a synced "stage4" value maps back to the platform default.
|
|
||||||
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
|
||||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||||
switch raw {
|
switch raw {
|
||||||
case "stage1": return allowStage1 ? .stage1 : nil
|
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||||
case "stage2": return .stage2
|
case "stage2": return .stage2
|
||||||
case "stage3": return .stage3
|
case "stage3": return .stage3
|
||||||
case "stage4":
|
default: return platformDefault
|
||||||
#if os(macOS)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
return .stage4
|
|
||||||
#endif
|
|
||||||
default: return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
/// 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
|
||||||
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||||
/// (`displaySyncEnabled` is macOS-only API), and at stream rate ≈ panel rate — an Apple TV's
|
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||||
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
|
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||||
/// steering the panel to the stream — that queue's depth is STICKY: one burst fills it and,
|
|
||||||
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
|
|
||||||
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
|
|
||||||
/// read ~30 ms display on arrival (~3 refreshes of queue), 22–28 ms glass-gated at depth 2
|
|
||||||
/// (a standing queue of 2 — the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
|
|
||||||
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
|
|
||||||
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
|
|
||||||
/// drawable per refresh, presented the moment a frame decodes — the queue cannot exist and
|
|
||||||
/// nothing waits on callbacks (see `PresentPacing.deadline`).
|
|
||||||
///
|
|
||||||
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
|
|
||||||
/// (design/apple-presentation-rebuild.md — the engine is field-proven on iOS and strictly
|
|
||||||
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
|
|
||||||
/// fallback lever if a TV-specific issue surfaces). 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.
|
|
||||||
static var platformDefault: PresenterChoice {
|
static var platformDefault: PresenterChoice {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(tvOS)
|
||||||
.stage4
|
.stage3
|
||||||
#else
|
#else
|
||||||
.stage2
|
.stage2
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user's presentation INTENT — what replaced the visible stage picker in the 2026-07
|
|
||||||
/// rebuild (design/apple-presentation-rebuild.md). Two intents, one engine per platform:
|
|
||||||
///
|
|
||||||
/// - `.latency` (the default): every frame shows as soon as the display can take it — the
|
|
||||||
/// newest-wins zero-queue store; network/decode jitter appears as the occasional repeat or
|
|
||||||
/// drop. This is the configuration the whole 2026-07 pacing saga optimized.
|
|
||||||
/// - `.smooth(buffer:)`: a small deliberate jitter buffer (`FrameStore.fifo`) evens the present
|
|
||||||
/// cadence at the cost of `buffer` refresh intervals of added display latency — which the HUD
|
|
||||||
/// SHOWS (only the OS floor is shaved, never the user's chosen buffer). `buffer` ∈ 1…3;
|
|
||||||
/// the "Automatic" setting (stored 0) currently maps to 2.
|
|
||||||
///
|
|
||||||
/// Mechanism stays internal: intents map onto `PresentPacing`/`FrameStore.Policy` per platform
|
|
||||||
/// in `SessionPresenter.start`; the stage ladder survives only as the PUNKTFUNK_PRESENTER debug
|
|
||||||
/// env lever. Internal (not private) for unit tests.
|
|
||||||
enum PresentPriority: Equatable {
|
|
||||||
case latency
|
|
||||||
case smooth(buffer: Int)
|
|
||||||
|
|
||||||
/// Resolve from the persisted settings: `DefaultsKey.presentPriority` ("latency" default;
|
|
||||||
/// anything but "smooth" — unset, garbage, a synced unknown future value — falls back to
|
|
||||||
/// latency) and `DefaultsKey.smoothBuffer` (0/out-of-range = Automatic = 2).
|
|
||||||
static func resolve(setting: String?, bufferSetting: Int?) -> PresentPriority {
|
|
||||||
guard setting == "smooth" else { return .latency }
|
|
||||||
let raw = bufferSetting ?? 0
|
|
||||||
return .smooth(buffer: (1...3).contains(raw) ? raw : 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The frame hand-off policy this intent runs (see `FrameStore`).
|
|
||||||
var storePolicy: FrameStorePolicy {
|
|
||||||
switch self {
|
|
||||||
case .latency: return .newestWins
|
|
||||||
case .smooth(let buffer): return .fifo(capacity: buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class SessionPresenter {
|
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 == .stage4 { return .deadline }
|
|
||||||
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): 1 everywhere.
|
|
||||||
///
|
|
||||||
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
|
|
||||||
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
|
||||||
/// 22–28 ms vs depth 1's 14: any second gate slot becomes a STANDING queue — a burst fills
|
|
||||||
/// it, and with presents and latches then running at the same rate the occupancy never
|
|
||||||
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
|
|
||||||
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
|
|
||||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
|
||||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
|
||||||
///
|
|
||||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
|
||||||
/// stays reproducible on-device; macOS is 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. 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 }
|
|
||||||
return 1
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pump: StreamPump?
|
private var pump: StreamPump?
|
||||||
private var stage2: Stage2Pipeline?
|
private var stage2: Stage2Pipeline?
|
||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
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?
|
private var connection: PunktfunkConnection?
|
||||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
/// 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
|
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||||
@@ -224,7 +93,6 @@ final class SessionPresenter {
|
|||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
presentFloorMeter: LatencyMeter? = nil,
|
|
||||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?,
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
@@ -233,79 +101,43 @@ final class SessionPresenter {
|
|||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||||
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||||
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
||||||
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||||
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||||
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||||
// value is deliberately ignored). The user-facing choice is the INTENT
|
|
||||||
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
|
|
||||||
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
|
|
||||||
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
|
|
||||||
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
|
|
||||||
// fallback).
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let allowStage1 = true
|
let allowStage1 = true
|
||||||
#else
|
#else
|
||||||
let allowStage1 = false
|
let allowStage1 = false
|
||||||
#endif
|
#endif
|
||||||
let explicit = PresenterChoice.explicit(
|
let choice = PresenterChoice.resolve(
|
||||||
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||||
allowStage1: allowStage1)
|
allowStage1: allowStage1)
|
||||||
let choice = explicit ?? PresenterChoice.platformDefault
|
|
||||||
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
|
|
||||||
let priority = PresentPriority.resolve(
|
|
||||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
|
|
||||||
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
|
|
||||||
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
|
|
||||||
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
|
|
||||||
// display, so the FIFO alone provides the buffering.
|
|
||||||
#if os(macOS)
|
|
||||||
let vsyncPaced = priority != .latency && pacing == .arrival
|
|
||||||
#else
|
|
||||||
let vsyncPaced = false
|
|
||||||
#endif
|
|
||||||
if choice != .stage1,
|
if choice != .stage1,
|
||||||
let pipeline = Stage2Pipeline(
|
let pipeline = Stage2Pipeline(
|
||||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
presentFloorMeter: presentFloorMeter,
|
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||||
pacing: pacing,
|
|
||||||
gateDepth: Self.gateDepth(
|
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
|
||||||
storePolicy: priority.storePolicy,
|
|
||||||
vsyncPaced: vsyncPaced) {
|
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||||
baseLayer.addSublayer(metal)
|
baseLayer.addSublayer(metal)
|
||||||
metalLayer = 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
|
stage2 = pipeline
|
||||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||||
// link's own report of the current refresh period (tracks VRR rate changes).
|
// link's own report of the current refresh period (tracks VRR rate changes).
|
||||||
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
|
let proxy = DisplayLinkProxy { [weak self] link in
|
||||||
// clock, and every one of its updates re-checks the ring, which IS the retry tick —
|
self?.stage2?.renderTick(
|
||||||
// a second link would only fight it over the frame-rate hint.
|
targetMediaTime: link.targetTimestamp,
|
||||||
if pacing != .deadline {
|
period: link.targetTimestamp - link.timestamp)
|
||||||
let proxy = DisplayLinkProxy { [weak self] link in
|
|
||||||
self?.stage2?.renderTick(
|
|
||||||
targetMediaTime: link.targetTimestamp,
|
|
||||||
period: link.targetTimestamp - link.timestamp)
|
|
||||||
}
|
|
||||||
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
|
||||||
link.add(to: .main, forMode: .common)
|
|
||||||
stage2Link = link
|
|
||||||
}
|
}
|
||||||
|
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
stage2Link = link
|
||||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||||
pipeline.start(
|
pipeline.start(
|
||||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||||
@@ -333,12 +165,7 @@ final class SessionPresenter {
|
|||||||
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
||||||
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
||||||
private func syncFrameRate(hz: UInt32) {
|
private func syncFrameRate(hz: UInt32) {
|
||||||
guard hz > 0 else { return }
|
guard hz > 0, let link = stage2Link else { return }
|
||||||
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
|
|
||||||
// applied from the link's own thread — see Stage2Pipeline.setFrameRateHint). A no-op
|
|
||||||
// under arrival/glass pacing, where the CADisplayLink below is the one hinted link.
|
|
||||||
stage2?.setFrameRateHint(hz: Float(hz))
|
|
||||||
guard let link = stage2Link else { return }
|
|
||||||
let hzF = Float(hz)
|
let hzF = Float(hz)
|
||||||
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -397,12 +224,6 @@ final class SessionPresenter {
|
|||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = contentsScale
|
metalLayer.contentsScale = contentsScale
|
||||||
metalLayer.frame = snapped
|
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()
|
CATransaction.commit()
|
||||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
// 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
|
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||||
@@ -430,31 +251,6 @@ final class SessionPresenter {
|
|||||||
contentSize = size
|
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
|
/// 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.
|
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
@@ -468,11 +264,6 @@ final class SessionPresenter {
|
|||||||
stage2 = nil
|
stage2 = nil
|
||||||
metalLayer?.removeFromSuperlayer()
|
metalLayer?.removeFromSuperlayer()
|
||||||
metalLayer = nil
|
metalLayer = nil
|
||||||
#if os(macOS)
|
|
||||||
surfaceLayer?.removeFromSuperlayer()
|
|
||||||
surfaceLayer = nil
|
|
||||||
surfacePresentsActive = false
|
|
||||||
#endif
|
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,10 @@
|
|||||||
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||||
// period ahead by construction, falling back to immediate when the link data is stale — a
|
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||||
// schedule can never sit far in the future holding drawables hostage.
|
// schedule can never sit far in the future holding drawables hostage.
|
||||||
// • Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by
|
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||||
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||||
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
|
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
||||||
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
|
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||||
// CAMetalDisplayLink-vended drawables the moment a frame decodes — deadline pacing, where the
|
|
||||||
// queue cannot exist at all — see PresentPacing's doc for the full rationale. Under deadline
|
|
||||||
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
|
|
||||||
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
|
|
||||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||||
//
|
//
|
||||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||||
@@ -44,7 +40,6 @@ import Foundation
|
|||||||
import Metal
|
import Metal
|
||||||
import PunktfunkShared
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import os
|
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||||
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
||||||
@@ -52,127 +47,33 @@ import os
|
|||||||
/// stdout is the cheapest reliable capture channel.
|
/// stdout is the cheapest reliable capture channel.
|
||||||
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||||
|
|
||||||
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") — the
|
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
||||||
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
||||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
private final class ReadyRing: @unchecked Sendable {
|
||||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
|
||||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
|
||||||
|
|
||||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
|
||||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
|
||||||
/// replaced the visible stage picker):
|
|
||||||
///
|
|
||||||
/// - `.newestWins` (Prioritize lowest latency, the default): a 1-slot ring — the decoder
|
|
||||||
/// overwrites (drops the older undisplayed frame), the render thread takes-and-clears. Zero
|
|
||||||
/// store by construction: any deeper app-held buffer ahead of a latch-paced display becomes a
|
|
||||||
/// STANDING queue costing one full refresh per slot, forever (the depth-2 gate post-mortem —
|
|
||||||
/// see SessionPresenter.gateDepth).
|
|
||||||
/// - `.fifo(capacity: K)` (Prioritize smoothness): a small deliberate jitter buffer. The
|
|
||||||
/// decoder appends; overflow drops the OLDEST (bounded added latency — the newest keeps
|
|
||||||
/// flowing); the render thread pops the oldest ONE per present opportunity, so the cadence is
|
|
||||||
/// the display's. `take` withholds frames until the buffer has PREROLLED to capacity once —
|
|
||||||
/// without preroll a steady stream drains every frame on arrival and headroom never builds —
|
|
||||||
/// and re-arms preroll when it runs dry (an underflow: the previous frame persists on glass,
|
|
||||||
/// a repeat by omission, while headroom rebuilds). Each buffered frame ≈ one refresh interval
|
|
||||||
/// of jitter absorbed for one interval of added display latency, which the metrics SHOW —
|
|
||||||
/// only the OS present floor is shaved from the HUD, never the user's chosen buffer.
|
|
||||||
///
|
|
||||||
/// Sendable; lock-guarded — decoder callbacks and the render thread cross here.
|
|
||||||
public enum FrameStorePolicy: Sendable, Equatable {
|
|
||||||
case newestWins
|
|
||||||
case fifo(capacity: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
public final class FrameStore<Frame>: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let capacity: Int // 1 = newest-wins semantics
|
private var frame: ReadyFrame?
|
||||||
private let isFifo: Bool
|
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
||||||
private var frames: [Frame] = []
|
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
||||||
private var prerolled = false
|
|
||||||
/// Submissions since the last `drainSubmitted` — the decode rate for the pf-present line.
|
|
||||||
private var submitted = 0
|
private var submitted = 0
|
||||||
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
|
func submit(_ f: ReadyFrame) {
|
||||||
/// runs-dry that re-armed preroll.
|
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
||||||
private var overflowDrops = 0
|
|
||||||
private var underflows = 0
|
|
||||||
|
|
||||||
public init(policy: FrameStorePolicy) {
|
|
||||||
switch policy {
|
|
||||||
case .newestWins:
|
|
||||||
capacity = 1
|
|
||||||
isFifo = false
|
|
||||||
case .fifo(let k):
|
|
||||||
capacity = max(1, k)
|
|
||||||
isFifo = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func submit(_ f: Frame) {
|
|
||||||
lock.lock()
|
|
||||||
if isFifo {
|
|
||||||
frames.append(f)
|
|
||||||
if frames.count > capacity {
|
|
||||||
frames.removeFirst() // oldest goes — bounded latency, the newest keeps flowing
|
|
||||||
overflowDrops += 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
frames = [f] // newest wins; the replaced frame is the intended drop point
|
|
||||||
}
|
|
||||||
submitted += 1
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func drainSubmitted() -> Int {
|
func drainSubmitted() -> Int {
|
||||||
lock.lock()
|
lock.lock(); defer { lock.unlock() }
|
||||||
defer { lock.unlock() }
|
let n = submitted; submitted = 0; return n
|
||||||
let n = submitted
|
|
||||||
submitted = 0
|
|
||||||
return n
|
|
||||||
}
|
}
|
||||||
|
func take() -> ReadyFrame? {
|
||||||
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
|
lock.lock(); defer { lock.unlock() }
|
||||||
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
|
let f = frame; frame = nil; return f
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let out = (overflowDrops, underflows)
|
|
||||||
overflowDrops = 0
|
|
||||||
underflows = 0
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
/// Return a frame the display link took but could not present (a transient `nextDrawable`
|
||||||
func take() -> Frame? {
|
/// failure). Kept only while the slot is still empty — a newer decoded frame wins, so
|
||||||
|
/// newest-ready ordering is preserved. Without this, a failed render silently LOSES the
|
||||||
|
/// frame, and under the host's infinite GOP a static scene sends no replacement until the
|
||||||
|
/// next damage — the stale picture would persist.
|
||||||
|
func putBack(_ f: ReadyFrame) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
if frame == nil { frame = f }
|
||||||
if isFifo {
|
|
||||||
if !prerolled {
|
|
||||||
guard frames.count >= capacity else { return nil } // still building headroom
|
|
||||||
prerolled = true
|
|
||||||
}
|
|
||||||
guard !frames.isEmpty else {
|
|
||||||
underflows += 1 // ran dry — repeat by omission, rebuild headroom
|
|
||||||
prerolled = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return frames.removeFirst()
|
|
||||||
}
|
|
||||||
let f = frames.first
|
|
||||||
frames.removeAll(keepingCapacity: true)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a frame the render thread took but could not present (no drawable yet, or a
|
|
||||||
/// transient render failure). Newest-wins keeps it only while the slot is still empty — a
|
|
||||||
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
|
|
||||||
/// capacity+1 is trimmed by the next submit). Without this, a failed present silently LOSES
|
|
||||||
/// the frame, and under the host's infinite GOP a static scene sends no replacement until
|
|
||||||
/// the next damage — the stale picture would persist.
|
|
||||||
func putBack(_ f: Frame) {
|
|
||||||
lock.lock()
|
|
||||||
if isFifo {
|
|
||||||
frames.insert(f, at: 0)
|
|
||||||
} else if frames.isEmpty {
|
|
||||||
frames = [f]
|
|
||||||
}
|
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,211 +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
|
/// 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.
|
/// 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
|
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||||
/// while the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable
|
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||||
/// per refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
/// 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:
|
/// 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
|
/// 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
|
/// 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
|
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||||
/// "fixed-interval" jitter reports).
|
/// "fixed-interval" jitter reports).
|
||||||
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
|
||||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
/// 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
|
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||||
/// explicit, correct frame drops. The residual cost: presents serialize on the on-glass
|
/// explicit, correct frame drops.
|
||||||
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
|
|
||||||
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
|
|
||||||
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only — see
|
|
||||||
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
|
|
||||||
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
|
|
||||||
/// it with the newest decoded frame THE MOMENT either half arrives — usually the frame, into
|
|
||||||
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
|
|
||||||
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
|
|
||||||
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
|
|
||||||
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
|
|
||||||
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
|
|
||||||
///
|
|
||||||
/// 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 {
|
public enum PresentPacing: Sendable {
|
||||||
case arrival
|
case arrival
|
||||||
case glass
|
case glass
|
||||||
case deadline
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Newest-wins 1-slot hand-off box (the generic sibling of `ReadyRing`): deadline pacing's
|
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||||
/// drawable stash — the link thread `put`s each update's vended drawable (replacing an
|
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||||
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
|
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||||
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
|
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||||
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
|
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||||
/// Sendable; lock-guarded.
|
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||||
final class LatestBox<T>: @unchecked Sendable {
|
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||||
private let lock = NSLock()
|
/// releaser runs on a Metal callback thread.
|
||||||
private var value: T?
|
|
||||||
func put(_ v: T) { lock.lock(); value = v; lock.unlock() }
|
|
||||||
func putBack(_ v: T) {
|
|
||||||
lock.lock()
|
|
||||||
if value == nil { value = v }
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
func take() -> T? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let v = value
|
|
||||||
value = nil
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
|
|
||||||
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
|
|
||||||
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
|
|
||||||
/// floor is PINNED at the stream rate — no idle ramp-down: with a low floor the link idles toward
|
|
||||||
/// it on a static scene (infinite GOP ⇒ no frames), and the first damage frame after idle would
|
|
||||||
/// wait out a slow tick before it could present. Empty wakes at stream rate are near-free; the
|
|
||||||
/// PANEL still idles via VRR because no presents happen. Sendable; lock-guarded.
|
|
||||||
private final class FrameRateHint: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var pending: CAFrameRateRange?
|
|
||||||
func stage(hz: Float) {
|
|
||||||
guard hz > 0 else { return }
|
|
||||||
lock.lock()
|
|
||||||
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
func drain() -> CAFrameRateRange? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let p = pending
|
|
||||||
pending = nil
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
|
|
||||||
/// vended drawable (newest wins) and nudges the render thread — which also wakes on decoder
|
|
||||||
/// arrivals, so whichever half completes the (frame, drawable) pair triggers the present. Also
|
|
||||||
/// applies the staged frame-rate hint from the link's own thread. Retained by the link thread's
|
|
||||||
/// closure (the link holds it weak); captures only the shared boxes, never the pipeline — the
|
|
||||||
/// same no-self-capture rule as the pump/render threads.
|
|
||||||
private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|
||||||
private let stash: LatestBox<CAMetalDrawable>
|
|
||||||
private let renderSignal: DispatchSemaphore
|
|
||||||
private let hint: FrameRateHint
|
|
||||||
private let stats: PresentDebugStats?
|
|
||||||
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
|
||||||
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
|
||||||
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
|
||||||
/// would read ~1 under direct-to-display, tracks VRR rate changes.
|
|
||||||
private let floorMeter: LatencyMeter?
|
|
||||||
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert —
|
|
||||||
/// reads 1 while vendLeadMs sits at ~2 periods ⇒ the scheduler ignores the request while
|
|
||||||
/// the layer is composited (the promotion hunt); reads 2 ⇒ the system clamped it outright.
|
|
||||||
private var loggedEffective = false
|
|
||||||
|
|
||||||
init(
|
|
||||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
|
||||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
|
|
||||||
) {
|
|
||||||
self.stash = stash
|
|
||||||
self.renderSignal = renderSignal
|
|
||||||
self.hint = hint
|
|
||||||
self.stats = stats
|
|
||||||
self.floorMeter = floorMeter
|
|
||||||
}
|
|
||||||
|
|
||||||
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
|
||||||
if let range = hint.drain(), link.preferredFrameRateRange != range {
|
|
||||||
link.preferredFrameRateRange = range
|
|
||||||
}
|
|
||||||
// Re-assert the minimum-latency request every update (cheap compare): it was set once
|
|
||||||
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
|
|
||||||
// thing the vendLeadMs stat exists to catch — belt and braces.
|
|
||||||
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
|
|
||||||
if !loggedEffective {
|
|
||||||
loggedEffective = true
|
|
||||||
let range = link.preferredFrameRateRange
|
|
||||||
let msg = String(
|
|
||||||
format: "deadline link up: effective preferredFrameLatency=%.2f "
|
|
||||||
+ "range=%.0f-%.0f preferred=%.0f",
|
|
||||||
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
|
|
||||||
presentLog.info("\(msg, privacy: .public)")
|
|
||||||
}
|
|
||||||
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
|
||||||
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
|
|
||||||
stats?.vendLead(ms: leadS * 1000)
|
|
||||||
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
|
|
||||||
// now − lead) — its 1 s p50 is the OS present floor SessionModel shaves off.
|
|
||||||
if leadS > 0, let floorMeter {
|
|
||||||
var ts = timespec()
|
|
||||||
clock_gettime(CLOCK_REALTIME, &ts)
|
|
||||||
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
|
||||||
floorMeter.record(
|
|
||||||
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
|
|
||||||
}
|
|
||||||
stash.put(update.drawable)
|
|
||||||
renderSignal.signal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
final class PresentGate: @unchecked Sendable {
|
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
|
static let staleAfter: CFTimeInterval = 0.1
|
||||||
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let capacity: Int
|
private var pending = false
|
||||||
/// Arm instants of the in-flight presents, oldest first (≤ `capacity` entries).
|
private var armedAt: CFTimeInterval = 0
|
||||||
private var armed: [CFTimeInterval] = []
|
|
||||||
private var forced = 0
|
private var forced = 0
|
||||||
|
|
||||||
/// `capacity` = the in-flight present budget (clamped to ≥ 1) — see the type doc.
|
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||||
init(capacity: Int = 1) {
|
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||||
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
|
|
||||||
/// display-link tick) retries with the freshest frame then.
|
/// display-link tick) retries with the freshest frame then.
|
||||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if armed.count >= capacity {
|
if pending {
|
||||||
// Full: reopen only by presuming the OLDEST in-flight present lost (its handler
|
guard now - armedAt > Self.staleAfter else { return false }
|
||||||
// never fired) rather than stalling the stream.
|
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||||
guard let oldest = armed.first, now - oldest > Self.staleAfter else { return false }
|
|
||||||
armed.removeFirst()
|
|
||||||
forced += 1
|
|
||||||
}
|
}
|
||||||
armed.append(now)
|
pending = true
|
||||||
|
armedAt = now
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One in-flight present reached glass (or was dropped, or its render failed before a present
|
/// The 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
|
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||||
/// lost present's handler firing late after its stale force-open can transiently over-admit
|
|
||||||
/// one flip, which the next glass callback corrects.
|
|
||||||
func release() {
|
func release() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if !armed.isEmpty { armed.removeFirst() }
|
pending = false
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,23 +184,13 @@ final class PresentGate: @unchecked Sendable {
|
|||||||
private final class PresentDebugStats: @unchecked Sendable {
|
private final class PresentDebugStats: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var last = CACurrentMediaTime()
|
private var last = CACurrentMediaTime()
|
||||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
|
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
||||||
private var maxRenderMs = 0.0
|
private var maxRenderMs = 0.0
|
||||||
private var lastGlassNs: Int64 = 0
|
private var lastGlassNs: Int64 = 0
|
||||||
private var glassDeltasMs: [Double] = []
|
private var glassDeltasMs: [Double] = []
|
||||||
/// Present-issue → on-glass delay per frame (system presentedTime minus the render call's
|
|
||||||
/// start) — the DIRECT decomposition of the display stage: ring/pairing wait lives upstream
|
|
||||||
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
|
|
||||||
/// a healthy latch reads under one period.
|
|
||||||
private var latchMs: [Double] = []
|
|
||||||
/// Deadline pacing: the link's own pipeline depth — `targetPresentationTimestamp - now` at
|
|
||||||
/// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can
|
|
||||||
/// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead
|
|
||||||
/// and one whole refresh of the display stage lives INSIDE the link, not in our pairing.
|
|
||||||
private var vendLeadMs: [Double] = []
|
|
||||||
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
/// 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
|
/// 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 inFlight = 0
|
||||||
private var maxInFlight = 0
|
private var maxInFlight = 0
|
||||||
|
|
||||||
@@ -458,14 +201,6 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
/// is normal, it just shows the gate working.
|
/// is normal, it just shows the gate working.
|
||||||
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
||||||
|
|
||||||
/// Deadline pacing: a decoded frame is waiting but the link hasn't vended this interval's
|
|
||||||
/// drawable yet — the frame presents on the link's next update. A high count just means
|
|
||||||
/// decode outruns the link's phase; the wait is bounded by one refresh.
|
|
||||||
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
|
|
||||||
|
|
||||||
/// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`).
|
|
||||||
func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() }
|
|
||||||
|
|
||||||
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if rendered {
|
if rendered {
|
||||||
@@ -479,59 +214,40 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presented(atNs: Int64?, issuedNs: Int64) {
|
func presented(atNs: Int64?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||||
if let atNs {
|
if let atNs {
|
||||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||||
lastGlassNs = atNs
|
lastGlassNs = atNs
|
||||||
latchMs.append(Double(atNs - issuedNs) / 1e6)
|
|
||||||
} else {
|
} else {
|
||||||
dropped += 1
|
dropped += 1
|
||||||
}
|
}
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
|
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
guard now - last >= 1 else { lock.unlock(); return }
|
guard now - last >= 1 else { lock.unlock(); return }
|
||||||
last = now
|
last = now
|
||||||
let decoded = ring.drainSubmitted()
|
let decoded = ring.drainSubmitted()
|
||||||
let smoothing = ring.drainSmoothing()
|
|
||||||
let deltas = glassDeltasMs.sorted()
|
let deltas = glassDeltasMs.sorted()
|
||||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||||
let dMax = deltas.last ?? 0
|
let dMax = deltas.last ?? 0
|
||||||
let latches = latchMs.sorted()
|
|
||||||
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
|
|
||||||
let latchMax = latches.last ?? 0
|
|
||||||
let vends = vendLeadMs.sorted()
|
|
||||||
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
|
|
||||||
let vendMax = vends.last ?? 0
|
|
||||||
let inflightMax = maxInFlight
|
let inflightMax = maxInFlight
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||||
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
|
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||||
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
|
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||||
+ "vendLeadMs p50=%.2f max=%.2f",
|
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||||
decoded, ok, failed, empty, gated, noDrawable, dropped,
|
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||||
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
|
|
||||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
|
|
||||||
vendP50, vendMax)
|
|
||||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
|
|
||||||
maxRenderMs = 0
|
maxRenderMs = 0
|
||||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||||
latchMs.removeAll(keepingCapacity: true)
|
|
||||||
vendLeadMs.removeAll(keepingCapacity: true)
|
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
// Console.app first (the on-device readout — see presentLog); stdout only under the env
|
print(line)
|
||||||
// lever (the CLI client's capture channel).
|
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||||
presentLog.info("\(line, privacy: .public)")
|
|
||||||
if presentDebug {
|
|
||||||
print(line)
|
|
||||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,27 +274,15 @@ private final class DecodeReport: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public final class Stage2Pipeline {
|
public final class Stage2Pipeline {
|
||||||
private let ring: FrameStore<ReadyFrame>
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
private let decoder: VideoDecoder
|
private let decoder: VideoDecoder
|
||||||
/// Present cadence — `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
|
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||||
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
|
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||||
/// SessionPresenter resolves it per session (see PresentPacing).
|
|
||||||
private let pacing: 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
|
|
||||||
/// macOS smoothness: pace presents onto the vsync grid (`present(at:)` via the VsyncClock),
|
|
||||||
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
|
|
||||||
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
|
|
||||||
private let vsyncPaced: Bool
|
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
/// The measured OS present floor (deadline pacing only): each link update's vend→glass lead
|
|
||||||
/// is recorded here, and its p50 is what SessionModel subtracts from the shown display/e2e
|
|
||||||
/// numbers — the pipeline-depth cost no client controls (design/apple-presentation-rebuild.md).
|
|
||||||
private let presentFloorMeter: LatencyMeter?
|
|
||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||||
/// binds the live connection + arming flag (see DecodeReport).
|
/// binds the live connection + arming flag (see DecodeReport).
|
||||||
@@ -609,9 +313,6 @@ public final class Stage2Pipeline {
|
|||||||
private let vsyncClock = VsyncClock()
|
private let vsyncClock = VsyncClock()
|
||||||
private let renderStopped = DispatchSemaphore(value: 0)
|
private let renderStopped = DispatchSemaphore(value: 0)
|
||||||
private var renderJoinable = false
|
private var renderJoinable = false
|
||||||
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
|
|
||||||
/// Created unconditionally (cheap); only the deadline link thread drains it.
|
|
||||||
private let frameRateHint = FrameRateHint()
|
|
||||||
|
|
||||||
/// The Metal layer the hosting view installs + sizes.
|
/// The Metal layer the hosting view installs + sizes.
|
||||||
public var layer: CAMetalLayer { presenter.layer }
|
public var layer: CAMetalLayer { presenter.layer }
|
||||||
@@ -622,28 +323,19 @@ public final class Stage2Pipeline {
|
|||||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
/// 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
|
/// 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
|
/// 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
|
/// (glass-gated) present cadence — see PresentPacing.
|
||||||
/// in-flight present budget (see `SessionPresenter.gateDepth`).
|
|
||||||
public init?(
|
public init?(
|
||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
presentFloorMeter: LatencyMeter? = nil,
|
pacing: PresentPacing = .arrival
|
||||||
pacing: PresentPacing = .arrival,
|
|
||||||
gateDepth: Int = 1,
|
|
||||||
storePolicy: FrameStorePolicy = .newestWins,
|
|
||||||
vsyncPaced: Bool = false
|
|
||||||
) {
|
) {
|
||||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
self.gateDepth = gateDepth
|
|
||||||
self.vsyncPaced = vsyncPaced
|
|
||||||
self.ring = FrameStore(policy: storePolicy)
|
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
self.presentFloorMeter = presentFloorMeter
|
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
@@ -818,17 +510,6 @@ public final class Stage2Pipeline {
|
|||||||
pumpJoinable = true
|
pumpJoinable = true
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
|
|
||||||
// vends the drawables and its per-refresh updates co-drive the render thread — see
|
|
||||||
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
|
|
||||||
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
|
|
||||||
// streams to Console.app via presentLog — the on-device pacing decomposition).
|
|
||||||
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
|
|
||||||
if pacing == .deadline {
|
|
||||||
startDeadlinePresenter(debugStats: debugStats)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
||||||
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
||||||
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
||||||
@@ -843,21 +524,16 @@ public final class Stage2Pipeline {
|
|||||||
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||||
// Resolved once per session.
|
// Resolved once per session.
|
||||||
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
||||||
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling — the FIFO store must drain
|
let vsyncEnabled = presentMode == "vsync"
|
||||||
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
|
|
||||||
let vsyncPaced = vsyncPaced
|
|
||||||
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|
|
||||||
|| (presentMode != "immediate"
|
|| (presentMode != "immediate"
|
||||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||||
|
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||||
let vsyncClock = vsyncClock
|
let vsyncClock = vsyncClock
|
||||||
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
||||||
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
defer { renderStopped.signal() }
|
||||||
// macOS smoothness: the vsync this thread last presented onto — at most ONE present
|
|
||||||
// per vsync so the FIFO drains on the display's cadence. Thread-confined.
|
|
||||||
var lastPresentTarget: CFTimeInterval = 0
|
|
||||||
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||||
@@ -867,15 +543,6 @@ public final class Stage2Pipeline {
|
|||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Smoothness pacing: this vsync's present slot already taken — the frame stays
|
|
||||||
// in the store, and the next display-link tick re-signals. (Tolerance well under
|
|
||||||
// any refresh period; a stale clock ⇒ nil target ⇒ no dedup, present flows.)
|
|
||||||
if vsyncPaced, let t = vsyncClock.nextVsync(after: CACurrentMediaTime()),
|
|
||||||
abs(t - lastPresentTarget) < 0.002 {
|
|
||||||
debugStats?.gatedWake()
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||||
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
||||||
@@ -896,7 +563,6 @@ public final class Stage2Pipeline {
|
|||||||
let presentAt = vsyncEnabled
|
let presentAt = vsyncEnabled
|
||||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||||
let renderStarted = CACurrentMediaTime()
|
let renderStarted = CACurrentMediaTime()
|
||||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
|
||||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||||
@@ -914,7 +580,7 @@ public final class Stage2Pipeline {
|
|||||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||||
// so no skew offset applies.
|
// so no skew offset applies.
|
||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
debugStats?.presented(atNs: presentedNs)
|
||||||
}
|
}
|
||||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||||
@@ -933,8 +599,6 @@ public final class Stage2Pipeline {
|
|||||||
if !rendered {
|
if !rendered {
|
||||||
gate?.release() // no present registered — its handler will never fire
|
gate?.release() // no present registered — its handler will never fire
|
||||||
ring.putBack(frame)
|
ring.putBack(frame)
|
||||||
} else if vsyncPaced, let presentAt {
|
|
||||||
lastPresentTarget = presentAt // this vsync's slot is now taken
|
|
||||||
}
|
}
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
} }
|
} }
|
||||||
@@ -945,186 +609,22 @@ public final class Stage2Pipeline {
|
|||||||
renderThread.start()
|
renderThread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deadline pacing's present half (stage-4 — see `PresentPacing.deadline`): a
|
|
||||||
/// CAMetalDisplayLink on its own runloop thread vends ONE drawable per refresh into the
|
|
||||||
/// newest-wins stash, and the render thread pairs it with the newest decoded frame the
|
|
||||||
/// moment either half completes the pair — the common case is a decoded frame presenting
|
|
||||||
/// instantly into an already-vended drawable, which the system then latches at the upcoming
|
|
||||||
/// refresh (`preferredFrameLatency` 1). No image queue can form (one vended drawable in
|
|
||||||
/// flight, ever) and nothing serializes on the on-glass callback. An unpresented stashed
|
|
||||||
/// drawable is simply replaced by the next update (back to the layer's pool), so the stash
|
|
||||||
/// is never stale by more than a refresh while the link runs.
|
|
||||||
///
|
|
||||||
/// Threading mirrors the arrival/glass half: neither thread captures `self`; the link is
|
|
||||||
/// created, driven and invalidated entirely on its own thread (CAMetalDisplayLink is only
|
|
||||||
/// ever touched there — the frame-rate hint crosses via `FrameRateHint`); the link thread's
|
|
||||||
/// runloop iterations each drain an autorelease pool (a vended CAMetalDrawable is
|
|
||||||
/// autoreleased like a `nextDrawable()` one — see the render loop's identical rule); the
|
|
||||||
/// 100 ms runloop horizon is the stop-flag poll, so teardown is bounded without a join.
|
|
||||||
private func startDeadlinePresenter(debugStats: PresentDebugStats?) {
|
|
||||||
let token = token
|
|
||||||
let ring = ring
|
|
||||||
let renderSignal = renderSignal
|
|
||||||
let renderStopped = renderStopped
|
|
||||||
let presenter = presenter
|
|
||||||
let endToEndMeter = endToEndMeter
|
|
||||||
let displayMeter = displayMeter
|
|
||||||
let offsetNs = offsetNs
|
|
||||||
let hint = frameRateHint
|
|
||||||
let layer = presenter.layer
|
|
||||||
let stash = LatestBox<CAMetalDrawable>()
|
|
||||||
|
|
||||||
let floorMeter = presentFloorMeter
|
|
||||||
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
|
||||||
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
|
||||||
// drawableSize for the whole connect window: every vend fails allocation and the system
|
|
||||||
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
|
|
||||||
// refresh until the first frame arrives. Before that frame there is nothing to present
|
|
||||||
// anyway, and the first frame waits at most one refresh for the first vend.
|
|
||||||
let startLink: () -> Void = {
|
|
||||||
let linkThread = Thread {
|
|
||||||
let delegate = DeadlineLinkDelegate(
|
|
||||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
|
||||||
floorMeter: floorMeter)
|
|
||||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
|
||||||
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
|
||||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
|
||||||
link.delegate = delegate // weak — this closure is the strong ref
|
|
||||||
link.add(to: RunLoop.current, forMode: .default)
|
|
||||||
while !token.isStopped {
|
|
||||||
autoreleasepool {
|
|
||||||
_ = RunLoop.current.run(
|
|
||||||
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
link.invalidate()
|
|
||||||
}
|
|
||||||
linkThread.name = "punktfunk-stage4-link"
|
|
||||||
linkThread.qualityOfService = .userInteractive
|
|
||||||
linkThread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
let renderThread = Thread {
|
|
||||||
defer { renderStopped.signal() }
|
|
||||||
// Whether startLink ran — render-thread confined (only this thread triggers it).
|
|
||||||
var linkLive = false
|
|
||||||
// Per-iteration autorelease pool — same contract as the arrival/glass loop (the
|
|
||||||
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
|
|
||||||
while !token.isStopped { autoreleasepool {
|
|
||||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Present needs the PAIR — frame first. The frame drives the layer reconcile,
|
|
||||||
// which must run even when NO drawable is vended yet: the link vends from the
|
|
||||||
// layer's CURRENT config, so drawableSize/format have to be right before a vend
|
|
||||||
// can succeed at all (see reconcileLayer — the session-start bootstrap, where
|
|
||||||
// the layer still has its initial 0×0 size and every vend fails allocation).
|
|
||||||
guard !token.isStopped, let frame = ring.take() else {
|
|
||||||
debugStats?.emptyWake()
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch frame.image {
|
|
||||||
case .video(let pixelBuffer, let isHDR):
|
|
||||||
presenter.reconcileLayer(
|
|
||||||
decodedSize: CGSize(
|
|
||||||
width: CVPixelBufferGetWidth(pixelBuffer),
|
|
||||||
height: CVPixelBufferGetHeight(pixelBuffer)),
|
|
||||||
isHDR: isHDR)
|
|
||||||
case .planar(let planes):
|
|
||||||
presenter.reconcileLayer(
|
|
||||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
|
||||||
isHDR: planes.pq)
|
|
||||||
}
|
|
||||||
// First frame: the layer now has a real config — start vending (see startLink).
|
|
||||||
if !linkLive {
|
|
||||||
linkLive = true
|
|
||||||
startLink()
|
|
||||||
}
|
|
||||||
guard let drawable = stash.take() else {
|
|
||||||
// No vend yet (session start: the reconcile above just unblocked the
|
|
||||||
// allocator, the link's next update delivers; steady state: decode beat the
|
|
||||||
// link's phase). putBack keeps newest-wins — a fresher decode replaces this
|
|
||||||
// frame while it waits, and the update's signal retries the pairing.
|
|
||||||
ring.putBack(frame)
|
|
||||||
debugStats?.noDrawableWake()
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let renderStarted = CACurrentMediaTime()
|
|
||||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
|
||||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
|
||||||
let atNs = presentedNs
|
|
||||||
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
|
||||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
|
||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
|
||||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
|
||||||
}
|
|
||||||
let rendered: Bool
|
|
||||||
switch frame.image {
|
|
||||||
case .video(let pixelBuffer, let isHDR):
|
|
||||||
rendered = presenter.render(
|
|
||||||
pixelBuffer, isHDR: isHDR, into: drawable, onPresented: onGlass)
|
|
||||||
case .planar(let planes):
|
|
||||||
rendered = presenter.renderPlanar(
|
|
||||||
planes, into: drawable, onPresented: onGlass)
|
|
||||||
}
|
|
||||||
debugStats?.renderReturned(
|
|
||||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
|
||||||
if !rendered {
|
|
||||||
// The vended drawable is spent either way (an unused/mismatched one drops
|
|
||||||
// back to the pool); the frame retries on the link's next vend. A format
|
|
||||||
// mismatch (mid-session HDR flip caught between the layer reconfigure and
|
|
||||||
// the next vend) self-heals the same way — see encodePresent's guard.
|
|
||||||
ring.putBack(frame)
|
|
||||||
}
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
} }
|
|
||||||
}
|
|
||||||
renderThread.name = "punktfunk-stage2-render"
|
|
||||||
renderThread.qualityOfService = .userInteractive
|
|
||||||
renderJoinable = true
|
|
||||||
renderThread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
||||||
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
||||||
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
||||||
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
||||||
/// replacement frame. Arrival/glass pacing only — deadline sessions have no CADisplayLink
|
/// replacement frame.
|
||||||
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
|
||||||
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||||
vsyncClock.set(target: targetMediaTime, period: period)
|
vsyncClock.set(target: targetMediaTime, period: period)
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MAIN thread (SessionPresenter — session start + every layout/Reconfigure): hint the
|
|
||||||
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
|
|
||||||
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
|
|
||||||
/// is the hinted link.
|
|
||||||
public func setFrameRateHint(hz: Float) {
|
|
||||||
frameRateHint.stage(hz: hz)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
||||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||||
public func setDrawableTarget(_ size: CGSize) {
|
public func setDrawableTarget(_ size: CGSize) {
|
||||||
presenter.setDrawableTarget(size)
|
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`
|
/// 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
|
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||||
@@ -1169,7 +669,7 @@ public final class Stage2Pipeline {
|
|||||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||||
private static func makePyroWavePump(
|
private static func makePyroWavePump(
|
||||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||||
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||||
device: MTLDevice, queue: MTLCommandQueue,
|
device: MTLDevice, queue: MTLCommandQueue,
|
||||||
decodeMeter: LatencyMeter?,
|
decodeMeter: LatencyMeter?,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -1213,9 +713,7 @@ public final class Stage2Pipeline {
|
|||||||
let chunkAligned =
|
let chunkAligned =
|
||||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||||
let ptsNs = au.ptsNs
|
let ptsNs = au.ptsNs
|
||||||
// Decode stage starts at the PULL (matching the VT path's FrameContext —
|
let receivedNs = au.receivedNs
|
||||||
// receipt→pull is the HUD's separate client-queue term, ABI v9 split).
|
|
||||||
let receivedNs = au.pulledNs
|
|
||||||
let flags = au.flags
|
let flags = au.flags
|
||||||
let submitted = decoder.decode(
|
let submitted = decoder.decode(
|
||||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||||
|
|||||||
@@ -65,21 +65,19 @@ public enum Stage444Probe {
|
|||||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||||
|
|
||||||
var produced: OSType = 0
|
var produced: OSType = 0
|
||||||
// SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback
|
let done = DispatchSemaphore(value: 0)
|
||||||
// runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it
|
|
||||||
// replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's
|
|
||||||
// callback thread carries no QoS class, and the userInteractive connect Task blocked on
|
|
||||||
// it through the semaphore (a priority inversion). A one-shot 256×256 probe gains
|
|
||||||
// nothing from decode parallelism; the lazy statics still cache the result.
|
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session, sampleBuffer: sample,
|
session, sampleBuffer: sample,
|
||||||
flags: [], infoFlagsOut: nil
|
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
||||||
) { status, _, imageBuffer, _, _ in
|
) { status, _, imageBuffer, _, _ in
|
||||||
if status == noErr, let imageBuffer {
|
if status == noErr, let imageBuffer {
|
||||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||||
}
|
}
|
||||||
|
done.signal()
|
||||||
}
|
}
|
||||||
guard status == noErr else { return false }
|
guard status == noErr else { return false }
|
||||||
|
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||||
|
_ = done.wait(timeout: .now() + 1.0)
|
||||||
return produced == want || produced == fullRangeSibling
|
return produced == want || produced == fullRangeSibling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
|
|||||||
public struct ReadyFrame: @unchecked Sendable {
|
public struct ReadyFrame: @unchecked Sendable {
|
||||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||||
public let ptsNs: UInt64
|
public let ptsNs: UInt64
|
||||||
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded
|
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
|
||||||
/// through the decode via the frame refcon), in nanoseconds — the decode stage's start
|
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
|
||||||
/// point. (Named for its historical role; since the ABI v9 receipt split the true
|
/// didn't stamp receipt) — the decode-stage meter then drops the sample via its sanity guard.
|
||||||
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receipt→pull is the HUD's own
|
|
||||||
/// client-queue term.) 0 when unknown (a caller that didn't stamp) — the decode-stage meter
|
|
||||||
/// then drops the sample via its sanity guard.
|
|
||||||
public let receivedNs: Int64
|
public let receivedNs: Int64
|
||||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||||
public let decodedNs: Int64
|
public let decodedNs: Int64
|
||||||
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
var infoOut = VTDecodeInfoFlags()
|
var infoOut = VTDecodeInfoFlags()
|
||||||
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||||
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||||
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly
|
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||||
// receipt: both consumers — the decode-stage meter and the ABR decode signal — are
|
|
||||||
// specified from the pull, and the receipt→pull wait is the HUD's separate client-queue
|
|
||||||
// term (see AccessUnit.pulledNs).
|
|
||||||
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
|
|
||||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let presentFloorMeter: LatencyMeter?
|
|
||||||
|
|
||||||
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
||||||
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
||||||
@@ -116,8 +115,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil
|
||||||
presentFloorMeter: LatencyMeter? = nil
|
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -130,7 +128,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
self.presentFloorMeter = presentFloorMeter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeNSView(context: Context) -> StreamLayerView {
|
public func makeNSView(context: Context) -> StreamLayerView {
|
||||||
@@ -141,7 +138,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
view.presentFloorMeter = presentFloorMeter
|
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -155,7 +151,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
view.presentFloorMeter = presentFloorMeter
|
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||||
@@ -177,7 +172,6 @@ public final class StreamLayerView: NSView {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
var presentFloorMeter: LatencyMeter?
|
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -667,7 +661,6 @@ public final class StreamLayerView: NSView {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
presentFloorMeter: presentFloorMeter,
|
|
||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
@@ -699,11 +692,6 @@ public final class StreamLayerView: NSView {
|
|||||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||||
private func layoutPresenter() {
|
private func layoutPresenter() {
|
||||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
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
|
// 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.
|
// bounds — a pre-window layout would report point-sized dimensions.
|
||||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let presentFloorMeter: LatencyMeter?
|
|
||||||
|
|
||||||
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
||||||
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
||||||
@@ -78,8 +77,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil
|
||||||
presentFloorMeter: LatencyMeter? = nil
|
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -91,7 +89,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
self.presentFloorMeter = presentFloorMeter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||||
@@ -101,7 +98,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
controller.presentFloorMeter = presentFloorMeter
|
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -114,7 +110,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
controller.presentFloorMeter = presentFloorMeter
|
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
if controller.connection !== connection {
|
if controller.connection !== connection {
|
||||||
@@ -150,7 +145,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
var presentFloorMeter: LatencyMeter?
|
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -412,7 +406,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
presentFloorMeter: presentFloorMeter,
|
|
||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// The brand color, in the dependency-free foundation so EVERY process can use it — the widget
|
|
||||||
// extension links PunktfunkShared alone (never PunktfunkKit's Rust staticlib), and before this
|
|
||||||
// moved here the Live Activity / widgets fell back to `.tint` = system blue.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public extension Color {
|
|
||||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
|
||||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
|
||||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
|
||||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
|
||||||
static let brand: Color = {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
return Color(UIColor { traits in
|
|
||||||
traits.userInterfaceStyle == .dark
|
|
||||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return Color(NSColor(name: nil) { appearance in
|
|
||||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
|
||||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#else
|
|
||||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
|
||||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -50,21 +50,12 @@ public enum DefaultsKey {
|
|||||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
public static let micChannel = "punktfunk.micChannel"
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
/// LEGACY (2026-07 presentation rebuild — design/apple-presentation-rebuild.md): the old
|
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||||
/// user-visible stage picker's key. No longer read — the presenter is resolved from
|
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||||
/// `presentPriority` below; the stage ladder survives only as the
|
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
|
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||||
/// value is documented, not mysterious.
|
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||||
public static let presenter = "punktfunk.presenter"
|
public static let presenter = "punktfunk.presenter"
|
||||||
/// The user's presentation intent: "latency" (default — every frame shows as soon as the
|
|
||||||
/// display can; jitter appears as the occasional repeat/drop) or "smooth" (a small client
|
|
||||||
/// jitter buffer evens the cadence at the cost of added, visible display latency).
|
|
||||||
/// Resolved once per session by SessionPresenter — see PresentPriority.
|
|
||||||
public static let presentPriority = "punktfunk.presentPriority"
|
|
||||||
/// Smoothness's jitter-buffer capacity in frames: 0 = Automatic (currently 2), or 1…3.
|
|
||||||
/// Each buffered frame adds ~one refresh interval of display latency and absorbs ~one
|
|
||||||
/// interval of arrival jitter. Only meaningful when `presentPriority` is "smooth".
|
|
||||||
public static let smoothBuffer = "punktfunk.smoothBuffer"
|
|
||||||
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import XCTest
|
|||||||
import QuartzCore
|
import QuartzCore
|
||||||
@testable import PunktfunkKit
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
|
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
|
||||||
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
|
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
|
||||||
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
|
|
||||||
/// stage-4 gate), and the per-platform glass-gate depth.
|
|
||||||
final class PresentPacingTests: XCTestCase {
|
final class PresentPacingTests: XCTestCase {
|
||||||
// MARK: - PresentGate
|
// MARK: - PresentGate
|
||||||
|
|
||||||
/// The depth-1 invariant: one present in flight. A second acquire while pending must fail
|
/// The core invariant: one present in flight. A second acquire while pending must fail (the
|
||||||
/// (the frame stays in the ring for the presented handler's re-signal); release reopens.
|
/// frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||||
func testGateAdmitsOneInFlightPresent() {
|
func testGateAdmitsOneInFlightPresent() {
|
||||||
let gate = PresentGate()
|
let gate = PresentGate()
|
||||||
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||||
@@ -22,34 +20,6 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung — no longer a default; see
|
|
||||||
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): 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
|
/// 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
|
/// 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.
|
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
|
||||||
@@ -75,150 +45,13 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
XCTAssertEqual(gate.drainForced(), 0)
|
XCTAssertEqual(gate.drainForced(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PresentPriority (the user-facing latency/smoothness intent)
|
|
||||||
|
|
||||||
/// Resolution from the persisted settings: anything but an explicit "smooth" is latency
|
|
||||||
/// (the default), and the buffer setting maps 0/out-of-range/garbage to Automatic (2).
|
|
||||||
func testPresentPriorityResolution() {
|
|
||||||
XCTAssertEqual(PresentPriority.resolve(setting: nil, bufferSetting: nil), .latency)
|
|
||||||
XCTAssertEqual(PresentPriority.resolve(setting: "latency", bufferSetting: 3), .latency)
|
|
||||||
XCTAssertEqual(PresentPriority.resolve(setting: "garbage", bufferSetting: nil), .latency)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: nil),
|
|
||||||
.smooth(buffer: 2), "unset buffer = Automatic = 2")
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 0), .smooth(buffer: 2))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 1), .smooth(buffer: 1))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 3), .smooth(buffer: 3))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 9),
|
|
||||||
.smooth(buffer: 2), "out-of-range buffer = Automatic")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The intent→store mapping: latency runs the zero-queue newest-wins slot, smoothness the
|
|
||||||
/// FIFO jitter buffer at the resolved capacity.
|
|
||||||
func testPresentPriorityStorePolicy() {
|
|
||||||
XCTAssertEqual(PresentPriority.latency.storePolicy, .newestWins)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.smooth(buffer: 3).storePolicy, .fifo(capacity: 3))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - FrameStore (the decoded-frame hand-off, both intents)
|
|
||||||
|
|
||||||
/// Newest-wins (latency): submit replaces the undisplayed frame, take clears, putBack
|
|
||||||
/// restores only into an empty slot — the exact pre-rebuild ReadyRing semantics.
|
|
||||||
func testFrameStoreNewestWins() {
|
|
||||||
let store = FrameStore<Int>(policy: .newestWins)
|
|
||||||
XCTAssertNil(store.take())
|
|
||||||
store.submit(1)
|
|
||||||
store.submit(2)
|
|
||||||
XCTAssertEqual(store.take(), 2, "the newer decode replaces the undisplayed frame")
|
|
||||||
XCTAssertNil(store.take())
|
|
||||||
store.putBack(7)
|
|
||||||
store.submit(8) // a fresh decode beats the putBack
|
|
||||||
store.putBack(7)
|
|
||||||
XCTAssertEqual(store.take(), 8)
|
|
||||||
XCTAssertEqual(store.drainSubmitted(), 3)
|
|
||||||
let smoothing = store.drainSmoothing()
|
|
||||||
XCTAssertEqual(smoothing.overflowDrops, 0)
|
|
||||||
XCTAssertEqual(smoothing.underflows, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIFO (smoothness): take withholds frames until the buffer has PREROLLED to capacity —
|
|
||||||
/// without preroll a steady stream drains on arrival and headroom never builds — then pops
|
|
||||||
/// oldest-first.
|
|
||||||
func testFrameStoreFifoPrerollsToCapacity() {
|
|
||||||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
|
||||||
store.submit(1)
|
|
||||||
XCTAssertNil(store.take(), "one frame buffered — still building headroom")
|
|
||||||
store.submit(2)
|
|
||||||
XCTAssertEqual(store.take(), 1, "prerolled — pops the OLDEST")
|
|
||||||
store.submit(3)
|
|
||||||
XCTAssertEqual(store.take(), 2, "steady state: one in, oldest out")
|
|
||||||
XCTAssertEqual(store.take(), 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIFO overflow drops the OLDEST (bounded added latency, the newest keeps flowing) and
|
|
||||||
/// counts it; running dry counts an underflow and re-arms preroll so headroom rebuilds.
|
|
||||||
func testFrameStoreFifoOverflowAndUnderflow() {
|
|
||||||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
|
||||||
store.submit(1)
|
|
||||||
store.submit(2)
|
|
||||||
store.submit(3) // full — 1 (the oldest) goes
|
|
||||||
XCTAssertEqual(store.take(), 2)
|
|
||||||
XCTAssertEqual(store.take(), 3)
|
|
||||||
XCTAssertNil(store.take(), "ran dry — an underflow, preroll re-arms")
|
|
||||||
store.submit(4)
|
|
||||||
XCTAssertNil(store.take(), "rebuilding headroom after the underflow")
|
|
||||||
store.submit(5)
|
|
||||||
XCTAssertEqual(store.take(), 4)
|
|
||||||
let smoothing = store.drainSmoothing()
|
|
||||||
XCTAssertEqual(smoothing.overflowDrops, 1)
|
|
||||||
XCTAssertEqual(smoothing.underflows, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIFO putBack reinserts at the FRONT — a frame the render thread couldn't present is
|
|
||||||
/// still the oldest, so present order is preserved.
|
|
||||||
func testFrameStoreFifoPutBackPreservesOrder() {
|
|
||||||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
|
||||||
store.submit(1)
|
|
||||||
store.submit(2)
|
|
||||||
let f = store.take()
|
|
||||||
XCTAssertEqual(f, 1)
|
|
||||||
store.putBack(f!)
|
|
||||||
XCTAssertEqual(store.take(), 1, "the returned frame stays first out")
|
|
||||||
XCTAssertEqual(store.take(), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - LatestBox (stage-4's drawable hand-off)
|
|
||||||
|
|
||||||
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
|
|
||||||
/// layer's pool by release), `take` empties the slot.
|
|
||||||
func testLatestBoxNewestWins() {
|
|
||||||
let box = LatestBox<Int>()
|
|
||||||
XCTAssertNil(box.take())
|
|
||||||
box.put(1)
|
|
||||||
box.put(2)
|
|
||||||
XCTAssertEqual(box.take(), 2, "a fresher put replaces the unpresented value")
|
|
||||||
XCTAssertNil(box.take(), "take empties the slot")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `putBack` fills only an EMPTY slot: the render thread returning a drawable it took but
|
|
||||||
/// didn't present must never clobber a fresher one the link vended in between.
|
|
||||||
func testLatestBoxPutBackNeverClobbersAFresherPut() {
|
|
||||||
let box = LatestBox<Int>()
|
|
||||||
box.put(1)
|
|
||||||
let stale = box.take()
|
|
||||||
XCTAssertEqual(stale, 1)
|
|
||||||
box.putBack(stale!)
|
|
||||||
XCTAssertEqual(box.take(), 1, "putBack into a still-empty slot restores the value")
|
|
||||||
box.put(2)
|
|
||||||
let taken = box.take()
|
|
||||||
box.put(3) // the link vends a fresher drawable while the render thread holds `taken`
|
|
||||||
box.putBack(taken!)
|
|
||||||
XCTAssertEqual(box.take(), 3, "the fresher vend wins over the stale return")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PresenterChoice
|
// MARK: - PresenterChoice
|
||||||
|
|
||||||
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
|
func testPresenterChoiceDefaultsToStage2() {
|
||||||
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
|
||||||
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
|
||||||
/// No selection / garbage falls back to it.
|
|
||||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
|
||||||
#else
|
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
|
||||||
#endif
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||||
PresenterChoice.platformDefault)
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true),
|
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||||
PresenterChoice.platformDefault)
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||||
}
|
}
|
||||||
@@ -236,109 +69,15 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "stage4" (deadline pacing) resolves only on iOS/tvOS. On macOS — whose present path is
|
|
||||||
/// entangled with the sync-off/DCP-panic saga — a synced-over "stage4" value maps back to
|
|
||||||
/// the platform default instead of engaging an unvalidated pacing.
|
|
||||||
func testPresenterChoiceGatesStage4ToVsyncLatchPlatforms() {
|
|
||||||
#if os(macOS)
|
|
||||||
XCTAssertNil(PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: "stage4", env: nil, allowStage1: true), .stage2)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage2)
|
|
||||||
#else
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true), .stage4)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage4)
|
|
||||||
// The env override wins over the persisted setting, both directions.
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: "stage4", env: "stage3", allowStage1: true), .stage3)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: "stage2", env: "stage4", allowStage1: true), .stage4)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
|
/// 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() {
|
func testPresenterChoiceGatesStage1() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false),
|
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||||
PresenterChoice.platformDefault)
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false),
|
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||||
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)
|
|
||||||
// Stage-4 means deadline regardless of codec or how it was chosen.
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Glass-gate depth
|
|
||||||
|
|
||||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
|
||||||
/// the 2026-07 iPad depth-2 experiment regressed display latency 14→22–28 ms (see
|
|
||||||
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
|
|
||||||
/// there is the swapID-panic mitigation — strict serialization is its point);
|
|
||||||
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on 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")
|
|
||||||
#else
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.gateDepth(env: nil), 1,
|
|
||||||
"any depth >1 is a standing queue — one refresh of display latency per slot")
|
|
||||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ final class PyroWaveParserTests: XCTestCase {
|
|||||||
func testLayoutMatchesUpstreamBlockSpace() {
|
func testLayoutMatchesUpstreamBlockSpace() {
|
||||||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
// 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).
|
// 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.alignedWidth, 256)
|
||||||
XCTAssertEqual(layout.alignedHeight, 160)
|
XCTAssertEqual(layout.alignedHeight, 160)
|
||||||
XCTAssertEqual(layout.levelWidth(0), 128)
|
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||||
@@ -85,7 +85,7 @@ final class PyroWaveParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
let layout = WaveletLayout(width: width, height: height)
|
||||||
var au = sof(totalBlocks: 4)
|
var au = sof(totalBlocks: 4)
|
||||||
au += packet(blockIndex: 0)
|
au += packet(blockIndex: 0)
|
||||||
au += packet(blockIndex: 3)
|
au += packet(blockIndex: 3)
|
||||||
@@ -273,17 +273,6 @@ final class PyroWaveGoldenTests: XCTestCase {
|
|||||||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
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
|
/// 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
|
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||||
/// localized blur, not garbage).
|
/// 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
@@ -55,11 +55,6 @@ pub struct AppModel {
|
|||||||
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
||||||
/// run in the session binary, which has its own.
|
/// run in the session binary, which has its own.
|
||||||
pub gamepad: crate::gamepad::GamepadService,
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
/// Device lists for the settings pickers (GPUs via `punktfunk-session
|
|
||||||
/// --list-adapters` — the shell deliberately links no Vulkan itself — and audio
|
|
||||||
/// endpoints via the PipeWire registry), probed once at startup on a worker thread.
|
|
||||||
/// Empty until the probe lands — empty lists simply hide their pickers.
|
|
||||||
pub probes: Rc<RefCell<crate::ui_settings::DeviceProbes>>,
|
|
||||||
hosts: Controller<HostsPage>,
|
hosts: Controller<HostsPage>,
|
||||||
/// One session child at a time — connects while one runs are ignored.
|
/// One session child at a time — connects while one runs are ignored.
|
||||||
busy: bool,
|
busy: bool,
|
||||||
@@ -165,42 +160,6 @@ impl SimpleComponent for AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let settings = Rc::new(RefCell::new(Settings::load()));
|
let settings = Rc::new(RefCell::new(Settings::load()));
|
||||||
// Device lists for the settings pickers: probe in the background, ready long
|
|
||||||
// before the dialog opens. A missing session binary or absent PipeWire just
|
|
||||||
// leaves the corresponding list empty (and its picker hidden).
|
|
||||||
let probes: Rc<RefCell<crate::ui_settings::DeviceProbes>> = Rc::default();
|
|
||||||
{
|
|
||||||
let (tx, rx) = async_channel::bounded::<crate::ui_settings::DeviceProbes>(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let adapters: Vec<String> =
|
|
||||||
std::process::Command::new(crate::spawn::session_binary())
|
|
||||||
.arg("--list-adapters")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.filter(|o| o.status.success())
|
|
||||||
.map(|o| {
|
|
||||||
String::from_utf8_lossy(&o.stdout)
|
|
||||||
.lines()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|l| !l.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default();
|
|
||||||
let _ = tx.send_blocking(crate::ui_settings::DeviceProbes {
|
|
||||||
adapters,
|
|
||||||
speakers,
|
|
||||||
mics,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let probes = probes.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Ok(found) = rx.recv().await {
|
|
||||||
*probes.borrow_mut() = found;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Re-apply the persisted forwarded-controller pin (stable key; the service
|
// Re-apply the persisted forwarded-controller pin (stable key; the service
|
||||||
// matches it whenever such a pad connects).
|
// matches it whenever such a pad connects).
|
||||||
{
|
{
|
||||||
@@ -238,7 +197,6 @@ impl SimpleComponent for AppModel {
|
|||||||
settings,
|
settings,
|
||||||
identity,
|
identity,
|
||||||
gamepad: init.gamepad,
|
gamepad: init.gamepad,
|
||||||
probes,
|
|
||||||
hosts,
|
hosts,
|
||||||
busy: false,
|
busy: false,
|
||||||
wake_fallback: None,
|
wake_fallback: None,
|
||||||
@@ -349,15 +307,8 @@ impl SimpleComponent for AppModel {
|
|||||||
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
|
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
|
||||||
// box is already booting while the dial times out, arm the wake-wait fallback
|
// box is already booting while the dial times out, arm the wake-wait fallback
|
||||||
// for THIS request, and connect immediately.
|
// for THIS request, and connect immediately.
|
||||||
//
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
// Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when
|
self.wake_fallback = Some(req.clone());
|
||||||
// they aren't): no packet and no wake-and-wait fallback — the dial either
|
|
||||||
// succeeds or fails with the normal error. The host-card menu's explicit
|
|
||||||
// "Wake host" is deliberately not gated.
|
|
||||||
if self.settings.borrow().auto_wake {
|
|
||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
|
||||||
self.wake_fallback = Some(req.clone());
|
|
||||||
}
|
|
||||||
sender.input(AppMsg::Connect(req));
|
sender.input(AppMsg::Connect(req));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,7 +435,6 @@ impl SimpleComponent for AppModel {
|
|||||||
&self.window,
|
&self.window,
|
||||||
self.settings.clone(),
|
self.settings.clone(),
|
||||||
&self.gamepad,
|
&self.gamepad,
|
||||||
&self.probes.borrow(),
|
|
||||||
move || {
|
move || {
|
||||||
// The library toggle changes the saved cards' menu — re-render.
|
// The library toggle changes the saved cards' menu — re-render.
|
||||||
let _ = hosts.send(HostsMsg::Refresh);
|
let _ = hosts.send(HostsMsg::Refresh);
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
|||||||
paired: false,
|
paired: false,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
match known.save() {
|
match known.save() {
|
||||||
@@ -535,34 +534,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
"settings" | "03-settings" => {
|
"settings" | "03-settings" => {
|
||||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||||
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
|
|
||||||
name: name.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
};
|
|
||||||
let probes = crate::ui_settings::DeviceProbes {
|
|
||||||
adapters: vec![
|
|
||||||
"NVIDIA GeForce RTX 4070".to_string(),
|
|
||||||
"AMD Radeon 780M".to_string(),
|
|
||||||
],
|
|
||||||
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
|
||||||
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
|
||||||
};
|
|
||||||
let dialog = crate::ui_settings::show(
|
|
||||||
&ctx.window,
|
|
||||||
ctx.settings.clone(),
|
|
||||||
&ctx.gamepad,
|
|
||||||
&probes,
|
|
||||||
|| {},
|
|
||||||
);
|
|
||||||
// Optional page for the capture (general/display/input/audio/controllers);
|
|
||||||
// the dialog opens on General otherwise.
|
|
||||||
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
|
|
||||||
if !page.is_empty() {
|
|
||||||
use adw::prelude::PreferencesDialogExt as _;
|
|
||||||
dialog.set_visible_page_name(&page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||||
"pair" | "05-pair" => {
|
"pair" | "05-pair" => {
|
||||||
|
|||||||
+103
-423
@@ -1,9 +1,5 @@
|
|||||||
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
|
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||||||
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
|
//! capture behavior. Written back to disk when the dialog closes.
|
||||||
//! everything about the picture — with per-field captions in each row's subtitle,
|
|
||||||
//! dynamic where the meaning depends on the selection (touch mode). Written back to
|
|
||||||
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
|
|
||||||
//! rather than as a page.
|
|
||||||
|
|
||||||
use crate::trust::Settings;
|
use crate::trust::Settings;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
@@ -44,31 +40,14 @@ const GAMEPADS: &[&str] = &[
|
|||||||
"steamdeck",
|
"steamdeck",
|
||||||
];
|
];
|
||||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
/// Codec setting values (persisted) paired with their display labels below.
|
||||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||||
/// HEVC when either side can't do it.
|
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
|
|
||||||
const CODEC_LABELS: &[&str] = &[
|
|
||||||
"Automatic",
|
|
||||||
"HEVC (H.265)",
|
|
||||||
"H.264 (AVC)",
|
|
||||||
"AV1",
|
|
||||||
"PyroWave (wired LAN)",
|
|
||||||
];
|
|
||||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
||||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||||
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
|
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
|
||||||
/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple
|
|
||||||
/// revamp's dynamic-caption idiom) instead of narrating all three modes at once.
|
|
||||||
/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a
|
|
||||||
/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis.
|
|
||||||
const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
|
||||||
"Drives the cursor like a laptop trackpad — tap to click",
|
|
||||||
"The cursor jumps to your finger — a tap clicks there",
|
|
||||||
"Real multi-touch reaches the host — for touch-native apps",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||||
const APP_LICENSE: &str = concat!(
|
const APP_LICENSE: &str = concat!(
|
||||||
@@ -287,92 +266,22 @@ impl ChoiceRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a row's caption after construction — the dynamic-caption hook (touch mode,
|
|
||||||
/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`]
|
|
||||||
/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode.
|
|
||||||
fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) {
|
|
||||||
if let Some(r) = row.downcast_ref::<adw::ActionRow>() {
|
|
||||||
r.set_subtitle(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window,
|
|
||||||
/// 2.. = explicit sizes) — one line each, see the caption-width note on
|
|
||||||
/// [`TOUCH_MODE_CAPTIONS`].
|
|
||||||
fn resolution_caption(i: u32) -> &'static str {
|
|
||||||
match i {
|
|
||||||
0 => "The native mode of this monitor, resolved at connect",
|
|
||||||
1 => "Follows the stream window — resizes renegotiate the host output",
|
|
||||||
_ => "The host drives a virtual output at exactly this size",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off
|
|
||||||
/// spelled out; everything else shares the soft-preference line.
|
|
||||||
fn codec_caption(i: u32) -> &'static str {
|
|
||||||
if CODECS.get(i as usize) == Some(&"pyrowave") {
|
|
||||||
"Wavelet codec for wired LAN — minimal latency, lots of bandwidth"
|
|
||||||
} else {
|
|
||||||
"A preference — the host falls back if it can't encode it"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A settings category page for the dialog's view switcher.
|
|
||||||
fn page(title: &str, icon: &str) -> adw::PreferencesPage {
|
|
||||||
adw::PreferencesPage::builder()
|
|
||||||
// The name addresses the page programmatically (`set_visible_page_name` — the
|
|
||||||
// screenshot harness's page knob); the title is what the view switcher shows.
|
|
||||||
.name(title.to_lowercase())
|
|
||||||
.title(title)
|
|
||||||
.icon_name(icon)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Startup device probes for the pickers — filled by the app shell in the background
|
|
||||||
/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire
|
|
||||||
/// registry); any list may still be empty when the dialog opens, which simply hides
|
|
||||||
/// that picker.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct DeviceProbes {
|
|
||||||
pub adapters: Vec<String>,
|
|
||||||
pub speakers: Vec<pf_client_core::audio::AudioDevice>,
|
|
||||||
pub mics: Vec<pf_client_core::audio::AudioDevice>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A titled group of rows; `description` (may be empty) is the one form-level note —
|
|
||||||
/// per-field explanations belong in row subtitles, not here.
|
|
||||||
fn group(title: &str, description: &str) -> adw::PreferencesGroup {
|
|
||||||
let g = adw::PreferencesGroup::builder().title(title).build();
|
|
||||||
if !description.is_empty() {
|
|
||||||
g.set_description(Some(description));
|
|
||||||
}
|
|
||||||
g
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||||||
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
|
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||||
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
|
|
||||||
/// presented dialog so the screenshot harness can select a page; callers ignore it.
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
parent: &impl IsA<gtk::Widget>,
|
parent: &impl IsA<gtk::Widget>,
|
||||||
settings: Rc<RefCell<Settings>>,
|
settings: Rc<RefCell<Settings>>,
|
||||||
gamepads: &crate::gamepad::GamepadService,
|
gamepads: &crate::gamepad::GamepadService,
|
||||||
probes: &DeviceProbes,
|
|
||||||
on_closed: impl Fn() + 'static,
|
on_closed: impl Fn() + 'static,
|
||||||
) -> adw::PreferencesDialog {
|
) {
|
||||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||||
// subpage onto it.
|
// subpage onto it.
|
||||||
let dialog = adw::PreferencesDialog::new();
|
let dialog = adw::PreferencesDialog::new();
|
||||||
dialog.set_title("Preferences");
|
dialog.set_title("Preferences");
|
||||||
dialog.set_search_enabled(true);
|
|
||||||
// Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the
|
|
||||||
// Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a
|
|
||||||
// breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that
|
|
||||||
// can't give the dialog this width it still collapses to the bottom bar on its own.
|
|
||||||
dialog.set_content_width(830);
|
|
||||||
let inline = gamescope_session();
|
let inline = gamescope_session();
|
||||||
|
let page = adw::PreferencesPage::new();
|
||||||
|
|
||||||
// ---- Display: Resolution ----
|
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||||
// `match_window` flag), then the explicit sizes.
|
// `match_window` flag), then the explicit sizes.
|
||||||
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||||
@@ -388,13 +297,10 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Resolution",
|
"Resolution",
|
||||||
resolution_caption(0),
|
"The host creates a virtual output at exactly this size — Match window follows \
|
||||||
|
the stream window, including mid-stream resizes",
|
||||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
{
|
|
||||||
let w = res_row.widget().clone();
|
|
||||||
res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
|
|
||||||
}
|
|
||||||
let hz_names: Vec<String> = REFRESH
|
let hz_names: Vec<String> = REFRESH
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&r| {
|
.map(|&r| {
|
||||||
@@ -409,11 +315,9 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Refresh rate",
|
"Refresh rate",
|
||||||
"Native follows the monitor the window is on",
|
"",
|
||||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Display: Quality ----
|
|
||||||
let scale_names: Vec<String> = RENDER_SCALES
|
let scale_names: Vec<String> = RENDER_SCALES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&s| render_scale_label(s))
|
.map(|&s| render_scale_label(s))
|
||||||
@@ -422,69 +326,13 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Render scale",
|
"Render scale",
|
||||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
|
||||||
|
(< 1×) for a lighter host — this device resamples to the window",
|
||||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||||
bitrate_row.set_title("Bitrate");
|
bitrate_row.set_title("Bitrate");
|
||||||
bitrate_row
|
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||||
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
|
|
||||||
let codec_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Video codec",
|
|
||||||
codec_caption(0),
|
|
||||||
CODEC_LABELS,
|
|
||||||
);
|
|
||||||
{
|
|
||||||
let w = codec_row.widget().clone();
|
|
||||||
codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i)));
|
|
||||||
}
|
|
||||||
let hdr_row = adw::SwitchRow::builder()
|
|
||||||
.title("10-bit HDR")
|
|
||||||
.subtitle(
|
|
||||||
"Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \
|
|
||||||
the display supports it, tone-mapped otherwise",
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
let decoder_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Video decoder",
|
|
||||||
"Automatic picks the best hardware decode, then software",
|
|
||||||
&["Automatic", "Vulkan Video", "VAAPI", "Software"],
|
|
||||||
);
|
|
||||||
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
|
|
||||||
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
|
|
||||||
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
|
|
||||||
let saved_adapter = settings.borrow().adapter.clone();
|
|
||||||
let mut gpu_names = vec!["Automatic".to_string()];
|
|
||||||
let mut gpu_keys: Vec<String> = vec![String::new()];
|
|
||||||
for a in &probes.adapters {
|
|
||||||
gpu_names.push(a.clone());
|
|
||||||
gpu_keys.push(a.clone());
|
|
||||||
}
|
|
||||||
if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) {
|
|
||||||
gpu_names.push(format!("{saved_adapter} (not detected)"));
|
|
||||||
gpu_keys.push(saved_adapter.clone());
|
|
||||||
}
|
|
||||||
let gpu_row = (gpu_keys.len() > 1).then(|| {
|
|
||||||
let row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"GPU",
|
|
||||||
"Decodes and presents the stream",
|
|
||||||
&gpu_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
||||||
);
|
|
||||||
let i = gpu_keys
|
|
||||||
.iter()
|
|
||||||
.position(|k| k == &saved_adapter)
|
|
||||||
.unwrap_or(0);
|
|
||||||
row.set_selected(i as u32);
|
|
||||||
row
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Display: Host output ----
|
|
||||||
let compositor_row = ChoiceRow::new(
|
let compositor_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
@@ -498,19 +346,19 @@ pub fn show(
|
|||||||
"gamescope",
|
"gamescope",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
let decoder_row = ChoiceRow::new(
|
||||||
// ---- General ----
|
&dialog,
|
||||||
let fullscreen_row = adw::SwitchRow::builder()
|
inline,
|
||||||
.title("Start streams in fullscreen")
|
"Video decoder",
|
||||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
||||||
.build();
|
Vulkan Video on NVIDIA), falling back to software",
|
||||||
let wake_row = adw::SwitchRow::builder()
|
&[
|
||||||
.title("Auto-wake on connect")
|
"Automatic (hardware → software)",
|
||||||
.subtitle(
|
"Vulkan Video",
|
||||||
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
|
"VAAPI",
|
||||||
off if hosts behind a VPN look offline when they aren't",
|
"Software",
|
||||||
)
|
],
|
||||||
.build();
|
);
|
||||||
let stats_row = ChoiceRow::new(
|
let stats_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
@@ -518,101 +366,20 @@ pub fn show(
|
|||||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||||
&["Off", "Compact", "Normal", "Detailed"],
|
&["Off", "Compact", "Normal", "Detailed"],
|
||||||
);
|
);
|
||||||
let library_row = adw::SwitchRow::builder()
|
let fullscreen_row = adw::SwitchRow::builder()
|
||||||
.title("Show game library")
|
.title("Start streams in fullscreen")
|
||||||
.subtitle(
|
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||||
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
|
|
||||||
and launch one directly. No extra host setup",
|
|
||||||
)
|
|
||||||
.build();
|
.build();
|
||||||
|
stream.add(res_row.widget());
|
||||||
|
stream.add(hz_row.widget());
|
||||||
|
stream.add(scale_row.widget());
|
||||||
|
stream.add(&bitrate_row);
|
||||||
|
stream.add(compositor_row.widget());
|
||||||
|
stream.add(decoder_row.widget());
|
||||||
|
stream.add(&fullscreen_row);
|
||||||
|
stream.add(stats_row.widget());
|
||||||
|
|
||||||
// ---- Input ----
|
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||||
let touch_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Touch input",
|
|
||||||
TOUCH_MODE_CAPTIONS[0],
|
|
||||||
TOUCH_MODE_LABELS,
|
|
||||||
);
|
|
||||||
// Dynamic caption: describe the SELECTED mode, not all three at once.
|
|
||||||
{
|
|
||||||
let w = touch_row.widget().clone();
|
|
||||||
touch_row.connect_changed(move |i| {
|
|
||||||
let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1);
|
|
||||||
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let inhibit_row = adw::SwitchRow::builder()
|
|
||||||
.title("Capture system shortcuts")
|
|
||||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
|
||||||
.build();
|
|
||||||
let invert_row = adw::SwitchRow::builder()
|
|
||||||
.title("Invert scroll direction")
|
|
||||||
.subtitle("Reverses the wheel and trackpad scroll direction sent to the host")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// ---- Audio ----
|
|
||||||
let surround_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Audio channels",
|
|
||||||
"Stereo or surround — the host downmixes if its output has fewer",
|
|
||||||
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
|
||||||
);
|
|
||||||
let mic_row = adw::SwitchRow::builder()
|
|
||||||
.title("Stream microphone")
|
|
||||||
.subtitle("Sends your microphone to the host's virtual mic")
|
|
||||||
.build();
|
|
||||||
// Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the
|
|
||||||
// stored value is the node name. Hidden when the probe found nothing; a saved
|
|
||||||
// device that's gone keeps a revertable "(not detected)" entry, like the GPU row.
|
|
||||||
let dev_row = |saved: String,
|
|
||||||
devs: &[pf_client_core::audio::AudioDevice],
|
|
||||||
title: &str,
|
|
||||||
subtitle: &str| {
|
|
||||||
let mut names = vec!["System default".to_string()];
|
|
||||||
let mut keys = vec![String::new()];
|
|
||||||
for d in devs {
|
|
||||||
names.push(d.description.clone());
|
|
||||||
keys.push(d.name.clone());
|
|
||||||
}
|
|
||||||
if !saved.is_empty() && !keys.contains(&saved) {
|
|
||||||
names.push(format!("{saved} (not detected)"));
|
|
||||||
keys.push(saved.clone());
|
|
||||||
}
|
|
||||||
let row = (keys.len() > 1).then(|| {
|
|
||||||
let row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
&names.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
||||||
);
|
|
||||||
row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32);
|
|
||||||
row
|
|
||||||
});
|
|
||||||
(row, keys)
|
|
||||||
};
|
|
||||||
let (speaker_row, speaker_keys) = dev_row(
|
|
||||||
settings.borrow().speaker_device.clone(),
|
|
||||||
&probes.speakers,
|
|
||||||
"Speaker",
|
|
||||||
"Host audio plays here — System default follows the desktop",
|
|
||||||
);
|
|
||||||
let (micdev_row, micdev_keys) = dev_row(
|
|
||||||
settings.borrow().mic_device.clone(),
|
|
||||||
&probes.mics,
|
|
||||||
"Microphone",
|
|
||||||
"The input that feeds the host's virtual mic",
|
|
||||||
);
|
|
||||||
// The device pick only matters while the mic streams at all.
|
|
||||||
if let Some(r) = &micdev_row {
|
|
||||||
let w = r.widget().clone();
|
|
||||||
w.set_sensitive(mic_row.is_active());
|
|
||||||
mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Controllers ----
|
|
||||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
|
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
|
||||||
// (Steam's virtual pad skipped); pinning one restricts the session to that single
|
// (Steam's virtual pad skipped); pinning one restricts the session to that single
|
||||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||||
@@ -646,7 +413,7 @@ pub fn show(
|
|||||||
if pads.is_empty() {
|
if pads.is_empty() {
|
||||||
"No controllers detected"
|
"No controllers detected"
|
||||||
} else {
|
} else {
|
||||||
"Every pad is its own player — pick one to force single-player"
|
"All controllers are forwarded, each as its own player; pick one to force single-player"
|
||||||
},
|
},
|
||||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
@@ -676,7 +443,7 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Gamepad type",
|
"Gamepad type",
|
||||||
"The virtual pad on the host — Automatic matches your controller",
|
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||||
&[
|
&[
|
||||||
"Automatic",
|
"Automatic",
|
||||||
"Xbox 360",
|
"Xbox 360",
|
||||||
@@ -686,8 +453,66 @@ pub fn show(
|
|||||||
"Steam Deck",
|
"Steam Deck",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
let touch_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Touch input",
|
||||||
|
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
|
||||||
|
Direct pointer jumps to your finger; Touch passthrough sends real touches",
|
||||||
|
TOUCH_MODE_LABELS,
|
||||||
|
);
|
||||||
|
let inhibit_row = adw::SwitchRow::builder()
|
||||||
|
.title("Capture system shortcuts")
|
||||||
|
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||||
|
.build();
|
||||||
|
input.add(forward_row.widget());
|
||||||
|
input.add(pad_row.widget());
|
||||||
|
input.add(touch_row.widget());
|
||||||
|
input.add(&inhibit_row);
|
||||||
|
|
||||||
// ---- Seed from the current settings ----
|
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||||
|
let surround_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Audio channels",
|
||||||
|
"Request stereo or surround (the host downmixes if its output has fewer)",
|
||||||
|
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
||||||
|
);
|
||||||
|
audio.add(surround_row.widget());
|
||||||
|
let codec_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Video codec",
|
||||||
|
"Preferred codec — the host falls back if it can't encode this one",
|
||||||
|
CODEC_LABELS,
|
||||||
|
);
|
||||||
|
stream.add(codec_row.widget());
|
||||||
|
let mic_row = adw::SwitchRow::builder()
|
||||||
|
.title("Stream microphone")
|
||||||
|
.subtitle("Send the default input device to the host's virtual microphone")
|
||||||
|
.build();
|
||||||
|
audio.add(&mic_row);
|
||||||
|
|
||||||
|
// Experimental — mirrors the Apple client's Experimental section (wording included).
|
||||||
|
let experimental = adw::PreferencesGroup::builder()
|
||||||
|
.title("Experimental")
|
||||||
|
.build();
|
||||||
|
let library_row = adw::SwitchRow::builder()
|
||||||
|
.title("Show game library")
|
||||||
|
.subtitle(
|
||||||
|
"Adds a “Browse library…” action to each saved host that lists its games \
|
||||||
|
(Steam + custom) via the host's management API — works once you've paired",
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
experimental.add(&library_row);
|
||||||
|
|
||||||
|
// About (with the license/third-party Legal pages) lives in the primary menu now.
|
||||||
|
page.add(&stream);
|
||||||
|
page.add(&input);
|
||||||
|
page.add(&audio);
|
||||||
|
page.add(&experimental);
|
||||||
|
|
||||||
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
let s = settings.borrow();
|
let s = settings.borrow();
|
||||||
let res_i = if s.match_window {
|
let res_i = if s.match_window {
|
||||||
@@ -700,7 +525,6 @@ pub fn show(
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
};
|
};
|
||||||
res_row.set_selected(res_i as u32);
|
res_row.set_selected(res_i as u32);
|
||||||
set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32));
|
|
||||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
hz_row.set_selected(hz_i as u32);
|
hz_row.set_selected(hz_i as u32);
|
||||||
let scale_i = RENDER_SCALES
|
let scale_i = RENDER_SCALES
|
||||||
@@ -716,8 +540,6 @@ pub fn show(
|
|||||||
.position(|&t| t == s.touch_mode)
|
.position(|&t| t == s.touch_mode)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
touch_row.set_selected(touch_i as u32);
|
touch_row.set_selected(touch_i as u32);
|
||||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
|
||||||
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
|
||||||
let comp_i = COMPOSITORS
|
let comp_i = COMPOSITORS
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&c| c == s.compositor)
|
.position(|&c| c == s.compositor)
|
||||||
@@ -731,11 +553,8 @@ pub fn show(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stats_row.set_selected(stats_i as u32);
|
stats_row.set_selected(stats_i as u32);
|
||||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||||
wake_row.set_active(s.auto_wake);
|
|
||||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
invert_row.set_active(s.invert_scroll);
|
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
hdr_row.set_active(s.hdr_enabled);
|
|
||||||
library_row.set_active(s.library_enabled);
|
library_row.set_active(s.library_enabled);
|
||||||
surround_row.set_selected(match s.audio_channels {
|
surround_row.set_selected(match s.audio_channels {
|
||||||
6 => 1,
|
6 => 1,
|
||||||
@@ -744,104 +563,9 @@ pub fn show(
|
|||||||
});
|
});
|
||||||
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
||||||
codec_row.set_selected(codec_i as u32);
|
codec_row.set_selected(codec_i as u32);
|
||||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Assemble the category pages (the Apple revamp's map) ----
|
dialog.add(&page);
|
||||||
let general = page("General", "preferences-system-symbolic");
|
|
||||||
let session_group = group("Session", "");
|
|
||||||
session_group.add(&fullscreen_row);
|
|
||||||
session_group.add(&wake_row);
|
|
||||||
let stats_group = group("Statistics", "");
|
|
||||||
stats_group.add(stats_row.widget());
|
|
||||||
let library_group = group("Library", "");
|
|
||||||
library_group.add(&library_row);
|
|
||||||
general.add(&session_group);
|
|
||||||
general.add(&stats_group);
|
|
||||||
general.add(&library_group);
|
|
||||||
|
|
||||||
let display = page("Display", "video-display-symbolic");
|
|
||||||
let resolution_group = group("Resolution", "");
|
|
||||||
resolution_group.add(res_row.widget());
|
|
||||||
resolution_group.add(hz_row.widget());
|
|
||||||
let quality_group = group("Quality", "");
|
|
||||||
quality_group.add(scale_row.widget());
|
|
||||||
quality_group.add(&bitrate_row);
|
|
||||||
quality_group.add(codec_row.widget());
|
|
||||||
quality_group.add(&hdr_row);
|
|
||||||
quality_group.add(decoder_row.widget());
|
|
||||||
if let Some(r) = &gpu_row {
|
|
||||||
quality_group.add(r.widget());
|
|
||||||
}
|
|
||||||
// The one form-level note (deliberately not repeated on every row).
|
|
||||||
let output_group = group(
|
|
||||||
"Host output",
|
|
||||||
"Display changes apply from the next session.",
|
|
||||||
);
|
|
||||||
output_group.add(compositor_row.widget());
|
|
||||||
display.add(&resolution_group);
|
|
||||||
display.add(&quality_group);
|
|
||||||
display.add(&output_group);
|
|
||||||
|
|
||||||
let input = page("Input", "input-keyboard-symbolic");
|
|
||||||
let touch_group = group("Touch", "");
|
|
||||||
touch_group.add(touch_row.widget());
|
|
||||||
// Group titles are Pango markup — the ampersand must be an entity.
|
|
||||||
let kbm_group = group("Keyboard & mouse", "");
|
|
||||||
kbm_group.add(&inhibit_row);
|
|
||||||
kbm_group.add(&invert_row);
|
|
||||||
input.add(&touch_group);
|
|
||||||
input.add(&kbm_group);
|
|
||||||
|
|
||||||
let audio = page("Audio", "audio-volume-high-symbolic");
|
|
||||||
let audio_group = group("", "Applies from the next session.");
|
|
||||||
audio_group.add(surround_row.widget());
|
|
||||||
if let Some(r) = &speaker_row {
|
|
||||||
audio_group.add(r.widget());
|
|
||||||
}
|
|
||||||
audio_group.add(&mic_row);
|
|
||||||
if let Some(r) = &micdev_row {
|
|
||||||
audio_group.add(r.widget());
|
|
||||||
}
|
|
||||||
audio.add(&audio_group);
|
|
||||||
|
|
||||||
let controllers = page("Controllers", "input-gaming-symbolic");
|
|
||||||
let controllers_group = group("", "");
|
|
||||||
// The detected-pad list (mirrors the Apple Controllers section): informational rows
|
|
||||||
// above the pickers, from the same snapshot that feeds the forwarding picker.
|
|
||||||
if pads.is_empty() {
|
|
||||||
let none = adw::ActionRow::builder()
|
|
||||||
.title("No controllers detected")
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.build();
|
|
||||||
controllers_group.add(&none);
|
|
||||||
} else {
|
|
||||||
for p in &pads {
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(&p.name)
|
|
||||||
.use_markup(false)
|
|
||||||
.build();
|
|
||||||
if p.steam_virtual {
|
|
||||||
row.set_subtitle(
|
|
||||||
"Steam Input's virtual pad — Automatic skips it while a real pad is connected",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
row.set_subtitle(p.kind_label());
|
|
||||||
}
|
|
||||||
row.add_prefix(>k::Image::from_icon_name("input-gaming-symbolic"));
|
|
||||||
controllers_group.add(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controllers_group.add(forward_row.widget());
|
|
||||||
controllers_group.add(pad_row.widget());
|
|
||||||
controllers.add(&controllers_group);
|
|
||||||
|
|
||||||
dialog.add(&general);
|
|
||||||
dialog.add(&display);
|
|
||||||
dialog.add(&input);
|
|
||||||
dialog.add(&audio);
|
|
||||||
dialog.add(&controllers);
|
|
||||||
|
|
||||||
dialog.connect_closed(move |_| {
|
dialog.connect_closed(move |_| {
|
||||||
let mut s = settings.borrow_mut();
|
let mut s = settings.borrow_mut();
|
||||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||||
@@ -856,40 +580,19 @@ pub fn show(
|
|||||||
s.render_scale =
|
s.render_scale =
|
||||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
// session, hand-edited or written by another client): it displays as "Automatic", and
|
|
||||||
// writing that back would silently erase it just by opening + closing the dialog.
|
|
||||||
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
|
||||||
// a listed one to begin with.
|
|
||||||
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
|
||||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
|
||||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
|
||||||
}
|
|
||||||
s.touch_mode =
|
s.touch_mode =
|
||||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||||
s.forward_pad = chosen_pin.borrow().clone();
|
s.forward_pad = chosen_pin.borrow().clone();
|
||||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
.to_string();
|
.to_string();
|
||||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||||
if let Some(r) = &gpu_row {
|
|
||||||
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
|
|
||||||
}
|
|
||||||
if let Some(r) = &speaker_row {
|
|
||||||
s.speaker_device =
|
|
||||||
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
|
|
||||||
}
|
|
||||||
if let Some(r) = &micdev_row {
|
|
||||||
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
|
|
||||||
}
|
|
||||||
s.set_stats_verbosity(
|
s.set_stats_verbosity(
|
||||||
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||||
);
|
);
|
||||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||||
s.auto_wake = wake_row.is_active();
|
|
||||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
s.invert_scroll = invert_row.is_active();
|
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
s.hdr_enabled = hdr_row.is_active();
|
|
||||||
s.audio_channels = match surround_row.selected() {
|
s.audio_channels = match surround_row.selected() {
|
||||||
1 => 6,
|
1 => 6,
|
||||||
2 => 8,
|
2 => 8,
|
||||||
@@ -902,7 +605,6 @@ pub fn show(
|
|||||||
on_closed();
|
on_closed();
|
||||||
});
|
});
|
||||||
dialog.present(Some(parent));
|
dialog.present(Some(parent));
|
||||||
dialog
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -976,17 +678,6 @@ mod tests {
|
|||||||
assert_eq!(fired.get(), 1);
|
assert_eq!(fired.get(), 1);
|
||||||
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
||||||
|
|
||||||
// The dynamic-caption hook drives the same subtitle both row shapes expose.
|
|
||||||
set_row_subtitle(row.widget(), "swapped");
|
|
||||||
assert_eq!(
|
|
||||||
row.widget()
|
|
||||||
.downcast_ref::<adw::ActionRow>()
|
|
||||||
.unwrap()
|
|
||||||
.subtitle()
|
|
||||||
.as_deref(),
|
|
||||||
Some("swapped")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-activating shows the check on the new selection (fresh subpage each time).
|
// Re-activating shows the check on the new selection (fresh subpage each time).
|
||||||
row.widget()
|
row.widget()
|
||||||
.downcast_ref::<adw::ActionRow>()
|
.downcast_ref::<adw::ActionRow>()
|
||||||
@@ -1007,16 +698,5 @@ mod tests {
|
|||||||
combo.set_selected(0);
|
combo.set_selected(0);
|
||||||
assert_eq!(combo.selected(), 0);
|
assert_eq!(combo.selected(), 0);
|
||||||
assert_eq!(combo_fired.get(), 0);
|
assert_eq!(combo_fired.get(), 0);
|
||||||
// ComboRow derives from ActionRow, so the caption hook reaches it too.
|
|
||||||
set_row_subtitle(combo.widget(), "combo caption");
|
|
||||||
assert_eq!(
|
|
||||||
combo
|
|
||||||
.widget()
|
|
||||||
.downcast_ref::<adw::ActionRow>()
|
|
||||||
.unwrap()
|
|
||||||
.subtitle()
|
|
||||||
.as_deref(),
|
|
||||||
Some("combo caption")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -458,11 +458,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
),
|
),
|
||||||
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
||||||
}
|
}
|
||||||
let (mut send, recv) = conn.open_bi().await.context("open control stream")?;
|
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
|
||||||
// Frame every read on the control stream through the resumable reader, exactly as the client
|
|
||||||
// pump does: `clock_sync` bounds each read with a timeout, and a frame straddling two wakeups
|
|
||||||
// would otherwise leave the stream permanently misaligned for the rest of the run.
|
|
||||||
let mut recv = io::MsgReader::new(recv);
|
|
||||||
|
|
||||||
io::write_msg(
|
io::write_msg(
|
||||||
&mut send,
|
&mut send,
|
||||||
@@ -517,8 +513,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let welcome =
|
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
|
||||||
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = ?welcome.mode,
|
mode = ?welcome.mode,
|
||||||
fec = ?welcome.fec,
|
fec = ?welcome.fec,
|
||||||
@@ -633,7 +629,10 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("Reconfigure write failed");
|
tracing::error!("Reconfigure write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rr.read_msg().await.map(|b| Reconfigured::decode(&b)) {
|
match io::read_msg(&mut rr)
|
||||||
|
.await
|
||||||
|
.map(|b| Reconfigured::decode(&b))
|
||||||
|
{
|
||||||
Ok(Ok(ack)) if ack.accepted => {
|
Ok(Ok(ack)) if ack.accepted => {
|
||||||
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
||||||
}
|
}
|
||||||
@@ -686,7 +685,10 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("SetBitrate write failed");
|
tracing::error!("SetBitrate write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rr.read_msg().await.map(|b| BitrateChanged::decode(&b)) {
|
match io::read_msg(&mut rr)
|
||||||
|
.await
|
||||||
|
.map(|b| BitrateChanged::decode(&b))
|
||||||
|
{
|
||||||
Ok(Ok(ack)) => tracing::info!(
|
Ok(Ok(ack)) => tracing::info!(
|
||||||
applied_kbps = ack.bitrate_kbps,
|
applied_kbps = ack.bitrate_kbps,
|
||||||
"BITRATE CHANGE acked by host"
|
"BITRATE CHANGE acked by host"
|
||||||
@@ -748,7 +750,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("ProbeRequest write failed");
|
tracing::error!("ProbeRequest write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let res = match sr.read_msg().await.map(|b| ProbeResult::decode(&b)) {
|
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
||||||
Ok(Ok(r)) => r,
|
Ok(Ok(r)) => r,
|
||||||
other => {
|
other => {
|
||||||
tracing::error!(?other, "bad ProbeResult");
|
tracing::error!(?other, "bad ProbeResult");
|
||||||
|
|||||||
@@ -27,13 +27,9 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
|
|||||||
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
||||||
# binary.
|
# binary.
|
||||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||||
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single
|
pf-presenter = { path = "../../crates/pf-presenter" }
|
||||||
# switch that turns the wavelet codec on, and it enables it explicitly on each. Inheriting their
|
|
||||||
# defaults instead would make `--no-default-features` a lie — the Windows ARM64 leg builds that
|
|
||||||
# way precisely to skip the vendored PyroWave C++, which has no ARM64 SIMD path.
|
|
||||||
pf-presenter = { path = "../../crates/pf-presenter", default-features = false }
|
|
||||||
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
|
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
|
||||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||||
serde_json = { version = "1", optional = true }
|
serde_json = { version = "1", optional = true }
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
v => v,
|
v => v,
|
||||||
},
|
},
|
||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
invert_scroll: settings_at_start.invert_scroll,
|
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||||
let fp_hex = trust::hex(&fingerprint);
|
let fp_hex = trust::hex(&fingerprint);
|
||||||
@@ -453,7 +452,6 @@ impl ServiceState {
|
|||||||
paired: false,
|
paired: false,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Err(e) = known.save() {
|
if let Err(e) = known.save() {
|
||||||
|
|||||||
@@ -103,14 +103,6 @@ mod session_main {
|
|||||||
force_software: Arc<AtomicBool>,
|
force_software: Arc<AtomicBool>,
|
||||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||||
) -> SessionParams {
|
) -> SessionParams {
|
||||||
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3), resolved
|
|
||||||
// here rather than passed in so every caller — a direct connect and the console's
|
|
||||||
// own launches — honors the same stored decision. `addr` is moved into the struct
|
|
||||||
// below, so read it first.
|
|
||||||
let clipboard = trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync);
|
|
||||||
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||||
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||||
// process. Applied per params-build (idempotent; browse re-launches included) so
|
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||||
@@ -173,7 +165,6 @@ mod session_main {
|
|||||||
// pump) pins one manually.
|
// pump) pins one manually.
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
mic_enabled: settings.mic_enabled,
|
mic_enabled: settings.mic_enabled,
|
||||||
clipboard,
|
|
||||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||||
@@ -268,66 +259,19 @@ mod session_main {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
|
||||||
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
|
||||||
if arg_flag("--list-adapters") {
|
|
||||||
return match pf_presenter::vk::list_adapters() {
|
|
||||||
Ok(names) => {
|
|
||||||
for n in names {
|
|
||||||
println!("{n}");
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("list-adapters: {e:#}");
|
|
||||||
EXIT_PRESENTER_FAILED
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
|
|
||||||
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
|
|
||||||
// same enumeration the GTK shell probes.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if arg_flag("--list-audio") {
|
|
||||||
return match pf_client_core::audio::devices() {
|
|
||||||
Ok((sinks, sources)) => {
|
|
||||||
for d in sinks {
|
|
||||||
println!("sink\t{}\t{}", d.name, d.description);
|
|
||||||
}
|
|
||||||
for d in sources {
|
|
||||||
println!("source\t{}\t{}", d.name, d.description);
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("list-audio: {e:#}");
|
|
||||||
EXIT_PRESENTER_FAILED
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
enable_radv_video_decode();
|
enable_radv_video_decode();
|
||||||
|
|
||||||
// The Settings device picks → env, unless the user already forced one by hand:
|
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
|
||||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
// name) → the presenter's device selection, unless the user already forced one.
|
||||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
|
||||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
|
||||||
// the RADV knob (covers --connect and --browse).
|
let adapter = trust::Settings::load().adapter;
|
||||||
{
|
if !adapter.is_empty() {
|
||||||
let s = trust::Settings::load();
|
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
|
||||||
for (var, value) in [
|
|
||||||
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
|
|
||||||
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
|
|
||||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
|
||||||
] {
|
|
||||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
|
||||||
std::env::set_var(var, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +373,6 @@ mod session_main {
|
|||||||
v => v,
|
v => v,
|
||||||
},
|
},
|
||||||
touch_mode: settings.touch_mode(),
|
touch_mode: settings.touch_mode(),
|
||||||
invert_scroll: settings.invert_scroll,
|
|
||||||
json_status: true,
|
json_status: true,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
// This host's card carries the accent bar in the desktop client now.
|
// This host's card carries the accent bar in the desktop client now.
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ repository.workspace = true
|
|||||||
name = "punktfunk-client"
|
name = "punktfunk-client"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
# The couch/HTPC Start-menu entry. Its own executable because an MSIX <Application> cannot
|
|
||||||
# pass arguments to a full-trust exe — see the binary's own docs.
|
|
||||||
[[bin]]
|
|
||||||
name = "punktfunk-console"
|
|
||||||
path = "src/bin/punktfunk-console.rs"
|
|
||||||
|
|
||||||
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
||||||
# other native clients live in clients/linux and clients/apple); on other
|
# other native clients live in clients/linux and clients/apple); on other
|
||||||
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
||||||
@@ -29,17 +23,7 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
|||||||
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
|
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
|
||||||
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
|
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
|
||||||
# data model (fetch + art pipeline) behind the library page.
|
# data model (fetch + art pipeline) behind the library page.
|
||||||
#
|
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||||
# `default-features = false` drops pf-client-core's default `pyrowave`, which would otherwise
|
|
||||||
# build the vendored PyroWave C++ INTO THE SHELL — dead weight here (the shell never decodes;
|
|
||||||
# it only offers "pyrowave" as a codec preference string the session binary acts on) and fatal
|
|
||||||
# on ARM64, where Granite's math falls back to x86 SSE intrinsics and stops at
|
|
||||||
# `simd.hpp: #error "Implement me."`. This does NOT drop PyroWave from the Windows client:
|
|
||||||
# decode lives in the spawned punktfunk-session binary, whose own default enables the feature,
|
|
||||||
# and cargo's feature unification turns it back on for the shared pf-client-core whenever that
|
|
||||||
# binary is in the same build (x64). On the ARM64 leg both are built --no-default-features, so
|
|
||||||
# nothing enables it and the C++ is never compiled.
|
|
||||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
|
||||||
|
|
||||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||||
|
|||||||
@@ -57,28 +57,6 @@
|
|||||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
||||||
</uap:VisualElements>
|
</uap:VisualElements>
|
||||||
</Application>
|
</Application>
|
||||||
<!--
|
|
||||||
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
|
|
||||||
desktop shell is the wrong first screen. Its own executable (punktfunk-console.exe)
|
|
||||||
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands
|
|
||||||
straight off to the session binary's controller-driven browse mode (host list,
|
|
||||||
pairing, settings, library) fullscreen.
|
|
||||||
|
|
||||||
NOTE: never write a double hyphen in this file. XML forbids it inside a comment, and
|
|
||||||
makepri rejects the whole manifest ("Appx manifest not found or is invalid") — which
|
|
||||||
is exactly how the console flag spelled out here broke the v0.15.0 MSIX build.
|
|
||||||
-->
|
|
||||||
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
|
|
||||||
EntryPoint="Windows.FullTrustApplication">
|
|
||||||
<uap:VisualElements
|
|
||||||
DisplayName="Punktfunk Console"
|
|
||||||
Description="Controller-driven couch interface for TVs and HTPCs"
|
|
||||||
BackgroundColor="transparent"
|
|
||||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
|
||||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
|
||||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
|
||||||
</uap:VisualElements>
|
|
||||||
</Application>
|
|
||||||
</Applications>
|
</Applications>
|
||||||
|
|
||||||
<Capabilities>
|
<Capabilities>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Nul
|
|||||||
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
|
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
|
||||||
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
||||||
# adds no DLLs of its own.
|
# adds no DLLs of its own.
|
||||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||||
foreach ($f in $required) {
|
foreach ($f in $required) {
|
||||||
$src = Join-Path $TargetDir $f
|
$src = Join-Path $TargetDir $f
|
||||||
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ pub(crate) fn initiate_waking(
|
|||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
) {
|
) {
|
||||||
if ctx.settings.lock().unwrap().auto_wake {
|
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
|
||||||
}
|
|
||||||
initiate_opts(ctx, target, set_screen, set_status, true)
|
initiate_opts(ctx, target, set_screen, set_status, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +272,6 @@ fn connect_spawn(
|
|||||||
paired: persist_paired,
|
paired: persist_paired,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
@@ -294,13 +291,9 @@ fn connect_spawn(
|
|||||||
*shared.target.lock().unwrap() = target.clone();
|
*shared.target.lock().unwrap() = target.clone();
|
||||||
ss.call(Screen::Pair);
|
ss.call(Screen::Pair);
|
||||||
}
|
}
|
||||||
Some((_, false))
|
Some((_, false)) if wake_on_fail => {
|
||||||
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
|
|
||||||
{
|
|
||||||
// The dial-first attempt to a non-advertising host failed — it
|
// The dial-first attempt to a non-advertising host failed — it
|
||||||
// may genuinely be asleep. NOW wake and wait. Skipped entirely
|
// may genuinely be asleep. NOW wake and wait.
|
||||||
// when auto-wake is off: the wait is only worth showing if we
|
|
||||||
// are actually sending magic packets to end it.
|
|
||||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||||
}
|
}
|
||||||
Some((msg, false)) => {
|
Some((msg, false)) => {
|
||||||
@@ -328,12 +321,9 @@ fn connect_spawn(
|
|||||||
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
|
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
|
||||||
/// the library window's `ready`, restored when the child exits (launched titles stream
|
/// the library window's `ready`, restored when the child exits (launched titles stream
|
||||||
/// in that same window, so the whole couch round-trip happens without the shell).
|
/// in that same window, so the whole couch round-trip happens without the shell).
|
||||||
/// `target = None` opens the console's own host view (discovery, pairing, settings) — the
|
|
||||||
/// couch entry point that isn't tied to one host; `Some` opens straight into that host's
|
|
||||||
/// library.
|
|
||||||
pub(crate) fn open_console(
|
pub(crate) fn open_console(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: Option<Target>,
|
target: Target,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
) {
|
) {
|
||||||
@@ -341,21 +331,15 @@ pub(crate) fn open_console(
|
|||||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||||
ctx.shared.stats_line.lock().unwrap().clear();
|
ctx.shared.stats_line.lock().unwrap().clear();
|
||||||
ctx.shared.browse.store(true, Ordering::SeqCst);
|
ctx.shared.browse.store(true, Ordering::SeqCst);
|
||||||
if let Some(t) = target.clone() {
|
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
*ctx.shared.target.lock().unwrap() = t;
|
|
||||||
}
|
|
||||||
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(Screen::Connecting);
|
set_screen.call(Screen::Connecting);
|
||||||
|
|
||||||
let shared = ctx.shared.clone();
|
let shared = ctx.shared.clone();
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let addr_port = target.as_ref().map(|t| (t.addr.clone(), t.port));
|
let spawned =
|
||||||
let spawned = crate::spawn::spawn_browse(
|
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
|
||||||
addr_port.as_ref().map(|(a, p)| (a.as_str(), *p)),
|
|
||||||
fullscreen,
|
|
||||||
child,
|
|
||||||
move |event| {
|
|
||||||
use crate::spawn::SpawnEvent;
|
use crate::spawn::SpawnEvent;
|
||||||
match event {
|
match event {
|
||||||
SpawnEvent::Ready => {
|
SpawnEvent::Ready => {
|
||||||
@@ -373,8 +357,7 @@ pub(crate) fn open_console(
|
|||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if let Err(e) = spawned {
|
if let Err(e) = spawned {
|
||||||
set_status.call(e);
|
set_status.call(e);
|
||||||
set_screen.call(Screen::Hosts);
|
set_screen.call(Screen::Hosts);
|
||||||
@@ -484,7 +467,6 @@ fn wake_and_connect(
|
|||||||
paired: false,
|
paired: false,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-192
@@ -1,5 +1,5 @@
|
|||||||
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
|
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
|
||||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / edit /
|
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
||||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||||
|
|
||||||
use super::connect::{initiate, initiate_waking, open_console};
|
use super::connect::{initiate, initiate_waking, open_console};
|
||||||
@@ -14,12 +14,10 @@ use windows_reactor::*;
|
|||||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||||
const MENU_CONNECT: &str = "Connect";
|
const MENU_CONNECT: &str = "Connect";
|
||||||
const MENU_LIBRARY: &str = "Browse library\u{2026}";
|
const MENU_LIBRARY: &str = "Browse library\u{2026}";
|
||||||
|
const MENU_CONSOLE: &str = "Open console UI";
|
||||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||||
const MENU_WAKE: &str = "Wake host";
|
const MENU_WAKE: &str = "Wake host";
|
||||||
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
|
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||||
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
|
|
||||||
/// that matter.
|
|
||||||
const MENU_EDIT: &str = "Edit\u{2026}";
|
|
||||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||||
@@ -189,114 +187,43 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host
|
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
||||||
/// property in one place, mirroring the Apple client's add/edit sheet — name, address,
|
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
||||||
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host.
|
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
|
||||||
/// Replaced a menu-item-per-property, which buried the useful entries in noise.
|
/// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
|
||||||
///
|
/// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
|
||||||
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state
|
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
|
||||||
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip
|
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
|
||||||
/// through a re-render.
|
fn rename_editor(
|
||||||
#[allow(clippy::too_many_arguments)]
|
initial: &str,
|
||||||
fn edit_editor(
|
fp: String,
|
||||||
fp: &str,
|
live: HookRef<String>,
|
||||||
initial_name: &str,
|
set_rename: AsyncSetState<Option<(String, String)>>,
|
||||||
name_draft: HookRef<String>,
|
|
||||||
addr_draft: HookRef<String>,
|
|
||||||
port_draft: HookRef<String>,
|
|
||||||
mac_draft: HookRef<String>,
|
|
||||||
clip_draft: HookRef<bool>,
|
|
||||||
set_edit: AsyncSetState<Option<(String, String)>>,
|
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let commit = {
|
let commit = {
|
||||||
let (fp, se) = (fp.to_string(), set_edit.clone());
|
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||||
let (name_draft, addr_draft, port_draft, mac_draft, clip_draft) = (
|
|
||||||
name_draft.clone(),
|
|
||||||
addr_draft.clone(),
|
|
||||||
port_draft.clone(),
|
|
||||||
mac_draft.clone(),
|
|
||||||
clip_draft.clone(),
|
|
||||||
);
|
|
||||||
move || {
|
move || {
|
||||||
let mut known = KnownHosts::load();
|
let draft = live.borrow();
|
||||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
let name = draft.trim();
|
||||||
// Each field falls back to what was stored: a cleared box means "leave it",
|
if !name.is_empty() {
|
||||||
// never "erase it" — except the MAC, which is legitimately clearable.
|
let mut known = KnownHosts::load();
|
||||||
let name = name_draft.borrow().trim().to_string();
|
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||||
if !name.is_empty() {
|
h.name = name.to_string();
|
||||||
h.name = name;
|
|
||||||
}
|
}
|
||||||
let addr = addr_draft.borrow().trim().to_string();
|
let _ = known.save();
|
||||||
if !addr.is_empty() {
|
|
||||||
h.addr = addr;
|
|
||||||
}
|
|
||||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
|
|
||||||
if p != 0 {
|
|
||||||
h.port = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mac = mac_draft.borrow().trim().to_string();
|
|
||||||
h.mac = if mac.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
mac.split(&[',', ' '][..])
|
|
||||||
.filter(|m| !m.trim().is_empty())
|
|
||||||
.map(|m| m.trim().to_string())
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
h.clipboard_sync = *clip_draft.borrow();
|
|
||||||
}
|
}
|
||||||
let _ = known.save();
|
sr.call(None);
|
||||||
se.call(None);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
|
let on_changed = {
|
||||||
vstack((
|
let live = live.clone();
|
||||||
text_block(label)
|
move |s: String| live.set(s)
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left),
|
|
||||||
text_box(&value)
|
|
||||||
.placeholder_text(placeholder)
|
|
||||||
.on_text_changed(move |t: String| draft.set(t)),
|
|
||||||
))
|
|
||||||
.spacing(2.0)
|
|
||||||
};
|
};
|
||||||
let (name0, addr0, port0, mac0, clip0) = (
|
|
||||||
name_draft.borrow().clone(),
|
|
||||||
addr_draft.borrow().clone(),
|
|
||||||
port_draft.borrow().clone(),
|
|
||||||
mac_draft.borrow().clone(),
|
|
||||||
*clip_draft.borrow(),
|
|
||||||
);
|
|
||||||
let _ = initial_name;
|
|
||||||
card(
|
card(
|
||||||
vstack((
|
vstack((
|
||||||
field("Name", name0, "e.g. Living Room", name_draft),
|
text_box(initial)
|
||||||
field("Address", addr0, "IP or hostname", addr_draft),
|
.placeholder_text("Host name")
|
||||||
field("Port", port0, "9777", port_draft),
|
.on_text_changed(on_changed),
|
||||||
field(
|
|
||||||
"MAC (Wake-on-LAN)",
|
|
||||||
mac0,
|
|
||||||
"auto-filled when known",
|
|
||||||
mac_draft,
|
|
||||||
),
|
|
||||||
vstack((
|
|
||||||
ToggleSwitch::new(clip0)
|
|
||||||
.header("Share clipboard with this host")
|
|
||||||
.on_content("On")
|
|
||||||
.off_content("Off")
|
|
||||||
.on_toggled(move |v: bool| clip_draft.set(v)),
|
|
||||||
text_block(
|
|
||||||
"Copy on one machine, paste on the other. Off for every host until you \
|
|
||||||
turn it on here; the host must allow it too.",
|
|
||||||
)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.wrap()
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left),
|
|
||||||
))
|
|
||||||
.spacing(4.0),
|
|
||||||
hstack((
|
hstack((
|
||||||
button("Save")
|
button("Save")
|
||||||
.accent()
|
.accent()
|
||||||
@@ -304,7 +231,7 @@ fn edit_editor(
|
|||||||
.on_click(commit),
|
.on_click(commit),
|
||||||
button("Cancel")
|
button("Cancel")
|
||||||
.subtle()
|
.subtle()
|
||||||
.on_click(move || set_edit.call(None)),
|
.on_click(move || set_rename.call(None)),
|
||||||
))
|
))
|
||||||
.spacing(4.0),
|
.spacing(4.0),
|
||||||
))
|
))
|
||||||
@@ -337,41 +264,16 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
let rename = props.rename.clone();
|
let rename = props.rename.clone();
|
||||||
let set_forget = &props.set_forget;
|
let set_forget = &props.set_forget;
|
||||||
let set_rename = &props.set_rename;
|
let set_rename = &props.set_rename;
|
||||||
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only
|
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
|
||||||
// the target's fingerprint + initial name, so typing never round-trips through a
|
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
|
||||||
// re-render. Every draft is re-seeded from the STORED host whenever the edit target
|
// TARGET changes (start, cancel, or a switch to another host).
|
||||||
// changes (open, cancel, or switching to another host).
|
let rename_draft = cx.use_ref(String::new());
|
||||||
let name_draft = cx.use_ref(String::new());
|
let rename_seed = cx.use_ref(Option::<String>::None);
|
||||||
let addr_draft = cx.use_ref(String::new());
|
|
||||||
let port_draft = cx.use_ref(String::new());
|
|
||||||
let mac_draft = cx.use_ref(String::new());
|
|
||||||
let clip_draft = cx.use_ref(false);
|
|
||||||
let edit_seed = cx.use_ref(Option::<String>::None);
|
|
||||||
{
|
{
|
||||||
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
||||||
if *edit_seed.borrow() != active {
|
if *rename_seed.borrow() != active {
|
||||||
let stored = active.as_ref().and_then(|fp| {
|
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||||
KnownHosts::load()
|
rename_seed.set(active);
|
||||||
.hosts
|
|
||||||
.into_iter()
|
|
||||||
.find(|h| &h.fp_hex == fp)
|
|
||||||
});
|
|
||||||
name_draft.set(stored.as_ref().map(|h| h.name.clone()).unwrap_or_default());
|
|
||||||
addr_draft.set(stored.as_ref().map(|h| h.addr.clone()).unwrap_or_default());
|
|
||||||
port_draft.set(
|
|
||||||
stored
|
|
||||||
.as_ref()
|
|
||||||
.map(|h| h.port.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
mac_draft.set(
|
|
||||||
stored
|
|
||||||
.as_ref()
|
|
||||||
.map(|h| h.mac.join(", "))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
clip_draft.set(stored.as_ref().is_some_and(|h| h.clipboard_sync));
|
|
||||||
edit_seed.set(active);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let hover = Hover {
|
let hover = Hover {
|
||||||
@@ -412,51 +314,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
.spacing(2.0)
|
.spacing(2.0)
|
||||||
.grid_column(0)
|
.grid_column(0)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
hstack({
|
hstack((
|
||||||
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add)
|
header_btn("Add host", Symbol::Add).accent().on_click({
|
||||||
.accent()
|
let sa = set_show_add.clone();
|
||||||
.on_click({
|
move || sa.call(true)
|
||||||
let sa = set_show_add.clone();
|
}),
|
||||||
move || sa.call(true)
|
header_btn("Shortcuts", Symbol::Keyboard).on_click({
|
||||||
})
|
let ss = set_screen.clone();
|
||||||
.into()];
|
move || ss.call(Screen::Help)
|
||||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
}),
|
||||||
// where the session binary ships without its Skia console.
|
header_btn("Settings", Symbol::Setting).on_click({
|
||||||
if CONSOLE_UI_AVAILABLE {
|
let ss = set_screen.clone();
|
||||||
actions.push(
|
move || ss.call(Screen::Settings)
|
||||||
header_btn("Console UI", Symbol::Play)
|
}),
|
||||||
.tooltip(
|
))
|
||||||
"The controller-driven couch interface \u{2014} host list, \
|
|
||||||
pairing and libraries, launching streams in the same window.",
|
|
||||||
)
|
|
||||||
.on_click({
|
|
||||||
let (c, ss, st) =
|
|
||||||
(ctx.clone(), set_screen.clone(), set_status.clone());
|
|
||||||
// No target: the console opens its OWN host view rather than
|
|
||||||
// one host's library — the couch counterpart of this page.
|
|
||||||
move || open_console(&c, None, &ss, &st)
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
actions.push(
|
|
||||||
header_btn("Shortcuts", Symbol::Keyboard)
|
|
||||||
.on_click({
|
|
||||||
let ss = set_screen.clone();
|
|
||||||
move || ss.call(Screen::Help)
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
actions.push(
|
|
||||||
header_btn("Settings", Symbol::Setting)
|
|
||||||
.on_click({
|
|
||||||
let ss = set_screen.clone();
|
|
||||||
move || ss.call(Screen::Settings)
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
actions
|
|
||||||
})
|
|
||||||
.spacing(8.0)
|
.spacing(8.0)
|
||||||
.grid_column(1)
|
.grid_column(1)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
@@ -476,23 +347,84 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A controller is connected and a paired host is REACHABLE (advertising or probed —
|
||||||
|
// an offline host would just open the console onto an error scene): offer the couch
|
||||||
|
// experience — the console (gamepad) UI on the most recently used such host.
|
||||||
|
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
|
||||||
|
let reachable = |k: &&crate::trust::KnownHost| {
|
||||||
|
hosts
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||||
|
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false)
|
||||||
|
};
|
||||||
|
if let Some(k) = known
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.filter(|h| h.paired)
|
||||||
|
.filter(reachable)
|
||||||
|
.max_by_key(|h| h.last_used.unwrap_or(0))
|
||||||
|
{
|
||||||
|
let target = Target {
|
||||||
|
name: k.name.clone(),
|
||||||
|
addr: k.addr.clone(),
|
||||||
|
port: k.port,
|
||||||
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
|
pair_optional: false,
|
||||||
|
mac: k.mac.clone(),
|
||||||
|
};
|
||||||
|
let svc = props.svc.clone();
|
||||||
|
body.push(
|
||||||
|
card(
|
||||||
|
grid((
|
||||||
|
vstack((
|
||||||
|
text_block("Controller detected").font_size(14.0).semibold(),
|
||||||
|
text_block(format!(
|
||||||
|
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
|
||||||
|
launches stream in the same window.",
|
||||||
|
k.name
|
||||||
|
))
|
||||||
|
.font_size(12.0)
|
||||||
|
.wrap()
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(2.0)
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
button("Open console UI")
|
||||||
|
.accent()
|
||||||
|
.icon(Symbol::Play)
|
||||||
|
.on_click(move || {
|
||||||
|
open_console(
|
||||||
|
&svc.ctx,
|
||||||
|
target.clone(),
|
||||||
|
&svc.set_screen,
|
||||||
|
&svc.set_status,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.margin(edges(12.0, 0.0, 0.0, 0.0)),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Star(1.0), GridLength::Auto]),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
|
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
|
||||||
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
||||||
if !known.hosts.is_empty() {
|
if !known.hosts.is_empty() {
|
||||||
body.push(section("SAVED HOSTS"));
|
body.push(section("SAVED HOSTS"));
|
||||||
let mut tiles: Vec<Element> = Vec::new();
|
let mut tiles: Vec<Element> = Vec::new();
|
||||||
for k in &known.hosts {
|
for k in &known.hosts {
|
||||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
|
||||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||||
let (fp, initial) = rename.clone().unwrap();
|
let (fp, initial) = rename.clone().unwrap();
|
||||||
tiles.push(edit_editor(
|
tiles.push(rename_editor(
|
||||||
&fp,
|
|
||||||
&initial,
|
&initial,
|
||||||
name_draft.clone(),
|
fp,
|
||||||
addr_draft.clone(),
|
rename_draft.clone(),
|
||||||
port_draft.clone(),
|
|
||||||
mac_draft.clone(),
|
|
||||||
clip_draft.clone(),
|
|
||||||
set_rename.clone(),
|
set_rename.clone(),
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
@@ -539,12 +471,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
if library_enabled && k.paired {
|
if library_enabled && k.paired {
|
||||||
items.push(menu_item(MENU_LIBRARY));
|
items.push(menu_item(MENU_LIBRARY));
|
||||||
}
|
}
|
||||||
|
if CONSOLE_UI_AVAILABLE && k.paired {
|
||||||
|
items.push(menu_item(MENU_CONSOLE));
|
||||||
|
}
|
||||||
items.push(menu_item(MENU_SPEED));
|
items.push(menu_item(MENU_SPEED));
|
||||||
// Offer an explicit wake only when the host is offline and we have a MAC.
|
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||||
if can_wake {
|
if can_wake {
|
||||||
items.push(menu_item(MENU_WAKE));
|
items.push(menu_item(MENU_WAKE));
|
||||||
}
|
}
|
||||||
items.push(menu_item(MENU_EDIT));
|
items.push(menu_item(MENU_RENAME));
|
||||||
items.push(menu_separator());
|
items.push(menu_separator());
|
||||||
items.push(menu_item(MENU_FORGET));
|
items.push(menu_item(MENU_FORGET));
|
||||||
items
|
items
|
||||||
@@ -558,6 +493,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
super::library::start_fetch(&svc.ctx, &svc.set_library);
|
super::library::start_fetch(&svc.ctx, &svc.set_library);
|
||||||
svc.set_screen.call(Screen::Library);
|
svc.set_screen.call(Screen::Library);
|
||||||
}
|
}
|
||||||
|
MENU_CONSOLE => {
|
||||||
|
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||||
|
}
|
||||||
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||||
MENU_SPEED => {
|
MENU_SPEED => {
|
||||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
@@ -569,7 +507,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
svc.set_speed.call(SpeedState::Running);
|
svc.set_speed.call(SpeedState::Running);
|
||||||
svc.set_screen.call(Screen::SpeedTest);
|
svc.set_screen.call(Screen::SpeedTest);
|
||||||
}
|
}
|
||||||
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))),
|
MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
|
||||||
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
|
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -241,8 +241,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
|
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
|
||||||
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
||||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||||
// Opens on General — the first sidebar item, matching the Apple client's landing category.
|
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||||
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
|
|
||||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
paired: true,
|
paired: true,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: target3.mac.clone(),
|
mac: target3.mac.clone(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||||
|
|||||||
+126
-324
@@ -1,15 +1,5 @@
|
|||||||
//! The settings screen. Every control writes straight back to the persisted [`Settings`]
|
//! The settings screen. Every control writes straight back to the persisted [`Settings`]
|
||||||
//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders.
|
//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders.
|
||||||
//!
|
|
||||||
//! **Structure mirrors the Apple client's 2026-07 settings revamp** (its
|
|
||||||
//! `SettingsCategory` + `SettingsView+Sections.swift`), so the two desktop clients read the
|
|
||||||
//! same way: General = session/app behavior, Display = everything about the picture,
|
|
||||||
//! Input = touch/keyboard/mouse, Audio, Controllers, About. Each field carries its
|
|
||||||
//! explanation DIRECTLY under it ([`described`]) rather than only on hover — the same move
|
|
||||||
//! Apple made, for the same reason (guidance nobody hovers for is guidance nobody reads).
|
|
||||||
//! Wording is shared verbatim wherever the setting means the same thing on both platforms;
|
|
||||||
//! where the BEHAVIOR differs the text is deliberately Windows-specific (the forwarded-
|
|
||||||
//! controller picker especially: Apple forwards one pad, this client forwards them all).
|
|
||||||
|
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{AppCtx, Screen};
|
use super::{AppCtx, Screen};
|
||||||
@@ -48,8 +38,7 @@ fn render_scale_label(scale: f64) -> String {
|
|||||||
// Automatic — which is exactly how the session's decoder chain reads that value.
|
// Automatic — which is exactly how the session's decoder chain reads that value.
|
||||||
const DECODERS: &[(&str, &str)] = &[
|
const DECODERS: &[(&str, &str)] = &[
|
||||||
("auto", "Automatic (GPU, fall back to CPU)"),
|
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||||
("vulkan", "Hardware (Vulkan Video)"),
|
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||||
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
|
||||||
("software", "Software (CPU)"),
|
("software", "Software (CPU)"),
|
||||||
];
|
];
|
||||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||||
@@ -62,9 +51,6 @@ const CODECS: &[(&str, &str)] = &[
|
|||||||
("hevc", "HEVC (H.265)"),
|
("hevc", "HEVC (H.265)"),
|
||||||
("h264", "H.264 (AVC)"),
|
("h264", "H.264 (AVC)"),
|
||||||
("av1", "AV1"),
|
("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
|
/// 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.
|
/// GTK client offers; "Automatic" resolves from the physical controller at connect.
|
||||||
@@ -148,63 +134,11 @@ fn setting_toggle(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One field: the control with its explanation directly underneath (Apple's `described`).
|
/// A settings card: just the controls. No heading (the section title is the NavigationView
|
||||||
///
|
/// header) and no description paragraph — per-control guidance is a `.tooltip(...)` on the
|
||||||
/// The caption goes BELOW the control on purpose. An earlier revision put guidance only in
|
/// control itself (a paragraph in the card reads as the first control's label).
|
||||||
/// hover tooltips because a paragraph *above* a control reads as that control's label — true,
|
fn settings_card(controls: Vec<Element>) -> Element {
|
||||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
card(vstack(controls).spacing(10.0)).into()
|
||||||
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
|
|
||||||
/// full-width caption runs into the control column and the whole cell reads as one block.
|
|
||||||
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
|
||||||
vstack((
|
|
||||||
control.into(),
|
|
||||||
text_block(caption)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.wrap()
|
|
||||||
.max_width(420.0)
|
|
||||||
// Stretch (the TextBlock default) CENTRES a MaxWidth-capped block in the leftover
|
|
||||||
// width — the caption must be pinned left or it drifts away from its control.
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left),
|
|
||||||
))
|
|
||||||
.spacing(5.0)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A settings sub-section heading. Deliberately NOT the shared [`section`] helper: that one
|
|
||||||
/// carries a 2px left inset (fine over the hosts/licenses lists it was written for), which
|
|
||||||
/// here left every heading hanging one nudge right of the card edge below it. Flush left, so
|
|
||||||
/// heading and card share one line.
|
|
||||||
fn group_heading(label: &str) -> Element {
|
|
||||||
text_block(label)
|
|
||||||
.font_size(12.0)
|
|
||||||
.semibold()
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left)
|
|
||||||
.margin(edges(0.0, 14.0, 0.0, 2.0))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One settings group: an optional sub-section label, a card of fields, and an optional
|
|
||||||
/// form-level note under it (Apple's Section header/footer). Groups stack down the page.
|
|
||||||
fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Vec<Element> {
|
|
||||||
let mut out = Vec::with_capacity(3);
|
|
||||||
if let Some(h) = header {
|
|
||||||
out.push(group_heading(h));
|
|
||||||
}
|
|
||||||
out.push(card(vstack(fields).spacing(14.0)).into());
|
|
||||||
if let Some(f) = footer {
|
|
||||||
out.push(
|
|
||||||
text_block(f)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.wrap()
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left)
|
|
||||||
.margin(edges(0.0, 6.0, 0.0, 0.0))
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
|
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
|
||||||
@@ -249,7 +183,12 @@ pub(crate) fn settings_page(
|
|||||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||||
s.match_window = i == 1;
|
s.match_window = i == 1;
|
||||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
|
||||||
|
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
|
||||||
|
follows the stream window, including mid-stream resizes.",
|
||||||
|
);
|
||||||
let (hz_names, hz_i) = {
|
let (hz_names, hz_i) = {
|
||||||
let names: Vec<String> = REFRESH
|
let names: Vec<String> = REFRESH
|
||||||
.iter()
|
.iter()
|
||||||
@@ -266,7 +205,8 @@ pub(crate) fn settings_page(
|
|||||||
};
|
};
|
||||||
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
|
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||||
s.refresh_hz = REFRESH[i];
|
s.refresh_hz = REFRESH[i];
|
||||||
});
|
})
|
||||||
|
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
|
||||||
let (scale_names, scale_i) = {
|
let (scale_names, scale_i) = {
|
||||||
let names: Vec<String> = RENDER_SCALES
|
let names: Vec<String> = RENDER_SCALES
|
||||||
.iter()
|
.iter()
|
||||||
@@ -280,26 +220,36 @@ pub(crate) fn settings_page(
|
|||||||
};
|
};
|
||||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
||||||
s.render_scale = RENDER_SCALES[i];
|
s.render_scale = RENDER_SCALES[i];
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
|
||||||
|
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
|
||||||
|
);
|
||||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||||
s.compositor = COMPOSITORS[i].0.to_string();
|
s.compositor = COMPOSITORS[i].0.to_string();
|
||||||
});
|
})
|
||||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
.tooltip(
|
||||||
s.auto_wake = on
|
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||||
});
|
choice is unavailable.",
|
||||||
|
);
|
||||||
let fullscreen_toggle = setting_toggle(
|
let fullscreen_toggle = setting_toggle(
|
||||||
ctx,
|
ctx,
|
||||||
"Start streams fullscreen",
|
"Start streams fullscreen",
|
||||||
s.fullscreen_on_stream,
|
s.fullscreen_on_stream,
|
||||||
|s, on| s.fullscreen_on_stream = on,
|
|s, on| s.fullscreen_on_stream = on,
|
||||||
);
|
)
|
||||||
|
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
|
||||||
|
|
||||||
// --- Video -----------------------------------------------------------------------------
|
// --- Video -----------------------------------------------------------------------------
|
||||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||||
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
||||||
s.decoder = DECODERS[i].0.to_string();
|
s.decoder = DECODERS[i].0.to_string();
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Hardware decode (Vulkan Video) is far lighter than software \u{2014} keep it on \
|
||||||
|
Automatic unless debugging.",
|
||||||
|
);
|
||||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||||
let gpus = crate::gpu::adapter_names();
|
let gpus = crate::gpu::adapter_names();
|
||||||
@@ -318,11 +268,18 @@ pub(crate) fn settings_page(
|
|||||||
gpus[i - 1].clone()
|
gpus[i - 1].clone()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Which adapter decodes and presents the stream. Applies to the next stream; \
|
||||||
|
Automatic uses the GPU driving this window's display.",
|
||||||
|
)
|
||||||
});
|
});
|
||||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||||
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||||
s.codec = CODECS[i].0.to_string();
|
s.codec = CODECS[i].0.to_string();
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"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
|
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||||
// round-trips exactly.
|
// round-trips exactly.
|
||||||
let bitrate_box = {
|
let bitrate_box = {
|
||||||
@@ -335,10 +292,18 @@ pub(crate) fn settings_page(
|
|||||||
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
|
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
|
||||||
s.save();
|
s.save();
|
||||||
})
|
})
|
||||||
|
.tooltip(
|
||||||
|
"0 lets the host decide. Run a per-host speed test from the host list for a \
|
||||||
|
recommendation.",
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
||||||
s.hdr_enabled = on
|
s.hdr_enabled = on
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
|
||||||
|
SDR content is unaffected.",
|
||||||
|
);
|
||||||
|
|
||||||
// --- Input -----------------------------------------------------------------------------
|
// --- Input -----------------------------------------------------------------------------
|
||||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||||
@@ -383,44 +348,60 @@ pub(crate) fn settings_page(
|
|||||||
s.forward_pad = key.unwrap_or_default();
|
s.forward_pad = key.unwrap_or_default();
|
||||||
s.save();
|
s.save();
|
||||||
})
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Every connected controller is forwarded, each as its own player. Pick one \
|
||||||
|
to force single-player \u{2014} only it reaches the host.",
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||||
});
|
});
|
||||||
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||||
s.gamepad = GAMEPADS[i].0.to_string();
|
s.gamepad = GAMEPADS[i].0.to_string();
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
|
||||||
|
controller.",
|
||||||
|
);
|
||||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||||
});
|
})
|
||||||
let invert_scroll_toggle =
|
.tooltip(
|
||||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
|
||||||
s.invert_scroll = on
|
pointer jumps to your finger, Touch passthrough sends real touches.",
|
||||||
});
|
);
|
||||||
let shortcuts_toggle = setting_toggle(
|
let shortcuts_toggle = setting_toggle(
|
||||||
ctx,
|
ctx,
|
||||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||||
s.inhibit_shortcuts,
|
s.inhibit_shortcuts,
|
||||||
|s, on| s.inhibit_shortcuts = on,
|
|s, on| s.inhibit_shortcuts = on,
|
||||||
);
|
)
|
||||||
|
.tooltip("Off: Alt+Tab, Win & co. act on this machine while the stream input is captured.");
|
||||||
|
|
||||||
// --- Audio -----------------------------------------------------------------------------
|
// --- Audio -----------------------------------------------------------------------------
|
||||||
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
||||||
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
|
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
|
||||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||||
});
|
})
|
||||||
|
.tooltip("The host downmixes if its output has fewer channels.");
|
||||||
let mic_toggle = setting_toggle(
|
let mic_toggle = setting_toggle(
|
||||||
ctx,
|
ctx,
|
||||||
"Stream microphone to the host",
|
"Stream microphone to the host",
|
||||||
s.mic_enabled,
|
s.mic_enabled,
|
||||||
|s, on| s.mic_enabled = on,
|
|s, on| s.mic_enabled = on,
|
||||||
);
|
)
|
||||||
|
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
||||||
|
|
||||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||||
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
|
||||||
|
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
|
||||||
|
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
|
||||||
|
);
|
||||||
|
|
||||||
let licenses_button = {
|
let licenses_button = {
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
@@ -431,6 +412,10 @@ pub(crate) fn settings_page(
|
|||||||
"Show game library (experimental)",
|
"Show game library (experimental)",
|
||||||
s.library_enabled,
|
s.library_enabled,
|
||||||
|s, on| s.library_enabled = on,
|
|s, on| s.library_enabled = on,
|
||||||
|
)
|
||||||
|
.tooltip(
|
||||||
|
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
|
||||||
|
launches in the stream. Mirrors the Apple client's toggle.",
|
||||||
);
|
);
|
||||||
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
||||||
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||||
@@ -443,227 +428,70 @@ pub(crate) fn settings_page(
|
|||||||
))
|
))
|
||||||
.spacing(2.0);
|
.spacing(2.0);
|
||||||
|
|
||||||
// The selected section's content, grouped exactly like the Apple client's categories
|
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
||||||
// (SettingsCategory + SettingsView+Sections.swift). Each field's explanation sits under
|
// card is just the controls.
|
||||||
// it; the only form-level notes are the "applies from the next session" footers, matching
|
let (title, card): (&str, Element) = match section {
|
||||||
// Apple's decision to keep exactly one of those per affected category.
|
"video" => (
|
||||||
let (title, groups): (&str, Vec<Element>) = match section {
|
"Video",
|
||||||
"display" => {
|
settings_card({
|
||||||
let mut out = group(
|
let mut controls: Vec<Element> = vec![decoder_combo.into()];
|
||||||
Some("Resolution"),
|
if let Some(c) = gpu_combo {
|
||||||
vec![
|
controls.push(c.into());
|
||||||
described(
|
}
|
||||||
res_combo,
|
controls.extend([
|
||||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
codec_combo.into(),
|
||||||
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
|
bitrate_box.into(),
|
||||||
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
hdr_toggle.into(),
|
||||||
(1:1) through every resize.",
|
hud_combo.into(),
|
||||||
),
|
]);
|
||||||
described(
|
controls
|
||||||
hz_combo,
|
}),
|
||||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
),
|
||||||
connect.",
|
"input" => (
|
||||||
),
|
"Input",
|
||||||
],
|
settings_card(vec![
|
||||||
None,
|
forward_combo.into(),
|
||||||
);
|
pad_combo.into(),
|
||||||
out.extend(group(
|
touch_combo.into(),
|
||||||
Some("Quality"),
|
shortcuts_toggle.into(),
|
||||||
vec![
|
]),
|
||||||
described(
|
|
||||||
scale_combo,
|
|
||||||
"Above native supersamples for sharpness; below renders lighter on the \
|
|
||||||
host and the link. This device resamples the result to the window.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
bitrate_box,
|
|
||||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
|
||||||
host card\u{2019}s context menu has a network speed test.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
codec_combo,
|
|
||||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
|
||||||
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 wants \
|
|
||||||
gigabit Ethernet.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
hdr_toggle,
|
|
||||||
"HDR10, when the host has HDR content and this display supports it. \
|
|
||||||
HEVC only; otherwise the stream stays SDR.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
out.extend(group(
|
|
||||||
Some("Decoding"),
|
|
||||||
{
|
|
||||||
let mut fields = vec![described(
|
|
||||||
decoder_combo,
|
|
||||||
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
|
|
||||||
11 on Intel, Vulkan Video on NVIDIA and AMD \u{2014} and falls back to \
|
|
||||||
the CPU. Change it only when debugging.",
|
|
||||||
)];
|
|
||||||
if let Some(c) = gpu_combo {
|
|
||||||
fields.push(described(
|
|
||||||
c,
|
|
||||||
"Which adapter decodes and presents the stream. Automatic uses the \
|
|
||||||
GPU driving this window\u{2019}s display.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
fields
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
out.extend(group(
|
|
||||||
Some("Host output"),
|
|
||||||
vec![described(
|
|
||||||
comp_combo,
|
|
||||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
|
||||||
specific choice falls back to auto-detection when that backend \
|
|
||||||
isn\u{2019}t available.",
|
|
||||||
)],
|
|
||||||
// The one form-level note, exactly as on Apple.
|
|
||||||
Some("Display changes apply from the next session."),
|
|
||||||
));
|
|
||||||
("Display", out)
|
|
||||||
}
|
|
||||||
"input" => {
|
|
||||||
let mut out = group(
|
|
||||||
Some("Touch & pointer"),
|
|
||||||
vec![described(
|
|
||||||
touch_combo,
|
|
||||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
|
||||||
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
|
|
||||||
you touch, Touch passthrough sends real multi-touch through.",
|
|
||||||
)],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
out.extend(group(
|
|
||||||
Some("Keyboard & mouse"),
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
shortcuts_toggle,
|
|
||||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
|
||||||
has input captured. Off, they act on this machine instead.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
invert_scroll_toggle,
|
|
||||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
("Input", out)
|
|
||||||
}
|
|
||||||
"controllers" => (
|
|
||||||
"Controllers",
|
|
||||||
group(
|
|
||||||
None,
|
|
||||||
vec![
|
|
||||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
|
||||||
// forwards every controller as its own player. Same picker, different rule.
|
|
||||||
described(
|
|
||||||
forward_combo,
|
|
||||||
"Every connected controller is forwarded, each as its own player. Pick \
|
|
||||||
one to force single-player \u{2014} only it reaches the host.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
pad_combo,
|
|
||||||
"The virtual pad created on the host. Automatic matches your controller \
|
|
||||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
|
||||||
motion.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Some("Applies from the next session."),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
"audio" => (
|
"audio" => (
|
||||||
"Audio",
|
"Audio",
|
||||||
group(
|
settings_card(vec![channels_combo.into(), mic_toggle.into()]),
|
||||||
None,
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
channels_combo,
|
|
||||||
"The speaker layout requested from the host. It downmixes if its own \
|
|
||||||
output has fewer channels.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
mic_toggle,
|
|
||||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Some("Applies from the next session."),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
"about" => (
|
"about" => (
|
||||||
"About",
|
"About",
|
||||||
group(
|
settings_card(vec![
|
||||||
None,
|
about_identity.into(),
|
||||||
vec![about_identity.into(), licenses_button.into()],
|
library_toggle.into(),
|
||||||
None,
|
licenses_button.into(),
|
||||||
),
|
]),
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
"Display",
|
||||||
|
settings_card(vec![
|
||||||
|
res_combo.into(),
|
||||||
|
hz_combo.into(),
|
||||||
|
scale_combo.into(),
|
||||||
|
fullscreen_toggle.into(),
|
||||||
|
comp_combo.into(),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
// "general" and anything unrecognized.
|
|
||||||
_ => {
|
|
||||||
let mut out = group(
|
|
||||||
Some("Session"),
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
fullscreen_toggle,
|
|
||||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
|
||||||
live.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
auto_wake_toggle,
|
|
||||||
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
|
|
||||||
waits for it to boot. Turn off if hosts behind a VPN look offline when \
|
|
||||||
they aren\u{2019}t.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
out.extend(group(
|
|
||||||
Some("Statistics"),
|
|
||||||
vec![described(
|
|
||||||
hud_combo,
|
|
||||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
|
||||||
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
|
|
||||||
tiers any time.",
|
|
||||||
)],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
out.extend(group(
|
|
||||||
Some("Library"),
|
|
||||||
vec![described(
|
|
||||||
library_toggle,
|
|
||||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
|
|
||||||
their Steam and custom games and launch one directly. No extra host setup.",
|
|
||||||
)],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
("General", out)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card
|
// The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card
|
||||||
// as content, the NavigationView's own back arrow returning to the host list. Auto display
|
// as content, the NavigationView's own back arrow returning to the host list. Auto display
|
||||||
// mode collapses the pane on a narrow window, exactly like Windows Settings.
|
// mode collapses the pane on a narrow window, exactly like Windows Settings.
|
||||||
// Category order mirrors the Apple client's sidebar exactly.
|
|
||||||
let items = vec![
|
let items = vec![
|
||||||
NavViewItem::new("General")
|
|
||||||
.tag("general")
|
|
||||||
.icon(Symbol::Setting),
|
|
||||||
NavViewItem::new("Display")
|
NavViewItem::new("Display")
|
||||||
.tag("display")
|
.tag("display")
|
||||||
.icon(Symbol::FullScreen),
|
.icon(Symbol::FullScreen),
|
||||||
|
NavViewItem::new("Video").tag("video").icon(Symbol::Video),
|
||||||
NavViewItem::new("Input")
|
NavViewItem::new("Input")
|
||||||
.tag("input")
|
.tag("input")
|
||||||
.icon(Symbol::Keyboard),
|
.icon(Symbol::Keyboard),
|
||||||
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
|
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
|
||||||
NavViewItem::new("Controllers")
|
|
||||||
.tag("controllers")
|
|
||||||
.icon(Symbol::Play),
|
|
||||||
NavViewItem::new("About").tag("about").icon(Symbol::Help),
|
NavViewItem::new("About").tag("about").icon(Symbol::Help),
|
||||||
];
|
];
|
||||||
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
|
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
|
||||||
@@ -674,38 +502,12 @@ pub(crate) fn settings_page(
|
|||||||
//
|
//
|
||||||
// The content column (not the NavigationView — the sidebar must stay put) carries the
|
// The content column (not the NavigationView — the sidebar must stay put) carries the
|
||||||
// section-switch entrance: fade + slide-up from the root-driven tween.
|
// section-switch entrance: fade + slide-up from the root-driven tween.
|
||||||
// No max-width cap here (unlike the other pages): the NavigationView already spends the
|
let content = page_wide(vec![card.with_key(section)])
|
||||||
// left third on its pane, so a 640-wide column left the cards as a narrow ribbon.
|
.opacity(progress)
|
||||||
// The category title is rendered HERE, not via NavigationView's Header: that header's
|
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||||
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
|
|
||||||
// sat noticeably right of the cards under it. In the content column it shares the cards'
|
|
||||||
// left edge by construction.
|
|
||||||
let titled: Vec<Element> = std::iter::once(
|
|
||||||
text_block(title)
|
|
||||||
.font_size(28.0)
|
|
||||||
.semibold()
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left)
|
|
||||||
.margin(edges(0.0, 0.0, 0.0, 6.0))
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
.chain(groups)
|
|
||||||
.collect();
|
|
||||||
// The keyed column MUST sit inside a panel's child list, not directly under the
|
|
||||||
// scroll_view: `ScrollView::children()` is `Children::PositionalSingle`, which
|
|
||||||
// reconciles its one child POSITIONALLY and ignores keys outright. Keyed straight onto
|
|
||||||
// the scroll_view's child, the section switch silently diffs one section's controls into
|
|
||||||
// another's — which re-sets each reused ComboBox's items (clearing WinUI's selection)
|
|
||||||
// but skips `selected_index` whenever the two sections' values compare equal, so the
|
|
||||||
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
|
|
||||||
// remounts the whole column and every prop is applied fresh.
|
|
||||||
let content = scroll_view(
|
|
||||||
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
|
|
||||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
|
||||||
)
|
|
||||||
.opacity(progress)
|
|
||||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
|
||||||
NavigationView::new(items, content)
|
NavigationView::new(items, content)
|
||||||
.pane_title("Settings")
|
.pane_title("Settings")
|
||||||
|
.header(title)
|
||||||
.selected_tag(section)
|
.selected_tag(section)
|
||||||
.on_selection_changed({
|
.on_selection_changed({
|
||||||
let ss = set_section.clone();
|
let ss = set_section.clone();
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
//! `punktfunk-console.exe` — the couch/HTPC entry point.
|
|
||||||
//!
|
|
||||||
//! Exists because an MSIX `<Application>` cannot pass ARGUMENTS to a full-trust executable:
|
|
||||||
//! a second Start-menu tile therefore cannot simply be "punktfunk-client.exe --console", it
|
|
||||||
//! needs its own executable. This is that executable, and it is deliberately nothing but a
|
|
||||||
//! hand-off — it starts the session binary's `--browse` mode (the complete controller-driven
|
|
||||||
//! client: host list, discovery, PIN pairing, settings, Wake-on-LAN, library) fullscreen and
|
|
||||||
//! mirrors its exit code, so whatever supervises this process sees the real result.
|
|
||||||
//!
|
|
||||||
//! `--windowed` keeps it in a window; everything else is the session binary's own business.
|
|
||||||
|
|
||||||
// No console window: this is launched from a Start-menu tile / shortcut, and a flashing
|
|
||||||
// console behind the couch UI looks like a crash.
|
|
||||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn main() {
|
|
||||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
|
||||||
let session = std::env::current_exe()
|
|
||||||
.ok()
|
|
||||||
.map(|e| e.with_file_name("punktfunk-session.exe"))
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.unwrap_or_else(|| "punktfunk-session".into());
|
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new(session);
|
|
||||||
cmd.arg("--browse");
|
|
||||||
if !std::env::args().any(|a| a == "--windowed") {
|
|
||||||
cmd.arg("--fullscreen");
|
|
||||||
}
|
|
||||||
match cmd.status() {
|
|
||||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
|
||||||
Err(_) => std::process::exit(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn main() {}
|
|
||||||
+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.
|
/// 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.
|
/// 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> {
|
pub fn adapter_names() -> Vec<String> {
|
||||||
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
|
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
|
||||||
let mut names: Vec<String> = Vec::new();
|
all_adapters()
|
||||||
for a in all_adapters() {
|
.iter()
|
||||||
let desc1 = a
|
.filter(|a| {
|
||||||
.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
a.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||||
.ok();
|
.map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0)
|
||||||
let name = adapter_name(&a);
|
.unwrap_or(true)
|
||||||
// Forensics for the next duplicate/oddity report — which adapters DXGI actually
|
})
|
||||||
// returned, and whether the repeats share a LUID (one adapter enumerated twice)
|
.map(adapter_name)
|
||||||
// or are distinct devices that merely present the same description.
|
.collect()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,27 +76,6 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `--console`: go straight to the gamepad/couch UI, skipping the WinUI shell entirely —
|
|
||||||
// the HTPC entry point (a Start-menu tile, a Steam shortcut, a startup item). The session
|
|
||||||
// binary's bare `--browse` IS a complete standalone client: host list, discovery, PIN
|
|
||||||
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
|
|
||||||
// its exit code, so anything supervising this process sees the real result.
|
|
||||||
if flag("--console") {
|
|
||||||
let mut cmd = std::process::Command::new(spawn::session_binary());
|
|
||||||
cmd.arg("--browse");
|
|
||||||
// A couch UI is fullscreen unless explicitly told otherwise.
|
|
||||||
if !flag("--windowed") {
|
|
||||||
cmd.arg("--fullscreen");
|
|
||||||
}
|
|
||||||
match cmd.status() {
|
|
||||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("could not start the console UI: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
|
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
|
||||||
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
|
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
|
||||||
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
|
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
|
||||||
|
|||||||
@@ -126,26 +126,21 @@ pub(crate) fn spawn_session(
|
|||||||
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
||||||
/// window presents, `error` on a failed start, EOF on quit.
|
/// window presents, `error` on a failed start, EOF on quit.
|
||||||
pub(crate) fn spawn_browse(
|
pub(crate) fn spawn_browse(
|
||||||
target: Option<(&str, u16)>,
|
addr: &str,
|
||||||
|
port: u16,
|
||||||
fullscreen: bool,
|
fullscreen: bool,
|
||||||
slot: SessionChild,
|
slot: SessionChild,
|
||||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut cmd = Command::new(session_binary());
|
let mut cmd = Command::new(session_binary());
|
||||||
cmd.arg("--browse");
|
cmd.arg("--browse")
|
||||||
// A target opens straight into that host's library; bare `--browse` opens the console's
|
.arg(format!("{addr}:{port}"))
|
||||||
// OWN host view (discovery, pairing, settings, Wake-on-LAN) — the couch equivalent of
|
.arg("--json-status");
|
||||||
// the shell's hosts page.
|
|
||||||
if let Some((addr, port)) = target {
|
|
||||||
cmd.arg(format!("{addr}:{port}"));
|
|
||||||
}
|
|
||||||
cmd.arg("--json-status");
|
|
||||||
if fullscreen {
|
if fullscreen {
|
||||||
cmd.arg("--fullscreen");
|
cmd.arg("--fullscreen");
|
||||||
}
|
}
|
||||||
add_window_pos(&mut cmd);
|
add_window_pos(&mut cmd);
|
||||||
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||||
spawn_with(cmd, &label, slot, on_event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
||||||
|
|||||||
@@ -244,13 +244,8 @@ pub struct ZeroCopyPolicy {
|
|||||||
/// The resolved backend produces GPU-resident frames (everything but the software encoder) —
|
/// The resolved backend produces GPU-resident frames (everything but the software encoder) —
|
||||||
/// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`).
|
/// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`).
|
||||||
pub backend_is_gpu: bool,
|
pub backend_is_gpu: bool,
|
||||||
/// THIS session encodes PyroWave: the frames' consumer is the wavelet encoder's own Vulkan
|
|
||||||
/// device, which imports raw dmabufs on ANY vendor — so the capturer takes the raw-dmabuf
|
|
||||||
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
|
|
||||||
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
|
|
||||||
pub pyrowave_session: bool,
|
|
||||||
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
/// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA,
|
||||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||||
pub pyrowave_modifiers: Vec<u64>,
|
pub pyrowave_modifiers: Vec<u64>,
|
||||||
}
|
}
|
||||||
@@ -259,55 +254,6 @@ pub struct ZeroCopyPolicy {
|
|||||||
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||||
true
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
|
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),
|
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
|
||||||
@@ -370,28 +316,16 @@ pub use idd_push::verify_is_wudfhost;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/mod.rs"]
|
#[path = "linux/mod.rs"]
|
||||||
mod linux;
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/synthetic_nv12.rs"]
|
#[path = "windows/synthetic_nv12.rs"]
|
||||||
pub mod synthetic_nv12;
|
pub mod synthetic_nv12;
|
||||||
|
|
||||||
/// Open the Linux xdg-ScreenCast portal capturer for a client-sized monitor. `anchored` drives
|
/// 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.
|
/// ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits that grant headlessly. The
|
||||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
/// [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||||
/// 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).
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_portal_monitor(
|
pub fn open_portal_monitor(anchored: bool, policy: ZeroCopyPolicy) -> Result<Box<dyn Capturer>> {
|
||||||
anchored: bool,
|
linux::PortalCapturer::open(anchored, policy).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
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>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
|
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
|
||||||
@@ -431,18 +365,9 @@ pub fn open_idd_push(
|
|||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
pyrowave: bool,
|
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
sender: FrameChannelSender,
|
sender: FrameChannelSender,
|
||||||
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||||
idd_push::IddPushCapturer::open(
|
idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
|
||||||
target,
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
preferred,
|
|
||||||
client_10bit,
|
|
||||||
want_444,
|
|
||||||
pyrowave,
|
|
||||||
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
|
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
|
||||||
/// rebuild retries on the CPU offer instead of failing identically forever.
|
/// rebuild retries on the CPU offer instead of failing identically forever.
|
||||||
vaapi_dmabuf: bool,
|
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.
|
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
||||||
node_id: u32,
|
node_id: u32,
|
||||||
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
|
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
|
||||||
@@ -87,9 +80,8 @@ pub struct PortalCapturer {
|
|||||||
impl PortalCapturer {
|
impl PortalCapturer {
|
||||||
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
|
/// `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
|
/// 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
|
/// ScreenCast session (wlroots, which has no RemoteDesktop portal).
|
||||||
/// GNOME 50+ HDR formats (10-bit PQ/BT.2020, dmabuf-only) instead of the SDR set.
|
pub fn open(anchored: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
|
||||||
pub fn open(anchored: bool, want_hdr: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
|
|
||||||
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
// 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>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
@@ -110,12 +102,11 @@ impl PortalCapturer {
|
|||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
node_id,
|
node_id,
|
||||||
want_hdr,
|
|
||||||
"ScreenCast portal session started; connecting PipeWire"
|
"ScreenCast portal session started; connecting PipeWire"
|
||||||
);
|
);
|
||||||
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
||||||
Ok(
|
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),
|
.into_capturer(node_id, None),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -144,15 +135,12 @@ impl PortalCapturer {
|
|||||||
want_444,
|
want_444,
|
||||||
"connecting PipeWire to virtual output"
|
"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(
|
Ok(spawn_pipewire(
|
||||||
remote_fd,
|
remote_fd,
|
||||||
node_id,
|
node_id,
|
||||||
preferred_mode,
|
preferred_mode,
|
||||||
allow_zerocopy,
|
allow_zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
false,
|
|
||||||
policy,
|
policy,
|
||||||
)?
|
)?
|
||||||
.into_capturer(node_id, Some(keepalive)))
|
.into_capturer(node_id, Some(keepalive)))
|
||||||
@@ -172,10 +160,6 @@ struct PwHandles {
|
|||||||
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
||||||
/// [`PortalCapturer::vaapi_dmabuf`]).
|
/// [`PortalCapturer::vaapi_dmabuf`]).
|
||||||
vaapi_dmabuf: bool,
|
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<()>,
|
quit: ::pipewire::channel::Sender<()>,
|
||||||
join: thread::JoinHandle<()>,
|
join: thread::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
@@ -193,8 +177,6 @@ impl PwHandles {
|
|||||||
broken: self.broken,
|
broken: self.broken,
|
||||||
stall_since: None,
|
stall_since: None,
|
||||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||||
hdr_offer: self.hdr_offer,
|
|
||||||
hdr_negotiated: self.hdr_negotiated,
|
|
||||||
node_id,
|
node_id,
|
||||||
quit: Some(self.quit),
|
quit: Some(self.quit),
|
||||||
join: Some(self.join),
|
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`)
|
// 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.
|
// instead of NV12/RGB, so the session stays zero-copy at full chroma.
|
||||||
want_444: bool,
|
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
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||||
// capture→encode edge (plan §W6).
|
// capture→encode edge (plan §W6).
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
@@ -235,31 +213,17 @@ fn spawn_pipewire(
|
|||||||
let streaming_cb = streaming.clone();
|
let streaming_cb = streaming.clone();
|
||||||
let broken = Arc::new(AtomicBool::new(false));
|
let broken = Arc::new(AtomicBool::new(false));
|
||||||
let broken_cb = broken.clone();
|
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
|
// 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
|
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||||
// inner `mod pipewire` shadows the crate name at this scope.
|
// inner `mod pipewire` shadows the crate name at this scope.
|
||||||
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
||||||
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
|
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
|
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
||||||
// backend or a PyroWave session the EGL→CUDA importer is never built) — kept on the capturer
|
// backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
|
||||||
// so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf
|
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
|
||||||
// passthrough offer.
|
let vaapi_dmabuf = zerocopy
|
||||||
let vaapi_dmabuf =
|
&& std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() != Ok("1")
|
||||||
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
|
&& policy.backend_is_vaapi;
|
||||||
let join = thread::Builder::new()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-pipewire".into())
|
.name("punktfunk-pipewire".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -271,10 +235,8 @@ fn spawn_pipewire(
|
|||||||
negotiated_cb,
|
negotiated_cb,
|
||||||
streaming_cb,
|
streaming_cb,
|
||||||
broken_cb,
|
broken_cb,
|
||||||
hdr_negotiated_cb,
|
|
||||||
zerocopy,
|
zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
want_hdr,
|
|
||||||
preferred,
|
preferred,
|
||||||
quit_rx,
|
quit_rx,
|
||||||
policy,
|
policy,
|
||||||
@@ -290,8 +252,6 @@ fn spawn_pipewire(
|
|||||||
streaming,
|
streaming,
|
||||||
broken,
|
broken,
|
||||||
vaapi_dmabuf,
|
vaapi_dmabuf,
|
||||||
hdr_offer: want_hdr,
|
|
||||||
hdr_negotiated,
|
|
||||||
quit: quit_tx,
|
quit: quit_tx,
|
||||||
join,
|
join,
|
||||||
})
|
})
|
||||||
@@ -394,26 +354,6 @@ impl Capturer for PortalCapturer {
|
|||||||
fn set_active(&self, active: bool) {
|
fn set_active(&self, active: bool) {
|
||||||
self.active.store(active, Ordering::Relaxed);
|
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 {
|
impl PortalCapturer {
|
||||||
@@ -432,20 +372,6 @@ impl PortalCapturer {
|
|||||||
or capture never started)",
|
or capture never started)",
|
||||||
self.node_id
|
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() {
|
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||||
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
||||||
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
||||||
@@ -490,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`),
|
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`),
|
||||||
/// preferring **cursor-as-metadata**: the compositor keeps its cheap hardware cursor plane and
|
/// 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),
|
/// ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + an occasional bitmap),
|
||||||
@@ -824,10 +669,6 @@ mod pipewire {
|
|||||||
VideoFormat::RGBA => PixelFormat::Rgba,
|
VideoFormat::RGBA => PixelFormat::Rgba,
|
||||||
VideoFormat::RGB => PixelFormat::Rgb,
|
VideoFormat::RGB => PixelFormat::Rgb,
|
||||||
VideoFormat::BGR => PixelFormat::Bgr,
|
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,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -891,9 +732,6 @@ mod pipewire {
|
|||||||
/// irrecoverably gone for this stream — the import worker died, or tiled imports failed
|
/// irrecoverably gone for this stream — the import worker died, or tiled imports failed
|
||||||
/// [`IMPORT_FAIL_POISON`] times in a row.
|
/// [`IMPORT_FAIL_POISON`] times in a row.
|
||||||
broken: Arc<AtomicBool>,
|
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`].
|
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
|
||||||
import_fail_streak: u32,
|
import_fail_streak: u32,
|
||||||
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
|
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
|
||||||
@@ -1048,91 +886,6 @@ mod pipewire {
|
|||||||
serialize_pod(obj)
|
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
|
/// 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).
|
/// 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 {
|
fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
|
||||||
@@ -1310,25 +1063,21 @@ mod pipewire {
|
|||||||
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||||
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||||
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
// `spa_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
|
||||||
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
// `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
|
||||||
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
// (or null), valid until requeue. The size argument matches the struct the result is cast to.
|
||||||
// are ALL producer-written, and without a bound against the actual region they drive
|
let cur = unsafe {
|
||||||
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
spa::sys::spa_buffer_find_meta_data(
|
||||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
spa_buf,
|
||||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
spa::sys::SPA_META_Cursor,
|
||||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
std::mem::size_of::<spa::sys::spa_meta_cursor>(),
|
||||||
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
) as *const spa::sys::spa_meta_cursor
|
||||||
if meta.is_null() {
|
};
|
||||||
|
if cur.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
// SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
|
||||||
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
// inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
|
||||||
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cur = data as *const spa::sys::spa_meta_cursor;
|
|
||||||
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
|
||||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||||
(
|
(
|
||||||
(*cur).id,
|
(*cur).id,
|
||||||
@@ -1351,18 +1100,13 @@ mod pipewire {
|
|||||||
// Position-only update — keep the cached bitmap.
|
// Position-only update — keep the cached bitmap.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let bmp_off = bmp_off as usize;
|
// SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
|
||||||
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
// producer placed inside the same meta region it sized for this cursor (>= the size we
|
||||||
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
// requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
|
||||||
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
let bmp =
|
||||||
Some(end) if end <= region_size => {}
|
unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
|
||||||
_ => return,
|
// SAFETY: `bmp` is the in-bounds, aligned `spa_meta_bitmap` pointer computed just above; the
|
||||||
}
|
// producer fully initialized this header, so reading its scalar fields is sound.
|
||||||
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
|
||||||
// so the header is fully in bounds; the producer places it aligned as before.
|
|
||||||
let bmp = unsafe { data.add(bmp_off) as *const spa::sys::spa_meta_bitmap };
|
|
||||||
// SAFETY: `bmp` is the in-bounds `spa_meta_bitmap` header validated just above; reading its
|
|
||||||
// scalar fields is sound.
|
|
||||||
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
||||||
(
|
(
|
||||||
(*bmp).format,
|
(*bmp).format,
|
||||||
@@ -1378,27 +1122,10 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
let row = bw as usize * 4;
|
let row = bw as usize * 4;
|
||||||
let stride = if stride < row { row } else { stride };
|
let stride = if stride < row { row } else { stride };
|
||||||
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it
|
let span = stride * (bh as usize - 1) + row;
|
||||||
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and
|
// SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
|
||||||
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before
|
// producer-sized meta region. `span` is the exact extent the strided copy below reads.
|
||||||
// fabricating the slice — this is the check whose absence made the read go out of bounds.
|
let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
|
||||||
let span = match stride
|
|
||||||
.checked_mul(bh as usize - 1)
|
|
||||||
.and_then(|v| v.checked_add(row))
|
|
||||||
{
|
|
||||||
Some(s) => s,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
match bmp_off
|
|
||||||
.checked_add(pix_off)
|
|
||||||
.and_then(|v| v.checked_add(span))
|
|
||||||
{
|
|
||||||
Some(end) if end <= region_size => {}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
// SAFETY: `bmp_off + pix_off + span <= region_size` (checked directly above), so the slice
|
|
||||||
// is fully within the producer's meta region; `span` is exactly the strided loop's extent.
|
|
||||||
let src = unsafe { std::slice::from_raw_parts(data.add(bmp_off + pix_off), span) };
|
|
||||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||||
for y in 0..bh as usize {
|
for y in 0..bh as usize {
|
||||||
for x in 0..bw as usize {
|
for x in 0..bw as usize {
|
||||||
@@ -1430,54 +1157,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
|
/// 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 —
|
/// 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).
|
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||||
@@ -1491,12 +1170,6 @@ mod pipewire {
|
|||||||
if !cursor.visible || cursor.rgba.is_empty() {
|
if !cursor.visible || cursor.rgba.is_empty() {
|
||||||
return;
|
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 {
|
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1671,10 +1344,7 @@ mod pipewire {
|
|||||||
// through to the shm de-pad copy below.
|
// through to the shm de-pad copy below.
|
||||||
let mut gpu_import_broken = false;
|
let mut gpu_import_broken = false;
|
||||||
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
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
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
// 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() {
|
|
||||||
let plane = pf_zerocopy::DmabufPlane {
|
let plane = pf_zerocopy::DmabufPlane {
|
||||||
fd: datas[0].fd(),
|
fd: datas[0].fd(),
|
||||||
offset: datas[0].chunk().offset(),
|
offset: datas[0].chunk().offset(),
|
||||||
@@ -1934,13 +1604,9 @@ mod pipewire {
|
|||||||
negotiated: Arc<AtomicBool>,
|
negotiated: Arc<AtomicBool>,
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
broken: Arc<AtomicBool>,
|
broken: Arc<AtomicBool>,
|
||||||
hdr_negotiated: Arc<AtomicBool>,
|
|
||||||
zerocopy: bool,
|
zerocopy: bool,
|
||||||
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
|
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
|
||||||
want_444: bool,
|
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)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
quit_rx: pw::channel::Receiver<()>,
|
quit_rx: pw::channel::Receiver<()>,
|
||||||
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||||
@@ -1973,18 +1639,13 @@ mod pipewire {
|
|||||||
// Build the GPU importer up front — normally the ISOLATED worker process
|
// Build the GPU importer up front — normally the ISOLATED worker process
|
||||||
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
|
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
|
||||||
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
|
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
|
||||||
// (we simply won't request dmabuf below). Skipped entirely when the frames go to the
|
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
|
||||||
// raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave
|
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
|
||||||
// (its Vulkan device imports raw dmabufs on any vendor): building the importer there
|
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
|
||||||
// would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can
|
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
|
||||||
// consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU
|
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
||||||
// stack must not crash-loop).
|
|
||||||
let backend_is_vaapi = policy.backend_is_vaapi;
|
let backend_is_vaapi = policy.backend_is_vaapi;
|
||||||
let raw_passthrough = backend_is_vaapi || policy.pyrowave_session;
|
let mut importer = if zerocopy && !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 && !raw_passthrough && !want_hdr {
|
|
||||||
if pf_zerocopy::gpu_import_disabled() {
|
if pf_zerocopy::gpu_import_disabled() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||||
@@ -2010,11 +1671,9 @@ mod pipewire {
|
|||||||
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
|
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
|
||||||
// race).
|
// race).
|
||||||
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
||||||
// Raw-dmabuf zero-copy passthrough: zero-copy on, no EGL→CUDA importer, and the frames'
|
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
|
||||||
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
|
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
|
||||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && backend_is_vaapi;
|
||||||
// dmabuf straight to the encoder.
|
|
||||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
|
||||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||||
@@ -2030,9 +1689,8 @@ mod pipewire {
|
|||||||
// advertisement with every modifier its device samples from, so compositors that
|
// advertisement with every modifier its device samples from, so compositors that
|
||||||
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
|
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
|
||||||
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
|
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
|
||||||
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
|
// the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never
|
||||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
// calls back into `encode` and needs no feature gate of its own (the emptiness check gates it).
|
||||||
// own (the emptiness check gates it).
|
|
||||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||||
for &m in &policy.pyrowave_modifiers {
|
for &m in &policy.pyrowave_modifiers {
|
||||||
if !modifiers.contains(&m) {
|
if !modifiers.contains(&m) {
|
||||||
@@ -2052,11 +1710,11 @@ mod pipewire {
|
|||||||
);
|
);
|
||||||
} else if zerocopy && !want_dmabuf {
|
} else if zerocopy && !want_dmabuf {
|
||||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
} else if vaapi_passthrough {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||||
);
|
);
|
||||||
} else if want_dmabuf && !vaapi_passthrough {
|
} else if want_dmabuf {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
count = modifiers.len(),
|
count = modifiers.len(),
|
||||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||||
@@ -2097,7 +1755,6 @@ mod pipewire {
|
|||||||
negotiated,
|
negotiated,
|
||||||
streaming,
|
streaming,
|
||||||
broken,
|
broken,
|
||||||
hdr_negotiated,
|
|
||||||
import_fail_streak: 0,
|
import_fail_streak: 0,
|
||||||
importer,
|
importer,
|
||||||
vaapi_passthrough,
|
vaapi_passthrough,
|
||||||
@@ -2165,20 +1822,12 @@ mod pipewire {
|
|||||||
let sz = ud.info.size();
|
let sz = ud.info.size();
|
||||||
ud.format = map_format(ud.info.format());
|
ud.format = map_format(ud.info.format());
|
||||||
ud.modifier = ud.info.modifier();
|
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!(
|
tracing::info!(
|
||||||
width = sz.width,
|
width = sz.width,
|
||||||
height = sz.height,
|
height = sz.height,
|
||||||
spa_format = ?ud.info.format(),
|
spa_format = ?ud.info.format(),
|
||||||
mapped = ?ud.format,
|
mapped = ?ud.format,
|
||||||
modifier = ud.modifier,
|
modifier = ud.modifier,
|
||||||
hdr,
|
|
||||||
transfer_function = ud.info.transfer_function(),
|
|
||||||
color_primaries = ud.info.color_primaries(),
|
|
||||||
"pipewire format negotiated"
|
"pipewire format negotiated"
|
||||||
);
|
);
|
||||||
if ud.format.is_none() {
|
if ud.format.is_none() {
|
||||||
@@ -2190,37 +1839,36 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.process(|stream, ud| {
|
.process(|stream, ud| {
|
||||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
|
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
||||||
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
|
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
||||||
// the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the
|
|
||||||
// `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is
|
|
||||||
// requeued exactly once AFTER the panic-containing region. Previously the whole thing was
|
|
||||||
// inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded
|
|
||||||
// `newest` forever, permanently shrinking the stream's fixed pool until capture wedged.
|
|
||||||
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on the
|
|
||||||
// loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null
|
|
||||||
// (null-checked), single-threaded so no concurrent access.
|
|
||||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
|
||||||
if newest.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut drained = 1u32;
|
|
||||||
loop {
|
|
||||||
// SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null.
|
|
||||||
let next = unsafe { stream.dequeue_raw_buffer() };
|
|
||||||
if next.is_null() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately
|
|
||||||
// overwrite it, so the requeued pointer is never touched again.
|
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
|
||||||
newest = next;
|
|
||||||
drained += 1;
|
|
||||||
}
|
|
||||||
// PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI
|
|
||||||
// boundary would abort the whole host. Contain the inspect/consume work — the only Rust
|
|
||||||
// code here that can panic — and requeue `newest` unconditionally after it.
|
|
||||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||||
|
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||||
|
// queued buffers, requeue the older ones, keep only the newest.
|
||||||
|
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
|
||||||
|
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
|
||||||
|
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
|
||||||
|
// null-checked before any use. The loop is single-threaded, so no concurrent access.
|
||||||
|
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||||
|
if newest.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut drained = 1u32;
|
||||||
|
loop {
|
||||||
|
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
|
||||||
|
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
|
||||||
|
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||||
|
if next.is_null() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
|
||||||
|
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
|
||||||
|
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
|
||||||
|
// touched again (no use-after-requeue). Loop thread, single-threaded.
|
||||||
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
|
newest = next;
|
||||||
|
drained += 1;
|
||||||
|
}
|
||||||
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
||||||
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
||||||
// load through a valid pointer — no mutation or aliasing.
|
// load through a valid pointer — no mutation or aliasing.
|
||||||
@@ -2299,18 +1947,19 @@ mod pipewire {
|
|||||||
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Skip this stale/cursor buffer — `newest` is requeued unconditionally below.
|
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
|
||||||
|
// skip path); hand it back to the stream exactly once and return without touching it
|
||||||
|
// again. Loop thread inside `.process`.
|
||||||
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consume_frame(ud, spa_buf);
|
consume_frame(ud, spa_buf);
|
||||||
|
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
|
||||||
|
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
|
||||||
|
// use-after-requeue. Loop thread inside `.process`.
|
||||||
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
}));
|
}));
|
||||||
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
|
|
||||||
// or a caught panic in the closure above. This single requeue is what keeps the fixed
|
|
||||||
// buffer pool from draining.
|
|
||||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
|
|
||||||
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
|
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
|
||||||
if outcome.is_err() {
|
if outcome.is_err() {
|
||||||
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
||||||
// format) would fire this every frame, so power-of-two throttle it — enough to
|
// format) would fire this every frame, so power-of-two throttle it — enough to
|
||||||
@@ -2380,36 +2029,20 @@ mod pipewire {
|
|||||||
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
||||||
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
||||||
// `param_changed` (the two-step DMA-BUF handshake). Otherwise offer the multi-format shm
|
// `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
|
// pod and let MAP_BUFFERS map it.
|
||||||
// PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering
|
let shm_values = serialize_pod(obj)?;
|
||||||
// SDR alongside would make the producer pick its earlier-listed SDR format, and the
|
let (dmabuf_values, buffers_values) = if want_dmabuf {
|
||||||
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
|
(
|
||||||
let format_pods: Vec<Vec<u8>> = if want_hdr {
|
Some(build_dmabuf_format(&modifiers, preferred)?),
|
||||||
tracing::info!(
|
Some(build_dmabuf_buffers()?),
|
||||||
"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()?)
|
|
||||||
} else if force_shm {
|
} else if force_shm {
|
||||||
// True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render).
|
// True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render).
|
||||||
Some(build_shm_only_buffers()?)
|
(None, Some(build_shm_only_buffers()?))
|
||||||
} else {
|
} else {
|
||||||
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
|
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
|
||||||
// modifier-bearing format pod wins the intersection).
|
// 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
|
// Ask for cursor-as-metadata on every path (harmless if the producer can't supply it): the
|
||||||
@@ -2417,8 +2050,9 @@ mod pipewire {
|
|||||||
// compositor keeps its cheap hardware cursor plane (see `choose_cursor_mode`).
|
// compositor keeps its cheap hardware cursor plane (see `choose_cursor_mode`).
|
||||||
let cursor_meta = build_cursor_meta_param()?;
|
let cursor_meta = build_cursor_meta_param()?;
|
||||||
let mut byte_slices: Vec<&[u8]> = Vec::new();
|
let mut byte_slices: Vec<&[u8]> = Vec::new();
|
||||||
for pod in &format_pods {
|
match &dmabuf_values {
|
||||||
byte_slices.push(pod);
|
Some(d) => byte_slices.push(d),
|
||||||
|
None => byte_slices.push(&shm_values),
|
||||||
}
|
}
|
||||||
if let Some(b) = &buffers_values {
|
if let Some(b) = &buffers_values {
|
||||||
byte_slices.push(b);
|
byte_slices.push(b);
|
||||||
@@ -2442,24 +2076,4 @@ mod pipewire {
|
|||||||
mainloop.run();
|
mainloop.run();
|
||||||
Ok(())
|
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).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![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 anyhow::{bail, Context, Result};
|
||||||
use std::ffi::c_void;
|
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`].
|
/// 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
|
/// 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.
|
/// 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::{
|
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_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
|
/// 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 {
|
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(
|
pub(crate) unsafe fn new(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
context: &ID3D11DeviceContext,
|
context: &ID3D11DeviceContext,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
scrgb_input: bool,
|
hdr: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
||||||
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
|
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
|
||||||
@@ -1103,15 +876,19 @@ impl VideoConverter {
|
|||||||
.CreateVideoProcessor(&enumr, 0)
|
.CreateVideoProcessor(&enumr, 0)
|
||||||
.context("CreateVideoProcessor")?;
|
.context("CreateVideoProcessor")?;
|
||||||
|
|
||||||
// Full-range RGB in → studio-range BT.709 NV12 out. Input gamma follows the ring format:
|
// Full-range RGB in → studio-range YUV out. HDR: scRGB linear (G10) → BT.2020 PQ (G2084).
|
||||||
// scRGB linear (G10) for the FP16 HDR ring, sRGB (G22) for the 8-bit BGRA SDR ring. The
|
// SDR: sRGB (G22) → BT.709 (G22).
|
||||||
// output is always BT.709 SDR (the video processor tone-maps the scRGB case).
|
let (in_cs, out_cs) = if hdr {
|
||||||
let in_cs = if scrgb_input {
|
(
|
||||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709
|
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709,
|
||||||
|
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||||
|
)
|
||||||
} else {
|
} 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.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
|
||||||
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
||||||
// One frame in, one frame out — no interpolation/auto-processing.
|
// 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).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::dxgi::{
|
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
|
||||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
|
|
||||||
WinCaptureTarget,
|
|
||||||
};
|
|
||||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use pf_driver_proto::{control, frame};
|
use pf_driver_proto::{control, frame};
|
||||||
@@ -36,16 +33,13 @@ use windows::Win32::Foundation::{
|
|||||||
HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0,
|
HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence,
|
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||||
ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
|
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
||||||
D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, D3D11_RESOURCE_MISC_SHARED,
|
D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||||
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
|
|
||||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
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_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||||
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
||||||
@@ -148,18 +142,6 @@ struct HostSlot {
|
|||||||
srv: ID3D11ShaderResourceView,
|
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)`,
|
/// 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
|
/// `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
|
/// 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
|
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
|
||||||
/// (and so stale-ring publishes are rejected).
|
/// (and so stale-ring publishes are rejected).
|
||||||
generation: u32,
|
generation: u32,
|
||||||
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Gates the
|
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open`
|
||||||
/// composition depth: a 10-bit client PROACTIVELY enables advanced color at `open` (HDR without a
|
/// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it
|
||||||
/// manual toggle); an SDR-only client forces it OFF and the descriptor poller PINS it there, so a
|
/// does NOT gate the per-frame conversion — that follows the display, like the WGC path (clients
|
||||||
/// client that advertised SDR ("HDR off") is never handed the in-band PQ upgrade the pixel-format-
|
/// under-report 10-bit yet all decode Main10 + auto-detect PQ from the VUI).
|
||||||
/// 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.)
|
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
/// The DISPLAY's CURRENT HDR state (from `advanced_color_enabled`) — the user can flip "Use HDR" in
|
/// 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.
|
/// 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):
|
/// 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.
|
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
|
||||||
want_444: bool,
|
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
|
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
|
||||||
/// its snapshot instead of running CCD queries inline on the frame path.
|
/// its snapshot instead of running CCD queries inline on the frame path.
|
||||||
desc_poller: DescriptorPoller,
|
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
|
/// 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
|
/// 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).
|
/// 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(
|
pub fn open(
|
||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
pyrowave: bool,
|
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
sender: crate::FrameChannelSender,
|
sender: crate::FrameChannelSender,
|
||||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
// 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.
|
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||||
pf_win_display::display_events::spawn_once();
|
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) => {
|
Ok(mut me) => {
|
||||||
me._keepalive = keepalive;
|
me._keepalive = keepalive;
|
||||||
Ok(me)
|
Ok(me)
|
||||||
@@ -624,13 +576,11 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn open_inner(
|
fn open_inner(
|
||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
pyrowave: bool,
|
|
||||||
sender: crate::FrameChannelSender,
|
sender: crate::FrameChannelSender,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||||
@@ -651,7 +601,6 @@ impl IddPushCapturer {
|
|||||||
preferred,
|
preferred,
|
||||||
client_10bit,
|
client_10bit,
|
||||||
want_444,
|
want_444,
|
||||||
pyrowave,
|
|
||||||
luid,
|
luid,
|
||||||
sender.clone(),
|
sender.clone(),
|
||||||
) {
|
) {
|
||||||
@@ -679,27 +628,17 @@ impl IddPushCapturer {
|
|||||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||||
driver's reported adapter"
|
driver's reported adapter"
|
||||||
);
|
);
|
||||||
Self::open_on(
|
Self::open_on(target, preferred, client_10bit, want_444, drv, sender)
|
||||||
target,
|
.context("IDD-push rebind to the driver's reported render adapter")
|
||||||
preferred,
|
|
||||||
client_10bit,
|
|
||||||
want_444,
|
|
||||||
pyrowave,
|
|
||||||
drv,
|
|
||||||
sender,
|
|
||||||
)
|
|
||||||
.context("IDD-push rebind to the driver's reported render adapter")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn open_on(
|
fn open_on(
|
||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
pyrowave: bool,
|
|
||||||
luid: LUID,
|
luid: LUID,
|
||||||
sender: crate::FrameChannelSender,
|
sender: crate::FrameChannelSender,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
@@ -724,12 +663,12 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
// 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
|
// 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
|
// COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
|
||||||
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
|
// the ring format must TRACK the display's ACTUAL mode (the driver's format-guard drops a
|
||||||
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
|
// mismatch). We poll the live state here and on every recreate. For a 10-bit-capable client we
|
||||||
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
|
// PROACTIVELY enable advanced color so HDR streams without the user toggling anything; an
|
||||||
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
|
// SDR-only client leaves the display alone (and still gets a tone-mapped picture, never a freeze,
|
||||||
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
|
// if the user does enable HDR).
|
||||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
// 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
|
// - `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.
|
// `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`
|
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||||
unsafe {
|
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
|
// 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
|
// 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
|
// 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) —
|
// 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.
|
// 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
|
let display_hdr = enabled_hdr
|
||||||
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
.unwrap_or(false);
|
||||||
// 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));
|
|
||||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
// 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
|
// 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
|
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||||
@@ -962,13 +853,6 @@ impl IddPushCapturer {
|
|||||||
client_10bit,
|
client_10bit,
|
||||||
display_hdr,
|
display_hdr,
|
||||||
want_444,
|
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(
|
desc_poller: DescriptorPoller::spawn(
|
||||||
target.target_id,
|
target.target_id,
|
||||||
DisplayDescriptor {
|
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
|
/// 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.
|
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
|
||||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
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.display_hdr {
|
||||||
if self.want_444 {
|
if self.want_444 {
|
||||||
warn_444_hdr_downgrade_once();
|
warn_444_hdr_downgrade_once();
|
||||||
@@ -1341,14 +1215,6 @@ impl IddPushCapturer {
|
|||||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
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.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||||
self.hdr_p010_conv = None;
|
self.hdr_p010_conv = None;
|
||||||
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
|
|
||||||
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
|
|
||||||
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
|
|
||||||
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
|
|
||||||
// builds when None, so it must be reset here like its siblings.
|
|
||||||
self.pyro_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.out_idx = 0;
|
||||||
self.last_present = None;
|
self.last_present = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1362,31 +1228,11 @@ impl IddPushCapturer {
|
|||||||
/// only when TWO consecutive samples agree on the same new descriptor (~½ s), so a
|
/// 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.
|
/// single-sample transient during a topology re-probe never costs a ring recreate.
|
||||||
fn poll_display_hdr(&mut self) {
|
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 {
|
if seq == self.desc_seq {
|
||||||
return; // no new sample since last consume
|
return; // no new sample since last consume
|
||||||
}
|
}
|
||||||
self.desc_seq = seq;
|
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 {
|
let current = DisplayDescriptor {
|
||||||
hdr: self.display_hdr,
|
hdr: self.display_hdr,
|
||||||
width: self.width,
|
width: self.width,
|
||||||
@@ -1435,8 +1281,7 @@ impl IddPushCapturer {
|
|||||||
},
|
},
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
|
// 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
|
// NVENC registers it as encode input — matching the WGC YUV ring.
|
||||||
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
|
|
||||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||||
CPUAccessFlags: 0,
|
CPUAccessFlags: 0,
|
||||||
MiscFlags: 0,
|
MiscFlags: 0,
|
||||||
@@ -1457,91 +1302,6 @@ impl IddPushCapturer {
|
|||||||
Ok(())
|
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
|
/// 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.
|
/// 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`).
|
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
|
||||||
@@ -1567,61 +1327,6 @@ impl IddPushCapturer {
|
|||||||
Ok(())
|
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>> {
|
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
|
||||||
self.log_driver_status_once();
|
self.log_driver_status_once();
|
||||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||||
@@ -1686,34 +1391,13 @@ impl IddPushCapturer {
|
|||||||
if seq == self.last_seq || slot >= self.slots.len() {
|
if seq == self.last_seq || slot >= self.slots.len() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release
|
self.ensure_out_ring()?;
|
||||||
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
// Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can
|
||||||
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring.
|
// `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||||
|
self.ensure_converter()?;
|
||||||
let i = self.out_idx;
|
let i = self.out_idx;
|
||||||
let (out, pyro_slot) = if self.pyrowave {
|
let out = self.out_ring[i].clone();
|
||||||
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 (_, pf) = self.out_format();
|
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
|
// 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
|
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
||||||
@@ -1730,30 +1414,14 @@ impl IddPushCapturer {
|
|||||||
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
||||||
// the slot back to the driver.
|
// the slot back to the driver.
|
||||||
unsafe {
|
unsafe {
|
||||||
if self.pyrowave {
|
if self.display_hdr {
|
||||||
// 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 {
|
|
||||||
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
// 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() {
|
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
||||||
conv.convert(
|
conv.convert(
|
||||||
&self.device,
|
&self.device,
|
||||||
&self.context,
|
&self.context,
|
||||||
&s.srv,
|
&s.srv,
|
||||||
out.as_ref().expect("out ring"),
|
&out,
|
||||||
self.width,
|
self.width,
|
||||||
self.height,
|
self.height,
|
||||||
)?;
|
)?;
|
||||||
@@ -1762,24 +1430,19 @@ impl IddPushCapturer {
|
|||||||
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
// 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
|
// 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.
|
// copy-engine move; the slot releases back to the driver immediately.
|
||||||
self.context
|
self.context.CopyResource(&out, &s.tex);
|
||||||
.CopyResource(out.as_ref().expect("out ring"), &s.tex);
|
|
||||||
} else {
|
} else {
|
||||||
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
// 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() {
|
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)`.
|
// `_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;
|
self.last_seq = seq;
|
||||||
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() {
|
self.last_present = Some((out.clone(), pf));
|
||||||
self.pyro_last = Some((y.clone(), cbcr.clone()));
|
|
||||||
} else {
|
|
||||||
self.last_present = Some((out.as_ref().expect("out ring").clone(), pf));
|
|
||||||
}
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if self.recovering_since.take().is_some() {
|
if self.recovering_since.take().is_some() {
|
||||||
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
||||||
@@ -1854,34 +1517,14 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.last_fresh = now; // feeds the driver-death watch
|
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,
|
|
||||||
ring_gen: self.generation,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(out.expect("out ring texture"), None)
|
|
||||||
};
|
|
||||||
Ok(Some(CapturedFrame {
|
Ok(Some(CapturedFrame {
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
pts_ns: now_ns(),
|
pts_ns: now_ns(),
|
||||||
format: pf,
|
format: pf,
|
||||||
payload: FramePayload::D3d11(D3d11Frame {
|
payload: FramePayload::D3d11(D3d11Frame {
|
||||||
texture,
|
texture: out,
|
||||||
device: self.device.clone(),
|
device: self.device.clone(),
|
||||||
pyro,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
}))
|
}))
|
||||||
@@ -1892,47 +1535,8 @@ impl IddPushCapturer {
|
|||||||
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the
|
// 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 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.
|
// 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,
|
|
||||||
ring_gen: self.generation,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
cursor: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let (src, pf) = self.last_present.clone()?;
|
let (src, pf) = self.last_present.clone()?;
|
||||||
|
let i = self.out_idx;
|
||||||
let dst = self.out_ring.get(i)?.clone();
|
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
|
// 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).
|
// identical format/size (src is a previous out-ring slot; dst the next).
|
||||||
@@ -1949,7 +1553,6 @@ impl IddPushCapturer {
|
|||||||
payload: FramePayload::D3d11(D3d11Frame {
|
payload: FramePayload::D3d11(D3d11Frame {
|
||||||
texture: dst,
|
texture: dst,
|
||||||
device: self.device.clone(),
|
device: self.device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ impl Capturer for SyntheticNv12Capturer {
|
|||||||
payload: FramePayload::D3d11(D3d11Frame {
|
payload: FramePayload::D3d11(D3d11Frame {
|
||||||
texture: self.default_tex.clone(),
|
texture: self.default_tex.clone(),
|
||||||
device: self.device.clone(),
|
device: self.device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ ffmpeg-next = "8"
|
|||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
|
|
||||||
mdns-sd = "0.20"
|
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.
|
# 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
|
# `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`.
|
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||||
@@ -48,6 +40,11 @@ tracing = "0.1"
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
pipewire = "0.9"
|
pipewire = "0.9"
|
||||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
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]
|
[target.'cfg(windows)'.dependencies]
|
||||||
wasapi = "0.23"
|
wasapi = "0.23"
|
||||||
@@ -64,10 +61,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
|||||||
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
|
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
|
||||||
# method itself is feature-gated behind this.
|
# method itself is feature-gated behind this.
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
# The OS-clipboard bridge (clipboard.rs): Open/Get/SetClipboardData + the sequence
|
|
||||||
# number, and the GlobalAlloc block the clipboard takes ownership of.
|
|
||||||
"Win32_System_DataExchange",
|
|
||||||
"Win32_System_Memory",
|
|
||||||
] }
|
] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -20,83 +20,6 @@ const MIC_FRAME: usize = 960;
|
|||||||
|
|
||||||
struct Terminate;
|
struct Terminate;
|
||||||
|
|
||||||
/// A selectable PipeWire endpoint for the settings pickers.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AudioDevice {
|
|
||||||
/// `node.name` — the stable key the streams target via `target.object`.
|
|
||||||
pub name: String,
|
|
||||||
/// `node.description` — the human label the picker shows.
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate audio endpoints: `(sinks, sources)`. One registry roundtrip on a private
|
|
||||||
/// mainloop (a few ms against a live PipeWire); no daemon errors out and the caller
|
|
||||||
/// simply shows no pickers.
|
|
||||||
pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
|
||||||
use pipewire as pw;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
|
||||||
PW_INIT.call_once(pw::init);
|
|
||||||
|
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
|
||||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
|
||||||
let core = context
|
|
||||||
.connect_rc(None)
|
|
||||||
.context("pw connect (is PipeWire running in this session?)")?;
|
|
||||||
let registry = core.get_registry_rc().context("pw registry")?;
|
|
||||||
|
|
||||||
let found: Rc<RefCell<(Vec<AudioDevice>, Vec<AudioDevice>)>> = Rc::default();
|
|
||||||
let _reg_listener = registry
|
|
||||||
.add_listener_local()
|
|
||||||
.global({
|
|
||||||
let found = found.clone();
|
|
||||||
move |g| {
|
|
||||||
let Some(props) = g.props else { return };
|
|
||||||
let sink = match props.get("media.class") {
|
|
||||||
Some("Audio/Sink") => true,
|
|
||||||
Some("Audio/Source") => false,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let Some(name) = props.get("node.name") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let description = props
|
|
||||||
.get("node.description")
|
|
||||||
.or_else(|| props.get("node.nick"))
|
|
||||||
.unwrap_or(name)
|
|
||||||
.to_string();
|
|
||||||
let dev = AudioDevice {
|
|
||||||
name: name.to_string(),
|
|
||||||
description,
|
|
||||||
};
|
|
||||||
let mut f = found.borrow_mut();
|
|
||||||
if sink { &mut f.0 } else { &mut f.1 }.push(dev);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
|
|
||||||
// The registry replays existing globals asynchronously; one core sync marks the
|
|
||||||
// point they've all been delivered — quit the loop there.
|
|
||||||
let pending = core.sync(0).context("pw sync")?;
|
|
||||||
let _core_listener = core
|
|
||||||
.add_listener_local()
|
|
||||||
.done({
|
|
||||||
let mainloop = mainloop.clone();
|
|
||||||
move |_, seq| {
|
|
||||||
if seq == pending {
|
|
||||||
mainloop.quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
mainloop.run();
|
|
||||||
|
|
||||||
let result = found.borrow().clone();
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioPlayer {
|
pub struct AudioPlayer {
|
||||||
pcm_tx: SyncSender<Vec<f32>>,
|
pcm_tx: SyncSender<Vec<f32>>,
|
||||||
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
||||||
@@ -195,26 +118,20 @@ fn pw_thread(
|
|||||||
move |_| mainloop.quit()
|
move |_| mainloop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut props = properties! {
|
let stream = pw::stream::StreamBox::new(
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
&core,
|
||||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
"punktfunk-client",
|
||||||
*pw::keys::MEDIA_ROLE => "Game",
|
properties! {
|
||||||
*pw::keys::NODE_NAME => "punktfunk-client",
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
*pw::keys::MEDIA_ROLE => "Game",
|
||||||
*pw::keys::NODE_LATENCY => "240/48000",
|
*pw::keys::NODE_NAME => "punktfunk-client",
|
||||||
};
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
||||||
// The Settings speaker pick (session main maps `Settings::speaker_device` here);
|
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
||||||
// unset/empty = PipeWire's default routing.
|
*pw::keys::NODE_LATENCY => "240/48000",
|
||||||
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SINK") {
|
},
|
||||||
if !target.is_empty() {
|
)
|
||||||
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
|
.context("pw Stream")?;
|
||||||
// libpipewire than we require; the wire name is stable.
|
|
||||||
props.insert("target.object", target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let stream =
|
|
||||||
pw::stream::StreamBox::new(&core, "punktfunk-client", props).context("pw Stream")?;
|
|
||||||
|
|
||||||
let ud = PlayerData {
|
let ud = PlayerData {
|
||||||
rx: pcm_rx,
|
rx: pcm_rx,
|
||||||
@@ -399,23 +316,18 @@ fn mic_thread(
|
|||||||
move |_| mainloop.quit()
|
move |_| mainloop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut props = properties! {
|
let stream = pw::stream::StreamBox::new(
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
&core,
|
||||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
"punktfunk-mic-capture",
|
||||||
*pw::keys::MEDIA_ROLE => "Communication",
|
properties! {
|
||||||
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
};
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
// The Settings microphone pick (`Settings::mic_device` via session main).
|
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
||||||
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SOURCE") {
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
||||||
if !target.is_empty() {
|
},
|
||||||
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
|
)
|
||||||
// libpipewire than we require; the wire name is stable.
|
.context("pw mic Stream")?;
|
||||||
props.insert("target.object", target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-mic-capture", props)
|
|
||||||
.context("pw mic Stream")?;
|
|
||||||
|
|
||||||
let ud = MicData {
|
let ud = MicData {
|
||||||
connector: connector.clone(),
|
connector: connector.clone(),
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
//! OS-clipboard bridge for the spawned session client (`design/clipboard-and-file-transfer.md`
|
|
||||||
//! §5). The protocol half already exists in `punktfunk_core::clipboard` — the per-session task
|
|
||||||
//! that runs fetch streams — and `NativeClient` exposes it as `clip_control` / `clip_offer` /
|
|
||||||
//! `clip_fetch` / `clip_serve` / `next_clip`. What was missing on Windows is exactly what §5.2
|
|
||||||
//! writes in Swift for macOS: the code that talks to the actual pasteboard. This is that half.
|
|
||||||
//!
|
|
||||||
//! Shape (one thread, owned by the session pump):
|
|
||||||
//!
|
|
||||||
//! * **Local → remote** stays lazy by construction. A poll of `GetClipboardSequenceNumber`
|
|
||||||
//! spots a local copy, we announce the FORMAT LIST (`clip_offer`) and nothing else; the
|
|
||||||
//! bytes are read only if the host actually pastes and sends a `FetchRequest`.
|
|
||||||
//! * **Remote → local** is EAGER in this first cut, and that is a deliberate deviation from
|
|
||||||
//! §5.2's promise-based apply. macOS gets laziness free from `NSPasteboardItemDataProvider`;
|
|
||||||
//! the Windows equivalent is delayed rendering (`SetClipboardData(fmt, NULL)` answered on
|
|
||||||
//! `WM_RENDERFORMAT`), which needs a clipboard-owning window running its own message pump —
|
|
||||||
//! a bigger piece than this. So we fetch on the offer and place real bytes, under
|
|
||||||
//! [`EAGER_FETCH_CAP`] so a huge host-side copy can't pull megabytes nobody pastes. Text is
|
|
||||||
//! tiny and always crosses; a large image simply isn't mirrored until delayed rendering lands.
|
|
||||||
//! * **Echo suppression** is §3.4's Windows rule verbatim: record the clipboard sequence
|
|
||||||
//! number right after our own `SetClipboardData` and ignore exactly that change, or every
|
|
||||||
//! copy ping-pongs between the two machines forever.
|
|
||||||
//!
|
|
||||||
//! Secrets are respected: a clipboard carrying `ExcludeClipboardContentFromMonitorProcessing`
|
|
||||||
//! (what password managers set) is never announced and never served — the Windows counterpart
|
|
||||||
//! of §5.2's `org.nspasteboard.ConcealedType` skip.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use punktfunk_core::clipboard::ClipEventCore;
|
|
||||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
|
||||||
|
|
||||||
/// Wire mime for UTF-8 text — the one format every peer must handle (§3.5).
|
|
||||||
const MIME_TEXT: &str = "text/plain;charset=utf-8";
|
|
||||||
/// Wire mime for the image floor (§3.5). Read/written through the "PNG" registered clipboard
|
|
||||||
/// format; apps that only publish `CF_DIB` are a follow-up (the conversion the host already
|
|
||||||
/// has in `image_to_dib`).
|
|
||||||
const MIME_PNG: &str = "image/png";
|
|
||||||
|
|
||||||
/// Ceiling on an EAGERLY fetched remote payload (see the module docs). Text never approaches
|
|
||||||
/// it; it exists so a host-side copy of something enormous doesn't cross for a paste that may
|
|
||||||
/// never happen. Lifted once delayed rendering makes the fetch lazy.
|
|
||||||
const EAGER_FETCH_CAP: u64 = 4 << 20;
|
|
||||||
|
|
||||||
/// How often the local clipboard is polled for changes. §3.2 asks for ≥ 100 ms between offers;
|
|
||||||
/// 400 ms keeps a copy→focus→paste round trip comfortably ahead of the user.
|
|
||||||
const POLL: Duration = Duration::from_millis(400);
|
|
||||||
|
|
||||||
/// Drain-and-poll cadence: how long `next_clip` blocks before we re-check the clipboard and
|
|
||||||
/// the stop flag.
|
|
||||||
const EVENT_WAIT: Duration = Duration::from_millis(120);
|
|
||||||
|
|
||||||
/// Run the clipboard bridge until `stop` is set or the session closes. Returns immediately
|
|
||||||
/// (doing nothing) when the host didn't advertise `HOST_CAP_CLIPBOARD` — an older host, or one
|
|
||||||
/// whose backend can't do it — so this is safe to spawn unconditionally.
|
|
||||||
pub fn run(client: Arc<NativeClient>, stop: Arc<AtomicBool>) {
|
|
||||||
if client.host_caps() & HOST_CAP_CLIPBOARD == 0 {
|
|
||||||
tracing::info!("host has no clipboard capability — shared clipboard off");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Opt-in per §3.1: nothing is announced or served until this crosses enabled.
|
|
||||||
if let Err(e) = client.clip_control(true, 0) {
|
|
||||||
tracing::warn!(error = %e, "clipboard: enable failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tracing::info!("shared clipboard enabled");
|
|
||||||
|
|
||||||
let mut state = State {
|
|
||||||
// Adopt the CURRENT sequence number without announcing: whatever is on the clipboard
|
|
||||||
// from before the session started is the user's, not a copy they made for this stream.
|
|
||||||
last_seq: os::sequence_number(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut next_poll = Instant::now() + POLL;
|
|
||||||
|
|
||||||
while !stop.load(Ordering::SeqCst) {
|
|
||||||
// Inbound first — a pending FetchRequest is the host waiting on us. `NoFrame` is the
|
|
||||||
// ordinary poll timeout (nothing pending); anything else means the connection is gone
|
|
||||||
// and the session teardown is already on its way.
|
|
||||||
match client.next_clip(EVENT_WAIT) {
|
|
||||||
Ok(ev) => handle_event(&client, &mut state, ev),
|
|
||||||
Err(punktfunk_core::error::PunktfunkError::NoFrame) => {}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
// The local clipboard is polled on its OWN cadence, not once per inbound wait: the
|
|
||||||
// event wait is short (it bounds teardown latency), and hammering the Win32 clipboard
|
|
||||||
// eight times a second would contend with whatever app the user is actually copying in.
|
|
||||||
let now = Instant::now();
|
|
||||||
if now >= next_poll {
|
|
||||||
poll_local(&client, &mut state);
|
|
||||||
next_poll = now + POLL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Best-effort: tell the host to stop announcing into a session that's ending.
|
|
||||||
let _ = client.clip_control(false, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct State {
|
|
||||||
/// Clipboard sequence number as of our last look — a change means someone copied.
|
|
||||||
last_seq: u32,
|
|
||||||
/// The sequence number our OWN `SetClipboardData` produced (§3.4 echo suppression).
|
|
||||||
self_written_seq: Option<u32>,
|
|
||||||
/// Monotonic offer counter (§3.2 — newest wins).
|
|
||||||
offer_seq: u32,
|
|
||||||
/// Rate-limit guard for offers (§3.2 asks ≥ 100 ms).
|
|
||||||
last_offer: Option<Instant>,
|
|
||||||
/// The host's current offer, so a fetch can name its `seq`.
|
|
||||||
remote_offer: Option<u32>,
|
|
||||||
/// In-flight eager fetch → the mime it will deliver, so `Data` knows how to place it.
|
|
||||||
pending_fetch: Option<(u32, String)>,
|
|
||||||
/// The last payload we placed locally, kept only to answer a host fetch of our own echo
|
|
||||||
/// without re-reading the OS clipboard.
|
|
||||||
last_applied: Option<(String, Vec<u8>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A local clipboard change → announce the format list (never the bytes).
|
|
||||||
fn poll_local(client: &NativeClient, state: &mut State) {
|
|
||||||
let seq = os::sequence_number();
|
|
||||||
if seq == state.last_seq {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.last_seq = seq;
|
|
||||||
// Our own apply — swallow it, or the two clipboards chase each other forever.
|
|
||||||
if state.self_written_seq == Some(seq) {
|
|
||||||
state.self_written_seq = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(t) = state.last_offer {
|
|
||||||
if t.elapsed() < Duration::from_millis(100) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if os::is_concealed() {
|
|
||||||
tracing::debug!("clipboard: concealed content — not announced");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut kinds: Vec<ClipKind> = Vec::new();
|
|
||||||
for (mime, size) in os::available_kinds() {
|
|
||||||
kinds.push(ClipKind {
|
|
||||||
mime: mime.to_string(),
|
|
||||||
size_hint: size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if kinds.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.offer_seq = state.offer_seq.wrapping_add(1);
|
|
||||||
state.last_offer = Some(Instant::now());
|
|
||||||
let seq_id = state.offer_seq;
|
|
||||||
tracing::debug!(
|
|
||||||
seq = seq_id,
|
|
||||||
kinds = kinds.len(),
|
|
||||||
"clipboard: offering local copy"
|
|
||||||
);
|
|
||||||
if let Err(e) = client.clip_offer(seq_id, kinds) {
|
|
||||||
tracing::warn!(error = %e, "clipboard: offer failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
|
|
||||||
match ev {
|
|
||||||
ClipEventCore::State {
|
|
||||||
enabled,
|
|
||||||
policy,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
tracing::info!(enabled, policy, reason, "clipboard: host state");
|
|
||||||
}
|
|
||||||
// The host copied. Pull the best format we can place (see the module docs on why this
|
|
||||||
// is eager for now) — text preferred, then PNG.
|
|
||||||
ClipEventCore::RemoteOffer { seq, kinds } => {
|
|
||||||
state.remote_offer = Some(seq);
|
|
||||||
let pick = kinds
|
|
||||||
.iter()
|
|
||||||
.find(|k| k.mime == MIME_TEXT)
|
|
||||||
.or_else(|| kinds.iter().find(|k| k.mime == MIME_PNG));
|
|
||||||
let Some(kind) = pick else {
|
|
||||||
tracing::debug!("clipboard: remote offer has no format we can place");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if kind.size_hint > EAGER_FETCH_CAP {
|
|
||||||
tracing::info!(
|
|
||||||
mime = %kind.mime,
|
|
||||||
size = kind.size_hint,
|
|
||||||
"clipboard: remote payload over the eager-fetch cap — not mirrored"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match client.clip_fetch(seq, kind.mime.clone(), CLIP_FILE_INDEX_NONE) {
|
|
||||||
Ok(xfer) => state.pending_fetch = Some((xfer, kind.mime.clone())),
|
|
||||||
Err(e) => tracing::warn!(error = %e, "clipboard: fetch failed to start"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bytes for the fetch above — place them, then record the sequence number they cause.
|
|
||||||
ClipEventCore::Data {
|
|
||||||
xfer_id,
|
|
||||||
bytes,
|
|
||||||
last,
|
|
||||||
} => {
|
|
||||||
let Some((pending, mime)) = state.pending_fetch.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if pending != xfer_id {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if last {
|
|
||||||
state.pending_fetch = None;
|
|
||||||
}
|
|
||||||
match os::set(&mime, &bytes) {
|
|
||||||
Ok(()) => {
|
|
||||||
// §3.4: this is the change WE caused; ignore exactly it.
|
|
||||||
state.self_written_seq = Some(os::sequence_number());
|
|
||||||
state.last_applied = Some((mime.clone(), bytes));
|
|
||||||
tracing::debug!(mime = %mime, "clipboard: applied remote content");
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, mime = %mime, "clipboard: apply failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The host is pasting what we offered: read the bytes NOW (this is the lazy half) and
|
|
||||||
// answer. A read failure still answers — with a cancel — so the host isn't left waiting.
|
|
||||||
ClipEventCore::FetchRequest {
|
|
||||||
req_id,
|
|
||||||
seq: _,
|
|
||||||
file_index: _,
|
|
||||||
mime,
|
|
||||||
} => {
|
|
||||||
if os::is_concealed() {
|
|
||||||
let _ = client.clip_cancel(req_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Serve our own last-applied payload verbatim when it still matches — avoids a
|
|
||||||
// lossy OS round trip for content that originated on the host anyway.
|
|
||||||
let bytes = match &state.last_applied {
|
|
||||||
Some((m, b)) if *m == mime && state.self_written_seq.is_some() => Ok(b.clone()),
|
|
||||||
_ => os::get(&mime),
|
|
||||||
};
|
|
||||||
match bytes {
|
|
||||||
Ok(b) => {
|
|
||||||
tracing::debug!(mime = %mime, len = b.len(), "clipboard: serving to host");
|
|
||||||
if let Err(e) = client.clip_serve(req_id, b, true) {
|
|
||||||
tracing::warn!(error = %e, "clipboard: serve failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, mime = %mime, "clipboard: nothing to serve");
|
|
||||||
let _ = client.clip_cancel(req_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClipEventCore::Cancelled { id } => tracing::debug!(id, "clipboard: transfer cancelled"),
|
|
||||||
ClipEventCore::Error { id, code } => {
|
|
||||||
tracing::debug!(id, code, "clipboard: transfer error");
|
|
||||||
if state.pending_fetch.as_ref().is_some_and(|(x, _)| *x == id) {
|
|
||||||
state.pending_fetch = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
mod os {
|
|
||||||
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
|
|
||||||
//! closes it — holding it across a network fetch would block every other app on the box.
|
|
||||||
|
|
||||||
use super::{MIME_PNG, MIME_TEXT};
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
|
||||||
use windows::core::PCWSTR;
|
|
||||||
use windows::Win32::Foundation::{HANDLE, HGLOBAL};
|
|
||||||
use windows::Win32::System::DataExchange::{
|
|
||||||
CloseClipboard, EmptyClipboard, GetClipboardData, GetClipboardSequenceNumber,
|
|
||||||
IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW, SetClipboardData,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Memory::{
|
|
||||||
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CF_UNICODETEXT: u32 = 13;
|
|
||||||
|
|
||||||
/// A registered clipboard format id, by name (`PNG`, the concealed marker, …).
|
|
||||||
fn registered(name: &str) -> u32 {
|
|
||||||
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
|
||||||
unsafe { RegisterClipboardFormatW(PCWSTR(wide.as_ptr())) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn png_format() -> u32 {
|
|
||||||
registered("PNG")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RAII clipboard open — `CloseClipboard` must run even on the error paths.
|
|
||||||
struct Clip;
|
|
||||||
impl Clip {
|
|
||||||
fn open() -> Result<Clip> {
|
|
||||||
// A retry loop: another app can hold the clipboard for a moment.
|
|
||||||
for _ in 0..10 {
|
|
||||||
if unsafe { OpenClipboard(None) }.is_ok() {
|
|
||||||
return Ok(Clip);
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
bail!("clipboard busy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for Clip {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = unsafe { CloseClipboard() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sequence_number() -> u32 {
|
|
||||||
unsafe { GetClipboardSequenceNumber() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Password managers mark secrets with this format; §5.2's concealed-type rule.
|
|
||||||
pub fn is_concealed() -> bool {
|
|
||||||
let fmt = registered("ExcludeClipboardContentFromMonitorProcessing");
|
|
||||||
unsafe { IsClipboardFormatAvailable(fmt) }.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The wire kinds the current clipboard can supply, with size hints where they're free.
|
|
||||||
pub fn available_kinds() -> Vec<(&'static str, u64)> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if unsafe { IsClipboardFormatAvailable(CF_UNICODETEXT) }.is_ok() {
|
|
||||||
out.push((MIME_TEXT, 0));
|
|
||||||
}
|
|
||||||
if unsafe { IsClipboardFormatAvailable(png_format()) }.is_ok() {
|
|
||||||
out.push((MIME_PNG, 0));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read one wire format off the clipboard.
|
|
||||||
pub fn get(mime: &str) -> Result<Vec<u8>> {
|
|
||||||
let _clip = Clip::open()?;
|
|
||||||
match mime {
|
|
||||||
MIME_TEXT => {
|
|
||||||
let h = unsafe { GetClipboardData(CF_UNICODETEXT) }?;
|
|
||||||
let g = HGLOBAL(h.0);
|
|
||||||
let p = unsafe { GlobalLock(g) } as *const u16;
|
|
||||||
if p.is_null() {
|
|
||||||
bail!("clipboard text lock failed");
|
|
||||||
}
|
|
||||||
// GlobalSize is a byte count of a NUL-terminated UTF-16 buffer.
|
|
||||||
let bytes = unsafe { GlobalSize(g) };
|
|
||||||
let mut len = bytes / 2;
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts(p, len) };
|
|
||||||
if let Some(nul) = slice.iter().position(|&c| c == 0) {
|
|
||||||
len = nul;
|
|
||||||
}
|
|
||||||
let text = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) });
|
|
||||||
let _ = unsafe { GlobalUnlock(g) };
|
|
||||||
Ok(text.into_bytes())
|
|
||||||
}
|
|
||||||
MIME_PNG => {
|
|
||||||
let h = unsafe { GetClipboardData(png_format()) }?;
|
|
||||||
let g = HGLOBAL(h.0);
|
|
||||||
let p = unsafe { GlobalLock(g) } as *const u8;
|
|
||||||
if p.is_null() {
|
|
||||||
bail!("clipboard png lock failed");
|
|
||||||
}
|
|
||||||
let len = unsafe { GlobalSize(g) };
|
|
||||||
let out = unsafe { std::slice::from_raw_parts(p, len) }.to_vec();
|
|
||||||
let _ = unsafe { GlobalUnlock(g) };
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
other => Err(anyhow!("unsupported clipboard format {other}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Place one wire format on the clipboard, replacing its contents.
|
|
||||||
pub fn set(mime: &str, bytes: &[u8]) -> Result<()> {
|
|
||||||
let (fmt, payload) = match mime {
|
|
||||||
MIME_TEXT => {
|
|
||||||
let text = String::from_utf8_lossy(bytes);
|
|
||||||
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
|
|
||||||
let raw: Vec<u8> = wide.iter().flat_map(|c| c.to_le_bytes()).collect();
|
|
||||||
(CF_UNICODETEXT, raw)
|
|
||||||
}
|
|
||||||
MIME_PNG => (png_format(), bytes.to_vec()),
|
|
||||||
other => bail!("unsupported clipboard format {other}"),
|
|
||||||
};
|
|
||||||
let _clip = Clip::open()?;
|
|
||||||
unsafe { EmptyClipboard() }?;
|
|
||||||
// The clipboard OWNS this block once SetClipboardData succeeds — do not free it.
|
|
||||||
let g = unsafe { GlobalAlloc(GMEM_MOVEABLE, payload.len()) }?;
|
|
||||||
let p = unsafe { GlobalLock(g) } as *mut u8;
|
|
||||||
if p.is_null() {
|
|
||||||
bail!("clipboard alloc lock failed");
|
|
||||||
}
|
|
||||||
unsafe { std::ptr::copy_nonoverlapping(payload.as_ptr(), p, payload.len()) };
|
|
||||||
let _ = unsafe { GlobalUnlock(g) };
|
|
||||||
unsafe { SetClipboardData(fmt, Some(HANDLE(g.0))) }?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
mod os {
|
|
||||||
//! Non-Windows stub. Linux needs the Wayland `data-control` seam (the same protocol the
|
|
||||||
//! host side already speaks) — the bridge above is platform-neutral and will drive it
|
|
||||||
//! unchanged once this module grows a real implementation.
|
|
||||||
use anyhow::{bail, Result};
|
|
||||||
|
|
||||||
pub fn sequence_number() -> u32 {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
pub fn is_concealed() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
pub fn available_kinds() -> Vec<(&'static str, u64)> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
pub fn get(_mime: &str) -> Result<Vec<u8>> {
|
|
||||||
bail!("clipboard unsupported on this platform")
|
|
||||||
}
|
|
||||||
pub fn set(_mime: &str, _bytes: &[u8]) -> Result<()> {
|
|
||||||
bail!("clipboard unsupported on this platform")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,19 +41,11 @@ mod video_software;
|
|||||||
mod video_vaapi;
|
mod video_vaapi;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
mod video_vulkan;
|
mod video_vulkan;
|
||||||
// The OS-clipboard bridge for the shared clipboard (design/clipboard-and-file-transfer.md §5).
|
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
||||||
// Built everywhere the session client is; the platform seam inside is Windows-real,
|
// present-path decision and the Apple Metal port are their own phases).
|
||||||
// stub elsewhere.
|
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
|
||||||
pub mod clipboard;
|
|
||||||
// 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.
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod video_d3d11;
|
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 video_pyrowave;
|
||||||
|
|
||||||
pub mod wol;
|
pub mod wol;
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ pub struct SessionParams {
|
|||||||
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
|
||||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
|
||||||
pub clipboard: bool,
|
|
||||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||||
/// `video::Decoder::new`).
|
/// `video::Decoder::new`).
|
||||||
pub decoder: String,
|
pub decoder: String,
|
||||||
@@ -230,7 +227,7 @@ fn pump(
|
|||||||
// the plan-§3 contract: the host only ever picks PyroWave when the client names it.
|
// the plan-§3 contract: the host only ever picks PyroWave when the client names it.
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut preferred = params.preferred_codec;
|
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 std::env::var("PUNKTFUNK_PREFER_PYROWAVE").as_deref() == Ok("1") {
|
||||||
if params.vulkan.as_ref().is_some_and(|v| v.pyrowave_decode) {
|
if params.vulkan.as_ref().is_some_and(|v| v.pyrowave_decode) {
|
||||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||||
@@ -299,27 +296,15 @@ fn pump(
|
|||||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||||
// reachable only through the explicit preference above (resolve_codec never
|
// reachable only through the explicit preference above (resolve_codec never
|
||||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
// 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 built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||||
let mode = connector.mode();
|
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() {
|
match params.vulkan.as_ref() {
|
||||||
Some(vk) => Decoder::new_pyrowave(
|
Some(vk) => Decoder::new_pyrowave(
|
||||||
vk,
|
vk,
|
||||||
mode.width,
|
mode.width,
|
||||||
mode.height,
|
mode.height,
|
||||||
connector.shard_payload as usize,
|
connector.shard_payload as usize,
|
||||||
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
|
||||||
color,
|
|
||||||
connector.bit_depth >= 10,
|
|
||||||
),
|
),
|
||||||
None => Err(anyhow::anyhow!(
|
None => Err(anyhow::anyhow!(
|
||||||
"pyrowave session without a presenter device"
|
"pyrowave session without a presenter device"
|
||||||
@@ -328,7 +313,7 @@ fn pump(
|
|||||||
} else {
|
} else {
|
||||||
Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref())
|
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 built = Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref());
|
||||||
let mut decoder = match built {
|
let mut decoder = match built {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@@ -342,20 +327,6 @@ fn pump(
|
|||||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
|
||||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
|
||||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
|
||||||
let clipboard_thread = params
|
|
||||||
.clipboard
|
|
||||||
.then(|| {
|
|
||||||
let c = connector.clone();
|
|
||||||
let s = stop.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pf-clipboard".into())
|
|
||||||
.spawn(move || crate::clipboard::run(c, s))
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.flatten();
|
|
||||||
let _mic = params
|
let _mic = params
|
||||||
.mic_enabled
|
.mic_enabled
|
||||||
.then(|| {
|
.then(|| {
|
||||||
@@ -447,16 +418,8 @@ fn pump(
|
|||||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||||
match connector.next_frame(Duration::from_millis(20)) {
|
match connector.next_frame(Duration::from_millis(20)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
// The `received` point: reassembly COMPLETION, stamped by the core session as
|
// The `received` point: AU fully reassembled, in hand, before decode.
|
||||||
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead
|
let received_ns = now_ns();
|
||||||
// would fold the pre-decode queue wait into `host+network` — a client-side
|
|
||||||
// standing backlog masquerading as network latency (the 2026-07 two-pair
|
|
||||||
// investigation). 0 = a core predating the stamp; fall back to the pull instant.
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns
|
|
||||||
} else {
|
|
||||||
now_ns()
|
|
||||||
};
|
|
||||||
// fps / goodput count every received AU (spec), decoded or not.
|
// fps / goodput count every received AU (spec), decoded or not.
|
||||||
frames_n += 1;
|
frames_n += 1;
|
||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
@@ -553,7 +516,7 @@ fn pump(
|
|||||||
DecodedImage::VkFrame(_) => "vulkan",
|
DecodedImage::VkFrame(_) => "vulkan",
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
DecodedImage::D3d11(_) => "d3d11va",
|
DecodedImage::D3d11(_) => "d3d11va",
|
||||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
DecodedImage::PyroWave(_) => "pyrowave",
|
DecodedImage::PyroWave(_) => "pyrowave",
|
||||||
};
|
};
|
||||||
if total_frames == 1 {
|
if total_frames == 1 {
|
||||||
@@ -564,10 +527,7 @@ fn pump(
|
|||||||
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
|
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
|
||||||
#[cfg(all(
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
any(target_os = "linux", windows),
|
|
||||||
feature = "pyrowave"
|
|
||||||
))]
|
|
||||||
DecodedImage::PyroWave(f) => (f.width, f.height, "pyrowave"),
|
DecodedImage::PyroWave(f) => (f.width, f.height, "pyrowave"),
|
||||||
};
|
};
|
||||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||||
@@ -824,9 +784,6 @@ fn pump(
|
|||||||
if let Some(t) = audio_thread {
|
if let Some(t) = audio_thread {
|
||||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||||
}
|
}
|
||||||
if let Some(t) = clipboard_thread {
|
|
||||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
|
||||||
}
|
|
||||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,12 +124,6 @@ pub struct KnownHost {
|
|||||||
/// pre-existing stores load; empty until first learned.
|
/// pre-existing stores load; empty until first learned.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mac: Vec<String>,
|
pub mac: Vec<String>,
|
||||||
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
|
|
||||||
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
|
|
||||||
/// host your clipboard is a trust decision about that host. Default off; the host must
|
|
||||||
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub clipboard_sync: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -207,7 +201,6 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
|||||||
paired,
|
paired,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
}
|
}
|
||||||
@@ -533,27 +526,6 @@ pub struct Settings {
|
|||||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||||
pub library_enabled: bool,
|
pub library_enabled: bool,
|
||||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
|
||||||
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
|
|
||||||
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
|
|
||||||
/// an offline-looking host is really just unreachable by broadcast and the wake +
|
|
||||||
/// wait only adds a delay.
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub auto_wake: bool,
|
|
||||||
/// Reverse the wheel/trackpad scroll direction sent to the host (the Apple client's
|
|
||||||
/// "Invert scroll direction"). Default off = the host scrolls the way this machine does.
|
|
||||||
#[serde(default)]
|
|
||||||
pub invert_scroll: bool,
|
|
||||||
/// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the
|
|
||||||
/// playback stream targets (`target.object`); empty = the session default (the
|
|
||||||
/// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`.
|
|
||||||
/// Ignored on Windows until the WASAPI endpoint leg exists.
|
|
||||||
#[serde(default)]
|
|
||||||
pub speaker_device: String,
|
|
||||||
/// Capture endpoint for the mic uplink (same semantics as `speaker_device`;
|
|
||||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
|
||||||
#[serde(default)]
|
|
||||||
pub mic_device: String,
|
|
||||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||||
@@ -642,10 +614,6 @@ impl Default for Settings {
|
|||||||
stats_verbosity: None,
|
stats_verbosity: None,
|
||||||
fullscreen_on_stream: true,
|
fullscreen_on_stream: true,
|
||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
auto_wake: true,
|
|
||||||
invert_scroll: false,
|
|
||||||
speaker_device: String::new(),
|
|
||||||
mic_device: String::new(),
|
|
||||||
match_window: false,
|
match_window: false,
|
||||||
last_window_w: 0,
|
last_window_w: 0,
|
||||||
last_window_h: 0,
|
last_window_h: 0,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||||
//!
|
//!
|
||||||
//! Three backends, picked at session start (auto is vendor-ordered on BOTH desktop OSes —
|
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
|
||||||
//! see [`VulkanDecodeDevice::prefer_vulkan_first`]. Linux: vaapi → vulkan → software on
|
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
|
||||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh. Windows: d3d11va → vulkan → software on
|
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
|
||||||
//! Intel/unknown, vulkan first on NVIDIA/AMD.
|
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||||
//! Override: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`):
|
|
||||||
//!
|
//!
|
||||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||||
//! (its handles arrive via [`VulkanDecodeDevice`]) — the decoded VkImage feeds the
|
//! (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.
|
//! 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
|
//! 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
|
//! chain there is Vulkan → **D3D11VA** (`crate::video_d3d11` — the vendor-agnostic DXVA
|
||||||
//! vendor-agnostic DXVA path every Windows video player exercises), ordered per vendor:
|
//! path, which is how Intel's Windows driver gets hardware decode without Vulkan Video)
|
||||||
//! Intel's driver DOES advertise Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan
|
//! → software. Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
// bindgen's C-enum repr is target-dependent (u32 on Linux/clang, i32 on MSVC), so the
|
// 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
|
// 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,
|
/// PyroWave planar output: three R8 plane views on the presenter's own device,
|
||||||
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
||||||
/// samples them directly (BT.709 limited, the codec's fixed colour contract).
|
/// 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),
|
PyroWave(crate::video_pyrowave::PyroWavePlanarFrame),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +151,7 @@ impl DecodedImage {
|
|||||||
DecodedImage::VkFrame(f) => f.keyframe,
|
DecodedImage::VkFrame(f) => f.keyframe,
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
DecodedImage::D3d11(f) => f.keyframe,
|
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,
|
DecodedImage::PyroWave(f) => f.keyframe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +167,7 @@ impl DecodedImage {
|
|||||||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
DecodedImage::D3d11(f) => (f.width, f.height),
|
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),
|
DecodedImage::PyroWave(f) => (f.width, f.height),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,10 +234,9 @@ enum Backend {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
||||||
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
||||||
/// no FFmpeg involvement (Linux + Windows — same Vulkan presenter on both). No demotion
|
/// no FFmpeg involvement. No demotion rung — there is no other decoder for it.
|
||||||
/// rung — there is no other decoder for it.
|
|
||||||
/// Boxed: the decoder (pinned create-info hold + plane ring) dwarfs the other variants.
|
/// 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>),
|
PyroWave(Box<crate::video_pyrowave::PyroWaveDecoder>),
|
||||||
Software(SoftwareDecoder),
|
Software(SoftwareDecoder),
|
||||||
}
|
}
|
||||||
@@ -255,42 +250,16 @@ pub struct Decoder {
|
|||||||
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
||||||
/// session its hardware decoder.
|
/// session its hardware decoder.
|
||||||
vaapi_fails: u32,
|
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).
|
/// 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
|
/// 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.
|
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
|
||||||
want_keyframe: bool,
|
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
|
/// Demote VAAPI→software only after this many consecutive hardware decode errors; a lone
|
||||||
/// this many consecutive decode errors; a lone transient error just re-requests an IDR
|
/// transient error just re-requests an IDR and keeps the hardware decoder.
|
||||||
/// and keeps the hardware decoder.
|
|
||||||
const VAAPI_DEMOTE_AFTER: u32 = 3;
|
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.
|
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||||
match wire {
|
match wire {
|
||||||
@@ -323,11 +292,11 @@ pub fn decodable_codecs() -> u8 {
|
|||||||
/// under its explicit opt-in.
|
/// under its explicit opt-in.
|
||||||
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
|
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
|
||||||
let bits = decodable_codecs();
|
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) {
|
if vk.map(|v| v.pyrowave_decode).unwrap_or(false) {
|
||||||
return bits | punktfunk_core::quic::CODEC_PYROWAVE;
|
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;
|
let _ = vk;
|
||||||
bits
|
bits
|
||||||
}
|
}
|
||||||
@@ -359,12 +328,10 @@ impl Decoder {
|
|||||||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||||
/// hatch, and the documented knob), then the setting; both default to auto.
|
/// hatch, and the documented knob), then the setting; both default to auto.
|
||||||
/// Auto's hardware order depends on the device on BOTH desktop OSes
|
/// Auto's hardware order on Linux depends on the device
|
||||||
/// ([`VulkanDecodeDevice::prefer_vulkan_first`]). Linux: VAAPI → Vulkan → software on
|
/// ([`VulkanDecodeDevice::prefer_vulkan_over_vaapi`]): VAAPI → Vulkan → software on
|
||||||
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
|
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
|
||||||
/// VanGogh. Windows (no VAAPI there): Vulkan → D3D11VA → software on NVIDIA/AMD,
|
/// VanGogh. Windows is Vulkan → D3D11VA → software (no VAAPI there).
|
||||||
/// D3D11VA → Vulkan → software on Intel/unknown (Intel's driver advertises Vulkan
|
|
||||||
/// Video, but FFmpeg-Vulkan on it strobes/overruns the budget — B580 field report).
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
codec_id: ffmpeg::codec::Id,
|
codec_id: ffmpeg::codec::Id,
|
||||||
pref: &str,
|
pref: &str,
|
||||||
@@ -376,25 +343,12 @@ impl Decoder {
|
|||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or_else(|| pref.to_string());
|
.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| {
|
let done = |backend| {
|
||||||
Ok(Decoder {
|
Ok(Decoder {
|
||||||
backend,
|
backend,
|
||||||
codec_id,
|
codec_id,
|
||||||
vaapi_fails: 0,
|
vaapi_fails: 0,
|
||||||
first_fail: None,
|
|
||||||
want_keyframe: false,
|
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
|
// 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")
|
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||||
&& !vk
|
&& !vk
|
||||||
.filter(|v| v.video_decode)
|
.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;
|
vaapi_tried = true;
|
||||||
match VaapiDecoder::new(codec_id) {
|
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") {
|
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||||||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
// `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
|
// 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
|
// Windows: D3D11VA is the vendor-agnostic DXVA fallback when Vulkan Video isn't
|
||||||
// or failed to open) and the explicit `d3d11va` preference — gated on the presenter
|
// available (Intel's Windows driver foremost) — gated on the presenter having the
|
||||||
// having the win32 external-memory import path, else its frames could never reach
|
// win32 external-memory import path, else its frames could never reach the screen.
|
||||||
// the screen. (On Intel/unknown auto it was already tried above — `d3d11_tried`
|
|
||||||
// skips the repeat.)
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if choice != "software" && choice != "vulkan" && !d3d11_tried {
|
if choice != "software" && choice != "vulkan" {
|
||||||
match vk.filter(|v| v.d3d11_import) {
|
match vk.filter(|v| v.d3d11_import) {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
match crate::video_d3d11::D3d11vaDecoder::new(
|
match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid) {
|
||||||
codec_id,
|
|
||||||
v.adapter_luid,
|
|
||||||
v.d3d11_hdr10,
|
|
||||||
) {
|
|
||||||
Ok(d) => {
|
Ok(d) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?codec_id,
|
?codec_id,
|
||||||
@@ -573,15 +483,12 @@ impl Decoder {
|
|||||||
/// Open a PyroWave decoder for a `CODEC_PYROWAVE` session (plan §4.5): pyrowave
|
/// 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
|
/// compute on the presenter's device, no FFmpeg. `codec_id` is irrelevant (kept as
|
||||||
/// HEVC so an — impossible — demotion path stays well-formed).
|
/// 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(
|
pub fn new_pyrowave(
|
||||||
vk: &VulkanDecodeDevice,
|
vk: &VulkanDecodeDevice,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
chroma444: bool,
|
|
||||||
color: ColorDesc,
|
|
||||||
hdr16: bool,
|
|
||||||
) -> Result<Decoder> {
|
) -> Result<Decoder> {
|
||||||
Ok(Decoder {
|
Ok(Decoder {
|
||||||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||||||
@@ -589,23 +496,10 @@ impl Decoder {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
shard_payload,
|
shard_payload,
|
||||||
chroma444,
|
|
||||||
color,
|
|
||||||
hdr16,
|
|
||||||
)?)),
|
)?)),
|
||||||
codec_id: ffmpeg::codec::Id::HEVC,
|
codec_id: ffmpeg::codec::Id::HEVC,
|
||||||
vaapi_fails: 0,
|
vaapi_fails: 0,
|
||||||
first_fail: None,
|
|
||||||
want_keyframe: false,
|
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");
|
tracing::warn!("presenter can't display hardware frames — demoting to software decode");
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
self.first_fail = None;
|
|
||||||
self.want_keyframe = true;
|
self.want_keyframe = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -649,7 +542,7 @@ impl Decoder {
|
|||||||
au: &[u8],
|
au: &[u8],
|
||||||
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
not(all(any(target_os = "linux", windows), feature = "pyrowave")),
|
not(all(target_os = "linux", feature = "pyrowave")),
|
||||||
allow(unused_variables)
|
allow(unused_variables)
|
||||||
)]
|
)]
|
||||||
user_flags: u32,
|
user_flags: u32,
|
||||||
@@ -667,7 +560,7 @@ impl Decoder {
|
|||||||
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
||||||
// error; the pump surfaces it and the session falls back to HEVC by
|
// error; the pump surfaces it and the session falls back to HEVC by
|
||||||
// renegotiation (plan §4.6), not by decoder swap.
|
// 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) => {
|
Backend::PyroWave(p) => {
|
||||||
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
||||||
return Ok(p
|
return Ok(p
|
||||||
@@ -679,7 +572,6 @@ impl Decoder {
|
|||||||
match result {
|
match result {
|
||||||
Ok(f) => {
|
Ok(f) => {
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
self.first_fail = None;
|
|
||||||
Ok(f)
|
Ok(f)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -691,9 +583,7 @@ impl Decoder {
|
|||||||
};
|
};
|
||||||
self.vaapi_fails += 1;
|
self.vaapi_fails += 1;
|
||||||
self.want_keyframe = true;
|
self.want_keyframe = true;
|
||||||
let first = *self.first_fail.get_or_insert_with(std::time::Instant::now);
|
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER && first.elapsed() >= HW_DEMOTE_MIN_STREAK
|
|
||||||
{
|
|
||||||
// A failing Vulkan backend still has a hardware rung below it on
|
// A failing Vulkan backend still has a hardware rung below it on
|
||||||
// Linux — demote to VAAPI first (user-reported: FFmpeg-Vulkan-on-Mesa
|
// Linux — demote to VAAPI first (user-reported: FFmpeg-Vulkan-on-Mesa
|
||||||
// error-streaking where VAAPI streams perfectly); only when that
|
// error-streaking where VAAPI streams perfectly); only when that
|
||||||
@@ -706,39 +596,16 @@ impl Decoder {
|
|||||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||||
self.backend = Backend::Vaapi(v);
|
self.backend = Backend::Vaapi(v);
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
self.first_fail = None;
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Err(va) => tracing::info!(reason = %va,
|
Err(va) => tracing::info!(reason = %va,
|
||||||
"VAAPI unavailable for demotion — software decode"),
|
"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,
|
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||||
"{which} decode failing repeatedly — demoting to software");
|
"{which} decode failing repeatedly — demoting to software");
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
self.first_fail = None;
|
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(backend = which, error = %e,
|
tracing::debug!(backend = which, error = %e,
|
||||||
"decode error — requesting keyframe, keeping hardware decode");
|
"decode error — requesting keyframe, keeping hardware decode");
|
||||||
@@ -845,10 +712,10 @@ pub struct VulkanDecodeDevice {
|
|||||||
pub physical_device: usize,
|
pub physical_device: usize,
|
||||||
pub device: usize,
|
pub device: usize,
|
||||||
/// PCI vendor of the presenter's physical device (0x10DE NVIDIA, 0x1002 AMD,
|
/// 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,
|
pub vendor_id: u32,
|
||||||
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
|
/// 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,
|
pub device_name: String,
|
||||||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||||
pub graphics_qf: u32,
|
pub graphics_qf: u32,
|
||||||
@@ -891,10 +758,6 @@ pub struct VulkanDecodeDevice {
|
|||||||
/// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`:
|
/// 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.
|
/// D3D11 shared-texture frames can reach the screen. Always `false` off Windows.
|
||||||
pub d3d11_import: bool,
|
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
|
/// `VkPhysicalDeviceIDProperties::deviceLUID` when the driver reports one — the D3D11VA
|
||||||
/// backend creates its decode device on the SAME adapter so shared textures never cross
|
/// 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).
|
/// GPUs. `None` when not reported (or off Windows, where it's unused).
|
||||||
@@ -906,11 +769,9 @@ pub struct VulkanDecodeDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VulkanDecodeDevice {
|
impl VulkanDecodeDevice {
|
||||||
/// Should `auto` try Vulkan Video BEFORE the platform's other hardware path (VAAPI on
|
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device?
|
||||||
/// Linux, D3D11VA on Windows) on this device?
|
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
|
||||||
/// * **NVIDIA** — Vulkan Video is the proven path (on Linux the only one: no usable
|
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
|
||||||
/// 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).
|
|
||||||
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
||||||
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
||||||
/// additionally shows chroma fringing; the session binary opts RADV into
|
/// 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),
|
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
|
||||||
/// so a broken Mesa Vulkan path still lands on the working driver.
|
/// 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
|
/// Intel (ANV) and unknown vendors keep the battle-tested zero-copy VAAPI first —
|
||||||
/// Vulkan Video is the least-proven Mesa path), D3D11VA on Windows — Intel's Windows
|
/// ANV's Vulkan Video is the least-proven Mesa path and VAAPI is what every other
|
||||||
/// driver advertises Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan on it is
|
/// Linux client uses there.
|
||||||
/// field-broken (B580, 2026-07: strobing + ~7 ms decodes) where DXVA streams clean.
|
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
|
||||||
pub fn prefer_vulkan_first(&self) -> bool {
|
|
||||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||||
const VENDOR_AMD: u32 = 0x1002;
|
const VENDOR_AMD: u32 = 0x1002;
|
||||||
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
||||||
@@ -979,33 +839,30 @@ mod tests {
|
|||||||
pyrowave_decode: false,
|
pyrowave_decode: false,
|
||||||
video_decode: true,
|
video_decode: true,
|
||||||
d3d11_import: false,
|
d3d11_import: false,
|
||||||
d3d11_hdr10: false,
|
|
||||||
adapter_luid: None,
|
adapter_luid: None,
|
||||||
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auto's hardware order (both OSes): Vulkan-first on NVIDIA (on Linux: no usable
|
/// Auto's Linux hardware order: Vulkan-first on NVIDIA (no usable VAAPI) and ALL AMD
|
||||||
/// VAAPI) and ALL AMD (Vulkan decode outperforms VAAPI on RADV — on-glass verdict;
|
/// (Vulkan decode outperforms VAAPI on RADV — on-glass verdict; VanGogh additionally
|
||||||
/// VanGogh additionally chroma-fringes over VAAPI); Intel/unknown take the proven
|
/// chroma-fringes over VAAPI); Intel/unknown keep VAAPI first (ANV's Vulkan Video is
|
||||||
/// path first — VAAPI on Linux (ANV's Vulkan Video is the least-proven Mesa path),
|
/// the least-proven Mesa path). A Vulkan failure streak still demotes to VAAPI, so
|
||||||
/// D3D11VA on Windows (Intel's driver advertises Vulkan Video since 2023, but
|
/// Vulkan-first can never strand a box on software decode.
|
||||||
/// 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.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vulkan_first_on_nvidia_and_amd_only() {
|
fn vulkan_over_vaapi_on_nvidia_and_amd() {
|
||||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_first());
|
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
|
||||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_first());
|
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
|
||||||
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());
|
|
||||||
assert!(
|
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
|
/// 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
|
//! 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
|
//! path that covers what Vulkan Video can't (Intel's Windows driver foremost, which has no
|
||||||
//! advertise Vulkan Video (Arc drivers since 2023 — don't trust the capability gate to
|
//! video-decode queue and previously landed on CPU decode).
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Ported from the retired in-process WinUI presenter's decoder (`clients/windows/src/video.rs`)
|
//! 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
|
//! 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
|
//! (`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
|
//! presenter drops (arrival-paced, newest wins) is simply never acquired, which a
|
||||||
//! key-ping-pong protocol would deadlock on.
|
//! key-ping-pong protocol would deadlock on.
|
||||||
//! * An HDR (PQ/BT.2020) stream passes through when the presenter can take it (RGB10A2
|
//! * An HDR (PQ/BT.2020) stream is tone-mapped to SDR by the video processor (input colour
|
||||||
//! import + an HDR10 swapchain — [`crate::video::VulkanDecodeDevice::d3d11_hdr10`]): the
|
//! space `G2084_P2020`, output sRGB): correct picture, no HDR presentation on this backend —
|
||||||
//! video processor converts YCbCr G2084 → RGB G2084 into an RGB10A2 ring, colorspace
|
//! its targets (Intel iGPU laptops) are SDR panels; HDR-first boxes take Vulkan Video.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! The decode device is created on the **presenter's adapter** (matched by the Vulkan device's
|
//! 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.
|
//! 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::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
use windows::core::{Interface, GUID};
|
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::Direct3D::{D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
||||||
@@ -57,12 +52,11 @@ use windows::Win32::Graphics::Direct3D11::{
|
|||||||
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
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_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
|
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_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_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
|
||||||
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_RATIONAL,
|
|
||||||
DXGI_SAMPLE_DESC,
|
DXGI_SAMPLE_DESC,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
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 struct D3d11Frame {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// What the ring slot actually CONTAINS after the video processor's conversion:
|
/// What the ring slot actually CONTAINS after the video processor's conversion: sRGB
|
||||||
/// sRGB BT.709 full-range RGB normally (a PQ stream was tone-mapped), or PQ BT.2020
|
/// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was
|
||||||
/// full-range RGB when the HDR pass-through ring is active (`rgb10`) — the presenter
|
/// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR.
|
||||||
/// keys its SDR/HDR handling off this.
|
|
||||||
pub color: ColorDesc,
|
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
|
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See
|
||||||
/// `crate::video::VkVideoFrame`.
|
/// `crate::video::VkVideoFrame`.
|
||||||
pub keyframe: bool,
|
pub keyframe: bool,
|
||||||
@@ -341,9 +331,6 @@ struct SharedRing {
|
|||||||
height: u32,
|
height: u32,
|
||||||
next: usize,
|
next: usize,
|
||||||
generation: u32,
|
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 {
|
impl SharedRing {
|
||||||
@@ -353,7 +340,6 @@ impl SharedRing {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
generation: u32,
|
generation: u32,
|
||||||
pq_out: bool,
|
|
||||||
) -> Result<SharedRing> {
|
) -> Result<SharedRing> {
|
||||||
// The video processor: NV12/P010 in, BGRA8 out, 1:1 (no scaling — the Vulkan side
|
// 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.
|
// scales at composite time like every other path). Frame rates are advisory.
|
||||||
@@ -383,15 +369,10 @@ impl SharedRing {
|
|||||||
Height: height,
|
Height: height,
|
||||||
MipLevels: 1,
|
MipLevels: 1,
|
||||||
ArraySize: 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
|
// universally exercised driver path (see the module docs — NV12 import TDRs
|
||||||
// on NVIDIA despite being advertised). RGB10A2 for the HDR pass-through
|
// on NVIDIA despite being advertised).
|
||||||
// flavor (gated on the presenter's probe), BGRA8 otherwise.
|
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||||
Format: if pq_out {
|
|
||||||
DXGI_FORMAT_R10G10B10A2_UNORM
|
|
||||||
} else {
|
|
||||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
|
||||||
},
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
Count: 1,
|
Count: 1,
|
||||||
Quality: 0,
|
Quality: 0,
|
||||||
@@ -449,8 +430,7 @@ impl SharedRing {
|
|||||||
height,
|
height,
|
||||||
slots = RING_SLOTS,
|
slots = RING_SLOTS,
|
||||||
generation,
|
generation,
|
||||||
hdr = pq_out,
|
"D3D11 shared hand-off ring built (VideoProcessor → BGRA8)"
|
||||||
"D3D11 shared hand-off ring built (VideoProcessor → RGB)"
|
|
||||||
);
|
);
|
||||||
Ok(SharedRing {
|
Ok(SharedRing {
|
||||||
slots,
|
slots,
|
||||||
@@ -460,7 +440,6 @@ impl SharedRing {
|
|||||||
height,
|
height,
|
||||||
next: 0,
|
next: 0,
|
||||||
generation,
|
generation,
|
||||||
pq_out,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,10 +457,6 @@ pub(crate) struct D3d11vaDecoder {
|
|||||||
/// setters (Win10 1703+, universally present — init fails to software without it).
|
/// setters (Win10 1703+, universally present — init fails to software without it).
|
||||||
video_context1: ID3D11VideoContext1,
|
video_context1: ID3D11VideoContext1,
|
||||||
ring: Option<SharedRing>,
|
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
|
// Single-owner pointers + COM interfaces, only touched from the session pump thread (the
|
||||||
@@ -492,7 +467,6 @@ impl D3d11vaDecoder {
|
|||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
codec_id: ffmpeg::codec::Id,
|
codec_id: ffmpeg::codec::Id,
|
||||||
luid: Option<[u8; 8]>,
|
luid: Option<[u8; 8]>,
|
||||||
hdr10_out: bool,
|
|
||||||
) -> Result<D3d11vaDecoder> {
|
) -> Result<D3d11vaDecoder> {
|
||||||
use ffmpeg::ffi;
|
use ffmpeg::ffi;
|
||||||
let (device, context) = create_device(luid)?;
|
let (device, context) = create_device(luid)?;
|
||||||
@@ -563,7 +537,6 @@ impl D3d11vaDecoder {
|
|||||||
video_device,
|
video_device,
|
||||||
video_context1,
|
video_context1,
|
||||||
ring: None,
|
ring: None,
|
||||||
hdr10_out,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,15 +591,12 @@ impl D3d11vaDecoder {
|
|||||||
let video_device = self.video_device.clone();
|
let video_device = self.video_device.clone();
|
||||||
let video_context1 = self.video_context1.clone();
|
let video_context1 = self.video_context1.clone();
|
||||||
let context = self.context.clone();
|
let context = self.context.clone();
|
||||||
// (Re)build the ring + video processor on first use, a stream size change, or a
|
// (Re)build the ring + video processor on first use or a stream size change (the
|
||||||
// flavor change (the host flips PQ in-band; SDR↔HDR swaps the slot format, so
|
// hand-off is BGRA8 regardless of the stream's bit depth, so depth never rebuilds).
|
||||||
// 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();
|
|
||||||
let rebuild = self
|
let rebuild = self
|
||||||
.ring
|
.ring
|
||||||
.as_ref()
|
.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 {
|
if rebuild {
|
||||||
let generation = self.ring.as_ref().map_or(0, |r| r.generation + 1);
|
let generation = self.ring.as_ref().map_or(0, |r| r.generation + 1);
|
||||||
self.ring = Some(SharedRing::build(
|
self.ring = Some(SharedRing::build(
|
||||||
@@ -635,7 +605,6 @@ impl D3d11vaDecoder {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
generation,
|
generation,
|
||||||
pq_out,
|
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
let ring = self.ring.as_mut().expect("ring built above");
|
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,
|
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||||
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_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.VideoProcessorSetStreamColorSpace1(&ring.vp, 0, in_cs);
|
||||||
video_context1.VideoProcessorSetOutputColorSpace1(
|
video_context1.VideoProcessorSetOutputColorSpace1(
|
||||||
&ring.vp,
|
&ring.vp,
|
||||||
// HDR ring: PQ in, PQ out — a pure colorspace conversion (YCbCr→RGB),
|
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||||
// 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
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
let stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
||||||
@@ -741,38 +684,17 @@ impl D3d11vaDecoder {
|
|||||||
// completion, and an unflushed deferred batch would add a driver-decided delay.
|
// completion, and an unflushed deferred batch would add a driver-decided delay.
|
||||||
context.Flush();
|
context.Flush();
|
||||||
|
|
||||||
let mut src_desc = D3D11_TEXTURE2D_DESC::default();
|
log_layout_once(width, height, index, color.is_pq());
|
||||||
src.GetDesc(&mut src_desc);
|
|
||||||
log_layout_once(
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
src_desc.Width,
|
|
||||||
src_desc.Height,
|
|
||||||
index,
|
|
||||||
color.is_pq(),
|
|
||||||
);
|
|
||||||
Ok(D3d11Frame {
|
Ok(D3d11Frame {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
// What the slot now CONTAINS. HDR ring: PQ BT.2020 full-range RGB (the
|
// What the slot now CONTAINS: sRGB BT.709 full-range RGB (PQ was tone-mapped).
|
||||||
// presenter reads is_pq() and flips its HDR10 swapchain). SDR ring: sRGB
|
color: ColorDesc {
|
||||||
// BT.709 full-range RGB (PQ was tone-mapped above).
|
primaries: 1,
|
||||||
color: if ring.pq_out {
|
transfer: 13, // sRGB (H.273)
|
||||||
ColorDesc {
|
matrix: 0, // identity — RGB
|
||||||
primaries: 9,
|
full_range: true,
|
||||||
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,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
rgb10: ring.pq_out,
|
|
||||||
// SAFETY: `self.frame` is the live decoded AVFrame for this call.
|
// SAFETY: `self.frame` is the live decoded AVFrame for this call.
|
||||||
keyframe: crate::video::frame_is_keyframe(self.frame),
|
keyframe: crate::video::frame_is_keyframe(self.frame),
|
||||||
handle,
|
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.
|
/// 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
|
fn log_layout_once(width: u32, height: u32, index: u32, pq: bool) {
|
||||||
/// stream source rect excludes.
|
|
||||||
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
if ONCE.swap(false, Ordering::Relaxed) {
|
||||||
tracing::info!(
|
tracing::info!(width, height, slice = index, pq, "D3D11VA first frame");
|
||||||
width,
|
|
||||||
height,
|
|
||||||
tex_w,
|
|
||||||
tex_h,
|
|
||||||
slice = index,
|
|
||||||
pq,
|
|
||||||
"D3D11VA first frame"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,12 +258,11 @@ unsafe fn make_plane(
|
|||||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
fmt: vk::Format,
|
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
let img = device.create_image(
|
let img = device.create_image(
|
||||||
&vk::ImageCreateInfo::default()
|
&vk::ImageCreateInfo::default()
|
||||||
.image_type(vk::ImageType::TYPE_2D)
|
.image_type(vk::ImageType::TYPE_2D)
|
||||||
.format(fmt)
|
.format(vk::Format::R8_UNORM)
|
||||||
.extent(vk::Extent3D {
|
.extent(vk::Extent3D {
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
@@ -307,7 +306,7 @@ unsafe fn make_plane(
|
|||||||
&vk::ImageViewCreateInfo::default()
|
&vk::ImageViewCreateInfo::default()
|
||||||
.image(img)
|
.image(img)
|
||||||
.view_type(vk::ImageViewType::TYPE_2D)
|
.view_type(vk::ImageViewType::TYPE_2D)
|
||||||
.format(fmt)
|
.format(vk::Format::R8_UNORM)
|
||||||
.subresource_range(vk::ImageSubresourceRange {
|
.subresource_range(vk::ImageSubresourceRange {
|
||||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||||
base_mip_level: 0,
|
base_mip_level: 0,
|
||||||
@@ -348,21 +347,12 @@ unsafe fn build_ring(
|
|||||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
chroma444: bool,
|
|
||||||
fmt: vk::Format,
|
|
||||||
) -> Result<Vec<PlaneSet>> {
|
) -> 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);
|
let mut ring: Vec<PlaneSet> = Vec::with_capacity(RING);
|
||||||
for _ in 0..RING {
|
for _ in 0..RING {
|
||||||
let built = (|| -> Result<PlaneSet> {
|
let built = (|| -> Result<PlaneSet> {
|
||||||
let (y, ym, yv) = make_plane(device, mem_props, width, height, fmt)?;
|
let (y, ym, yv) = make_plane(device, mem_props, width, height)?;
|
||||||
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch, fmt) {
|
let (cb, cbm, cbv) = match make_plane(device, mem_props, width / 2, height / 2) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
device.destroy_image_view(yv, None);
|
device.destroy_image_view(yv, None);
|
||||||
@@ -371,7 +361,7 @@ unsafe fn build_ring(
|
|||||||
return Err(e);
|
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,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
|
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
|
||||||
@@ -419,16 +409,6 @@ pub struct PyroWaveDecoder {
|
|||||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: 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
|
/// 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.
|
/// window holds whole self-delimiting codec packets, zero-padded to the window.
|
||||||
wire_window: usize,
|
wire_window: usize,
|
||||||
@@ -444,20 +424,17 @@ impl PyroWaveDecoder {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
chroma444: bool,
|
|
||||||
color: ColorDesc,
|
|
||||||
hdr16: bool,
|
|
||||||
) -> Result<PyroWaveDecoder> {
|
) -> Result<PyroWaveDecoder> {
|
||||||
if !vkd.pyrowave_decode {
|
if !vkd.pyrowave_decode {
|
||||||
bail!("presenter device lacks the PyroWave compute feature set");
|
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})");
|
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
|
// SAFETY: the handles in `vkd` are the presenter's live instance/device (it
|
||||||
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
|
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
|
||||||
// `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime.
|
// `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(
|
unsafe fn new_inner(
|
||||||
@@ -465,9 +442,6 @@ impl PyroWaveDecoder {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
chroma444: bool,
|
|
||||||
color: ColorDesc,
|
|
||||||
hdr16: bool,
|
|
||||||
) -> Result<PyroWaveDecoder> {
|
) -> Result<PyroWaveDecoder> {
|
||||||
let static_fn = ash::StaticFn {
|
let static_fn = ash::StaticFn {
|
||||||
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
||||||
@@ -522,11 +496,7 @@ impl PyroWaveDecoder {
|
|||||||
device: pw_dev,
|
device: pw_dev,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
chroma: if chroma444 {
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
|
||||||
} else {
|
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
|
||||||
},
|
|
||||||
// The fragment-iDWT path is for Mali/Adreno-class mobile GPUs only.
|
// The fragment-iDWT path is for Mali/Adreno-class mobile GPUs only.
|
||||||
fragment_path: false,
|
fragment_path: false,
|
||||||
};
|
};
|
||||||
@@ -543,27 +513,7 @@ impl PyroWaveDecoder {
|
|||||||
let mem_props = instance.get_physical_device_memory_properties(
|
let mem_props = instance.get_physical_device_memory_properties(
|
||||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||||
);
|
);
|
||||||
// 16-bit sessions decode into R16_UNORM storage planes; STORAGE_IMAGE support for
|
let ring = match build_ring(&device, &mem_props, width, height) {
|
||||||
// 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) {
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
pw::pyrowave_decoder_destroy(pw_dec);
|
pw::pyrowave_decoder_destroy(pw_dec);
|
||||||
@@ -607,9 +557,6 @@ impl PyroWaveDecoder {
|
|||||||
mem_props,
|
mem_props,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
chroma444,
|
|
||||||
color,
|
|
||||||
hdr16,
|
|
||||||
wire_window: shard_payload.max(64),
|
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
|
/// The old ring is RETIRED, not destroyed: the presenter / frame channel may still
|
||||||
/// reference its views (see [`RETIRE_HANDOVERS`]).
|
/// reference its views (see [`RETIRE_HANDOVERS`]).
|
||||||
unsafe fn reconfigure(&mut self, width: u32, height: u32) -> Result<()> {
|
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})");
|
bail!("pyrowave 4:2:0 needs even dimensions (resize to {width}x{height})");
|
||||||
}
|
}
|
||||||
let dinfo = pw::pyrowave_decoder_create_info {
|
let dinfo = pw::pyrowave_decoder_create_info {
|
||||||
device: self.pw_dev,
|
device: self.pw_dev,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
// Chroma is session-fixed (negotiated); a resize never changes it.
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
chroma: if self.chroma444 {
|
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
|
||||||
} else {
|
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
|
||||||
},
|
|
||||||
fragment_path: false,
|
fragment_path: false,
|
||||||
};
|
};
|
||||||
let mut new_dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
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),
|
pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
|
||||||
"decoder_create (mid-stream resize)",
|
"decoder_create (mid-stream resize)",
|
||||||
)?;
|
)?;
|
||||||
let new_ring = match build_ring(
|
let new_ring = match build_ring(&self.device, &self.mem_props, width, height) {
|
||||||
&self.device,
|
|
||||||
&self.mem_props,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
self.chroma444,
|
|
||||||
if self.hdr16 {
|
|
||||||
vk::Format::R16_UNORM
|
|
||||||
} else {
|
|
||||||
vk::Format::R8_UNORM
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
pw::pyrowave_decoder_destroy(new_dec);
|
pw::pyrowave_decoder_destroy(new_dec);
|
||||||
@@ -955,10 +886,15 @@ impl PyroWaveDecoder {
|
|||||||
],
|
],
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
// No VUI in the bitstream: the negotiated Welcome `ColorInfo` is the contract
|
// No VUI in the bitstream: BT.709 limited is the fixed contract with the
|
||||||
// with the host's CSC (BT.709 limited for SDR sessions; BT.2020 PQ once the
|
// host's CSC (plan §4.7 CscRows note; sequence-header signaling is a
|
||||||
// HDR leg lands — design/pyrowave-444-hdr.md).
|
// follow-up once the C API exposes it).
|
||||||
color: self.color,
|
color: ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
keyframe: true,
|
keyframe: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# `start`), so it stays free of platform cfg.
|
# `start`), so it stays free of platform cfg.
|
||||||
[package]
|
[package]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version.workspace = true
|
version = "0.12.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -53,23 +53,15 @@ ffmpeg-next = { version = "8", optional = true }
|
|||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
|
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
|
||||||
libvpl-sys = { path = "../libvpl-sys", optional = true }
|
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 = [
|
windows = { version = "0.62", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Graphics_Direct3D",
|
"Win32_Graphics_Direct3D",
|
||||||
"Win32_Graphics_Direct3D11",
|
"Win32_Graphics_Direct3D11",
|
||||||
"Win32_Graphics_Dxgi",
|
"Win32_Graphics_Dxgi",
|
||||||
"Win32_Graphics_Dxgi_Common",
|
"Win32_Graphics_Dxgi_Common",
|
||||||
# SECURITY_ATTRIBUTES — the PyroWave backend's IDXGIResource1::CreateSharedHandle signature.
|
|
||||||
"Win32_Security",
|
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
"Win32_System_Threading",
|
"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]
|
[features]
|
||||||
|
|||||||
@@ -104,14 +104,13 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit;
|
/// 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
|
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
||||||
/// depth-agnostic, design/pyrowave-444-hdr.md). H.264 is always 8-bit (High10 is neither an
|
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
||||||
/// NVENC nor a VCN encode mode — negotiation never asks). `true` here is only the
|
|
||||||
/// *codec-level* gate: the active GPU/backend must still pass
|
/// *codec-level* gate: the active GPU/backend must still pass
|
||||||
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
|
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
|
||||||
pub fn supports_10bit(self) -> bool {
|
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
|
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||||
@@ -219,11 +218,6 @@ pub struct EncoderCaps {
|
|||||||
|
|
||||||
/// A hardware encoder. One per session; runs on the encode thread.
|
/// A hardware encoder. One per session; runs on the encode thread.
|
||||||
pub trait Encoder: Send {
|
pub trait Encoder: Send {
|
||||||
/// Submit one captured frame for encoding. Lifetime contract: the caller must keep `frame`
|
|
||||||
/// (and its GPU payload) alive until this frame's AU has been returned by
|
|
||||||
/// [`poll`](Self::poll) — a stream-ordered backend (Linux direct-NVENC's IO-stream binding)
|
|
||||||
/// may still be reading the payload asynchronously after `submit` returns. Both host encode
|
|
||||||
/// loops already hold the frame across their poll drain; new callers must do the same.
|
|
||||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||||
@@ -269,15 +263,6 @@ pub trait Encoder: Send {
|
|||||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
|
|
||||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
|
||||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
|
||||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
|
||||||
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
|
||||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
|
||||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
/// Pull the next encoded AU if one is ready.
|
/// Pull the next encoded AU if one is ready.
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||||
@@ -307,16 +292,6 @@ pub trait Encoder: Send {
|
|||||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||||
/// How many frames the CAPTURER guarantees the encoder may hold in flight before it starts
|
|
||||||
/// reusing an input texture (`Capturer::pipeline_depth`). Backends that encode the capturer's
|
|
||||||
/// textures IN PLACE — no `CopyResource` — must not pipeline deeper than this: the capturer
|
|
||||||
/// rotates its output ring per delivered frame with no regard for encode completion, so a
|
|
||||||
/// deeper pipeline lets it overwrite a texture mid-encode. That is visual corruption (torn or
|
|
||||||
/// mixed frames), not UB, so it fails silently and intermittently.
|
|
||||||
///
|
|
||||||
/// Called once by the session glue after the capturer is known; a backend that copies its
|
|
||||||
/// input, or is synchronous, ignores it. Default: no-op.
|
|
||||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
|
||||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||||
fn flush(&mut self) -> Result<()>;
|
fn flush(&mut self) -> Result<()>;
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ use super::libav::{
|
|||||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||||
|
|
||||||
/// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not
|
/// 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
|
/// the NVENC-padded `*0` form). Used by the 4:4:4 RGB→YUV444P conversion path. Mirrors the VAAPI
|
||||||
/// X2RGB10/X2BGR10→P010. Mirrors the VAAPI CPU-input mapping; YUV inputs can't feed this path.
|
/// 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> {
|
fn sws_src_pixel(format: PixelFormat) -> Result<Pixel> {
|
||||||
Ok(match format {
|
Ok(match format {
|
||||||
PixelFormat::Bgrx => Pixel::BGRZ, // bgr0
|
PixelFormat::Bgrx => Pixel::BGRZ, // bgr0
|
||||||
@@ -37,12 +37,8 @@ fn sws_src_pixel(format: PixelFormat) -> Result<Pixel> {
|
|||||||
PixelFormat::Rgba => Pixel::RGBA,
|
PixelFormat::Rgba => Pixel::RGBA,
|
||||||
PixelFormat::Rgb => Pixel::RGB24,
|
PixelFormat::Rgb => Pixel::RGB24,
|
||||||
PixelFormat::Bgr => Pixel::BGR24,
|
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 => {
|
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
|
// the Windows paths; the Linux capturer never emits them. Map to BGRA so the match is
|
||||||
// exhaustive — unreachable here.
|
// exhaustive — unreachable here.
|
||||||
PixelFormat::Rgb10a2 | PixelFormat::P010 => (Pixel::BGRA, false),
|
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>,
|
frame: Option<VideoFrame>,
|
||||||
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
|
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
|
||||||
cuda: Option<CudaHw>,
|
cuda: Option<CudaHw>,
|
||||||
/// CPU CSC paths only: swscale context converting the captured packed source into
|
/// 4:4:4 CPU path only: swscale context converting the captured packed RGB/BGR → planar
|
||||||
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
|
/// YUV444P into [`Self::frame`], because `hevc_nvenc` only emits 4:4:4 from a YUV444 *input*
|
||||||
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
|
/// (RGB-in is always 4:2:0). `None` on the 4:2:0 paths AND on the zero-copy 4:4:4 path (the
|
||||||
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
|
/// worker's GPU convert delivers YUV444 CUDA frames). Freed in `Drop`.
|
||||||
/// worker's GPU convert delivers ready CUDA frames). Freed in `Drop`.
|
sws_444: Option<*mut ffi::SwsContext>,
|
||||||
sws_csc: Option<*mut ffi::SwsContext>,
|
|
||||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
src_format: PixelFormat,
|
src_format: PixelFormat,
|
||||||
@@ -199,7 +191,7 @@ pub struct NvencEncoder {
|
|||||||
args: OpenArgs,
|
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.
|
// 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`
|
// 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.
|
// 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,
|
bit_depth: u8,
|
||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// HDR / 10-bit (GNOME 50+ HDR screencast): a 10-bit session whose capture negotiated a
|
// TODO(hdr): Linux 10-bit parity. Unlike the Windows raw-SDK path (which upconverts 8-bit
|
||||||
// packed 2:10:10:10 PQ/BT.2020 format (`X2Rgb10`/`X2Bgr10`) encodes HEVC Main10 / 10-bit
|
// ARGB → Main10 via pixelBitDepthMinus8), libavcodec hevc_nvenc needs a 10-bit input pixel
|
||||||
// AV1 from a P010 input frame we produce by swscale (BT.2020 limited; the PQ transfer
|
// format (p010) for Main10, so it's a bigger change; deferred until a Linux GPU box is
|
||||||
// rides through per-channel — BT.2020 NCL Y'CbCr *is* derived from the PQ-encoded R'G'B').
|
// available to validate. The Linux host stays 8-bit for now.
|
||||||
// A 10-bit request whose capture stayed SDR (HDR offer downgraded) honestly encodes 8-bit.
|
if bit_depth != 8 {
|
||||||
let want_hdr10 = bit_depth == 10 && format.is_hdr_rgb10() && codec.supports_10bit();
|
|
||||||
if bit_depth == 10 && !want_hdr10 {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
bit_depth,
|
bit_depth,
|
||||||
?format,
|
"Linux NVENC 10-bit not yet wired — encoding 8-bit"
|
||||||
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()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Full-chroma 4:4:4 (HEVC Range Extensions). `hevc_nvenc` only emits 4:4:4 from a YUV444
|
// 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
|
// (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.
|
// feed `profile=rext`; the range follows `PUNKTFUNK_444_FULLRANGE` in both.
|
||||||
let want_444 = chroma.is_444() && codec == Codec::H265;
|
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")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
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)
|
// 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)
|
let av_codec = encoder::find_by_name(name)
|
||||||
.ok_or_else(|| anyhow!("{name} not built into libavcodec"))?;
|
.ok_or_else(|| anyhow!("{name} not built into libavcodec"))?;
|
||||||
let (rgb_pixel, rgb_expand) = nvenc_input(format);
|
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
|
// 4:4:4 feeds NVENC a planar YUV444P frame we produce by swscale; the ordinary path feeds the
|
||||||
// frame likewise; the ordinary path feeds the captured RGB straight in and lets NVENC's
|
// captured RGB straight in and lets NVENC's internal CSC subsample to 4:2:0.
|
||||||
// internal CSC subsample to 4:2:0.
|
|
||||||
let (nvenc_pixel, expand) = if want_444 {
|
let (nvenc_pixel, expand) = if want_444 {
|
||||||
(Pixel::YUV444P, false)
|
(Pixel::YUV444P, false)
|
||||||
} else if want_hdr10 {
|
|
||||||
(Pixel::P010LE, false)
|
|
||||||
} else {
|
} else {
|
||||||
(rgb_pixel, rgb_expand)
|
(rgb_pixel, rgb_expand)
|
||||||
};
|
};
|
||||||
@@ -354,21 +325,7 @@ impl NvencEncoder {
|
|||||||
// visible win. Linux-only: the Windows path's NVENC-internal CSC range is unmeasured.
|
// visible win. Linux-only: the Windows path's NVENC-internal CSC range is unmeasured.
|
||||||
let full_range_444 =
|
let full_range_444 =
|
||||||
want_444 && std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
|
want_444 && std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
|
||||||
if want_hdr10 {
|
if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||||
// 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 {
|
|
||||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
// 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
|
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||||
@@ -413,20 +370,17 @@ impl NvencEncoder {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
// 4:4:4 CPU path: build the RGB→YUV444P swscale (BT.709, range per the flag; no rescale).
|
||||||
// input frame. Two users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag) and HDR
|
// Mirrors the VAAPI CPU path's RGB→NV12 scaler, but the dst is full-chroma planar 4:4:4.
|
||||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers
|
||||||
// through the matrix untouched). Skipped on the zero-copy path (`cuda`): the worker's GPU
|
// planar YUV444 CUDA frames — no CPU pixels exist to scale.
|
||||||
// convert already delivers ready CUDA frames — no CPU pixels exist to scale.
|
let sws_444 = if want_444 && !cuda {
|
||||||
let sws_csc = if (want_444 || want_hdr10) && !cuda {
|
|
||||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
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
|
// 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
|
// 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
|
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated, packed-RGB-only source), the
|
||||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
// dst is YUV444P. The trailing filter/param pointers are null = "use defaults" (documented
|
||||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
// as accepted). No Rust memory is borrowed; the returned pointer is null-checked below.
|
||||||
// null-checked below.
|
|
||||||
let sws = unsafe {
|
let sws = unsafe {
|
||||||
ffi::sws_getContext(
|
ffi::sws_getContext(
|
||||||
width as c_int,
|
width as c_int,
|
||||||
@@ -434,7 +388,7 @@ impl NvencEncoder {
|
|||||||
src_av,
|
src_av,
|
||||||
width as c_int,
|
width as c_int,
|
||||||
height as c_int,
|
height as c_int,
|
||||||
dst_av,
|
ffi::AVPixelFormat::AV_PIX_FMT_YUV444P,
|
||||||
SWS_POINT,
|
SWS_POINT,
|
||||||
ptr::null_mut(),
|
ptr::null_mut(),
|
||||||
ptr::null_mut(),
|
ptr::null_mut(),
|
||||||
@@ -442,22 +396,17 @@ impl NvencEncoder {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if sws.is_null() {
|
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
|
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
|
||||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for HDR
|
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
|
||||||
// — matching the VUI written above) are process-lifetime libswscale statics, reused for
|
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
|
||||||
// src+dst matrices; `sws_setColorspaceDetails` only reads them and writes scalar CSC
|
// CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||||
// settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
// PUNKTFUNK_444_FULLRANGE experiment). No Rust memory is passed.
|
||||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||||
super::libav::SWS_CS_BT2020
|
|
||||||
} else {
|
|
||||||
SWS_CS_ITU709
|
|
||||||
});
|
|
||||||
let dst_range = i32::from(full_range_444);
|
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)
|
Some(sws)
|
||||||
} else {
|
} else {
|
||||||
@@ -483,12 +432,6 @@ impl NvencEncoder {
|
|||||||
// dropped on a future libavcodec.
|
// dropped on a future libavcodec.
|
||||||
opts.set("profile", "rext");
|
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
|
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
||||||
@@ -558,7 +501,7 @@ impl NvencEncoder {
|
|||||||
enc,
|
enc,
|
||||||
frame,
|
frame,
|
||||||
cuda: cuda_hw,
|
cuda: cuda_hw,
|
||||||
sws_csc,
|
sws_444,
|
||||||
want_444,
|
want_444,
|
||||||
src_format: format,
|
src_format: format,
|
||||||
expand,
|
expand,
|
||||||
@@ -697,7 +640,7 @@ impl NvencEncoder {
|
|||||||
);
|
);
|
||||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
// 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.
|
// 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
|
let frame = self
|
||||||
.frame
|
.frame
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -826,7 +769,7 @@ impl NvencEncoder {
|
|||||||
(*f).linesize[i] as usize,
|
(*f).linesize[i] as usize,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
|
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts)
|
||||||
} else if self.want_444 {
|
} else if self.want_444 {
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
bail!(
|
bail!(
|
||||||
@@ -839,11 +782,11 @@ impl NvencEncoder {
|
|||||||
let y_pitch = (*f).linesize[0] as usize;
|
let y_pitch = (*f).linesize[0] as usize;
|
||||||
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let uv_pitch = (*f).linesize[1] as usize;
|
let uv_pitch = (*f).linesize[1] as usize;
|
||||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
|
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch)
|
||||||
} else {
|
} else {
|
||||||
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let dst_pitch = (*f).linesize[0] as usize;
|
let dst_pitch = (*f).linesize[0] as usize;
|
||||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
|
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch)
|
||||||
};
|
};
|
||||||
if let Err(e) = copy_res {
|
if let Err(e) = copy_res {
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
@@ -867,7 +810,7 @@ impl NvencEncoder {
|
|||||||
|
|
||||||
impl Drop for NvencEncoder {
|
impl Drop for NvencEncoder {
|
||||||
fn drop(&mut self) {
|
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
|
// 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).
|
// 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.
|
// `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) };
|
unsafe { ffi::av_log_set_level(prev) };
|
||||||
ok
|
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,20 +16,12 @@
|
|||||||
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
|
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
|
||||||
//! device→device copied into the current ring slot (via the existing `copy_*_to_device`
|
//! device→device copied into the current ring slot (via the existing `copy_*_to_device`
|
||||||
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
|
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
|
||||||
//! (NVENC rejects a null-`buf[0]` frame; the captured buffer is worker-owned CUDA-IPC memory
|
//! (NVENC rejects a null-`buf[0]` frame and its CUDADEVICEPTR registration cache is bounded +
|
||||||
//! recycled on drop, so registering it directly needs a contiguous worker-pool layout + a
|
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
|
||||||
//! registration↔IPC-mapping lifetime tie — the true zero-copy follow-up, plan §7 LN2 v2).
|
//! zero regression versus today; true zero-copy input registration is a follow-up.
|
||||||
//! **Stream-ordered submit** (default, `PUNKTFUNK_NVENC_STREAM_ORDERED=0` reverts): the
|
|
||||||
//! session's IO streams are bound to the encode thread's copy stream
|
|
||||||
//! (`NvEncSetIOCudaStreams`), so in sync-retrieve depth-1 use the copy + cursor blend enqueue
|
|
||||||
//! with NO per-frame `cuStreamSynchronize` and the encode orders after them on the stream —
|
|
||||||
//! the submit path's CPU stalls are gone even though the copy itself remains.
|
|
||||||
//!
|
//!
|
||||||
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC`: `1` = always, `0` = never, unset =
|
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
|
||||||
//! **adaptive** — engaged by the session loop's contention escalation via
|
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
|
||||||
//! [`Encoder::set_pipelined`] when depth-1 can't hold cadence; at depth-1 it costs ~one loop
|
|
||||||
//! tick of latency, which is why it is not simply on. gpu-contention plan §5.B, latency plan
|
|
||||||
//! T2.2/§7 LN3): NVENC *async mode*
|
|
||||||
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
|
//! (`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*
|
//! 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
|
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
|
||||||
@@ -128,14 +120,6 @@ struct EncodeApi {
|
|||||||
encode_picture:
|
encode_picture:
|
||||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
|
||||||
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
||||||
/// `NvEncSetIOCudaStreams` — binds the session's input/output ordering to a CUDA stream so the
|
|
||||||
/// input copy + cursor blend can enqueue without a CPU sync (stream-ordered submit). The two
|
|
||||||
/// `NV_ENC_CUSTREAM_PTR` args are pointers TO `CUstream` values.
|
|
||||||
set_io_cuda_streams: unsafe extern "C" fn(
|
|
||||||
*mut c_void,
|
|
||||||
nv::NV_ENC_CUSTREAM_PTR,
|
|
||||||
nv::NV_ENC_CUSTREAM_PTR,
|
|
||||||
) -> nv::NVENCSTATUS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
|
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
|
||||||
@@ -228,7 +212,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
|
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
|
||||||
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
|
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
|
||||||
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
|
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
|
||||||
set_io_cuda_streams: list.nvEncSetIOCudaStreams.ok_or(MISSING)?,
|
|
||||||
};
|
};
|
||||||
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
|
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
|
||||||
Ok(api)
|
Ok(api)
|
||||||
@@ -240,25 +223,15 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
/// bitstream/ring slot is never reused mid-encode.
|
/// bitstream/ring slot is never reused mid-encode.
|
||||||
const POOL: usize = 8;
|
const POOL: usize = 8;
|
||||||
|
|
||||||
/// The operator's `PUNKTFUNK_NVENC_ASYNC` intent (the SAME knob as the Windows backend):
|
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the
|
||||||
/// `Some(true)` = force the two-thread retrieve from session open — note that at the Linux
|
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in
|
||||||
/// default pipeline depth of 1 this adds ~one loop tick of latency (the non-blocking poll's AU
|
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync
|
||||||
/// rides the next tick), so it only pays under GPU contention; `Some(false)` = never (also
|
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting
|
||||||
/// vetoes the session loop's contention escalation via [`Encoder::set_pipelined`]); `None`
|
/// config to fail the open.
|
||||||
/// (unset) = adaptive — off until the session loop escalates on sustained cadence overrun.
|
|
||||||
/// 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_env() -> Option<bool> {
|
|
||||||
match std::env::var("PUNKTFUNK_NVENC_ASYNC") {
|
|
||||||
Ok(v) if matches!(v.trim(), "1" | "true" | "yes" | "on") => Some(true),
|
|
||||||
Ok(v) if matches!(v.trim(), "0" | "false" | "no" | "off") => Some(false),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Operator forced the two-thread retrieve on from session open (see [`async_retrieve_env`]).
|
|
||||||
fn async_retrieve_requested() -> bool {
|
fn async_retrieve_requested() -> bool {
|
||||||
async_retrieve_env() == Some(true)
|
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
|
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
||||||
@@ -272,18 +245,6 @@ fn async_inflight_cap() -> usize {
|
|||||||
.clamp(2, POOL - 1)
|
.clamp(2, POOL - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
|
|
||||||
/// blocking copies). With the session's IO streams bound to the encode thread's copy stream
|
|
||||||
/// (`NvEncSetIOCudaStreams`), the input copy + cursor blend enqueue with NO CPU sync and
|
|
||||||
/// `encode_picture` orders after them on the stream — deleting the 1–3 per-frame
|
|
||||||
/// `cuStreamSynchronize` stalls from the submit path (latency plan §7 LN2). Sync-retrieve mode
|
|
||||||
/// only, and only while nothing is in flight (see the gate in [`Encoder::submit`]).
|
|
||||||
fn stream_ordered_requested() -> bool {
|
|
||||||
std::env::var("PUNKTFUNK_NVENC_STREAM_ORDERED")
|
|
||||||
.map(|v| v.trim() != "0")
|
|
||||||
.unwrap_or(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
|
/// 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
|
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
|
||||||
/// the session it belongs to is destroyed).
|
/// the session it belongs to is destroyed).
|
||||||
@@ -341,14 +302,7 @@ fn retrieve_loop(
|
|||||||
if let Err(e) = cuda::make_current() {
|
if let Err(e) = cuda::make_current() {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
||||||
}
|
}
|
||||||
let mut jobs: u64 = 0;
|
|
||||||
while let Ok(job) = work_rx.recv() {
|
while let Ok(job) = work_rx.recv() {
|
||||||
// In two-thread mode the host loop's `wait_us` wraps a non-blocking poll, so the real
|
|
||||||
// encode wait (scheduling + ASIC) is measured by NO timer there — sample it here instead
|
|
||||||
// (same PUNKTFUNK_PERF cadence as the submit split).
|
|
||||||
let sample = pf_host_config::config().perf && jobs % 120 == 0;
|
|
||||||
jobs += 1;
|
|
||||||
let t0 = std::time::Instant::now();
|
|
||||||
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
|
// 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
|
// 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
|
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
|
||||||
@@ -387,16 +341,6 @@ fn retrieve_loop(
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if sample {
|
|
||||||
if let Ok((data, _)) = &result {
|
|
||||||
tracing::info!(
|
|
||||||
lock_us = t0.elapsed().as_micros() as u64,
|
|
||||||
au_kib = (data.len() / 1024) as u64,
|
|
||||||
"NVENC retrieve lock (sampled): blocking lock_bitstream + AU copy on \
|
|
||||||
pf-nvenc-out (the async-mode encode wait)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
||||||
break; // encoder side gone (teardown drains us via join)
|
break; // encoder side gone (teardown drains us via join)
|
||||||
}
|
}
|
||||||
@@ -452,9 +396,6 @@ pub struct NvencCudaEncoder {
|
|||||||
/// submit). Empty until the session is initialized.
|
/// submit). Empty until the session is initialized.
|
||||||
ring: Vec<RingSlot>,
|
ring: Vec<RingSlot>,
|
||||||
next: usize,
|
next: usize,
|
||||||
/// Frames submitted over the encoder's lifetime (never reset, unlike `next`) — drives the
|
|
||||||
/// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter.
|
|
||||||
frames: u64,
|
|
||||||
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
|
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
|
||||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||||
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
||||||
@@ -499,19 +440,6 @@ pub struct NvencCudaEncoder {
|
|||||||
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
||||||
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
||||||
async_rt: Option<AsyncRetrieve>,
|
async_rt: Option<AsyncRetrieve>,
|
||||||
/// The session loop escalated into pipelined retrieve ([`Encoder::set_pipelined`], the
|
|
||||||
/// contention analog of the capturer depth escalation). Sticky across session rebuilds
|
|
||||||
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
|
||||||
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
|
||||||
want_async: bool,
|
|
||||||
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
|
||||||
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
|
||||||
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
|
||||||
/// session is destroyed.
|
|
||||||
io_stream: *mut *mut c_void,
|
|
||||||
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
|
|
||||||
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
|
|
||||||
stream_ordered: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||||
@@ -546,13 +474,9 @@ impl NvencCudaEncoder {
|
|||||||
// clear reason instead of an opaque session error on the first frame.
|
// clear reason instead of an opaque session error on the first frame.
|
||||||
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
|
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
|
||||||
if bit_depth >= 10 {
|
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!(
|
tracing::warn!(
|
||||||
"Linux direct-NVENC: 10-bit requested but the CUDA capture path has no 10-bit \
|
"Linux direct-NVENC: 10-bit requested but no P010 capture path exists yet \
|
||||||
import yet (HDR rides the libav P010 path) — encoding 8-bit SDR"
|
(Phase 5.1) — encoding 8-bit SDR"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -573,7 +497,6 @@ impl NvencCudaEncoder {
|
|||||||
hdr_meta: None,
|
hdr_meta: None,
|
||||||
ring: Vec::new(),
|
ring: Vec::new(),
|
||||||
next: 0,
|
next: 0,
|
||||||
frames: 0,
|
|
||||||
bitstreams: Vec::new(),
|
bitstreams: Vec::new(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
frame_idx: 0,
|
frame_idx: 0,
|
||||||
@@ -591,35 +514,9 @@ impl NvencCudaEncoder {
|
|||||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||||
last_rfi_range: None,
|
last_rfi_range: None,
|
||||||
async_rt: None,
|
async_rt: None,
|
||||||
want_async: false,
|
|
||||||
io_stream: ptr::null_mut(),
|
|
||||||
stream_ordered: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Engage the escalated pipelined retrieve at a safe point: nothing in flight, and — because
|
|
||||||
/// a live session has its IO streams bound for stream-ordered submit, whose output-stream
|
|
||||||
/// semantics would make every later stream op wait on the previous encode and so serialize a
|
|
||||||
/// pipelined session — via a clean session rebuild WITHOUT the binding (the re-open's first
|
|
||||||
/// frame is the standard session-opening IDR). No-op until [`want_async`](Self::want_async)
|
|
||||||
/// is set and `pending` drains.
|
|
||||||
fn maybe_engage_async(&mut self) {
|
|
||||||
if !self.want_async || self.async_rt.is_some() || !self.pending.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.inited {
|
|
||||||
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight; `teardown` handles
|
|
||||||
// exactly this live-session state (and a torn-down encoder lazily re-inits on the
|
|
||||||
// next submit, which spawns the retrieve thread and skips the IO-stream arming).
|
|
||||||
unsafe { self.teardown() };
|
|
||||||
tracing::info!(
|
|
||||||
"NVENC pipelined-retrieve escalation: rebuilding the session without the \
|
|
||||||
IO-stream binding (stream-ordered submit and two-thread retrieve are mutually \
|
|
||||||
exclusive); next frame opens with an IDR"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
||||||
unsafe fn teardown(&mut self) {
|
unsafe fn teardown(&mut self) {
|
||||||
if self.encoder.is_null() {
|
if self.encoder.is_null() {
|
||||||
@@ -656,14 +553,6 @@ impl NvencCudaEncoder {
|
|||||||
session's slot toward the concurrent-session cap"
|
session's slot toward the concurrent-session cap"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The boxed CUstream the IO-stream binding pointed at — freed only now, AFTER the session
|
|
||||||
// that referenced it is destroyed (created by `Box::into_raw` in `init_session`, freed
|
|
||||||
// exactly once here; `io_stream` is nulled so a re-init can't double-free).
|
|
||||||
if !self.io_stream.is_null() {
|
|
||||||
drop(Box::from_raw(self.io_stream));
|
|
||||||
self.io_stream = ptr::null_mut();
|
|
||||||
}
|
|
||||||
self.stream_ordered = false;
|
|
||||||
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
||||||
self.bitstreams.clear();
|
self.bitstreams.clear();
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
@@ -726,10 +615,6 @@ impl NvencCudaEncoder {
|
|||||||
enc,
|
enc,
|
||||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
||||||
);
|
);
|
||||||
// Sub-frame-output prerequisites (latency plan §7 LN1): logged for fleet visibility now,
|
|
||||||
// consumed when slice-level readback lands. Not stored — LN1 re-probes when it configures.
|
|
||||||
let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK);
|
|
||||||
let dyn_slice = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_DYNAMIC_SLICE_MODE);
|
|
||||||
let _ = (api().destroy_encoder)(enc);
|
let _ = (api().destroy_encoder)(enc);
|
||||||
|
|
||||||
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
||||||
@@ -752,8 +637,6 @@ impl NvencCudaEncoder {
|
|||||||
rfi = self.rfi_supported,
|
rfi = self.rfi_supported,
|
||||||
custom_vbv = self.custom_vbv,
|
custom_vbv = self.custom_vbv,
|
||||||
yuv444 = self.yuv444_supported,
|
yuv444 = self.yuv444_supported,
|
||||||
subframe_readback = subframe != 0,
|
|
||||||
dynamic_slice = dyn_slice != 0,
|
|
||||||
max = %format!("{wmax}x{hmax}"),
|
max = %format!("{wmax}x{hmax}"),
|
||||||
"NVENC (Linux direct) capabilities probed"
|
"NVENC (Linux direct) capabilities probed"
|
||||||
);
|
);
|
||||||
@@ -920,11 +803,7 @@ impl NvencCudaEncoder {
|
|||||||
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
|
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
|
||||||
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
|
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
|
||||||
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
|
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
|
||||||
// live pitched CUDA allocation on the shared context. `set_io_cuda_streams` takes `enc` plus
|
// live pitched CUDA allocation on the shared context. No handle escapes the encode thread.
|
||||||
// two pointers to the boxed live `CUstream` (`Box::into_raw`), which outlives the session —
|
|
||||||
// freed exactly once: in `teardown` after `destroy_encoder` when armed, or via
|
|
||||||
// `Box::from_raw` right here on the rejection path (where `io_stream` is never set). No
|
|
||||||
// handle escapes the encode thread.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
// Bind to the shared CUDA context; make it current on this (encode) thread for both the
|
// Bind to the shared CUDA context; make it current on this (encode) thread for both the
|
||||||
// session open and every subsequent device→device input copy.
|
// session open and every subsequent device→device input copy.
|
||||||
@@ -1077,53 +956,13 @@ impl NvencCudaEncoder {
|
|||||||
self.inited = true;
|
self.inited = true;
|
||||||
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
||||||
// session parameter differs — teardown/rebuild always stops it before destroy.
|
// session parameter differs — teardown/rebuild always stops it before destroy.
|
||||||
if async_retrieve_requested() || self.want_async {
|
if async_retrieve_requested() {
|
||||||
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
depth = async_inflight_cap(),
|
depth = async_inflight_cap(),
|
||||||
escalated = self.want_async,
|
|
||||||
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Stream-ordered submit (latency plan §7 LN2): bind the session's IO streams to this
|
|
||||||
// thread's copy stream so the input copy + cursor blend enqueue with no CPU sync and
|
|
||||||
// `encode_picture` orders after them. Same stream both ways: input-stream semantics
|
|
||||||
// start the encode only after our enqueued copies, output-stream semantics insert the
|
|
||||||
// encode's completion INTO the stream — so later stream work (the next frame's copy
|
|
||||||
// into a reused ring slot) also waits for it. Sync-retrieve mode only: in two-thread
|
|
||||||
// mode the captured buffer may be recycled after `submit` returns while the stream
|
|
||||||
// still holds its copy (the blocking copies are the lifetime guarantee there).
|
|
||||||
if self.async_rt.is_none() && stream_ordered_requested() {
|
|
||||||
let stream = cuda::copy_stream_handle();
|
|
||||||
if !stream.is_null() {
|
|
||||||
// The pointee must outlive the session (the driver takes CUstream POINTERS) —
|
|
||||||
// box it; `teardown` frees it after `destroy_encoder`.
|
|
||||||
let holder = Box::into_raw(Box::new(stream));
|
|
||||||
match (api().set_io_cuda_streams)(
|
|
||||||
enc,
|
|
||||||
holder as nv::NV_ENC_CUSTREAM_PTR,
|
|
||||||
holder as nv::NV_ENC_CUSTREAM_PTR,
|
|
||||||
)
|
|
||||||
.nv_ok()
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
|
||||||
self.io_stream = holder;
|
|
||||||
self.stream_ordered = true;
|
|
||||||
tracing::info!(
|
|
||||||
"NVENC stream-ordered submit armed (IO streams bound — no CPU \
|
|
||||||
sync in the submit path)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
drop(Box::from_raw(holder));
|
|
||||||
tracing::debug!(
|
|
||||||
status = ?e,
|
|
||||||
"NvEncSetIOCudaStreams rejected — keeping blocking copies"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||||
bit_depth = self.bit_depth,
|
bit_depth = self.bit_depth,
|
||||||
@@ -1137,10 +976,8 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
|
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
|
||||||
/// on the shared context). `sync` blocks until the copy completes (the pre-existing behavior);
|
/// on the shared context, synchronized by the copy helpers).
|
||||||
/// `!sync` enqueues on the encode thread's copy stream and leaves ordering to the session's
|
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize) -> Result<()> {
|
||||||
/// IO-stream binding (stream-ordered submit — see the gate in [`Encoder::submit`]).
|
|
||||||
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize, sync: bool) -> Result<()> {
|
|
||||||
let s = &self.ring[slot].surface;
|
let s = &self.ring[slot].surface;
|
||||||
let base = s.ptr;
|
let base = s.ptr;
|
||||||
let pitch = s.pitch;
|
let pitch = s.pitch;
|
||||||
@@ -1155,16 +992,16 @@ impl NvencCudaEncoder {
|
|||||||
(base + pitch as u64 * hh, pitch),
|
(base + pitch as u64 * hh, pitch),
|
||||||
(base + 2 * pitch as u64 * hh, pitch),
|
(base + 2 * pitch as u64 * hh, pitch),
|
||||||
];
|
];
|
||||||
cuda::copy_yuv444_to_device(buf, planes, sync)
|
cuda::copy_yuv444_to_device(buf, planes)
|
||||||
}
|
}
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||||
if !buf.is_nv12() {
|
if !buf.is_nv12() {
|
||||||
bail!("NV12 session but the captured buffer has no chroma plane");
|
bail!("NV12 session but the captured buffer has no chroma plane");
|
||||||
}
|
}
|
||||||
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
|
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
|
||||||
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch, sync)
|
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch)
|
||||||
}
|
}
|
||||||
_ => cuda::copy_device_to_device(buf, base, pitch, sync),
|
_ => cuda::copy_device_to_device(buf, base, pitch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,9 +1048,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
|
|
||||||
// (nothing in flight after the previous poll drained).
|
|
||||||
self.maybe_engage_async();
|
|
||||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||||
let new_fmt = buffer_format(buf);
|
let new_fmt = buffer_format(buf);
|
||||||
@@ -1240,18 +1074,7 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
||||||
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
||||||
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
||||||
// `init_session` publishes `self.encoder` before its remaining fallible steps (bitstream
|
self.init_session()?;
|
||||||
// buffers, input-surface alloc, `register_resource`), so a failure there leaves a live
|
|
||||||
// session with `inited == false`. Every guard on the re-init path keys off `inited`, so
|
|
||||||
// without this the next submit would skip teardown and overwrite `self.encoder`, leaking
|
|
||||||
// the session and its registered input surfaces permanently. `teardown` keys off
|
|
||||||
// `encoder.is_null()`, not `inited`, so it cleans up exactly this half-built state.
|
|
||||||
if let Err(e) = self.init_session() {
|
|
||||||
// SAFETY: the encode thread owns the session and a failed init leaves nothing
|
|
||||||
// mid-encode to race with.
|
|
||||||
unsafe { self.teardown() };
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Steady state: the copy helpers need the shared context current on this thread.
|
// Steady state: the copy helpers need the shared context current on this thread.
|
||||||
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
|
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
|
||||||
@@ -1277,31 +1100,8 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
let slot = self.next % POOL;
|
let slot = self.next % POOL;
|
||||||
self.next += 1;
|
self.next += 1;
|
||||||
|
|
||||||
// Sampled breakdown of the submit hot path under PUNKTFUNK_PERF (~1 line per 2 s at
|
|
||||||
// 60 fps, the VAAPI submit-split convention): copy = the per-frame device→device input
|
|
||||||
// copy (the zero-copy-registration target), blend = cursor overlay kernel (0 without a
|
|
||||||
// cursor), map/pic = the NVENC map_input_resource / encode_picture launches. The host
|
|
||||||
// loop's `submit_us` folds all four together; this is what splits them apart.
|
|
||||||
let sample = pf_host_config::config().perf && self.frames % 120 == 0;
|
|
||||||
self.frames += 1;
|
|
||||||
|
|
||||||
// Stream-ordered fast path (§7 LN2): enqueue the copy + blend with no CPU sync and let the
|
|
||||||
// IO-stream binding order `encode_picture` after them — but ONLY while nothing is in
|
|
||||||
// flight (true depth-1 usage). The gate is what makes this sound: with `pending` empty,
|
|
||||||
// every prior encode was drained by a blocking `poll`, so (a) the ring slot being reused
|
|
||||||
// was fully read, and (b) the caller still holds this frame's payload across the matching
|
|
||||||
// `poll` (both host loops do — see `Encoder::submit`'s doc), which blocks until the encode
|
|
||||||
// (and therefore the enqueued copy) completed. A pipelined caller (pending non-empty)
|
|
||||||
// falls back to the blocking copy so an early-recycled source can never be read late.
|
|
||||||
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
|
||||||
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
|
||||||
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
|
||||||
let ordered = self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
|
|
||||||
let t0 = std::time::Instant::now();
|
|
||||||
|
|
||||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||||
self.copy_into_slot(buf, slot, !ordered)?;
|
self.copy_into_slot(buf, slot)?;
|
||||||
let t_copy = t0.elapsed();
|
|
||||||
|
|
||||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
|
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
|
||||||
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
|
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
|
||||||
@@ -1344,12 +1144,12 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
let (w, h) = (self.width, s.height);
|
let (w, h) = (self.width, s.height);
|
||||||
let r = match self.buffer_fmt {
|
let r = match self.buffer_fmt {
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
|
||||||
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
|
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
||||||
}
|
}
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||||
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
|
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
||||||
}
|
}
|
||||||
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered),
|
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
||||||
};
|
};
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
if !self.cursor_blend_warned {
|
if !self.cursor_blend_warned {
|
||||||
@@ -1365,9 +1165,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let t_blend = t0.elapsed() - t_copy;
|
|
||||||
let t_map: std::time::Duration;
|
|
||||||
let t_pic: std::time::Duration;
|
|
||||||
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
|
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
|
||||||
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
|
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
|
||||||
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
|
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
|
||||||
@@ -1375,11 +1172,8 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
|
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
|
||||||
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
|
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
|
||||||
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
||||||
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
// just filled by the (synchronized) device→device copy and is not overwritten until this slot
|
||||||
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
// is reused POOL submits later, by which time this encode was polled (POOL ≥ in-flight depth).
|
||||||
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
|
|
||||||
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
|
||||||
// blocking lock additionally proves the enqueued copy completed).
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let reg = self.ring[slot].reg;
|
let reg = self.ring[slot].reg;
|
||||||
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
|
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
|
||||||
@@ -1387,11 +1181,9 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
registeredResource: reg,
|
registeredResource: reg,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let tm = std::time::Instant::now();
|
|
||||||
(api().map_input_resource)(self.encoder, &mut mp)
|
(api().map_input_resource)(self.encoder, &mut mp)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
||||||
t_map = tm.elapsed();
|
|
||||||
|
|
||||||
let pts = self.frame_idx as u64;
|
let pts = self.frame_idx as u64;
|
||||||
self.frame_idx += 1;
|
self.frame_idx += 1;
|
||||||
@@ -1461,11 +1253,9 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let tp = std::time::Instant::now();
|
|
||||||
(api().encode_picture)(self.encoder, &mut pic)
|
(api().encode_picture)(self.encoder, &mut pic)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||||
t_pic = tp.elapsed();
|
|
||||||
self.pending.push_back((
|
self.pending.push_back((
|
||||||
self.bitstreams[slot],
|
self.bitstreams[slot],
|
||||||
mp.mappedResource,
|
mp.mappedResource,
|
||||||
@@ -1473,16 +1263,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
anchor,
|
anchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if sample {
|
|
||||||
tracing::info!(
|
|
||||||
copy_us = t_copy.as_micros() as u64,
|
|
||||||
blend_us = t_blend.as_micros() as u64,
|
|
||||||
map_us = t_map.as_micros() as u64,
|
|
||||||
pic_us = t_pic.as_micros() as u64,
|
|
||||||
"NVENC submit split (sampled): copy=input D2D copy blend=cursor map=map_input \
|
|
||||||
pic=encode_picture launch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
|
// 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).
|
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
|
||||||
if let Some(rt) = &self.async_rt {
|
if let Some(rt) = &self.async_rt {
|
||||||
@@ -1504,21 +1284,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
self.force_kf = true;
|
self.force_kf = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
|
||||||
if !on {
|
|
||||||
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
|
|
||||||
return self.want_async || self.async_rt.is_some();
|
|
||||||
}
|
|
||||||
if async_retrieve_env() == Some(false) {
|
|
||||||
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
|
||||||
}
|
|
||||||
if !self.want_async && self.async_rt.is_none() {
|
|
||||||
self.want_async = true;
|
|
||||||
self.maybe_engage_async();
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
supports_rfi: self.rfi_supported,
|
supports_rfi: self.rfi_supported,
|
||||||
@@ -2112,220 +1877,4 @@ mod tests {
|
|||||||
assert!(got, "recovered encoder must produce an AU");
|
assert!(got, "recovered encoder must produce an AU");
|
||||||
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
|
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ON-HARDWARE (RTX box `.21`): the stream-ordered submit (latency plan §7 LN2) must ARM on a
|
|
||||||
/// default-env session — `NvEncSetIOCudaStreams` accepted, boxed `CUstream` held. Guards
|
|
||||||
/// against a silent fallback to blocking copies: a rejected binding still encodes correctly,
|
|
||||||
/// just with the per-frame CPU syncs back, which no other test would notice.
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
|
||||||
fn nvenc_cuda_stream_ordered_arms() {
|
|
||||||
const W: u32 = 640;
|
|
||||||
const H: u32 = 360;
|
|
||||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
|
||||||
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
|
|
||||||
if !stream_ordered_requested() || async_retrieve_requested() {
|
|
||||||
println!("skipped: stream-ordered submit disabled by env");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut enc = NvencCudaEncoder::open(
|
|
||||||
Codec::H265,
|
|
||||||
PixelFormat::Nv12,
|
|
||||||
W,
|
|
||||||
H,
|
|
||||||
60,
|
|
||||||
8_000_000,
|
|
||||||
true,
|
|
||||||
8,
|
|
||||||
ChromaFormat::Yuv420,
|
|
||||||
)
|
|
||||||
.expect("open NVENC CUDA session");
|
|
||||||
let frame = nv12_frame(W, H, 0);
|
|
||||||
enc.submit_indexed(&frame, 0).expect("submit");
|
|
||||||
let au = enc.poll().expect("poll").expect("AU");
|
|
||||||
assert!(au.keyframe, "opening AU must be the session IDR");
|
|
||||||
assert!(
|
|
||||||
enc.stream_ordered,
|
|
||||||
"IO-stream binding must arm on a default-env session (NvEncSetIOCudaStreams rejected?)"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!enc.io_stream.is_null(),
|
|
||||||
"the boxed CUstream must be held while armed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
|
||||||
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
|
||||||
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
|
||||||
/// post-escalation AU is the re-open's session-opening IDR; pipelined `poll` is
|
|
||||||
/// non-blocking, so AUs may ride a later tick).
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
|
||||||
fn nvenc_cuda_pipelined_escalation() {
|
|
||||||
const W: u32 = 1280;
|
|
||||||
const H: u32 = 720;
|
|
||||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
|
||||||
if async_retrieve_env() == Some(false) {
|
|
||||||
println!("skipped: PUNKTFUNK_NVENC_ASYNC=0 vetoes the escalation");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut enc = NvencCudaEncoder::open(
|
|
||||||
Codec::H265,
|
|
||||||
PixelFormat::Nv12,
|
|
||||||
W,
|
|
||||||
H,
|
|
||||||
60,
|
|
||||||
8_000_000,
|
|
||||||
true,
|
|
||||||
8,
|
|
||||||
ChromaFormat::Yuv420,
|
|
||||||
)
|
|
||||||
.expect("open NVENC CUDA session");
|
|
||||||
// Steady sync frames first (stream-ordered mode).
|
|
||||||
for i in 0..3u32 {
|
|
||||||
let frame = nv12_frame(W, H, i);
|
|
||||||
enc.submit_indexed(&frame, i).expect("submit");
|
|
||||||
enc.poll().expect("poll").expect("AU");
|
|
||||||
}
|
|
||||||
assert!(enc.async_rt.is_none(), "session starts sync");
|
|
||||||
assert!(enc.set_pipelined(true), "escalation must be accepted");
|
|
||||||
let mut aus = 0usize;
|
|
||||||
let mut first_key = false;
|
|
||||||
for i in 3..13u32 {
|
|
||||||
let frame = nv12_frame(W, H, i);
|
|
||||||
enc.submit_indexed(&frame, i)
|
|
||||||
.expect("submit post-escalation");
|
|
||||||
while let Some(au) = enc.poll().expect("poll") {
|
|
||||||
if aus == 0 {
|
|
||||||
first_key = au.keyframe;
|
|
||||||
}
|
|
||||||
aus += 1;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(3));
|
|
||||||
}
|
|
||||||
// Drain the pipelined tail (bounded).
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
|
|
||||||
while aus < 10 && std::time::Instant::now() < deadline {
|
|
||||||
if enc.poll().expect("poll").is_some() {
|
|
||||||
aus += 1;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
enc.async_rt.is_some(),
|
|
||||||
"retrieve thread must be live after escalation"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!enc.stream_ordered,
|
|
||||||
"IO-stream binding must be gone in pipelined mode"
|
|
||||||
);
|
|
||||||
assert_eq!(aus, 10, "every post-escalation frame must deliver an AU");
|
|
||||||
assert!(first_key, "first post-escalation AU is the re-open IDR");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ON-HARDWARE (RTX box `.21`), MEASUREMENT probe for latency plan §7 LN1 — answers the
|
|
||||||
/// go/no-go question for sub-frame slice output: with `PUNKTFUNK_NVENC_SLICES=4` +
|
|
||||||
/// `PUNKTFUNK_NVENC_SUBFRAME=1`, do slices become READABLE incrementally while the frame is
|
|
||||||
/// still encoding (and with what spacing), or does the driver only publish them at frame
|
|
||||||
/// completion? Spins `lock_bitstream(doNotWait)` against the in-flight bitstream and prints a
|
|
||||||
/// `(t_us, numSlices, bytes)` timeline. Asserts only the config half (4 slices materialize);
|
|
||||||
/// the timeline is the experiment's output — read it with `--nocapture`. Run single-threaded
|
|
||||||
/// (env vars are process-global): `-- --ignored --test-threads=1`.
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
|
||||||
fn nvenc_cuda_subframe_slice_probe() {
|
|
||||||
const W: u32 = 1920;
|
|
||||||
const H: u32 = 1080;
|
|
||||||
struct EnvGuard;
|
|
||||||
impl Drop for EnvGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
|
||||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
|
|
||||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
|
||||||
let _guard = EnvGuard;
|
|
||||||
|
|
||||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
|
||||||
let mut enc = NvencCudaEncoder::open(
|
|
||||||
Codec::H265,
|
|
||||||
PixelFormat::Nv12,
|
|
||||||
W,
|
|
||||||
H,
|
|
||||||
60,
|
|
||||||
20_000_000,
|
|
||||||
true,
|
|
||||||
8,
|
|
||||||
ChromaFormat::Yuv420,
|
|
||||||
)
|
|
||||||
.expect("open NVENC CUDA session");
|
|
||||||
|
|
||||||
// Frame 0 opens the session (IDR) — drain it normally.
|
|
||||||
let frame = nv12_frame(W, H, 0);
|
|
||||||
enc.submit_indexed(&frame, 0).expect("submit opening frame");
|
|
||||||
enc.poll().expect("poll").expect("opening AU");
|
|
||||||
|
|
||||||
// Frame 1: spin doNotWait locks against the in-flight bitstream BEFORE the blocking poll.
|
|
||||||
let frame = nv12_frame(W, H, 1);
|
|
||||||
enc.submit_indexed(&frame, 1).expect("submit probed frame");
|
|
||||||
let bs = enc.pending.back().expect("in-flight entry").0;
|
|
||||||
let t0 = std::time::Instant::now();
|
|
||||||
let mut timeline: Vec<(u64, nv::NVENCSTATUS, u32, u32)> = Vec::new();
|
|
||||||
let mut offsets = [0u32; 32];
|
|
||||||
loop {
|
|
||||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
|
||||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
|
||||||
outputBitstream: bs,
|
|
||||||
sliceOffsets: offsets.as_mut_ptr(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
lock.set_doNotWait(1);
|
|
||||||
// SAFETY: `bs` is the pool bitstream the just-submitted `encode_picture` targets and
|
|
||||||
// the session is live for the whole test; `lock` (version set, doNotWait) and
|
|
||||||
// `offsets` are live stack locals across the synchronous call; a successful lock is
|
|
||||||
// unlocked before the next iteration reuses the struct. `reportSliceOffsets` was
|
|
||||||
// armed at init so `sliceOffsets` may be written up to `numSlices` ≤ 32 entries
|
|
||||||
// (sliceModeData = 4).
|
|
||||||
let (status, n, bytes) = unsafe {
|
|
||||||
let st = (api().lock_bitstream)(enc.encoder, &mut lock);
|
|
||||||
let ok = st == nv::NVENCSTATUS::NV_ENC_SUCCESS;
|
|
||||||
let (n, b) = if ok {
|
|
||||||
(lock.numSlices, lock.bitstreamSizeInBytes)
|
|
||||||
} else {
|
|
||||||
(0, 0)
|
|
||||||
};
|
|
||||||
if ok {
|
|
||||||
let _ = (api().unlock_bitstream)(enc.encoder, bs);
|
|
||||||
}
|
|
||||||
(st, n, b)
|
|
||||||
};
|
|
||||||
let t_us = t0.elapsed().as_micros() as u64;
|
|
||||||
timeline.push((t_us, status, n, bytes));
|
|
||||||
// A successful doNotWait lock on a COMPLETE frame reports the final slice count; on
|
|
||||||
// LOCK_BUSY the frame is still encoding. Stop once complete (all 4 slices) or after
|
|
||||||
// a generous 50 ms safety window.
|
|
||||||
if (status == nv::NVENCSTATUS::NV_ENC_SUCCESS && n >= 4) || t_us > 50_000 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_micros(50));
|
|
||||||
}
|
|
||||||
println!("subframe probe timeline (t_us, status, numSlices, bytes):");
|
|
||||||
for (t, st, n, b) in &timeline {
|
|
||||||
println!(" {t:>7} us {st:?} slices={n} bytes={b}");
|
|
||||||
}
|
|
||||||
// Drain the probed frame through the normal path (lock again + unmap) — proves the probe
|
|
||||||
// locks didn't corrupt the session.
|
|
||||||
let au = enc.poll().expect("poll probed frame").expect("probed AU");
|
|
||||||
assert!(!au.data.is_empty(), "probed AU must carry data");
|
|
||||||
let last = timeline.last().expect("at least one sample");
|
|
||||||
assert_eq!(
|
|
||||||
last.2, 4,
|
|
||||||
"4 slices must materialize (PUNKTFUNK_NVENC_SLICES=4 + subframe readback armed)"
|
|
||||||
);
|
|
||||||
// One more frame end-to-end for session health.
|
|
||||||
let frame = nv12_frame(W, H, 2);
|
|
||||||
enc.submit_indexed(&frame, 2).expect("submit follow-up");
|
|
||||||
enc.poll().expect("poll").expect("follow-up AU");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// client CSC must assume BT.709 limited range.
|
||||||
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
|
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
|
/// 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.
|
/// sampling by its push constant, so one allocation fits every pointer bitmap.
|
||||||
const CURSOR_MAX: u32 = 256;
|
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;
|
/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta;
|
||||||
/// the rate controller itself never exceeds the budget).
|
/// the rate controller itself never exceeds the budget).
|
||||||
const BS_SLACK: usize = 256 * 1024;
|
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
|
/// 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
|
/// packed-RGB format. The capture advertises these for the pyrowave passthrough instead of
|
||||||
@@ -193,9 +197,6 @@ pub struct PyroWaveEncoder {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
fps: 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)`.
|
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
|
||||||
frame_budget: usize,
|
frame_budget: usize,
|
||||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary and pad every codec
|
/// 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 {
|
impl PyroWaveEncoder {
|
||||||
pub fn open(
|
pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
|
||||||
width: u32,
|
if width % 2 != 0 || height % 2 != 0 {
|
||||||
height: u32,
|
|
||||||
fps: u32,
|
|
||||||
bitrate_bps: u64,
|
|
||||||
chroma: crate::ChromaFormat,
|
|
||||||
) -> Result<Self> {
|
|
||||||
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
|
|
||||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||||
}
|
}
|
||||||
if 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
|
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
|
||||||
// establishes itself (valid instance/device, correctly-chained create-infos that
|
// establishes itself (valid instance/device, correctly-chained create-infos that
|
||||||
// `DeviceHold` keeps alive); all handles are freshly created and owned by the result.
|
// `DeviceHold` keeps alive); all handles are freshly created and owned by the result.
|
||||||
unsafe {
|
unsafe { Self::open_inner(width, height, fps.max(1), bitrate_bps.max(1_000_000)) }
|
||||||
Self::open_inner(
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fps.max(1),
|
|
||||||
bitrate_bps.max(1_000_000),
|
|
||||||
chroma.is_444(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 entry = ash::Entry::load().context("load vulkan loader")?;
|
||||||
|
|
||||||
let mut hold = DeviceHold {
|
let mut hold = DeviceHold {
|
||||||
@@ -402,11 +381,7 @@ impl PyroWaveEncoder {
|
|||||||
device: pw_dev,
|
device: pw_dev,
|
||||||
width: w as i32,
|
width: w as i32,
|
||||||
height: h as i32,
|
height: h as i32,
|
||||||
chroma: if chroma444 {
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
|
||||||
} else {
|
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||||
if let Err(e) = pw_check(
|
if let Err(e) = pw_check(
|
||||||
@@ -417,10 +392,8 @@ impl PyroWaveEncoder {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CSC planes: full-res R8 luma + RG8 chroma (half-res for 4:2:0, full-res for
|
// ---- CSC planes: full-res R8 luma + half-res RG8 chroma, storage-written by the CSC
|
||||||
// 4:4:4), storage-written by the CSC and sampled directly by pyrowave (R/G view
|
// and sampled directly by pyrowave (R/G view swizzles synthesize Cb/Cr) ----
|
||||||
// swizzles synthesize Cb/Cr) ----
|
|
||||||
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
|
|
||||||
let (y_img, y_mem, y_view) = make_plain_image(
|
let (y_img, y_mem, y_view) = make_plain_image(
|
||||||
&device,
|
&device,
|
||||||
&mem_props,
|
&mem_props,
|
||||||
@@ -433,8 +406,8 @@ impl PyroWaveEncoder {
|
|||||||
&device,
|
&device,
|
||||||
&mem_props,
|
&mem_props,
|
||||||
vk::Format::R8G8_UNORM,
|
vk::Format::R8G8_UNORM,
|
||||||
cw,
|
w / 2,
|
||||||
ch,
|
h / 2,
|
||||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::SAMPLED,
|
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::SAMPLED,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -447,11 +420,7 @@ impl PyroWaveEncoder {
|
|||||||
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(if chroma444 {
|
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
|
||||||
CSC444_SPV
|
|
||||||
} else {
|
|
||||||
CSC_SPV
|
|
||||||
}))?;
|
|
||||||
let shader =
|
let shader =
|
||||||
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
|
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
|
||||||
let sb = |b: u32, t: vk::DescriptorType| {
|
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(),
|
gpu = %props.device_name_as_c_str().unwrap_or(c"?").to_string_lossy(),
|
||||||
mode = %format!("{w}x{h}@{fps}"),
|
mode = %format!("{w}x{h}@{fps}"),
|
||||||
budget_kib = frame_budget / 1024,
|
budget_kib = frame_budget / 1024,
|
||||||
chroma = if chroma444 { "4:4:4" } else { "4:2:0" },
|
"PyroWave encoder open (intra-only wavelet, BT.709 limited 4:2:0)"
|
||||||
"PyroWave encoder open (intra-only wavelet, BT.709 limited)"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -640,7 +608,6 @@ impl PyroWaveEncoder {
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
fps,
|
fps,
|
||||||
chroma444,
|
|
||||||
frame_budget,
|
frame_budget,
|
||||||
wire_chunk: None,
|
wire_chunk: None,
|
||||||
bitstream: Vec::new(),
|
bitstream: Vec::new(),
|
||||||
@@ -865,13 +832,6 @@ impl PyroWaveEncoder {
|
|||||||
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
||||||
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
||||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||||
// A failed `reset()` leaves the encoder destroyed and null. Callers today turn that into
|
|
||||||
// a session error and never resubmit, but a null here would be a use-after-free inside
|
|
||||||
// pyrowave rather than a clean error — so fail loudly instead of relying on that.
|
|
||||||
anyhow::ensure!(
|
|
||||||
!self.pw_enc.is_null(),
|
|
||||||
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
|
|
||||||
);
|
|
||||||
let dev = self.device.clone();
|
let dev = self.device.clone();
|
||||||
let (w, h) = (self.width, self.height);
|
let (w, h) = (self.width, self.height);
|
||||||
dev.begin_command_buffer(
|
dev.begin_command_buffer(
|
||||||
@@ -1011,12 +971,7 @@ impl PyroWaveEncoder {
|
|||||||
0,
|
0,
|
||||||
&pc_bytes,
|
&pc_bytes,
|
||||||
);
|
);
|
||||||
// 4:2:0: one invocation per 2x2 luma block (per chroma sample); 4:4:4: per pixel.
|
dev.cmd_dispatch(self.cmd, (w / 2).div_ceil(8), (h / 2).div_ceil(8), 1);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSC storage writes -> pyrowave's sampled reads (images stay GENERAL — the layout
|
// CSC storage writes -> pyrowave's sampled reads (images stay GENERAL — the layout
|
||||||
// pyrowave's GPU-buffer contract accepts without transitions).
|
// pyrowave's GPU-buffer contract accepts without transitions).
|
||||||
@@ -1069,19 +1024,17 @@ impl PyroWaveEncoder {
|
|||||||
),
|
),
|
||||||
// Two-component chroma image: view swizzles R/G synthesize the Cb/Cr planes
|
// 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 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(
|
plane(
|
||||||
self.uv_img,
|
self.uv_img,
|
||||||
if self.chroma444 { w } else { w / 2 },
|
w / 2,
|
||||||
if self.chroma444 { h } else { h / 2 },
|
h / 2,
|
||||||
rg8,
|
rg8,
|
||||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
||||||
),
|
),
|
||||||
plane(
|
plane(
|
||||||
self.uv_img,
|
self.uv_img,
|
||||||
if self.chroma444 { w } else { w / 2 },
|
w / 2,
|
||||||
if self.chroma444 { h } else { h / 2 },
|
h / 2,
|
||||||
rg8,
|
rg8,
|
||||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
||||||
),
|
),
|
||||||
@@ -1124,8 +1077,8 @@ impl PyroWaveEncoder {
|
|||||||
// boundary by design.
|
// boundary by design.
|
||||||
let cap = self.frame_budget + BS_SLACK;
|
let cap = self.frame_budget + BS_SLACK;
|
||||||
self.bitstream.resize(cap, 0);
|
self.bitstream.resize(cap, 0);
|
||||||
// Chunked mode reserves the 4-byte window prefix from the packetize boundary (shared helper).
|
// Chunked mode reserves 4 bytes per window for the framing prefix.
|
||||||
let boundary = crate::pyrowave_wire::packet_boundary(self.wire_chunk, cap);
|
let boundary = self.wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(cap);
|
||||||
let mut n: usize = 0;
|
let mut n: usize = 0;
|
||||||
pw_check(
|
pw_check(
|
||||||
pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n),
|
pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n),
|
||||||
@@ -1148,16 +1101,67 @@ impl PyroWaveEncoder {
|
|||||||
"packetize",
|
"packetize",
|
||||||
)?;
|
)?;
|
||||||
packets.truncate(out_n.max(1));
|
packets.truncate(out_n.max(1));
|
||||||
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
let au = if let Some(chunk) = self.wire_chunk {
|
||||||
// emits BT.709 LIMITED — patch the bits HONEST so VUI-honoring clients don't wash out
|
// Window framing (§4.4): each `chunk`-sized window opens with a 4-byte prefix
|
||||||
// blacks. (Linux capture has no HDR path, so this side never stamps BT.2020/PQ.)
|
// (u16 used-length + u16 kind) and carries either WHOLE self-delimiting codec
|
||||||
if let Some(p) = packets.first() {
|
// packets (PACKED — several small ones share a window) or one fragment of an
|
||||||
crate::pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, false);
|
// 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
|
||||||
// Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense
|
// skips it and drops any fragment chain it interrupts.
|
||||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
let payload_max = chunk - WINDOW_PREFIX;
|
||||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
let mut au: Vec<u8> = Vec::with_capacity((packets.len() + 1) * chunk);
|
||||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_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.frame_count += 1;
|
||||||
self.pending.push_back(EncodedFrame {
|
self.pending.push_back(EncodedFrame {
|
||||||
data: au,
|
data: au,
|
||||||
@@ -1176,35 +1180,13 @@ impl Encoder for PyroWaveEncoder {
|
|||||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
|
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||||
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this
|
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this
|
||||||
// struct owns and waits its own fence before touching results.
|
// struct owns and waits its own fence before touching results.
|
||||||
let r = unsafe { self.encode_frame(frame) };
|
unsafe { self.encode_frame(frame) }
|
||||||
if r.is_err() {
|
|
||||||
// `encode_frame` opens the recording window early and has several fallible steps
|
|
||||||
// inside it (cursor prep, dmabuf import, format mapping, the CPU-RGB staging path,
|
|
||||||
// an unsupported-payload bail, and the encode call itself). Every one returns with
|
|
||||||
// `self.cmd` still RECORDING, and nothing downstream repairs it — there is exactly
|
|
||||||
// one `begin_command_buffer` in this file and `reset()`/`Drop` never touch `cmd` —
|
|
||||||
// so the NEXT frame would call `begin` on a recording buffer, which is invalid usage.
|
|
||||||
// Legal here on every path: the pool carries RESET_COMMAND_BUFFER and the buffer is
|
|
||||||
// not pending (we never reached the submit, or the submit itself failed).
|
|
||||||
// SAFETY: `self.cmd` is owned by this encoder and, on these paths, not in flight.
|
|
||||||
unsafe {
|
|
||||||
let _ = self
|
|
||||||
.device
|
|
||||||
.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
// No RFI / no intra-refresh wave (every frame is intra). Report the real opened chroma so
|
// All defaults: no RFI (meaningless — every frame is intra), no HDR (8-bit SDR codec),
|
||||||
// the session glue's post-open cross-check stays quiet on a genuine 4:4:4 session — a
|
// no intra-refresh wave (ditto). 4:2:0 only until the 4:4:4 ride-along (plan §6).
|
||||||
// hardcoded `default()` here mis-reports a 4:4:4 open as 4:2:0 and fires a spurious
|
EncoderCaps::default()
|
||||||
// "chroma disagrees with the negotiated Welcome" warn.
|
|
||||||
EncoderCaps {
|
|
||||||
chroma_444: self.chroma444,
|
|
||||||
..EncoderCaps::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
@@ -1219,30 +1201,16 @@ impl Encoder for PyroWaveEncoder {
|
|||||||
unsafe {
|
unsafe {
|
||||||
self.device.device_wait_idle().ok();
|
self.device.device_wait_idle().ok();
|
||||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||||
// Publish the null IMMEDIATELY: the create below is fallible, and its failure path
|
|
||||||
// must not leave a freed pointer in the field. `pyrowave_encoder_destroy` is a plain
|
|
||||||
// `delete` (pyrowave_c.cpp) with no null check, so `Drop` running on a stale handle
|
|
||||||
// is a double free — the exact shape this reset hits when the rebuild fails because
|
|
||||||
// the device is already lost, which is the state that made the watchdog fire.
|
|
||||||
self.pw_enc = std::ptr::null_mut();
|
|
||||||
let einfo = pw::pyrowave_encoder_create_info {
|
let einfo = pw::pyrowave_encoder_create_info {
|
||||||
device: self.pw_dev,
|
device: self.pw_dev,
|
||||||
width: self.width as i32,
|
width: self.width as i32,
|
||||||
height: self.height as i32,
|
height: self.height as i32,
|
||||||
chroma: if self.chroma444 {
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
|
||||||
} else {
|
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||||
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||||
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
|
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
|
||||||
// `pw_enc` stays null — `Drop` and `encode_frame` both guard on it. The queued
|
|
||||||
// AUs are forfeit either way (the caller turns a false reset into a session
|
|
||||||
// error), so drop them rather than shipping output from a dead encoder.
|
|
||||||
self.pending.clear();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.pw_enc = enc;
|
self.pw_enc = enc;
|
||||||
@@ -1288,11 +1256,7 @@ impl Drop for PyroWaveEncoder {
|
|||||||
// before the VkDevice they borrow (encoder before device, per pyrowave.h).
|
// before the VkDevice they borrow (encoder before device, per pyrowave.h).
|
||||||
unsafe {
|
unsafe {
|
||||||
self.device.device_wait_idle().ok();
|
self.device.device_wait_idle().ok();
|
||||||
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
|
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||||
// is not null-safe.
|
|
||||||
if !self.pw_enc.is_null() {
|
|
||||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
|
||||||
}
|
|
||||||
pw::pyrowave_device_destroy(self.pw_dev);
|
pw::pyrowave_device_destroy(self.pw_dev);
|
||||||
for (_, _, i, m, v) in self.import_cache.drain(..) {
|
for (_, _, i, m, v) in self.import_cache.drain(..) {
|
||||||
self.device.destroy_image_view(v, None);
|
self.device.destroy_image_view(v, None);
|
||||||
@@ -1363,16 +1327,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode an AU with a standalone pyrowave decoder and return the full planar YUV
|
/// Decode an AU with a standalone pyrowave decoder and return the full YUV420P planes.
|
||||||
/// (half-res chroma for 4:2:0, full-res for 4:4:4). This is the golden oracle for the
|
/// This is the golden oracle for both the Phase-1 smoke check (plane means) and the Apple
|
||||||
/// smoke checks (plane means) and the Apple Metal port's committed PSNR fixtures
|
/// Metal port's committed PSNR fixtures (`pyrowave_dump_golden`).
|
||||||
/// (`pyrowave_dump_golden`).
|
unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||||
unsafe fn decode_planes_chroma(
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
au: &[u8],
|
|
||||||
chroma444: bool,
|
|
||||||
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
|
||||||
let mut dev: pw::pyrowave_device = std::ptr::null_mut();
|
let mut dev: pw::pyrowave_device = std::ptr::null_mut();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pw::pyrowave_create_default_device(&mut dev),
|
pw::pyrowave_create_default_device(&mut dev),
|
||||||
@@ -1382,11 +1340,7 @@ mod tests {
|
|||||||
device: dev,
|
device: dev,
|
||||||
width: w as i32,
|
width: w as i32,
|
||||||
height: h as i32,
|
height: h as i32,
|
||||||
chroma: if chroma444 {
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
|
||||||
} else {
|
|
||||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
|
||||||
},
|
|
||||||
fragment_path: false,
|
fragment_path: false,
|
||||||
};
|
};
|
||||||
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||||
@@ -1400,16 +1354,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(pw::pyrowave_decoder_decode_is_ready(dec, false));
|
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 y = vec![0u8; (w * h) as usize];
|
||||||
let mut cb = vec![0u8; (cw * ch) as usize];
|
let mut cb = vec![0u8; (w * h / 4) as usize];
|
||||||
let mut cr = vec![0u8; (cw * ch) as usize];
|
let mut cr = vec![0u8; (w * h / 4) as usize];
|
||||||
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
||||||
buf.format = if chroma444 {
|
buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P;
|
||||||
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV444P
|
|
||||||
} else {
|
|
||||||
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P
|
|
||||||
};
|
|
||||||
buf.width = w as i32;
|
buf.width = w as i32;
|
||||||
buf.height = h as i32;
|
buf.height = h as i32;
|
||||||
buf.data = [
|
buf.data = [
|
||||||
@@ -1417,7 +1366,7 @@ mod tests {
|
|||||||
cb.as_mut_ptr() as *mut _,
|
cb.as_mut_ptr() as *mut _,
|
||||||
cr.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()];
|
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
||||||
@@ -1428,15 +1377,10 @@ mod tests {
|
|||||||
(y, cb, cr)
|
(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.
|
// SAFETY: forwarded — same contract as the caller.
|
||||||
unsafe { decode_planes_chroma(w, h, au, false) }
|
let (y, cb, cr) = unsafe { decode_planes(w, h, au) };
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64;
|
let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64;
|
||||||
(mean(&y), mean(&cb), mean(&cr))
|
(mean(&y), mean(&cb), mean(&cr))
|
||||||
}
|
}
|
||||||
@@ -1450,8 +1394,7 @@ mod tests {
|
|||||||
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
|
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
|
||||||
fn pyrowave_smoke() {
|
fn pyrowave_smoke() {
|
||||||
let (w, h) = (256u32, 256u32);
|
let (w, h) = (256u32, 256u32);
|
||||||
let mut enc =
|
let mut enc = PyroWaveEncoder::open(w, h, 60, 40_000_000).expect("open");
|
||||||
PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv420).expect("open");
|
|
||||||
assert!(!enc.caps().supports_rfi);
|
assert!(!enc.caps().supports_rfi);
|
||||||
|
|
||||||
let colors = [
|
let colors = [
|
||||||
@@ -1471,7 +1414,7 @@ mod tests {
|
|||||||
"AU exceeds rate budget"
|
"AU exceeds rate budget"
|
||||||
);
|
);
|
||||||
// SAFETY: test-only FFI into the vendored decoder with locally-owned buffers.
|
// 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);
|
let (ye, cbe, cre) = bt709(*c);
|
||||||
assert!(
|
assert!(
|
||||||
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
|
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
|
||||||
@@ -1565,68 +1508,6 @@ mod tests {
|
|||||||
assert!(enc.poll().expect("poll").is_some());
|
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
|
/// A deterministic busy BGRA test card (gradients + checker + LCG noise) — flat fills
|
||||||
/// exercise almost none of the entropy decoder, this hits every subband.
|
/// exercise almost none of the entropy decoder, this hits every subband.
|
||||||
fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame {
|
fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame {
|
||||||
@@ -1677,8 +1558,7 @@ mod tests {
|
|||||||
// Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the
|
// Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the
|
||||||
// block-grid overhang. ~1.6 bpp at 60 fps.
|
// block-grid overhang. ~1.6 bpp at 60 fps.
|
||||||
let (w, h) = (256u32, 144u32);
|
let (w, h) = (256u32, 144u32);
|
||||||
let mut enc =
|
let mut enc = PyroWaveEncoder::open(w, h, 60, 4_000_000).expect("open");
|
||||||
PyroWaveEncoder::open(w, h, 60, 4_000_000, crate::ChromaFormat::Yuv420).expect("open");
|
|
||||||
|
|
||||||
let dump = |name: &str, bytes: &[u8]| {
|
let dump = |name: &str, bytes: &[u8]| {
|
||||||
std::fs::write(dir.join(name), bytes).expect("write fixture");
|
std::fs::write(dir.join(name), bytes).expect("write fixture");
|
||||||
@@ -1730,19 +1610,5 @@ mod tests {
|
|||||||
dump("ref-chunked-y.bin", &y);
|
dump("ref-chunked-y.bin", &y);
|
||||||
dump("ref-chunked-cb.bin", &cb);
|
dump("ref-chunked-cb.bin", &cb);
|
||||||
dump("ref-chunked-cr.bin", &cr);
|
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::Rgba => Pixel::RGBA,
|
||||||
PixelFormat::Rgb => Pixel::RGB24,
|
PixelFormat::Rgb => Pixel::RGB24,
|
||||||
PixelFormat::Bgr => Pixel::BGR24,
|
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 => {
|
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
|
||||||
bail!("VAAPI CPU-input path supports packed RGB/BGR only; got {format:?}")
|
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.
|
/// 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.
|
/// 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`).
|
/// Safety contract is [`open_vaapi_encoder_mode`]'s (borrowed `device_ref`/`frames_ref`).
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
unsafe fn open_vaapi_encoder(
|
unsafe fn open_vaapi_encoder(
|
||||||
codec: Codec,
|
codec: Codec,
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -114,7 +109,6 @@ unsafe fn open_vaapi_encoder(
|
|||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
device_ref: *mut ffi::AVBufferRef,
|
device_ref: *mut ffi::AVBufferRef,
|
||||||
frames_ref: *mut ffi::AVBufferRef,
|
frames_ref: *mut ffi::AVBufferRef,
|
||||||
ten_bit: bool,
|
|
||||||
) -> Result<encoder::video::Encoder> {
|
) -> Result<encoder::video::Encoder> {
|
||||||
let idx = lp_idx(codec);
|
let idx = lp_idx(codec);
|
||||||
let modes: &[bool] = match low_power_override() {
|
let modes: &[bool] = match low_power_override() {
|
||||||
@@ -136,7 +130,6 @@ unsafe fn open_vaapi_encoder(
|
|||||||
bitrate_bps,
|
bitrate_bps,
|
||||||
device_ref,
|
device_ref,
|
||||||
frames_ref,
|
frames_ref,
|
||||||
ten_bit,
|
|
||||||
lp,
|
lp,
|
||||||
) {
|
) {
|
||||||
Ok(enc) => {
|
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,
|
/// 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),
|
/// infinite GOP, BT.709-limited VUI, `pix_fmt=VAAPI`, and the given hw device + frames contexts.
|
||||||
/// `pix_fmt=VAAPI`, and the given hw device + frames contexts. Returns the opened encoder.
|
/// Returns the opened encoder. `device_ref`/`frames_ref` are borrowed (ref'd into the context).
|
||||||
/// `device_ref`/`frames_ref` are borrowed (ref'd into the context).
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
unsafe fn open_vaapi_encoder_mode(
|
unsafe fn open_vaapi_encoder_mode(
|
||||||
codec: Codec,
|
codec: Codec,
|
||||||
@@ -177,7 +169,6 @@ unsafe fn open_vaapi_encoder_mode(
|
|||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
device_ref: *mut ffi::AVBufferRef,
|
device_ref: *mut ffi::AVBufferRef,
|
||||||
frames_ref: *mut ffi::AVBufferRef,
|
frames_ref: *mut ffi::AVBufferRef,
|
||||||
ten_bit: bool,
|
|
||||||
low_power: bool,
|
low_power: bool,
|
||||||
) -> Result<encoder::video::Encoder> {
|
) -> Result<encoder::video::Encoder> {
|
||||||
let name = codec.vaapi_name();
|
let name = codec.vaapi_name();
|
||||||
@@ -190,39 +181,23 @@ unsafe fn open_vaapi_encoder_mode(
|
|||||||
.context("alloc video encoder")?;
|
.context("alloc video encoder")?;
|
||||||
video.set_width(width);
|
video.set_width(width);
|
||||||
video.set_height(height);
|
video.set_height(height);
|
||||||
// sw view (pix_fmt overridden to VAAPI below): NV12, or P010 for the 10-bit HDR session.
|
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
|
||||||
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.
|
||||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
|
||||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
let raw = video.as_mut_ptr();
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||||
if ten_bit {
|
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||||
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
|
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||||
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
|
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||||
// zero-copy path). The client decoder auto-detects PQ from the VUI.
|
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||||
(*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;
|
|
||||||
}
|
|
||||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||||
|
|
||||||
let mut opts = Dictionary::new();
|
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
|
// 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
|
// 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
|
// = 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();
|
let prev = ffi::av_log_get_level();
|
||||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||||
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
||||||
Ok(hw) => open_vaapi_encoder(
|
Ok(hw) => {
|
||||||
codec,
|
open_vaapi_encoder(codec, 640, 480, 30, 2_000_000, hw.device_ref, hw.frames_ref)
|
||||||
640,
|
.is_ok()
|
||||||
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(),
|
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
};
|
};
|
||||||
ffi::av_log_set_level(prev);
|
ffi::av_log_set_level(prev);
|
||||||
@@ -422,21 +348,12 @@ impl CpuInner {
|
|||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let src_pixel = vaapi_sws_src(format)?;
|
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;
|
const POOL: c_int = 16;
|
||||||
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
// 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
|
// 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
|
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
|
||||||
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
|
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
|
||||||
// on drop.
|
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
|
||||||
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
|
|
||||||
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
// 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
|
// 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
|
// 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,
|
bitrate_bps,
|
||||||
hw.device_ref,
|
hw.device_ref,
|
||||||
hw.frames_ref,
|
hw.frames_ref,
|
||||||
ten_bit,
|
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
// swscale RGB→NV12 (BT.709 limited) or X2RGB10→P010 (BT.2020 limited, HDR) — matches the
|
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
|
||||||
// VUI; no rescale.
|
|
||||||
let src_av = pixel_to_av(src_pixel);
|
let src_av = pixel_to_av(src_pixel);
|
||||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
|
// 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`;
|
// 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_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,
|
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
|
||||||
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
|
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
|
||||||
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
|
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
|
||||||
// just below.
|
|
||||||
let sws = unsafe {
|
let sws = unsafe {
|
||||||
ffi::sws_getContext(
|
ffi::sws_getContext(
|
||||||
width as c_int,
|
width as c_int,
|
||||||
@@ -471,7 +385,7 @@ impl CpuInner {
|
|||||||
src_av,
|
src_av,
|
||||||
width as c_int,
|
width as c_int,
|
||||||
height as c_int,
|
height as c_int,
|
||||||
staging_av,
|
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||||
SWS_POINT,
|
SWS_POINT,
|
||||||
ptr::null_mut(),
|
ptr::null_mut(),
|
||||||
ptr::null_mut(),
|
ptr::null_mut(),
|
||||||
@@ -479,54 +393,45 @@ impl CpuInner {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if sws.is_null() {
|
if sws.is_null() {
|
||||||
bail!(
|
bail!("sws_getContext(RGB→NV12) failed");
|
||||||
"sws_getContext(RGB→{})",
|
|
||||||
if ten_bit { "P010" } else { "NV12" }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||||
// check immediately preceding returned false). The coefficient table from
|
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
|
||||||
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
|
// pointer into a libswscale static const coefficient table valid for the whole process, reused
|
||||||
// libswscale static const valid for the whole process, reused here for both the inverse
|
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
|
||||||
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
|
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
|
||||||
// writes scalar CSC settings into `sws`; the table pointer outlives the synchronous call and
|
// synchronous call and no Rust memory is passed.
|
||||||
// no Rust memory is passed.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let cs = ffi::sws_getCoefficients(if ten_bit {
|
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||||
super::libav::SWS_CS_BT2020
|
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||||
} else {
|
|
||||||
SWS_CS_ITU709
|
|
||||||
});
|
|
||||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
|
|
||||||
}
|
}
|
||||||
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
// 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`/
|
// 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).
|
// `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
|
// `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
|
// 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
|
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
|
||||||
// unique fresh pointer, so none of these writes alias anything.
|
// fresh pointer, so none of these writes alias anything.
|
||||||
let nv12 = unsafe {
|
let nv12 = unsafe {
|
||||||
let f = ffi::av_frame_alloc();
|
let f = ffi::av_frame_alloc();
|
||||||
if f.is_null() {
|
if f.is_null() {
|
||||||
ffi::sws_freeContext(sws);
|
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).width = width as c_int;
|
||||||
(*f).height = height as c_int;
|
(*f).height = height as c_int;
|
||||||
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
||||||
let mut f = f;
|
let mut f = f;
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
ffi::sws_freeContext(sws);
|
ffi::sws_freeContext(sws);
|
||||||
bail!("av_frame_get_buffer(staging) failed");
|
bail!("av_frame_get_buffer(NV12) failed");
|
||||||
}
|
}
|
||||||
f
|
f
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
encoder = codec.vaapi_name(),
|
encoder = codec.vaapi_name(),
|
||||||
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
|
"VAAPI encode active ({width}x{height}@{fps}, CPU→NV12 upload path)"
|
||||||
if ten_bit { "P010 (HDR10)" } else { "NV12" }
|
|
||||||
);
|
);
|
||||||
Ok(CpuInner {
|
Ok(CpuInner {
|
||||||
enc,
|
enc,
|
||||||
@@ -658,15 +563,6 @@ impl DmabufInner {
|
|||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let drm_fourcc = pf_frame::drm_fourcc(format)
|
let drm_fourcc = pf_frame::drm_fourcc(format)
|
||||||
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
.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();
|
let node = render_node();
|
||||||
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
||||||
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
|
// `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;
|
let fc = (*drm_frames).data as *mut ffi::AVHWFramesContext;
|
||||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME;
|
(*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).width = width as c_int;
|
||||||
(*fc).height = height as c_int;
|
(*fc).height = height as c_int;
|
||||||
if ffi::av_hwframe_ctx_init(drm_frames) < 0 {
|
if ffi::av_hwframe_ctx_init(drm_frames) < 0 {
|
||||||
@@ -819,24 +715,14 @@ impl DmabufInner {
|
|||||||
}
|
}
|
||||||
init!(src, ptr::null(), "buffer");
|
init!(src, ptr::null(), "buffer");
|
||||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
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,
|
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited).
|
||||||
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
|
// Without the explicit options the conversion matrix is whatever the driver defaults
|
||||||
// the matrix untouched). Without the explicit options the conversion matrix is
|
// to for an unspecified output (Mesa: BT.601) — a hue shift against the signaled VUI.
|
||||||
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
|
init!(
|
||||||
// shift against the signaled VUI.
|
scale,
|
||||||
if ten_bit {
|
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||||
init!(
|
"scale_vaapi"
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
init!(sink, ptr::null(), "buffersink");
|
init!(sink, ptr::null(), "buffersink");
|
||||||
|
|
||||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||||
@@ -880,7 +766,6 @@ impl DmabufInner {
|
|||||||
bitrate_bps,
|
bitrate_bps,
|
||||||
vaapi_device,
|
vaapi_device,
|
||||||
nv12_ctx,
|
nv12_ctx,
|
||||||
ten_bit,
|
|
||||||
) {
|
) {
|
||||||
Ok(enc) => enc,
|
Ok(enc) => enc,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -894,8 +779,7 @@ impl DmabufInner {
|
|||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
encoder = codec.vaapi_name(),
|
encoder = codec.vaapi_name(),
|
||||||
"VAAPI encode active ({width}x{height}@{fps}, zero-copy dmabuf → GPU {})",
|
"VAAPI encode active ({width}x{height}@{fps}, zero-copy dmabuf → GPU NV12)"
|
||||||
if ten_bit { "P010 (HDR10)" } else { "NV12" }
|
|
||||||
);
|
);
|
||||||
Ok(DmabufInner {
|
Ok(DmabufInner {
|
||||||
enc,
|
enc,
|
||||||
@@ -1103,22 +987,8 @@ impl VaapiEncoder {
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: super::ChromaFormat,
|
chroma: super::ChromaFormat,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
|
if bit_depth != 8 {
|
||||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
|
tracing::warn!(bit_depth, "VAAPI 10-bit not yet wired — encoding 8-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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
// 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
|
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
||||||
|
|||||||
@@ -77,32 +77,6 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
|||||||
d: &pf_frame::DmabufFrame,
|
d: &pf_frame::DmabufFrame,
|
||||||
cw: u32,
|
cw: u32,
|
||||||
ch: u32,
|
ch: u32,
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
|
||||||
import_rgb_dmabuf_as(
|
|
||||||
device,
|
|
||||||
ext_fd,
|
|
||||||
mem_props,
|
|
||||||
d,
|
|
||||||
cw,
|
|
||||||
ch,
|
|
||||||
vk::ImageUsageFlags::SAMPLED,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list
|
|
||||||
/// (chained into the image create) — the RGB-direct encode path imports the captured buffer
|
|
||||||
/// as a profiled `VIDEO_ENCODE_SRC` image instead of a sampled one.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(crate) unsafe fn import_rgb_dmabuf_as(
|
|
||||||
device: &ash::Device,
|
|
||||||
ext_fd: &ash::khr::external_memory_fd::Device,
|
|
||||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
|
||||||
d: &pf_frame::DmabufFrame,
|
|
||||||
cw: u32,
|
|
||||||
ch: u32,
|
|
||||||
usage: vk::ImageUsageFlags,
|
|
||||||
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use std::os::fd::IntoRawFd;
|
use std::os::fd::IntoRawFd;
|
||||||
@@ -116,27 +90,26 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
|||||||
.plane_layouts(&plane);
|
.plane_layouts(&plane);
|
||||||
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
||||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||||
let mut ci = vk::ImageCreateInfo::default()
|
let img = device.create_image(
|
||||||
.image_type(vk::ImageType::TYPE_2D)
|
&vk::ImageCreateInfo::default()
|
||||||
.format(fmt)
|
.image_type(vk::ImageType::TYPE_2D)
|
||||||
.extent(vk::Extent3D {
|
.format(fmt)
|
||||||
width: cw,
|
.extent(vk::Extent3D {
|
||||||
height: ch,
|
width: cw,
|
||||||
depth: 1,
|
height: ch,
|
||||||
})
|
depth: 1,
|
||||||
.mip_levels(1)
|
})
|
||||||
.array_layers(1)
|
.mip_levels(1)
|
||||||
.samples(vk::SampleCountFlags::TYPE_1)
|
.array_layers(1)
|
||||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
.samples(vk::SampleCountFlags::TYPE_1)
|
||||||
.usage(usage)
|
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
.usage(vk::ImageUsageFlags::SAMPLED)
|
||||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||||
.push_next(&mut ext)
|
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||||
.push_next(&mut drm);
|
.push_next(&mut ext)
|
||||||
if let Some(pl) = profile_list {
|
.push_next(&mut drm),
|
||||||
ci = ci.push_next(pl);
|
None,
|
||||||
}
|
)?;
|
||||||
let img = device.create_image(&ci, None)?;
|
|
||||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
||||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
||||||
let fd_props = {
|
let fd_props = {
|
||||||
@@ -210,8 +183,7 @@ pub(crate) unsafe fn make_plain_image(
|
|||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
let req = device.get_image_memory_requirements(img);
|
let req = device.get_image_memory_requirements(img);
|
||||||
// Unwind on failure: callers (the encoders' open paths) only ever see the completed triple.
|
let mem = device.allocate_memory(
|
||||||
let mem = match device.allocate_memory(
|
|
||||||
&vk::MemoryAllocateInfo::default()
|
&vk::MemoryAllocateInfo::default()
|
||||||
.allocation_size(req.size)
|
.allocation_size(req.size)
|
||||||
.memory_type_index(find_mem(
|
.memory_type_index(find_mem(
|
||||||
@@ -220,24 +192,8 @@ pub(crate) unsafe fn make_plain_image(
|
|||||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||||
)),
|
)),
|
||||||
None,
|
None,
|
||||||
) {
|
)?;
|
||||||
Ok(m) => m,
|
device.bind_image_memory(img, mem, 0)?;
|
||||||
Err(e) => {
|
let view = make_view(device, img, fmt, 0)?;
|
||||||
device.destroy_image(img, None);
|
Ok((img, mem, view))
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
|
||||||
device.destroy_image(img, None);
|
|
||||||
device.free_memory(mem, None);
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
match make_view(device, img, fmt, 0) {
|
|
||||||
Ok(view) => Ok((img, mem, view)),
|
|
||||||
Err(e) => {
|
|
||||||
device.destroy_image(img, None);
|
|
||||||
device.free_memory(mem, None);
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
//! Vendored `VK_VALVE_video_encode_rgb_conversion` bindings — the RGB→YCbCr encode-source
|
|
||||||
//! extension (Vulkan 1.4.327; RADV since Mesa 26.0, hardware-gated on the VCN EFC front-end
|
|
||||||
//! conversion block). Our pinned `ash 0.38.0+1.3.281` predates it entirely; same vendoring
|
|
||||||
//! rationale as [`vk_av1_encode`](super::vk_av1_encode) — definitions copied from the registry
|
|
||||||
//! so the layouts are correct-by-construction, chained via raw `p_next`. Consumed by
|
|
||||||
//! `vulkan_video.rs`: B0 probes + logs availability (design/vulkan-rgb-direct-encode.md);
|
|
||||||
//! B1 makes the captured BGRx dmabuf the direct encode source with EFC doing the 709-narrow CSC.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use ash::vk;
|
|
||||||
use std::ffi::{c_void, CStr};
|
|
||||||
|
|
||||||
pub const EXTENSION_NAME: &CStr = c"VK_VALVE_video_encode_rgb_conversion";
|
|
||||||
|
|
||||||
// ---------- struct-type (VkStructureType) values — construct via `stype` ----------
|
|
||||||
pub const ST_PHYSICAL_DEVICE_FEATURES: i32 = 1_000_390_000;
|
|
||||||
pub const ST_CAPABILITIES: i32 = 1_000_390_001;
|
|
||||||
pub const ST_PROFILE_INFO: i32 = 1_000_390_002;
|
|
||||||
pub const ST_SESSION_CREATE_INFO: i32 = 1_000_390_003;
|
|
||||||
|
|
||||||
// `VkVideoEncodeRgbModelConversionFlagBitsVALVE`
|
|
||||||
pub const MODEL_RGB_IDENTITY: u32 = 0x01;
|
|
||||||
pub const MODEL_YCBCR_IDENTITY: u32 = 0x02;
|
|
||||||
pub const MODEL_YCBCR_709: u32 = 0x04;
|
|
||||||
pub const MODEL_YCBCR_601: u32 = 0x08;
|
|
||||||
pub const MODEL_YCBCR_2020: u32 = 0x10;
|
|
||||||
// `VkVideoEncodeRgbRangeCompressionFlagBitsVALVE`
|
|
||||||
pub const RANGE_FULL: u32 = 0x01;
|
|
||||||
pub const RANGE_NARROW: u32 = 0x02;
|
|
||||||
// `VkVideoEncodeRgbChromaOffsetFlagBitsVALVE`
|
|
||||||
pub const CHROMA_OFFSET_COSITED_EVEN: u32 = 0x01;
|
|
||||||
pub const CHROMA_OFFSET_MIDPOINT: u32 = 0x02;
|
|
||||||
|
|
||||||
/// `VkPhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE` — chain into
|
|
||||||
/// `VkPhysicalDeviceFeatures2` (query) / `VkDeviceCreateInfo` (enable).
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
|
|
||||||
pub s_type: vk::StructureType,
|
|
||||||
pub p_next: *mut c_void,
|
|
||||||
pub video_encode_rgb_conversion: vk::Bool32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `VkVideoEncodeRgbConversionCapabilitiesVALVE` — chain into the
|
|
||||||
/// `vkGetPhysicalDeviceVideoCapabilitiesKHR` output when the queried profile carries
|
|
||||||
/// [`VideoEncodeProfileRgbConversionInfoVALVE`]; reports which conversions the HW does.
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct VideoEncodeRgbConversionCapabilitiesVALVE {
|
|
||||||
pub s_type: vk::StructureType,
|
|
||||||
pub p_next: *mut c_void,
|
|
||||||
pub rgb_models: u32,
|
|
||||||
pub rgb_ranges: u32,
|
|
||||||
pub x_chroma_offsets: u32,
|
|
||||||
pub y_chroma_offsets: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `VkVideoEncodeProfileRgbConversionInfoVALVE` — part of the video-profile *identity*: every
|
|
||||||
/// consumer of the profile (caps query, format query, session, image profile lists) must carry
|
|
||||||
/// the same chain.
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct VideoEncodeProfileRgbConversionInfoVALVE {
|
|
||||||
pub s_type: vk::StructureType,
|
|
||||||
pub p_next: *const c_void,
|
|
||||||
pub perform_encode_rgb_conversion: vk::Bool32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `VkVideoEncodeSessionRgbConversionCreateInfoVALVE` — chain into
|
|
||||||
/// `VkVideoSessionCreateInfoKHR`; single-bit selections of the conversion actually performed.
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
|
||||||
pub s_type: vk::StructureType,
|
|
||||||
pub p_next: *const c_void,
|
|
||||||
pub rgb_model: u32,
|
|
||||||
pub rgb_range: u32,
|
|
||||||
pub x_chroma_offset: u32,
|
|
||||||
pub y_chroma_offset: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `vk::StructureType` for a raw `ST_*` constant above.
|
|
||||||
#[inline]
|
|
||||||
pub fn stype(raw: i32) -> vk::StructureType {
|
|
||||||
vk::StructureType::from_raw(raw)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -101,15 +101,6 @@ pub(super) fn build_init_params(
|
|||||||
};
|
};
|
||||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||||
init.set_splitEncodeMode(split_mode);
|
init.set_splitEncodeMode(split_mode);
|
||||||
// Sub-frame readback (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off): the driver
|
|
||||||
// writes each slice into the output buffer as it completes and reports per-slice offsets, so a
|
|
||||||
// sync-mode consumer can read slices out while the frame is still encoding. Pair with
|
|
||||||
// `PUNKTFUNK_NVENC_SLICES` (a single-slice frame yields nothing to read early).
|
|
||||||
// `reportSliceOffsets` requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
|
|
||||||
if !enable_async && std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() == Ok("1") {
|
|
||||||
init.set_enableSubFrameWrite(1);
|
|
||||||
init.set_reportSliceOffsets(1);
|
|
||||||
}
|
|
||||||
init
|
init
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +118,6 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
|||||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
||||||
cfg.frameIntervalP = 1;
|
cfg.frameIntervalP = 1;
|
||||||
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
||||||
// Explicit zero reorder delay: with P-only + no lookahead there is no reordering to buffer,
|
|
||||||
// but pin the bit so no preset/driver default can ever slip a frame of reorder delay in.
|
|
||||||
cfg.rcParams.set_zeroReorderDelay(1);
|
|
||||||
let bps = c.bitrate.min(u32::MAX as u64) as u32;
|
let bps = c.bitrate.min(u32::MAX as u64) as u32;
|
||||||
cfg.rcParams.averageBitRate = bps;
|
cfg.rcParams.averageBitRate = bps;
|
||||||
cfg.rcParams.maxBitRate = bps;
|
cfg.rcParams.maxBitRate = bps;
|
||||||
@@ -158,29 +146,6 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
|||||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-slice frames (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off = the preset's
|
|
||||||
// single slice): `PUNKTFUNK_NVENC_SLICES=N` (2..=32) splits every frame into N slices
|
|
||||||
// (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss
|
|
||||||
// concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC
|
|
||||||
// only — AV1 partitions via tiles, not slices.
|
|
||||||
if let Some(n) = std::env::var("PUNKTFUNK_NVENC_SLICES")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.filter(|n| (2..=32).contains(n))
|
|
||||||
{
|
|
||||||
match c.codec {
|
|
||||||
Codec::H264 => {
|
|
||||||
cfg.encodeCodecConfig.h264Config.sliceMode = 3;
|
|
||||||
cfg.encodeCodecConfig.h264Config.sliceModeData = n;
|
|
||||||
}
|
|
||||||
Codec::H265 => {
|
|
||||||
cfg.encodeCodecConfig.hevcConfig.sliceMode = 3;
|
|
||||||
cfg.encodeCodecConfig.hevcConfig.sliceModeData = n;
|
|
||||||
}
|
|
||||||
Codec::Av1 | Codec::PyroWave => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
|
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
|
||||||
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
||||||
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
||||||
|
|||||||
@@ -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::Bgr => (3, 2, 1, 0),
|
||||||
PixelFormat::Rgba | PixelFormat::Rgbx => (4, 0, 1, 2),
|
PixelFormat::Rgba | PixelFormat::Rgbx => (4, 0, 1, 2),
|
||||||
PixelFormat::Bgra | PixelFormat::Bgrx => (4, 2, 1, 0),
|
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
|
// 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder
|
||||||
// represent it (and never receives it — HDR is never negotiated on a software host).
|
// can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC).
|
||||||
PixelFormat::Rgb10a2 | PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10 => {
|
PixelFormat::Rgb10a2 => {
|
||||||
anyhow::bail!(
|
anyhow::bail!("software H.264 encoder cannot encode 10-bit HDR (Rgb10a2)")
|
||||||
"software H.264 encoder cannot encode 10-bit HDR ({:?})",
|
|
||||||
self.src_format
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// NV12/P010 are GPU-resident video-processor outputs for the NVENC path; the software
|
// NV12/P010 are GPU-resident video-processor outputs for the NVENC path; the software
|
||||||
// encoder never receives them (it only gets CPU RGB frames).
|
// encoder never receives them (it only gets CPU RGB frames).
|
||||||
|
|||||||
@@ -2077,15 +2077,9 @@ impl Encoder for AmfEncoder {
|
|||||||
}
|
}
|
||||||
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
|
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
|
||||||
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
|
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
|
||||||
// Guard against a slot the taint sweep emptied since the force was queued: the
|
|
||||||
// HARDWARE slot still holds the tainted mark, so forcing it would re-reference the
|
|
||||||
// very corruption being recovered from — and the frame must not ship tagged
|
|
||||||
// `recovery_anchor` either (the client lifts its post-loss freeze on that tag).
|
|
||||||
if let Some(slot) = self.pending_force.take() {
|
if let Some(slot) = self.pending_force.take() {
|
||||||
if self.ltr_slots[slot].is_some() {
|
force_slot = Some(slot);
|
||||||
force_slot = Some(slot);
|
recovery_anchor = true;
|
||||||
recovery_anchor = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
|
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
|
||||||
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
|
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
|
||||||
@@ -2369,16 +2363,6 @@ impl Encoder for AmfEncoder {
|
|||||||
if !self.ltr_active || first < 0 || first > last {
|
if !self.ltr_active || first < 0 || first > last {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
|
||||||
// encoded inside the client's corrupt window — the client either never received it or
|
|
||||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
|
||||||
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
|
|
||||||
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
|
||||||
for marked in self.ltr_slots.iter_mut() {
|
|
||||||
if marked.is_some_and(|idx| idx >= first) {
|
|
||||||
*marked = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
||||||
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
||||||
@@ -2407,9 +2391,6 @@ impl Encoder for AmfEncoder {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
|
|
||||||
// at — clear it so the next submit can't force-reference a tainted hardware slot.
|
|
||||||
self.pending_force = None;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
first,
|
first,
|
||||||
last,
|
last,
|
||||||
@@ -2807,7 +2788,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
@@ -2993,7 +2973,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
@@ -3135,7 +3114,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
@@ -3283,7 +3261,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -136,15 +136,7 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
|||||||
PixelFormat::Rgba => Pixel::RGBA,
|
PixelFormat::Rgba => Pixel::RGBA,
|
||||||
PixelFormat::Rgb => Pixel::RGB24,
|
PixelFormat::Rgb => Pixel::RGB24,
|
||||||
PixelFormat::Bgr => Pixel::BGR24,
|
PixelFormat::Bgr => Pixel::BGR24,
|
||||||
// X2Rgb10/X2Bgr10 are the Linux GNOME 50 HDR screencast formats — the Windows HDR path
|
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
|
||||||
// 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 => {
|
|
||||||
bail!("ffmpeg_win swscale path supports packed RGB/BGR only; got {format:?}")
|
bail!("ffmpeg_win swscale path supports packed RGB/BGR only; got {format:?}")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -409,12 +409,6 @@ pub struct NvencD3d11Encoder {
|
|||||||
events: Vec<usize>,
|
events: Vec<usize>,
|
||||||
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
|
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
|
||||||
async_rt: Option<AsyncRetrieve>,
|
async_rt: Option<AsyncRetrieve>,
|
||||||
/// The capturer's `pipeline_depth` (`set_input_ring_depth`). This backend encodes the
|
|
||||||
/// capturer's textures IN PLACE, so it is a HARD ceiling on async in-flight depth: the
|
|
||||||
/// capturer rotates its ring per delivered frame regardless of encode completion, so
|
|
||||||
/// pipelining deeper lets it overwrite a texture mid-encode (torn frames). `None` until the
|
|
||||||
/// session glue reports it — treated as "unknown, don't pipeline past the env cap".
|
|
||||||
input_ring_depth: Option<usize>,
|
|
||||||
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
|
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
|
||||||
async_supported: bool,
|
async_supported: bool,
|
||||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||||
@@ -511,7 +505,6 @@ impl NvencD3d11Encoder {
|
|||||||
bitstreams: Vec::new(),
|
bitstreams: Vec::new(),
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
async_rt: None,
|
async_rt: None,
|
||||||
input_ring_depth: None,
|
|
||||||
async_supported: false,
|
async_supported: false,
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
frame_idx: 0,
|
frame_idx: 0,
|
||||||
@@ -1142,19 +1135,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
self.chroma_444 = false;
|
self.chroma_444 = false;
|
||||||
}
|
}
|
||||||
let device = frame.device.clone();
|
let device = frame.device.clone();
|
||||||
// `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its
|
self.init_session(&device)?;
|
||||||
// last fallible steps, so a failure there leaves a live session with `inited == false`.
|
|
||||||
// Every guard on the re-init path keys off `inited`, so without this the next submit
|
|
||||||
// would skip teardown and overwrite `self.encoder` — leaking the session permanently
|
|
||||||
// (toward the driver's per-process cap) along with its session-budget units.
|
|
||||||
// `teardown` keys off `encoder.is_null()`, not `inited`, so it cleans up exactly this
|
|
||||||
// half-built state and is a no-op when nothing was opened.
|
|
||||||
if let Err(e) = self.init_session(&device) {
|
|
||||||
// SAFETY: same contract as the teardown above — the encode thread owns the session,
|
|
||||||
// and a failed init leaves nothing mid-encode to race with.
|
|
||||||
unsafe { self.teardown() };
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
self.init_device = dev_raw;
|
self.init_device = dev_raw;
|
||||||
}
|
}
|
||||||
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
|
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
|
||||||
@@ -1163,21 +1144,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
// index, which is non-zero on a mid-session encoder rebuild's first frame.
|
// index, which is non-zero on a mid-session encoder rebuild's first frame.
|
||||||
let opening = self.next == 0;
|
let opening = self.next == 0;
|
||||||
// Async backpressure: never hand NVENC an output bitstream that is still in flight, and
|
// Async backpressure: never hand NVENC an output bitstream that is still in flight, and
|
||||||
// keep in-flight depth within the capturer's texture ring. At the cap, block on the OLDEST
|
// keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
|
||||||
// completion (the retrieve thread is already waiting on its event) before submitting more —
|
// the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
|
||||||
// bounding depth exactly like the sync path's per-tick blocking poll, just `cap` deep
|
// event) before submitting more — bounding depth exactly like the sync path's per-tick
|
||||||
// instead of 1.
|
// blocking poll, just `cap` deep instead of 1.
|
||||||
//
|
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
|
||||||
// The ring term is the one that matters for correctness: `async_inflight_cap()` is only the
|
|
||||||
// output-bitstream-pool ceiling plus an env knob, and consults NOTHING about the capturer,
|
|
||||||
// despite this comment previously claiming otherwise. Since this backend encodes the
|
|
||||||
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
|
|
||||||
// rotate a texture out from under a live encode — torn frames, silently.
|
|
||||||
let cap = match self.input_ring_depth {
|
|
||||||
Some(d) => async_inflight_cap().min(d.max(1)),
|
|
||||||
None => async_inflight_cap(),
|
|
||||||
};
|
|
||||||
while self.async_rt.is_some() && self.pending.len() >= cap {
|
|
||||||
let done = {
|
let done = {
|
||||||
let rt = self.async_rt.as_mut().expect("checked in loop condition");
|
let rt = self.async_rt.as_mut().expect("checked in loop condition");
|
||||||
rt.done_rx
|
rt.done_rx
|
||||||
@@ -1353,17 +1324,6 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
self.submit(frame)
|
self.submit(frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_input_ring_depth(&mut self, depth: usize) {
|
|
||||||
// This backend registers and encodes the capturer's textures in place (no CopyResource),
|
|
||||||
// so the capturer's ring depth is a hard ceiling on how deep async may pipeline.
|
|
||||||
self.input_ring_depth = Some(depth);
|
|
||||||
tracing::debug!(
|
|
||||||
depth,
|
|
||||||
env_cap = async_inflight_cap(),
|
|
||||||
"NVENC: capturer input-ring depth reported — async in-flight bounded by the smaller"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_keyframe(&mut self) {
|
fn request_keyframe(&mut self) {
|
||||||
self.force_kf = true;
|
self.force_kf = true;
|
||||||
}
|
}
|
||||||
@@ -1851,7 +1811,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(D3d11Frame {
|
payload: FramePayload::D3d11(D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
@@ -1954,7 +1913,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(D3d11Frame {
|
payload: FramePayload::D3d11(D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -146,12 +146,7 @@ const NUM_LTR_SLOTS: usize = 2;
|
|||||||
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
|
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
|
||||||
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
|
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
|
||||||
fn ltr_disabled() -> bool {
|
fn ltr_disabled() -> bool {
|
||||||
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
|
std::env::var("PUNKTFUNK_NO_QSV_LTR").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
|
|
||||||
// silently left LTR enabled on Intel while the identical value worked on AMD.
|
|
||||||
std::env::var("PUNKTFUNK_NO_QSV_LTR")
|
|
||||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
|
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
|
||||||
@@ -176,22 +171,13 @@ fn ltr_test_force_at() -> Option<i64> {
|
|||||||
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
|
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
|
||||||
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
|
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
|
||||||
fn intra_refresh_requested() -> bool {
|
fn intra_refresh_requested() -> bool {
|
||||||
// Spelling parity with AMF (see `ltr_disabled` above).
|
|
||||||
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
||||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
|
/// The wave period in frames (~0.5 s), the same shape as Linux NVENC / AMF.
|
||||||
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
|
|
||||||
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
|
|
||||||
fn intra_refresh_period(fps: u32) -> u16 {
|
fn intra_refresh_period(fps: u32) -> u16 {
|
||||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
(fps / 2).clamp(8, 240) as u16
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
|
||||||
.filter(|v| *v >= 2)
|
|
||||||
.unwrap_or(fps / 2)
|
|
||||||
.clamp(8, 240) as u16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -714,16 +700,7 @@ pub struct QsvEncoder {
|
|||||||
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
|
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
|
||||||
ltr_active: bool,
|
ltr_active: bool,
|
||||||
/// The wire frame index stored in each LTR slot (`None` = never marked).
|
/// The wire frame index stored in each LTR slot (`None` = never marked).
|
||||||
///
|
|
||||||
/// This mirrors the HARDWARE DPB, so an entry must not be cleared merely because we distrust
|
|
||||||
/// it: nulling issues no VPL call, and the encoder keeps the frame marked long-term until that
|
|
||||||
/// `LongTermIdx` is re-marked or an IDR flushes it. Distrust is recorded in `ltr_tainted`
|
|
||||||
/// instead, so the rejection list can still NAME the entry the hardware is holding.
|
|
||||||
ltr_slots: [Option<i64>; NUM_LTR_SLOTS],
|
ltr_slots: [Option<i64>; NUM_LTR_SLOTS],
|
||||||
/// Per-slot taint from `invalidate_ref_frames`' sweep: the mark is still live in the hardware
|
|
||||||
/// DPB but was encoded inside the client's corrupt window, so it may not anchor a recovery —
|
|
||||||
/// it must be REJECTED instead. Cleared wherever the slot is re-marked or the DPB is flushed.
|
|
||||||
ltr_tainted: [bool; NUM_LTR_SLOTS],
|
|
||||||
next_ltr_slot: usize,
|
next_ltr_slot: usize,
|
||||||
ltr_mark_interval: i64,
|
ltr_mark_interval: i64,
|
||||||
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
|
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
|
||||||
@@ -798,7 +775,6 @@ impl QsvEncoder {
|
|||||||
ir_active: false,
|
ir_active: false,
|
||||||
ltr_active: false,
|
ltr_active: false,
|
||||||
ltr_slots: [None; NUM_LTR_SLOTS],
|
ltr_slots: [None; NUM_LTR_SLOTS],
|
||||||
ltr_tainted: [false; NUM_LTR_SLOTS],
|
|
||||||
next_ltr_slot: 0,
|
next_ltr_slot: 0,
|
||||||
ltr_mark_interval: ltr_mark_interval(fps),
|
ltr_mark_interval: ltr_mark_interval(fps),
|
||||||
pending_force: None,
|
pending_force: None,
|
||||||
@@ -923,7 +899,6 @@ impl QsvEncoder {
|
|||||||
self.ltr_active = ltr_active;
|
self.ltr_active = ltr_active;
|
||||||
self.ir_active = ir_active;
|
self.ir_active = ir_active;
|
||||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||||
self.ltr_tainted = [false; NUM_LTR_SLOTS];
|
|
||||||
self.next_ltr_slot = 0;
|
self.next_ltr_slot = 0;
|
||||||
self.pending_force = None;
|
self.pending_force = None;
|
||||||
self.hdr_applied = self.hdr_meta;
|
self.hdr_applied = self.hdr_meta;
|
||||||
@@ -1042,14 +1017,13 @@ impl Encoder for QsvEncoder {
|
|||||||
self.frame_idx += 1;
|
self.frame_idx += 1;
|
||||||
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
|
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
|
||||||
let mut mark_slot: Option<usize> = None;
|
let mut mark_slot: Option<usize> = None;
|
||||||
let mut force_ltr: Option<(usize, i64)> = None;
|
let mut force_slot: Option<usize> = None;
|
||||||
let mut recovery_anchor = false;
|
let mut recovery_anchor = false;
|
||||||
if self.ltr_active {
|
if self.ltr_active {
|
||||||
if forced {
|
if forced {
|
||||||
// An IDR voids the decoder's reference buffers — drop stale slots and any
|
// An IDR voids the decoder's reference buffers — drop stale slots and any
|
||||||
// queued force; the mark cadence below re-anchors on the IDR itself.
|
// queued force; the mark cadence below re-anchors on the IDR itself.
|
||||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||||
self.ltr_tainted = [false; NUM_LTR_SLOTS]; // the IDR flushed the DPB with them
|
|
||||||
self.next_ltr_slot = 0;
|
self.next_ltr_slot = 0;
|
||||||
self.pending_force = None;
|
self.pending_force = None;
|
||||||
} else if self.ltr_test_force_at == Some(cur_idx) {
|
} else if self.ltr_test_force_at == Some(cur_idx) {
|
||||||
@@ -1061,29 +1035,17 @@ impl Encoder for QsvEncoder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(slot) = self.pending_force.take() {
|
if let Some(slot) = self.pending_force.take() {
|
||||||
// Resolve the anchor NOW: a taint sweep in `invalidate_ref_frames` may have
|
force_slot = Some(slot);
|
||||||
// emptied the slot since the force was queued. An empty slot means there is
|
recovery_anchor = true;
|
||||||
// nothing clean to re-reference — the frame must ship as a plain P WITHOUT the
|
|
||||||
// `recovery_anchor` tag (the client lifts its post-loss freeze on that tag).
|
|
||||||
// The slot is no longer emptied by the sweep, so test the taint flag too — a
|
|
||||||
// tainted slot is exactly the "nothing clean to re-reference" case.
|
|
||||||
if let Some(idx) = self.ltr_slots[slot].filter(|_| !self.ltr_tainted[slot]) {
|
|
||||||
force_ltr = Some((slot, idx));
|
|
||||||
recovery_anchor = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||||
let slot = self.next_ltr_slot;
|
let slot = self.next_ltr_slot;
|
||||||
self.ltr_slots[slot] = Some(cur_idx);
|
self.ltr_slots[slot] = Some(cur_idx);
|
||||||
// Re-marking replaces the hardware's LongTermIdx: the tainted frame is gone from
|
|
||||||
// the DPB and this slot is clean again.
|
|
||||||
self.ltr_tainted[slot] = false;
|
|
||||||
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
|
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
|
||||||
mark_slot = Some(slot);
|
mark_slot = Some(slot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ltr_slots = self.ltr_slots;
|
let ltr_slots = self.ltr_slots;
|
||||||
let reject_ok = self.codec != Codec::Av1;
|
|
||||||
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
||||||
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
|
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
|
||||||
// `poll`) instead of letting the queue grow under overload.
|
// `poll`) instead of letting the queue grow under overload.
|
||||||
@@ -1168,7 +1130,7 @@ impl Encoder for QsvEncoder {
|
|||||||
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
|
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
|
||||||
// Per-frame control: forced IDR and/or the LTR reflist.
|
// Per-frame control: forced IDR and/or the LTR reflist.
|
||||||
let mut ctrl: Option<Box<FrameCtrl>> = None;
|
let mut ctrl: Option<Box<FrameCtrl>> = None;
|
||||||
if forced || mark_slot.is_some() || force_ltr.is_some() {
|
if forced || mark_slot.is_some() || force_slot.is_some() {
|
||||||
let mut c = FrameCtrl::new();
|
let mut c = FrameCtrl::new();
|
||||||
if forced {
|
if forced {
|
||||||
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
|
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
|
||||||
@@ -1186,55 +1148,24 @@ impl Encoder for QsvEncoder {
|
|||||||
c.reflist.ApplyLongTermIdx = 1;
|
c.reflist.ApplyLongTermIdx = 1;
|
||||||
use_reflist = true;
|
use_reflist = true;
|
||||||
}
|
}
|
||||||
if let Some((slot, ltr_frame)) = force_ltr {
|
if let Some(slot) = force_slot {
|
||||||
// Force THIS frame to predict only from the known-good LTR — the
|
if let Some(ltr_frame) = ltr_slots[slot] {
|
||||||
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
|
// Force THIS frame to predict only from the known-good LTR — the
|
||||||
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
|
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
|
||||||
// FrameOrder).
|
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
|
||||||
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
|
// FrameOrder).
|
||||||
c.reflist.PreferredRefList[0].PicStruct =
|
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
|
||||||
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
c.reflist.PreferredRefList[0].PicStruct =
|
||||||
// A preference alone is a reorder HINT (VPL spec) — the encoder may
|
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
||||||
// still predict from the tainted short-term refs alongside it. AMF's
|
use_reflist = true;
|
||||||
// ForceLTRReferenceBitfield and NVENC's invalidation are hard
|
tracing::info!(
|
||||||
// exclusions; emulate that here by rejecting every other DPB
|
slot,
|
||||||
// candidate — the short-term sliding window (the 2 most recent
|
ltr_frame,
|
||||||
// frames) and the other LTR slot — and capping L0 at one active
|
frame = cur_idx,
|
||||||
// entry. Without this the "clean recovery" frame can carry the
|
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
|
||||||
// corruption forward, which the client cannot detect (the field
|
no IDR)"
|
||||||
// failure: permanent macroblock soup under sustained loss).
|
);
|
||||||
// AVC/HEVC only: the AV1 runtime's universal-reflist rejection path
|
|
||||||
// is unvalidated, and an unhonored hint there still converges via
|
|
||||||
// the host's IDR escalation.
|
|
||||||
if reject_ok {
|
|
||||||
let mut rej = 0;
|
|
||||||
let mut reject = |idx: i64| {
|
|
||||||
if idx >= 0 && idx != ltr_frame {
|
|
||||||
c.reflist.RejectedRefList[rej].FrameOrder = idx as u32;
|
|
||||||
c.reflist.RejectedRefList[rej].PicStruct =
|
|
||||||
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
|
||||||
rej += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reject(cur_idx - 1);
|
|
||||||
reject(cur_idx - 2);
|
|
||||||
for (s, marked) in ltr_slots.iter().enumerate() {
|
|
||||||
if s != slot {
|
|
||||||
if let Some(idx) = *marked {
|
|
||||||
reject(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.reflist.NumRefIdxL0Active = 1;
|
|
||||||
}
|
}
|
||||||
use_reflist = true;
|
|
||||||
tracing::info!(
|
|
||||||
slot,
|
|
||||||
ltr_frame,
|
|
||||||
frame = cur_idx,
|
|
||||||
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
|
|
||||||
no IDR)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if use_reflist {
|
if use_reflist {
|
||||||
c.attach_reflist();
|
c.attach_reflist();
|
||||||
@@ -1334,29 +1265,8 @@ impl Encoder for QsvEncoder {
|
|||||||
if !self.ltr_active || first < 0 || first > last {
|
if !self.ltr_active || first < 0 || first > last {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
|
||||||
// encoded inside the client's corrupt window — the client either never received it or
|
|
||||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
|
||||||
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
|
|
||||||
// the soup — the sustained-loss field failure where the picture never healed. Dropped
|
|
||||||
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
|
||||||
//
|
|
||||||
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
|
|
||||||
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
|
|
||||||
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
|
|
||||||
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
|
|
||||||
// could still predict from it. With two slots the "exactly one swept" case is the modal
|
|
||||||
// one, and it was the broken one.
|
|
||||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
|
||||||
if marked.is_some_and(|idx| idx >= first) {
|
|
||||||
self.ltr_tainted[slot] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut best: Option<(usize, i64)> = None;
|
let mut best: Option<(usize, i64)> = None;
|
||||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||||
if self.ltr_tainted[slot] {
|
|
||||||
continue; // still in the DPB, but encoded inside the corrupt window
|
|
||||||
}
|
|
||||||
if let Some(idx) = *marked {
|
if let Some(idx) = *marked {
|
||||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
||||||
best = Some((slot, idx));
|
best = Some((slot, idx));
|
||||||
@@ -1377,9 +1287,6 @@ impl Encoder for QsvEncoder {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
|
|
||||||
// at — clear it so the next submit can't half-apply a stale recovery.
|
|
||||||
self.pending_force = None;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
first,
|
first,
|
||||||
last,
|
last,
|
||||||
@@ -1459,21 +1366,15 @@ impl Encoder for QsvEncoder {
|
|||||||
let inner = self.inner.as_mut().expect("checked above");
|
let inner = self.inner.as_mut().expect("checked above");
|
||||||
// Best-effort settle of in-flight operations (Close aborts them anyway).
|
// Best-effort settle of in-flight operations (Close aborts them anyway).
|
||||||
while sync_one(inner, 5).ok().flatten().is_some() {}
|
while sync_one(inner, 5).ok().flatten().is_some() {}
|
||||||
// Close BEFORE dropping `pending`. Each `Pending` owns the `Box<BsBuf>` the runtime
|
inner.pending.clear();
|
||||||
// is writing into asynchronously (and a `Box<FrameCtrl>` it reads), so clearing first
|
inner.ready.clear();
|
||||||
// frees that heap while the operation is still live — a use-after-free by the VPL
|
inner.frames_submitted = 0;
|
||||||
// runtime. The drain above is best-effort and bails on the first `Err`, which is
|
inner.first_au_logged = false;
|
||||||
// exactly the wedged-encoder case that triggers this reset, so it cannot be relied on
|
|
||||||
// to have retired everything. Close aborts the operations; only then is the drop safe.
|
|
||||||
// SAFETY: the session is live on this thread; Close on a wedged encoder is legal
|
// SAFETY: the session is live on this thread; Close on a wedged encoder is legal
|
||||||
// (result deliberately ignored) and re-Init happens through `init_encode`.
|
// (result deliberately ignored) and re-Init happens through `init_encode`.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = vpl::MFXVideoENCODE_Close(inner.session.0);
|
let _ = vpl::MFXVideoENCODE_Close(inner.session.0);
|
||||||
}
|
}
|
||||||
inner.pending.clear();
|
|
||||||
inner.ready.clear();
|
|
||||||
inner.frames_submitted = 0;
|
|
||||||
inner.first_au_logged = false;
|
|
||||||
inner.session.0
|
inner.session.0
|
||||||
};
|
};
|
||||||
match self.init_encode(rebuilt) {
|
match self.init_encode(rebuilt) {
|
||||||
@@ -1481,7 +1382,6 @@ impl Encoder for QsvEncoder {
|
|||||||
self.ltr_active = ltr;
|
self.ltr_active = ltr;
|
||||||
self.ir_active = ir;
|
self.ir_active = ir;
|
||||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||||
self.ltr_tainted = [false; NUM_LTR_SLOTS];
|
|
||||||
self.next_ltr_slot = 0;
|
self.next_ltr_slot = 0;
|
||||||
self.pending_force = None;
|
self.pending_force = None;
|
||||||
if let Some(inner) = self.inner.as_mut() {
|
if let Some(inner) = self.inner.as_mut() {
|
||||||
@@ -1846,7 +1746,6 @@ mod tests {
|
|||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
pyro: None,
|
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
};
|
};
|
||||||
@@ -1945,36 +1844,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Taint sweep: a loss that predates every live LTR mark leaves NO clean anchor — every
|
|
||||||
/// slot was marked inside the client's corrupt window. The invalidate must decline (the
|
|
||||||
/// caller then serves the IDR) and no recovery_anchor AU may ship; before the sweep this
|
|
||||||
/// force-referenced a tainted mark and shipped corruption tagged as a clean recovery.
|
|
||||||
#[test]
|
|
||||||
fn qsv_live_ltr_rfi_taint_sweep_declines() {
|
|
||||||
let mut rfi_answered = None;
|
|
||||||
let Some(aus) = drive_live(Codec::H264, false, 60, |enc, i| {
|
|
||||||
if i == 30 && enc.caps().supports_rfi {
|
|
||||||
// Frame 0 lost: the IDR itself — every mark (0, 15, ...) is at-or-after it.
|
|
||||||
rfi_answered = Some(enc.invalidate_ref_frames(0, 2));
|
|
||||||
}
|
|
||||||
}) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
assert_stream_shape(&aus, 60, true);
|
|
||||||
let Some(answered) = rfi_answered else {
|
|
||||||
eprintln!("note: driver declined LTR (supports_rfi=false) — sweep not exercised");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
!answered,
|
|
||||||
"a loss covering every live LTR mark must fall back to IDR recovery"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!aus.iter().any(|a| a.recovery_anchor),
|
|
||||||
"no recovery_anchor AU may ship when the sweep left no clean LTR"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be
|
/// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be
|
||||||
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
|
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+23
-149
@@ -48,19 +48,7 @@ impl Codec {
|
|||||||
} else {
|
} else {
|
||||||
0u8
|
0u8
|
||||||
};
|
};
|
||||||
// Windows: the wavelet encoder rides on top of whatever GPU backend the box has (NVENC/AMF/
|
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||||
// 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")))]
|
|
||||||
let pyro = 0u8;
|
let pyro = 0u8;
|
||||||
let base = (|| {
|
let base = (|| {
|
||||||
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
||||||
@@ -209,11 +197,6 @@ impl Encoder for TrackedEncoder {
|
|||||||
fn set_wire_chunking(&mut self, shard_payload: usize) {
|
fn set_wire_chunking(&mut self, shard_payload: usize) {
|
||||||
self.inner.set_wire_chunking(shard_payload)
|
self.inner.set_wire_chunking(shard_payload)
|
||||||
}
|
}
|
||||||
// Forwarded for the same reason as `set_wire_chunking` above — an unforwarded default here
|
|
||||||
// would silently leave the in-place backends pipelining past the capturer's ring.
|
|
||||||
fn set_input_ring_depth(&mut self, depth: usize) {
|
|
||||||
self.inner.set_input_ring_depth(depth)
|
|
||||||
}
|
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
self.inner.poll()
|
self.inner.poll()
|
||||||
}
|
}
|
||||||
@@ -228,13 +211,6 @@ impl Encoder for TrackedEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ceiling applied to the negotiated bitrate before it reaches openh264: software H.264 realistically
|
|
||||||
/// caps far below the rates a hardware session negotiates, and handing it the full figure just
|
|
||||||
/// misconfigures its rate control. Module-scope so BOTH software arms share one value — the Linux
|
|
||||||
/// arm was missing the clamp the Windows arm applied.
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
||||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
|
||||||
|
|
||||||
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
||||||
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
||||||
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
||||||
@@ -260,11 +236,10 @@ fn open_video_backend(
|
|||||||
if fps == 0 || fps > 1000 {
|
if fps == 0 || fps > 1000 {
|
||||||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
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
|
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
||||||
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future
|
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
||||||
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request
|
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
||||||
// degrades to 4:2:0 with a warning.
|
let chroma = if chroma.is_444() && codec != Codec::H265 {
|
||||||
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
?codec,
|
?codec,
|
||||||
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
||||||
@@ -280,7 +255,7 @@ fn open_video_backend(
|
|||||||
if codec == Codec::PyroWave {
|
if codec == Codec::PyroWave {
|
||||||
#[cfg(feature = "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"));
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "pyrowave"))]
|
#[cfg(not(feature = "pyrowave"))]
|
||||||
@@ -300,14 +275,8 @@ fn open_video_backend(
|
|||||||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
// 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.
|
// 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)> {
|
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")]
|
#[cfg(feature = "vulkan-encode")]
|
||||||
if matches!(codec, Codec::H265 | Codec::Av1)
|
if matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() {
|
||||||
&& vulkan_encode_enabled()
|
|
||||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
|
||||||
{
|
|
||||||
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||||
{
|
{
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
@@ -388,17 +357,8 @@ fn open_video_backend(
|
|||||||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||||||
normal sessions negotiate it instead)"
|
normal sessions negotiate it instead)"
|
||||||
);
|
);
|
||||||
// The lab override forces the wavelet stream onto a session negotiated for
|
pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||||
// another codec — that session's chroma may be HEVC-4:4:4, which the
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||||
// 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"))
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "pyrowave"))]
|
#[cfg(not(feature = "pyrowave"))]
|
||||||
{
|
{
|
||||||
@@ -419,14 +379,8 @@ fn open_video_backend(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||||||
sw::OpenH264Encoder::open(
|
sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps)
|
||||||
format,
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fps,
|
|
||||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
|
||||||
)
|
|
||||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
|
||||||
}
|
}
|
||||||
"auto" | "" => {
|
"auto" | "" => {
|
||||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||||
@@ -445,29 +399,10 @@ fn open_video_backend(
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
// A NEGOTIATED PyroWave session (client advertised + preferred it) routes straight to the
|
// The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3);
|
||||||
// NV12 zero-copy wavelet backend (design/pyrowave-windows-host-zerocopy.md) — placed FIRST,
|
// host_wire_caps never advertises the bit here, so this only guards a forged preference.
|
||||||
// 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.
|
|
||||||
if codec == Codec::PyroWave {
|
if codec == Codec::PyroWave {
|
||||||
#[cfg(feature = "pyrowave")]
|
anyhow::bail!("PyroWave host encode is not available on Windows yet");
|
||||||
{
|
|
||||||
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)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let _ = cuda; // always false on Windows (no Cuda payload)
|
let _ = cuda; // always false on Windows (no Cuda payload)
|
||||||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||||||
@@ -628,6 +563,8 @@ fn open_video_backend(
|
|||||||
(build a GPU backend: --features nvenc or amf-qsv, or request H264)"
|
(build a GPU backend: --features nvenc or amf-qsv, or request H264)"
|
||||||
);
|
);
|
||||||
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
|
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
|
||||||
|
// Software H.264 realistically caps far below the negotiated hardware rates.
|
||||||
|
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||||
sw::OpenH264Encoder::open(
|
sw::OpenH264Encoder::open(
|
||||||
format,
|
format,
|
||||||
width,
|
width,
|
||||||
@@ -800,12 +737,6 @@ fn nvidia_present() -> bool {
|
|||||||
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
|
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
|
||||||
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
|
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
|
||||||
/// today's NVIDIA-presence probe, unchanged.
|
/// today's NVIDIA-presence probe, unchanged.
|
||||||
///
|
|
||||||
/// ⚠ This resolves the **`auto` case only** — it deliberately ignores `encoder_pref`. It is NOT a
|
|
||||||
/// mirror of [`open_video`]'s dispatch and must not be used to decide which backend a capability
|
|
||||||
/// probe should ask: use [`linux_zero_copy_is_vaapi`], which layers `encoder_pref` on top of this.
|
|
||||||
/// (`can_encode_10bit` used this directly and answered for the wrong backend whenever a host
|
|
||||||
/// forced one.)
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn linux_auto_is_vaapi() -> bool {
|
fn linux_auto_is_vaapi() -> bool {
|
||||||
if let Some(g) = pf_gpu::manual_selection() {
|
if let Some(g) = pf_gpu::manual_selection() {
|
||||||
@@ -904,13 +835,6 @@ pub fn vaapi_codec_support() -> CodecSupport {
|
|||||||
pub fn can_encode_444(codec: Codec) -> bool {
|
pub fn can_encode_444(codec: Codec) -> bool {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
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 {
|
if codec != Codec::H265 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -984,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;
|
/// 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
|
/// 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
|
/// 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**
|
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. Every
|
||||||
/// probes a tiny real Main10 open on the auto-resolved backend — libav NVENC (the HDR X2RGB10→
|
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
|
||||||
/// P010 swscale path) or VAAPI (P010 pool + Main10) — for the GNOME 50+ HDR portal capture;
|
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
|
||||||
/// the direct-SDK CUDA path and Vulkan-video stay 8-bit and a 10-bit session routes around them.
|
/// 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"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
pub fn can_encode_10bit(codec: Codec) -> bool {
|
pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -995,12 +920,6 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
|||||||
if !codec.supports_10bit() {
|
if !codec.supports_10bit() {
|
||||||
return false;
|
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
|
// 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`.
|
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||||
@@ -1012,24 +931,8 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
|||||||
let supported = {
|
let supported = {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
// NVENC (libav, the HDR P010 swscale path) or VAAPI (P010 upload / dmabuf graph),
|
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
|
||||||
// probed by opening a tiny real Main10 encoder — the same honesty contract as
|
false
|
||||||
// `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.
|
|
||||||
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
|
|
||||||
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
|
|
||||||
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
|
|
||||||
// would answer for NVENC while the session actually opens VAAPI, and the negotiated bit
|
|
||||||
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
|
|
||||||
// never runs. That is exactly the dishonesty this probe exists to prevent.
|
|
||||||
if linux_zero_copy_is_vaapi() {
|
|
||||||
vaapi::probe_can_encode_10bit(codec)
|
|
||||||
} else {
|
|
||||||
linux::probe_can_encode_10bit(codec)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -1343,12 +1246,6 @@ mod vulkan_video;
|
|||||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
#[path = "enc/linux/vk_av1_encode.rs"]
|
#[path = "enc/linux/vk_av1_encode.rs"]
|
||||||
mod vk_av1_encode;
|
mod vk_av1_encode;
|
||||||
// Vendored `VK_VALVE_video_encode_rgb_conversion` bindings (host-only) — RGB encode source with
|
|
||||||
// the VCN EFC front-end doing the CSC (design/vulkan-rgb-direct-encode.md). ash 0.38 predates
|
|
||||||
// the extension; same vendoring rationale as `vk_av1_encode`.
|
|
||||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
|
||||||
#[path = "enc/linux/vk_valve_rgb.rs"]
|
|
||||||
mod vk_valve_rgb;
|
|
||||||
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
||||||
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
@@ -1363,29 +1260,6 @@ mod vk_util;
|
|||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
#[path = "enc/linux/pyrowave.rs"]
|
#[path = "enc/linux/pyrowave.rs"]
|
||||||
mod pyrowave;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -34,41 +34,10 @@ pub struct WinCaptureTarget {
|
|||||||
pub wudf_pid: u32,
|
pub wudf_pid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The PyroWave (Windows) zero-copy sharing payload attached to a captured frame: the SECOND plane
|
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
||||||
/// 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,
|
|
||||||
/// The capturer's ring generation, bumped every time it recreates its texture ring. The
|
|
||||||
/// PyroWave encoder caches its plane imports keyed on the texture's COM address, which carries
|
|
||||||
/// no reference — after a recreate those addresses can be recycled by the allocator, so a
|
|
||||||
/// cached import may describe a texture that no longer exists. The encoder flushes its import
|
|
||||||
/// cache whenever this changes, making cache identity independent of allocator behaviour.
|
|
||||||
pub ring_gen: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub struct D3d11Frame {
|
pub struct D3d11Frame {
|
||||||
pub texture: ID3D11Texture2D,
|
pub texture: ID3D11Texture2D,
|
||||||
pub device: ID3D11Device,
|
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.
|
// 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
|
// 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
|
/// `DeviceBuffer::yuv444` — three full-res planes stacked in one allocation); NVENC encodes
|
||||||
/// it natively under the Range-Extensions profile. Never a CPU payload.
|
/// it natively under the Range-Extensions profile. Never a CPU payload.
|
||||||
Yuv444,
|
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 {
|
impl PixelFormat {
|
||||||
@@ -80,12 +67,6 @@ impl PixelFormat {
|
|||||||
_ => 4,
|
_ => 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"`).
|
/// 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
|
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
|
||||||
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
||||||
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
|
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.
|
// 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
|
// 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.
|
// 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
|
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
|
||||||
/// 4:2:0 session.
|
/// 4:2:0 session.
|
||||||
pub chroma_444: bool,
|
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 {
|
impl OutputFormat {
|
||||||
@@ -159,8 +130,6 @@ impl OutputFormat {
|
|||||||
hdr,
|
hdr,
|
||||||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||||
chroma_444: false,
|
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
|
/// 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.
|
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||||
pub vdisplay: Option<String>,
|
pub vdisplay: Option<String>,
|
||||||
/// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam
|
/// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam
|
||||||
/// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its
|
/// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their
|
||||||
/// own regardless of this knob; it exists to force it on for non-Steam launches too. Managed
|
/// own flags and do not consult this.
|
||||||
/// gamescope-session-plus/SteamOS sessions own their own flags and do not consult this.
|
|
||||||
pub gamescope_steam: bool,
|
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
|
/// `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
|
/// 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
|
/// 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"
|
"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")
|
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||||
.filter(|s| !s.trim().is_empty()),
|
.filter(|s| !s.trim().is_empty()),
|
||||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user