Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dab40ed98e |
@@ -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
-30
@@ -2159,7 +2159,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2264,7 +2264,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2832,7 +2832,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2850,7 +2850,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2881,7 +2881,6 @@ dependencies = [
|
|||||||
"libvpl-sys",
|
"libvpl-sys",
|
||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
"pf-capture",
|
|
||||||
"pf-frame",
|
"pf-frame",
|
||||||
"pf-gpu",
|
"pf-gpu",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2895,7 +2894,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2904,7 +2903,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2916,7 +2915,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2930,11 +2929,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2962,14 +2961,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2984,7 +2983,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3014,7 +3013,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3026,7 +3025,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3222,7 +3221,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3238,7 +3237,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3254,7 +3253,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3269,7 +3268,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3288,7 +3287,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3319,7 +3318,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3364,13 +3363,11 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reis",
|
"reis",
|
||||||
"ring",
|
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
"semver",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -3403,7 +3400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3417,7 +3414,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.17.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3440,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.17.0"
|
version = "0.14.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.17.0"
|
version = "0.14.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
-1282
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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?
|
||||||
@@ -498,8 +472,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 +655,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 +671,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 and
|
||||||
|
/// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
|
||||||
|
/// this so an untouched picker shows what actually runs.
|
||||||
|
static var presenterDefault: String {
|
||||||
|
#if os(macOS)
|
||||||
|
"stage2"
|
||||||
|
#else
|
||||||
|
"stage3"
|
||||||
|
#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,265 @@ 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) vs stage-3 (the same
|
||||||
|
// pipeline with glass-gated present pacing). The default is per-platform — glass on iOS/tvOS,
|
||||||
|
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
|
||||||
|
// the queue-saturation rationale) — so the "(default)" marker rides presenterDefault instead
|
||||||
|
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
|
||||||
|
// DEBUG-only diagnostic — it freezes hard on a lost HEVC reference.
|
||||||
|
@ViewBuilder var presenterSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Presenter", selection: $presenter) {
|
||||||
|
ForEach(SettingsOptions.presenters, id: \.tag) { option in
|
||||||
|
Text(option.tag == SettingsOptions.presenterDefault
|
||||||
|
? "\(option.label) (default)" : option.label)
|
||||||
|
.tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Video presenter")
|
||||||
|
} footer: {
|
||||||
|
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
|
||||||
|
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||||
|
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||||
|
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
|
||||||
|
+ "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 +538,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 +565,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -602,36 +602,6 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
#endif
|
#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 +617,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 +674,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,7 +693,6 @@ 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()
|
||||||
@@ -779,8 +742,7 @@ public final class MetalVideoPresenter {
|
|||||||
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: planarPipeline,
|
||||||
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]
|
||||||
@@ -907,17 +869,9 @@ public final class MetalVideoPresenter {
|
|||||||
/// 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 +884,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 }
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
// 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: the Metal pipeline
|
||||||
// (explicit VTDecompressionSession decode → CAMetalLayer) is the default — deadline-paced
|
// (explicit VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's
|
||||||
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
// CADisplayLink) is the default — glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
|
||||||
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump → AVSampleBufferDisplayLayer)
|
||||||
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits — capture,
|
||||||
// Stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
|
// window/scale tracking, and constructing the display link — and delegate the shared presenter
|
||||||
// The views own the platform bits — capture, window/scale tracking, and constructing the
|
// lifecycle here.
|
||||||
// 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 +28,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)
|
||||||
@@ -54,92 +50,36 @@ enum PresenterChoice: Equatable {
|
|||||||
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
/// 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
|
/// 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
|
/// (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
|
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
|
||||||
/// 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? {
|
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 : nil
|
||||||
case "stage2": return .stage2
|
case "stage2": return .stage2
|
||||||
case "stage3": return .stage3
|
case "stage3": return .stage3
|
||||||
case "stage4":
|
|
||||||
#if os(macOS)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
return .stage4
|
|
||||||
#endif
|
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
/// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
|
||||||
///
|
/// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
|
||||||
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
/// near the stream rate — an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
|
||||||
/// (`displaySyncEnabled` is macOS-only API), and at stream rate ≈ panel rate — an Apple TV's
|
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
|
||||||
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
|
/// makes the COMMON case — arrival pacing pins the queue at ~`maximumDrawableCount` and every
|
||||||
/// steering the panel to the stream — that queue's depth is STICKY: one burst fills it and,
|
/// frame rides ~2–3 refreshes of it (measured: ~50 ms on Apple TV; 23–30 ms at 120 Hz on
|
||||||
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
|
/// ProMotion iPads — the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
|
||||||
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
|
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
|
||||||
/// read ~30 ms display on arrival (~3 refreshes of queue), 22–28 ms glass-gated at depth 2
|
/// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
|
||||||
/// (a standing queue of 2 — the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
|
/// so arrival is genuinely lowest-latency there.
|
||||||
/// 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) || os(iOS)
|
||||||
.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
|
/// 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
|
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||||
@@ -159,7 +99,6 @@ final class SessionPresenter {
|
|||||||
static func pacing(
|
static func pacing(
|
||||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||||
) -> PresentPacing {
|
) -> PresentPacing {
|
||||||
if choice == .stage4 { return .deadline }
|
|
||||||
if choice == .stage3 { return .glass }
|
if choice == .stage3 { return .glass }
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if explicit == nil, codec == .pyrowave { return .glass }
|
if explicit == nil, codec == .pyrowave { return .glass }
|
||||||
@@ -167,27 +106,31 @@ final class SessionPresenter {
|
|||||||
return .arrival
|
return .arrival
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
|
/// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
|
||||||
///
|
///
|
||||||
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
|
/// - iOS/iPadOS: 2 — one flip scanning out plus one queued for the next latch. A decoded
|
||||||
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
/// frame presents immediately (the gate is open in steady state) and latches the very next
|
||||||
/// 22–28 ms vs depth 1's 14: any second gate slot becomes a STANDING queue — a burst fills
|
/// vsync, so the present cadence never serializes on the on-glass callback's own latency —
|
||||||
/// it, and with presents and latches then running at the same rate the occupancy never
|
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
|
||||||
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
|
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
|
||||||
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
|
/// FIFO saturation (23–30 ms) stays gone.
|
||||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
/// - tvOS: 1 — the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
|
||||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
/// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
|
||||||
|
/// - macOS: pinned to 1, env ignored — glass pacing exists there as the DCP swapID
|
||||||
|
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
|
||||||
///
|
///
|
||||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
/// `PUNKTFUNK_GATE_DEPTH` (1…3) overrides on iOS/tvOS for on-device A/B, mirroring the other
|
||||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
/// presenter env levers. Internal (not private) for unit tests.
|
||||||
/// 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 {
|
static func gateDepth(env: String?) -> Int {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return 1
|
return 1
|
||||||
#else
|
#else
|
||||||
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
||||||
|
#if os(tvOS)
|
||||||
return 1
|
return 1
|
||||||
|
#else
|
||||||
|
return 2
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +167,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,50 +175,31 @@ final class SessionPresenter {
|
|||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
// Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
|
||||||
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
|
||||||
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
|
||||||
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
|
||||||
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
// on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B.
|
||||||
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
|
||||||
// value is deliberately ignored). The user-facing choice is the INTENT
|
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||||
// (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 explicit = PresenterChoice.explicit(
|
||||||
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 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: Self.pacing(
|
||||||
pacing: pacing,
|
for: choice, explicit: explicit, codec: connection.videoCodec),
|
||||||
gateDepth: Self.gateDepth(
|
gateDepth: Self.gateDepth(
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
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().
|
||||||
@@ -293,19 +216,14 @@ final class SessionPresenter {
|
|||||||
// 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 +251,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)
|
||||||
|
|||||||
@@ -18,14 +18,11 @@
|
|||||||
// – 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 a bounded number of undisplayed
|
||||||
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
|
// drawables (the gate depth — see PresentPacing + SessionPresenter.gateDepth) so the layer's
|
||||||
// CAMetalDisplayLink-vended drawables the moment a frame decodes — deadline pacing, where the
|
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||||
// 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 +41,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 +48,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,24 +117,13 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
/// ~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 23–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, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
|
||||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
/// undisplayed drawables in flight (`PresentGate`; the depth — 1 or 2 — is per-platform, see
|
||||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
/// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
|
||||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
/// 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
|
/// 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
|
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
||||||
@@ -241,122 +132,6 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
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
|
|
||||||
/// drawable stash — the link thread `put`s each update's vended drawable (replacing an
|
|
||||||
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
|
|
||||||
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
|
|
||||||
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
|
|
||||||
/// Sendable; lock-guarded.
|
|
||||||
final class LatestBox<T>: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
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.
|
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
|
||||||
@@ -431,20 +206,10 @@ 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 pegs it at the gate depth).
|
||||||
@@ -458,14 +223,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 +236,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 +296,18 @@ 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
|
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||||
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||||
private let gateDepth: Int
|
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 +338,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 }
|
||||||
@@ -628,22 +354,16 @@ public final class Stage2Pipeline {
|
|||||||
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,
|
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.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 +538,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 +552,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 bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
||||||
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
// (like 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(capacity: gateDepth) : 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 +571,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 +591,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 +608,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 +627,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,168 +637,16 @@ 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) {
|
||||||
@@ -1169,7 +709,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 +753,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,
|
||||||
|
|||||||
@@ -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,10 +4,9 @@ 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 bounded in-flight `PresentGate` (depth 1 + depth 2), the
|
||||||
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
|
/// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
|
||||||
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
|
/// release-build stage-1 gate), and the per-platform glass-gate depth.
|
||||||
/// stage-4 gate), and the per-platform glass-gate depth.
|
|
||||||
final class PresentPacingTests: XCTestCase {
|
final class PresentPacingTests: XCTestCase {
|
||||||
// MARK: - PresentGate
|
// MARK: - PresentGate
|
||||||
|
|
||||||
@@ -22,10 +21,8 @@ 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
|
/// Depth 2 (the iOS default): a second present may queue behind the flip scanning out — the
|
||||||
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue
|
/// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
|
||||||
/// behind the flip scanning out — the bound only bites at the THIRD. One release (a glass
|
|
||||||
/// callback) reopens exactly one slot.
|
|
||||||
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
||||||
let gate = PresentGate(capacity: 2)
|
let gate = PresentGate(capacity: 2)
|
||||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||||
@@ -75,143 +72,16 @@ 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
|
/// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
|
||||||
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
/// tvOS — arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
|
||||||
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
/// (sync-off presents don't queue). No selection / garbage falls back to it.
|
||||||
/// No selection / garbage falls back to it.
|
|
||||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(macOS)
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
|
||||||
#else
|
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||||
|
#else
|
||||||
|
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
|
||||||
#endif
|
#endif
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||||||
@@ -236,29 +106,6 @@ 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 the platform default.
|
||||||
func testPresenterChoiceGatesStage1() {
|
func testPresenterChoiceGatesStage1() {
|
||||||
@@ -309,30 +156,25 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
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
|
// MARK: - Glass-gate depth
|
||||||
|
|
||||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
/// The per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
|
||||||
/// the 2026-07 iPad depth-2 experiment regressed display latency 14→22–28 ms (see
|
/// queued — the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
|
||||||
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
|
/// there is the swapID-panic mitigation — strict serialization is its point, so the env
|
||||||
/// there is the swapID-panic mitigation — strict serialization is its point);
|
/// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
|
||||||
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS.
|
/// values are ignored.
|
||||||
/// Out-of-range/garbage values are ignored.
|
|
||||||
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
||||||
|
#elseif os(tvOS)
|
||||||
|
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||||
|
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device A/B lever")
|
||||||
#else
|
#else
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
|
||||||
SessionPresenter.gateDepth(env: nil), 1,
|
XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
|
||||||
"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
|
#endif
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
||||||
|
|||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -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,
|
||||||
@@ -487,11 +483,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
||||||
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
||||||
// STREAMED_AU: the same shared reassembler accepts sentinel-headed streamed
|
|
||||||
// blocks, and the probe is exactly the tool that measures the overlap win.
|
|
||||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
||||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ
|
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
|
||||||
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
|
|
||||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||||
}
|
}
|
||||||
@@ -520,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,
|
||||||
@@ -636,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")
|
||||||
}
|
}
|
||||||
@@ -689,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"
|
||||||
@@ -751,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
-321
@@ -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
|
||||||
@@ -148,63 +137,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 +186,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 +208,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 +223,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 +271,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. PyroWave is the low-latency wavelet codec for a WIRED link: it trades bitrate (hundreds of Mb/s) for near-zero decode time, so it needs gigabit Ethernet.",
|
||||||
|
);
|
||||||
// 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 +295,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 +351,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 +415,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 +431,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 +505,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() {}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ use pf_frame::DmabufFrame;
|
|||||||
pub trait Capturer: Send {
|
pub trait Capturer: Send {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||||
|
|
||||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
|
||||||
/// backend's default. The pipeline retry loop shortens its FIRST attempt's wait: a PipeWire
|
|
||||||
/// stream connected while gamescope re-inits its headless takeover can negotiate a format,
|
|
||||||
/// reach `Streaming`, and still never receive a buffer — a fresh connect then delivers within
|
|
||||||
/// ~0.5 s, so waiting out the full default budget on a doomed stream just delays the retry
|
|
||||||
/// that fixes it. Backends without an internal wait budget ignore it (the default delegates).
|
|
||||||
fn next_frame_within(&mut self, _budget: std::time::Duration) -> Result<CapturedFrame> {
|
|
||||||
self.next_frame()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||||
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||||
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||||
@@ -254,19 +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,
|
|
||||||
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
|
|
||||||
/// Video backend on an H265/AV1 session — resolved by the host facade via
|
|
||||||
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
|
|
||||||
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
|
|
||||||
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
|
|
||||||
pub native_nv12_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>,
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-266
@@ -255,11 +255,9 @@ fn spawn_pipewire(
|
|||||||
want_hdr
|
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 && !force_shm && policy.backend_is_vaapi;
|
||||||
let vaapi_dmabuf =
|
|
||||||
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
|
|
||||||
let join = thread::Builder::new()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-pipewire".into())
|
.name("punktfunk-pipewire".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -299,11 +297,29 @@ fn spawn_pipewire(
|
|||||||
|
|
||||||
impl Capturer for PortalCapturer {
|
impl Capturer for PortalCapturer {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
self.frame_within(Duration::from_secs(10))
|
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
|
||||||
}
|
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
|
||||||
|
// instead of sitting out the full first-frame budget.
|
||||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
self.frame_within(budget)
|
loop {
|
||||||
|
if self.broken.load(Ordering::Relaxed) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||||
|
failed repeatedly — rebuilding capture",
|
||||||
|
self.node_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(f) = self.pending.take() {
|
||||||
|
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
||||||
|
}
|
||||||
|
let slice = Duration::from_millis(500)
|
||||||
|
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||||
|
match self.frames.recv_timeout(slice) {
|
||||||
|
Ok(frame) => return Ok(frame),
|
||||||
|
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||||
|
Err(e) => return self.next_frame_timed_out(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supports_arrival_wait(&self) -> bool {
|
fn supports_arrival_wait(&self) -> bool {
|
||||||
@@ -399,41 +415,9 @@ impl Capturer for PortalCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PortalCapturer {
|
impl PortalCapturer {
|
||||||
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
|
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
|
||||||
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
|
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||||
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
|
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
|
||||||
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
|
|
||||||
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
|
||||||
let deadline = std::time::Instant::now() + budget;
|
|
||||||
loop {
|
|
||||||
if self.broken.load(Ordering::Relaxed) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
|
||||||
failed repeatedly — rebuilding capture",
|
|
||||||
self.node_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(f) = self.pending.take() {
|
|
||||||
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
|
||||||
}
|
|
||||||
let slice = Duration::from_millis(500)
|
|
||||||
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
|
||||||
match self.frames.recv_timeout(slice) {
|
|
||||||
Ok(frame) => return Ok(frame),
|
|
||||||
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
|
||||||
Err(e) => return self.next_frame_timed_out(e, budget),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
|
||||||
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
|
||||||
fn next_frame_timed_out(
|
|
||||||
&self,
|
|
||||||
err: RecvTimeoutError,
|
|
||||||
budget: Duration,
|
|
||||||
) -> Result<CapturedFrame> {
|
|
||||||
let within = budget.as_secs_f32();
|
|
||||||
match err {
|
match err {
|
||||||
RecvTimeoutError::Timeout => {
|
RecvTimeoutError::Timeout => {
|
||||||
// Split the two black-screen root causes apart so the operator gets a cause, not
|
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||||
@@ -441,10 +425,9 @@ impl PortalCapturer {
|
|||||||
// not (no acceptable format / node never emitted a param)?
|
// not (no acceptable format / node never emitted a param)?
|
||||||
if self.negotiated.load(Ordering::Relaxed) {
|
if self.negotiated.load(Ordering::Relaxed) {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): format negotiated but no \
|
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
|
||||||
buffers arrived — the compositor produced no frames (virtual output \
|
arrived — the compositor produced no frames (virtual output idle/unmapped, \
|
||||||
idle/unmapped, capture never started, or a stream bound during a \
|
or capture never started)",
|
||||||
compositor (re)start that will never deliver — a reconnect fixes that)",
|
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else if self.hdr_offer {
|
} else if self.hdr_offer {
|
||||||
@@ -455,10 +438,10 @@ impl PortalCapturer {
|
|||||||
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
||||||
super::note_hdr_capture_failed();
|
super::note_hdr_capture_failed();
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored monitor in \
|
||||||
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
|
HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \
|
||||||
reconnect to stream SDR",
|
to stream SDR",
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||||
@@ -467,15 +450,14 @@ impl PortalCapturer {
|
|||||||
// retries on the CPU offer instead of failing this same negotiation forever.
|
// retries on the CPU offer instead of failing this same negotiation forever.
|
||||||
pf_zerocopy::note_vaapi_dmabuf_failed();
|
pf_zerocopy::note_vaapi_dmabuf_failed();
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||||
accepted the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this \
|
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
||||||
host to the CPU capture path; the pipeline rebuild will renegotiate \
|
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
|
||||||
without dmabuf",
|
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
"no PipeWire frame within 10s (node {}): format negotiation never \
|
||||||
completed — the compositor offered no format this consumer accepts \
|
completed — the compositor offered no format this consumer accepts \
|
||||||
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
||||||
self.node_id
|
self.node_id
|
||||||
@@ -840,7 +822,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,
|
||||||
VideoFormat::NV12 => PixelFormat::Nv12,
|
|
||||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
|
// 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).
|
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
|
||||||
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
||||||
@@ -1006,10 +987,10 @@ mod pipewire {
|
|||||||
.into_inner())
|
.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
/// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a
|
||||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
/// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which
|
||||||
|
/// we read back in `param_changed`.
|
||||||
fn build_dmabuf_format(
|
fn build_dmabuf_format(
|
||||||
format: VideoFormat,
|
|
||||||
modifiers: &[u64],
|
modifiers: &[u64],
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
@@ -1020,7 +1001,7 @@ mod pipewire {
|
|||||||
pw::spa::param::ParamType::EnumFormat,
|
pw::spa::param::ParamType::EnumFormat,
|
||||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, VideoFormat::BGRx),
|
||||||
pw::spa::pod::property!(
|
pw::spa::pod::property!(
|
||||||
FormatProperties::VideoSize,
|
FormatProperties::VideoSize,
|
||||||
Choice,
|
Choice,
|
||||||
@@ -1049,22 +1030,6 @@ mod pipewire {
|
|||||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if format == VideoFormat::NV12 {
|
|
||||||
obj.properties.push(pw::spa::pod::Property {
|
|
||||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
|
||||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
|
||||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
|
||||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
obj.properties.push(pw::spa::pod::Property {
|
|
||||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
|
||||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
|
||||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
|
||||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
obj.properties.push(pw::spa::pod::Property {
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
@@ -1343,25 +1308,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,
|
||||||
@@ -1384,18 +1345,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,
|
||||||
@@ -1411,27 +1367,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 {
|
||||||
@@ -1637,8 +1576,8 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can
|
// VAAPI zero-copy passthrough: hand the raw dmabuf straight to the encoder, which imports
|
||||||
// be consumed by the Vulkan Video encoder without another color conversion.
|
// it into a VA surface and does RGB→NV12 on the GPU video engine. No CUDA importer here.
|
||||||
if ud.vaapi_passthrough {
|
if ud.vaapi_passthrough {
|
||||||
if let Some(fmt) = ud.format {
|
if let Some(fmt) = ud.format {
|
||||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
@@ -1646,41 +1585,9 @@ mod pipewire {
|
|||||||
let chunk = datas[0].chunk();
|
let chunk = datas[0].chunk();
|
||||||
let offset = chunk.offset();
|
let offset = chunk.offset();
|
||||||
let stride = chunk.stride().max(0) as u32;
|
let stride = chunk.stride().max(0) as u32;
|
||||||
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
|
|
||||||
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
|
|
||||||
// may align the Y plane before UV). Pass it through instead of assuming
|
|
||||||
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
|
|
||||||
// by inode, not fd number; a genuinely two-BO frame cannot travel through
|
|
||||||
// the single-fd import — drop it with a diagnosis instead of streaming
|
|
||||||
// garbage chroma.
|
|
||||||
let plane1 =
|
|
||||||
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
|
|
||||||
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
|
|
||||||
// owned by the live PipeWire buffer for this callback, and `fstat`
|
|
||||||
// only writes the out-param structs, whose fields are read only after
|
|
||||||
// the `== 0` success checks.
|
|
||||||
let same_bo = unsafe {
|
|
||||||
let mut s0: libc::stat = std::mem::zeroed();
|
|
||||||
let mut s1: libc::stat = std::mem::zeroed();
|
|
||||||
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
|
|
||||||
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
|
|
||||||
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
|
|
||||||
};
|
|
||||||
if !same_bo {
|
|
||||||
warn_once(
|
|
||||||
"NV12 planes live in different buffer objects — frames \
|
|
||||||
dropped (single-fd import only)",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let c1 = datas[1].chunk();
|
|
||||||
Some((c1.offset(), c1.stride().max(0) as u32))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||||
// imports it. Content stability across the brief import/encode window relies
|
// imports it. (Content stability across the brief map+CSC window relies on
|
||||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||||
@@ -1707,10 +1614,9 @@ mod pipewire {
|
|||||||
modifier: ud.modifier,
|
modifier: ud.modifier,
|
||||||
offset,
|
offset,
|
||||||
stride,
|
stride,
|
||||||
plane1,
|
|
||||||
}),
|
}),
|
||||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
// Cursor-as-metadata: the encoder blends this into its owned VA
|
||||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
// surface (raw dmabuf never touched).
|
||||||
cursor: ud.cursor.overlay(),
|
cursor: ud.cursor.overlay(),
|
||||||
});
|
});
|
||||||
static ONCE: std::sync::atomic::AtomicBool =
|
static ONCE: std::sync::atomic::AtomicBool =
|
||||||
@@ -1721,12 +1627,7 @@ mod pipewire {
|
|||||||
h,
|
h,
|
||||||
modifier = ud.modifier,
|
modifier = ud.modifier,
|
||||||
fourcc = format_args!("{:#010x}", fourcc),
|
fourcc = format_args!("{:#010x}", fourcc),
|
||||||
source = if fmt == PixelFormat::Nv12 {
|
"zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)"
|
||||||
"producer-native NV12"
|
|
||||||
} else {
|
|
||||||
"packed RGB (encoder GPU CSC)"
|
|
||||||
},
|
|
||||||
"zero-copy: handing the raw DMA-BUF to the encoder"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -2044,18 +1945,16 @@ 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;
|
|
||||||
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
|
// 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
|
// 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.
|
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
|
||||||
let mut importer = if zerocopy && !raw_passthrough && !want_hdr {
|
let mut importer = if zerocopy && !backend_is_vaapi && !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"
|
||||||
@@ -2081,33 +1980,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;
|
|
||||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
|
||||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
|
||||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
|
||||||
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
|
|
||||||
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
|
|
||||||
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
|
|
||||||
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
|
|
||||||
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
|
|
||||||
// packed-RGB fallback pod.
|
|
||||||
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
|
|
||||||
&& policy.native_nv12_session
|
|
||||||
&& backend_is_vaapi
|
|
||||||
&& vaapi_passthrough
|
|
||||||
&& !policy.pyrowave_session
|
|
||||||
&& !want_444
|
|
||||||
&& !want_hdr;
|
|
||||||
if prefer_native_nv12 {
|
|
||||||
tracing::info!(
|
|
||||||
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
|
|
||||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 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:
|
||||||
@@ -2123,9 +1998,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) {
|
||||||
@@ -2145,13 +2019,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!(
|
||||||
native_nv12_preferred = prefer_native_nv12,
|
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||||
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \
|
|
||||||
when enabled, packed RGB fallback)"
|
|
||||||
);
|
);
|
||||||
} 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)],
|
||||||
@@ -2285,37 +2157,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.
|
||||||
@@ -2394,18 +2265,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
|
||||||
@@ -2489,18 +2361,7 @@ mod pipewire {
|
|||||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
||||||
]
|
]
|
||||||
} else if want_dmabuf {
|
} else if want_dmabuf {
|
||||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
vec![build_dmabuf_format(&modifiers, preferred)?]
|
||||||
if prefer_native_nv12 {
|
|
||||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
|
||||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
|
||||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
|
||||||
}
|
|
||||||
pods.push(build_dmabuf_format(
|
|
||||||
VideoFormat::BGRx,
|
|
||||||
&modifiers,
|
|
||||||
preferred,
|
|
||||||
)?);
|
|
||||||
pods
|
|
||||||
} else {
|
} else {
|
||||||
vec![serialize_pod(obj)?]
|
vec![serialize_pod(obj)?]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -308,9 +308,8 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
|||||||
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
||||||
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
||||||
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
|
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
|
||||||
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format. (There is no runtime
|
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format so the caller can fall
|
||||||
/// fallback — the error propagates through `try_consume` and ends the session; the "R10 path" the
|
/// back to the existing R10 path.
|
||||||
/// original design referenced was never kept.)
|
|
||||||
pub(crate) struct HdrP010Converter {
|
pub(crate) struct HdrP010Converter {
|
||||||
vs: ID3D11VertexShader,
|
vs: ID3D11VertexShader,
|
||||||
ps_y: ID3D11PixelShader,
|
ps_y: ID3D11PixelShader,
|
||||||
@@ -738,157 +737,14 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
|||||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn hdr_p010_selftest() -> Result<()> {
|
pub fn hdr_p010_selftest() -> Result<()> {
|
||||||
hdr_p010_selftest_at(64, 64, None)
|
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||||
}
|
use windows::Win32::Graphics::Dxgi::IDXGIAdapter;
|
||||||
|
|
||||||
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
|
// 64x64, even dims. A 4x4 grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform →
|
||||||
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
|
// exact chroma comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus
|
||||||
/// top of the 64×64 default because the field sessions run at capture resolutions whose height
|
// a couple of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||||
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
|
const W: u32 = 64;
|
||||||
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
|
const H: u32 = 64;
|
||||||
/// adapter is not the one the session encodes on.
|
|
||||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
|
||||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
|
||||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
|
||||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
|
||||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
|
||||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
|
||||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
|
||||||
/// (325,448,598) (226,650,535) (64,512,512).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn hdr_p010_convert_bars_on_luid(
|
|
||||||
luid: [u8; 8],
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
|
||||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
|
||||||
|
|
||||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
|
||||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
|
||||||
}
|
|
||||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
|
||||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
|
||||||
const BARS: [(f32, f32, f32); 8] = [
|
|
||||||
(1.0, 1.0, 1.0),
|
|
||||||
(1.0, 1.0, 0.0),
|
|
||||||
(0.0, 1.0, 1.0),
|
|
||||||
(0.0, 1.0, 0.0),
|
|
||||||
(1.0, 0.0, 1.0),
|
|
||||||
(1.0, 0.0, 0.0),
|
|
||||||
(0.0, 0.0, 1.0),
|
|
||||||
(0.0, 0.0, 0.0),
|
|
||||||
];
|
|
||||||
let bar_w = (w / 8).max(1) as usize;
|
|
||||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
|
||||||
for y in 0..h as usize {
|
|
||||||
for x in 0..w as usize {
|
|
||||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
|
||||||
let i = (y * w as usize + x) * 4;
|
|
||||||
fp16[i] = f32_to_f16(r);
|
|
||||||
fp16[i + 1] = f32_to_f16(g);
|
|
||||||
fp16[i + 2] = f32_to_f16(b);
|
|
||||||
fp16[i + 3] = f32_to_f16(1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
|
||||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
|
||||||
// their references.
|
|
||||||
unsafe {
|
|
||||||
let luid = windows::Win32::Foundation::LUID {
|
|
||||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
|
||||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
|
||||||
};
|
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
|
||||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
|
||||||
D3D11CreateDevice(
|
|
||||||
&adapter,
|
|
||||||
D3D_DRIVER_TYPE_UNKNOWN,
|
|
||||||
HMODULE::default(),
|
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
|
||||||
D3D11_SDK_VERSION,
|
|
||||||
Some(&mut device),
|
|
||||||
None,
|
|
||||||
Some(&mut context),
|
|
||||||
)
|
|
||||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
|
||||||
let device = device.context("null device")?;
|
|
||||||
let context = context.context("null context")?;
|
|
||||||
|
|
||||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: w,
|
|
||||||
Height: h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
|
||||||
pSysMem: fp16.as_ptr() as *const c_void,
|
|
||||||
SysMemPitch: w * 8,
|
|
||||||
SysMemSlicePitch: 0,
|
|
||||||
};
|
|
||||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
|
||||||
.context("CreateTexture2D(fp16 bars)")?;
|
|
||||||
let src_tex = src_tex.context("null src tex")?;
|
|
||||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
|
||||||
device
|
|
||||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
|
||||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
|
||||||
let src_srv = src_srv.context("null src srv")?;
|
|
||||||
|
|
||||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
|
||||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: w,
|
|
||||||
Height: h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_P010,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut p010: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
|
||||||
.context("CreateTexture2D(P010 bars dst)")?;
|
|
||||||
let p010 = p010.context("null p010 tex")?;
|
|
||||||
|
|
||||||
let conv = HdrP010Converter::new(&device)?;
|
|
||||||
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
|
|
||||||
Ok((device, p010))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|
||||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
|
||||||
|
|
||||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
|
||||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
|
||||||
}
|
|
||||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
|
||||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
|
||||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
let (W, H) = (w, h);
|
|
||||||
const BLK: u32 = 16;
|
const BLK: u32 = 16;
|
||||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||||
let named: [(&str, f32, f32, f32); 8] = [
|
let named: [(&str, f32, f32, f32); 8] = [
|
||||||
@@ -941,36 +797,12 @@ pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|||||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||||
// proven individually at the `read_u16` closure below.
|
// proven individually at the `read_u16` closure below.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
|
||||||
// the GPU it actually tested.
|
|
||||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
|
||||||
None => None,
|
|
||||||
Some(want) => {
|
|
||||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
|
||||||
let mut found = None;
|
|
||||||
for i in 0.. {
|
|
||||||
let Ok(a) = factory.EnumAdapters(i) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let desc = a.GetDesc().context("adapter desc")?;
|
|
||||||
if desc.VendorId == want {
|
|
||||||
found = Some(a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
let mut device: Option<ID3D11Device> = None;
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
let mut context: Option<ID3D11DeviceContext> = None;
|
||||||
D3D11CreateDevice(
|
D3D11CreateDevice(
|
||||||
adapter.as_ref(),
|
None::<&IDXGIAdapter>,
|
||||||
if adapter.is_some() {
|
D3D_DRIVER_TYPE_HARDWARE,
|
||||||
D3D_DRIVER_TYPE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
D3D_DRIVER_TYPE_HARDWARE
|
|
||||||
},
|
|
||||||
HMODULE::default(),
|
HMODULE::default(),
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
@@ -982,22 +814,6 @@ pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|||||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||||
let device = device.context("null device")?;
|
let device = device.context("null device")?;
|
||||||
let context = context.context("null context")?;
|
let context = context.context("null context")?;
|
||||||
{
|
|
||||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
|
||||||
device.cast().context("device -> IDXGIDevice")?;
|
|
||||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
|
||||||
let name = String::from_utf16_lossy(
|
|
||||||
&desc.Description[..desc
|
|
||||||
.Description
|
|
||||||
.iter()
|
|
||||||
.position(|&c| c == 0)
|
|
||||||
.unwrap_or(desc.Description.len())],
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
|
||||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source FP16 texture (initialized) + SRV.
|
// Source FP16 texture (initialized) + SRV.
|
||||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||||
@@ -1359,16 +1175,3 @@ impl VideoConverter {
|
|||||||
blt.context("VideoProcessorBlt")
|
blt.context("VideoProcessorBlt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod hdr_selftests {
|
|
||||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
|
||||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
|
||||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
|
||||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
|
||||||
#[test]
|
|
||||||
#[ignore]
|
|
||||||
fn hdr_p010_selftest_intel_1080_live() {
|
|
||||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1341,12 +1341,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_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
|
||||||
self.pyro_last = None;
|
self.pyro_last = None;
|
||||||
self.out_idx = 0;
|
self.out_idx = 0;
|
||||||
@@ -1867,7 +1861,6 @@ impl IddPushCapturer {
|
|||||||
cbcr,
|
cbcr,
|
||||||
fence_handle,
|
fence_handle,
|
||||||
fence_value,
|
fence_value,
|
||||||
ring_gen: self.generation,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -1926,7 +1919,6 @@ impl IddPushCapturer {
|
|||||||
cbcr: dst_cbcr,
|
cbcr: dst_cbcr,
|
||||||
fence_handle,
|
fence_handle,
|
||||||
fence_value,
|
fence_value,
|
||||||
ring_gen: self.generation,
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
|
|||||||
@@ -64,10 +64,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,11 +41,6 @@ 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).
|
|
||||||
// Built everywhere the session client is; the platform seam inside is Windows-real,
|
|
||||||
// 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).
|
// 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
|
// 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
|
// Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -342,20 +339,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 +430,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;
|
||||||
@@ -824,9 +799,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,
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ tracing = "0.1"
|
|||||||
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dev-dependencies]
|
|
||||||
# The QSV live e2e drives the REAL HdrP010Converter output (an RTV-written, ring-profile P010
|
|
||||||
# texture) into the encoder — the one seam the CPU-upload tests can't reach.
|
|
||||||
pf-capture = { path = "../pf-capture" }
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||||
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
||||||
openh264 = "0.9"
|
openh264 = "0.9"
|
||||||
|
|||||||
@@ -32,46 +32,6 @@ pub struct EncodedFrame {
|
|||||||
pub chunk_aligned: bool,
|
pub chunk_aligned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One slice-boundary chunk of an encoded AU, emitted by a chunked-poll backend
|
|
||||||
/// ([`Encoder::poll_chunk`], latency plan §7 LN1): the encoder hands out completed slices while
|
|
||||||
/// the rest of the frame is still encoding, so packetize/FEC/pacing can overlap the encode tail.
|
|
||||||
/// The chunks of one AU concatenate to exactly the bytes [`Encoder::poll`] would have returned,
|
|
||||||
/// and every cut lands on an Annex-B NAL boundary (slice starts). AU-level metadata
|
|
||||||
/// (`pts_ns`/`keyframe`/`recovery_anchor`/`chunk_aligned`) is authoritative on the FIRST chunk
|
|
||||||
/// (`first`) — the host opens the wire frame from it; `last` closes the AU. `keyframe` on a
|
|
||||||
/// non-final chunk is the encoder's own prediction (exact under the P-only/infinite-GOP config —
|
|
||||||
/// the driver only ever emits an IDR we asked for); the final chunk re-checks it against the
|
|
||||||
/// driver's reported picture type.
|
|
||||||
pub struct AuChunk {
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub pts_ns: u64,
|
|
||||||
pub keyframe: bool,
|
|
||||||
/// See [`EncodedFrame::recovery_anchor`].
|
|
||||||
pub recovery_anchor: bool,
|
|
||||||
/// See [`EncodedFrame::chunk_aligned`].
|
|
||||||
pub chunk_aligned: bool,
|
|
||||||
/// Opens the AU (carries the authoritative AU metadata).
|
|
||||||
pub first: bool,
|
|
||||||
/// Closes the AU (the concatenation is complete; the encoder's in-flight slot is released).
|
|
||||||
pub last: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuChunk {
|
|
||||||
/// A whole AU as a single self-closing chunk — what every non-chunked backend's
|
|
||||||
/// [`Encoder::poll_chunk`] default emits, so a chunk consumer needs no per-backend fork.
|
|
||||||
pub fn whole(f: EncodedFrame) -> Self {
|
|
||||||
AuChunk {
|
|
||||||
data: f.data,
|
|
||||||
pts_ns: f.pts_ns,
|
|
||||||
keyframe: f.keyframe,
|
|
||||||
recovery_anchor: f.recovery_anchor,
|
|
||||||
chunk_aligned: f.chunk_aligned,
|
|
||||||
first: true,
|
|
||||||
last: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codec selection negotiated with the client.
|
/// Codec selection negotiated with the client.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Codec {
|
pub enum Codec {
|
||||||
@@ -259,11 +219,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
|
||||||
@@ -309,37 +264,8 @@ 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>>;
|
||||||
/// Whether [`poll_chunk`](Self::poll_chunk) currently emits sub-AU chunks — i.e. the LIVE
|
|
||||||
/// session has slice-level readback armed (Linux direct-NVENC with the
|
|
||||||
/// `PUNKTFUNK_NVENC_SLICES` and `PUNKTFUNK_NVENC_SUBFRAME` knobs on a sync depth-1
|
|
||||||
/// retrieve). Dynamic, not static: a pipelined-retrieve escalation or a session rebuild can
|
|
||||||
/// turn it off — re-query per AU, never cache across frames. `false` (the default) means
|
|
||||||
/// `poll_chunk` degrades to one whole-AU chunk per frame.
|
|
||||||
fn supports_chunked_poll(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
/// Pull the next slice-boundary chunk of the oldest in-flight AU (latency plan §7 LN1).
|
|
||||||
/// Semantics when chunking is live: BLOCKS until the next chunk is readable, and the final
|
|
||||||
/// (`last`) chunk blocks exactly like [`poll`](Self::poll) does — the depth-1 pump treats
|
|
||||||
/// `None` as re-poll-next-tick, so a non-blocking tail would ride the AU one tick late (the
|
|
||||||
/// `6dc195f9` Vulkan bug class). `Ok(None)` only when no AU is in flight. Each AU must be
|
|
||||||
/// drained through ONE method: calling `poll` on a partially-chunked AU is a caller bug (the
|
|
||||||
/// backend errors rather than double-emit bytes). Default: delegates to `poll`, wrapping the
|
|
||||||
/// whole AU as a single `first && last` chunk.
|
|
||||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
|
||||||
Ok(self.poll()?.map(AuChunk::whole))
|
|
||||||
}
|
|
||||||
/// 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
|
||||||
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||||
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||||
@@ -367,16 +293,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<()>;
|
||||||
@@ -496,23 +412,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole-AU chunk (every non-chunked backend's `poll_chunk` shape) must carry the AU's
|
|
||||||
/// metadata verbatim and be self-closing (`first && last`).
|
|
||||||
#[test]
|
|
||||||
fn whole_au_chunk_is_self_closing() {
|
|
||||||
let c = AuChunk::whole(EncodedFrame {
|
|
||||||
data: vec![0, 0, 0, 1, 0x40],
|
|
||||||
pts_ns: 42,
|
|
||||||
keyframe: true,
|
|
||||||
recovery_anchor: true,
|
|
||||||
chunk_aligned: false,
|
|
||||||
});
|
|
||||||
assert_eq!(c.data, vec![0, 0, 0, 1, 0x40]);
|
|
||||||
assert_eq!(c.pts_ns, 42);
|
|
||||||
assert!(c.keyframe && c.recovery_anchor && !c.chunk_aligned);
|
|
||||||
assert!(c.first && c.last);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
||||||
#[test]
|
#[test]
|
||||||
fn codec_wire_roundtrip_and_label() {
|
fn codec_wire_roundtrip_and_label() {
|
||||||
|
|||||||
@@ -826,7 +826,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 +839,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);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -865,13 +865,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(
|
||||||
@@ -1176,24 +1169,7 @@ 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 {
|
||||||
@@ -1219,12 +1195,6 @@ 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,
|
||||||
@@ -1239,10 +1209,6 @@ impl Encoder for PyroWaveEncoder {
|
|||||||
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 +1254,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);
|
||||||
|
|||||||
@@ -37,11 +37,9 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
|
|||||||
const AR24: u32 = 0x3432_5241; // ARGB8888
|
const AR24: u32 = 0x3432_5241; // ARGB8888
|
||||||
const XB24: u32 = 0x3432_4258; // XBGR8888
|
const XB24: u32 = 0x3432_4258; // XBGR8888
|
||||||
const AB24: u32 = 0x3432_4241; // ABGR8888
|
const AB24: u32 = 0x3432_4241; // ABGR8888
|
||||||
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
|
|
||||||
match fourcc {
|
match fourcc {
|
||||||
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
|
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
|
||||||
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
|
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
|
||||||
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,82 +77,39 @@ 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.
|
|
||||||
/// Despite the historical name, this also imports gamescope's one-fd LINEAR NV12: the UV
|
|
||||||
/// subresource layout comes from the producer's plane-1 chunk when it reported one, falling
|
|
||||||
/// back to the shared-stride contiguous-plane contract.
|
|
||||||
#[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;
|
||||||
let fmt = fourcc_to_vk(d.fourcc)
|
let fmt = fourcc_to_vk(d.fourcc)
|
||||||
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
||||||
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
|
let plane = [vk::SubresourceLayout::default()
|
||||||
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
|
.offset(d.offset as u64)
|
||||||
d.offset as u64 + d.stride as u64 * ch as u64,
|
.row_pitch(d.stride as u64)];
|
||||||
d.stride as u64,
|
|
||||||
));
|
|
||||||
vec![
|
|
||||||
vk::SubresourceLayout::default()
|
|
||||||
.offset(d.offset as u64)
|
|
||||||
.row_pitch(d.stride as u64),
|
|
||||||
vk::SubresourceLayout::default()
|
|
||||||
.offset(uv_offset)
|
|
||||||
.row_pitch(uv_stride),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
vec![vk::SubresourceLayout::default()
|
|
||||||
.offset(d.offset as u64)
|
|
||||||
.row_pitch(d.stride as u64)]
|
|
||||||
};
|
|
||||||
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
||||||
.drm_format_modifier(d.modifier)
|
.drm_format_modifier(d.modifier)
|
||||||
.plane_layouts(&planes);
|
.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 = {
|
||||||
@@ -228,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(
|
||||||
@@ -238,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
@@ -35,38 +35,6 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
|
|
||||||
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
|
|
||||||
/// escape**, needed now that a backend can default higher), else the backend's
|
|
||||||
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
|
|
||||||
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
|
|
||||||
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
|
|
||||||
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
|
|
||||||
/// never disagree about whether a session is multi-slice.
|
|
||||||
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
|
|
||||||
if !matches!(codec, Codec::H264 | Codec::H265) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
std::env::var("PUNKTFUNK_NVENC_SLICES")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.filter(|n| (1..=32).contains(n))
|
|
||||||
.unwrap_or(default_slices)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolved sub-frame readback (`enableSubFrameWrite` + `reportSliceOffsets`; sync sessions
|
|
||||||
/// only, see [`build_init_params`]): `PUNKTFUNK_NVENC_SUBFRAME` tri-state — `0` = never (the
|
|
||||||
/// default-on escape), `1` = force (even where the caps probe says unsupported — an operator
|
|
||||||
/// explicitly testing), unset = the backend's `default_on` (Linux direct-NVENC passes its
|
|
||||||
/// SUBFRAME_READBACK caps-probe result since Phase 3; Windows passes `false`).
|
|
||||||
pub(super) fn resolve_subframe(default_on: bool) -> bool {
|
|
||||||
match std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() {
|
|
||||||
Ok("0") => false,
|
|
||||||
Ok("1") => true,
|
|
||||||
_ => default_on,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
||||||
@@ -98,9 +66,6 @@ pub(super) struct LowLatencyConfig {
|
|||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
|
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
|
||||||
pub rfi_supported: bool,
|
pub rfi_supported: bool,
|
||||||
/// Resolved per-frame slice count ([`resolve_slices`] — env override, else the backend
|
|
||||||
/// default). ≤ 1 leaves the preset's single slice untouched.
|
|
||||||
pub slices: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
|
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
|
||||||
@@ -117,7 +82,6 @@ pub(super) fn build_init_params(
|
|||||||
cfg: &mut nv::NV_ENC_CONFIG,
|
cfg: &mut nv::NV_ENC_CONFIG,
|
||||||
split_mode: u32,
|
split_mode: u32,
|
||||||
enable_async: bool,
|
enable_async: bool,
|
||||||
subframe: bool,
|
|
||||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||||
@@ -137,16 +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; default-on for Linux direct-NVENC since Phase 3 —
|
|
||||||
// the caller resolves `subframe` via [`resolve_subframe`] + its caps probe): 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
|
|
||||||
// multi-slice (a single-slice frame yields nothing to read early). `reportSliceOffsets`
|
|
||||||
// requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
|
|
||||||
if !enable_async && subframe {
|
|
||||||
init.set_enableSubFrameWrite(1);
|
|
||||||
init.set_reportSliceOffsets(1);
|
|
||||||
}
|
|
||||||
init
|
init
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,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;
|
||||||
@@ -195,26 +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): `c.slices` 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 (the resolver already returns 1 there).
|
|
||||||
// Default 4 on Linux direct-NVENC (Phase 3), env-only elsewhere; ≤ 1 keeps the preset's
|
|
||||||
// single slice.
|
|
||||||
if let Some(n) = Some(c.slices).filter(|n| *n >= 2) {
|
|
||||||
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -37,8 +37,7 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::nvenc_core::{
|
use super::nvenc_core::{
|
||||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
|
||||||
};
|
};
|
||||||
use super::nvenc_status;
|
use super::nvenc_status;
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
@@ -410,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
|
||||||
@@ -512,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,
|
||||||
@@ -745,10 +737,6 @@ impl NvencD3d11Encoder {
|
|||||||
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
|
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
|
||||||
hdr: self.hdr,
|
hdr: self.hdr,
|
||||||
rfi_supported: self.rfi_supported,
|
rfi_supported: self.rfi_supported,
|
||||||
// Env-only on Windows (default single slice): the Phase-3 default-on is a
|
|
||||||
// Linux-direct-NVENC decision — the Windows async path stays untouched, and a
|
|
||||||
// Windows operator opting in must choose slices+sync over async retrieve.
|
|
||||||
slices: resolve_slices(self.codec, 1),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
@@ -796,9 +784,6 @@ impl NvencD3d11Encoder {
|
|||||||
&mut cfg,
|
&mut cfg,
|
||||||
split_mode,
|
split_mode,
|
||||||
enable_async,
|
enable_async,
|
||||||
// Windows: env opt-in only ("1"), never a default — and build_init_params
|
|
||||||
// additionally refuses it on an async session.
|
|
||||||
resolve_subframe(false),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||||
@@ -1150,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
|
||||||
@@ -1171,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
|
||||||
@@ -1361,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;
|
||||||
}
|
}
|
||||||
@@ -1576,7 +1528,6 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
&mut cfg,
|
&mut cfg,
|
||||||
self.split_mode,
|
self.split_mode,
|
||||||
self.session_async,
|
self.session_async,
|
||||||
resolve_subframe(false),
|
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ const BS_SLACK: usize = 256 * 1024;
|
|||||||
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
|
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
|
||||||
const IMPORT_CACHE_CAP: usize = 8;
|
const IMPORT_CACHE_CAP: usize = 8;
|
||||||
|
|
||||||
/// Plane-import cache key: the texture's COM address plus the extent it was imported at.
|
|
||||||
type PlaneKey = (isize, u32, u32);
|
|
||||||
|
|
||||||
// --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the
|
// --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the
|
||||||
// pyrowave C API are generated; these plain #define / flags-typedef values are stable spec
|
// pyrowave C API are generated; these plain #define / flags-typedef values are stable spec
|
||||||
// constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32
|
// constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32
|
||||||
@@ -139,12 +136,8 @@ pub struct PyroWaveEncoder {
|
|||||||
// Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot):
|
// Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot):
|
||||||
// the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar
|
// the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar
|
||||||
// NV12 import is unreliable on NVIDIA at arbitrary sizes).
|
// NV12 import is unreliable on NVIDIA at arbitrary sizes).
|
||||||
/// The capturer ring generation the cached plane imports below belong to. A recreate bumps it,
|
y_images: Vec<(isize, pw::pyrowave_image)>,
|
||||||
/// and every cached import is destroyed — the COM addresses they are keyed on can be recycled
|
cbcr_images: Vec<(isize, pw::pyrowave_image)>,
|
||||||
/// by the allocator after a recreate, so identity cannot rest on the pointer alone.
|
|
||||||
ring_gen: Option<u32>,
|
|
||||||
y_images: Vec<(PlaneKey, pw::pyrowave_image)>,
|
|
||||||
cbcr_images: Vec<(PlaneKey, pw::pyrowave_image)>,
|
|
||||||
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
@@ -275,7 +268,6 @@ impl PyroWaveEncoder {
|
|||||||
pw_dev,
|
pw_dev,
|
||||||
pw_enc,
|
pw_enc,
|
||||||
sync: std::ptr::null_mut(),
|
sync: std::ptr::null_mut(),
|
||||||
ring_gen: None,
|
|
||||||
y_images: Vec::new(),
|
y_images: Vec::new(),
|
||||||
cbcr_images: Vec::new(),
|
cbcr_images: Vec::new(),
|
||||||
width,
|
width,
|
||||||
@@ -359,16 +351,10 @@ impl PyroWaveEncoder {
|
|||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// Same contract as [`import_plane`].
|
/// Same contract as [`import_plane`].
|
||||||
/// Keyed on `(texture address, width, height)` rather than the bare address: the COM pointer
|
|
||||||
/// carries no reference here, so a released texture's address can be recycled by a later
|
|
||||||
/// allocation and return an import describing the WRONG surface. Folding the extent in means a
|
|
||||||
/// recycled address at a different size can never alias. (A recycle at the SAME size is still
|
|
||||||
/// possible in principle — the complete fix is to key on the capturer's ring generation, which
|
|
||||||
/// needs that generation plumbed onto `PyroFrameShare`.)
|
|
||||||
unsafe fn cached_plane(
|
unsafe fn cached_plane(
|
||||||
cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>,
|
cache: &mut Vec<(isize, pw::pyrowave_image)>,
|
||||||
make: impl FnOnce() -> Result<pw::pyrowave_image>,
|
make: impl FnOnce() -> Result<pw::pyrowave_image>,
|
||||||
key: PlaneKey,
|
key: isize,
|
||||||
) -> Result<pw::pyrowave_image> {
|
) -> Result<pw::pyrowave_image> {
|
||||||
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
|
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
|
||||||
return Ok(*img);
|
return Ok(*img);
|
||||||
@@ -431,27 +417,6 @@ impl PyroWaveEncoder {
|
|||||||
/// # Safety
|
/// # Safety
|
||||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||||
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 — fail cleanly rather than
|
|
||||||
// handing null to pyrowave (see the Linux twin).
|
|
||||||
anyhow::ensure!(
|
|
||||||
!self.pw_enc.is_null(),
|
|
||||||
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
|
|
||||||
);
|
|
||||||
// The plane textures are imported at the encoder's CONFIGURED extent, not the frame's, so a
|
|
||||||
// capture that changed size would be read under a stale `VkImageCreateInfo`. This is
|
|
||||||
// reachable without any client Reconfigure: the IDD capturer autonomously recreates its ring
|
|
||||||
// on a confirmed display-descriptor change (e.g. a fullscreen game mode-setting the virtual
|
|
||||||
// display). Refuse instead — the session must reopen the encoder at the new mode. Mirrors
|
|
||||||
// the guard the QSV and AMF backends already carry.
|
|
||||||
anyhow::ensure!(
|
|
||||||
frame.width == self.width && frame.height == self.height,
|
|
||||||
"pyrowave: captured frame {}x{} != encoder {}x{} (the capturer recreated its ring at a \
|
|
||||||
new mode — the encoder must be reopened)",
|
|
||||||
frame.width,
|
|
||||||
frame.height,
|
|
||||||
self.width,
|
|
||||||
self.height
|
|
||||||
);
|
|
||||||
let FramePayload::D3d11(d3d) = &frame.payload else {
|
let FramePayload::D3d11(d3d) = &frame.payload else {
|
||||||
bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)")
|
bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)")
|
||||||
};
|
};
|
||||||
@@ -460,25 +425,6 @@ impl PyroWaveEncoder {
|
|||||||
in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)",
|
in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Ring recreate ⇒ every cached plane import belongs to textures that no longer exist. Their
|
|
||||||
// COM addresses can be handed back out by the allocator, so a pointer-keyed hit could return
|
|
||||||
// an image bound to freed memory. Flush on the generation change rather than relying on the
|
|
||||||
// address (or the FIFO cap) to notice.
|
|
||||||
if self.ring_gen != Some(share.ring_gen) {
|
|
||||||
if self.ring_gen.is_some() {
|
|
||||||
tracing::info!(
|
|
||||||
from = ?self.ring_gen,
|
|
||||||
to = share.ring_gen,
|
|
||||||
cached = self.y_images.len() + self.cbcr_images.len(),
|
|
||||||
"pyrowave: capturer recreated its ring — flushing stale plane imports"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
|
|
||||||
pw::pyrowave_image_destroy(img);
|
|
||||||
}
|
|
||||||
self.ring_gen = Some(share.ring_gen);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh
|
// Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh
|
||||||
// encoder after a client mode-switch rebuild (the capturer passes the persistent handle on
|
// encoder after a client mode-switch rebuild (the capturer passes the persistent handle on
|
||||||
// every frame precisely so a rebuilt encoder can re-import it).
|
// every frame precisely so a rebuilt encoder can re-import it).
|
||||||
@@ -513,7 +459,7 @@ impl PyroWaveEncoder {
|
|||||||
};
|
};
|
||||||
let pw_dev = self.pw_dev;
|
let pw_dev = self.pw_dev;
|
||||||
let y_img = {
|
let y_img = {
|
||||||
let key = (d3d.texture.as_raw() as isize, w, h);
|
let key = d3d.texture.as_raw() as isize;
|
||||||
let tex = &d3d.texture;
|
let tex = &d3d.texture;
|
||||||
Self::cached_plane(
|
Self::cached_plane(
|
||||||
&mut self.y_images,
|
&mut self.y_images,
|
||||||
@@ -522,7 +468,7 @@ impl PyroWaveEncoder {
|
|||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
let cbcr_img = {
|
let cbcr_img = {
|
||||||
let key = (share.cbcr.as_raw() as isize, cw, ch);
|
let key = share.cbcr.as_raw() as isize;
|
||||||
let tex = &share.cbcr;
|
let tex = &share.cbcr;
|
||||||
Self::cached_plane(
|
Self::cached_plane(
|
||||||
&mut self.cbcr_images,
|
&mut self.cbcr_images,
|
||||||
@@ -693,10 +639,6 @@ impl Encoder for PyroWaveEncoder {
|
|||||||
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
|
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
|
||||||
unsafe {
|
unsafe {
|
||||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||||
// Publish the null IMMEDIATELY — see the Linux twin. The create below is fallible and
|
|
||||||
// `pyrowave_encoder_destroy` is a plain `delete` with no null check, so leaving the
|
|
||||||
// freed pointer in the field makes `Drop` a double free.
|
|
||||||
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,
|
||||||
@@ -713,8 +655,6 @@ impl Encoder for PyroWaveEncoder {
|
|||||||
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.
|
|
||||||
self.pending.clear();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.pw_enc = enc;
|
self.pw_enc = enc;
|
||||||
@@ -758,11 +698,7 @@ impl Drop for PyroWaveEncoder {
|
|||||||
// SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go
|
// SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go
|
||||||
// before the device they borrow (per pyrowave.h).
|
// before the device they borrow (per pyrowave.h).
|
||||||
unsafe {
|
unsafe {
|
||||||
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
|
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||||
// is not null-safe (same guard `sync` below has had all along).
|
|
||||||
if !self.pw_enc.is_null() {
|
|
||||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
|
||||||
}
|
|
||||||
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
|
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
|
||||||
pw::pyrowave_image_destroy(img);
|
pw::pyrowave_image_destroy(img);
|
||||||
}
|
}
|
||||||
@@ -1024,9 +960,6 @@ mod tests {
|
|||||||
cbcr: cbcr_tex,
|
cbcr: cbcr_tex,
|
||||||
fence_handle: Some(fence_handle.0 as isize),
|
fence_handle: Some(fence_handle.0 as isize),
|
||||||
fence_value: 1,
|
fence_value: 1,
|
||||||
// One synthetic ring for the whole case: a constant generation exercises the
|
|
||||||
// steady-state cache-hit path (a changing one would flush every frame).
|
|
||||||
ring_gen: 1,
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -478,14 +464,10 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
|||||||
b
|
b
|
||||||
});
|
});
|
||||||
|
|
||||||
// Colour signalling, written UNCONDITIONALLY (mirrors nvenc_core.rs): the input is already
|
// HDR signalling (10-bit sessions are the HDR path on Windows — same coupling as NVENC):
|
||||||
// CSC'd to a specific matrix — BT.709 limited for SDR (the capture-side VideoConverter),
|
// BT.2020/PQ colour description + the source's mastering/CLL grade at every IDR.
|
||||||
// BT.2020 PQ for HDR (HdrP010Converter) — so the stream must say so. An SDR stream without a
|
|
||||||
// colour description leaves the choice to the decoder's "unspecified" default, and
|
|
||||||
// Moonlight/third-party/Android-vendor decoders default to 601 at sub-HD → mis-rendered
|
|
||||||
// colours. (10-bit sessions are the HDR path on Windows — same coupling as NVENC.)
|
|
||||||
let hdr = cfg.ten_bit && cfg.codec != Codec::H264;
|
let hdr = cfg.ten_bit && cfg.codec != Codec::H264;
|
||||||
let vsi = {
|
let vsi = hdr.then(|| {
|
||||||
// SAFETY: all-zero is valid; header stamped below.
|
// SAFETY: all-zero is valid; header stamped below.
|
||||||
let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() });
|
let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() });
|
||||||
b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32;
|
b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32;
|
||||||
@@ -493,17 +475,11 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
|||||||
b.VideoFormat = 5; // unspecified
|
b.VideoFormat = 5; // unspecified
|
||||||
b.VideoFullRange = 0;
|
b.VideoFullRange = 0;
|
||||||
b.ColourDescriptionPresent = 1;
|
b.ColourDescriptionPresent = 1;
|
||||||
if hdr {
|
b.ColourPrimaries = 9; // BT.2020
|
||||||
b.ColourPrimaries = 9; // BT.2020
|
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
|
||||||
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
|
b.MatrixCoefficients = 9; // BT.2020 non-constant
|
||||||
b.MatrixCoefficients = 9; // BT.2020 non-constant
|
b
|
||||||
} else {
|
});
|
||||||
b.ColourPrimaries = 1; // BT.709
|
|
||||||
b.TransferCharacteristics = 1; // BT.709
|
|
||||||
b.MatrixCoefficients = 1; // BT.709
|
|
||||||
}
|
|
||||||
Some(b)
|
|
||||||
};
|
|
||||||
let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| {
|
let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| {
|
||||||
// SAFETY: all-zero is valid; header stamped below.
|
// SAFETY: all-zero is valid; header stamped below.
|
||||||
let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> =
|
let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> =
|
||||||
@@ -724,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.
|
||||||
@@ -808,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,
|
||||||
@@ -933,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;
|
||||||
@@ -1052,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) {
|
||||||
@@ -1071,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.
|
||||||
@@ -1178,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
|
||||||
@@ -1196,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();
|
||||||
@@ -1344,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));
|
||||||
@@ -1387,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,
|
||||||
@@ -1469,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) {
|
||||||
@@ -1491,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() {
|
||||||
@@ -1955,36 +1845,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]
|
||||||
@@ -2004,246 +1864,4 @@ mod tests {
|
|||||||
"the bitrate retarget emitted a keyframe (StartNewSequence leak)"
|
"the bitrate retarget emitted a keyframe (StartNewSequence leak)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FULL-CHAIN colour check at the field capture size: a known P010 colour-bar source at
|
|
||||||
/// 1920x1080 — whose height is NOT 16-aligned, so the ingest `CopySubresourceRegion` copies
|
|
||||||
/// into a 1920x1088 runtime pool surface whose chroma plane sits at a DIFFERENT row offset
|
|
||||||
/// than the source's (the seam no 640x480 test exercises) — encoded to Main10 HEVC and
|
|
||||||
/// dumped to `%TEMP%\pf_qsv_1080_bars.h265` for off-box decode verification against the
|
|
||||||
/// same codes. On-box this asserts stream shape; the pixel verdict needs a decoder.
|
|
||||||
#[test]
|
|
||||||
fn qsv_live_p010_1080_colorbars_dump() {
|
|
||||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
|
||||||
D3D11CreateDevice, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
|
||||||
D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_USAGE_DEFAULT,
|
|
||||||
};
|
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_P010, DXGI_SAMPLE_DESC};
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
|
||||||
|
|
||||||
// (Y, Cb, Cr) 10-bit limited codes for the 8 sRGB bars white/yellow/cyan/green/magenta/
|
|
||||||
// red/blue/black at 80-nit SDR white under PQ/BT.2020 — the same math as pf-capture's
|
|
||||||
// `p010_reference` (and the bars_pq2020 client fixture). Stored MSB-aligned (`<<6`).
|
|
||||||
const BARS: [(u16, u16, u16); 8] = [
|
|
||||||
(490, 512, 512),
|
|
||||||
(478, 423, 518),
|
|
||||||
(464, 525, 473),
|
|
||||||
(450, 432, 476),
|
|
||||||
(350, 584, 585),
|
|
||||||
(325, 448, 598),
|
|
||||||
(226, 650, 535),
|
|
||||||
(64, 512, 512),
|
|
||||||
];
|
|
||||||
const W: u32 = 1920;
|
|
||||||
const H: u32 = 1080;
|
|
||||||
|
|
||||||
init_tracing();
|
|
||||||
let Ok((_l, impls)) = intel_loader() else {
|
|
||||||
eprintln!("skipping: no VPL loader");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
|
|
||||||
eprintln!("skipping: no Intel VPL implementation on this box");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !probe_can_encode_10bit(Codec::H265) {
|
|
||||||
eprintln!("skipping: this GPU declines 10-bit HEVC");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// P010 initial data: plane 0 = H rows of W u16 luma; plane 1 = H/2 rows of W u16
|
|
||||||
// (interleaved Cb,Cr pairs), same pitch. Bars are vertical: bar index = x / (W/8).
|
|
||||||
let bar_w = (W / 8) as usize;
|
|
||||||
let mut init = vec![0u16; (W as usize) * (H as usize + H as usize / 2)];
|
|
||||||
for y in 0..H as usize {
|
|
||||||
for x in 0..W as usize {
|
|
||||||
init[y * W as usize + x] = BARS[(x / bar_w).min(7)].0 << 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let chroma_base = (W as usize) * (H as usize);
|
|
||||||
for cy in 0..(H as usize / 2) {
|
|
||||||
for cx in 0..(W as usize / 2) {
|
|
||||||
let (_, cb, cr) = BARS[((cx * 2) / bar_w).min(7)];
|
|
||||||
init[chroma_base + cy * W as usize + cx * 2] = cb << 6;
|
|
||||||
init[chroma_base + cy * W as usize + cx * 2 + 1] = cr << 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: self-contained harness on one thread/device (same contract as `drive_live`);
|
|
||||||
// the initial-data pointer outlives the synchronous CreateTexture2D that reads it.
|
|
||||||
let (device, tex) = unsafe {
|
|
||||||
let luid = windows::Win32::Foundation::LUID {
|
|
||||||
LowPart: u32::from_le_bytes(imp.luid[..4].try_into().unwrap()),
|
|
||||||
HighPart: i32::from_le_bytes(imp.luid[4..].try_into().unwrap()),
|
|
||||||
};
|
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().expect("dxgi factory");
|
|
||||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).expect("intel adapter");
|
|
||||||
let mut device = None;
|
|
||||||
D3D11CreateDevice(
|
|
||||||
&adapter,
|
|
||||||
D3D_DRIVER_TYPE_UNKNOWN,
|
|
||||||
windows::Win32::Foundation::HMODULE::default(),
|
|
||||||
Default::default(),
|
|
||||||
None,
|
|
||||||
D3D11_SDK_VERSION,
|
|
||||||
Some(&mut device),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("d3d11 device on intel adapter");
|
|
||||||
let device: ID3D11Device = device.expect("device");
|
|
||||||
let desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: W,
|
|
||||||
Height: H,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_P010,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
|
||||||
CPUAccessFlags: 0,
|
|
||||||
MiscFlags: 0,
|
|
||||||
};
|
|
||||||
let data = D3D11_SUBRESOURCE_DATA {
|
|
||||||
pSysMem: init.as_ptr() as *const std::ffi::c_void,
|
|
||||||
SysMemPitch: W * 2,
|
|
||||||
SysMemSlicePitch: 0,
|
|
||||||
};
|
|
||||||
let mut t: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&desc, Some(&data), Some(&mut t))
|
|
||||||
.expect("bar texture");
|
|
||||||
(device.clone(), t.expect("texture"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut enc = QsvEncoder::open(
|
|
||||||
Codec::H265,
|
|
||||||
PixelFormat::P010,
|
|
||||||
W,
|
|
||||||
H,
|
|
||||||
30,
|
|
||||||
10_000_000,
|
|
||||||
10,
|
|
||||||
ChromaFormat::Yuv420,
|
|
||||||
)
|
|
||||||
.expect("open");
|
|
||||||
enc.set_hdr_meta(Some(test_hdr_meta()));
|
|
||||||
let mut stream = Vec::new();
|
|
||||||
let mut aus = 0usize;
|
|
||||||
let mut keyframes = 0usize;
|
|
||||||
for i in 0..12u32 {
|
|
||||||
let frame = CapturedFrame {
|
|
||||||
width: W,
|
|
||||||
height: H,
|
|
||||||
pts_ns: i as u64 * 33_333_333,
|
|
||||||
format: PixelFormat::P010,
|
|
||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
|
||||||
texture: tex.clone(),
|
|
||||||
device: device.clone(),
|
|
||||||
pyro: None,
|
|
||||||
}),
|
|
||||||
cursor: None,
|
|
||||||
};
|
|
||||||
enc.submit_indexed(&frame, i).expect("submit");
|
|
||||||
if let Some(au) = enc.poll().expect("poll") {
|
|
||||||
aus += 1;
|
|
||||||
keyframes += au.keyframe as usize;
|
|
||||||
stream.extend_from_slice(&au.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enc.flush().expect("flush");
|
|
||||||
while let Some(au) = enc.poll().expect("drain") {
|
|
||||||
aus += 1;
|
|
||||||
keyframes += au.keyframe as usize;
|
|
||||||
stream.extend_from_slice(&au.data);
|
|
||||||
}
|
|
||||||
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
|
|
||||||
assert!(keyframes >= 1, "expected an IDR in the dump");
|
|
||||||
let path = std::env::temp_dir().join("pf_qsv_1080_bars.h265");
|
|
||||||
std::fs::write(&path, &stream).expect("write dump");
|
|
||||||
println!(
|
|
||||||
"wrote {} AUs ({} bytes, {keyframes} keyframes) to {}",
|
|
||||||
aus,
|
|
||||||
stream.len(),
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The PRODUCTION host chain minus the IDD ring: the REAL `HdrP010Converter` renders the 8
|
|
||||||
/// sRGB bars into a ring-profile P010 texture (`BIND_RENDER_TARGET` only — RTV-written, not
|
|
||||||
/// CPU-uploaded) on the VPL implementation's own adapter, and THAT texture goes through the
|
|
||||||
/// unaligned-height ingest copy into a Main10 encode. Dumped to
|
|
||||||
/// `%TEMP%\pf_qsv_conv_1080_bars.h265`; expected decode codes = the bars_pq2020 fixture set
|
|
||||||
/// (see `hdr_p010_convert_bars_on_luid`).
|
|
||||||
#[test]
|
|
||||||
fn qsv_live_hdr_converter_e2e_1080_dump() {
|
|
||||||
const W: u32 = 1920;
|
|
||||||
const H: u32 = 1080;
|
|
||||||
|
|
||||||
init_tracing();
|
|
||||||
let Ok((_l, impls)) = intel_loader() else {
|
|
||||||
eprintln!("skipping: no VPL loader");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
|
|
||||||
eprintln!("skipping: no Intel VPL implementation on this box");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !probe_can_encode_10bit(Codec::H265) {
|
|
||||||
eprintln!("skipping: this GPU declines 10-bit HEVC");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (device, tex) = pf_capture::dxgi::hdr_p010_convert_bars_on_luid(imp.luid, W, H)
|
|
||||||
.expect("converter bars");
|
|
||||||
|
|
||||||
let mut enc = QsvEncoder::open(
|
|
||||||
Codec::H265,
|
|
||||||
PixelFormat::P010,
|
|
||||||
W,
|
|
||||||
H,
|
|
||||||
30,
|
|
||||||
10_000_000,
|
|
||||||
10,
|
|
||||||
ChromaFormat::Yuv420,
|
|
||||||
)
|
|
||||||
.expect("open");
|
|
||||||
enc.set_hdr_meta(Some(test_hdr_meta()));
|
|
||||||
let mut stream = Vec::new();
|
|
||||||
let mut aus = 0usize;
|
|
||||||
for i in 0..12u32 {
|
|
||||||
let frame = CapturedFrame {
|
|
||||||
width: W,
|
|
||||||
height: H,
|
|
||||||
pts_ns: i as u64 * 33_333_333,
|
|
||||||
format: PixelFormat::P010,
|
|
||||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
|
||||||
texture: tex.clone(),
|
|
||||||
device: device.clone(),
|
|
||||||
pyro: None,
|
|
||||||
}),
|
|
||||||
cursor: None,
|
|
||||||
};
|
|
||||||
enc.submit_indexed(&frame, i).expect("submit");
|
|
||||||
if let Some(au) = enc.poll().expect("poll") {
|
|
||||||
aus += 1;
|
|
||||||
stream.extend_from_slice(&au.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enc.flush().expect("flush");
|
|
||||||
while let Some(au) = enc.poll().expect("drain") {
|
|
||||||
aus += 1;
|
|
||||||
stream.extend_from_slice(&au.data);
|
|
||||||
}
|
|
||||||
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
|
|
||||||
let path = std::env::temp_dir().join("pf_qsv_conv_1080_bars.h265");
|
|
||||||
std::fs::write(&path, &stream).expect("write dump");
|
|
||||||
println!(
|
|
||||||
"wrote {aus} AUs ({} bytes) to {}",
|
|
||||||
stream.len(),
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-125
@@ -129,9 +129,6 @@ pub fn open_video(
|
|||||||
cuda: bool,
|
cuda: bool,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
|
|
||||||
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
|
|
||||||
cursor_blend: bool,
|
|
||||||
) -> Result<Box<dyn Encoder>> {
|
) -> Result<Box<dyn Encoder>> {
|
||||||
let (inner, backend) = open_video_backend(
|
let (inner, backend) = open_video_backend(
|
||||||
codec,
|
codec,
|
||||||
@@ -143,7 +140,6 @@ pub fn open_video(
|
|||||||
cuda,
|
cuda,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
chroma,
|
chroma,
|
||||||
cursor_blend,
|
|
||||||
)?;
|
)?;
|
||||||
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
||||||
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
||||||
@@ -207,34 +203,15 @@ impl Encoder for TrackedEncoder {
|
|||||||
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 {
|
||||||
self.inner.invalidate_ref_frames(first_frame, last_frame)
|
self.inner.invalidate_ref_frames(first_frame, last_frame)
|
||||||
}
|
}
|
||||||
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
|
|
||||||
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
|
|
||||||
// escalation for every session, since the host loop only ever holds the wrapped box.
|
|
||||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
|
||||||
self.inner.set_pipelined(on)
|
|
||||||
}
|
|
||||||
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
|
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
|
||||||
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
|
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
|
||||||
// §4.4 chunking probe run hit the default while the plan said Some(1408)).
|
// §4.4 chunking probe run hit the default while the plan said Some(1408)).
|
||||||
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()
|
||||||
}
|
}
|
||||||
// Both chunked-poll methods forwarded (the same trap class): the defaults would report
|
|
||||||
// "not chunked" and wrap whole AUs, silently discarding the sub-frame overlap.
|
|
||||||
fn supports_chunked_poll(&self) -> bool {
|
|
||||||
self.inner.supports_chunked_poll()
|
|
||||||
}
|
|
||||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
|
||||||
self.inner.poll_chunk()
|
|
||||||
}
|
|
||||||
fn reset(&mut self) -> bool {
|
fn reset(&mut self) -> bool {
|
||||||
self.inner.reset()
|
self.inner.reset()
|
||||||
}
|
}
|
||||||
@@ -246,13 +223,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
|
||||||
@@ -268,9 +238,7 @@ fn open_video_backend(
|
|||||||
cuda: bool,
|
cuda: bool,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
cursor_blend: bool,
|
|
||||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||||
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below
|
|
||||||
validate_dimensions(codec, width, height)?;
|
validate_dimensions(codec, width, height)?;
|
||||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||||
@@ -328,15 +296,8 @@ fn open_video_backend(
|
|||||||
&& vulkan_encode_enabled()
|
&& vulkan_encode_enabled()
|
||||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||||
{
|
{
|
||||||
match vulkan_video::VulkanVideoEncoder::open(
|
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||||
codec,
|
{
|
||||||
format,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fps,
|
|
||||||
bitrate_bps,
|
|
||||||
cursor_blend,
|
|
||||||
) {
|
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
codec = ?codec,
|
codec = ?codec,
|
||||||
@@ -345,32 +306,12 @@ fn open_video_backend(
|
|||||||
);
|
);
|
||||||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||||||
}
|
}
|
||||||
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
|
|
||||||
// libav's dmabuf lane would import the two-plane buffer as packed RGB
|
|
||||||
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
|
|
||||||
Err(e) if format == PixelFormat::Nv12 => {
|
|
||||||
return Err(e.context(
|
|
||||||
"Vulkan Video open failed on a native-NV12 capture \
|
|
||||||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
|
||||||
restore the packed-RGB negotiation",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
error = %format!("{e:#}"),
|
error = %format!("{e:#}"),
|
||||||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Same rule when the Vulkan backend was never eligible (H264 session,
|
|
||||||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
|
||||||
if format == PixelFormat::Nv12 {
|
|
||||||
anyhow::bail!(
|
|
||||||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
|
||||||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
|
||||||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
|
||||||
the packed-RGB negotiation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
vaapi::VaapiEncoder::open(
|
vaapi::VaapiEncoder::open(
|
||||||
codec,
|
codec,
|
||||||
format,
|
format,
|
||||||
@@ -409,16 +350,8 @@ fn open_video_backend(
|
|||||||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
vulkan_video::VulkanVideoEncoder::open(
|
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||||
codec,
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||||||
format,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fps,
|
|
||||||
bitrate_bps,
|
|
||||||
cursor_blend,
|
|
||||||
)
|
|
||||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "vulkan-encode"))]
|
#[cfg(not(feature = "vulkan-encode"))]
|
||||||
{
|
{
|
||||||
@@ -474,14 +407,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
|
||||||
@@ -683,6 +610,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,
|
||||||
@@ -841,33 +770,6 @@ fn vulkan_encode_enabled() -> bool {
|
|||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
|
|
||||||
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
|
|
||||||
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
|
|
||||||
/// the backend must be eligible to open. The host facade threads the verdict into the capture
|
|
||||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
|
||||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
|
||||||
/// escapes at the capture gate).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
|
||||||
#[cfg(feature = "vulkan-encode")]
|
|
||||||
{
|
|
||||||
matches!(codec, Codec::H265 | Codec::Av1)
|
|
||||||
&& vulkan_encode_enabled()
|
|
||||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
|
||||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
|
||||||
&& !matches!(
|
|
||||||
pf_host_config::config().encoder_pref.as_str(),
|
|
||||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "vulkan-encode"))]
|
|
||||||
{
|
|
||||||
let _ = codec;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||||
@@ -882,12 +784,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() {
|
||||||
@@ -1101,13 +997,7 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
|||||||
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
|
// 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
|
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
|
||||||
// honor), since this probe can't know what the compositor will negotiate.
|
// honor), since this probe can't know what the compositor will negotiate.
|
||||||
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
|
if linux_auto_is_vaapi() {
|
||||||
// `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)
|
vaapi::probe_can_encode_10bit(codec)
|
||||||
} else {
|
} else {
|
||||||
linux::probe_can_encode_10bit(codec)
|
linux::probe_can_encode_10bit(codec)
|
||||||
@@ -1425,12 +1315,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(
|
||||||
|
|||||||
@@ -52,12 +52,6 @@ pub struct PyroFrameShare {
|
|||||||
/// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan
|
/// 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.
|
/// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC.
|
||||||
pub fence_value: u64,
|
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;
|
/// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place;
|
||||||
|
|||||||
+10
-31
@@ -105,15 +105,13 @@ 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
|
||||||
// Linux native NV12 capture (gamescope PipeWire): one LINEAR dmabuf with contiguous Y then
|
|
||||||
// interleaved UV, exposed under DRM_FORMAT_NV12.
|
|
||||||
Nv12 => drm_fourcc_code(b"NV12"),
|
|
||||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
|
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
|
||||||
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
|
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
|
||||||
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
|
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/P010 are Windows formats; Yuv444 is OUR convert output, never a capture source.
|
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
|
||||||
Rgb | Bgr | Rgb10a2 | P010 | Yuv444 => return None,
|
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
|
||||||
|
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +144,6 @@ pub struct OutputFormat {
|
|||||||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
/// (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).
|
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||||||
pub pyrowave: bool,
|
pub pyrowave: bool,
|
||||||
/// THIS session's encoder can ingest a producer-native NV12 capture (Linux raw Vulkan Video
|
|
||||||
/// backend on an H265/AV1 session — see `pf_encode::linux_native_nv12_ok`). The Linux capture
|
|
||||||
/// negotiation only offers gamescope the NV12 pod when this is set: libav VAAPI (the H264
|
|
||||||
/// codec's backend, and the fallback family) would misread the two-plane buffer as packed
|
|
||||||
/// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats).
|
|
||||||
pub nv12_native: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutputFormat {
|
impl OutputFormat {
|
||||||
@@ -169,11 +161,6 @@ impl OutputFormat {
|
|||||||
chroma_444: false,
|
chroma_444: false,
|
||||||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||||||
pyrowave: false,
|
pyrowave: false,
|
||||||
// Conservative: the GameStream + spike paths don't resolve the codec here, and a
|
|
||||||
// Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so
|
|
||||||
// they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via
|
|
||||||
// `SessionPlan::output_format()`, which knows the codec.
|
|
||||||
nv12_native: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,26 +201,18 @@ pub struct CapturedFrame {
|
|||||||
pub cursor: Option<CursorOverlay>,
|
pub cursor: Option<CursorOverlay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
|
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
|
||||||
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
|
|
||||||
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
|
|
||||||
/// fallback `offset + stride * frame_height` with the shared `stride`.
|
|
||||||
///
|
|
||||||
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||||||
/// imported there without the compositor's buffer being closed underneath it. Content stability
|
/// imported into a VA surface there without the compositor's buffer being closed underneath it.
|
||||||
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
|
/// (Content stability across the brief import window relies on the compositor's buffer pool depth,
|
||||||
/// capture.
|
/// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.)
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub struct DmabufFrame {
|
pub struct DmabufFrame {
|
||||||
pub fd: std::os::fd::OwnedFd,
|
pub fd: std::os::fd::OwnedFd,
|
||||||
/// DRM FourCC (`XR24` for BGRx, `NV12` for native 4:2:0).
|
/// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx).
|
||||||
pub fourcc: u32,
|
pub fourcc: u32,
|
||||||
/// DRM format modifier the compositor allocated (0 = LINEAR).
|
/// DRM format modifier the compositor allocated (0 = LINEAR).
|
||||||
pub modifier: u64,
|
pub modifier: u64,
|
||||||
/// Second-plane `(offset, stride)` within the SAME fd, when the producer reported one (the
|
|
||||||
/// PipeWire buffer's plane-1 chunk — NV12's interleaved UV). `None` falls back to the
|
|
||||||
/// contiguous-plane contract above. Always `None` for single-plane packed RGB.
|
|
||||||
pub plane1: Option<(u32, u32)>,
|
|
||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
pub stride: u32,
|
pub stride: u32,
|
||||||
}
|
}
|
||||||
@@ -246,8 +225,8 @@ pub enum FramePayload {
|
|||||||
/// The dmabuf has already been imported + copied into this owned device buffer.
|
/// The dmabuf has already been imported + copied into this owned device buffer.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Cuda(pf_zerocopy::DeviceBuffer),
|
Cuda(pf_zerocopy::DeviceBuffer),
|
||||||
/// A raw DMA-BUF: packed RGB for the existing GPU CSC paths, or native NV12 from a producer
|
/// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into
|
||||||
/// such as gamescope. The encoder imports it without a host copy.
|
/// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Dmabuf(DmabufFrame),
|
Dmabuf(DmabufFrame),
|
||||||
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
|
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::mem::size_of;
|
use std::mem::size_of;
|
||||||
use std::os::fd::RawFd;
|
use std::os::fd::RawFd;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
@@ -196,45 +196,6 @@ impl Drop for GadgetFd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The signal used to break a worker thread out of a blocking raw_gadget ioctl at teardown.
|
|
||||||
/// `EVENT_FETCH`/`EP_WRITE` are `wait_event_interruptible` in the kernel with no timeout and no
|
|
||||||
/// `O_NONBLOCK` honouring, and closing the fd cannot wake a thread already inside the ioctl (the
|
|
||||||
/// in-flight syscall holds a reference to the struct file). A signal is the only reliable lever:
|
|
||||||
/// delivered with a no-op, non-`SA_RESTART` handler it forces the ioctl to return `EINTR`, after
|
|
||||||
/// which the loop's top-of-iteration `running` check exits. `SIGUSR1` is unused elsewhere in this
|
|
||||||
/// process; the handler is a no-op, so a stray `SIGUSR1` becomes harmless rather than fatal.
|
|
||||||
const WAKE_SIGNAL: libc::c_int = libc::SIGUSR1;
|
|
||||||
|
|
||||||
/// Install the no-op `WAKE_SIGNAL` handler exactly once. Crucially `sa_flags = 0` (no `SA_RESTART`)
|
|
||||||
/// so a delivered signal makes the interruptible ioctl return `EINTR` instead of auto-restarting.
|
|
||||||
fn install_wake_handler() {
|
|
||||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
|
||||||
ONCE.call_once(|| {
|
|
||||||
extern "C" fn noop(_: libc::c_int) {}
|
|
||||||
// SAFETY: installing a well-formed `sigaction` with an empty mask and a valid no-op handler
|
|
||||||
// for a single signal; touches only this process's disposition for `WAKE_SIGNAL`.
|
|
||||||
unsafe {
|
|
||||||
let mut sa: libc::sigaction = std::mem::zeroed();
|
|
||||||
// Via `*const ()`: casting a function item straight to an integer is what
|
|
||||||
// `clippy::function_casts_as_integer` rejects, and the pointer hop is the documented
|
|
||||||
// way to spell it. `sa_sigaction` is a `usize`-typed handler slot, so the value is
|
|
||||||
// unchanged.
|
|
||||||
sa.sa_sigaction = noop as *const () as usize;
|
|
||||||
libc::sigemptyset(&mut sa.sa_mask);
|
|
||||||
sa.sa_flags = 0;
|
|
||||||
libc::sigaction(WAKE_SIGNAL, &sa, std::ptr::null_mut());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lets `Drop` wake a specific worker thread parked in a blocking ioctl. `tid` is the thread's
|
|
||||||
/// `pthread_self()` (0 until it starts); `done` is set right before the thread returns, so `Drop`
|
|
||||||
/// stops signalling a thread that has already exited.
|
|
||||||
struct Waker {
|
|
||||||
tid: Arc<AtomicU64>,
|
|
||||||
done: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
|
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
|
||||||
/// closes the gadget (the kernel tears down the device).
|
/// closes the gadget (the kernel tears down the device).
|
||||||
pub struct SteamDeckGadget {
|
pub struct SteamDeckGadget {
|
||||||
@@ -242,7 +203,6 @@ pub struct SteamDeckGadget {
|
|||||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
threads: Vec<JoinHandle<()>>,
|
threads: Vec<JoinHandle<()>>,
|
||||||
wakers: Vec<Waker>,
|
|
||||||
_fd: Arc<GadgetFd>,
|
_fd: Arc<GadgetFd>,
|
||||||
seq: u32,
|
seq: u32,
|
||||||
}
|
}
|
||||||
@@ -283,18 +243,6 @@ impl SteamDeckGadget {
|
|||||||
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
|
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
|
||||||
let configured = Arc::new(AtomicBool::new(false));
|
let configured = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
// The teardown wake path (see `WAKE_SIGNAL`) needs the handler installed before any thread
|
|
||||||
// can park in a blocking ioctl.
|
|
||||||
install_wake_handler();
|
|
||||||
let ctrl_waker = Waker {
|
|
||||||
tid: Arc::new(AtomicU64::new(0)),
|
|
||||||
done: Arc::new(AtomicBool::new(false)),
|
|
||||||
};
|
|
||||||
let stream_waker = Waker {
|
|
||||||
tid: Arc::new(AtomicU64::new(0)),
|
|
||||||
done: Arc::new(AtomicBool::new(false)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Control thread: enumerate + answer every control transfer.
|
// Control thread: enumerate + answer every control transfer.
|
||||||
let control = {
|
let control = {
|
||||||
let fd = fd.clone();
|
let fd = fd.clone();
|
||||||
@@ -302,15 +250,10 @@ impl SteamDeckGadget {
|
|||||||
let ctrl_ep = ctrl_ep.clone();
|
let ctrl_ep = ctrl_ep.clone();
|
||||||
let configured = configured.clone();
|
let configured = configured.clone();
|
||||||
let feedback = feedback.clone();
|
let feedback = feedback.clone();
|
||||||
let tid = ctrl_waker.tid.clone();
|
|
||||||
let done = ctrl_waker.done.clone();
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-deck-gadget-ctrl".into())
|
.name("pf-deck-gadget-ctrl".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// SAFETY: `pthread_self` is always valid on the calling thread.
|
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
|
||||||
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
|
|
||||||
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id);
|
|
||||||
done.store(true, Ordering::SeqCst);
|
|
||||||
})
|
})
|
||||||
.context("spawn gadget control thread")?
|
.context("spawn gadget control thread")?
|
||||||
};
|
};
|
||||||
@@ -321,16 +264,9 @@ impl SteamDeckGadget {
|
|||||||
let ctrl_ep = ctrl_ep.clone();
|
let ctrl_ep = ctrl_ep.clone();
|
||||||
let configured = configured.clone();
|
let configured = configured.clone();
|
||||||
let report = report.clone();
|
let report = report.clone();
|
||||||
let tid = stream_waker.tid.clone();
|
|
||||||
let done = stream_waker.done.clone();
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-deck-gadget-stream".into())
|
.name("pf-deck-gadget-stream".into())
|
||||||
.spawn(move || {
|
.spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
|
||||||
// SAFETY: `pthread_self` is always valid on the calling thread.
|
|
||||||
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
|
|
||||||
stream_loop(fd, running, ctrl_ep, configured, report);
|
|
||||||
done.store(true, Ordering::SeqCst);
|
|
||||||
})
|
|
||||||
.context("spawn gadget stream thread")?
|
.context("spawn gadget stream thread")?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,7 +275,6 @@ impl SteamDeckGadget {
|
|||||||
feedback,
|
feedback,
|
||||||
running,
|
running,
|
||||||
threads: vec![control, stream],
|
threads: vec![control, stream],
|
||||||
wakers: vec![ctrl_waker, stream_waker],
|
|
||||||
_fd: fd,
|
_fd: fd,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
})
|
})
|
||||||
@@ -367,32 +302,6 @@ impl SteamDeckGadget {
|
|||||||
impl Drop for SteamDeckGadget {
|
impl Drop for SteamDeckGadget {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.running.store(false, Ordering::SeqCst);
|
self.running.store(false, Ordering::SeqCst);
|
||||||
// The control thread spends steady state parked in a blocking `EVENT_FETCH` ioctl that only
|
|
||||||
// tests `running` at the top of its loop, so clearing the flag is not enough — it must be
|
|
||||||
// signalled out of the syscall (see `WAKE_SIGNAL`). Without this the join below can hang the
|
|
||||||
// caller (the session input thread, via `PadSlots::sweep`) indefinitely. Retry until each
|
|
||||||
// thread reports done, to cover the race where the signal lands just before the thread
|
|
||||||
// re-enters the ioctl; bounded (~1 s) so a genuinely stuck thread can't wedge teardown either.
|
|
||||||
for _ in 0..200 {
|
|
||||||
let mut all_done = true;
|
|
||||||
for w in &self.wakers {
|
|
||||||
if w.done.load(Ordering::SeqCst) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
all_done = false;
|
|
||||||
let tid = w.tid.load(Ordering::SeqCst);
|
|
||||||
if tid != 0 {
|
|
||||||
// SAFETY: the thread is joinable and not yet joined (join runs after this loop),
|
|
||||||
// so `tid` names a live pthread; `pthread_kill` on a finished-but-unjoined thread
|
|
||||||
// is defined (returns ESRCH), never UB.
|
|
||||||
unsafe { libc::pthread_kill(tid as libc::pthread_t, WAKE_SIGNAL) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if all_done {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
for t in self.threads.drain(..) {
|
for t in self.threads.drain(..) {
|
||||||
let _ = t.join();
|
let _ = t.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,20 +299,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||||
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
||||||
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
||||||
// HEAP-allocated, deliberately: `sw_create_cb` writes `result` + up to 127 u16 of instance id
|
let mut ctx = SwCreateCtx {
|
||||||
// through this pointer and then `SetEvent`s. The wait below is bounded (10 s), so on a wedged-PnP
|
|
||||||
// timeout the callback may still be PENDING — a stack context would be popped and a late callback
|
|
||||||
// would corrupt whatever the input thread put there next, and SetEvent a closed/recycled handle.
|
|
||||||
// On the timeout path we therefore LEAK the box and leave the event open (a one-off ~264 B + one
|
|
||||||
// HANDLE, only on that rare path) so a late callback always writes to live memory.
|
|
||||||
let ctx = Box::into_raw(Box::new(SwCreateCtx {
|
|
||||||
event,
|
event,
|
||||||
result: E_FAIL,
|
result: E_FAIL,
|
||||||
instance_id: [0; 128],
|
instance_id: [0; 128],
|
||||||
}));
|
};
|
||||||
// SAFETY: info + the buffers outlive the call; `ctx` is a live heap allocation that outlives every
|
// SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
|
||||||
// path below (reclaimed only where the callback provably ran). windows-rs returns the HSWDEVICE
|
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
|
||||||
// (the C out-param) as the Result value.
|
|
||||||
let hsw = match unsafe {
|
let hsw = match unsafe {
|
||||||
SwDeviceCreate(
|
SwDeviceCreate(
|
||||||
w!("punktfunk"),
|
w!("punktfunk"),
|
||||||
@@ -320,15 +313,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
&info,
|
&info,
|
||||||
None,
|
None,
|
||||||
Some(sw_create_cb),
|
Some(sw_create_cb),
|
||||||
Some(ctx as *const c_void),
|
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||||
)
|
)
|
||||||
} {
|
} {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// SAFETY: the call failed, so no callback was registered and `ctx` is ours to reclaim;
|
// SAFETY: event is valid.
|
||||||
// `event` is valid and unreferenced.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
drop(Box::from_raw(ctx));
|
|
||||||
let _ = CloseHandle(event);
|
let _ = CloseHandle(event);
|
||||||
}
|
}
|
||||||
return Err(anyhow!("SwDeviceCreate failed: {e}"));
|
return Err(anyhow!("SwDeviceCreate failed: {e}"));
|
||||||
@@ -337,22 +328,17 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
// Block until PnP finishes enumerating (the callback signals), then check its result.
|
// Block until PnP finishes enumerating (the callback signals), then check its result.
|
||||||
// SAFETY: event is valid.
|
// SAFETY: event is valid.
|
||||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||||
|
// SAFETY: event is valid.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(event);
|
||||||
|
}
|
||||||
if wait != WAIT_OBJECT_0 {
|
if wait != WAIT_OBJECT_0 {
|
||||||
// Timed out: the callback may still fire. Intentionally leak `ctx` AND leave `event` open so
|
|
||||||
// its eventual write + SetEvent target live memory/handle rather than freed ones.
|
|
||||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||||
unsafe { SwDeviceClose(hsw) };
|
unsafe { SwDeviceClose(hsw) };
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged"
|
"SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// The callback ran (it is what signalled the event), so nothing else will touch `ctx`/`event`.
|
|
||||||
// SAFETY: `ctx` came from `Box::into_raw` above and is reclaimed exactly once here; `event` is
|
|
||||||
// valid and no longer referenced by a pending callback.
|
|
||||||
let ctx = unsafe {
|
|
||||||
let _ = CloseHandle(event);
|
|
||||||
Box::from_raw(ctx)
|
|
||||||
};
|
|
||||||
if ctx.result.is_err() {
|
if ctx.result.is_err() {
|
||||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||||
unsafe { SwDeviceClose(hsw) };
|
unsafe { SwDeviceClose(hsw) };
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ impl Ds4WinPad {
|
|||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
}
|
}
|
||||||
let inst = format!("pf_ds4_{index}");
|
let inst = format!("pf_ds4_{index}");
|
||||||
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
container_tag: 0x5046_4453, // "PFDS"
|
container_tag: 0x5046_4453, // "PFDS"
|
||||||
container_index: index,
|
container_index: index,
|
||||||
@@ -70,13 +70,13 @@ impl Ds4WinPad {
|
|||||||
usb_vid_pid: "VID_054C&PID_09CC",
|
usb_vid_pid: "VID_054C&PID_09CC",
|
||||||
usb_mi: None,
|
usb_mi: None,
|
||||||
description: "punktfunk Virtual DualShock 4",
|
description: "punktfunk Virtual DualShock 4",
|
||||||
})?; // Propagate, do NOT swallow — see below.
|
}) {
|
||||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
Ok((h, id)) => (Some(h), id),
|
||||||
// Swallowing a create failure here (the previous behaviour) latched the pad slot to
|
Err(e) => {
|
||||||
// `Some(pad)` with no live devnode: `PadSlots::ensure` short-circuits on `is_some()` and
|
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
|
||||||
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to
|
(None, None)
|
||||||
// self-heal a transient PnP failure never retried. The game saw no controller for the whole
|
}
|
||||||
// session unless the client unplugged the pad. Matches the XUSB sibling, which propagates.
|
};
|
||||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
|
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
|
||||||
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
|
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
|
||||||
|
|||||||
@@ -82,16 +82,12 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
|||||||
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||||
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
||||||
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
||||||
// HEAP-allocated for the same reason as the DualSense sibling: the callback writes through this
|
let mut ctx = SwCreateCtx {
|
||||||
// pointer and SetEvents, and the wait below is bounded — a stack context would be popped while a
|
|
||||||
// late callback still holds it. On the timeout path the box is deliberately leaked and the event
|
|
||||||
// left open so a late write/SetEvent always targets live memory/handle.
|
|
||||||
let ctx = Box::into_raw(Box::new(SwCreateCtx {
|
|
||||||
event,
|
event,
|
||||||
result: E_FAIL,
|
result: E_FAIL,
|
||||||
instance_id: [0; 128],
|
instance_id: [0; 128],
|
||||||
}));
|
};
|
||||||
// SAFETY: info + buffers outlive the call; `ctx` is a live heap allocation outliving every path.
|
// SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
|
||||||
let hsw = match unsafe {
|
let hsw = match unsafe {
|
||||||
SwDeviceCreate(
|
SwDeviceCreate(
|
||||||
w!("punktfunk"),
|
w!("punktfunk"),
|
||||||
@@ -99,14 +95,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
|||||||
&info,
|
&info,
|
||||||
None,
|
None,
|
||||||
Some(sw_create_cb),
|
Some(sw_create_cb),
|
||||||
Some(ctx as *const c_void),
|
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||||
)
|
)
|
||||||
} {
|
} {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// SAFETY: the call failed, so no callback is pending and `ctx` is ours to reclaim.
|
// SAFETY: event is valid.
|
||||||
unsafe {
|
unsafe {
|
||||||
drop(Box::from_raw(ctx));
|
|
||||||
let _ = CloseHandle(event);
|
let _ = CloseHandle(event);
|
||||||
}
|
}
|
||||||
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
|
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
|
||||||
@@ -114,20 +109,17 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
|||||||
};
|
};
|
||||||
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
|
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
|
||||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||||
|
// SAFETY: event is valid.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(event);
|
||||||
|
}
|
||||||
if wait != WAIT_OBJECT_0 {
|
if wait != WAIT_OBJECT_0 {
|
||||||
// Timed out — intentionally leak `ctx` and leave `event` open (see above).
|
|
||||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||||
unsafe { SwDeviceClose(hsw) };
|
unsafe { SwDeviceClose(hsw) };
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
|
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// The callback ran (it signalled the event), so nothing else will touch `ctx`/`event`.
|
|
||||||
// SAFETY: `ctx` came from `Box::into_raw` and is reclaimed exactly once here.
|
|
||||||
let ctx = unsafe {
|
|
||||||
let _ = CloseHandle(event);
|
|
||||||
Box::from_raw(ctx)
|
|
||||||
};
|
|
||||||
if ctx.result.is_err() {
|
if ctx.result.is_err() {
|
||||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||||
unsafe { SwDeviceClose(hsw) };
|
unsafe { SwDeviceClose(hsw) };
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ impl DeckWinPad {
|
|||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
}
|
}
|
||||||
let inst = format!("pf_deck_{index}");
|
let inst = format!("pf_deck_{index}");
|
||||||
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
container_tag: 0x5046_4453, // "PFDS"
|
container_tag: 0x5046_4453, // "PFDS"
|
||||||
container_index: index,
|
container_index: index,
|
||||||
@@ -77,8 +77,13 @@ impl DeckWinPad {
|
|||||||
// spike's run-1 failure).
|
// spike's run-1 failure).
|
||||||
usb_mi: Some(2),
|
usb_mi: Some(2),
|
||||||
description: "punktfunk Virtual Steam Deck",
|
description: "punktfunk Virtual Steam Deck",
|
||||||
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
|
}) {
|
||||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
Ok((h, i)) => (Some(h), i),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
|
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
|
||||||
// it for descriptors, or the pad would enumerate with the default DualSense identity.
|
// it for descriptors, or the pad would enumerate with the default DualSense identity.
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ repository.workspace = true
|
|||||||
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
|
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
|
||||||
# Linux-only module — see lib.rs).
|
# Linux-only module — see lib.rs).
|
||||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||||
# `default-features = false`: the PyroWave decode backend is turned on through THIS crate's own
|
pf-client-core = { path = "../pf-client-core" }
|
||||||
# `pyrowave` feature (which re-exports it below), never by inheriting the dependency's default.
|
|
||||||
# Otherwise a consumer that deliberately builds us without `pyrowave` still drags the vendored
|
|
||||||
# C++ in — fatal on Windows ARM64, where Granite has no SIMD path.
|
|
||||||
pf-client-core = { path = "../pf-client-core", default-features = false }
|
|
||||||
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
|
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
|
||||||
pf-ffvk = { path = "../pf-ffvk" }
|
pf-ffvk = { path = "../pf-ffvk" }
|
||||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ pub struct Capture {
|
|||||||
/// The touchscreen input model for this session, and — for trackpad/pointer — the
|
/// The touchscreen input model for this session, and — for trackpad/pointer — the
|
||||||
/// gesture state machine finger events feed.
|
/// gesture state machine finger events feed.
|
||||||
touch_mode: TouchMode,
|
touch_mode: TouchMode,
|
||||||
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
|
||||||
invert_scroll: bool,
|
|
||||||
gestures: Gestures,
|
gestures: Gestures,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Capture {
|
impl Capture {
|
||||||
pub fn new(
|
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
|
||||||
connector: Arc<NativeClient>,
|
|
||||||
touch_mode: TouchMode,
|
|
||||||
invert_scroll: bool,
|
|
||||||
) -> Capture {
|
|
||||||
Capture {
|
Capture {
|
||||||
connector,
|
connector,
|
||||||
captured: false,
|
captured: false,
|
||||||
@@ -85,7 +79,6 @@ impl Capture {
|
|||||||
scroll_acc: (0.0, 0.0),
|
scroll_acc: (0.0, 0.0),
|
||||||
touch_slots: HashMap::new(),
|
touch_slots: HashMap::new(),
|
||||||
touch_mode,
|
touch_mode,
|
||||||
invert_scroll,
|
|
||||||
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,10 +194,9 @@ impl Capture {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.flush_motion(); // scroll happens at the latest cursor position
|
self.flush_motion(); // scroll happens at the latest cursor position
|
||||||
let sign = if self.invert_scroll { -1.0 } else { 1.0 };
|
|
||||||
let (mut ax, mut ay) = self.scroll_acc;
|
let (mut ax, mut ay) = self.scroll_acc;
|
||||||
ay += f64::from(dy) * 120.0 * sign;
|
ay += f64::from(dy) * 120.0;
|
||||||
ax += f64::from(dx) * 120.0 * sign;
|
ax += f64::from(dx) * 120.0;
|
||||||
let vy = ay.trunc() as i32;
|
let vy = ay.trunc() as i32;
|
||||||
if vy != 0 {
|
if vy != 0 {
|
||||||
ay -= f64::from(vy);
|
ay -= f64::from(vy);
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ pub struct SessionOpts {
|
|||||||
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
||||||
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
||||||
pub touch_mode: TouchMode,
|
pub touch_mode: TouchMode,
|
||||||
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
|
||||||
pub invert_scroll: bool,
|
|
||||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||||
pub json_status: bool,
|
pub json_status: bool,
|
||||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||||
@@ -333,11 +331,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
|
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
|
||||||
// and consumes no mouse, so nothing wanted these synthetic events anyway.
|
// and consumes no mouse, so nothing wanted these synthetic events anyway.
|
||||||
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
||||||
// The Wayland `app_id` (and X11 WM_CLASS) — compositors match it against
|
|
||||||
// io.unom.Punktfunk.desktop for the window/taskbar icon. Without it SDL uses a generic
|
|
||||||
// identity and the session window gets the default-Wayland icon (the Linux analog of
|
|
||||||
// the AppUserModelID adoption above).
|
|
||||||
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
|
|
||||||
let sdl = sdl3::init().context("SDL init")?;
|
let sdl = sdl3::init().context("SDL init")?;
|
||||||
let video = sdl.video().context("SDL video")?;
|
let video = sdl.video().context("SDL video")?;
|
||||||
let events = sdl.event().context("SDL events")?;
|
let events = sdl.event().context("SDL events")?;
|
||||||
@@ -818,7 +811,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
.ok();
|
.ok();
|
||||||
gamepad.attach(c.clone());
|
gamepad.attach(c.clone());
|
||||||
st.clock_offset = Some(c.clock_offset_shared());
|
st.clock_offset = Some(c.clock_offset_shared());
|
||||||
let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll);
|
let mut cap = Capture::new(c.clone(), opts.touch_mode);
|
||||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||||
apply_capture(&mut window, &mouse, true);
|
apply_capture(&mut window, &mouse, true);
|
||||||
st.capture = Some(cap);
|
st.capture = Some(cap);
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ mod reconfig;
|
|||||||
mod resources;
|
mod resources;
|
||||||
mod setup;
|
mod setup;
|
||||||
|
|
||||||
pub use setup::list_adapters;
|
|
||||||
|
|
||||||
/// One presenter iteration's video input.
|
/// One presenter iteration's video input.
|
||||||
pub enum FrameInput<'a> {
|
pub enum FrameInput<'a> {
|
||||||
/// No new frame — re-composite the retained video image (expose/resize).
|
/// No new frame — re-composite the retained video image (expose/resize).
|
||||||
|
|||||||
@@ -30,17 +30,9 @@ impl Presenter {
|
|||||||
// switch modes before anything touches this frame. Only where the surface
|
// switch modes before anything touches this frame. Only where the surface
|
||||||
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
|
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
|
||||||
// tonemaps (mode 1).
|
// tonemaps (mode 1).
|
||||||
//
|
|
||||||
// CPU frames NEVER take the HDR10 surface: software decode uploads swscale RGBA with
|
|
||||||
// no CSC/tonemap pass, so on a mode-0 swapchain that sRGB-encoded content would be
|
|
||||||
// composed as PQ — the field-reported psychedelic cyan/magenta picture (reproduced
|
|
||||||
// 2026-07-21: Fedora-class client, no hw HEVC decode, GNOME/Mesa offering HDR10 even
|
|
||||||
// on an SDR desktop). On the SDR swapchain the same frames are merely untonemapped
|
|
||||||
// (washed out) — wrong in the known, benign way until the CPU lane grows a real
|
|
||||||
// PQ→sRGB pass.
|
|
||||||
let frame_pq = match &input {
|
let frame_pq = match &input {
|
||||||
FrameInput::Redraw => None,
|
FrameInput::Redraw => None,
|
||||||
FrameInput::Cpu(_) => Some(false),
|
FrameInput::Cpu(f) => Some(f.color.is_pq()),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
||||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||||
|
|||||||
@@ -497,52 +497,6 @@ impl Presenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The physical devices' marketing names — the shells' GPU-picker source
|
|
||||||
/// (`punktfunk-session --list-adapters`). No surface and no logical device; discrete
|
|
||||||
/// GPUs first (mirroring `pick_device`'s tie-break), duplicates collapsed (the name is
|
|
||||||
/// the whole `PUNKTFUNK_VK_ADAPTER` match key, so a second identical card adds nothing).
|
|
||||||
/// Same 1.3 instance the presenter creates, so the list matches what streaming sees.
|
|
||||||
pub fn list_adapters() -> Result<Vec<String>> {
|
|
||||||
let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?;
|
|
||||||
let app_name = CString::new("punktfunk-session").unwrap();
|
|
||||||
let app_info = vk::ApplicationInfo::default()
|
|
||||||
.application_name(&app_name)
|
|
||||||
.api_version(vk::API_VERSION_1_3);
|
|
||||||
let instance = unsafe {
|
|
||||||
entry.create_instance(
|
|
||||||
&vk::InstanceCreateInfo::default().application_info(&app_info),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.context("vkCreateInstance")?;
|
|
||||||
let mut ranked: Vec<(u8, String)> = unsafe { instance.enumerate_physical_devices() }?
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| {
|
|
||||||
let props = unsafe { instance.get_physical_device_properties(d) };
|
|
||||||
let rank = match props.device_type {
|
|
||||||
vk::PhysicalDeviceType::DISCRETE_GPU => 0u8,
|
|
||||||
vk::PhysicalDeviceType::INTEGRATED_GPU => 1,
|
|
||||||
_ => 2,
|
|
||||||
};
|
|
||||||
let name = props
|
|
||||||
.device_name_as_c_str()
|
|
||||||
.map(|c| c.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
(rank, name)
|
|
||||||
})
|
|
||||||
.filter(|(_, n)| !n.is_empty())
|
|
||||||
.collect();
|
|
||||||
unsafe { instance.destroy_instance(None) };
|
|
||||||
ranked.sort_by_key(|(r, _)| *r); // stable: enumeration order within each tier
|
|
||||||
let mut names: Vec<String> = Vec::new();
|
|
||||||
for (_, n) in ranked {
|
|
||||||
if !names.contains(&n) {
|
|
||||||
names.push(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First physical device with a queue family that does graphics + present here;
|
/// First physical device with a queue family that does graphics + present here;
|
||||||
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
|
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
|
||||||
fn pick_device(
|
fn pick_device(
|
||||||
@@ -617,15 +571,6 @@ pub(super) fn pick_formats(
|
|||||||
surface: vk::SurfaceKHR,
|
surface: vk::SurfaceKHR,
|
||||||
colorspace_ext: bool,
|
colorspace_ext: bool,
|
||||||
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
|
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
|
||||||
// `PUNKTFUNK_HDR10=0` (explicit-off grammar) refuses the HDR10/ST.2084 swapchain outright,
|
|
||||||
// pinning PQ streams to the shader tonemap on an SDR surface. Two reasons this exists:
|
|
||||||
// desktop compositors newly offer HDR10 even on SDR desktops (GNOME 48 / Plasma 6 with
|
|
||||||
// Mesa ≥ 25.1 — a lane that otherwise engages silently), and it is the A/B lever that
|
|
||||||
// splits "HDR10 passthrough composes wrong" from "the decoded planes are wrong" in the
|
|
||||||
// field without rebuilding anything.
|
|
||||||
let colorspace_ext = colorspace_ext
|
|
||||||
&& !std::env::var("PUNKTFUNK_HDR10")
|
|
||||||
.is_ok_and(|v| matches!(v.as_str(), "0" | "false" | "off" | "no"));
|
|
||||||
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
|
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
|
||||||
let mut sdr = None;
|
let mut sdr = None;
|
||||||
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
|
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
|
||||||
|
|||||||
@@ -254,34 +254,6 @@ pub fn detect() -> Result<Compositor> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
|
|
||||||
/// or take over box sessions — they may only attach to an already-live output, and fail fast
|
|
||||||
/// otherwise. The capture-loss rebuild holds one for its first seconds: right after a capture
|
|
||||||
/// loss the active-session detection can be STALE (a Game→Desktop switch observed live: the
|
|
||||||
/// probe's gamescope re-acquire restarted `gamescope-session.target` and yanked the user out of
|
|
||||||
/// the KDE session they had just switched to). A counter, so overlapping scopes compose.
|
|
||||||
static REBUILD_PROBES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
|
||||||
|
|
||||||
/// RAII scope marking pipeline builds as attach-only probes (see [`rebuild_probe_active`]).
|
|
||||||
pub struct RebuildProbeScope(());
|
|
||||||
|
|
||||||
pub fn rebuild_probe_scope() -> RebuildProbeScope {
|
|
||||||
REBUILD_PROBES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
RebuildProbeScope(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for RebuildProbeScope {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
REBUILD_PROBES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is any [`rebuild_probe_scope`] active? Destructive session operations (stop/relaunch/
|
|
||||||
/// takeover-restart) must be skipped while true.
|
|
||||||
pub fn rebuild_probe_active() -> bool {
|
|
||||||
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the virtual-display driver for `compositor`.
|
/// Open the virtual-display driver for `compositor`.
|
||||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -325,29 +325,6 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
if steamos_session_present() {
|
if steamos_session_present() {
|
||||||
return create_managed_session_steamos(mode);
|
return create_managed_session_steamos(mode);
|
||||||
}
|
}
|
||||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
|
||||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
|
||||||
// destructive rebuild here would fight the session the user just switched to.
|
|
||||||
if crate::rebuild_probe_active() {
|
|
||||||
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
|
||||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
|
||||||
});
|
|
||||||
if same_mode {
|
|
||||||
if let Some(node_id) = find_gamescope_node() {
|
|
||||||
point_injector_at_eis();
|
|
||||||
tracing::info!(
|
|
||||||
node_id,
|
|
||||||
"gamescope session: attach-only probe reusing live node"
|
|
||||||
);
|
|
||||||
return Ok(managed_output(node_id, mode));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Err(anyhow!(
|
|
||||||
"gamescope session has no attachable live node — attach-only rebuild probe refuses \
|
|
||||||
to stop/relaunch box sessions (re-detection follows the live session)"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the
|
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the
|
||||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||||
@@ -630,17 +607,12 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
|
|||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
|
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
// UnsetEnvironment: the same headless-must-not-attach armor `launch_session` gives its
|
|
||||||
// transient unit — the manager env can carry a stale desktop DISPLAY/WAYLAND_DISPLAY (from a
|
|
||||||
// portal settle), and gamescope would abort trying to attach to it instead of becoming the
|
|
||||||
// display server. Unit-scoped belt-and-suspenders on top of the observe_session_instance scrub.
|
|
||||||
let body = format!(
|
let body = format!(
|
||||||
"[Service]\n\
|
"[Service]\n\
|
||||||
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
|
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
|
||||||
Environment=PF_W={w}\n\
|
Environment=PF_W={w}\n\
|
||||||
Environment=PF_H={h}\n\
|
Environment=PF_H={h}\n\
|
||||||
Environment=PF_HZ={hz}\n\
|
Environment=PF_HZ={hz}\n",
|
||||||
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
|
|
||||||
shim = shim_dir.display(),
|
shim = shim_dir.display(),
|
||||||
w = mode.width,
|
w = mode.width,
|
||||||
h = mode.height,
|
h = mode.height,
|
||||||
@@ -678,16 +650,6 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
|||||||
}
|
}
|
||||||
*guard = None; // tracked session lost its node — fall through to a clean restart
|
*guard = None; // tracked session lost its node — fall through to a clean restart
|
||||||
}
|
}
|
||||||
// Attach-only rebuild probe: the reuse path above may attach, but a restart of the session
|
|
||||||
// target is out of bounds — observed live on a Deck: a stale post-capture-loss detection made
|
|
||||||
// this restart steal the seat back from the KDE session the user had just switched to.
|
|
||||||
if crate::rebuild_probe_active() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"gamescope has no live node and this is an attach-only rebuild probe — refusing to \
|
|
||||||
restart {STEAMOS_SESSION_TARGET} (the box may be mid-switch to another session; \
|
|
||||||
re-detection follows it)"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let shim_dir = write_headless_shim()?;
|
let shim_dir = write_headless_shim()?;
|
||||||
write_steamos_dropin(&shim_dir, mode)?;
|
write_steamos_dropin(&shim_dir, mode)?;
|
||||||
systemctl_user(&["daemon-reload"]);
|
systemctl_user(&["daemon-reload"]);
|
||||||
|
|||||||
@@ -57,13 +57,6 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
|||||||
if let Some(old) = compositor_for_kind(prev.0) {
|
if let Some(old) = compositor_for_kind(prev.0) {
|
||||||
registry::invalidate_backend(old.id());
|
registry::invalidate_backend(old.id());
|
||||||
}
|
}
|
||||||
// The dead desktop's socket vars may still sit in the systemd --user manager env
|
|
||||||
// ([`settle_desktop_portal`]'s import-environment) — scrub them NOW, or the next
|
|
||||||
// `gamescope-session.target` start inherits a stale WAYLAND_DISPLAY and gamescope
|
|
||||||
// runs NESTED against the dead desktop socket instead of becoming the display
|
|
||||||
// server ("Failed to connect to wayland socket: wayland-0" — kept a Deck's Game
|
|
||||||
// Mode from starting at all, observed live 2026-07-21).
|
|
||||||
scrub_desktop_manager_env();
|
|
||||||
}
|
}
|
||||||
let epoch = bump_session_epoch();
|
let epoch = bump_session_epoch();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -77,23 +70,6 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
|||||||
*last = Some(cur);
|
*last = Some(cur);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counterpart to [`settle_desktop_portal`]'s `import-environment`: drop the desktop session's
|
|
||||||
/// socket vars from the systemd `--user` manager env once that desktop instance is GONE. They
|
|
||||||
/// persist in the manager otherwise, and every later user unit inherits them — including
|
|
||||||
/// `gamescope-session.target`, whose gamescope then aborts trying to attach to the dead desktop
|
|
||||||
/// socket. Best-effort; the D-Bus activation env has no unset op, but gamescope-session is
|
|
||||||
/// systemd-started, so the manager scrub is the one that matters. (A desktop restart re-imports
|
|
||||||
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn scrub_desktop_manager_env() {
|
|
||||||
let _ = std::process::Command::new("systemctl")
|
|
||||||
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
fn scrub_desktop_manager_env() {}
|
|
||||||
|
|
||||||
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||||
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||||
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Child, Command};
|
use std::process::{Child, Command};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
||||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
|
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
@@ -64,27 +64,11 @@ impl Drop for Shared {
|
|||||||
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
|
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
|
||||||
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
|
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
|
||||||
/// workers don't linger as zombies for more than one capture generation.
|
/// workers don't linger as zombies for more than one capture generation.
|
||||||
static REAPER: Mutex<Vec<(Child, Instant)>> = Mutex::new(Vec::new());
|
static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
/// How long past `REPLY_TIMEOUT` a parked worker may linger before it is force-killed. A worker
|
|
||||||
/// wedged INSIDE a driver call never observes socket EOF, so `try_wait` alone would keep it (and
|
|
||||||
/// its CUcontext + BufferPool — order hundreds of MB of VRAM) forever.
|
|
||||||
const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20);
|
|
||||||
|
|
||||||
fn sweep_reaper() {
|
fn sweep_reaper() {
|
||||||
let mut list = REAPER.lock().unwrap();
|
let mut list = REAPER.lock().unwrap();
|
||||||
let now = Instant::now();
|
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
||||||
list.retain_mut(|(c, parked)| {
|
|
||||||
if matches!(c.try_wait(), Ok(Some(_))) {
|
|
||||||
return false; // exited on its own → reaped
|
|
||||||
}
|
|
||||||
if now.duration_since(*parked) > REAPER_KILL_DEADLINE {
|
|
||||||
let _ = c.kill();
|
|
||||||
let _ = c.wait();
|
|
||||||
return false; // wedged past the deadline → force-killed + reaped
|
|
||||||
}
|
|
||||||
true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
|
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
|
||||||
@@ -471,7 +455,7 @@ impl Drop for RemoteImporter {
|
|||||||
// gone; park the rest for the next sweep.
|
// gone; park the rest for the next sweep.
|
||||||
if let Some(mut child) = self.child.take() {
|
if let Some(mut child) = self.child.take() {
|
||||||
if !matches!(child.try_wait(), Ok(Some(_))) {
|
if !matches!(child.try_wait(), Ok(Some(_))) {
|
||||||
REAPER.lock().unwrap().push((child, Instant::now()));
|
REAPER.lock().unwrap().push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sweep_reaper();
|
sweep_reaper();
|
||||||
|
|||||||
@@ -251,32 +251,6 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
|||||||
ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
|
ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue `copy` on this thread's priority stream WITHOUT waiting — for stream-ordered consumers
|
|
||||||
/// only (the direct-NVENC submit path with `NvEncSetIOCudaStreams` bound to this stream): the
|
|
||||||
/// stream, not the CPU, orders completion, so the SOURCE must stay valid until the downstream
|
|
||||||
/// stream work (the encode) has finished.
|
|
||||||
unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
|
||||||
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
|
|
||||||
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
|
|
||||||
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
|
|
||||||
if sync {
|
|
||||||
copy_blocking(copy, what)
|
|
||||||
} else {
|
|
||||||
copy_async(copy, what)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The calling thread's copy/launch stream as a raw handle, for binding external stream-ordering
|
|
||||||
/// (the direct-NVENC `NvEncSetIOCudaStreams` hookup). Null = the NULL stream (priority-stream
|
|
||||||
/// creation failed) — callers should treat null as "stream-ordering unavailable" and keep their
|
|
||||||
/// blocking copies. The shared context must be current on this thread.
|
|
||||||
pub fn copy_stream_handle() -> *mut c_void {
|
|
||||||
copy_stream() // CUstream IS *mut c_void (opaque CUstream_st*)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path.
|
/// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path.
|
||||||
pub const CURSOR_MAX: u32 = 256;
|
pub const CURSOR_MAX: u32 = 256;
|
||||||
|
|
||||||
@@ -380,7 +354,6 @@ impl CursorBlend {
|
|||||||
ch: u32,
|
ch: u32,
|
||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (mut a_surf, mut a_cur) = (surf, self.cur_buf);
|
let (mut a_surf, mut a_cur) = (surf, self.cur_buf);
|
||||||
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
||||||
@@ -397,7 +370,7 @@ impl CursorBlend {
|
|||||||
&mut a_ox as *mut _ as *mut c_void,
|
&mut a_ox as *mut _ as *mut c_void,
|
||||||
&mut a_oy as *mut _ as *mut c_void,
|
&mut a_oy as *mut _ as *mut c_void,
|
||||||
];
|
];
|
||||||
self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args, sync)
|
self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`.
|
/// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`.
|
||||||
@@ -412,7 +385,6 @@ impl CursorBlend {
|
|||||||
ch: u32,
|
ch: u32,
|
||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (mut a_base, mut a_cur) = (base, self.cur_buf);
|
let (mut a_base, mut a_cur) = (base, self.cur_buf);
|
||||||
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
||||||
@@ -429,7 +401,7 @@ impl CursorBlend {
|
|||||||
&mut a_ox as *mut _ as *mut c_void,
|
&mut a_ox as *mut _ as *mut c_void,
|
||||||
&mut a_oy as *mut _ as *mut c_void,
|
&mut a_oy as *mut _ as *mut c_void,
|
||||||
];
|
];
|
||||||
self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args, sync)
|
self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`).
|
/// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`).
|
||||||
@@ -444,7 +416,6 @@ impl CursorBlend {
|
|||||||
ch: u32,
|
ch: u32,
|
||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf);
|
let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf);
|
||||||
let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32);
|
let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32);
|
||||||
@@ -470,20 +441,16 @@ impl CursorBlend {
|
|||||||
(a_cw as u32).div_ceil(2),
|
(a_cw as u32).div_ceil(2),
|
||||||
(a_ch as u32).div_ceil(2),
|
(a_ch as u32).div_ceil(2),
|
||||||
&mut args,
|
&mut args,
|
||||||
sync,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream; `sync` waits
|
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream, then synchronize.
|
||||||
/// for it, `!sync` leaves completion to the stream (stream-ordered consumers only — the
|
|
||||||
/// kernel PARAMETERS are copied at launch time, so the arg locals need not outlive the call).
|
|
||||||
fn launch(
|
fn launch(
|
||||||
&self,
|
&self,
|
||||||
f: CUfunction,
|
f: CUfunction,
|
||||||
work_w: u32,
|
work_w: u32,
|
||||||
work_h: u32,
|
work_h: u32,
|
||||||
args: &mut [*mut c_void],
|
args: &mut [*mut c_void],
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if work_w == 0 || work_h == 0 {
|
if work_w == 0 || work_h == 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -491,11 +458,9 @@ impl CursorBlend {
|
|||||||
const B: u32 = 16;
|
const B: u32 = 16;
|
||||||
let stream = copy_stream();
|
let stream = copy_stream();
|
||||||
// SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live
|
// SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live
|
||||||
// locals whose types match the kernel's C parameters (per the call site above) — CUDA
|
// locals whose types match the kernel's C parameters (per the call site above); grid/block
|
||||||
// copies the parameter values during `cuLaunchKernel` itself, so they need not outlive
|
// dims are non-zero. Launched on the copy stream (ordered after the input-surface copy that
|
||||||
// the call. Grid/block dims are non-zero. Launched on the copy stream (ordered after the
|
// `copy_into_slot` already synchronized) then synchronized. Requires the context current.
|
||||||
// input-surface copy issued on the same stream); `sync` waits, `!sync` leaves ordering to
|
|
||||||
// the stream (the NVENC IO-stream binding). Requires the context current.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuLaunchKernel(
|
cuLaunchKernel(
|
||||||
@@ -513,10 +478,7 @@ impl CursorBlend {
|
|||||||
),
|
),
|
||||||
"cuLaunchKernel(cursor)",
|
"cuLaunchKernel(cursor)",
|
||||||
)?;
|
)?;
|
||||||
if sync {
|
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")
|
||||||
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1041,13 +1003,7 @@ impl RegisteredTexture {
|
|||||||
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
|
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
|
||||||
// (its only constructor), so the wrappers forward to the live table; the caller holds the
|
// (its only constructor), so the wrappers forward to the live table; the caller holds the
|
||||||
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
|
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
|
||||||
// `count == 1` resource via `&mut self.resource` (a live field). It is issued on
|
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
|
||||||
// `copy_stream()` — NOT the NULL stream — because map's only ordering guarantee is that
|
|
||||||
// prior GL work completes before subsequent CUDA work issued IN THE STREAM PASSED TO IT;
|
|
||||||
// the copy below runs on `copy_stream()` (a `CU_STREAM_NON_BLOCKING` stream, exempt from
|
|
||||||
// implicit NULL-stream ordering), so mapping on NULL left the copy free to race the GL
|
|
||||||
// de-tile/CSC that produced this texture (glFlush only, no fence) — intermittent torn or
|
|
||||||
// stale frames under GPU load. Map, copy, and unmap now all share `copy_stream()`.
|
|
||||||
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
|
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
|
||||||
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` is a live
|
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` is a live
|
||||||
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
|
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
|
||||||
@@ -1056,12 +1012,12 @@ impl RegisteredTexture {
|
|||||||
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
|
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
|
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||||
"cuGraphicsMapResources",
|
"cuGraphicsMapResources",
|
||||||
)?;
|
)?;
|
||||||
let mut array: CUarray = std::ptr::null_mut();
|
let mut array: CUarray = std::ptr::null_mut();
|
||||||
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
|
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
|
||||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||||
bail!("cuGraphicsSubResourceGetMappedArray failed");
|
bail!("cuGraphicsSubResourceGetMappedArray failed");
|
||||||
}
|
}
|
||||||
let copy = CUDA_MEMCPY2D {
|
let copy = CUDA_MEMCPY2D {
|
||||||
@@ -1075,7 +1031,7 @@ impl RegisteredTexture {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let res = copy_blocking(©, "cuMemcpy2DAsync_v2");
|
let res = copy_blocking(©, "cuMemcpy2DAsync_v2");
|
||||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1102,12 +1058,12 @@ impl RegisteredTexture {
|
|||||||
// so the map/unmap pair stays balanced and the array outlives the copy.
|
// so the map/unmap pair stays balanced and the array outlives the copy.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
|
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||||
"cuGraphicsMapResources",
|
"cuGraphicsMapResources",
|
||||||
)?;
|
)?;
|
||||||
let mut array: CUarray = std::ptr::null_mut();
|
let mut array: CUarray = std::ptr::null_mut();
|
||||||
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
|
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
|
||||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||||
bail!("cuGraphicsSubResourceGetMappedArray failed");
|
bail!("cuGraphicsSubResourceGetMappedArray failed");
|
||||||
}
|
}
|
||||||
let copy = CUDA_MEMCPY2D {
|
let copy = CUDA_MEMCPY2D {
|
||||||
@@ -1121,7 +1077,7 @@ impl RegisteredTexture {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let res = copy_blocking(©, "cuMemcpy2DAsync_v2(plane)");
|
let res = copy_blocking(©, "cuMemcpy2DAsync_v2(plane)");
|
||||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1166,13 +1122,10 @@ pub fn copy_mapped_yuv444(
|
|||||||
/// Copy a pitched device buffer into another device region (device→device), e.g. our imported
|
/// Copy a pitched device buffer into another device region (device→device), e.g. our imported
|
||||||
/// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels.
|
/// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels.
|
||||||
/// The caller must have the shared context current on this thread (see [`make_current`]).
|
/// The caller must have the shared context current on this thread (see [`make_current`]).
|
||||||
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
|
|
||||||
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
|
|
||||||
pub fn copy_device_to_device(
|
pub fn copy_device_to_device(
|
||||||
src: &DeviceBuffer,
|
src: &DeviceBuffer,
|
||||||
dst_ptr: CUdeviceptr,
|
dst_ptr: CUdeviceptr,
|
||||||
dst_pitch: usize,
|
dst_pitch: usize,
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let copy = CUDA_MEMCPY2D {
|
let copy = CUDA_MEMCPY2D {
|
||||||
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||||
@@ -1185,27 +1138,23 @@ pub fn copy_device_to_device(
|
|||||||
Height: src.height as usize,
|
Height: src.height as usize,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: `copy_issue` is unsafe (issues a CUDA copy); the caller must have the shared
|
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
|
||||||
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||||
// the enqueue: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/`dstPitch` the
|
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
|
||||||
// caller's live region, `width*4`×`height` within both; `sync: false` shifts the source-
|
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
|
||||||
// lifetime obligation to the caller (documented above). Wrapper → live table.
|
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->dev)") }
|
||||||
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(dev->dev)", sync) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface
|
/// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface
|
||||||
/// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` +
|
/// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` +
|
||||||
/// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is
|
/// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is
|
||||||
/// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current.
|
/// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current.
|
||||||
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
|
|
||||||
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
|
|
||||||
pub fn copy_nv12_to_device(
|
pub fn copy_nv12_to_device(
|
||||||
src: &DeviceBuffer,
|
src: &DeviceBuffer,
|
||||||
y_dst: CUdeviceptr,
|
y_dst: CUdeviceptr,
|
||||||
y_pitch: usize,
|
y_pitch: usize,
|
||||||
uv_dst: CUdeviceptr,
|
uv_dst: CUdeviceptr,
|
||||||
uv_pitch: usize,
|
uv_pitch: usize,
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (src_uv_ptr, src_uv_pitch) = src
|
let (src_uv_ptr, src_uv_pitch) = src
|
||||||
.uv
|
.uv
|
||||||
@@ -1234,16 +1183,15 @@ pub fn copy_nv12_to_device(
|
|||||||
Height: h / 2,
|
Height: h / 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
|
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
|
||||||
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
||||||
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
||||||
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
||||||
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
||||||
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
|
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
|
||||||
// to the caller (documented above). Wrappers → live table.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
|
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
||||||
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
|
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,13 +1199,7 @@ pub fn copy_nv12_to_device(
|
|||||||
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
|
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
|
||||||
/// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the
|
/// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the
|
||||||
/// single allocation. The caller must have the shared context current.
|
/// single allocation. The caller must have the shared context current.
|
||||||
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
|
pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]) -> Result<()> {
|
||||||
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
|
|
||||||
pub fn copy_yuv444_to_device(
|
|
||||||
src: &DeviceBuffer,
|
|
||||||
dsts: [(CUdeviceptr, usize); 3],
|
|
||||||
sync: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
|
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
|
||||||
let w = src.width as usize;
|
let w = src.width as usize;
|
||||||
let h = src.height as usize;
|
let h = src.height as usize;
|
||||||
@@ -1273,13 +1215,12 @@ pub fn copy_yuv444_to_device(
|
|||||||
Height: h,
|
Height: h,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
|
// SAFETY: unsafe `copy_blocking` device→device copy; the caller must have the shared
|
||||||
// context current (documented). `©` is a live local outliving the enqueue;
|
// context current (documented). `©` is a live local outliving the synchronous call;
|
||||||
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
||||||
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
||||||
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
|
// both. Wrapper → live table.
|
||||||
// above). Wrapper → live table.
|
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
|
||||||
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -691,15 +691,6 @@ impl EglImporter {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> Result<DeviceBuffer> {
|
) -> Result<DeviceBuffer> {
|
||||||
// Even dimensions only: the UV copy walks `height.div_ceil(2)` chroma rows (the correct NV12
|
|
||||||
// count), but the pooled UV plane is sized at `height/2` rows — for an odd height those
|
|
||||||
// disagree by one row and the copy writes a full `uv_pitch` past the allocation (OOB device
|
|
||||||
// write / CUDA_ERROR_ILLEGAL_ADDRESS that poisons the shared context). Reject here, matching
|
|
||||||
// the guards `Nv12Blit::new`/`Yuv444Blit::new` already carry.
|
|
||||||
anyhow::ensure!(
|
|
||||||
width % 2 == 0 && height % 2 == 0,
|
|
||||||
"LINEAR NV12 needs even dimensions (got {width}x{height})"
|
|
||||||
);
|
|
||||||
cuda::make_current()?;
|
cuda::make_current()?;
|
||||||
if self
|
if self
|
||||||
.linear_nv12_pool
|
.linear_nv12_pool
|
||||||
|
|||||||
@@ -86,11 +86,9 @@ impl VkBridge {
|
|||||||
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
|
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
|
||||||
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
|
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
|
||||||
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
|
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
|
||||||
// `exts`, `prio`, `qci`, `gp_info`, and the inline infos) that all live for the duration of
|
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
|
||||||
// the synchronous `create_*`/`enumerate_*` call that reads them — the ladder loop rebuilds
|
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
|
||||||
// `prio`/`gp_info`/`qci`/`exts` fresh per attempt, so every `enabled_extension_names(&exts)`
|
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
|
||||||
// / `queue_priorities(&prio)` / `push_next(&mut gp_info)` borrow outlives its own
|
|
||||||
// `create_device` call.
|
|
||||||
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
|
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
|
||||||
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
|
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
|
||||||
// constructor shares nothing across threads.
|
// constructor shares nothing across threads.
|
||||||
@@ -124,93 +122,23 @@ impl VkBridge {
|
|||||||
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
||||||
as u32;
|
as u32;
|
||||||
|
|
||||||
// Global-priority queue (latency plan §7 LN4, PyroWave's `ac0e7332` lever for the
|
let exts = [
|
||||||
// VkBridge): the LINEAR/gamescope CSC dispatch shares the SM/compute cores with the
|
|
||||||
// game, so ask for an elevated global priority to get scheduled ahead of it.
|
|
||||||
// `PUNKTFUNK_VK_QUEUE_PRIORITY` = off | high | realtime (default realtime); the
|
|
||||||
// create loop downgrades REALTIME→HIGH→none on NOT_PERMITTED (and retries a plain
|
|
||||||
// create on INITIALIZATION_FAILED) so a refused class never fails the bridge.
|
|
||||||
let gp_ext = std::env::var("PUNKTFUNK_VK_QUEUE_PRIORITY")
|
|
||||||
.ok()
|
|
||||||
.as_deref()
|
|
||||||
.map_or(Some(vk::QueueGlobalPriorityKHR::REALTIME), |v| match v {
|
|
||||||
"off" | "0" => None,
|
|
||||||
"high" => Some(vk::QueueGlobalPriorityKHR::HIGH),
|
|
||||||
_ => Some(vk::QueueGlobalPriorityKHR::REALTIME),
|
|
||||||
})
|
|
||||||
.and_then(|want| {
|
|
||||||
// Enable whichever alias the driver advertises (KHR = the promoted name).
|
|
||||||
let props = instance.enumerate_device_extension_properties(phys).ok()?;
|
|
||||||
let has = |name: &std::ffi::CStr| {
|
|
||||||
props
|
|
||||||
.iter()
|
|
||||||
.any(|p| p.extension_name_as_c_str() == Ok(name))
|
|
||||||
};
|
|
||||||
if has(vk::KHR_GLOBAL_PRIORITY_NAME) {
|
|
||||||
Some((vk::KHR_GLOBAL_PRIORITY_NAME, want))
|
|
||||||
} else if has(vk::EXT_GLOBAL_PRIORITY_NAME) {
|
|
||||||
Some((vk::EXT_GLOBAL_PRIORITY_NAME, want))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let base_exts = [
|
|
||||||
ash::khr::external_memory_fd::NAME.as_ptr(),
|
ash::khr::external_memory_fd::NAME.as_ptr(),
|
||||||
ash::ext::external_memory_dma_buf::NAME.as_ptr(),
|
ash::ext::external_memory_dma_buf::NAME.as_ptr(),
|
||||||
];
|
];
|
||||||
let mut try_priority = gp_ext.map(|(_, want)| want);
|
let prio = [1.0f32];
|
||||||
let device = loop {
|
let qci = [vk::DeviceQueueCreateInfo::default()
|
||||||
let prio = [1.0f32];
|
.queue_family_index(qf)
|
||||||
let mut gp_info = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default()
|
.queue_priorities(&prio)];
|
||||||
.global_priority(try_priority.unwrap_or(vk::QueueGlobalPriorityKHR::MEDIUM));
|
let device = instance
|
||||||
let mut qci0 = vk::DeviceQueueCreateInfo::default()
|
.create_device(
|
||||||
.queue_family_index(qf)
|
|
||||||
.queue_priorities(&prio);
|
|
||||||
let mut exts: Vec<*const std::ffi::c_char> = base_exts.to_vec();
|
|
||||||
if try_priority.is_some() {
|
|
||||||
qci0 = qci0.push_next(&mut gp_info);
|
|
||||||
exts.push(gp_ext.expect("try_priority implies gp_ext").0.as_ptr());
|
|
||||||
}
|
|
||||||
let qci = [qci0];
|
|
||||||
match instance.create_device(
|
|
||||||
phys,
|
phys,
|
||||||
&vk::DeviceCreateInfo::default()
|
&vk::DeviceCreateInfo::default()
|
||||||
.queue_create_infos(&qci)
|
.queue_create_infos(&qci)
|
||||||
.enabled_extension_names(&exts),
|
.enabled_extension_names(&exts),
|
||||||
None,
|
None,
|
||||||
) {
|
)
|
||||||
Ok(d) => {
|
.context("vkCreateDevice (external-memory extensions supported?)")?;
|
||||||
if let Some(p) = try_priority {
|
|
||||||
tracing::info!(
|
|
||||||
priority = ?p,
|
|
||||||
"VkBridge queue at elevated global priority (CSC schedules \
|
|
||||||
ahead of a GPU-bound game where the driver honors it)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break d;
|
|
||||||
}
|
|
||||||
// A refused class must never fail the bridge — walk the ladder down.
|
|
||||||
Err(
|
|
||||||
vk::Result::ERROR_NOT_PERMITTED_KHR
|
|
||||||
| vk::Result::ERROR_INITIALIZATION_FAILED,
|
|
||||||
) if try_priority == Some(vk::QueueGlobalPriorityKHR::REALTIME) => {
|
|
||||||
try_priority = Some(vk::QueueGlobalPriorityKHR::HIGH);
|
|
||||||
}
|
|
||||||
Err(
|
|
||||||
vk::Result::ERROR_NOT_PERMITTED_KHR
|
|
||||||
| vk::Result::ERROR_INITIALIZATION_FAILED,
|
|
||||||
) if try_priority.is_some() => {
|
|
||||||
tracing::debug!(
|
|
||||||
"global-priority queue not permitted — VkBridge at default priority"
|
|
||||||
);
|
|
||||||
try_priority = None;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e)
|
|
||||||
.context("vkCreateDevice (external-memory extensions supported?)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
||||||
let queue = device.get_device_queue(qf, 0);
|
let queue = device.get_device_queue(qf, 0);
|
||||||
|
|
||||||
@@ -265,17 +193,10 @@ impl VkBridge {
|
|||||||
|
|
||||||
/// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`.
|
/// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`.
|
||||||
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
|
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
|
||||||
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
|
|
||||||
let dup = libc::dup(fd);
|
let dup = libc::dup(fd);
|
||||||
if dup < 0 {
|
if dup < 0 {
|
||||||
bail!("dup(dmabuf fd)");
|
bail!("dup(dmabuf fd)");
|
||||||
}
|
}
|
||||||
// Own the dup so every early return BEFORE Vulkan consumes it (at `allocate_memory` success)
|
|
||||||
// closes it. `SrcBuf` holds raw handles with no Drop and is only populated on the success
|
|
||||||
// path, so each fallible step below must also destroy the buffer it created — otherwise a
|
|
||||||
// failed import (which the worker survives and the caller retries every frame) leaks a
|
|
||||||
// VkBuffer + VkDeviceMemory + fd per frame for the worker's whole lifetime.
|
|
||||||
let dup = OwnedFd::from_raw_fd(dup);
|
|
||||||
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
||||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||||
let buffer = self
|
let buffer = self
|
||||||
@@ -291,55 +212,41 @@ impl VkBridge {
|
|||||||
.push_next(&mut ext_info),
|
.push_next(&mut ext_info),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.context("create import buffer")?; // `dup` drops → closes on failure
|
.context("create import buffer")?;
|
||||||
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
|
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
|
||||||
if let Err(e) = self.ext_fd.get_memory_fd_properties(
|
self.ext_fd
|
||||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
.get_memory_fd_properties(
|
||||||
dup.as_raw_fd(),
|
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||||
&mut fd_props,
|
dup,
|
||||||
) {
|
&mut fd_props,
|
||||||
self.device.destroy_buffer(buffer, None);
|
)
|
||||||
return Err(e).context("vkGetMemoryFdPropertiesKHR");
|
.context("vkGetMemoryFdPropertiesKHR")?;
|
||||||
}
|
|
||||||
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
||||||
let mem_type = match self.memory_type(
|
let mem_type = self.memory_type(
|
||||||
reqs.memory_type_bits & fd_props.memory_type_bits,
|
reqs.memory_type_bits & fd_props.memory_type_bits,
|
||||||
vk::MemoryPropertyFlags::empty(),
|
vk::MemoryPropertyFlags::empty(),
|
||||||
) {
|
)?;
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
self.device.destroy_buffer(buffer, None);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Vulkan takes ownership of the fd on a SUCCESSFUL import: hand over the raw fd now, and on
|
|
||||||
// failure close it ourselves (matching the original contract) plus destroy the buffer.
|
|
||||||
let raw = dup.into_raw_fd();
|
|
||||||
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
||||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||||
.fd(raw);
|
.fd(dup); // Vulkan takes ownership of `dup` on success
|
||||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||||
let memory = match self.device.allocate_memory(
|
let memory = self
|
||||||
&vk::MemoryAllocateInfo::default()
|
.device
|
||||||
.allocation_size(reqs.size.max(size))
|
.allocate_memory(
|
||||||
.memory_type_index(mem_type)
|
&vk::MemoryAllocateInfo::default()
|
||||||
.push_next(&mut import)
|
.allocation_size(reqs.size.max(size))
|
||||||
.push_next(&mut dedicated),
|
.memory_type_index(mem_type)
|
||||||
None,
|
.push_next(&mut import)
|
||||||
) {
|
.push_next(&mut dedicated),
|
||||||
Ok(m) => m,
|
None,
|
||||||
Err(e) => {
|
)
|
||||||
libc::close(raw); // failed import does not consume the fd
|
.map_err(|e| {
|
||||||
self.device.destroy_buffer(buffer, None);
|
libc::close(dup); // failed import does not consume the fd
|
||||||
return Err(anyhow!("import dmabuf memory: {e}"));
|
anyhow!("import dmabuf memory: {e}")
|
||||||
}
|
})?;
|
||||||
};
|
self.device
|
||||||
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) {
|
.bind_buffer_memory(buffer, memory, 0)
|
||||||
// `memory` owns the imported fd — freeing it releases the fd too.
|
.context("bind import memory")?;
|
||||||
self.device.free_memory(memory, None);
|
|
||||||
self.device.destroy_buffer(buffer, None);
|
|
||||||
return Err(e).context("bind import memory");
|
|
||||||
}
|
|
||||||
self.src_cache.insert(
|
self.src_cache.insert(
|
||||||
fd,
|
fd,
|
||||||
SrcBuf {
|
SrcBuf {
|
||||||
@@ -356,11 +263,11 @@ impl VkBridge {
|
|||||||
if self.dst.as_ref().is_some_and(|d| d.size >= size) {
|
if self.dst.as_ref().is_some_and(|d| d.size >= size) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Build the replacement FULLY before retiring the old one. Previously the old dst was
|
if let Some(old) = self.dst.take() {
|
||||||
// destroyed and `self.dst` nulled up front, so a failed rebuild both dropped the working
|
self.device.destroy_buffer(old.buffer, None);
|
||||||
// buffer AND leaked every object the partial rebuild created (`buffer`/`memory` are raw ash
|
self.device.free_memory(old.memory, None);
|
||||||
// handles with no Drop, and `VkBridge::drop` only frees the live `self.dst`). Now every
|
// old.cuda drops its mapping with it
|
||||||
// fallible step unwinds locally, and the swap happens only on full success.
|
}
|
||||||
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
||||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||||
let buffer = self
|
let buffer = self
|
||||||
@@ -378,63 +285,35 @@ impl VkBridge {
|
|||||||
.context("create export buffer")?;
|
.context("create export buffer")?;
|
||||||
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
||||||
let mem_type =
|
let mem_type =
|
||||||
match self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL) {
|
self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
self.device.destroy_buffer(buffer, None);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut export = vk::ExportMemoryAllocateInfo::default()
|
let mut export = vk::ExportMemoryAllocateInfo::default()
|
||||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||||
let memory = match self.device.allocate_memory(
|
let memory = self
|
||||||
&vk::MemoryAllocateInfo::default()
|
.device
|
||||||
.allocation_size(reqs.size)
|
.allocate_memory(
|
||||||
.memory_type_index(mem_type)
|
&vk::MemoryAllocateInfo::default()
|
||||||
.push_next(&mut export)
|
.allocation_size(reqs.size)
|
||||||
.push_next(&mut dedicated),
|
.memory_type_index(mem_type)
|
||||||
None,
|
.push_next(&mut export)
|
||||||
) {
|
.push_next(&mut dedicated),
|
||||||
Ok(m) => m,
|
None,
|
||||||
Err(e) => {
|
)
|
||||||
self.device.destroy_buffer(buffer, None);
|
.context("allocate exportable memory")?;
|
||||||
return Err(e).context("allocate exportable memory");
|
self.device
|
||||||
}
|
.bind_buffer_memory(buffer, memory, 0)
|
||||||
};
|
.context("bind export memory")?;
|
||||||
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) {
|
let opaque_fd = self
|
||||||
self.device.free_memory(memory, None);
|
.ext_fd
|
||||||
self.device.destroy_buffer(buffer, None);
|
.get_memory_fd(
|
||||||
return Err(e).context("bind export memory");
|
&vk::MemoryGetFdInfoKHR::default()
|
||||||
}
|
.memory(memory)
|
||||||
let opaque_fd = match self.ext_fd.get_memory_fd(
|
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
|
||||||
&vk::MemoryGetFdInfoKHR::default()
|
)
|
||||||
.memory(memory)
|
.context("vkGetMemoryFdKHR")?;
|
||||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
|
|
||||||
) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
self.device.free_memory(memory, None);
|
|
||||||
self.device.destroy_buffer(buffer, None);
|
|
||||||
return Err(e).context("vkGetMemoryFdKHR");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// CUDA imports (and on success owns) the exported fd. Size must match the allocation.
|
// CUDA imports (and on success owns) the exported fd. Size must match the allocation.
|
||||||
// `import_owned_fd` closes `opaque_fd` on its own failure, so only the Vulkan objects unwind.
|
let cuda = cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size)
|
||||||
let cuda = match cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size) {
|
.context("cuImportExternalMemory(OPAQUE_FD from Vulkan)")?;
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
self.device.free_memory(memory, None);
|
|
||||||
self.device.destroy_buffer(buffer, None);
|
|
||||||
return Err(e).context("cuImportExternalMemory(OPAQUE_FD from Vulkan)");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Full success: retire the previous buffer now, then publish the new one.
|
|
||||||
if let Some(old) = self.dst.take() {
|
|
||||||
self.device.destroy_buffer(old.buffer, None);
|
|
||||||
self.device.free_memory(old.memory, None);
|
|
||||||
// old.cuda drops its mapping with it
|
|
||||||
}
|
|
||||||
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
|
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
|
||||||
self.dst = Some(DstBuf {
|
self.dst = Some(DstBuf {
|
||||||
buffer,
|
buffer,
|
||||||
@@ -665,19 +544,9 @@ impl VkBridge {
|
|||||||
self.device
|
self.device
|
||||||
.queue_submit(self.queue, &[submit], self.fence)
|
.queue_submit(self.queue, &[submit], self.fence)
|
||||||
.context("queue submit")?;
|
.context("queue submit")?;
|
||||||
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still
|
self.device
|
||||||
// executing — `self.cmd` and `self.fence` are reused every frame, and the caller retries
|
|
||||||
// on the SAME bridge (and `ensure_dst` later destroys `dst.buffer` assuming no in-flight
|
|
||||||
// work references it). Drain the GPU and reset the fence before propagating so the shared
|
|
||||||
// cmd/fence return clean.
|
|
||||||
if let Err(e) = self
|
|
||||||
.device
|
|
||||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||||
{
|
.context("fence wait")?;
|
||||||
let _ = self.device.device_wait_idle();
|
|
||||||
let _ = self.device.reset_fences(&[self.fence]);
|
|
||||||
return Err(e).context("fence wait");
|
|
||||||
}
|
|
||||||
self.device
|
self.device
|
||||||
.reset_fences(&[self.fence])
|
.reset_fences(&[self.fence])
|
||||||
.context("reset fence")?;
|
.context("reset fence")?;
|
||||||
@@ -770,19 +639,9 @@ impl VkBridge {
|
|||||||
self.device
|
self.device
|
||||||
.queue_submit(self.queue, &[submit], self.fence)
|
.queue_submit(self.queue, &[submit], self.fence)
|
||||||
.context("queue submit")?;
|
.context("queue submit")?;
|
||||||
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still
|
self.device
|
||||||
// executing — `self.cmd` and `self.fence` are reused every frame, and the caller retries
|
|
||||||
// on the SAME bridge (and `ensure_dst` later destroys `dst.buffer` assuming no in-flight
|
|
||||||
// work references it). Drain the GPU and reset the fence before propagating so the shared
|
|
||||||
// cmd/fence return clean.
|
|
||||||
if let Err(e) = self
|
|
||||||
.device
|
|
||||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||||
{
|
.context("fence wait")?;
|
||||||
let _ = self.device.device_wait_idle();
|
|
||||||
let _ = self.device.reset_fences(&[self.fence]);
|
|
||||||
return Err(e).context("fence wait");
|
|
||||||
}
|
|
||||||
self.device
|
self.device
|
||||||
.reset_fences(&[self.fence])
|
.reset_fences(&[self.fence])
|
||||||
.context("reset fence")?;
|
.context("reset fence")?;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=src/abi.rs");
|
println!("cargo:rerun-if-changed=src/abi.rs");
|
||||||
println!("cargo:rerun-if-changed=src/config.rs");
|
println!("cargo:rerun-if-changed=src/config.rs");
|
||||||
println!("cargo:rerun-if-changed=src/input.rs");
|
println!("cargo:rerun-if-changed=src/input.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/client.rs");
|
||||||
println!("cargo:rerun-if-changed=src/error.rs");
|
println!("cargo:rerun-if-changed=src/error.rs");
|
||||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||||
|
|
||||||
|
|||||||
@@ -78,11 +78,6 @@ impl PunktfunkConfig {
|
|||||||
u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
||||||
let max_data_per_block =
|
let max_data_per_block =
|
||||||
u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
||||||
// The one narrowing here that differs by target width: on 32-bit (armeabi-v7a) an
|
|
||||||
// `as usize` silently truncates a >4 GiB value to a plausible-looking residue that
|
|
||||||
// passes validate() — reject it instead, like every narrowing above.
|
|
||||||
let max_frame_bytes =
|
|
||||||
usize::try_from(self.max_frame_bytes).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
role,
|
role,
|
||||||
phase,
|
phase,
|
||||||
@@ -92,7 +87,7 @@ impl PunktfunkConfig {
|
|||||||
max_data_per_block,
|
max_data_per_block,
|
||||||
},
|
},
|
||||||
shard_payload: self.shard_payload as usize,
|
shard_payload: self.shard_payload as usize,
|
||||||
max_frame_bytes,
|
max_frame_bytes: self.max_frame_bytes as usize,
|
||||||
encrypt: self.encrypt != 0,
|
encrypt: self.encrypt != 0,
|
||||||
key: self.key,
|
key: self.key,
|
||||||
salt: self.salt,
|
salt: self.salt,
|
||||||
@@ -130,12 +125,6 @@ pub struct PunktfunkFrame {
|
|||||||
pub frame_index: u32,
|
pub frame_index: u32,
|
||||||
pub pts_ns: u64,
|
pub pts_ns: u64,
|
||||||
pub flags: u32,
|
pub flags: u32,
|
||||||
/// Wall-clock reassembly-completion instant (ns since the Unix epoch, CLOCK_REALTIME — the
|
|
||||||
/// clock `pts_ns` and the skew handshake use). THIS is the receipt stamp for latency math:
|
|
||||||
/// a stamp the embedder takes itself at the poll return additionally contains the
|
|
||||||
/// pre-decode hand-off queue wait, so a client-side standing backlog would masquerade as
|
|
||||||
/// network latency (ABI v9 — the 2026-07 two-pair standing-latency investigation).
|
|
||||||
pub received_ns: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of session counters.
|
/// Snapshot of session counters.
|
||||||
@@ -402,7 +391,6 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
|||||||
frame_index: f.frame_index,
|
frame_index: f.frame_index,
|
||||||
pts_ns: f.pts_ns,
|
pts_ns: f.pts_ns,
|
||||||
flags: f.flags,
|
flags: f.flags,
|
||||||
received_ns: f.received_ns,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
PunktfunkStatus::Ok
|
PunktfunkStatus::Ok
|
||||||
@@ -468,31 +456,23 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
|
|||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
|
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
|
||||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let s = match unsafe { s.as_mut() } {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return PunktfunkStatus::NullPointer as i32,
|
||||||
|
};
|
||||||
|
let cb = s.input_cb;
|
||||||
let mut count = 0i32;
|
let mut count = 0i32;
|
||||||
loop {
|
loop {
|
||||||
// Narrow scope: re-derive the handle and pull ONE event, then drop the borrow
|
match s.inner.poll_input() {
|
||||||
// before dispatching. The callback may legally re-enter `punktfunk_*` on this
|
Ok(Some(ev)) => {
|
||||||
// handle (get_stats, send_input, clearing the callback) — with a `&mut` held
|
if let Some((cb, user)) = cb {
|
||||||
// across the call that re-entry aliased it (UB under noalias). Re-reading
|
cb(&ev as *const InputEvent, user);
|
||||||
// `input_cb` per iteration also makes a mid-drain
|
}
|
||||||
// `punktfunk_set_input_callback(s, NULL, NULL)` take effect immediately instead
|
count += 1;
|
||||||
// of firing the cleared callback for the queued remainder. (Freeing the session
|
|
||||||
// from inside the callback remains forbidden, as on every entry point.)
|
|
||||||
let (ev, cb) = {
|
|
||||||
let s = match unsafe { s.as_mut() } {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return PunktfunkStatus::NullPointer as i32,
|
|
||||||
};
|
|
||||||
match s.inner.poll_input() {
|
|
||||||
Ok(Some(ev)) => (ev, s.input_cb),
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(e) => return e.status() as i32,
|
|
||||||
}
|
}
|
||||||
};
|
Ok(None) => break,
|
||||||
if let Some((cb, user)) = cb {
|
Err(e) => return e.status() as i32,
|
||||||
cb(&ev as *const InputEvent, user);
|
|
||||||
}
|
}
|
||||||
count += 1;
|
|
||||||
}
|
}
|
||||||
count
|
count
|
||||||
}));
|
}));
|
||||||
@@ -898,8 +878,8 @@ pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0;
|
|||||||
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
||||||
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
||||||
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
||||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored on
|
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
||||||
/// Linux (UHID) and Windows (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
||||||
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
||||||
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
||||||
@@ -908,8 +888,8 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
|||||||
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||||
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
||||||
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
||||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored on Linux (UHID) and Windows
|
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||||
/// (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
/// hosts); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||||
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||||
@@ -919,12 +899,10 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
|||||||
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||||
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
/// client's back paddles land on native slots. Honored on Linux (UHID `hid-playstation`) and
|
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||||
/// Windows (UMDF) hosts; otherwise the host falls back to X-Box 360.
|
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
||||||
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||||
/// positional layout, gyro/accel, HD rumble. Honored only where available (Linux hosts, UHID
|
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||||
/// `hid-nintendo`); otherwise the host falls back to X-Box 360.
|
|
||||||
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||||
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
||||||
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
|
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
|
||||||
@@ -1766,7 +1744,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
|
|||||||
frame_index: f.frame_index,
|
frame_index: f.frame_index,
|
||||||
pts_ns: f.pts_ns,
|
pts_ns: f.pts_ns,
|
||||||
flags: f.flags,
|
flags: f.flags,
|
||||||
received_ns: f.received_ns,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
PunktfunkStatus::Ok
|
PunktfunkStatus::Ok
|
||||||
@@ -1929,13 +1906,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
|||||||
}
|
}
|
||||||
let AudioPcmState { decoder, pcm } = &mut *state;
|
let AudioPcmState { decoder, pcm } = &mut *state;
|
||||||
let dec = decoder.as_mut().unwrap();
|
let dec = decoder.as_mut().unwrap();
|
||||||
// A header-only datagram (DTX silence — a legal wire form) must be SKIPPED, not
|
|
||||||
// decoded: `decode_float` treats an empty payload as a loss and synthesizes a full
|
|
||||||
// 120 ms of concealment for a ~5 ms slot, growing the playout ring without bound.
|
|
||||||
// Mirrors the host mic pump's guard; the sink underruns to silence on its own.
|
|
||||||
if pkt.data.is_empty() {
|
|
||||||
return PunktfunkStatus::NoFrame;
|
|
||||||
}
|
|
||||||
// `decode_float` divides the output buffer length by the channel count to get the
|
// `decode_float` divides the output buffer length by the channel count to get the
|
||||||
// per-channel capacity; an empty payload requests packet-loss concealment.
|
// per-channel capacity; an empty payload requests packet-loss concealment.
|
||||||
match dec.decode_float(&pkt.data, pcm, false) {
|
match dec.decode_float(&pkt.data, pcm, false) {
|
||||||
@@ -2986,14 +2956,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
|||||||
unsafe { *out = out_ev };
|
unsafe { *out = out_ev };
|
||||||
PunktfunkStatus::Ok
|
PunktfunkStatus::Ok
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => e.status(),
|
||||||
// Release the parked payload once the embedder polls past it: clipboard
|
|
||||||
// traffic is sporadic, so without this a one-off 50 MiB paste stays resident
|
|
||||||
// for the rest of the session (there is no other release entry point). The
|
|
||||||
// borrow contract already says `out` data is valid only until the next call.
|
|
||||||
*c.last_clip.lock().unwrap() = None;
|
|
||||||
e.status()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3083,35 +3046,6 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The **live** host↔client wall-clock offset (nanoseconds, host minus client): the
|
|
||||||
/// connect-time estimate of [`punktfunk_connection_clock_offset_ns`], updated by every applied
|
|
||||||
/// mid-stream clock re-sync. Ongoing latency math (per-frame `received − pts` splits, the
|
|
||||||
/// glass-to-glass meter) must use this one — after a wall-clock step/slew the frozen
|
|
||||||
/// connect-time value reads tens of milliseconds wrong for the rest of the session, while the
|
|
||||||
/// core itself has already re-synced. Same clock contract as the connect-time getter.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
|
|
||||||
#[cfg(feature = "quic")]
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
|
|
||||||
c: *const PunktfunkConnection,
|
|
||||||
offset_ns: *mut i64,
|
|
||||||
) -> PunktfunkStatus {
|
|
||||||
guard(|| {
|
|
||||||
let c = match unsafe { c.as_ref() } {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return PunktfunkStatus::NullPointer,
|
|
||||||
};
|
|
||||||
unsafe {
|
|
||||||
if !offset_ns.is_null() {
|
|
||||||
*offset_ns = c.inner.clock_offset_now_ns();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PunktfunkStatus::Ok
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
||||||
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
||||||
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
||||||
@@ -3251,11 +3185,6 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
|||||||
out: *mut u64,
|
out: *mut u64,
|
||||||
) -> PunktfunkStatus {
|
) -> PunktfunkStatus {
|
||||||
guard(|| {
|
guard(|| {
|
||||||
// The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
|
|
||||||
// check, so an embedder that skips the status never reads an uninitialized slot.
|
|
||||||
if !out.is_null() {
|
|
||||||
unsafe { *out = 0 };
|
|
||||||
}
|
|
||||||
let c = match unsafe { c.as_ref() } {
|
let c = match unsafe { c.as_ref() } {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return PunktfunkStatus::NullPointer,
|
None => return PunktfunkStatus::NullPointer,
|
||||||
@@ -3313,11 +3242,6 @@ pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
|
|||||||
out: *mut bool,
|
out: *mut bool,
|
||||||
) -> PunktfunkStatus {
|
) -> PunktfunkStatus {
|
||||||
guard(|| {
|
guard(|| {
|
||||||
// The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
|
|
||||||
// check: an uninitialized byte is not even a valid C++/Swift bool to read.
|
|
||||||
if !out.is_null() {
|
|
||||||
unsafe { *out = false };
|
|
||||||
}
|
|
||||||
let c = match unsafe { c.as_ref() } {
|
let c = match unsafe { c.as_ref() } {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return PunktfunkStatus::NullPointer,
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
|||||||
@@ -73,142 +73,6 @@ pub(crate) const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
|
|||||||
/// FIRST no-op clock flush — the moment a step is actually suspected.
|
/// FIRST no-op clock flush — the moment a step is actually suspected.
|
||||||
pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
|
pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Standing-latency bleed (the 2026-07 two-pair investigation): how far above the session's own
|
|
||||||
/// one-way-delay floor a report window's MINIMUM must sit to count as a standing elevation. The
|
|
||||||
/// jump-to-live detectors above deliberately ignore anything below ~6 frames / 400 ms, so a
|
|
||||||
/// small standing state — a sub-frame kernel/reassembly backlog, or a stale clock offset after a
|
|
||||||
/// wall-clock step — is carried forever and reads as permanent extra "network" latency. 10 ms
|
|
||||||
/// sits above skew-handshake error + normal LAN jitter, and below a single 60 fps frame period,
|
|
||||||
/// so the observed one-frame plateau (~17 ms) trips it while a healthy stream cannot.
|
|
||||||
pub(crate) const STANDING_LAT_THRESH_NS: i128 = 10_000_000;
|
|
||||||
|
|
||||||
/// Consecutive elevated report windows (~750 ms each) before the bleed escalates — ~4.5 s of a
|
|
||||||
/// continuously standing, loss-free elevation. Windows with any loss reset the run: loss means
|
|
||||||
/// genuine congestion, which the FEC/ABR machinery owns, not this detector.
|
|
||||||
pub(crate) const STANDING_LAT_WINDOWS: u32 = 6;
|
|
||||||
|
|
||||||
/// Per-session cap on flush+keyframe bleeds. A standing state that survives a clock re-sync AND
|
|
||||||
/// this many local flushes is not local and not clock — the path latency itself changed; the
|
|
||||||
/// detector disarms with a warning instead of paying a recovery keyframe every few seconds.
|
|
||||||
pub(crate) const STANDING_LAT_MAX_BLEEDS: u32 = 3;
|
|
||||||
|
|
||||||
/// What the standing-latency detector asks the pump to do this window (see [`StandingLatency`]).
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub(crate) enum StandingLatAction {
|
|
||||||
None,
|
|
||||||
/// First escalation: ask for a mid-stream clock re-sync — free, and a stale offset from a
|
|
||||||
/// stepped/slewed wall clock produces exactly this signature (an applied re-sync re-bases
|
|
||||||
/// the floor via the pump's `clock_gen` watch, clearing the elevation if that was the cause).
|
|
||||||
Resync {
|
|
||||||
above_ms: i64,
|
|
||||||
},
|
|
||||||
/// The elevation survived a re-sync attempt: flush the local receive backlog + request a
|
|
||||||
/// keyframe (the jump-to-live action), draining a real sub-threshold standing queue. The
|
|
||||||
/// pump reports execution back via [`StandingLatency::bled`]; an unexecuted action simply
|
|
||||||
/// re-arms next window.
|
|
||||||
Bleed {
|
|
||||||
above_ms: i64,
|
|
||||||
},
|
|
||||||
/// Bleed cap reached and the elevation is back: give up and say so.
|
|
||||||
Disarm {
|
|
||||||
above_ms: i64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detector for a small, constant, loss-free one-way-delay elevation — the standing state the
|
|
||||||
/// jump-to-live thresholds deliberately tolerate. Tracks the session's OWD floor (minimum of
|
|
||||||
/// report-window minimums since start / last re-base) and escalates when windows sit
|
|
||||||
/// persistently above it: re-sync first, then a bounded number of flush+keyframe bleeds, then
|
|
||||||
/// disarm. Pure state machine (no clocks, no I/O) so the escalation ladder is unit-testable.
|
|
||||||
pub(crate) struct StandingLatency {
|
|
||||||
/// Lowest window-minimum OWD seen since session start / last [`rebase`](Self::rebase).
|
|
||||||
floor_ns: Option<i128>,
|
|
||||||
/// Minimum per-frame OWD this report window; `None` = no frames yet.
|
|
||||||
window_min_ns: Option<i128>,
|
|
||||||
/// Consecutive elevated windows.
|
|
||||||
run: u32,
|
|
||||||
/// The current elevation already got its re-sync request — next escalation is a bleed.
|
|
||||||
resync_tried: bool,
|
|
||||||
bleeds: u32,
|
|
||||||
disarmed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandingLatency {
|
|
||||||
pub(crate) fn new() -> Self {
|
|
||||||
StandingLatency {
|
|
||||||
floor_ns: None,
|
|
||||||
window_min_ns: None,
|
|
||||||
run: 0,
|
|
||||||
resync_tried: false,
|
|
||||||
bleeds: 0,
|
|
||||||
disarmed: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed one frame's skew-corrected OWD (capture→reassembly-complete, ns). Caller gates on a
|
|
||||||
/// live clock offset and plausibility (0 < owd < 10 s), like the ABR OWD signal.
|
|
||||||
pub(crate) fn note_frame(&mut self, owd_ns: i128) {
|
|
||||||
self.window_min_ns = Some(match self.window_min_ns {
|
|
||||||
Some(m) => m.min(owd_ns),
|
|
||||||
None => owd_ns,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close a report window. `loss_free` = the window carried zero loss (loss resets the run —
|
|
||||||
/// congestion is the FEC/ABR machinery's problem, and queues under loss are not "standing").
|
|
||||||
pub(crate) fn on_window(&mut self, loss_free: bool) -> StandingLatAction {
|
|
||||||
let Some(wmin) = self.window_min_ns.take() else {
|
|
||||||
return StandingLatAction::None; // no frames this window — no evidence either way
|
|
||||||
};
|
|
||||||
let floor = *self.floor_ns.get_or_insert(wmin);
|
|
||||||
self.floor_ns = Some(floor.min(wmin));
|
|
||||||
let above_ns = wmin - floor;
|
|
||||||
if self.disarmed {
|
|
||||||
return StandingLatAction::None;
|
|
||||||
}
|
|
||||||
if !loss_free || above_ns < STANDING_LAT_THRESH_NS {
|
|
||||||
self.run = 0;
|
|
||||||
if above_ns < STANDING_LAT_THRESH_NS {
|
|
||||||
self.resync_tried = false; // elevation cleared — a future one re-syncs first again
|
|
||||||
}
|
|
||||||
return StandingLatAction::None;
|
|
||||||
}
|
|
||||||
self.run += 1;
|
|
||||||
if self.run < STANDING_LAT_WINDOWS {
|
|
||||||
return StandingLatAction::None;
|
|
||||||
}
|
|
||||||
self.run = 0; // each escalation gets a fresh observation run
|
|
||||||
let above_ms = (above_ns / 1_000_000) as i64;
|
|
||||||
if !self.resync_tried {
|
|
||||||
self.resync_tried = true;
|
|
||||||
StandingLatAction::Resync { above_ms }
|
|
||||||
} else if self.bleeds < STANDING_LAT_MAX_BLEEDS {
|
|
||||||
StandingLatAction::Bleed { above_ms }
|
|
||||||
} else {
|
|
||||||
self.disarmed = true;
|
|
||||||
StandingLatAction::Disarm { above_ms }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pump executed a [`StandingLatAction::Bleed`] (flush + keyframe). The floor is KEPT: a
|
|
||||||
/// successful bleed brings OWD back down to it (elevation clears naturally); an unsuccessful
|
|
||||||
/// one leaves the elevation visible so the ladder continues toward the cap.
|
|
||||||
pub(crate) fn bled(&mut self) {
|
|
||||||
self.bleeds += 1;
|
|
||||||
self.window_min_ns = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mid-stream clock re-sync was APPLIED (the pump's `clock_gen` watch): every OWD reading
|
|
||||||
/// shifted, so the floor and any elevation measured under the old offset are meaningless —
|
|
||||||
/// re-learn from scratch. The bleed budget survives (it caps keyframes per session).
|
|
||||||
pub(crate) fn rebase(&mut self) {
|
|
||||||
self.floor_ns = None;
|
|
||||||
self.window_min_ns = None;
|
|
||||||
self.run = 0;
|
|
||||||
self.resync_tried = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal.
|
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal.
|
||||||
/// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the
|
/// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the
|
||||||
/// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
|
/// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
|
||||||
@@ -327,7 +191,6 @@ mod frame_channel_tests {
|
|||||||
pts_ns: i as u64,
|
pts_ns: i as u64,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
complete: true,
|
complete: true,
|
||||||
received_ns: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,143 +258,3 @@ mod frame_channel_tests {
|
|||||||
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
|
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod standing_latency_tests {
|
|
||||||
use super::{
|
|
||||||
StandingLatAction, StandingLatency, STANDING_LAT_MAX_BLEEDS, STANDING_LAT_THRESH_NS,
|
|
||||||
STANDING_LAT_WINDOWS,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLOOR: i128 = 2_000_000; // a healthy 2 ms LAN OWD
|
|
||||||
const ELEVATED: i128 = FLOOR + STANDING_LAT_THRESH_NS + 7_000_000; // ~one 60fps frame above
|
|
||||||
|
|
||||||
/// Run `n` windows at `owd`, asserting every window but the last returns None; returns the
|
|
||||||
/// last window's action.
|
|
||||||
fn run_windows(d: &mut StandingLatency, owd: i128, n: u32) -> StandingLatAction {
|
|
||||||
for i in 0..n {
|
|
||||||
d.note_frame(owd);
|
|
||||||
let a = d.on_window(true);
|
|
||||||
if i + 1 < n {
|
|
||||||
assert_eq!(a, StandingLatAction::None, "window {i} escalated early");
|
|
||||||
} else {
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unreachable!("n > 0 by construction");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Learn a clean floor: one window at the healthy OWD.
|
|
||||||
fn learned(d: &mut StandingLatency) {
|
|
||||||
d.note_frame(FLOOR);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn healthy_stream_never_escalates() {
|
|
||||||
let mut d = StandingLatency::new();
|
|
||||||
learned(&mut d);
|
|
||||||
// Jitter riding above the floor but under the threshold: never a run.
|
|
||||||
for _ in 0..(STANDING_LAT_WINDOWS * 4) {
|
|
||||||
d.note_frame(FLOOR + STANDING_LAT_THRESH_NS - 1);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn escalation_ladder_resync_then_bleeds_then_disarm() {
|
|
||||||
let mut d = StandingLatency::new();
|
|
||||||
learned(&mut d);
|
|
||||||
// First full elevated run asks for the free fix: a clock re-sync.
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Resync { .. }
|
|
||||||
));
|
|
||||||
// Re-sync didn't help (no rebase came) — each further run is a bleed, up to the cap...
|
|
||||||
for _ in 0..STANDING_LAT_MAX_BLEEDS {
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Bleed { .. }
|
|
||||||
));
|
|
||||||
d.bled();
|
|
||||||
}
|
|
||||||
// ...then the detector gives up loudly, once, and stays quiet.
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Disarm { .. }
|
|
||||||
));
|
|
||||||
d.note_frame(ELEVATED);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loss_windows_reset_the_run() {
|
|
||||||
let mut d = StandingLatency::new();
|
|
||||||
learned(&mut d);
|
|
||||||
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
|
|
||||||
d.note_frame(ELEVATED);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
}
|
|
||||||
// A lossy window means congestion, not a standing state: run resets...
|
|
||||||
d.note_frame(ELEVATED);
|
|
||||||
assert_eq!(d.on_window(false), StandingLatAction::None);
|
|
||||||
// ...so the ladder needs the full run again before acting.
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Resync { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recovery_resets_the_ladder_to_resync_first() {
|
|
||||||
let mut d = StandingLatency::new();
|
|
||||||
learned(&mut d);
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Resync { .. }
|
|
||||||
));
|
|
||||||
// The elevation clears on its own (e.g. the successful bleed case, or transient): the
|
|
||||||
// next episode starts back at the free escalation, not at a bleed.
|
|
||||||
d.note_frame(FLOOR);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Resync { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn applied_resync_rebases_and_clears_a_stale_offset_elevation() {
|
|
||||||
let mut d = StandingLatency::new();
|
|
||||||
learned(&mut d);
|
|
||||||
assert!(matches!(
|
|
||||||
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
||||||
StandingLatAction::Resync { .. }
|
|
||||||
));
|
|
||||||
// The re-sync APPLIES (pump sees clock_gen move) → rebase. The corrected offset brings
|
|
||||||
// OWD readings back to truth; the floor re-learns and nothing ever escalates to a bleed.
|
|
||||||
d.rebase();
|
|
||||||
for _ in 0..(STANDING_LAT_WINDOWS * 2) {
|
|
||||||
d.note_frame(FLOOR);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_windows_are_no_evidence() {
|
|
||||||
let mut d = StandingLatency::new();
|
|
||||||
learned(&mut d);
|
|
||||||
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
|
|
||||||
d.note_frame(ELEVATED);
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
}
|
|
||||||
// A frameless window (paused stream) neither advances nor resets the run...
|
|
||||||
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
||||||
// ...so one more elevated window completes it.
|
|
||||||
d.note_frame(ELEVATED);
|
|
||||||
assert!(matches!(
|
|
||||||
d.on_window(true),
|
|
||||||
StandingLatAction::Resync { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -425,12 +425,6 @@ impl NativeClient {
|
|||||||
Ok(Ok(t)) => t,
|
Ok(Ok(t)) => t,
|
||||||
Ok(Err(e)) => return Err(e),
|
Ok(Err(e)) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// A connect we already reported as failed must not leave a lingering host
|
|
||||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
|
||||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
|
||||||
// instead of holding the session (and its virtual display) for a reconnect
|
|
||||||
// that will never come.
|
|
||||||
quit.store(true, Ordering::SeqCst);
|
|
||||||
shutdown.store(true, Ordering::SeqCst);
|
shutdown.store(true, Ordering::SeqCst);
|
||||||
return Err(PunktfunkError::Timeout);
|
return Err(PunktfunkError::Timeout);
|
||||||
}
|
}
|
||||||
@@ -462,14 +456,9 @@ impl NativeClient {
|
|||||||
hot_tids,
|
hot_tids,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
decode_lat,
|
decode_lat,
|
||||||
// The controller arms exactly when the pump does — all three terms, not two: Automatic
|
// The controller arms exactly when the pump does (see `abr::BitrateController::new`
|
||||||
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host
|
// below): Automatic (the user asked for bitrate 0) and not a rate-pinned PyroWave stream.
|
||||||
// echoed the rate it actually configured. Dropping the last term made this
|
wants_decode: bitrate_kbps == 0 && negotiated.codec != crate::quic::CODEC_PYROWAVE,
|
||||||
// over-advertise against an old host that reports no rate, so an embedder fed decode
|
|
||||||
// latency to a controller that never runs.
|
|
||||||
wants_decode: bitrate_kbps == 0
|
|
||||||
&& negotiated.codec != crate::quic::CODEC_PYROWAVE
|
|
||||||
&& negotiated.bitrate_kbps > 0,
|
|
||||||
mode: mode_slot,
|
mode: mode_slot,
|
||||||
host_fingerprint: negotiated.host_fingerprint,
|
host_fingerprint: negotiated.host_fingerprint,
|
||||||
resolved_compositor: negotiated.compositor,
|
resolved_compositor: negotiated.compositor,
|
||||||
@@ -714,23 +703,14 @@ impl NativeClient {
|
|||||||
// Reset the accumulator so a fresh run doesn't blend into the previous one.
|
// Reset the accumulator so a fresh run doesn't blend into the previous one.
|
||||||
*self.probe.lock().unwrap() = ProbeState {
|
*self.probe.lock().unwrap() = ProbeState {
|
||||||
active: true,
|
active: true,
|
||||||
duration_ms,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let sent = self
|
self.ctrl_tx
|
||||||
.ctrl_tx
|
|
||||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||||
target_kbps,
|
target_kbps,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
}))
|
}))
|
||||||
.map_err(|_| PunktfunkError::Closed);
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
if sent.is_err() {
|
|
||||||
// Nothing was asked of the host, so nothing will ever answer. Leaving `active` latched
|
|
||||||
// would suppress the pump's entire report tick for the rest of the session (the pump
|
|
||||||
// mirrors the startup path's rollback at the same point).
|
|
||||||
self.probe.lock().unwrap().active = false;
|
|
||||||
}
|
|
||||||
sent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the current speed-test measurement (partial until `done`, final once the host's
|
/// Read the current speed-test measurement (partial until `done`, final once the host's
|
||||||
@@ -765,9 +745,7 @@ impl NativeClient {
|
|||||||
0.0
|
0.0
|
||||||
} as f32;
|
} as f32;
|
||||||
// Host-side drop: what the send buffer couldn't even accept (the host-side ceiling).
|
// Host-side drop: what the send buffer couldn't even accept (the host-side ceiling).
|
||||||
// Saturating: both counters arrive verbatim off the wire (same discipline as the
|
let offered_wire = p.host_wire_packets + p.host_send_dropped;
|
||||||
// saturating_sub/mul above — a hostile sum must not overflow-panic a debug build).
|
|
||||||
let offered_wire = p.host_wire_packets.saturating_add(p.host_send_dropped);
|
|
||||||
let host_drop_pct = if offered_wire > 0 {
|
let host_drop_pct = if offered_wire > 0 {
|
||||||
p.host_send_dropped as f64 / offered_wire as f64 * 100.0
|
p.host_send_dropped as f64 / offered_wire as f64 * 100.0
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user