Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a784682d4c | |||
| 134874b3c8 | |||
| b22d0da75b | |||
| d398e2296f | |||
| f9668b16a1 | |||
| 04e4394ee0 | |||
| 9296e1bed7 | |||
| c2bba13405 | |||
| 940a260506 | |||
| 7084ebf78d | |||
| 40c2f68231 | |||
| 8fe90a8a4b | |||
| cac23b7a05 | |||
| 2508b72062 | |||
| aa45757a72 | |||
| cab6350723 | |||
| a042dc2850 | |||
| 67d2964db9 | |||
| 344618e1f0 | |||
| 51f7ff1b3f | |||
| 7a1809547b | |||
| 72583b272e | |||
| 1ef0229bd9 | |||
| fecbec2daf | |||
| ba1caf0281 | |||
| 677a31acd7 | |||
| beb7a4dae9 | |||
| a65c47874d | |||
| b781b38b77 | |||
| 0b8e8312a5 | |||
| f0c511c8fa | |||
| f250db96f4 | |||
| 62af85eaea | |||
| 6fc32ee355 | |||
| ffa7ebb3db | |||
| 8a40e46706 | |||
| 17302ee811 | |||
| 70fd8ac0d3 | |||
| c66acebc41 | |||
| 8a71ed3fd1 | |||
| ba977095f7 | |||
| ab2bcc8e68 | |||
| d2daeacc60 | |||
| 1d587a259e | |||
| 1436c0b9b4 | |||
| fb8deb31a5 | |||
| 40030e90c8 | |||
| 2149673f89 | |||
| c4d6e6a877 | |||
| efbcc4cdb6 | |||
| 168040005a | |||
| 99a1d1bed6 | |||
| 2123e4e580 | |||
| 232b41e88b | |||
| 59b766fb6c | |||
| 00f759ec72 | |||
| c131603f0d | |||
| a83acf3ee1 | |||
| 3ae5f222d3 | |||
| 0e977817f9 | |||
| 4f64125025 | |||
| a8c8b1bb13 | |||
| 75474dcc90 | |||
| ac0e73321c | |||
| dc20e4452e | |||
| 017b083e32 | |||
| e5eec51a78 | |||
| 9fe9c451dc | |||
| b89dbfa979 | |||
| 770994b7aa | |||
| 3ff1973d7f | |||
| 188edde2b3 | |||
| 4861824e7d | |||
| 5eb930e71d | |||
| 97cf15e3b7 | |||
| 94533bb071 | |||
| f407f41855 | |||
| 574e3e4e3f | |||
| ebd9967547 | |||
| 1e7c18b2c8 | |||
| 9aebc3f251 | |||
| 7f639f7cf5 | |||
| 2c0aee3979 | |||
| ec84b30eae | |||
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 | |||
| 4d2632a845 | |||
| 17a262aace | |||
| 205e5c5a59 | |||
| 3584b47fb8 | |||
| de651c0f1a | |||
| e7a8cf2bba | |||
| fb92dcd1c2 | |||
| 8e75f3d8bf | |||
| 5e1e64e50b | |||
| fbe1e62ef2 | |||
| 0c9d3ee33f | |||
| df04c2c54f | |||
| 9da38b8d9c | |||
| 1bc156aab0 | |||
| 399ea9a0f1 | |||
| df70bf00f9 | |||
| 9e6ab9b94d | |||
| 6f409caac7 | |||
| 5977b4b9f1 | |||
| 0a617e7779 | |||
| 75d1322b8a | |||
| 55e7f3fca9 | |||
| 055b95a181 | |||
| f4b52d0bf5 | |||
| bbe4380b41 | |||
| aedee2a4e3 | |||
| c28b10a5b9 | |||
| 78d018ae2f | |||
| 1b58130e68 | |||
| 12d4b025f7 | |||
| 871ebb31ce | |||
| 600693914f | |||
| 4d89dcd3d7 | |||
| 97c5778a36 | |||
| 22b352c1da | |||
| 15233a68cf | |||
| 2a637eaf3f | |||
| 6c976e9dc5 | |||
| 691c064a37 | |||
| 34519566ba | |||
| 44b71e7460 |
@@ -1,4 +1,4 @@
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from
|
||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||
@@ -42,11 +42,12 @@ jobs:
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
|
||||
# the official binary.
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
@@ -105,7 +106,7 @@ jobs:
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
makepkg -f -d --holdver
|
||||
|
||||
+121
-18
@@ -1,8 +1,18 @@
|
||||
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
||||
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
||||
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
||||
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
||||
# target boxes run.
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||
# apt distribution/component:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||
#
|
||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
||||
# Box setup (once): see packaging/debian/README.md
|
||||
@@ -83,22 +93,15 @@ jobs:
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host + client
|
||||
- name: Build release clients
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
||||
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
@@ -128,10 +131,12 @@ jobs:
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
|
||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
@@ -166,3 +171,101 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
|
||||
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
|
||||
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
|
||||
# byte-identical so host and clients share a version line.
|
||||
build-publish-host:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Version + channel
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "host package version $V -> apt distribution '$DIST'"
|
||||
|
||||
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
|
||||
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
|
||||
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
|
||||
- name: dpkg-dev + patchelf + python3
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev patchelf python3
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||
# ci.yml's 26.04 target cache (mixing would poison both).
|
||||
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
||||
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
||||
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
||||
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-tray
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
|
||||
# defaults to /opt/ffmpeg (the image's ENV also sets it).
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the host .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
@@ -39,6 +39,12 @@ jobs:
|
||||
- image: punktfunk-rust-ci
|
||||
dockerfile: ci/rust-ci.Dockerfile
|
||||
context: ci
|
||||
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
||||
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
context: ci
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
context: ci
|
||||
|
||||
@@ -258,9 +258,12 @@ jobs:
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
||||
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
||||
# step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
@@ -271,6 +274,10 @@ jobs:
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
@@ -308,19 +315,32 @@ jobs:
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||
# manually-installed App Store distribution profile survives for export.
|
||||
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
||||
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
||||
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
||||
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
||||
# App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
||||
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
||||
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
@@ -335,7 +355,10 @@ jobs:
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
@@ -359,9 +382,11 @@ jobs:
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
||||
# DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
@@ -370,6 +395,10 @@ jobs:
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
|
||||
@@ -92,9 +92,10 @@ jobs:
|
||||
echo "rpm $V-$R -> group '$GROUP'"
|
||||
|
||||
- name: Build RPM
|
||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
||||
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
|
||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh
|
||||
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
||||
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
||||
# Recommends both). Both need bun (ensured in Prep).
|
||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
|
||||
|
||||
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
||||
env:
|
||||
@@ -131,7 +132,8 @@ jobs:
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Publish the TypeScript SDK (@punktfunk/host) to the Gitea npm registry
|
||||
# (https://git.unom.io/api/packages/unom/npm/).
|
||||
#
|
||||
# Trigger: push a tag `sdk-vX.Y.Z` (must equal sdk/package.json "version"), or run manually.
|
||||
# The SDK versions independently of the app's `v*` tags, so bumping the host doesn't republish it.
|
||||
#
|
||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
|
||||
# write:package scope). No new secret needed.
|
||||
name: sdk-publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['sdk-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: sdk
|
||||
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
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
- name: Build (dist/ JS + .d.ts)
|
||||
run: bun run build
|
||||
|
||||
- name: Tag matches package version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#sdk-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
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||
# .npmrc already maps the @punktfunk scope to the registry; append the auth line.
|
||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
||||
bun publish
|
||||
@@ -1,7 +1,8 @@
|
||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
||||
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
||||
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
||||
# windows-amd64 runner
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||
#
|
||||
@@ -23,10 +24,15 @@
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
# AMD/Intel-only boxes before main).
|
||||
# - QSV native (Intel, VPL — design/native-qsv-encoder.md): the MIT dispatcher is built from the
|
||||
# vendored tree (libvpl-sys, cmake+bindgen — LIBCLANG_PATH already in the runner env for
|
||||
# pyrowave-sys) and statically linked; the GPU runtime comes from the Intel driver store at run
|
||||
# time, so no new DLL ships and non-Intel boxes are unaffected. This is the Intel dispatch;
|
||||
# the ffmpeg *_qsv path below stays as its open-failure fallback until Phase 4 deletes it.
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||
@@ -40,9 +46,14 @@ on:
|
||||
- 'crates/punktfunk-host/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/punktfunk-tray/**'
|
||||
# The encode subsystem (split out in W6) + the vendored VPL dispatcher the `qsv` feature
|
||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||
- 'crates/pf-encode/**'
|
||||
- 'crates/libvpl-sys/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
- 'sdk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-host.yml'
|
||||
@@ -89,6 +100,12 @@ jobs:
|
||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x
|
||||
# refuses outright. Green runs today only survive on the cached configure output — a
|
||||
# target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as
|
||||
# observed on a clean build on this very runner (2026-07-17). No-op for compliant
|
||||
# projects (libvpl-sys pins 3.13+).
|
||||
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
@@ -114,10 +131,11 @@ jobs:
|
||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "host version $v"
|
||||
|
||||
- name: Build (release, nvenc + amf-qsv)
|
||||
- name: Build (release, nvenc + amf-qsv + qsv)
|
||||
shell: pwsh
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + native QSV (Intel, static VPL dispatcher)
|
||||
# + AMF/QSV (AMD + the Intel ffmpeg fallback, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv,qsv
|
||||
|
||||
- name: Build (release, status tray)
|
||||
shell: pwsh
|
||||
@@ -127,9 +145,18 @@ jobs:
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
|
||||
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
|
||||
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
|
||||
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
|
||||
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
|
||||
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
|
||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
@@ -196,6 +223,24 @@ jobs:
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
shell: pwsh
|
||||
# `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
|
||||
# SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
|
||||
# it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
|
||||
# public npm (effect), so no @unom token is needed here.
|
||||
run: |
|
||||
$bun = $env:BUN_EXE
|
||||
Push-Location sdk
|
||||
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
|
||||
New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
|
||||
& $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
|
||||
Pop-Location
|
||||
if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
|
||||
throw "runner bundle missing the dynamic plugin import - wrong build"
|
||||
}
|
||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
|
||||
@@ -104,8 +104,9 @@ jobs:
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
@@ -43,3 +43,7 @@ CLAUDE.md
|
||||
/result
|
||||
/result-*
|
||||
.direnv/
|
||||
|
||||
# Gradle build output inside the vendored pyrowave Granite Android platform (regenerated, never ours to commit)
|
||||
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/.gradle/
|
||||
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/build/
|
||||
|
||||
Generated
+134
-63
@@ -358,28 +358,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
@@ -560,6 +538,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
@@ -756,6 +740,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -1022,12 +1012,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
@@ -1141,6 +1125,15 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fec-rs"
|
||||
version = "0.1.0"
|
||||
@@ -1273,12 +1266,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -1495,6 +1482,16 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.22.6"
|
||||
@@ -1988,6 +1985,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -2145,7 +2159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2248,6 +2262,14 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2277,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2386,6 +2408,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nasm-rs"
|
||||
version = "0.3.2"
|
||||
@@ -2756,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2776,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2800,11 +2832,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"futures-util",
|
||||
"image",
|
||||
"libc",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
@@ -2817,7 +2850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2838,13 +2871,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"ffmpeg-next",
|
||||
"libc",
|
||||
"libloading",
|
||||
"libvpl-sys",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"pf-frame",
|
||||
@@ -2860,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2869,7 +2903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2881,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2895,11 +2929,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2927,14 +2961,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2949,7 +2983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2979,7 +3013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -2991,7 +3025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3075,6 +3109,19 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -3174,7 +3221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3190,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3206,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3221,7 +3268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3240,7 +3287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3271,7 +3318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3353,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3367,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3382,9 +3429,15 @@ dependencies = [
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -3583,7 +3636,6 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3812,7 +3864,6 @@ version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -3877,7 +3928,6 @@ version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -5202,6 +5252,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
@@ -6014,6 +6070,21 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ members = [
|
||||
"crates/pf-inject",
|
||||
"crates/pf-vdisplay",
|
||||
"crates/pyrowave-sys",
|
||||
"crates/libvpl-sys",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
@@ -47,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+480
-2
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.12.0"
|
||||
"version": "0.15.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1348,6 +1348,117 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "List the library scanners",
|
||||
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
|
||||
"operationId": "listLibraryScanners",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "This host's scanners with their enable state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScannerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Enable or disable a library scanner",
|
||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
|
||||
"operationId": "setLibraryScanner",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The scanner id (e.g. `steam`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ScannerToggle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Toggle stored; the full scanner list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScannerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such scanner on this platform",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/local/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1931,6 +2042,184 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "List registered plugins",
|
||||
"description": "The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry\nreports its id, title, optional version, and — for plugins that serve one — a UI descriptor\n(loopback port + icon). The console renders these as nav entries and proxies to the port; it\nfetches the secret separately, server-side.",
|
||||
"operationId": "listPlugins",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Live plugin registrations",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginSummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Register or renew a plugin",
|
||||
"description": "Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs\nthis every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console\nwill proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed\n(first registration, restart, or re-scan) — a pure renewal is silent.",
|
||||
"operationId": "registerPlugin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginRegistration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Registered / renewed"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid id or registration",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Deregister a plugin",
|
||||
"description": "The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls\nthis from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was\nremoved. Idempotent — deleting an unknown/expired id is a no-op `204`.",
|
||||
"operationId": "deregisterPlugin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The plugin id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Deregistered (or already absent)"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}/ui-credential": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Fetch a plugin UI's proxy credential",
|
||||
"description": "Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.\nBearer + loopback only (like every mutation), and additionally excluded from the console's browser\npassthrough: the secret never reaches a browser.",
|
||||
"operationId": "getPluginUiCredential",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The plugin id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The proxy credential",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UiCredential"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No live plugin with that id, or it serves no UI",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/session": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
@@ -2744,7 +3033,7 @@
|
||||
},
|
||||
"CustomEntry": {
|
||||
"type": "object",
|
||||
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
||||
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
@@ -3294,6 +3583,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"plugins.changed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -4099,6 +4407,116 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
},
|
||||
"ui": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginUi",
|
||||
"description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional plugin version, purely informational (≤32 chars)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginSummary": {
|
||||
"type": "object",
|
||||
"description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"ui": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginUiPublic"
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginUi": {
|
||||
"type": "object",
|
||||
"description": "A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request\nbody, never a response ([`PluginUiPublic`] is the secret-free view).",
|
||||
"required": [
|
||||
"port",
|
||||
"secret"
|
||||
],
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)."
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:<port>`; a registration can never carry a hostname.",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginUiPublic": {
|
||||
"type": "object",
|
||||
"description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.",
|
||||
"required": [
|
||||
"port"
|
||||
],
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PortMap": {
|
||||
"type": "object",
|
||||
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
||||
@@ -4364,6 +4782,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScannerToggle": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setLibraryScanner`.",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the scanner should run on this host."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionInfo": {
|
||||
"type": "object",
|
||||
"description": "Client-requested launch parameters (key material is never exposed here).",
|
||||
@@ -4710,6 +5166,24 @@
|
||||
"primary",
|
||||
"exclusive"
|
||||
]
|
||||
},
|
||||
"UiCredential": {
|
||||
"type": "object",
|
||||
"description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser.",
|
||||
"required": [
|
||||
"port",
|
||||
"secret"
|
||||
],
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -4772,6 +5246,10 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
|
||||
},
|
||||
{
|
||||
"name": "plugins",
|
||||
"description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
|
||||
#
|
||||
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
|
||||
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
|
||||
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
|
||||
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
|
||||
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
|
||||
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
|
||||
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
|
||||
# the package no longer depends on the distro's libav* at all. Everything else the host links
|
||||
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
|
||||
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
|
||||
#
|
||||
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
|
||||
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
|
||||
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
|
||||
# come from whatever driver the target runs, out of band.
|
||||
#
|
||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
|
||||
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
|
||||
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
|
||||
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
|
||||
FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
||||
dpkg-dev patchelf \
|
||||
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
||||
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
|
||||
nasm libva-dev libdrm-dev zlib1g-dev \
|
||||
# host link deps present on 24.04 with sonames compatible up to 26.04
|
||||
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
|
||||
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
|
||||
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
|
||||
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
|
||||
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
|
||||
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
|
||||
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
||||
ARG NVHDR_TAG=n12.2.72.0
|
||||
RUN set -eux; \
|
||||
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
||||
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
||||
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
||||
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
||||
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
||||
cd /tmp/ffmpeg; \
|
||||
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
||||
--prefix=/opt/ffmpeg \
|
||||
--enable-shared --disable-static \
|
||||
--disable-doc --disable-programs --disable-debug \
|
||||
--enable-nvenc --enable-vaapi \
|
||||
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
|
||||
make -j"$(nproc)"; make install; \
|
||||
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
|
||||
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
|
||||
test -e /opt/ffmpeg/lib/libavcodec.so.62
|
||||
|
||||
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
|
||||
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
|
||||
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
|
||||
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
|
||||
FFMPEG_PREFIX=/opt/ffmpeg
|
||||
|
||||
# Toolchain shared across CI users (jobs may run as different uids).
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
--component rustfmt,clippy \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo clippy --version && cargo fmt --version
|
||||
@@ -30,7 +30,13 @@ suspend fun connectToHost(
|
||||
): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
val (baseW, baseH, hz) = settings.effectiveMode(context)
|
||||
// Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples
|
||||
// (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under
|
||||
// native. 1.0 leaves the resolved mode untouched.
|
||||
val (w, h) = RenderScale.apply(
|
||||
baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec)
|
||||
)
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
|
||||
@@ -16,6 +16,14 @@ data class Settings(
|
||||
val height: Int = 0,
|
||||
val hz: Int = 0,
|
||||
val bitrateKbps: Int = 0,
|
||||
/**
|
||||
* Render-resolution multiplier: the client asks the host to render/encode at `chosen mode ×
|
||||
* renderScale` and the compositor downscales the larger decoded frame to the SurfaceView
|
||||
* (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native
|
||||
* for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped
|
||||
* even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale.
|
||||
*/
|
||||
val renderScale: Double = 1.0,
|
||||
/**
|
||||
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
||||
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
||||
@@ -137,6 +145,7 @@ class SettingsStore(context: Context) {
|
||||
height = prefs.getInt(K_H, 0),
|
||||
hz = prefs.getInt(K_HZ, 0),
|
||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
@@ -171,6 +180,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_H, s.height)
|
||||
.putInt(K_HZ, s.hz)
|
||||
.putInt(K_BITRATE, s.bitrateKbps)
|
||||
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
@@ -193,6 +203,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_H = "height"
|
||||
const val K_HZ = "hz"
|
||||
const val K_BITRATE = "bitrate_kbps"
|
||||
const val K_RENDER_SCALE = "render_scale"
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
@@ -281,6 +292,54 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(w, h, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side render-scale geometry — the Kotlin twin of `punktfunk-core`'s `render_scale` module
|
||||
* (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the
|
||||
* host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't
|
||||
* ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest].
|
||||
*/
|
||||
object RenderScale {
|
||||
val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0)
|
||||
|
||||
/** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */
|
||||
fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192
|
||||
|
||||
/** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */
|
||||
fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0
|
||||
|
||||
/** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */
|
||||
fun label(scale: Double): String = when {
|
||||
scale == 1.0 -> "Native (1×)"
|
||||
scale > 1.0 -> "${trim(scale)}× · supersample"
|
||||
else -> "${trim(scale)}×"
|
||||
}
|
||||
|
||||
private fun trim(s: Double): String =
|
||||
if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString()
|
||||
|
||||
/** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */
|
||||
fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair<Int, Int> {
|
||||
val s = sanitize(scale)
|
||||
var w = maxOf(baseW, 1) * s
|
||||
var h = maxOf(baseH, 1) * s
|
||||
val cap = maxDim.toDouble()
|
||||
val over = maxOf(w / cap, h / cap)
|
||||
if (over > 1.0) {
|
||||
w /= over
|
||||
h /= over
|
||||
}
|
||||
return Pair(evenFloor(w, 320), evenFloor(h, 200))
|
||||
}
|
||||
|
||||
private fun evenFloor(value: Double, minimum: Int): Int {
|
||||
val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0)
|
||||
return v / 2 * 2
|
||||
}
|
||||
}
|
||||
|
||||
/** (scale, label) for the render-scale picker. `1.0` = Native. */
|
||||
val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) }
|
||||
|
||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||
|
||||
/** (width, height, label). `(0,0)` = native display. */
|
||||
|
||||
@@ -333,6 +333,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
update(s.copy(bitrateKbps = kbps))
|
||||
}
|
||||
|
||||
SettingDropdown(
|
||||
label = "Render scale",
|
||||
options = RENDER_SCALE_OPTIONS,
|
||||
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
|
||||
// exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode);
|
||||
// < 1 renders under native for a lighter host — this device resamples to the display.
|
||||
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
|
||||
) { scale -> update(s.copy(renderScale = scale)) }
|
||||
|
||||
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
||||
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
||||
// device stays visible so the selection is always representable.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the client-side render-scale geometry ([RenderScale]) — the Kotlin twin of
|
||||
* `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`.
|
||||
*/
|
||||
class RenderScaleTest {
|
||||
@Test
|
||||
fun sanitizeClampsAndDefaults() {
|
||||
assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native
|
||||
assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0)
|
||||
assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0)
|
||||
assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor
|
||||
assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling
|
||||
assertEquals(1.5, RenderScale.sanitize(1.5), 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxDimensionIsCodecAware() {
|
||||
assertEquals(4096, RenderScale.maxDimension("h264"))
|
||||
assertEquals(8192, RenderScale.maxDimension("hevc"))
|
||||
assertEquals(8192, RenderScale.maxDimension("av1"))
|
||||
assertEquals(8192, RenderScale.maxDimension("auto"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeIsIdentity() {
|
||||
assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supersampleDoubles() {
|
||||
assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun underRenderHalves() {
|
||||
assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultsAreEven() {
|
||||
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
|
||||
val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192)
|
||||
assertEquals(0, w % 2)
|
||||
assertEquals(0, h % 2)
|
||||
assertEquals(2048 to 1152, w to h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overCeilingClampsUniformly() {
|
||||
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
|
||||
val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192)
|
||||
assertTrue(w <= 8192 && h <= 8192)
|
||||
assertEquals(8192 to 4608, w to h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun h264CeilingIsTighter() {
|
||||
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
|
||||
assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun minimumFloorHonoured() {
|
||||
val (w, h) = RenderScale.apply(400, 300, 0.5, 8192)
|
||||
assertTrue(w >= 320 && h >= 200)
|
||||
}
|
||||
}
|
||||
@@ -404,7 +404,14 @@ fn feeder_loop(
|
||||
// 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.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// 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
|
||||
.lock()
|
||||
|
||||
@@ -221,7 +221,13 @@ pub(super) fn run_sync(
|
||||
// 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.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
||||
// 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));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||
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, ); }; };
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -504,6 +504,7 @@
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -540,6 +541,7 @@
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -712,14 +714,14 @@
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -757,14 +759,14 @@
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -861,15 +863,15 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkKit;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
productName = SwiftUINavigationTransitions;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = AA000000000000000000000D /* Project object */;
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "MTL_HUD_ENABLED"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E2955696300948B9009F939C"
|
||||
BuildableName = "PunktfunkWidgetsExtension.appex"
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB0000000000000000000009"
|
||||
BuildableName = "Punktfunk-iOS.app"
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2"
|
||||
queueDebuggingEnabled = "No">
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "2"
|
||||
BundleIdentifier = "com.apple.springboard">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E2955696300948B9009F939C"
|
||||
BuildableName = "PunktfunkWidgetsExtension.appex"
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</RemoteRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB0000000000000000000009"
|
||||
BuildableName = "Punktfunk-iOS.app"
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetKind"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetDefaultView"
|
||||
value = "timeline"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetFamily"
|
||||
value = "systemMedium"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB0000000000000000000009"
|
||||
BuildableName = "Punktfunk-iOS.app"
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
.foregroundStyle(Color.brand)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Punktfunk")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(.tint)
|
||||
.foregroundStyle(Color.brand)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(.tint)
|
||||
.foregroundStyle(Color.brand)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
@@ -184,3 +184,47 @@ private struct EmptyHostView: View {
|
||||
.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,43 +19,69 @@ struct PunktfunkSessionLiveActivity: Widget {
|
||||
LockScreenView(context: context)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} 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 {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.brand)
|
||||
Text(context.attributes.hostName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.leading, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
ElapsedClock(startedAt: context.state.startedAt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 64, alignment: .trailing)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
// The expanded island's height is there to be used: one info row (status
|
||||
// leading, live numbers trailing — the mode string stays on the Lock
|
||||
// Screen), then the platform-conventional LARGE full-width action button.
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
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: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
CompactReadout(state: context.state)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
}
|
||||
.keylineTint(Color.brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,21 +95,20 @@ private struct LockScreenView: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
.foregroundStyle(Color.brand)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
ElapsedClock(startedAt: context.state.startedAt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
StatusLine(state: context.state)
|
||||
StatsLine(state: context.state)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
@@ -95,46 +120,237 @@ private struct LockScreenView: View {
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: View {
|
||||
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
||||
private struct ElapsedClock: 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
|
||||
|
||||
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 {
|
||||
case .streaming:
|
||||
Text("Streaming")
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Background · ends in")
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.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)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.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:
|
||||
EmptyView()
|
||||
if let ms = state.latencyMs {
|
||||
Text("\(ms) ms")
|
||||
.font(.caption2.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
ElapsedClock(startedAt: state.startedAt)
|
||||
.font(.caption2)
|
||||
.frame(maxWidth: 48)
|
||||
}
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Keeps running for")
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: 48)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
badge("Running in background", .orange)
|
||||
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
badge("Reconnecting…", .yellow)
|
||||
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
||||
case .ending:
|
||||
badge("Session ended", .secondary)
|
||||
Image(systemName: "stop.fill").foregroundStyle(.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).
|
||||
/// `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 {
|
||||
var fullWidth = false
|
||||
|
||||
var body: some View {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
if fullWidth {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End Session", systemImage: "stop.fill")
|
||||
.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,6 +89,11 @@ struct ContentView: View {
|
||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||
@State private var showShortcutHint = false
|
||||
#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)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
@@ -193,6 +198,9 @@ struct ContentView: View {
|
||||
#if os(macOS) || os(tvOS)
|
||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||
#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"
|
||||
// plus the accent ring on the most recent host).
|
||||
guard let host = model.activeHost else { break }
|
||||
@@ -292,7 +300,7 @@ struct ContentView: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { req in
|
||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
|
||||
+ "pair with the 4-digit PIN it can display.")
|
||||
}
|
||||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
||||
@@ -335,7 +343,7 @@ struct ContentView: View {
|
||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||
} message: { req in
|
||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||
+ "console (port 47992 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||
@@ -492,12 +500,19 @@ struct ContentView: View {
|
||||
}
|
||||
// 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
|
||||
// steering and the next click still reaches the stream.
|
||||
// steering and the next click still reaches the stream. Mounted ONLY while a
|
||||
// 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 {
|
||||
if pendingFingerprint == nil {
|
||||
ResizeIndicatorView(active: model.resizing)
|
||||
if pendingFingerprint == nil, model.resizing {
|
||||
ResizeIndicatorView(active: true)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||
if let fp = pendingFingerprint {
|
||||
TrustCardView(
|
||||
fingerprint: fp,
|
||||
@@ -564,13 +579,21 @@ struct ContentView: View {
|
||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||
split = model.latencySplit, queue = model.clientQueue,
|
||||
offset = conn.clockOffsetNs] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick).
|
||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
||||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
||||
// longer contains the client-queue wait...
|
||||
split.recordReceipt(
|
||||
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
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
@@ -587,7 +610,8 @@ struct ContentView: View {
|
||||
},
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage
|
||||
displayMeter: model.displayStage,
|
||||
presentFloorMeter: model.presentFloor
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||
@@ -636,18 +660,27 @@ struct ContentView: View {
|
||||
#if os(iOS)
|
||||
// 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) —
|
||||
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||
// sole touch disconnect path in those tiers.
|
||||
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
||||
// HUD) so the glyph stays legible over a bright frame.
|
||||
//
|
||||
// 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) {
|
||||
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||
if captureEnabled,
|
||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||
// over the frame (26+, material fallback). interactive: the disc
|
||||
// IS the tap target, so the glass reacts to press.
|
||||
// Floating glass disc over the frame (26+, material fallback).
|
||||
// interactive: the disc IS the tap target, so the glass reacts
|
||||
// to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
// Match the hit region to the visible disc so every tap also
|
||||
// triggers the interactive-glass press highlight.
|
||||
@@ -656,6 +689,12 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
.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
|
||||
|
||||
@@ -102,6 +102,26 @@ final class SessionModel: ObservableObject {
|
||||
@Published var decodeValid = false
|
||||
@Published var displayP50Ms = 0.0
|
||||
@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
|
||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||
@Published var lostFrames = 0
|
||||
@@ -133,6 +153,12 @@ final class SessionModel: ObservableObject {
|
||||
let endToEnd = LatencyMeter()
|
||||
let decodeStage = 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).
|
||||
private var lastFramesDropped: UInt64 = 0
|
||||
private var statsTimer: Timer?
|
||||
@@ -210,7 +236,13 @@ final class SessionModel: ObservableObject {
|
||||
// metadata we apply (Step 2) when it IS HDR.
|
||||
let displayHDR: Bool = {
|
||||
#if os(macOS)
|
||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
// POTENTIAL, not current, headroom: `maximumExtendedDynamicRangeColorComponentValue`
|
||||
// is the CURRENTLY-ALLOCATED headroom, which macOS hands out on demand — on an idle
|
||||
// SDR desktop it reads 1.0 even with HDR enabled and active (external HDR displays
|
||||
// like the Samsung G95SC allocate EDR only when content asks). Gating on it means an
|
||||
// HDR monitor never gets advertised at connect time. `maximumPotential…` is the
|
||||
// mode-independent capability (the macOS analogue of the tvOS/iOS gates below).
|
||||
return (NSScreen.main?.maximumPotentialExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
#elseif os(tvOS)
|
||||
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
||||
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||||
@@ -264,14 +296,11 @@ final class SessionModel: ObservableObject {
|
||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
||||
// advertises the bit and prefers it — the host never auto-selects it, and the
|
||||
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
||||
// every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
|
||||
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
||||
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
||||
// every M-series Mac and the ATV 4K gen 3 pass). The decoder self-configures from
|
||||
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ — design/pyrowave-444-hdr.md),
|
||||
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1.
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
||||
| PunktfunkConnection.videoCapHDR
|
||||
| PunktfunkConnection.videoCap444)
|
||||
}
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
@@ -335,7 +364,7 @@ final class SessionModel: ObservableObject {
|
||||
// operator didn't approve it before the host's park window elapsed (or
|
||||
// the host was unreachable).
|
||||
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||
+ "Approve it in the host's web console (port 47992 → Pairing), then "
|
||||
+ "request access again — the request expires after a few minutes."
|
||||
} else {
|
||||
self.errorMessage = pin != nil
|
||||
@@ -469,6 +498,8 @@ final class SessionModel: ObservableObject {
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
@@ -652,15 +683,31 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
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,
|
||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||
// `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
|
||||
// upstream (host capture/encode or the network).
|
||||
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(
|
||||
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,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
@@ -668,7 +715,11 @@ final class SessionModel: ObservableObject {
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||
self.decodeValid ? self.decodeP50Ms : -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,14 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
||||
// Toggle the window's fullscreen. ⌃⌘F is the macOS-standard fullscreen combo; here it's
|
||||
// explicit so it's discoverable AND survives capture — while streaming the stream view
|
||||
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
|
||||
// notification the key window's FullscreenController observes.
|
||||
Button("Toggle Fullscreen") {
|
||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: [.control, .command])
|
||||
#endif
|
||||
Divider()
|
||||
Button("Disconnect") { session?.disconnect() }
|
||||
|
||||
@@ -65,7 +65,9 @@ struct StreamHUDView: View {
|
||||
private var compactLine: String {
|
||||
var parts = ["\(model.fps) fps"]
|
||||
if model.endToEndValid {
|
||||
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
||||
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
||||
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
||||
} else if model.hostNetworkValid {
|
||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||
}
|
||||
@@ -86,24 +88,46 @@ struct StreamHUDView: View {
|
||||
}
|
||||
if model.endToEndValid {
|
||||
// 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 handshake.
|
||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||||
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
||||
// 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))
|
||||
.foregroundStyle(.secondary)
|
||||
// The equation (detailed tier only): the stages tiling the headline interval
|
||||
// (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
|
||||
// into host + network (phase 2); an old host keeps the combined term.
|
||||
// into host + network (phase 2); an old host keeps the combined term. The
|
||||
// display term is floor-shaved like the headline, so the equation still sums.
|
||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.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 {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
|
||||
@@ -40,7 +40,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@@ -288,11 +290,19 @@ struct GamepadSettingsView: View {
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||
options: SettingsOptions.presenters, current: presenter
|
||||
) { presenter = $0 },
|
||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||
+ "from the next session.",
|
||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
||||
) { 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(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
|
||||
@@ -8,7 +8,10 @@ import SwiftUI
|
||||
/// 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.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general, display, audio, controllers, advanced, about
|
||||
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
|
||||
// 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 }
|
||||
|
||||
@@ -16,9 +19,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .input: return "Input"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
@@ -27,9 +30,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .input: return "keyboard"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,28 +37,31 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||
static var presenters: [(label: String, tag: String)] {
|
||||
var options: [(label: String, tag: String)] = [
|
||||
("Stage 2", "stage2"),
|
||||
("Stage 3", "stage3"),
|
||||
]
|
||||
#if DEBUG
|
||||
options.append(("Stage 1 (debug)", "stage1"))
|
||||
#endif
|
||||
return options
|
||||
}
|
||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||
/// PUNKTFUNK_PRESENTER debug env lever.
|
||||
static let presentPriorities: [(label: String, tag: String)] = [
|
||||
("Lowest latency", "latency"),
|
||||
("Smoothness", "smooth"),
|
||||
]
|
||||
static let presentPriorityDefault = "latency"
|
||||
|
||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
||||
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
||||
/// an untouched picker shows what actually runs.
|
||||
static var presenterDefault: String {
|
||||
#if os(tvOS)
|
||||
"stage3"
|
||||
#else
|
||||
"stage2"
|
||||
#endif
|
||||
/// 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),
|
||||
]
|
||||
}
|
||||
|
||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||
// 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)
|
||||
import CoreHaptics
|
||||
@@ -8,18 +18,23 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsView {
|
||||
// MARK: - Sections (shared)
|
||||
// MARK: - Display: Resolution
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
@ViewBuilder var resolutionSection: some View {
|
||||
Section("Resolution") {
|
||||
#if os(iOS) || os(macOS)
|
||||
// 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.
|
||||
Toggle("Match window", isOn: $matchWindow)
|
||||
described(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
|
||||
#if os(iOS)
|
||||
iosResolutionWheel
|
||||
@@ -32,30 +47,19 @@ extension SettingsView {
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
}
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
described("The host drives a real virtual output at exactly this size and refresh — "
|
||||
+ "true pixels, no scaling.") {
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
}
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
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(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
// MARK: - Display: Resolution (iOS wheel)
|
||||
|
||||
/// 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
|
||||
@@ -74,6 +78,11 @@ extension SettingsView {
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +174,69 @@ extension SettingsView {
|
||||
}
|
||||
#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)
|
||||
/// 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.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
|
||||
+ "fixed rate — a host card's context menu has a network speed test.") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
}
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
@@ -188,26 +256,228 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
// 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 os(macOS)
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
// 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
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Section {
|
||||
described("The speaker layout requested from the host.") {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
described("Host audio plays through this device; System default follows your "
|
||||
+ "Mac's output changes.") {
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
described("This device's microphone feeds the host's virtual mic.") {
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
}
|
||||
#if os(macOS)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
@@ -223,264 +493,27 @@ extension SettingsView {
|
||||
// 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).
|
||||
if micChannelCount > 1 {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
} footer: {
|
||||
Text(Self.audioFooter)
|
||||
Text("Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
Section {
|
||||
Picker("Touch input", selection: $touchMode) {
|
||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
}
|
||||
} header: {
|
||||
Text("Touch & pointer")
|
||||
} footer: {
|
||||
Text(pointerFooterText)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
||||
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||
/// type-checker budget and was what actually broke the iOS archive.
|
||||
private var pointerFooterText: String {
|
||||
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
|
||||
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
|
||||
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
|
||||
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
|
||||
text += "the host. Applies from the next touch."
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
|
||||
text += "absolute positions. Needs the stream full-screen and frontmost."
|
||||
}
|
||||
return text
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var compositorSection: some View {
|
||||
Section {
|
||||
Picker("Compositor", selection: $compositor) {
|
||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Host compositor")
|
||||
} footer: {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||
@ViewBuilder var wakeSection: some View {
|
||||
Section {
|
||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||
} header: {
|
||||
Text("Wake-on-LAN")
|
||||
} footer: {
|
||||
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||
+ "way.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var windowSection: some View {
|
||||
#if os(macOS)
|
||||
Section {
|
||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||
} header: {
|
||||
Text("Window")
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
|
||||
@ViewBuilder var vrrSection: some View {
|
||||
#if !os(tvOS)
|
||||
Section {
|
||||
Toggle("Allow VRR", isOn: $allowVRR)
|
||||
} header: {
|
||||
Text("Variable refresh rate")
|
||||
} footer: {
|
||||
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
|
||||
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
|
||||
// exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter).
|
||||
@ViewBuilder var vsyncSection: some View {
|
||||
#if os(macOS)
|
||||
Section {
|
||||
Toggle("V-Sync", isOn: $vsync)
|
||||
} header: {
|
||||
Text("Presentation")
|
||||
} footer: {
|
||||
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
|
||||
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
|
||||
+ "in step with the display's refresh — evenly paced, up to one refresh of "
|
||||
+ "added latency. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
||||
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
||||
// freezes hard on a lost HEVC reference.
|
||||
@ViewBuilder var presenterSection: some View {
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 3 (experimental)").tag("stage3")
|
||||
#if DEBUG
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
#endif
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
||||
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
||||
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||
+ "display time to compare. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var hdrSection: some View {
|
||||
Section {
|
||||
Picker("Video codec", selection: $codec) {
|
||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||
} header: {
|
||||
Text("Video quality")
|
||||
} footer: {
|
||||
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
||||
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
||||
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
||||
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
||||
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
||||
+ "session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var statisticsSection: some View {
|
||||
Section {
|
||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||
Text(tier.label).tag(tier.rawValue)
|
||||
}
|
||||
}
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
}
|
||||
}
|
||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
|
||||
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
|
||||
/// detail can reference it unconditionally.
|
||||
@ViewBuilder var keepAliveSection: some View {
|
||||
#if os(iOS)
|
||||
Section {
|
||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||
if backgroundKeepAlive {
|
||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||
Text("1 minute").tag(1)
|
||||
Text("5 minutes").tag(5)
|
||||
Text("10 minutes").tag(10)
|
||||
Text("30 minutes").tag(30)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Background")
|
||||
} footer: {
|
||||
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
|
||||
+ "playing and the connection stays live (video is dropped to save power) after you "
|
||||
+ "switch away — and the session auto-disconnects after the time above so it can't "
|
||||
+ "run down your battery. Returning to the app resumes video instantly.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var experimentalSection: some View {
|
||||
Section {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
} header: {
|
||||
Text("Experimental")
|
||||
} footer: {
|
||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
|
||||
+ "extra host setup.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
// MARK: - Controllers
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
@@ -492,24 +525,37 @@ extension SettingsView {
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
||||
+ "recently connected.") {
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
described("The virtual pad created on the host. Automatic matches your controller "
|
||||
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
|
||||
+ "clip-on controllers without motors of their own.") {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
described("With a controller connected, the host list and library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||
+ "browser.") {
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
}
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
@@ -519,22 +565,9 @@ extension SettingsView {
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
// The gamepad-UI blurb is appended here, not merged into the shared
|
||||
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
||||
// 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)
|
||||
Text("Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,31 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
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
|
||||
|
||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||
@@ -17,6 +42,7 @@ extension SettingsView {
|
||||
private static let minSliderKbps = 2_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 =
|
||||
"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 "
|
||||
@@ -53,55 +79,27 @@ extension SettingsView {
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
static var statisticsFooter: String {
|
||||
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||
+ "breakdown."
|
||||
static var statisticsDescription: String {
|
||||
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
||||
+ "Detailed adds the latency stage breakdown."
|
||||
#if os(macOS)
|
||||
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||
return base + " ⌃⌥⇧S cycles the tiers any time."
|
||||
#elseif os(iOS)
|
||||
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||
+ "any time."
|
||||
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
|
||||
#else
|
||||
return base
|
||||
#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
|
||||
|
||||
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
||||
static let controllersFooter =
|
||||
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||
+ "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`).
|
||||
var controllerOptions: [(label: String, tag: String)] {
|
||||
SettingsOptions.controllerOptions(gamepads)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
|
||||
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
||||
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||||
//
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// Navigation differs per platform, but all three follow the same category map (General =
|
||||
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
||||
// About — see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
|
||||
// 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
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||||
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||||
// focus-native pushed-picker layout in the same order. The individual sections
|
||||
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
||||
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
||||
// per-field `described` caption idiom) in SettingsView+Support.swift.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -25,10 +28,16 @@ struct SettingsView: View {
|
||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
|
||||
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
|
||||
// link). 1.0 = Native (the prior behaviour).
|
||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
#endif
|
||||
@@ -51,6 +60,12 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
#if !os(tvOS)
|
||||
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
|
||||
// both wheel axes; modifier-layout relocates the ⌥/⌘ → Alt/Super roles by physical position.
|
||||
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
|
||||
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
@@ -109,26 +124,32 @@ struct SettingsView: View {
|
||||
|
||||
#if os(macOS)
|
||||
private var macBody: some View {
|
||||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
||||
// picture (resolution lives here), Input = keyboard & mouse.
|
||||
TabView {
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
sessionSection
|
||||
overlaySection
|
||||
librarySection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
vrrSection
|
||||
vsyncSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
resolutionSection
|
||||
qualitySection
|
||||
presentationSection
|
||||
hostOutputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Display", systemImage: "display") }
|
||||
|
||||
Form {
|
||||
inputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Input", systemImage: "keyboard") }
|
||||
|
||||
Form {
|
||||
audioSection
|
||||
}
|
||||
@@ -156,16 +177,10 @@ struct SettingsView: View {
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||
|
||||
Form {
|
||||
experimentalSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||
|
||||
AcknowledgementsView()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 480, height: 460)
|
||||
.frame(width: 500, height: 520)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -240,25 +255,31 @@ struct SettingsView: View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
sessionSection
|
||||
overlaySection
|
||||
librarySection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
vrrSection
|
||||
statisticsSection
|
||||
resolutionSection
|
||||
qualitySection
|
||||
presentationSection
|
||||
hostOutputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .input:
|
||||
Form {
|
||||
pointerSection
|
||||
inputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Input")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
@@ -269,11 +290,6 @@ struct SettingsView: View {
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .advanced:
|
||||
Form { experimentalSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Advanced")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// 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
|
||||
@@ -319,6 +335,16 @@ struct SettingsView: View {
|
||||
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 {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -331,44 +357,55 @@ struct SettingsView: View {
|
||||
if !options.contains(where: { $0.tag == currentTag }) {
|
||||
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 {
|
||||
VStack(spacing: 16) {
|
||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||
TVSelectionRow(
|
||||
title: "Render scale",
|
||||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||
selection: $renderScale)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
TVSelectionRow(
|
||||
title: "Audio channels",
|
||||
options: SettingsOptions.audioChannels,
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: SettingsOptions.compositors,
|
||||
selection: $compositor)
|
||||
TVSelectionRow(
|
||||
title: "Presenter",
|
||||
options: SettingsOptions.presenters,
|
||||
selection: $presenter)
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
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(
|
||||
title: "Auto-wake on connect",
|
||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "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)
|
||||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
||||
+ "it before streaming.")
|
||||
TVSelectionRow(
|
||||
title: "Statistics overlay",
|
||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||
@@ -388,11 +425,7 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
tvCaption(Self.controllersFooter)
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000 →
|
||||
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 47992 →
|
||||
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
|
||||
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
||||
// attacker exactly one online guess — for the user a typo just means "try again" (the
|
||||
@@ -45,7 +45,7 @@ struct PairSheet: View {
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 24) {
|
||||
Text("The PIN is shown in the host's web console "
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "(https://<host>:47992 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||
@@ -118,7 +118,7 @@ struct PairSheet: View {
|
||||
.foregroundStyle(.tint)
|
||||
} footer: {
|
||||
Text("The PIN is shown in the host's web console "
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "(https://<host>:47992 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
@@ -210,7 +210,7 @@ struct PairSheet: View {
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||
errorText = "Wrong PIN — check the host's web console (port 47992) "
|
||||
+ "and try again."
|
||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||
// The host answered and said why (not armed / rate-limited / armed for
|
||||
|
||||
@@ -29,6 +29,11 @@ public final class ClipboardSync: NSObject {
|
||||
("text/rtf", .rtf),
|
||||
("text/html", .html),
|
||||
("image/png", .png),
|
||||
// Original image formats pass through VERBATIM beside the PNG floor — a copied JPEG
|
||||
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
|
||||
// kind it can place.
|
||||
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
|
||||
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
|
||||
]
|
||||
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
||||
/// with these — see nspasteboard.org).
|
||||
@@ -202,9 +207,20 @@ public final class ClipboardSync: NSObject {
|
||||
let types = pb.types ?? []
|
||||
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
|
||||
offerSeq &+= 1
|
||||
let kinds = Self.wireToPasteboard
|
||||
var kinds = Self.wireToPasteboard
|
||||
.filter { types.contains($0.type) }
|
||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
||||
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
|
||||
// — native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
|
||||
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
|
||||
// fallback costs nothing unless a peer actually pastes it.
|
||||
if !kinds.contains(where: { $0.mime == "image/png" }),
|
||||
types.contains(.tiff)
|
||||
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|
||||
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
|
||||
{
|
||||
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
|
||||
}
|
||||
// Empty = the pasteboard holds nothing we sync (or was cleared) — clears the host side.
|
||||
connection.clipOffer(seq: offerSeq, kinds: kinds)
|
||||
}
|
||||
@@ -305,8 +321,7 @@ public final class ClipboardSync: NSObject {
|
||||
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
|
||||
let pb = NSPasteboard.general
|
||||
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
|
||||
let type = Self.wireToPasteboard.first(where: { $0.wire == mime })?.type,
|
||||
let data = pb.data(forType: type)
|
||||
let data = Self.readWireData(pb, mime)
|
||||
else {
|
||||
connection.clipCancel(id: reqId)
|
||||
return
|
||||
@@ -322,6 +337,30 @@ public final class ClipboardSync: NSObject {
|
||||
connection.clipServe(reqId: reqId, data: Data(), last: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one wire format from the pasteboard, converting where macOS stores a different
|
||||
/// native type: `image/png` is served from a real `.png` entry when present, else converted
|
||||
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
|
||||
/// WebP/AVIF/GIF from browsers — `NSImage` decodes them all) into PNG at fetch time.
|
||||
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
|
||||
guard mime == "image/png" else {
|
||||
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
|
||||
return nil
|
||||
}
|
||||
return pb.data(forType: type)
|
||||
}
|
||||
if let png = pb.data(forType: .png) {
|
||||
return png
|
||||
}
|
||||
// No native PNG: decode whatever image the pasteboard carries and re-encode.
|
||||
guard let img = NSImage(pasteboard: pb),
|
||||
let tiff = img.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return rep.representation(using: .png, properties: [:])
|
||||
}
|
||||
}
|
||||
|
||||
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
|
||||
@@ -354,6 +393,8 @@ private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider
|
||||
case .rtf: return "text/rtf"
|
||||
case .html: return "text/html"
|
||||
case .png: return "image/png"
|
||||
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
|
||||
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,31 @@ public struct AccessUnit: Sendable {
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC,
|
||||
/// decrypted — `PunktfunkFrame.received_ns`, ABI v9) — the **received** measurement point of
|
||||
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -662,11 +683,16 @@ public final class PunktfunkConnection {
|
||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
let pulledNs = 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(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags,
|
||||
receivedNs: receivedNs)
|
||||
receivedNs: receivedNs, pulledNs: pulledNs)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
|
||||
@@ -41,6 +41,7 @@ import UIKit
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import os
|
||||
|
||||
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
|
||||
@@ -125,6 +126,12 @@ public final class InputCapture {
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||
/// menu handles it. Main queue.
|
||||
public var onToggleFullscreen: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
@@ -273,6 +280,14 @@ public final class InputCapture {
|
||||
break
|
||||
}
|
||||
}
|
||||
// ⌃⌘F toggles the streaming window's fullscreen. Intercepted only while forwarding (the
|
||||
// captured stream view swallows the menu's identical equivalent); the F is latched so its
|
||||
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
|
||||
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
|
||||
self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC
|
||||
self.onToggleFullscreen?()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
#endif
|
||||
@@ -318,7 +333,7 @@ public final class InputCapture {
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
for vk in pressedVKs {
|
||||
connection.send(.key(vk, down: false))
|
||||
emitKey(vk, down: false)
|
||||
}
|
||||
for button in pressedButtons {
|
||||
connection.send(.mouseButton(button, down: false))
|
||||
@@ -331,6 +346,15 @@ public final class InputCapture {
|
||||
residualScrollY = 0
|
||||
}
|
||||
|
||||
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
|
||||
/// active location-based modifier layout is applied in exactly one place while all internal
|
||||
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
|
||||
/// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next
|
||||
/// key without re-arming capture. Non-modifier VKs pass through untouched.
|
||||
private func emitKey(_ vk: UInt32, down: Bool) {
|
||||
connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down))
|
||||
}
|
||||
|
||||
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
||||
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
|
||||
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
|
||||
@@ -399,7 +423,7 @@ public final class InputCapture {
|
||||
inputLog.debug(
|
||||
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
|
||||
}
|
||||
connection.send(.key(vk, down: down))
|
||||
emitKey(vk, down: down)
|
||||
}
|
||||
|
||||
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
||||
@@ -566,8 +590,15 @@ public final class InputCapture {
|
||||
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery
|
||||
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
|
||||
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
|
||||
public func sendScroll(dx: Float, dy: Float) {
|
||||
public func sendScroll(dx rawDx: Float, dy rawDy: Float) {
|
||||
guard forwarding else { return }
|
||||
// Optionally invert both axes (read live). This is the ONE scroll sink for every platform —
|
||||
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here — so the toggle
|
||||
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
|
||||
// between events doesn't strand a fractional remainder of the old sign.
|
||||
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
|
||||
let dx = invert ? -rawDx : rawDx
|
||||
let dy = invert ? -rawDy : rawDy
|
||||
let fy = dy + residualScrollY
|
||||
let fx = dx + residualScrollX
|
||||
let iy = fy.rounded(.towardZero)
|
||||
@@ -643,7 +674,7 @@ public final class InputCapture {
|
||||
} else {
|
||||
self.pressedVKs.remove(vk)
|
||||
}
|
||||
self.connection.send(.key(vk, down: pressed))
|
||||
self.emitKey(vk, down: pressed)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all
|
||||
// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path).
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
extension InputCapture {
|
||||
/// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on
|
||||
/// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs
|
||||
/// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows
|
||||
/// user's `⌘ = Alt, ⌥ = Win` muscle memory lands correctly:
|
||||
/// L Command 0x5B ↔ L Alt 0xA4 · R Command 0x5C ↔ R Alt 0xA5.
|
||||
/// It's applied ONLY at the send boundary (`InputCapture.emitKey`) — all press/release
|
||||
/// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local
|
||||
/// ⌘-based shortcuts are untouched. The swap is its own inverse: a key that went down remapped
|
||||
/// goes up remapped, so the host never sees a stuck modifier.
|
||||
static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 {
|
||||
guard layout == .windows else { return vk }
|
||||
switch vk {
|
||||
case 0x5B: return 0xA4 // L Command → L Alt (VK_LWIN → VK_LMENU)
|
||||
case 0x5C: return 0xA5 // R Command → R Alt (VK_RWIN → VK_RMENU)
|
||||
case 0xA4: return 0x5B // L Option → L Win (VK_LMENU → VK_LWIN)
|
||||
case 0xA5: return 0x5C // R Option → R Win (VK_RMENU → VK_RWIN)
|
||||
default: return vk
|
||||
}
|
||||
}
|
||||
/// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted
|
||||
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
||||
static let hidToVK: [Int: UInt32] = {
|
||||
|
||||
@@ -57,30 +57,8 @@ public enum BrandFont {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
||||
// extension links Shared alone and must render the same purple.
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// from looping request → rollback → request.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||
@@ -51,6 +52,11 @@ public final class MatchWindowFollower {
|
||||
private weak var connection: PunktfunkConnection?
|
||||
private let debounce: TimeInterval
|
||||
private let minSpacing: TimeInterval
|
||||
/// Render-scale multiplier applied to the live window size before it's requested, so the
|
||||
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
|
||||
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
|
||||
private let renderScale: Double
|
||||
private let maxDimension: Int
|
||||
private var enabled: Bool
|
||||
|
||||
private var work: DispatchWorkItem?
|
||||
@@ -74,15 +80,28 @@ public final class MatchWindowFollower {
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
enabled: Bool,
|
||||
renderScale: Double = 1.0,
|
||||
maxDimension: Int = 8192,
|
||||
debounce: TimeInterval = 0.4,
|
||||
minSpacing: TimeInterval = 1.0
|
||||
) {
|
||||
self.connection = connection
|
||||
self.enabled = enabled
|
||||
self.renderScale = RenderScale.sanitize(renderScale)
|
||||
self.maxDimension = maxDimension
|
||||
self.debounce = debounce
|
||||
self.minSpacing = minSpacing
|
||||
}
|
||||
|
||||
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
|
||||
/// aspect-preserved, even, and clamped to the codec ceiling — the match-window twin of
|
||||
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
|
||||
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||
RenderScale.apply(
|
||||
baseWidth: widthPx, baseHeight: heightPx,
|
||||
scale: renderScale, maxDimension: maxDimension)
|
||||
}
|
||||
|
||||
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||
public func setEnabled(_ on: Bool) {
|
||||
enabled = on
|
||||
@@ -109,7 +128,7 @@ public final class MatchWindowFollower {
|
||||
/// mode yet → nothing to compare against, skip.
|
||||
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||
guard let connection else { return }
|
||||
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
||||
let target = targetMode(widthPx: widthPx, heightPx: heightPx)
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0 else { return }
|
||||
if target.width == mode.width, target.height == mode.height {
|
||||
@@ -137,7 +156,7 @@ public final class MatchWindowFollower {
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
||||
let target = targetMode(widthPx: size.width, heightPx: size.height)
|
||||
let mode = connection.currentMode()
|
||||
pendingSize = nil
|
||||
guard let req = MatchWindow.request(
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import CoreGraphics
|
||||
import CoreVideo
|
||||
#if os(macOS)
|
||||
import IOSurface
|
||||
#endif
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
@@ -193,13 +196,11 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
// display-referred SDR. (When the display IS in an HDR mode — requested per session via
|
||||
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
|
||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr).
|
||||
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
|
||||
// ST 2084 EOTF: PQ code value → linear light, 1.0 = 10,000 nits.
|
||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
||||
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
||||
static inline float3 pqToSdr(float3 pq) {
|
||||
const float m1 = 2610.0/16384.0;
|
||||
const float m2 = 78.84375;
|
||||
const float c1 = 3424.0/4096.0;
|
||||
@@ -207,20 +208,49 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
const float c3 = 18.6875;
|
||||
float3 p = pow(pq, 1.0/m2);
|
||||
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
||||
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
|
||||
float3 t = lin * (10000.0/203.0);
|
||||
// BT.2020 → BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
|
||||
float3 t709 = float3(
|
||||
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
||||
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
||||
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
||||
t709 = max(t709, 0.0);
|
||||
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
|
||||
const float w = 1000.0/203.0;
|
||||
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
||||
// BT.709 OETF — the same encoding the SDR stream arrives in, so both paths present alike.
|
||||
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
||||
return float4(e, 1.0);
|
||||
return e;
|
||||
}
|
||||
|
||||
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr), then the tail.
|
||||
return float4(pqToSdr(sampleRgb(lumaTex, chromaTex, in.uv, csc)), 1.0);
|
||||
}
|
||||
|
||||
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
||||
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
||||
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> cbTex [[texture(1)]],
|
||||
texture2d<float> crTex [[texture(2)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
#ifdef PF_BILINEAR_LUMA
|
||||
float lumaY = lumaTex.sample(s, in.uv).r;
|
||||
#else
|
||||
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
|
||||
#endif
|
||||
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
|
||||
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
|
||||
float3 pq = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||
return float4(pqToSdr(pq), 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -228,6 +258,51 @@ public final class MetalVideoPresenter {
|
||||
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
||||
public let layer: CAMetalLayer
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||
///
|
||||
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||
public let surfaceLayer: CALayer = {
|
||||
let l = CALayer()
|
||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||
l.isOpaque = true
|
||||
l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()]
|
||||
return l
|
||||
}()
|
||||
|
||||
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||
private struct SurfaceSlot {
|
||||
let surface: IOSurfaceRef
|
||||
let texture: MTLTexture
|
||||
/// Monotonic use stamp — the reuse picker takes the least-recently-rendered free slot.
|
||||
var seq: UInt64 = 0
|
||||
}
|
||||
|
||||
private var surfacePool: [SurfaceSlot] = []
|
||||
private var surfacePoolSize: CGSize = .zero
|
||||
private var surfaceSeq: UInt64 = 0
|
||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||
private var lastHandedOff: Int?
|
||||
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||
private var surfacePresentsStaged = false
|
||||
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
|
||||
private let device: MTLDevice
|
||||
private let queue: MTLCommandQueue
|
||||
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||
@@ -239,6 +314,11 @@ public final class MetalVideoPresenter {
|
||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
||||
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
|
||||
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
||||
@@ -289,6 +369,8 @@ public final class MetalVideoPresenter {
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
let pipelinePlanar: MTLRenderPipelineState
|
||||
let pipelinePlanarHDR: MTLRenderPipelineState
|
||||
let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||
do {
|
||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||
@@ -326,8 +408,18 @@ public final class MetalVideoPresenter {
|
||||
let planar = MTLRenderPipelineDescriptor()
|
||||
planar.vertexFunction = vtx
|
||||
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
|
||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
||||
let planarHdr = MTLRenderPipelineDescriptor()
|
||||
planarHdr.vertexFunction = vtx
|
||||
planarHdr.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||
planarHdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
|
||||
pipelinePlanarHDR = try device.makeRenderPipelineState(descriptor: planarHdr)
|
||||
let planarTm = MTLRenderPipelineDescriptor()
|
||||
planarTm.vertexFunction = vtx
|
||||
planarTm.fragmentFunction = library.makeFunction(name: "pf_frag_planar_tm")
|
||||
planarTm.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||
pipelinePlanarToneMap = try device.makeRenderPipelineState(descriptor: planarTm)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -368,6 +460,7 @@ public final class MetalVideoPresenter {
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||
pipelinePlanarHDR: pipelinePlanarHDR, pipelinePlanarToneMap: pipelinePlanarToneMap,
|
||||
textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
@@ -375,6 +468,8 @@ public final class MetalVideoPresenter {
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||
pipelinePlanar: MTLRenderPipelineState,
|
||||
pipelinePlanarHDR: MTLRenderPipelineState,
|
||||
pipelinePlanarToneMap: MTLRenderPipelineState,
|
||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
@@ -383,6 +478,8 @@ public final class MetalVideoPresenter {
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||
self.pipelinePlanar = pipelinePlanar
|
||||
self.pipelinePlanarHDR = pipelinePlanarHDR
|
||||
self.pipelinePlanarToneMap = pipelinePlanarToneMap
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
@@ -493,6 +590,48 @@ public final class MetalVideoPresenter {
|
||||
stagingLock.unlock()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
stagingLock.lock()
|
||||
surfacePresentsStaged = on
|
||||
stagingLock.unlock()
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 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;
|
||||
/// `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
|
||||
@@ -508,10 +647,15 @@ public final class MetalVideoPresenter {
|
||||
/// 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
|
||||
/// (`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
|
||||
public func render(
|
||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||
@@ -565,7 +709,8 @@ public final class MetalVideoPresenter {
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
return encodePresent(
|
||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
onPresented: onPresented,
|
||||
// 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.
|
||||
keepAlive: [luma, chroma, pixelBuffer]
|
||||
@@ -584,17 +729,58 @@ public final class MetalVideoPresenter {
|
||||
func renderPlanar(
|
||||
_ planes: WaveletPlanes,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
#if os(macOS)
|
||||
let surfaceMode = surfacePresentsStaged
|
||||
#endif
|
||||
stagingLock.unlock()
|
||||
configure(hdr: false)
|
||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
||||
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
||||
#if os(macOS)
|
||||
configure(hdr: planes.pq && !surfaceMode)
|
||||
#else
|
||||
configure(hdr: planes.pq)
|
||||
#endif
|
||||
var csc = planes.csc
|
||||
#if os(macOS)
|
||||
if surfaceMode != surfacePresentsActive {
|
||||
surfacePresentsActive = surfaceMode
|
||||
presenterLog.info(
|
||||
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||
if !surfaceMode {
|
||||
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||
// and re-entering windowed mode rebuilds it in one frame.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
}
|
||||
if surfaceMode {
|
||||
return renderPlanarToSurface(
|
||||
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||
}
|
||||
#endif
|
||||
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||
#if os(tvOS)
|
||||
let planarPassthrough = hdrActive && hdrPassthroughActive
|
||||
#else
|
||||
let planarPassthrough = hdrActive
|
||||
#endif
|
||||
let planarPipeline: MTLRenderPipelineState =
|
||||
planes.pq
|
||||
? (planarPassthrough ? pipelinePlanarHDR : pipelinePlanarToneMap)
|
||||
: pipelinePlanar
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
onPresented: onPresented,
|
||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||
@@ -606,12 +792,132 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||
private func renderPlanarToSurface(
|
||||
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||
onPresented: ((Int64?) -> Void)?
|
||||
) -> Bool {
|
||||
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
ensureSurfacePool(size: targetSize)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
onPresented?(
|
||||
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||
private func ensureSurfacePool(size: CGSize) {
|
||||
guard size != surfacePoolSize else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: 4,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
///
|
||||
/// `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(
|
||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||
@@ -624,11 +930,17 @@ public final class MetalVideoPresenter {
|
||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? 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 DEBUG
|
||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||
#endif
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
if let providedDrawable,
|
||||
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()
|
||||
else { return false }
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ struct WaveletLayout {
|
||||
|
||||
let width: Int
|
||||
let height: Int
|
||||
/// Full-res chroma (4:4:4): chroma components get the full band set including level 0,
|
||||
/// exactly like luma — upstream `init_block_meta` with `Chroma444`.
|
||||
let chroma444: Bool
|
||||
let alignedWidth: Int
|
||||
let alignedHeight: Int
|
||||
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||
@@ -59,9 +62,10 @@ struct WaveletLayout {
|
||||
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
||||
|
||||
init(width: Int, height: Int) {
|
||||
init(width: Int, height: Int, chroma444: Bool) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.chroma444 = chroma444
|
||||
let align = { (v: Int) in
|
||||
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||
}
|
||||
@@ -78,7 +82,7 @@ struct WaveletLayout {
|
||||
let ah = alignedHeight
|
||||
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
|
||||
if level == 0 && component != 0 && !chroma444 { continue } // 4:2:0: no top-level chroma
|
||||
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let levelW = (aw / 2) >> level
|
||||
let levelH = (ah / 2) >> level
|
||||
@@ -108,6 +112,9 @@ struct ParsedWaveletFrame {
|
||||
var decodedBlocks: Int
|
||||
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||
var bt2020: Bool
|
||||
/// PQ transfer ⇒ HDR session: 16-bit studio-code planes + EDR present (the host stamps
|
||||
/// this bit iff the session negotiated 10-bit — the depth is coupled to the transfer).
|
||||
var pq: Bool
|
||||
var fullRange: Bool
|
||||
|
||||
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
||||
@@ -131,6 +138,12 @@ enum WaveletBitstream {
|
||||
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||
var state = ParseState()
|
||||
// Reserve the coefficient buffer ONCE, up front. Every packet's payload is a slice of the
|
||||
// AU, so `au.count / 4` words is a tight upper bound — reserving it here lets the per-packet
|
||||
// appends stay amortized O(1). (Reserving per packet forces Swift to allocate the exact new
|
||||
// size each time, turning the walk O(n²) — invisible on the tiny golden fixtures, but ~5 ms
|
||||
// per 1.4 MB frame on a real 5120x1440 stream.)
|
||||
state.payload.reserveCapacity(au.count / 4)
|
||||
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return false
|
||||
@@ -203,6 +216,7 @@ enum WaveletBitstream {
|
||||
var totalBlocks = 0
|
||||
var decodedBlocks = 0
|
||||
var bt2020 = false
|
||||
var pq = false
|
||||
var fullRange = false
|
||||
var sawSOF = false
|
||||
|
||||
@@ -220,22 +234,27 @@ enum WaveletBitstream {
|
||||
// siting[31].
|
||||
let code = (word1 >> 24) & 0x3
|
||||
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
||||
let chromaRes = (word1 >> 26) & 1
|
||||
guard chromaRes == 0 else { return false } // host contract: 4:2:0
|
||||
let chroma444 = (word1 >> 26) & 1 != 0
|
||||
let w = Int(word0 & 0x3fff) + 1
|
||||
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
||||
guard w >= 2, h >= 2, chroma444 || (w % 2 == 0 && h % 2 == 0) else {
|
||||
return false
|
||||
}
|
||||
if sawSOF {
|
||||
// One frame, one geometry — a second SOF must agree.
|
||||
guard layout?.width == w, layout?.height == h else { return false }
|
||||
guard layout?.width == w, layout?.height == h,
|
||||
layout?.chroma444 == chroma444
|
||||
else { return false }
|
||||
} else {
|
||||
sawSOF = true
|
||||
let l = WaveletLayout(width: w, height: h)
|
||||
let l = WaveletLayout(width: w, height: h, chroma444: chroma444)
|
||||
layout = l
|
||||
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||
payload.reserveCapacity(64 * 1024 / 4)
|
||||
totalBlocks = Int(word1 & 0xff_ffff)
|
||||
bt2020 = (word1 >> 29) & 1 != 0
|
||||
// transfer_function bit: PQ ⇒ an HDR session (16-bit studio-code
|
||||
// planes by the negotiated coupling — design/pyrowave-444-hdr.md).
|
||||
pq = (word1 >> 28) & 1 != 0
|
||||
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||
}
|
||||
pos += 8
|
||||
@@ -252,9 +271,15 @@ enum WaveletBitstream {
|
||||
if offsets[blockIndex] == .max {
|
||||
offsets[blockIndex] = UInt32(payload.count)
|
||||
decodedBlocks += 1
|
||||
payload.reserveCapacity(payload.count + payloadWords)
|
||||
for w in 0..<payloadWords {
|
||||
payload.append(loadWord(base, pos + w * 4))
|
||||
// Bulk-copy the packet's coefficient words in one memcpy rather than
|
||||
// word-by-word. All Apple platforms are little-endian, so the wire's LE
|
||||
// u32s land in the [UInt32] buffer verbatim; memcpy has no alignment
|
||||
// requirement, so a non-word-aligned `base + pos` is fine. `reserveCapacity`
|
||||
// up in `parse` keeps the grow amortized O(1).
|
||||
let dstWord = payload.count
|
||||
payload.append(contentsOf: repeatElement(0, count: payloadWords))
|
||||
payload.withUnsafeMutableBytes { dst in
|
||||
_ = memcpy(dst.baseAddress! + dstWord * 4, base + pos, payloadWords * 4)
|
||||
}
|
||||
}
|
||||
} else if layout != nil {
|
||||
@@ -280,7 +305,7 @@ enum WaveletBitstream {
|
||||
return ParsedWaveletFrame(
|
||||
layout: layout, offsets: offsets, payload: payload,
|
||||
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
||||
bt2020: bt2020, fullRange: fullRange)
|
||||
bt2020: bt2020, pq: pq, fullRange: fullRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +318,8 @@ public struct WaveletPlanes: @unchecked Sendable {
|
||||
public let cb: MTLTexture
|
||||
public let cr: MTLTexture
|
||||
public let csc: CscUniform
|
||||
/// PQ (HDR) stream: the presenter picks the HDR/tone-map planar pipeline + EDR config.
|
||||
public let pq: Bool
|
||||
public var width: Int { y.width }
|
||||
public var height: Int { y.height }
|
||||
}
|
||||
@@ -351,6 +378,8 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
private var slots: [Slot] = []
|
||||
private var nextSlot = 0
|
||||
/// The ring's plane format facts (from the last SOF): PQ ⇒ 16-bit UNORM planes.
|
||||
private var hdr16 = false
|
||||
|
||||
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
||||
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
||||
@@ -409,8 +438,9 @@ public final class MetalWaveletDecoder {
|
||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||
else { return false }
|
||||
|
||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
||||
guard rebuild(layout: frame.layout) else { return false }
|
||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height
|
||||
|| layout?.chroma444 != frame.layout.chroma444 || hdr16 != frame.pq {
|
||||
guard rebuild(layout: frame.layout, hdr16: frame.pq) else { return false }
|
||||
}
|
||||
guard let layout, !slots.isEmpty else { return false }
|
||||
|
||||
@@ -450,7 +480,7 @@ public final class MetalWaveletDecoder {
|
||||
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||
for level in 0..<WaveletLayout.decompositionLevels {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue } // 4:2:0
|
||||
if level == 0 && component != 0 && !layout.chroma444 { continue } // 4:2:0
|
||||
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let meta = layout.blockMeta[component][level][band]
|
||||
let w = layout.levelWidth(level)
|
||||
@@ -489,15 +519,20 @@ public final class MetalWaveletDecoder {
|
||||
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||
if inputLevel == 0 {
|
||||
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
||||
// Final full-res pass: luma only in 4:2:0 (chroma finished at level 1); all
|
||||
// three components in 4:4:4 (chroma runs the full pyramid like luma).
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(coefficients[0][0], index: 0)
|
||||
idwt.setTexture(slot.y, index: 1)
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
let components = layout.chroma444 ? 3 : 1
|
||||
for component in 0..<components {
|
||||
idwt.setTexture(coefficients[component][0], index: 0)
|
||||
let out = component == 0 ? slot.y : (component == 1 ? slot.cb : slot.cr)
|
||||
idwt.setTexture(out, index: 1)
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
}
|
||||
} else {
|
||||
for component in 0..<3 {
|
||||
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
||||
if component != 0 && inputLevel == 1 {
|
||||
if component != 0 && inputLevel == 1 && !layout.chroma444 {
|
||||
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||
@@ -513,7 +548,9 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
let planes = WaveletPlanes(
|
||||
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
||||
csc: CscRows.rows(
|
||||
frame.cscSignal, depth: frame.pq ? 10 : 8, msbPacked: frame.pq),
|
||||
pq: frame.pq)
|
||||
cmd.addCompletedHandler { buffer in
|
||||
completion(buffer.error == nil ? planes : nil)
|
||||
}
|
||||
@@ -524,9 +561,9 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
||||
/// resize path: a Reconfigure shows up here as new SOF dims.
|
||||
private func rebuild(layout newLayout: WaveletLayout) -> Bool {
|
||||
private func rebuild(layout newLayout: WaveletLayout, hdr16 newHdr16: Bool) -> Bool {
|
||||
waveletLog.info(
|
||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
|
||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks, \(newLayout.chroma444 ? "4:4:4" : "4:2:0", privacy: .public)\(newHdr16 ? " HDR16" : "", privacy: .public))")
|
||||
var coeff: [[MTLTexture]] = []
|
||||
var lls: [[MTLTexture]] = []
|
||||
for component in 0..<3 {
|
||||
@@ -560,19 +597,22 @@ public final class MetalWaveletDecoder {
|
||||
|
||||
var newSlots: [Slot] = []
|
||||
for i in 0..<Self.ringDepth {
|
||||
let planeFormat: MTLPixelFormat = newHdr16 ? .r16Unorm : .r8Unorm
|
||||
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
|
||||
pixelFormat: planeFormat, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.shaderRead, .shaderWrite]
|
||||
desc.storageMode = .private
|
||||
let t = self.device.makeTexture(descriptor: desc)
|
||||
t?.label = name
|
||||
return t
|
||||
}
|
||||
let cw = newLayout.chroma444 ? newLayout.width : newLayout.width / 2
|
||||
let ch = newLayout.chroma444 ? newLayout.height : newLayout.height / 2
|
||||
guard
|
||||
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
||||
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
||||
let cb = plane(cw, ch, "pyrowave Cb[\(i)]"),
|
||||
let cr = plane(cw, ch, "pyrowave Cr[\(i)]"),
|
||||
let offsets = device.makeBuffer(
|
||||
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||
@@ -585,6 +625,7 @@ public final class MetalWaveletDecoder {
|
||||
slots = newSlots
|
||||
nextSlot = 0
|
||||
layout = newLayout
|
||||
hdr16 = newHdr16
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit
|
||||
// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the
|
||||
// default; stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG
|
||||
// fallback. The views own the platform bits — capture, window/scale tracking, and constructing the
|
||||
// display link — and delegate the shared presenter lifecycle here.
|
||||
// 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
|
||||
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
||||
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
||||
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
||||
// Stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
|
||||
// The views own the platform bits — capture, window/scale tracking, and constructing the
|
||||
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink) —
|
||||
// and delegate the shared presenter lifecycle here.
|
||||
//
|
||||
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
||||
|
||||
@@ -26,15 +30,17 @@ public final class DisplayLinkProxy: NSObject {
|
||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||
}
|
||||
|
||||
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
|
||||
/// pacing (`PresentPacing` — see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
|
||||
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
|
||||
/// CAMetalDisplayLink-vended drawables (deadline pacing — iOS/tvOS only; see `PresentPacing`'s
|
||||
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
|
||||
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||
enum PresenterChoice: Equatable {
|
||||
case stage1
|
||||
case stage2
|
||||
case stage3
|
||||
case stage4
|
||||
|
||||
/// 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)
|
||||
@@ -42,34 +48,159 @@ enum PresenterChoice: Equatable {
|
||||
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||
/// freeze-prone fallback.
|
||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||
explicit(setting: setting, env: env, allowStage1: allowStage1) ?? platformDefault
|
||||
}
|
||||
|
||||
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
||||
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
|
||||
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
|
||||
/// explicit "stage2" must stay a faithful A/B of arrival pacing. "stage4" resolves only on
|
||||
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
|
||||
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
|
||||
/// deliberately validated there — a synced "stage4" value maps back to the platform default.
|
||||
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||
case "stage1": return allowStage1 ? .stage1 : nil
|
||||
case "stage2": return .stage2
|
||||
case "stage3": return .stage3
|
||||
default: return platformDefault
|
||||
case "stage4":
|
||||
#if os(macOS)
|
||||
return nil
|
||||
#else
|
||||
return .stage4
|
||||
#endif
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
||||
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
||||
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
||||
///
|
||||
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
||||
/// (`displaySyncEnabled` is macOS-only API), and at stream rate ≈ panel rate — an Apple TV's
|
||||
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
|
||||
/// steering the panel to the stream — that queue's depth is STICKY: one burst fills it and,
|
||||
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
|
||||
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
|
||||
/// read ~30 ms display on arrival (~3 refreshes of queue), 22–28 ms glass-gated at depth 2
|
||||
/// (a standing queue of 2 — the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
|
||||
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
|
||||
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
|
||||
/// drawable per refresh, presented the moment a frame decodes — the queue cannot exist and
|
||||
/// nothing waits on callbacks (see `PresentPacing.deadline`).
|
||||
///
|
||||
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
|
||||
/// (design/apple-presentation-rebuild.md — the engine is field-proven on iOS and strictly
|
||||
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
|
||||
/// fallback lever if a TV-specific issue surfaces). macOS keeps stage-2: with the layer's
|
||||
/// sync off, presents are out-of-band flips that don't queue, so arrival is genuinely
|
||||
/// lowest-latency there.
|
||||
static var platformDefault: PresenterChoice {
|
||||
#if os(tvOS)
|
||||
.stage3
|
||||
#if os(iOS) || os(tvOS)
|
||||
.stage4
|
||||
#else
|
||||
.stage2
|
||||
#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 {
|
||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||
/// of stage-2 defaults there predate any panic report.
|
||||
static func pacing(
|
||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||
) -> PresentPacing {
|
||||
if choice == .stage4 { return .deadline }
|
||||
if choice == .stage3 { return .glass }
|
||||
#if os(macOS)
|
||||
if explicit == nil, codec == .pyrowave { return .glass }
|
||||
#endif
|
||||
return .arrival
|
||||
}
|
||||
|
||||
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
|
||||
///
|
||||
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
|
||||
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
||||
/// 22–28 ms vs depth 1's 14: any second gate slot becomes a STANDING queue — a burst fills
|
||||
/// it, and with presents and latches then running at the same rate the occupancy never
|
||||
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
|
||||
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
|
||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||
///
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
||||
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
|
||||
/// serialization is its point. Internal (not private) for unit tests.
|
||||
static func gateDepth(env: String?) -> Int {
|
||||
#if os(macOS)
|
||||
return 1
|
||||
#else
|
||||
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
||||
return 1
|
||||
#endif
|
||||
}
|
||||
|
||||
private var pump: StreamPump?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||
private var surfaceLayer: CALayer?
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||
@@ -93,6 +224,7 @@ final class SessionPresenter {
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil,
|
||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
@@ -101,43 +233,79 @@ final class SessionPresenter {
|
||||
stop()
|
||||
self.connection = connection
|
||||
|
||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
||||
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
||||
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
||||
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
||||
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
||||
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
||||
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
||||
// value is deliberately ignored). The user-facing choice is the INTENT
|
||||
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
|
||||
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
|
||||
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
|
||||
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
|
||||
// fallback).
|
||||
#if DEBUG
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
let choice = PresenterChoice.resolve(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||
let explicit = PresenterChoice.explicit(
|
||||
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||
allowStage1: allowStage1)
|
||||
let choice = explicit ?? PresenterChoice.platformDefault
|
||||
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
|
||||
let priority = PresentPriority.resolve(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
|
||||
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
|
||||
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
|
||||
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
|
||||
// display, so the FIFO alone provides the buffering.
|
||||
#if os(macOS)
|
||||
let vsyncPaced = priority != .latency && pacing == .arrival
|
||||
#else
|
||||
let vsyncPaced = false
|
||||
#endif
|
||||
if choice != .stage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
pacing: pacing,
|
||||
gateDepth: Self.gateDepth(
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
||||
storePolicy: priority.storePolicy,
|
||||
vsyncPaced: vsyncPaced) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
baseLayer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
#if os(macOS)
|
||||
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||
// contents) while the metal path presents, covering it while surface presents run.
|
||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||
surfaceLayer = pipeline.surfaceLayer
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
stage2 = pipeline
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||
// link's own report of the current refresh period (tracks VRR rate changes).
|
||||
let proxy = DisplayLinkProxy { [weak self] link in
|
||||
self?.stage2?.renderTick(
|
||||
targetMediaTime: link.targetTimestamp,
|
||||
period: link.targetTimestamp - link.timestamp)
|
||||
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
|
||||
// clock, and every one of its updates re-checks the ring, which IS the retry tick —
|
||||
// a second link would only fight it over the frame-rate hint.
|
||||
if pacing != .deadline {
|
||||
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)
|
||||
pipeline.start(
|
||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||
@@ -165,7 +333,12 @@ final class SessionPresenter {
|
||||
/// 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.
|
||||
private func syncFrameRate(hz: UInt32) {
|
||||
guard hz > 0, let link = stage2Link else { return }
|
||||
guard hz > 0 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 allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||
#if os(macOS)
|
||||
@@ -224,6 +397,12 @@ final class SessionPresenter {
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = contentsScale
|
||||
metalLayer.frame = snapped
|
||||
#if os(macOS)
|
||||
// The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces
|
||||
// are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too.
|
||||
surfaceLayer?.contentsScale = contentsScale
|
||||
surfaceLayer?.frame = snapped
|
||||
#endif
|
||||
CATransaction.commit()
|
||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||
@@ -251,6 +430,31 @@ final class SessionPresenter {
|
||||
contentSize = size
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||
func setComposited(_ composited: Bool) {
|
||||
guard let stage2, let connection else { return }
|
||||
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||
guard wantsSurface != surfacePresentsActive else { return }
|
||||
surfacePresentsActive = wantsSurface
|
||||
stage2.setSurfacePresents(wantsSurface)
|
||||
if !wantsSurface {
|
||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||
// entry shows the previous frame until the next present — no black flash).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer?.contents = nil
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||
/// Idempotent.
|
||||
@@ -264,6 +468,11 @@ final class SessionPresenter {
|
||||
stage2 = nil
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
#if os(macOS)
|
||||
surfaceLayer?.removeFromSuperlayer()
|
||||
surfaceLayer = nil
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
// – 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
|
||||
// schedule can never sit far in the future holding drawables hostage.
|
||||
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||
// • Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by
|
||||
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
|
||||
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
|
||||
// CAMetalDisplayLink-vended drawables the moment a frame decodes — deadline pacing, where the
|
||||
// queue cannot exist at all — see PresentPacing's doc for the full rationale. Under deadline
|
||||
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
|
||||
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
|
||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||
//
|
||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||
@@ -40,6 +44,7 @@ import Foundation
|
||||
import Metal
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
/// 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
|
||||
@@ -47,33 +52,127 @@ import QuartzCore
|
||||
/// stdout is the cheapest reliable capture channel.
|
||||
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||
|
||||
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
||||
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
||||
private final class ReadyRing: @unchecked Sendable {
|
||||
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") — the
|
||||
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// 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 var frame: ReadyFrame?
|
||||
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
||||
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
||||
private let capacity: Int // 1 = newest-wins semantics
|
||||
private let isFifo: Bool
|
||||
private var frames: [Frame] = []
|
||||
private var prerolled = false
|
||||
/// Submissions since the last `drainSubmitted` — the decode rate for the pf-present line.
|
||||
private var submitted = 0
|
||||
func submit(_ f: ReadyFrame) {
|
||||
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
||||
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
|
||||
/// runs-dry that re-armed preroll.
|
||||
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 drainSubmitted() -> Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
let n = submitted; submitted = 0; return n
|
||||
}
|
||||
func take() -> ReadyFrame? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
let f = frame; frame = nil; return f
|
||||
}
|
||||
/// Return a frame the display link took but could not present (a transient `nextDrawable`
|
||||
/// 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) {
|
||||
|
||||
func submit(_ f: Frame) {
|
||||
lock.lock()
|
||||
if frame == nil { frame = f }
|
||||
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 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let n = submitted
|
||||
submitted = 0
|
||||
return n
|
||||
}
|
||||
|
||||
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
|
||||
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let out = (overflowDrops, underflows)
|
||||
overflowDrops = 0
|
||||
underflows = 0
|
||||
return out
|
||||
}
|
||||
|
||||
func take() -> Frame? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -107,63 +206,211 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||
/// half, same newest-wins ring; only the present cadence differs.
|
||||
///
|
||||
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// - `arrival` (stage-2, the macOS default): present the moment a frame is decoded. Lowest latency
|
||||
/// while the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable
|
||||
/// per refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||
/// ~2–3 refreshes of queue (the measured 29–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
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||
/// explicit, correct frame drops.
|
||||
/// explicit, correct frame drops. The residual cost: presents serialize on the on-glass
|
||||
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
|
||||
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
|
||||
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only — see
|
||||
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
|
||||
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
|
||||
/// it with the newest decoded frame THE MOMENT either half arrives — usually the frame, into
|
||||
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
|
||||
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
|
||||
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
|
||||
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
|
||||
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
|
||||
///
|
||||
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
|
||||
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
||||
/// "mismatched swapID's" KERNEL PANIC, and the one-in-flight gate removes that pattern — see
|
||||
/// `SessionPresenter.pacing` for the full rationale.
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
case glass
|
||||
case deadline
|
||||
}
|
||||
|
||||
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||
/// releaser runs on a Metal callback thread.
|
||||
/// 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.
|
||||
/// The render thread `tryAcquire`s before taking a frame; the drawable's presented handler
|
||||
/// `release`s and re-signals the render thread. Depth 1 fully serializes presents on the on-glass
|
||||
/// callback — which costs a refresh whenever the callback's own latency pushes the next present
|
||||
/// past a vsync; depth 2 keeps one flip queued behind the one scanning out, so a decoded frame
|
||||
/// presents immediately and latches the very next vsync while the queue still can't build (see
|
||||
/// `SessionPresenter.gateDepth` for the per-platform choice). `staleAfter` is insurance against a
|
||||
/// present whose handler never fires (the macOS "out-of-band presents aren't damage" hazard class
|
||||
/// — see MetalVideoPresenter's init post-mortem): rather than freezing the stream, a full gate
|
||||
/// force-opens a slot 100 ms after its oldest present, a visible ~10 fps degradation that
|
||||
/// PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0 on healthy systems). Internal
|
||||
/// (not private) for unit tests. Sendable; lock-guarded — the releaser runs on a Metal callback
|
||||
/// thread.
|
||||
final class PresentGate: @unchecked Sendable {
|
||||
/// How long one pending present may hold the gate before it's presumed lost.
|
||||
/// How long one pending present may hold its slot before it's presumed lost.
|
||||
static let staleAfter: CFTimeInterval = 0.1
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending = false
|
||||
private var armedAt: CFTimeInterval = 0
|
||||
private let capacity: Int
|
||||
/// Arm instants of the in-flight presents, oldest first (≤ `capacity` entries).
|
||||
private var armed: [CFTimeInterval] = []
|
||||
private var forced = 0
|
||||
|
||||
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||
/// `capacity` = the in-flight present budget (clamped to ≥ 1) — see the type doc.
|
||||
init(capacity: Int = 1) {
|
||||
self.capacity = max(1, capacity)
|
||||
}
|
||||
|
||||
/// Arm the gate for one present. False = the gate is full of live presents (none stale) —
|
||||
/// leave the frame in the ring; a presented handler's release/re-signal (or the next
|
||||
/// display-link tick) retries with the freshest frame then.
|
||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending {
|
||||
guard now - armedAt > Self.staleAfter else { return false }
|
||||
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||
if armed.count >= capacity {
|
||||
// Full: reopen only by presuming the OLDEST in-flight present lost (its handler
|
||||
// never fired) rather than stalling the stream.
|
||||
guard let oldest = armed.first, now - oldest > Self.staleAfter else { return false }
|
||||
armed.removeFirst()
|
||||
forced += 1
|
||||
}
|
||||
pending = true
|
||||
armedAt = now
|
||||
armed.append(now)
|
||||
return true
|
||||
}
|
||||
|
||||
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||
/// One in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — free the oldest slot. A release with nothing in flight is a no-op; a
|
||||
/// lost present's handler firing late after its stale force-open can transiently over-admit
|
||||
/// one flip, which the next glass callback corrects.
|
||||
func release() {
|
||||
lock.lock()
|
||||
pending = false
|
||||
if !armed.isEmpty { armed.removeFirst() }
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@@ -184,13 +431,23 @@ final class PresentGate: @unchecked Sendable {
|
||||
private final class PresentDebugStats: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var last = CACurrentMediaTime()
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
|
||||
private var maxRenderMs = 0.0
|
||||
private var lastGlassNs: Int64 = 0
|
||||
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
|
||||
/// 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 should peg it at 1).
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
|
||||
@@ -201,6 +458,14 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
/// is normal, it just shows the gate working.
|
||||
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) {
|
||||
lock.lock()
|
||||
if rendered {
|
||||
@@ -214,40 +479,59 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func presented(atNs: Int64?) {
|
||||
func presented(atNs: Int64?, issuedNs: Int64) {
|
||||
lock.lock()
|
||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||
if let atNs {
|
||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||
lastGlassNs = atNs
|
||||
latchMs.append(Double(atNs - issuedNs) / 1e6)
|
||||
} else {
|
||||
dropped += 1
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
|
||||
lock.lock()
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else { lock.unlock(); return }
|
||||
last = now
|
||||
let decoded = ring.drainSubmitted()
|
||||
let smoothing = ring.drainSmoothing()
|
||||
let deltas = glassDeltasMs.sorted()
|
||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||
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 line = String(
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
||||
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
|
||||
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
|
||||
+ "vendLeadMs p50=%.2f max=%.2f",
|
||||
decoded, ok, failed, empty, gated, noDrawable, dropped,
|
||||
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
|
||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||
latchMs.removeAll(keepingCapacity: true)
|
||||
vendLeadMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
print(line)
|
||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||
// Console.app first (the on-device readout — see presentLog); stdout only under the env
|
||||
// lever (the CLI client's capture channel).
|
||||
presentLog.info("\(line, privacy: .public)")
|
||||
if presentDebug {
|
||||
print(line)
|
||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,15 +558,27 @@ private final class DecodeReport: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let ring: FrameStore<ReadyFrame>
|
||||
private let presenter: MetalVideoPresenter
|
||||
private let decoder: VideoDecoder
|
||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
/// Present cadence — `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
|
||||
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
|
||||
/// SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||
private let gateDepth: Int
|
||||
/// 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 decodeMeter: 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()
|
||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||
/// binds the live connection + arming flag (see DecodeReport).
|
||||
@@ -313,6 +609,9 @@ public final class Stage2Pipeline {
|
||||
private let vsyncClock = VsyncClock()
|
||||
private let renderStopped = DispatchSemaphore(value: 0)
|
||||
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.
|
||||
public var layer: CAMetalLayer { presenter.layer }
|
||||
@@ -323,19 +622,28 @@ public final class Stage2Pipeline {
|
||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||
/// (glass-gated) present cadence — see PresentPacing.
|
||||
/// (glass-gated) present cadence — see PresentPacing; `gateDepth` is the glass gate's
|
||||
/// in-flight present budget (see `SessionPresenter.gateDepth`).
|
||||
public init?(
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival
|
||||
presentFloorMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival,
|
||||
gateDepth: Int = 1,
|
||||
storePolicy: FrameStorePolicy = .newestWins,
|
||||
vsyncPaced: Bool = false
|
||||
) {
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.gateDepth = gateDepth
|
||||
self.vsyncPaced = vsyncPaced
|
||||
self.ring = FrameStore(policy: storePolicy)
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
@@ -510,6 +818,17 @@ public final class Stage2Pipeline {
|
||||
pumpJoinable = true
|
||||
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/
|
||||
// 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
|
||||
@@ -524,16 +843,21 @@ public final class Stage2Pipeline {
|
||||
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||
// Resolved once per session.
|
||||
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
||||
let vsyncEnabled = presentMode == "vsync"
|
||||
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling — the FIFO store must drain
|
||||
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
|
||||
let vsyncPaced = vsyncPaced
|
||||
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|
||||
|| (presentMode != "immediate"
|
||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||
let vsyncClock = vsyncClock
|
||||
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
||||
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
||||
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||
let renderThread = Thread {
|
||||
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`):
|
||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||
@@ -543,6 +867,15 @@ public final class Stage2Pipeline {
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
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
|
||||
// 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
|
||||
@@ -563,6 +896,7 @@ public final class Stage2Pipeline {
|
||||
let presentAt = vsyncEnabled
|
||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||
let renderStarted = CACurrentMediaTime()
|
||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
// 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.
|
||||
@@ -580,7 +914,7 @@ public final class Stage2Pipeline {
|
||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||
// so no skew offset applies.
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
debugStats?.presented(atNs: presentedNs)
|
||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
||||
}
|
||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||
@@ -599,6 +933,8 @@ public final class Stage2Pipeline {
|
||||
if !rendered {
|
||||
gate?.release() // no present registered — its handler will never fire
|
||||
ring.putBack(frame)
|
||||
} else if vsyncPaced, let presentAt {
|
||||
lastPresentTarget = presentAt // this vsync's slot is now taken
|
||||
}
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
} }
|
||||
@@ -609,22 +945,186 @@ public final class Stage2Pipeline {
|
||||
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)
|
||||
/// 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
|
||||
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
||||
/// replacement frame.
|
||||
/// replacement frame. Arrival/glass pacing only — deadline sessions have no CADisplayLink
|
||||
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
||||
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||
vsyncClock.set(target: targetMediaTime, period: period)
|
||||
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
|
||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||
public func setDrawableTarget(_ size: CGSize) {
|
||||
presenter.setDrawableTarget(size)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
|
||||
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
presenter.setSurfacePresents(on)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||
@@ -669,7 +1169,7 @@ public final class Stage2Pipeline {
|
||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||
private static func makePyroWavePump(
|
||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
||||
device: MTLDevice, queue: MTLCommandQueue,
|
||||
decodeMeter: LatencyMeter?,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
@@ -713,7 +1213,9 @@ public final class Stage2Pipeline {
|
||||
let chunkAligned =
|
||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||
let ptsNs = au.ptsNs
|
||||
let receivedNs = au.receivedNs
|
||||
// Decode stage starts at the PULL (matching the VT path's FrameContext —
|
||||
// receipt→pull is the HUD's separate client-queue term, ABI v9 split).
|
||||
let receivedNs = au.pulledNs
|
||||
let flags = au.flags
|
||||
let submitted = decoder.decode(
|
||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
|
||||
@@ -65,19 +65,21 @@ public enum Stage444Probe {
|
||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||
|
||||
var produced: OSType = 0
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
// SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback
|
||||
// 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(
|
||||
session, sampleBuffer: sample,
|
||||
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
||||
flags: [], infoFlagsOut: nil
|
||||
) { status, _, imageBuffer, _, _ in
|
||||
if status == noErr, let imageBuffer {
|
||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
}
|
||||
done.signal()
|
||||
}
|
||||
guard status == noErr else { return false }
|
||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||
_ = done.wait(timeout: .now() + 1.0)
|
||||
return produced == want || produced == fullRangeSibling
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,12 @@ public enum ReadyImage: @unchecked Sendable {
|
||||
public struct ReadyFrame: @unchecked Sendable {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
|
||||
/// didn't stamp receipt) — the decode-stage meter then drops the sample via its sanity guard.
|
||||
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds — the decode stage's start
|
||||
/// point. (Named for its historical role; since the ABI v9 receipt split the true
|
||||
/// 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
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
@@ -167,7 +170,11 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
var infoOut = VTDecodeInfoFlags()
|
||||
// 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.
|
||||
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly
|
||||
// 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 status = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
|
||||
@@ -93,6 +93,7 @@ public struct StreamView: NSViewRepresentable {
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let presentFloorMeter: LatencyMeter?
|
||||
|
||||
/// `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
|
||||
@@ -115,7 +116,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
@@ -128,6 +130,7 @@ public struct StreamView: NSViewRepresentable {
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
}
|
||||
|
||||
public func makeNSView(context: Context) -> StreamLayerView {
|
||||
@@ -138,6 +141,7 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.presentFloorMeter = presentFloorMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -151,6 +155,7 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.presentFloorMeter = presentFloorMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||
@@ -172,6 +177,7 @@ public final class StreamLayerView: NSView {
|
||||
var endToEndMeter: LatencyMeter?
|
||||
var decodeMeter: LatencyMeter?
|
||||
var displayMeter: LatencyMeter?
|
||||
var presentFloorMeter: LatencyMeter?
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
@@ -621,6 +627,12 @@ public final class StreamLayerView: NSView {
|
||||
guard let self, self.window?.isKeyWindow == true else { return }
|
||||
self.onDisconnectRequest?()
|
||||
}
|
||||
capture.onToggleFullscreen = { [weak self] in
|
||||
// App-level window action: post to the key window's FullscreenController (same routing as
|
||||
// the Stream menu's ⌃⌘F item, so captured and released states hit one code path).
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||
}
|
||||
capture.onCycleStats = { [weak self] in
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||
@@ -655,6 +667,7 @@ public final class StreamLayerView: NSView {
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
@@ -671,7 +684,10 @@ public final class StreamLayerView: NSView {
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||
maxDimension: RenderScale.maxDimension(
|
||||
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
@@ -683,6 +699,11 @@ public final class StreamLayerView: NSView {
|
||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||
// not yet in a window counts as composited (the safe default).
|
||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||
|
||||
@@ -61,6 +61,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let presentFloorMeter: LatencyMeter?
|
||||
|
||||
/// `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
|
||||
@@ -77,7 +78,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
@@ -89,6 +91,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||
@@ -98,6 +101,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.presentFloorMeter = presentFloorMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -110,6 +114,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.presentFloorMeter = presentFloorMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
if controller.connection !== connection {
|
||||
@@ -145,6 +150,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
var endToEndMeter: LatencyMeter?
|
||||
var decodeMeter: LatencyMeter?
|
||||
var displayMeter: LatencyMeter?
|
||||
var presentFloorMeter: LatencyMeter?
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
@@ -385,7 +391,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||
maxDimension: RenderScale.maxDimension(
|
||||
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
@@ -403,6 +412,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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
|
||||
}()
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public enum DefaultsKey {
|
||||
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
||||
/// stream views' `MatchWindowFollower`.
|
||||
public static let matchWindow = "punktfunk.matchWindow"
|
||||
/// Render-resolution multiplier (a `RenderScale` value, default 1.0): the client asks the host
|
||||
/// to render/encode at `chosen resolution × scale`, then the presenter downscales the larger
|
||||
/// decoded frame to this display in one Catmull-Rom pass. > 1 supersamples (sharper, at the cost
|
||||
/// of more bandwidth AND client decode — both grow ∝ scale²); < 1 renders below native for a
|
||||
/// weak host GPU / constrained link (the presenter upscales). Purely client-side — the host just
|
||||
/// sees a normal (larger/smaller) `Mode`, and Automatic bitrate scales with it. Clamped even +
|
||||
/// to the codec's max dimension at connect. Applies to the fixed mode and the match-window path.
|
||||
public static let renderScale = "punktfunk.renderScale"
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
@@ -42,12 +50,21 @@ public enum DefaultsKey {
|
||||
/// 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.
|
||||
public static let micChannel = "punktfunk.micChannel"
|
||||
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||
/// LEGACY (2026-07 presentation rebuild — design/apple-presentation-rebuild.md): the old
|
||||
/// user-visible stage picker's key. No longer read — the presenter is resolved from
|
||||
/// `presentPriority` below; the stage ladder survives only as the
|
||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
|
||||
/// value is documented, not mysterious.
|
||||
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
|
||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||
@@ -69,6 +86,21 @@ public enum DefaultsKey {
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||
public static let cursorMode = "punktfunk.cursorMode"
|
||||
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||
/// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel. For users whose host expects the
|
||||
/// opposite convention from their local OS preference.
|
||||
public static let invertScroll = "punktfunk.invertScroll"
|
||||
/// Location-based modifier mapping (a `ModifierLayout` value, default `.mac`): which Windows VK
|
||||
/// each PHYSICAL modifier position forwards to the host. `.mac` keeps ⌥ Option → Alt and
|
||||
/// ⌘ Command → Super/Win (the Apple positions). `.windows` swaps the Alt/Super ROLE between the
|
||||
/// Option and Command keys — preserving side (L/R) — so the key nearest the space bar acts as
|
||||
/// Alt and the next one as the Windows key, matching a Windows keyboard's `Ctrl / ⊞ / Alt` row.
|
||||
/// Only what's FORWARDED changes; client-local shortcuts (⌘⎋ &co.) stay on the physical ⌘ key.
|
||||
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
|
||||
/// both keyboards).
|
||||
public static let modifierLayout = "punktfunk.modifierLayout"
|
||||
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||
@@ -132,6 +164,12 @@ extension Notification.Name {
|
||||
/// discoverable menu-bar surface.
|
||||
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
||||
|
||||
/// Posted by the app's Stream menu ("Toggle Fullscreen", ⌃⌘F) and by InputCapture's monitor
|
||||
/// when the same combo fires while input is captured (the menu key-equivalent never reaches a
|
||||
/// captured stream view). The key window's `FullscreenController` flips the window's fullscreen
|
||||
/// state. macOS only.
|
||||
public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen")
|
||||
|
||||
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||
/// which runs in the app's process): the app tears the active session down deliberately
|
||||
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Location-based modifier mapping — which Windows VK each PHYSICAL modifier position forwards to
|
||||
// the host. A Mac keyboard's bottom row is `⌃ Control / ⌥ Option / ⌘ Command / space`; a Windows
|
||||
// keyboard's is `Ctrl / ⊞ Super / Alt / space`. So the key NEAREST the space bar is Command on a
|
||||
// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who
|
||||
// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that
|
||||
// muscle memory back WITHOUT relabelling keycaps — it remaps by physical position, not by a blunt
|
||||
// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction.
|
||||
//
|
||||
// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and
|
||||
// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows`
|
||||
// the ⌘ position emits Alt (VK_L/RMENU) and the ⌥ position emits the Windows key (VK_L/RWIN), while
|
||||
// left stays left and right stays right. Control and Shift are in the same place on both keyboards,
|
||||
// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and
|
||||
// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How the physical ⌥ Option / ⌘ Command keys map to host modifiers. The raw values are stable on
|
||||
/// disk — rename the cases freely, never the strings.
|
||||
public enum ModifierLayout: String, CaseIterable, Sendable {
|
||||
/// Apple positions (default): ⌥ Option → Alt, ⌘ Command → Super/Windows. The current behaviour.
|
||||
case mac
|
||||
/// Windows positions: the key nearest the space bar (⌘ Command) → Alt, the next one (⌥ Option)
|
||||
/// → the Windows/Super key. Side (left/right) is preserved.
|
||||
case windows
|
||||
|
||||
/// User-facing label (Settings picker).
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .mac: return "Mac (⌥ Alt · ⌘ Super)"
|
||||
case .windows: return "Windows (⌘ Alt · ⌥ Super)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line explanation for the setting's footer.
|
||||
public var detail: String {
|
||||
switch self {
|
||||
case .mac:
|
||||
return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout."
|
||||
case .windows:
|
||||
return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key."
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted layout (default `.mac` when unset).
|
||||
public static var current: ModifierLayout {
|
||||
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
|
||||
return .mac
|
||||
}
|
||||
return ModifierLayout(rawValue: raw) ?? .mac
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Render-resolution scaling — the pure geometry behind `DefaultsKey.renderScale`. The client asks
|
||||
// the host to render/encode at `chosen resolution × scale` and lets the presenter downscale the
|
||||
// larger decoded frame to the display (a Catmull-Rom minification, > 1 = supersampling for
|
||||
// sharpness) or upscale a smaller one (< 1 = a performance mode for a weak host GPU / thin link).
|
||||
//
|
||||
// This is where the multiplier is turned into a host-valid `Mode` dimension: multiply, preserve the
|
||||
// aspect ratio, floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the
|
||||
// codec's per-axis ceiling so the connect can't ask for something the encoder will reject. Kept
|
||||
// dependency-free + side-effect-free so it's unit-tested (`RenderScaleTests`) and reused by both the
|
||||
// fixed-mode connect and the match-window follower.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RenderScale {
|
||||
/// The supported multiplier range. Below 1 renders under native (upscaled on present); above 1
|
||||
/// supersamples. The UI clamps its slider to this and the connect clamps the raw stored value.
|
||||
public static let range: ClosedRange<Double> = 0.5...4.0
|
||||
|
||||
/// The multipliers the picker offers. 1.0 (Native) is the default; the rest are the round stops
|
||||
/// users reason about.
|
||||
public static let presets: [Double] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]
|
||||
|
||||
/// The encoder/host per-axis ceiling for a codec preference string (`DefaultsKey.codec`). H.264
|
||||
/// tops out at 4096 px/axis; HEVC / AV1 / PyroWave (and "auto", which negotiates one of those in
|
||||
/// practice) at 8192. The host enforces the same walls in `codec.rs::validate_dimensions`.
|
||||
public static func maxDimension(codec: String) -> Int {
|
||||
codec == "h264" ? 4096 : 8192
|
||||
}
|
||||
|
||||
/// A compact user-facing label for a multiplier: "Native (1×)", "1.5×", "2× · supersample".
|
||||
/// Shared by every platform's picker so the wording stays identical.
|
||||
public static func label(_ scale: Double) -> String {
|
||||
if scale == 1.0 { return "Native (1×)" }
|
||||
let magnitude = String(format: "%g×", scale)
|
||||
return scale > 1 ? "\(magnitude) · supersample" : magnitude
|
||||
}
|
||||
|
||||
/// Clamp a raw stored multiplier into `range`, treating a missing/zero value as 1.0 (Native).
|
||||
public static func sanitize(_ raw: Double) -> Double {
|
||||
guard raw > 0 else { return 1.0 }
|
||||
return min(max(raw, range.lowerBound), range.upperBound)
|
||||
}
|
||||
|
||||
/// Apply `scale` to a base pixel size, preserving aspect, even-flooring each axis, and clamping
|
||||
/// uniformly so neither axis exceeds `maxDimension` (the larger axis lands on the cap, the ratio
|
||||
/// is kept). Also floors each axis at `minWidth`/`minHeight` (the host never accepts < 320×200).
|
||||
/// The result is a directly host-valid `Mode` width/height.
|
||||
public static func apply(
|
||||
baseWidth: Int,
|
||||
baseHeight: Int,
|
||||
scale rawScale: Double,
|
||||
maxDimension: Int,
|
||||
minWidth: Int = 320,
|
||||
minHeight: Int = 200
|
||||
) -> (width: UInt32, height: UInt32) {
|
||||
let scale = sanitize(rawScale)
|
||||
var w = Double(max(baseWidth, 1)) * scale
|
||||
var h = Double(max(baseHeight, 1)) * scale
|
||||
// Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact.
|
||||
let cap = Double(maxDimension)
|
||||
let over = max(w / cap, h / cap)
|
||||
if over > 1 {
|
||||
w /= over
|
||||
h /= over
|
||||
}
|
||||
let evenFloor: (Double, Int) -> UInt32 = { value, minimum in
|
||||
let clamped = max(Int(value.rounded(.down)), minimum)
|
||||
return UInt32(clamped / 2 * 2)
|
||||
}
|
||||
return (evenFloor(w, minWidth), evenFloor(h, minHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) — the wire-boundary
|
||||
/// swap that relocates the Alt/Super role between the physical ⌥/⌘ keys per side, without touching
|
||||
/// Control/Shift or the physical detection upstream.
|
||||
final class ModifierLayoutMappingTests: XCTestCase {
|
||||
// The four physical modifier VKs the remap can touch, and everything else it must not.
|
||||
private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C
|
||||
private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5
|
||||
|
||||
func testMacLayoutIsIdentity() {
|
||||
for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] {
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk)
|
||||
}
|
||||
}
|
||||
|
||||
func testWindowsLayoutSwapsAltAndSuperPerSide() {
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L ⌘ → L Alt
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R ⌘ → R Alt
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L ⌥ → L Win
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R ⌥ → R Win
|
||||
}
|
||||
|
||||
func testWindowsLayoutKeepsSideNeverCrossesLeftRight() {
|
||||
// A left key never becomes a right VK or vice-versa.
|
||||
XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt)
|
||||
XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt)
|
||||
}
|
||||
|
||||
func testControlShiftAndRegularKeysNeverMove() {
|
||||
for vk: UInt32 in [
|
||||
0xA0, 0xA1, // L/R Shift
|
||||
0xA2, 0xA3, // L/R Control
|
||||
0x41, 0x5A, // A, Z
|
||||
0x1B, 0x0D, // Esc, Return
|
||||
] {
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk)
|
||||
}
|
||||
}
|
||||
|
||||
func testWindowsRemapIsItsOwnInverse() {
|
||||
// Down-remapped then the same key up-remapped land on the same host VK — no stuck modifier.
|
||||
for vk: UInt32 in [lWin, rWin, lAlt, rAlt] {
|
||||
let once = InputCapture.applyModifierLayout(vk, .windows)
|
||||
XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import XCTest
|
||||
import QuartzCore
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
|
||||
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
|
||||
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
|
||||
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
|
||||
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
|
||||
/// stage-4 gate), and the per-platform glass-gate depth.
|
||||
final class PresentPacingTests: XCTestCase {
|
||||
// MARK: - PresentGate
|
||||
|
||||
/// The core invariant: one present in flight. A second acquire while pending must fail (the
|
||||
/// frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
/// The depth-1 invariant: one present in flight. A second acquire while pending must fail
|
||||
/// (the frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
func testGateAdmitsOneInFlightPresent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||
@@ -20,6 +22,34 @@ final class PresentPacingTests: XCTestCase {
|
||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||
}
|
||||
|
||||
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung — no longer a default; see
|
||||
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue
|
||||
/// behind the flip scanning out — the bound only bites at the THIRD. One release (a glass
|
||||
/// callback) reopens exactly one slot.
|
||||
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.001), "depth 2 must admit a queued second flip")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.002), "the third present must wait for glass")
|
||||
gate.release()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.003), "one glass callback frees one slot")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.004))
|
||||
XCTAssertEqual(gate.drainForced(), 0)
|
||||
}
|
||||
|
||||
/// Depth 2 staleness anchors to the OLDEST in-flight present: a full gate stays closed while
|
||||
/// the oldest is live, force-opens once it ages out, and the younger present keeps its slot.
|
||||
func testGateDepthTwoForceOpensOnTheOldestStalePresent() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10.05))
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
|
||||
XCTAssertEqual(gate.drainForced(), 1)
|
||||
// The 10.05 present is still live, so the gate is full again right after the force-open.
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.02))
|
||||
}
|
||||
|
||||
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
|
||||
/// aren't damage" hazard class) must not freeze the stream — past `staleAfter` the gate
|
||||
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
|
||||
@@ -45,13 +75,150 @@ final class PresentPacingTests: XCTestCase {
|
||||
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
|
||||
|
||||
func testPresenterChoiceDefaultsToStage2() {
|
||||
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
|
||||
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
||||
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
||||
/// No selection / garbage falls back to it.
|
||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||
#if os(iOS) || os(tvOS)
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
||||
#else
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||||
PresenterChoice.platformDefault)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true),
|
||||
PresenterChoice.platformDefault)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
}
|
||||
@@ -69,15 +236,109 @@ final class PresentPacingTests: XCTestCase {
|
||||
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
|
||||
/// builds); a leftover "stage1" value in a release build maps back to stage-2.
|
||||
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
|
||||
func testPresenterChoiceGatesStage1() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false),
|
||||
PresenterChoice.platformDefault)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false),
|
||||
PresenterChoice.platformDefault)
|
||||
}
|
||||
|
||||
/// `explicit` is nil exactly when `resolve` would fall back to the platform default — the
|
||||
/// distinction the codec-conditional pacing default rides on.
|
||||
func testPresenterChoiceExplicitIsNilWithoutASelection() {
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: nil, env: nil, allowStage1: true))
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: "garbage", env: nil, allowStage1: true))
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: "stage1", env: nil, allowStage1: false))
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.explicit(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.explicit(setting: nil, env: "stage3", allowStage1: true), .stage3)
|
||||
}
|
||||
|
||||
// MARK: - Session pacing (the macOS PyroWave swapID-panic mitigation)
|
||||
|
||||
/// macOS PyroWave sessions under the DEFAULT stage-2 choice must get glass pacing (the
|
||||
/// one-in-flight gate is the "mismatched swapID's" kernel-panic mitigation); an EXPLICIT
|
||||
/// stage-2 pick must stay a faithful arrival-pacing A/B. Elsewhere the default is unchanged.
|
||||
func testPacingDefaultsPyroWaveToGlassOnMacOS() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .glass,
|
||||
"defaulted macOS PyroWave must serialize presents (swapID-panic mitigation)")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: .stage2, codec: .pyrowave), .arrival,
|
||||
"an explicit stage-2 pick must keep arrival pacing (honest A/B)")
|
||||
#else
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .arrival)
|
||||
#endif
|
||||
// Non-PyroWave defaults keep arrival pacing under stage-2 everywhere.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .hevc), .arrival)
|
||||
// Stage-3 means glass regardless of codec or how it was chosen.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
||||
// Stage-4 means deadline regardless of codec or how it was chosen.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
||||
}
|
||||
|
||||
// MARK: - Glass-gate depth
|
||||
|
||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
||||
/// the 2026-07 iPad depth-2 experiment regressed display latency 14→22–28 ms (see
|
||||
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
|
||||
/// there is the swapID-panic mitigation — strict serialization is its point);
|
||||
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS.
|
||||
/// Out-of-range/garbage values are ignored.
|
||||
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
||||
#else
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: nil), 1,
|
||||
"any depth >1 is a standing queue — one refresh of display latency per slot")
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder lever")
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
||||
"out-of-range env values fall back to the platform depth")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "garbage"), SessionPresenter.gateDepth(env: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -57,7 +57,7 @@ final class PyroWaveParserTests: XCTestCase {
|
||||
func testLayoutMatchesUpstreamBlockSpace() {
|
||||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||||
XCTAssertEqual(layout.alignedWidth, 256)
|
||||
XCTAssertEqual(layout.alignedHeight, 160)
|
||||
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||
@@ -85,7 +85,7 @@ final class PyroWaveParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||||
var au = sof(totalBlocks: 4)
|
||||
au += packet(blockIndex: 0)
|
||||
au += packet(blockIndex: 3)
|
||||
@@ -273,6 +273,17 @@ final class PyroWaveGoldenTests: XCTestCase {
|
||||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||||
}
|
||||
|
||||
/// 4:4:4: the chroma components run the full pyramid like luma (no level-0 skip, no
|
||||
/// early half-res emit) — the layout + dispatch structure Phase 4 added
|
||||
/// (design/pyrowave-444-hdr.md). The fixture comes from the 4:4:4 host encoder; the
|
||||
/// reference is upstream's own 4:4:4 decode (full-res chroma planes).
|
||||
func testDense444GoldenFrame() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
let au = try fixture("au-dense444")
|
||||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||||
try assertMatchesReference(decoded, prefix: "ref-dense444")
|
||||
}
|
||||
|
||||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||
/// localized blur, not garbage).
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
/// Pins the render-scale geometry (`RenderScale`) — the multiply → aspect-preserve → even-floor →
|
||||
/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp
|
||||
/// helpers the UI and the connect share.
|
||||
final class RenderScaleTests: XCTestCase {
|
||||
func testSanitizeClampsToRangeAndDefaultsMissing() {
|
||||
XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero → Native
|
||||
XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense → Native
|
||||
XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor
|
||||
XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling
|
||||
XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched
|
||||
}
|
||||
|
||||
func testMaxDimensionIsCodecAware() {
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096)
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192)
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192)
|
||||
XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192)
|
||||
}
|
||||
|
||||
func testNativeScaleIsIdentity() {
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width, 1920)
|
||||
XCTAssertEqual(m.height, 1080)
|
||||
}
|
||||
|
||||
func testSupersampleDoubles() {
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width, 3840)
|
||||
XCTAssertEqual(m.height, 2160)
|
||||
}
|
||||
|
||||
func testUnderRenderHalves() {
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width, 960)
|
||||
XCTAssertEqual(m.height, 540)
|
||||
}
|
||||
|
||||
func testResultsAreAlwaysEven() {
|
||||
// 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 → 2048×1152.
|
||||
let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192)
|
||||
XCTAssertEqual(m.width % 2, 0)
|
||||
XCTAssertEqual(m.height % 2, 0)
|
||||
XCTAssertEqual(m.width, 2048)
|
||||
XCTAssertEqual(m.height, 1152)
|
||||
}
|
||||
|
||||
func testOverCeilingClampsUniformlyPreservingAspect() {
|
||||
// 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap
|
||||
// and the ratio is kept (16:9 → 8192×4608, even).
|
||||
let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192)
|
||||
XCTAssertLessThanOrEqual(m.width, 8192)
|
||||
XCTAssertLessThanOrEqual(m.height, 8192)
|
||||
XCTAssertEqual(m.width, 8192)
|
||||
XCTAssertEqual(m.height, 4608)
|
||||
// 16:9 preserved to within the even-floor rounding.
|
||||
XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testH264CeilingIsTighter() {
|
||||
// 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept.
|
||||
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096)
|
||||
XCTAssertEqual(m.width, 4096)
|
||||
XCTAssertEqual(m.height, 2304)
|
||||
}
|
||||
|
||||
func testMinimumFloorHonoured() {
|
||||
// A tiny base under a small scale can't fall below 320×200.
|
||||
let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192)
|
||||
XCTAssertGreaterThanOrEqual(m.width, 320)
|
||||
XCTAssertGreaterThanOrEqual(m.height, 200)
|
||||
}
|
||||
|
||||
func testLabels() {
|
||||
XCTAssertEqual(RenderScale.label(1.0), "Native (1×)")
|
||||
XCTAssertEqual(RenderScale.label(2.0), "2× · supersample")
|
||||
XCTAssertEqual(RenderScale.label(0.5), "0.5×")
|
||||
}
|
||||
}
|
||||
@@ -896,8 +896,8 @@ class Plugin:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
||||
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface StreamSettings {
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
|
||||
@@ -25,6 +25,10 @@ const RESOLUTIONS: [number, number, string][] = [
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
@@ -106,6 +110,24 @@ export const SettingsSection: FC = () => {
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Render scale"
|
||||
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||
)}
|
||||
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
|
||||
@@ -55,6 +55,11 @@ pub struct AppModel {
|
||||
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
||||
/// run in the session binary, which has its own.
|
||||
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>,
|
||||
/// One session child at a time — connects while one runs are ignored.
|
||||
busy: bool,
|
||||
@@ -160,6 +165,42 @@ impl SimpleComponent for AppModel {
|
||||
}
|
||||
|
||||
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
|
||||
// matches it whenever such a pad connects).
|
||||
{
|
||||
@@ -197,6 +238,7 @@ impl SimpleComponent for AppModel {
|
||||
settings,
|
||||
identity,
|
||||
gamepad: init.gamepad,
|
||||
probes,
|
||||
hosts,
|
||||
busy: false,
|
||||
wake_fallback: None,
|
||||
@@ -307,8 +349,15 @@ impl SimpleComponent for AppModel {
|
||||
// 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
|
||||
// for THIS request, and connect immediately.
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
self.wake_fallback = Some(req.clone());
|
||||
//
|
||||
// Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -435,6 +484,7 @@ impl SimpleComponent for AppModel {
|
||||
&self.window,
|
||||
self.settings.clone(),
|
||||
&self.gamepad,
|
||||
&self.probes.borrow(),
|
||||
move || {
|
||||
// The library toggle changes the saved cards' menu — re-render.
|
||||
let _ = hosts.send(HostsMsg::Refresh);
|
||||
|
||||
@@ -352,6 +352,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
}
|
||||
match known.save() {
|
||||
@@ -534,7 +535,34 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
)));
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
||||
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()),
|
||||
"pair" | "05-pair" => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||||
//! capture behavior. Written back to disk when the dialog closes.
|
||||
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
|
||||
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
|
||||
//! 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 adw::prelude::*;
|
||||
@@ -17,6 +21,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the monitor's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
|
||||
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||
fn render_scale_label(scale: f64) -> String {
|
||||
if scale == 1.0 {
|
||||
"Native".to_string()
|
||||
} else if scale > 1.0 {
|
||||
format!("{scale}× (supersample)")
|
||||
} else {
|
||||
format!("{scale}×")
|
||||
}
|
||||
}
|
||||
const GAMEPADS: &[&str] = &[
|
||||
"auto",
|
||||
"xbox360",
|
||||
@@ -26,14 +44,31 @@ const GAMEPADS: &[&str] = &[
|
||||
"steamdeck",
|
||||
];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
||||
/// HEVC when either side can't do it.
|
||||
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"];
|
||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||
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.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
@@ -252,22 +287,92 @@ 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
|
||||
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
|
||||
/// 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(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
gamepads: &crate::gamepad::GamepadService,
|
||||
probes: &DeviceProbes,
|
||||
on_closed: impl Fn() + 'static,
|
||||
) {
|
||||
) -> adw::PreferencesDialog {
|
||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||
// subpage onto it.
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
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 page = adw::PreferencesPage::new();
|
||||
|
||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||
// ---- Display: Resolution ----
|
||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||
// `match_window` flag), then the explicit sizes.
|
||||
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||
@@ -283,10 +388,13 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Resolution",
|
||||
"The host creates a virtual output at exactly this size — Match window follows \
|
||||
the stream window, including mid-stream resizes",
|
||||
resolution_caption(0),
|
||||
&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
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
@@ -301,12 +409,82 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Refresh rate",
|
||||
"",
|
||||
"Native follows the monitor the window is on",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// ---- Display: Quality ----
|
||||
let scale_names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
.map(|&s| render_scale_label(s))
|
||||
.collect();
|
||||
let scale_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Render scale",
|
||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||
bitrate_row
|
||||
.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(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -320,19 +498,19 @@ pub fn show(
|
||||
"gamescope",
|
||||
],
|
||||
);
|
||||
let decoder_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video decoder",
|
||||
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
||||
Vulkan Video on NVIDIA), falling back to software",
|
||||
&[
|
||||
"Automatic (hardware → software)",
|
||||
"Vulkan Video",
|
||||
"VAAPI",
|
||||
"Software",
|
||||
],
|
||||
);
|
||||
|
||||
// ---- General ----
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
.build();
|
||||
let wake_row = adw::SwitchRow::builder()
|
||||
.title("Auto-wake on connect")
|
||||
.subtitle(
|
||||
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
|
||||
off if hosts behind a VPN look offline when they aren't",
|
||||
)
|
||||
.build();
|
||||
let stats_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -340,19 +518,101 @@ pub fn show(
|
||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||
&["Off", "Compact", "Normal", "Detailed"],
|
||||
);
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
let library_row = adw::SwitchRow::builder()
|
||||
.title("Show game library")
|
||||
.subtitle(
|
||||
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
|
||||
and launch one directly. No extra host setup",
|
||||
)
|
||||
.build();
|
||||
stream.add(res_row.widget());
|
||||
stream.add(hz_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());
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// ---- Input ----
|
||||
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
|
||||
// (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`),
|
||||
@@ -386,7 +646,7 @@ pub fn show(
|
||||
if pads.is_empty() {
|
||||
"No controllers detected"
|
||||
} else {
|
||||
"All controllers are forwarded, each as its own player; pick one to force single-player"
|
||||
"Every pad is its own player — pick one to force single-player"
|
||||
},
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
@@ -416,7 +676,7 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Gamepad type",
|
||||
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||
"The virtual pad on the host — Automatic matches your controller",
|
||||
&[
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
@@ -426,66 +686,8 @@ pub fn show(
|
||||
"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);
|
||||
|
||||
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.
|
||||
// ---- Seed from the current settings ----
|
||||
{
|
||||
let s = settings.borrow();
|
||||
let res_i = if s.match_window {
|
||||
@@ -498,8 +700,14 @@ pub fn show(
|
||||
.unwrap_or(0)
|
||||
};
|
||||
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);
|
||||
hz_row.set_selected(hz_i as u32);
|
||||
let scale_i = RENDER_SCALES
|
||||
.iter()
|
||||
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
scale_row.set_selected(scale_i as u32);
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||
pad_row.set_selected(pad_i as u32);
|
||||
@@ -508,6 +716,8 @@ pub fn show(
|
||||
.position(|&t| t == s.touch_mode)
|
||||
.unwrap_or(0);
|
||||
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
|
||||
.iter()
|
||||
.position(|&c| c == s.compositor)
|
||||
@@ -521,8 +731,11 @@ pub fn show(
|
||||
.unwrap_or(0);
|
||||
stats_row.set_selected(stats_i as u32);
|
||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||
wake_row.set_active(s.auto_wake);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
invert_row.set_active(s.invert_scroll);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
hdr_row.set_active(s.hdr_enabled);
|
||||
library_row.set_active(s.library_enabled);
|
||||
surround_row.set_selected(match s.audio_channels {
|
||||
6 => 1,
|
||||
@@ -531,9 +744,104 @@ pub fn show(
|
||||
});
|
||||
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
||||
codec_row.set_selected(codec_i as u32);
|
||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
|
||||
}
|
||||
|
||||
dialog.add(&page);
|
||||
// ---- Assemble the category pages (the Apple revamp's map) ----
|
||||
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 |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||
@@ -545,6 +853,8 @@ pub fn show(
|
||||
RESOLUTIONS[res_i - 1]
|
||||
};
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.render_scale =
|
||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.touch_mode =
|
||||
@@ -553,12 +863,25 @@ pub fn show(
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.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(
|
||||
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||
);
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.auto_wake = wake_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.hdr_enabled = hdr_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
@@ -571,6 +894,7 @@ pub fn show(
|
||||
on_closed();
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
dialog
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -644,6 +968,17 @@ mod tests {
|
||||
assert_eq!(fired.get(), 1);
|
||||
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).
|
||||
row.widget()
|
||||
.downcast_ref::<adw::ActionRow>()
|
||||
@@ -664,5 +999,16 @@ mod tests {
|
||||
combo.set_selected(0);
|
||||
assert_eq!(combo.selected(), 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1241,6 +1241,12 @@ async fn session(args: Args) -> Result<()> {
|
||||
std::collections::VecDeque::new();
|
||||
let mut host_us_v: Vec<u64> = Vec::new();
|
||||
let mut net_us_v: Vec<u64> = Vec::new();
|
||||
// T0.1 host-stage split (extended 0xCF): queue/encode/pace + the derived seal/xfer
|
||||
// residual. Empty against a host that predates the stage tail.
|
||||
let mut queue_us_v: Vec<u64> = Vec::new();
|
||||
let mut enc_us_v: Vec<u64> = Vec::new();
|
||||
let mut xfer_us_v: Vec<u64> = Vec::new();
|
||||
let mut pace_us_v: Vec<u64> = Vec::new();
|
||||
let mut last_rx = std::time::Instant::now();
|
||||
let started = std::time::Instant::now();
|
||||
// Stream-duration cap: `--seconds N`, else the 120s default. Ending the loop here reaches the
|
||||
@@ -1316,6 +1322,14 @@ async fn session(args: Args) -> Result<()> {
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
host_us_v.push(t.host_us as u64);
|
||||
net_us_v.push(hostnet_us.saturating_sub(t.host_us as u64));
|
||||
if let Some(s) = t.stages {
|
||||
queue_us_v.push(s.queue_us as u64);
|
||||
enc_us_v.push(s.encode_us as u64);
|
||||
pace_us_v.push(s.pace_us as u64);
|
||||
xfer_us_v.push((t.host_us as u64).saturating_sub(
|
||||
s.queue_us as u64 + s.encode_us as u64 + s.pace_us as u64,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if expected > 0 {
|
||||
@@ -1399,6 +1413,23 @@ async fn session(args: Args) -> Result<()> {
|
||||
"host/network latency split (host = capture→sent on the host; network = wire + \
|
||||
reassembly)"
|
||||
);
|
||||
if !queue_us_v.is_empty() {
|
||||
// The T0.1 per-stage host attribution: queue (capture→submit) → encode
|
||||
// (submit→bitstream) → xfer (seal/FEC + send-channel wait, derived residual)
|
||||
// → pace (the microburst spread). The four tile host_us per frame.
|
||||
tracing::info!(
|
||||
stage_samples = queue_us_v.len(),
|
||||
queue_p50_us = pcts(&mut queue_us_v, 0.50),
|
||||
queue_p95_us = pcts(&mut queue_us_v, 0.95),
|
||||
encode_p50_us = pcts(&mut enc_us_v, 0.50),
|
||||
encode_p95_us = pcts(&mut enc_us_v, 0.95),
|
||||
xfer_p50_us = pcts(&mut xfer_us_v, 0.50),
|
||||
xfer_p95_us = pcts(&mut xfer_us_v, 0.95),
|
||||
pace_p50_us = pcts(&mut pace_us_v, 0.50),
|
||||
pace_p95_us = pcts(&mut pace_us_v, 0.95),
|
||||
"host stage split (queue → encode → xfer → pace tile the host figure)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::info!("no host timing datagrams (0xCF) — old host; host+network unsplit");
|
||||
}
|
||||
|
||||
@@ -27,9 +27,13 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
|
||||
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
||||
# binary.
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
pf-presenter = { path = "../../crates/pf-presenter" }
|
||||
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single
|
||||
# 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-client-core = { path = "../../crates/pf-client-core" }
|
||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
@@ -158,6 +158,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
@@ -175,6 +176,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// Latched at console start (like the stats tier above): toggling Match window in
|
||||
// the console's settings screen applies from the next console launch.
|
||||
match_window: crate::session_main::match_window(&settings_at_start),
|
||||
render_scale: settings_at_start.render_scale,
|
||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
|
||||
};
|
||||
|
||||
let result =
|
||||
@@ -450,6 +453,7 @@ impl ServiceState {
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
|
||||
@@ -103,6 +103,14 @@ mod session_main {
|
||||
force_software: Arc<AtomicBool>,
|
||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
) -> 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`
|
||||
// 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
|
||||
@@ -127,6 +135,20 @@ mod session_main {
|
||||
settings.refresh_hz
|
||||
},
|
||||
};
|
||||
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
|
||||
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
|
||||
// Native/explicit resolution so it composes uniformly with both.
|
||||
let (sw, sh) = punktfunk_core::render_scale::apply(
|
||||
mode.width,
|
||||
mode.height,
|
||||
settings.render_scale,
|
||||
punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: sw,
|
||||
height: sh,
|
||||
..mode
|
||||
};
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
@@ -151,6 +173,7 @@ mod session_main {
|
||||
// pump) pins one manually.
|
||||
display_hdr: None,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||
@@ -245,19 +268,66 @@ mod session_main {
|
||||
)
|
||||
.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
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_radv_video_decode();
|
||||
|
||||
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
|
||||
// name) → the presenter's device selection, unless the user already forced one.
|
||||
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
|
||||
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
|
||||
let adapter = trust::Settings::load().adapter;
|
||||
if !adapter.is_empty() {
|
||||
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
|
||||
// The Settings device picks → env, unless the user already forced one by hand:
|
||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||
// the RADV knob (covers --connect and --browse).
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +429,7 @@ mod session_main {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
@@ -372,6 +443,8 @@ mod session_main {
|
||||
overlay: None,
|
||||
window_size: window_size(&settings),
|
||||
match_window: match_window(&settings),
|
||||
render_scale: settings.render_scale,
|
||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
};
|
||||
|
||||
let outcome =
|
||||
|
||||
@@ -12,6 +12,12 @@ repository.workspace = true
|
||||
name = "punktfunk-client"
|
||||
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
|
||||
# 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")
|
||||
@@ -23,7 +29,17 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# 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
|
||||
# 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
|
||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||
|
||||
@@ -57,6 +57,28 @@
|
||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
||||
</uap:VisualElements>
|
||||
</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>
|
||||
|
||||
<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/
|
||||
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
||||
# adds no DLLs of its own.
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $TargetDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
||||
|
||||
@@ -36,7 +36,9 @@ pub(crate) fn initiate_waking(
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
if ctx.settings.lock().unwrap().auto_wake {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
}
|
||||
initiate_opts(ctx, target, set_screen, set_status, true)
|
||||
}
|
||||
|
||||
@@ -272,6 +274,7 @@ fn connect_spawn(
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
@@ -291,9 +294,13 @@ fn connect_spawn(
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
}
|
||||
Some((_, false)) if wake_on_fail => {
|
||||
Some((_, false))
|
||||
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
|
||||
{
|
||||
// The dial-first attempt to a non-advertising host failed — it
|
||||
// may genuinely be asleep. NOW wake and wait.
|
||||
// may genuinely be asleep. NOW wake and wait. Skipped entirely
|
||||
// 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);
|
||||
}
|
||||
Some((msg, false)) => {
|
||||
@@ -321,9 +328,12 @@ fn connect_spawn(
|
||||
/// 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
|
||||
/// 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(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
target: Option<Target>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
@@ -331,15 +341,21 @@ pub(crate) fn open_console(
|
||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||
ctx.shared.stats_line.lock().unwrap().clear();
|
||||
ctx.shared.browse.store(true, Ordering::SeqCst);
|
||||
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||
if let Some(t) = target.clone() {
|
||||
*ctx.shared.target.lock().unwrap() = t;
|
||||
}
|
||||
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
||||
set_status.call(String::new());
|
||||
set_screen.call(Screen::Connecting);
|
||||
|
||||
let shared = ctx.shared.clone();
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let spawned =
|
||||
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
|
||||
let addr_port = target.as_ref().map(|t| (t.addr.clone(), t.port));
|
||||
let spawned = crate::spawn::spawn_browse(
|
||||
addr_port.as_ref().map(|(a, p)| (a.as_str(), *p)),
|
||||
fullscreen,
|
||||
child,
|
||||
move |event| {
|
||||
use crate::spawn::SpawnEvent;
|
||||
match event {
|
||||
SpawnEvent::Ready => {
|
||||
@@ -357,7 +373,8 @@ pub(crate) fn open_console(
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Err(e) = spawned {
|
||||
set_status.call(e);
|
||||
set_screen.call(Screen::Hosts);
|
||||
@@ -467,6 +484,7 @@ fn wake_and_connect(
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
|
||||
+192
-130
@@ -1,5 +1,5 @@
|
||||
//! 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 / rename /
|
||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / edit /
|
||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||
|
||||
use super::connect::{initiate, initiate_waking, open_console};
|
||||
@@ -14,10 +14,12 @@ use windows_reactor::*;
|
||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||
const MENU_CONNECT: &str = "Connect";
|
||||
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_WAKE: &str = "Wake host";
|
||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
|
||||
/// 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}";
|
||||
|
||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||
@@ -187,43 +189,114 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
||||
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
||||
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
|
||||
/// 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
|
||||
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
|
||||
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
|
||||
fn rename_editor(
|
||||
initial: &str,
|
||||
fp: String,
|
||||
live: HookRef<String>,
|
||||
set_rename: AsyncSetState<Option<(String, String)>>,
|
||||
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host
|
||||
/// property in one place, mirroring the Apple client's add/edit sheet — name, address,
|
||||
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host.
|
||||
/// Replaced a menu-item-per-property, which buried the useful entries in noise.
|
||||
///
|
||||
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state
|
||||
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip
|
||||
/// through a re-render.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_editor(
|
||||
fp: &str,
|
||||
initial_name: &str,
|
||||
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 {
|
||||
let commit = {
|
||||
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||
let (fp, se) = (fp.to_string(), set_edit.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 || {
|
||||
let draft = live.borrow();
|
||||
let name = draft.trim();
|
||||
if !name.is_empty() {
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.name = name.to_string();
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
// Each field falls back to what was stored: a cleared box means "leave it",
|
||||
// never "erase it" — except the MAC, which is legitimately clearable.
|
||||
let name = name_draft.borrow().trim().to_string();
|
||||
if !name.is_empty() {
|
||||
h.name = name;
|
||||
}
|
||||
let _ = known.save();
|
||||
let addr = addr_draft.borrow().trim().to_string();
|
||||
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();
|
||||
}
|
||||
sr.call(None);
|
||||
let _ = known.save();
|
||||
se.call(None);
|
||||
}
|
||||
};
|
||||
let on_changed = {
|
||||
let live = live.clone();
|
||||
move |s: String| live.set(s)
|
||||
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
|
||||
vstack((
|
||||
text_block(label)
|
||||
.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(
|
||||
vstack((
|
||||
text_box(initial)
|
||||
.placeholder_text("Host name")
|
||||
.on_text_changed(on_changed),
|
||||
field("Name", name0, "e.g. Living Room", name_draft),
|
||||
field("Address", addr0, "IP or hostname", addr_draft),
|
||||
field("Port", port0, "9777", port_draft),
|
||||
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((
|
||||
button("Save")
|
||||
.accent()
|
||||
@@ -231,7 +304,7 @@ fn rename_editor(
|
||||
.on_click(commit),
|
||||
button("Cancel")
|
||||
.subtle()
|
||||
.on_click(move || set_rename.call(None)),
|
||||
.on_click(move || set_edit.call(None)),
|
||||
))
|
||||
.spacing(4.0),
|
||||
))
|
||||
@@ -264,16 +337,41 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let rename = props.rename.clone();
|
||||
let set_forget = &props.set_forget;
|
||||
let set_rename = &props.set_rename;
|
||||
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
|
||||
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
|
||||
// TARGET changes (start, cancel, or a switch to another host).
|
||||
let rename_draft = cx.use_ref(String::new());
|
||||
let rename_seed = cx.use_ref(Option::<String>::None);
|
||||
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only
|
||||
// the target's fingerprint + initial name, so typing never round-trips through a
|
||||
// re-render. Every draft is re-seeded from the STORED host whenever the edit target
|
||||
// changes (open, cancel, or switching to another host).
|
||||
let name_draft = cx.use_ref(String::new());
|
||||
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());
|
||||
if *rename_seed.borrow() != active {
|
||||
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||
rename_seed.set(active);
|
||||
if *edit_seed.borrow() != active {
|
||||
let stored = active.as_ref().and_then(|fp| {
|
||||
KnownHosts::load()
|
||||
.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 {
|
||||
@@ -314,20 +412,51 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.spacing(2.0)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
hstack((
|
||||
header_btn("Add host", Symbol::Add).accent().on_click({
|
||||
let sa = set_show_add.clone();
|
||||
move || sa.call(true)
|
||||
}),
|
||||
header_btn("Shortcuts", Symbol::Keyboard).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Help)
|
||||
}),
|
||||
header_btn("Settings", Symbol::Setting).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
}),
|
||||
))
|
||||
hstack({
|
||||
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add)
|
||||
.accent()
|
||||
.on_click({
|
||||
let sa = set_show_add.clone();
|
||||
move || sa.call(true)
|
||||
})
|
||||
.into()];
|
||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
||||
// where the session binary ships without its Skia console.
|
||||
if CONSOLE_UI_AVAILABLE {
|
||||
actions.push(
|
||||
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)
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
@@ -347,84 +476,23 @@ 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
|
||||
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
||||
if !known.hosts.is_empty() {
|
||||
body.push(section("SAVED HOSTS"));
|
||||
let mut tiles: Vec<Element> = Vec::new();
|
||||
for k in &known.hosts {
|
||||
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
|
||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||
let (fp, initial) = rename.clone().unwrap();
|
||||
tiles.push(rename_editor(
|
||||
tiles.push(edit_editor(
|
||||
&fp,
|
||||
&initial,
|
||||
fp,
|
||||
rename_draft.clone(),
|
||||
name_draft.clone(),
|
||||
addr_draft.clone(),
|
||||
port_draft.clone(),
|
||||
mac_draft.clone(),
|
||||
clip_draft.clone(),
|
||||
set_rename.clone(),
|
||||
));
|
||||
continue;
|
||||
@@ -471,15 +539,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
if library_enabled && k.paired {
|
||||
items.push(menu_item(MENU_LIBRARY));
|
||||
}
|
||||
if CONSOLE_UI_AVAILABLE && k.paired {
|
||||
items.push(menu_item(MENU_CONSOLE));
|
||||
}
|
||||
items.push(menu_item(MENU_SPEED));
|
||||
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||
if can_wake {
|
||||
items.push(menu_item(MENU_WAKE));
|
||||
}
|
||||
items.push(menu_item(MENU_RENAME));
|
||||
items.push(menu_item(MENU_EDIT));
|
||||
items.push(menu_separator());
|
||||
items.push(menu_item(MENU_FORGET));
|
||||
items
|
||||
@@ -493,9 +558,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
super::library::start_fetch(&svc.ctx, &svc.set_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_SPEED => {
|
||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||
@@ -507,7 +569,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
svc.set_speed.call(SpeedState::Running);
|
||||
svc.set_screen.call(Screen::SpeedTest);
|
||||
}
|
||||
MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
|
||||
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))),
|
||||
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
|
||||
_ => {}
|
||||
})
|
||||
|
||||
@@ -241,7 +241,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
|
||||
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||
// Opens on General — the first sidebar item, matching the Apple client's landing category.
|
||||
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
|
||||
// (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.
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: target3.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
|
||||
+351
-120
@@ -1,5 +1,15 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! **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::{AppCtx, Screen};
|
||||
@@ -19,12 +29,27 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the display's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
|
||||
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||
fn render_scale_label(scale: f64) -> String {
|
||||
if scale == 1.0 {
|
||||
"Native".to_string()
|
||||
} else if scale > 1.0 {
|
||||
format!("{scale}\u{00D7} (supersample)")
|
||||
} else {
|
||||
format!("{scale}\u{00D7}")
|
||||
}
|
||||
}
|
||||
/// Decode backend presets: `(stored value, display label)`.
|
||||
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
|
||||
// Automatic — which is exactly how the session's decoder chain reads that value.
|
||||
const DECODERS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||
("vulkan", "Hardware (Vulkan Video)"),
|
||||
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
||||
("software", "Software (CPU)"),
|
||||
];
|
||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||
@@ -37,6 +62,9 @@ const CODECS: &[(&str, &str)] = &[
|
||||
("hevc", "HEVC (H.265)"),
|
||||
("h264", "H.264 (AVC)"),
|
||||
("av1", "AV1"),
|
||||
// Preference-only by design: `resolve_codec` never auto-picks PyroWave, and asking for
|
||||
// it on a host or device that can't do it simply falls back down the ladder to HEVC.
|
||||
("pyrowave", "PyroWave (wired LAN)"),
|
||||
];
|
||||
/// Virtual-pad presets: `(stored value, display label)` — the pad the HOST creates. Same set the
|
||||
/// GTK client offers; "Automatic" resolves from the physical controller at connect.
|
||||
@@ -120,11 +148,63 @@ fn setting_toggle(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// control itself (a paragraph in the card reads as the first control's label).
|
||||
fn settings_card(controls: Vec<Element>) -> Element {
|
||||
card(vstack(controls).spacing(10.0)).into()
|
||||
/// One field: the control with its explanation directly underneath (Apple's `described`).
|
||||
///
|
||||
/// The caption goes BELOW the control on purpose. An earlier revision put guidance only in
|
||||
/// hover tooltips because a paragraph *above* a control reads as that control's label — true,
|
||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
||||
/// 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) —
|
||||
@@ -169,12 +249,7 @@ pub(crate) fn settings_page(
|
||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||
s.match_window = 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 names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
@@ -191,33 +266,40 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, 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 names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
.map(|&x| render_scale_label(x))
|
||||
.collect();
|
||||
let i = RENDER_SCALES
|
||||
.iter()
|
||||
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
(names, i)
|
||||
};
|
||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
||||
s.render_scale = RENDER_SCALES[i];
|
||||
});
|
||||
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| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||
choice is unavailable.",
|
||||
);
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let fullscreen_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Start streams fullscreen",
|
||||
s.fullscreen_on_stream,
|
||||
|s, on| s.fullscreen_on_stream = on,
|
||||
)
|
||||
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
|
||||
);
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
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| {
|
||||
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.
|
||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||
let gpus = crate::gpu::adapter_names();
|
||||
@@ -236,18 +318,11 @@ pub(crate) fn settings_page(
|
||||
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_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"A soft preference \u{2014} the host falls back to the best codec both sides support.",
|
||||
);
|
||||
});
|
||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||
// round-trips exactly.
|
||||
let bitrate_box = {
|
||||
@@ -260,18 +335,10 @@ pub(crate) fn settings_page(
|
||||
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
|
||||
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| {
|
||||
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 -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -316,60 +383,44 @@ pub(crate) fn settings_page(
|
||||
s.forward_pad = key.unwrap_or_default();
|
||||
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| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
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_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
|
||||
pointer jumps to your finger, Touch passthrough sends real touches.",
|
||||
);
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
s.inhibit_shortcuts,
|
||||
|s, on| s.inhibit_shortcuts = on,
|
||||
)
|
||||
.tooltip("Off: Alt+Tab, Win & co. act on this machine while the stream input is captured.");
|
||||
);
|
||||
|
||||
// --- Audio -----------------------------------------------------------------------------
|
||||
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| {
|
||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||
})
|
||||
.tooltip("The host downmixes if its output has fewer channels.");
|
||||
});
|
||||
let mic_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Stream microphone to the host",
|
||||
s.mic_enabled,
|
||||
|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_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||
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 ss = set_screen.clone();
|
||||
@@ -380,10 +431,6 @@ pub(crate) fn settings_page(
|
||||
"Show game library (experimental)",
|
||||
s.library_enabled,
|
||||
|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
|
||||
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||
@@ -396,69 +443,227 @@ pub(crate) fn settings_page(
|
||||
))
|
||||
.spacing(2.0);
|
||||
|
||||
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
||||
// card is just the controls.
|
||||
let (title, card): (&str, Element) = match section {
|
||||
"video" => (
|
||||
"Video",
|
||||
settings_card({
|
||||
let mut controls: Vec<Element> = vec![decoder_combo.into()];
|
||||
if let Some(c) = gpu_combo {
|
||||
controls.push(c.into());
|
||||
}
|
||||
controls.extend([
|
||||
codec_combo.into(),
|
||||
bitrate_box.into(),
|
||||
hdr_toggle.into(),
|
||||
hud_combo.into(),
|
||||
]);
|
||||
controls
|
||||
}),
|
||||
),
|
||||
"input" => (
|
||||
"Input",
|
||||
settings_card(vec![
|
||||
forward_combo.into(),
|
||||
pad_combo.into(),
|
||||
touch_combo.into(),
|
||||
shortcuts_toggle.into(),
|
||||
]),
|
||||
// The selected section's content, grouped exactly like the Apple client's categories
|
||||
// (SettingsCategory + SettingsView+Sections.swift). Each field's explanation sits under
|
||||
// it; the only form-level notes are the "applies from the next session" footers, matching
|
||||
// Apple's decision to keep exactly one of those per affected category.
|
||||
let (title, groups): (&str, Vec<Element>) = match section {
|
||||
"display" => {
|
||||
let mut out = group(
|
||||
Some("Resolution"),
|
||||
vec![
|
||||
described(
|
||||
res_combo,
|
||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
||||
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
|
||||
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
||||
(1:1) through every resize.",
|
||||
),
|
||||
described(
|
||||
hz_combo,
|
||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
||||
connect.",
|
||||
),
|
||||
],
|
||||
None,
|
||||
);
|
||||
out.extend(group(
|
||||
Some("Quality"),
|
||||
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",
|
||||
settings_card(vec![channels_combo.into(), mic_toggle.into()]),
|
||||
group(
|
||||
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",
|
||||
settings_card(vec![
|
||||
about_identity.into(),
|
||||
library_toggle.into(),
|
||||
licenses_button.into(),
|
||||
]),
|
||||
),
|
||||
_ => (
|
||||
"Display",
|
||||
settings_card(vec![
|
||||
res_combo.into(),
|
||||
hz_combo.into(),
|
||||
fullscreen_toggle.into(),
|
||||
comp_combo.into(),
|
||||
]),
|
||||
group(
|
||||
None,
|
||||
vec![about_identity.into(), licenses_button.into()],
|
||||
None,
|
||||
),
|
||||
),
|
||||
// "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
|
||||
// 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.
|
||||
// Category order mirrors the Apple client's sidebar exactly.
|
||||
let items = vec![
|
||||
NavViewItem::new("General")
|
||||
.tag("general")
|
||||
.icon(Symbol::Setting),
|
||||
NavViewItem::new("Display")
|
||||
.tag("display")
|
||||
.icon(Symbol::FullScreen),
|
||||
NavViewItem::new("Video").tag("video").icon(Symbol::Video),
|
||||
NavViewItem::new("Input")
|
||||
.tag("input")
|
||||
.icon(Symbol::Keyboard),
|
||||
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
|
||||
NavViewItem::new("Controllers")
|
||||
.tag("controllers")
|
||||
.icon(Symbol::Play),
|
||||
NavViewItem::new("About").tag("about").icon(Symbol::Help),
|
||||
];
|
||||
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
|
||||
@@ -469,12 +674,38 @@ pub(crate) fn settings_page(
|
||||
//
|
||||
// The content column (not the NavigationView — the sidebar must stay put) carries the
|
||||
// section-switch entrance: fade + slide-up from the root-driven tween.
|
||||
let content = page_wide(vec![card.with_key(section)])
|
||||
.opacity(progress)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
// No max-width cap here (unlike the other pages): the NavigationView already spends the
|
||||
// left third on its pane, so a 640-wide column left the cards as a narrow ribbon.
|
||||
// The category title is rendered HERE, not via NavigationView's Header: that header's
|
||||
// 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)
|
||||
.pane_title("Settings")
|
||||
.header(title)
|
||||
.selected_tag(section)
|
||||
.on_selection_changed({
|
||||
let ss = set_section.clone();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `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() {}
|
||||
+35
-10
@@ -41,16 +41,41 @@ fn all_adapters() -> Vec<IDXGIAdapter> {
|
||||
|
||||
/// Descriptions of the real (hardware, non-WARP) GPUs — the Settings GPU picker's option list.
|
||||
/// The picker only shows when this has more than one entry.
|
||||
///
|
||||
/// **Deduplicated by description**, because the description IS the identity everywhere
|
||||
/// downstream: the pick is persisted as that string (`Settings::adapter`) and matched by
|
||||
/// name in the session binary (`PUNKTFUNK_VK_ADAPTER`). So two entries with the same name
|
||||
/// are one selectable choice however many times DXGI enumerates them — listing it twice
|
||||
/// only offers the user a meaningless coin flip. Seen live on an Intel Arc laptop
|
||||
/// (2026-07-19), whose Vulkan ICD likewise enumerates the one physical iGPU twice.
|
||||
pub fn adapter_names() -> Vec<String> {
|
||||
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
|
||||
all_adapters()
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(adapter_name)
|
||||
.collect()
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for a in all_adapters() {
|
||||
let desc1 = a
|
||||
.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.ok();
|
||||
let name = adapter_name(&a);
|
||||
// Forensics for the next duplicate/oddity report — which adapters DXGI actually
|
||||
// returned, and whether the repeats share a LUID (one adapter enumerated twice)
|
||||
// or are distinct devices that merely present the same description.
|
||||
if let Some(d) = &desc1 {
|
||||
tracing::debug!(
|
||||
name = %name,
|
||||
luid = format!("{:08x}-{:08x}", d.AdapterLuid.HighPart, d.AdapterLuid.LowPart),
|
||||
vendor = format_args!("{:#06x}", d.VendorId),
|
||||
device = format_args!("{:#06x}", d.DeviceId),
|
||||
flags = d.Flags,
|
||||
"DXGI adapter"
|
||||
);
|
||||
}
|
||||
if desc1.is_some_and(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE != 0) {
|
||||
continue; // WARP / software renderer — never a streaming target
|
||||
}
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
@@ -76,6 +76,27 @@ fn main() {
|
||||
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.
|
||||
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
|
||||
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
|
||||
|
||||
@@ -126,21 +126,26 @@ pub(crate) fn spawn_session(
|
||||
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
||||
/// window presents, `error` on a failed start, EOF on quit.
|
||||
pub(crate) fn spawn_browse(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
target: Option<(&str, u16)>,
|
||||
fullscreen: bool,
|
||||
slot: SessionChild,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--browse")
|
||||
.arg(format!("{addr}:{port}"))
|
||||
.arg("--json-status");
|
||||
cmd.arg("--browse");
|
||||
// A target opens straight into that host's library; bare `--browse` opens the console's
|
||||
// OWN host view (discovery, pairing, settings, Wake-on-LAN) — the couch equivalent of
|
||||
// the shell's hosts page.
|
||||
if let Some((addr, port)) = target {
|
||||
cmd.arg(format!("{addr}:{port}"));
|
||||
}
|
||||
cmd.arg("--json-status");
|
||||
if fullscreen {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
add_window_pos(&mut cmd);
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
|
||||
spawn_with(cmd, &label, slot, on_event)
|
||||
}
|
||||
|
||||
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Thin driver over the vendored Intel VPL tree (vendor/libvpl): build the
|
||||
# dispatcher as a static archive, nothing else. Replaces upstream's top-level
|
||||
# CMakeLists.txt (which also wires examples/env/install machinery).
|
||||
cmake_minimum_required(VERSION 3.13.0)
|
||||
if(MSVC)
|
||||
cmake_policy(SET CMP0091 NEW)
|
||||
endif()
|
||||
|
||||
file(STRINGS "vendor/libvpl/version.txt" version_txt)
|
||||
project(vpl VERSION ${version_txt})
|
||||
|
||||
set(BUILD_SHARED_LIBS OFF)
|
||||
set(BUILD_TESTS OFF)
|
||||
set(INSTALL_DEV OFF)
|
||||
set(INSTALL_LIB OFF)
|
||||
set(INSTALL_EXAMPLES OFF)
|
||||
# Defines ONEVPL_EXPERIMENTAL inside the dispatcher — must match the bindgen
|
||||
# flag in build.rs (the D3D11 import API lives behind it).
|
||||
set(BUILD_EXPERIMENTAL ON)
|
||||
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE true)
|
||||
# One predictable place for the archive; MSVC multi-config generators still
|
||||
# append a per-config subdir (Release/) — build.rs searches both.
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR})
|
||||
|
||||
# api/ first: it exports API_VERSION_MAJOR/MINOR + the vpl-api interface
|
||||
# target into this scope, which libvpl/ then consumes (same ordering contract
|
||||
# as upstream's top-level).
|
||||
add_subdirectory(vendor/libvpl/api)
|
||||
add_subdirectory(vendor/libvpl/libvpl)
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "libvpl-sys"
|
||||
description = "Vendored Intel VPL (libvpl) dispatcher built from source as a static archive + bindgen over the C API — substrate for the native QSV encoder backend (design/native-qsv-encoder.md). Upstream pin recorded in vendor/libvpl/PUNKTFUNK-VENDOR.txt; the tree builds with ONEVPL_EXPERIMENTAL, so bumping the pin re-opens the experimental-ABI check (§3.1)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
links = "vpl"
|
||||
|
||||
[build-dependencies]
|
||||
# Same CMake-from-vendored-source model as pyrowave-sys/opus: reproducible
|
||||
# offline builds (CI, MSVC, flatpak) — no network, no system libvpl.
|
||||
cmake = "0.1"
|
||||
# Same bindgen configuration as pyrowave-sys (runtime = dlopen libclang).
|
||||
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Build the vendored Intel VPL dispatcher (`vendor/libvpl`) as a static
|
||||
//! archive via CMake and generate bindings over the C API (`vpl/mfx.h`).
|
||||
//!
|
||||
//! Windows only — the native QSV backend is a Windows-host feature
|
||||
//! (design/native-qsv-encoder.md); Linux Intel is served by VAAPI/Vulkan
|
||||
//! Video. Other targets get an empty bindings file so the workspace builds
|
||||
//! everywhere.
|
||||
//!
|
||||
//! The dispatcher is MIT and statically linked: no new runtime DLL. At run
|
||||
//! time it locates the Intel GPU runtimes (`libmfx64-gen.dll`, legacy
|
||||
//! `libmfxhw64.dll`) in the driver store; a box without an Intel driver just
|
||||
//! fails MFXCreateSession and the encoder open falls through — the same
|
||||
//! degrade contract as the NVENC/AMF runtime loaders.
|
||||
//!
|
||||
//! Everything compiles from the committed vendor tree: no network, no system
|
||||
//! libvpl, no pkg-config. Only cmake + a libclang for bindgen are required on
|
||||
//! the build machine (both already in the build closure via pyrowave-sys).
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=wrapper.h");
|
||||
println!("cargo:rerun-if-changed=CMakeLists.txt");
|
||||
println!("cargo:rerun-if-changed=vendor/libvpl");
|
||||
|
||||
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let bindings_path = out.join("bindings.rs");
|
||||
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os != "windows" {
|
||||
std::fs::write(
|
||||
&bindings_path,
|
||||
"// libvpl-sys: Windows-only, empty on this target\n",
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let api_include = manifest_dir.join("vendor/libvpl/api");
|
||||
|
||||
// Always Release: mirrors pyrowave-sys — a debug dispatcher buys nothing
|
||||
// and the MSVC debug CRT would clash with Rust's release CRT.
|
||||
let dst = cmake::Config::new(&manifest_dir)
|
||||
.profile("Release")
|
||||
.build_target("VPL")
|
||||
.build();
|
||||
let build = dst.join("build");
|
||||
println!("cargo:rustc-link-search=native={}", build.display());
|
||||
// MSVC multi-config generators put the archive in a Release/ subdir.
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}",
|
||||
build.join("Release").display()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=static=vpl");
|
||||
// The dispatcher's Win32 import closure (registry, DXGI adapter probing
|
||||
// COM plumbing). d3d9.dll/dxgi.dll themselves are LoadLibrary'd at
|
||||
// runtime — no static import.
|
||||
for lib in ["advapi32", "ole32", "user32", "uuid", "gdi32"] {
|
||||
println!("cargo:rustc-link-lib=dylib={lib}");
|
||||
}
|
||||
|
||||
// ONEVPL_EXPERIMENTAL must match the CMake side (BUILD_EXPERIMENTAL=ON):
|
||||
// it gates the D3D11 surface-import API in both the headers and the
|
||||
// dispatcher object code.
|
||||
let bindings = bindgen::Builder::default()
|
||||
.header("wrapper.h")
|
||||
.clang_arg(format!("-I{}", api_include.display()))
|
||||
.clang_arg("-DONEVPL_EXPERIMENTAL")
|
||||
// Plain MFX_* constants instead of EnumName_MFX_* — the C API is used
|
||||
// by FourCC/flag value, never by enum type.
|
||||
.prepend_enum_name(false)
|
||||
.derive_default(true)
|
||||
.generate()
|
||||
.expect("bindgen failed for vpl/mfx.h");
|
||||
bindings
|
||||
.write_to_file(&bindings_path)
|
||||
.expect("failed to write libvpl bindings");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Raw FFI bindings to the vendored Intel VPL C API (`vpl/mfx.h`) plus the
|
||||
//! statically linked MIT dispatcher.
|
||||
//!
|
||||
//! Empty on targets other than Windows — see build.rs. The safe wrapper lives
|
||||
//! with its consumer (`pf-encode`'s `enc/windows/qsv.rs`); this crate is
|
||||
//! bindings only.
|
||||
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
|
||||
#![allow(improper_ctypes)]
|
||||
// Generated code — clippy findings in it (missing safety docs on generated unsafe fns, style
|
||||
// nits across 14k lines) are bindgen's shape, not ours; the safe wrapper in pf-encode is the
|
||||
// linted surface.
|
||||
#![allow(clippy::all)]
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Link sanity: the static dispatcher resolves and its loader spins up
|
||||
/// without an Intel driver present (enumeration may find zero
|
||||
/// implementations — that's fine, MFXLoad itself must still succeed).
|
||||
#[test]
|
||||
fn dispatcher_links_and_loads() {
|
||||
unsafe {
|
||||
let loader = MFXLoad();
|
||||
assert!(!loader.is_null(), "MFXLoad returned NULL");
|
||||
MFXUnload(loader);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Intel Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,14 @@
|
||||
Upstream: https://github.com/intel/libvpl
|
||||
Pin: 674d015bcb294bc39fa276e99a652ea045423e82 (v2.17.0 per version.txt, 2026-07)
|
||||
License: MIT (LICENSE; third-party notices in third-party-programs.txt)
|
||||
|
||||
Trimmed vendor tree: only what the static dispatcher build + bindgen need.
|
||||
Dropped from upstream: examples/, doc/, env/, script/, tools, api/strings/,
|
||||
api/tests/, libvpl/test/, top-level CMakeLists.txt (replaced by the crate-root
|
||||
CMakeLists.txt one level up, which pins BUILD_SHARED_LIBS=OFF, all INSTALL_*
|
||||
off, BUILD_EXPERIMENTAL=ON).
|
||||
|
||||
BUILD_EXPERIMENTAL=ON defines ONEVPL_EXPERIMENTAL: the D3D11 surface-import
|
||||
API (mfxMemoryInterface / mfxSurfaceD3D11Tex2D) is experimental ABI that is
|
||||
release-coupled — bumping this pin requires re-checking every ONEVPL_EXPERIMENTAL
|
||||
symbol qsv.rs touches (design/native-qsv-encoder.md §3.1/§3.4).
|
||||
@@ -0,0 +1,41 @@
|
||||
# ##############################################################################
|
||||
# Copyright (C) Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
# ##############################################################################
|
||||
|
||||
cmake_minimum_required(VERSION 3.13.0)
|
||||
|
||||
set(VPL_API_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
if(DEFINED ENV{ONEVPL_API_HEADER_DIRECTORY})
|
||||
set(VPL_API_INCLUDE_DIR $ENV{ONEVPL_API_HEADER_DIRECTORY})
|
||||
message(STATUS "Using custom API header directory: ${VPL_API_INCLUDE_DIR}")
|
||||
endif()
|
||||
|
||||
# get API version
|
||||
file(READ ${VPL_API_INCLUDE_DIR}/vpl/mfxdefs.h mfxdefs)
|
||||
|
||||
string(REGEX MATCH "MFX_VERSION_MAJOR ([0-9]*)" _ ${mfxdefs})
|
||||
set(API_VERSION_MAJOR ${CMAKE_MATCH_1})
|
||||
set(API_VERSION_MAJOR
|
||||
${API_VERSION_MAJOR}
|
||||
PARENT_SCOPE)
|
||||
|
||||
string(REGEX MATCH "MFX_VERSION_MINOR ([0-9]*)" _ ${mfxdefs})
|
||||
set(API_VERSION_MINOR ${CMAKE_MATCH_1})
|
||||
set(API_VERSION_MINOR
|
||||
${API_VERSION_MINOR}
|
||||
PARENT_SCOPE)
|
||||
|
||||
message(STATUS "API version: ${API_VERSION_MAJOR}.${API_VERSION_MINOR}")
|
||||
add_library(vpl-api INTERFACE)
|
||||
|
||||
target_include_directories(vpl-api INTERFACE ${VPL_API_INCLUDE_DIR})
|
||||
|
||||
if(INSTALL_DEV)
|
||||
install(
|
||||
DIRECTORY ${VPL_API_INCLUDE_DIR}/vpl
|
||||
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
|
||||
COMPONENT ${VPL_COMPONENT_DEV})
|
||||
endif()
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifndef __MFX_H__
|
||||
#define __MFX_H__
|
||||
|
||||
#include "mfxdefs.h"
|
||||
#include "mfxcommon.h"
|
||||
#include "mfxstructures.h"
|
||||
#include "mfxdispatcher.h"
|
||||
#include "mfximplcaps.h"
|
||||
#include "mfxsession.h"
|
||||
#include "mfxvideo.h"
|
||||
#include "mfxadapter.h"
|
||||
|
||||
#include "mfxbrc.h"
|
||||
#include "mfxmvc.h"
|
||||
#include "mfxpcp.h"
|
||||
#include "mfxvp8.h"
|
||||
#include "mfxjpeg.h"
|
||||
|
||||
#include "mfxsurfacepool.h"
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
#include "mfxencodestats.h"
|
||||
#endif
|
||||
|
||||
#endif /* __MFXDEFS_H__ */
|
||||
@@ -0,0 +1,85 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#include "mfxdefs.h"
|
||||
#ifndef __MFXADAPTER_H__
|
||||
#define __MFXADAPTER_H__
|
||||
|
||||
#include "mfxstructures.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif
|
||||
|
||||
/*!
|
||||
@brief
|
||||
Returns a list of adapters that are suitable to handle workload @p input_info. The list is sorted in priority order, with iGPU given the highest precedence.
|
||||
This rule may change in the future. If the @p input_info pointer is NULL, the list of all available adapters will be returned.
|
||||
|
||||
@param[in] input_info Pointer to workload description. See mfxComponentInfo description for details.
|
||||
@param[out] adapters Pointer to output description of all suitable adapters for input workload. See mfxAdaptersInfo description for details.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. \n
|
||||
MFX_ERR_NULL_PTR
|
||||
@p input_info or adapters pointer is NULL. \n
|
||||
MFX_ERR_NOT_FOUND No suitable adapters found. \n
|
||||
MFX_WRN_OUT_OF_RANGE Not enough memory to report back entire list of adapters. In this case as many adapters as possible will be returned.
|
||||
|
||||
@since This function is available since API version 1.31.
|
||||
|
||||
@deprecated Deprecated in API version 2.9. Use MFXEnumImplementations and MFXSetConfigFilterProperty to query adapter capabilities and
|
||||
to select a suitable adapter for the input workload.
|
||||
Use MFX_DEPRECATED_OFF macro to turn off the deprecation message visualization.
|
||||
*/
|
||||
MFX_DEPRECATED mfxStatus MFX_CDECL MFXQueryAdapters(mfxComponentInfo* input_info, mfxAdaptersInfo* adapters);
|
||||
|
||||
/*!
|
||||
@brief
|
||||
Returns list of adapters that are suitable to decode the input bitstream. The list is sorted in priority order, with iGPU given the highest precedence. This rule may change in the future. This function is a simplification of MFXQueryAdapters, because bitstream is a description of the workload itself.
|
||||
|
||||
@param[in] bitstream Pointer to bitstream with input data.
|
||||
@param[in] codec_id Codec ID to determine the type of codec for the input bitstream.
|
||||
@param[out] adapters Pointer to the output list of adapters. Memory should be allocated by user. See mfxAdaptersInfo description for details.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. \n
|
||||
MFX_ERR_NULL_PTR bitstream or @p adapters pointer is NULL. \n
|
||||
MFX_ERR_NOT_FOUND No suitable adapters found. \n
|
||||
MFX_WRN_OUT_OF_RANGE Not enough memory to report back entire list of adapters. In this case as many adapters as possible will be returned.
|
||||
|
||||
@since This function is available since API version 1.31.
|
||||
|
||||
@deprecated Deprecated in API version 2.9. Use MFXEnumImplementations and MFXSetConfigFilterProperty to query adapter capabilities and
|
||||
to select a suitable adapter for the input workload.
|
||||
Use MFX_DEPRECATED_OFF macro to turn off the deprecation message visualization.
|
||||
*/
|
||||
MFX_DEPRECATED mfxStatus MFX_CDECL MFXQueryAdaptersDecode(mfxBitstream* bitstream, mfxU32 codec_id, mfxAdaptersInfo* adapters);
|
||||
|
||||
/*!
|
||||
@brief
|
||||
Returns the number of detected graphics adapters. It can be used before calling MFXQueryAdapters to determine the size of input data that the user will need to allocate.
|
||||
|
||||
@param[out] num_adapters Pointer for the output number of detected graphics adapters.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. \n
|
||||
MFX_ERR_NULL_PTR num_adapters pointer is NULL.
|
||||
|
||||
@since This function is available since API version 1.31.
|
||||
|
||||
@deprecated Deprecated in API version 2.9. Use MFXEnumImplementations and MFXSetConfigFilterProperty to query adapter capabilities and
|
||||
to select a suitable adapter for the input workload.
|
||||
Use MFX_DEPRECATED_OFF macro to turn off the deprecation message visualization.
|
||||
*/
|
||||
MFX_DEPRECATED mfxStatus MFX_CDECL MFXQueryAdaptersNumber(mfxU32* num_adapters);
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // __MFXADAPTER_H__
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifndef __MFXBRC_H__
|
||||
#define __MFXBRC_H__
|
||||
|
||||
#include "mfxstructures.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif /* __cplusplus */
|
||||
|
||||
/*! See the mfxExtBRC structure for details. */
|
||||
enum {
|
||||
MFX_EXTBUFF_BRC = MFX_MAKEFOURCC('E','B','R','C')
|
||||
};
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
Describes frame parameters required for external BRC functions.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxU32 reserved[23];
|
||||
mfxU16 SceneChange; /*!< Frame belongs to a new scene if non zero. */
|
||||
mfxU16 LongTerm; /*!< Frame is a Long Term Reference frame if non zero. */
|
||||
mfxU32 FrameCmplx; /*!< Frame Complexity Frame spatial complexity if non zero. Zero if complexity is not available. */
|
||||
mfxU32 EncodedOrder; /*!< The frame number in a sequence of reordered frames starting from encoder Init. */
|
||||
mfxU32 DisplayOrder; /*!< The frame number in a sequence of frames in display order starting from last IDR. */
|
||||
mfxU32 CodedFrameSize; /*!< Size of the frame in bytes after encoding. */
|
||||
mfxU16 FrameType; /*!< Frame type. See FrameType enumerator for possible values. */
|
||||
mfxU16 PyramidLayer; /*!< B-pyramid or P-pyramid layer that the frame belongs to. */
|
||||
mfxU16 NumRecode; /*!< Number of recodings performed for this frame. */
|
||||
mfxU16 NumExtParam; /*!< Reserved for future use. */
|
||||
mfxExtBuffer** ExtParam;/*!< Reserved for future use. */
|
||||
} mfxBRCFrameParam;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
Specifies controls for next frame encoding provided by external BRC functions.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxI32 QpY; /*!< Frame-level Luma QP. */
|
||||
mfxU32 InitialCpbRemovalDelay; /*!< See initial_cpb_removal_delay in codec standard. Ignored if no HRD control:
|
||||
mfxExtCodingOption::VuiNalHrdParameters = MFX_CODINGOPTION_OFF. Calculated by encoder if
|
||||
initial_cpb_removal_delay==0 && initial_cpb_removal_offset == 0 && HRD control is switched on. */
|
||||
mfxU32 InitialCpbRemovalOffset; /*!< See initial_cpb_removal_offset in codec standard. Ignored if no HRD control:
|
||||
mfxExtCodingOption::VuiNalHrdParameters = MFX_CODINGOPTION_OFF. Calculated by encoder if
|
||||
initial_cpb_removal_delay==0 && initial_cpb_removal_offset == 0 && HRD control is switched on. */
|
||||
mfxU32 reserved1[7];
|
||||
mfxU32 MaxFrameSize; /*!< Max frame size in bytes. Option for repack feature. Driver calls PAK until current frame size is
|
||||
less than or equal to MaxFrameSize, or number of repacking for this frame is equal to MaxNumRePak. Repack is available
|
||||
if there is driver support, MaxFrameSize !=0, and MaxNumRePak != 0. Ignored if MaxNumRePak == 0. */
|
||||
mfxU8 DeltaQP[8]; /*!< Option for repack feature. Ignored if MaxNumRePak == 0 or MaxNumRePak==0. If current
|
||||
frame size > MaxFrameSize and/or number of repacking (nRepack) for this frame <= MaxNumRePak,
|
||||
PAK is called with QP = mfxBRCFrameCtrl::QpY + Sum(DeltaQP[i]), where i = [0,nRepack].
|
||||
Non zero DeltaQP[nRepack] are ignored if nRepack > MaxNumRePak.
|
||||
If repacking feature is on ( MaxFrameSize & MaxNumRePak are not zero), it is calculated by the encoder. */
|
||||
mfxU16 MaxNumRepak; /*!< Number of possible repacks in driver if current frame size > MaxFrameSize. Ignored if MaxFrameSize==0.
|
||||
See MaxFrameSize description. Possible values are in the range of 0 to 8. */
|
||||
mfxU16 NumExtParam; /*!< Reserved for future use. */
|
||||
mfxExtBuffer** ExtParam; /*!< Reserved for future use. */
|
||||
} mfxBRCFrameCtrl;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! The BRCStatus enumerator itemizes instructions to the encoder by mfxExtBrc::Update. */
|
||||
enum {
|
||||
MFX_BRC_OK = 0, /*!< CodedFrameSize is acceptable, no further recoding/padding/skip required, proceed to next frame. */
|
||||
MFX_BRC_BIG_FRAME = 1, /*!< Coded frame is too big, recoding required. */
|
||||
MFX_BRC_SMALL_FRAME = 2, /*!< Coded frame is too small, recoding required. */
|
||||
MFX_BRC_PANIC_BIG_FRAME = 3, /*!< Coded frame is too big, no further recoding possible - skip frame. */
|
||||
MFX_BRC_PANIC_SMALL_FRAME = 4 /*!< Coded frame is too small, no further recoding possible - required padding to mfxBRCFrameStatus::MinFrameSize. */
|
||||
};
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
Specifies instructions for the encoder provided by external BRC after each frame encoding. See the BRCStatus enumerator for details.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxU32 MinFrameSize; /*!< Size in bytes, coded frame must be padded to when Status = MFX_BRC_PANIC_SMALL_FRAME. */
|
||||
mfxU16 BRCStatus; /*!< BRC status. See the BRCStatus enumerator for possible values. */
|
||||
mfxU16 reserved[25];
|
||||
mfxHDL reserved1;
|
||||
} mfxBRCFrameStatus;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
Contains a set of callbacks to perform external bitrate control. Can be attached to the mfxVideoParam structure during
|
||||
encoder initialization. Set the mfxExtCodingOption2::ExtBRC option to ON to make the encoder use the external BRC instead of the native one.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUFF_BRC. */
|
||||
|
||||
mfxU32 reserved[14];
|
||||
mfxHDL pthis; /*!< Pointer to the BRC object. */
|
||||
|
||||
/*!
|
||||
@brief Initializes the BRC session according to parameters from input mfxVideoParam and attached structures. It does not modify the input mfxVideoParam and attached structures. Invoked during MFXVideoENCODE_Init.
|
||||
|
||||
@param[in] pthis Pointer to the BRC object.
|
||||
@param[in] par Pointer to the mfxVideoParam structure that was used for the encoder initialization.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. \n
|
||||
MFX_ERR_UNSUPPORTED The function detected unsupported video parameters.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *Init) (mfxHDL pthis, mfxVideoParam* par);
|
||||
|
||||
/*!
|
||||
@brief Resets BRC session according to new parameters. It does not modify the input mfxVideoParam and attached structures. Invoked during MFXVideoENCODE_Reset.
|
||||
|
||||
@param[in] pthis Pointer to the BRC object.
|
||||
@param[in] par Pointer to the mfxVideoParam structure that was used for the encoder initialization.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. \n
|
||||
MFX_ERR_UNSUPPORTED The function detected unsupported video parameters. \n
|
||||
MFX_ERR_INCOMPATIBLE_VIDEO_PARAM The function detected that the video parameters provided by the application are incompatible with
|
||||
initialization parameters. Reset requires additional memory allocation and cannot be executed.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *Reset) (mfxHDL pthis, mfxVideoParam* par);
|
||||
|
||||
/*!
|
||||
@brief Deallocates any internal resources acquired in Init for this BRC session. Invoked during MFXVideoENCODE_Close.
|
||||
|
||||
@param[in] pthis Pointer to the BRC object.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *Close) (mfxHDL pthis);
|
||||
|
||||
/*! @brief Returns controls (@p ctrl) to encode next frame based on info from input mfxBRCFrameParam structure (@p par) and
|
||||
internal BRC state. Invoked asynchronously before each frame encoding or recoding.
|
||||
|
||||
@param[in] pthis Pointer to the BRC object.
|
||||
@param[in] par Pointer to the mfxVideoParam structure that was used for the encoder initialization.
|
||||
@param[out] ctrl Pointer to the output mfxBRCFrameCtrl structure.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL* GetFrameCtrl) (mfxHDL pthis, mfxBRCFrameParam* par, mfxBRCFrameCtrl* ctrl);
|
||||
|
||||
/*!
|
||||
@brief Updates internal BRC state and returns status to instruct encoder whether it should recode the previous frame,
|
||||
skip the previous frame, do padding, or proceed to next frame based on info from input mfxBRCFrameParam and mfxBRCFrameCtrl structures.
|
||||
Invoked asynchronously after each frame encoding or recoding.
|
||||
|
||||
@param[in] pthis Pointer to the BRC object.
|
||||
@param[in] par Pointer to the mfxVideoParam structure that was used for the encoder initialization.
|
||||
@param[in] ctrl Pointer to the output mfxBRCFrameCtrl structure.
|
||||
@param[in] status Pointer to the output mfxBRCFrameStatus structure.
|
||||
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL* Update) (mfxHDL pthis, mfxBRCFrameParam* par, mfxBRCFrameCtrl* ctrl, mfxBRCFrameStatus* status);
|
||||
|
||||
mfxHDL reserved1[10];
|
||||
} mfxExtBRC;
|
||||
MFX_PACK_END()
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif /* __cplusplus */
|
||||
|
||||
#endif
|
||||
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
#ifndef __MFXCAMERA_H__
|
||||
#define __MFXCAMERA_H__
|
||||
|
||||
#include "mfxcommon.h"
|
||||
|
||||
|
||||
#if !defined(__GNUC__)
|
||||
#pragma warning(disable : 4201)
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif /* __cplusplus */
|
||||
|
||||
/*!
|
||||
The ExtendedBufferID enumerator itemizes and defines identifiers (BufferId) for extended buffers in camera processing.
|
||||
The application should attach these extended buffers to the mfxVideoParam structure to configure camera processing through VideoVPP functions.
|
||||
And Implementation capabilities of camera processing features can be delivered by the function MFXQueryImplsDescription via VPP configuration
|
||||
mfxVPPDescription.
|
||||
*/
|
||||
enum {
|
||||
/*!
|
||||
This extended buffer is mandatory for camera raw accelerator initialization. See the mfxExtCamPipeControl structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_PIPECONTROL = MFX_MAKEFOURCC('C', 'P', 'P', 'C'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera White Balance filter algorithm. See mfxExtCamWhiteBalance structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_WHITE_BALANCE = MFX_MAKEFOURCC('C', 'W', 'B', 'L'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Hot Pixel Removal filter algorithm. See mfxExtCamHotPixelRemoval structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_HOT_PIXEL_REMOVAL = MFX_MAKEFOURCC('C', 'H', 'P', 'R'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Black Level Correction filter algorithm. See mfxExtCamBlackLevelCorrection structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_BLACK_LEVEL_CORRECTION = MFX_MAKEFOURCC('C', 'B', 'L', 'C'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Vignette Correction filter algorithm. See mfxCamVignetteCorrectionParam structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_VIGNETTE_CORRECTION = MFX_MAKEFOURCC('C', 'V', 'G', 'T'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Bayer Denoise filter algorithm. See mfxExtCamBayerDenoise structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_BAYER_DENOISE = MFX_MAKEFOURCC('C', 'D', 'N', 'S'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Color Correction filter algorithm. See mfxExtCamColorCorrection3x3 structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_COLOR_CORRECTION_3X3 = MFX_MAKEFOURCC('C', 'C', '3', '3'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Padding. See mfxExtCamPadding structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_PADDING = MFX_MAKEFOURCC('C', 'P', 'A', 'D'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Forward Gamma Correction filter algorithm. See mfxExtCamFwdGamma structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_FORWARD_GAMMA_CORRECTION =
|
||||
MFX_MAKEFOURCC('C', 'F', 'G', 'C'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Lens Geometry Distortion and Chroma Aberration Correction filter algorithm. See mfxExtCamLensGeomDistCorrection structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_LENS_GEOM_DIST_CORRECTION =
|
||||
MFX_MAKEFOURCC('C', 'L', 'G', 'D'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera 3DLUT filter algorithm. See mfxExtCam3DLut structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_3DLUT = MFX_MAKEFOURCC('C', 'L', 'U', 'T'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera Total Color Control algorithm. See mfxExtCamTotalColorControl structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_TOTAL_COLOR_CONTROL = MFX_MAKEFOURCC('C', 'T', 'C', 'C'),
|
||||
/*!
|
||||
This extended buffer defines control parameters for the Camera YUV to RGB conversion algorithm. See mfxExtCamCscYuvRgb structure for details.
|
||||
The application should attach this extended buffer to the mfxVideoParam structure to configure camera processing initialization.
|
||||
*/
|
||||
MFX_EXTBUF_CAM_CSC_YUV_RGB = MFX_MAKEFOURCC('C', 'C', 'Y', 'R')
|
||||
};
|
||||
|
||||
/*!
|
||||
A enumeration that defines white balance mode.
|
||||
*/
|
||||
typedef enum {
|
||||
MFX_CAM_WHITE_BALANCE_MANUAL = 0x0001, /*!< White balance manual mode.*/
|
||||
MFX_CAM_WHITE_BALANCE_AUTO = 0x0002 /*!< White balance auto mode.*/
|
||||
} mfxCamWhiteBalanceMode;
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera White Balance filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_WHITE_BALANCE. */
|
||||
mfxU32 Mode; /*!< Specifies one of White Balance operation modes defined in enumeration mfxCamWhiteBalanceMode. */
|
||||
mfxF64 R; /*!< White Balance Red correction.*/
|
||||
mfxF64 G0; /*!< White Balance Green Top correction.*/
|
||||
mfxF64 B; /*!< White Balance Blue correction.*/
|
||||
mfxF64 G1; /*!< White Balance Green Bottom correction. */
|
||||
mfxU32 reserved[8]; /*!< Reserved for future extension. */
|
||||
} mfxExtCamWhiteBalance;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera Total Color Control filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_TOTAL_COLOR_CONTROL. */
|
||||
mfxU16 R; /*!< Red element.*/
|
||||
mfxU16 G; /*!< Green element.*/
|
||||
mfxU16 B; /*!< Blue element.*/
|
||||
mfxU16 C; /*!< Cyan element.*/
|
||||
mfxU16 M; /*!< Magenta element.*/
|
||||
mfxU16 Y; /*!< Yellow element.*/
|
||||
mfxU16 reserved[6]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamTotalColorControl;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera YUV to RGB format conversion.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_CSC_YUV_RGB. */
|
||||
mfxF32 PreOffset[3]; /*!< Specifies offset for conversion from full range RGB input to limited range YUV for input color coordinate.*/
|
||||
mfxF32 Matrix[3][3]; /*!< Specifies conversion matrix with CSC coefficients.*/
|
||||
mfxF32 PostOffset[3]; /*!< Specifies offset for conversion from full range RGB input to limited range YUV for output color coordinate.*/
|
||||
mfxU16 reserved[30]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamCscYuvRgb;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera Hot Pixel Removal filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_HOT_PIXEL_REMOVAL. */
|
||||
mfxU16 PixelThresholdDifference; /*!< Threshold for Hot Pixel difference. */
|
||||
mfxU16 PixelCountThreshold; /*!< Count pixel detection.*/
|
||||
mfxU16 reserved[32]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamHotPixelRemoval;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
typedef struct {
|
||||
/*!
|
||||
A hint structure that configures Camera black level correction.
|
||||
*/
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_BLACK_LEVEL_CORRECTION. */
|
||||
mfxU16 R; /*!< Black Level Red correction.*/
|
||||
mfxU16 G0; /*!< Black Level Green Top correction.*/
|
||||
mfxU16 B; /*!< Black Level Blue correction.*/
|
||||
mfxU16 G1; /*!< Black Level Green Bottom correction.*/
|
||||
mfxU32 reserved[4]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamBlackLevelCorrection;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A structure that defines Camera Vignette Correction Element.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxU8 integer; /*!< Integer part of correction element.*/
|
||||
mfxU8 mantissa; /*!< Fractional part of correction element.*/
|
||||
mfxU8 reserved[6]; /*!< Reserved for future extension.*/
|
||||
} mfxCamVignetteCorrectionElement;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A structure that defines Camera Vignette Correction Parameters.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxCamVignetteCorrectionElement R; /*!< Red correction element.*/
|
||||
mfxCamVignetteCorrectionElement G0; /*!< Green top correction element.*/
|
||||
mfxCamVignetteCorrectionElement B; /*!< Blue Correction element.*/
|
||||
mfxCamVignetteCorrectionElement G1; /*!< Green bottom correction element.*/
|
||||
mfxU32 reserved[4]; /*!< Reserved for future extension.*/
|
||||
} mfxCamVignetteCorrectionParam;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
A hint structure that configures Camera Vignette Correction filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_VIGNETTE_CORRECTION. */
|
||||
mfxU32 Width; /*!< Width of Correction Map 2D buffer in mfxCamVignetteCorrectionParam elements. */
|
||||
mfxU32 Height; /*!< Height of Correction Map 2D buffer in mfxCamVignetteCorrectionParam elements. */
|
||||
mfxU32 Pitch; /*!< Pitch of Correction Map 2D buffer in mfxCamVignetteCorrectionParam elements. */
|
||||
mfxU32 reserved[7]; /*!< Reserved for future extension.*/
|
||||
|
||||
union {
|
||||
mfxCamVignetteCorrectionParam* CorrectionMap; /*!< 2D buffer of mfxCamVignetteCorrectionParam elements.*/
|
||||
mfxU64 reserved1; /*!< Reserved for alignment on 32bit and 64bit.*/
|
||||
};
|
||||
} mfxExtCamVignetteCorrection;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera Bayer denoise filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_BAYER_DENOISE. */
|
||||
mfxU16 Threshold; /*!< Level of denoise, legal values: [0:63].*/
|
||||
mfxU16 reserved[27]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamBayerDenoise;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_L_TYPE()
|
||||
/*!
|
||||
A hint structure that configures Camera Color correction filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_COLOR_CORRECTION_3X3. */
|
||||
mfxF32 CCM[3][3]; /*!< 3x3 dimension matrix providing RGB Color Correction coefficients.*/
|
||||
mfxU32 reserved[32]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamColorCorrection3x3;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera Padding.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_PADDING. */
|
||||
mfxU16 Top; /*!< Specify number of padded columns respectively. Currently only 8 pixels supported for all dimensions. */
|
||||
mfxU16 Bottom; /*!< Specify number of padded columns respectively. Currently only 8 pixels supported for all dimensions. */
|
||||
mfxU16 Left; /*!< Specify number of padded rows respectively. Currently only 8 pixels supported for all dimensions. */
|
||||
mfxU16 Right; /*!< Specify number of padded rows respectively. Currently only 8 pixels supported for all dimensions. */
|
||||
mfxU32 reserved[4]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamPadding;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*!
|
||||
A enumeration that defines Bayer mode.
|
||||
*/
|
||||
typedef enum {
|
||||
/*!
|
||||
Pixel Representation BG
|
||||
GR.
|
||||
*/
|
||||
MFX_CAM_BAYER_BGGR = 0x0000,
|
||||
/*!
|
||||
Pixel Representation RG
|
||||
GB.
|
||||
*/
|
||||
MFX_CAM_BAYER_RGGB = 0x0001,
|
||||
/*!
|
||||
Pixel Representation GB
|
||||
RG.
|
||||
*/
|
||||
MFX_CAM_BAYER_GBRG = 0x0002,
|
||||
/*!
|
||||
Pixel Representation GR
|
||||
BG.
|
||||
*/
|
||||
MFX_CAM_BAYER_GRBG = 0x0003
|
||||
} mfxCamBayerFormat;
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures camera pipe control.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_PIPECONTROL. */
|
||||
mfxU16 RawFormat; /*!< Specifies one of the four Bayer patterns defined in mfxCamBayerFormat enumeration. */
|
||||
mfxU16 reserved1; /*!< Reserved for future extension.*/
|
||||
mfxU32 reserved[5]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamPipeControl;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A structure that specifies forward gamma segment.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxU16 Pixel; /*!< Pixel value.*/
|
||||
mfxU16 Red; /*!< Corrected Red value.*/
|
||||
mfxU16 Green; /*!< Corrected Green value.*/
|
||||
mfxU16 Blue; /*!< Corrected Blue value.*/
|
||||
} mfxCamFwdGammaSegment;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_L_TYPE()
|
||||
/*!
|
||||
A hint structure that configures Camera Forward Gamma Correction filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_FORWARD_GAMMA_CORRECTION. */
|
||||
|
||||
mfxU16 reserved[19]; /*!< Reserved for future extension.*/
|
||||
mfxU16 NumSegments; /*!< Number of Gamma segments.*/
|
||||
union {
|
||||
mfxCamFwdGammaSegment* Segment; /*!< Pointer to Gamma segments array.*/
|
||||
mfxU64 reserved1; /*!< Reserved for future extension.*/
|
||||
};
|
||||
} mfxExtCamFwdGamma;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A hint structure that configures Camera Lens Geometry Distortion and Chroma Aberration Correction filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer
|
||||
Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_LENS_GEOM_DIST_CORRECTION. */
|
||||
|
||||
mfxF32 a[3]; /*!< Polynomial coefficients a for R/G/B*/
|
||||
mfxF32 b[3]; /*!< Polynomial coefficients b for R/G/B*/
|
||||
mfxF32 c[3]; /*!< Polynomial coefficients c for R/G/B*/
|
||||
mfxF32 d[3]; /*!< Polynomial coefficients d for R/G/B*/
|
||||
mfxU16 reserved[36]; /*!< Reserved for future extension.*/
|
||||
} mfxExtCamLensGeomDistCorrection;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*!
|
||||
A enumeration that defines 3DLUT size.
|
||||
*/
|
||||
enum {
|
||||
MFX_CAM_3DLUT17_SIZE = (17 * 17 * 17), /*!< 17^3 LUT size*/
|
||||
MFX_CAM_3DLUT33_SIZE = (33 * 33 * 33), /*!< 33^3 LUT size*/
|
||||
MFX_CAM_3DLUT65_SIZE = (65 * 65 * 65) /*!< 65^3 LUT size*/
|
||||
};
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
A structure that defines 3DLUT entry.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxU16 R; /*!< R channel*/
|
||||
mfxU16 G; /*!< G channel*/
|
||||
mfxU16 B; /*!< B channel*/
|
||||
mfxU16 Reserved; /*!< Reserved for future extension.*/
|
||||
} mfxCam3DLutEntry;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_L_TYPE()
|
||||
/*!
|
||||
A hint structure that configures Camera 3DLUT filter.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUF_CAM_3DLUT. */
|
||||
|
||||
mfxU16 reserved[10]; /*!< Reserved for future extension.*/
|
||||
mfxU32 Size; /*!< LUT size, defined in MFX_CAM_3DLUT17/33/65_SIZE enumeration.*/
|
||||
union {
|
||||
mfxCam3DLutEntry* Table; /*!< Pointer to mfxCam3DLutEntry, size of each dimension depends on LUT size, e.g. LUT[17][17][17] for 17x17x17 look up table.*/
|
||||
mfxU64 reserved1; /*!< Reserved for future extension.*/
|
||||
};
|
||||
} mfxExtCam3DLut;
|
||||
MFX_PACK_END()
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // __MFXCAMERA_H__
|
||||
+740
@@ -0,0 +1,740 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifndef __MFXCOMMON_H__
|
||||
#define __MFXCOMMON_H__
|
||||
#include "mfxdefs.h"
|
||||
|
||||
#if !defined (__GNUC__)
|
||||
#pragma warning(disable: 4201)
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif /* __cplusplus */
|
||||
|
||||
#define MFX_MAKEFOURCC(A,B,C,D) ((((int)A))+(((int)B)<<8)+(((int)C)<<16)+(((int)D)<<24))
|
||||
|
||||
/* Extended Configuration Header Structure */
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! The common header definition for external buffers and video
|
||||
processing hints. */
|
||||
typedef struct {
|
||||
mfxU32 BufferId; /*!< Identifier of the buffer content. See the ExtendedBufferID enumerator for a complete list of extended buffers. */
|
||||
mfxU32 BufferSz; /*!< Size of the buffer. */
|
||||
} mfxExtBuffer;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
#define MFX_REFINTERFACE_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The structure represents reference counted interface structure.
|
||||
The memory is allocated and released by the implementation.
|
||||
*/
|
||||
typedef struct mfxRefInterface {
|
||||
mfxHDL Context; /*!< The context of the container interface. User should not touch (change, set, null) this pointer. */
|
||||
mfxStructVersion Version; /*!< The version of the structure. */
|
||||
/*! @brief
|
||||
Increments the internal reference counter of the container. The container is not destroyed until the container
|
||||
is released using the mfxRefInterface::Release function.
|
||||
mfxRefInterface::AddRef should be used each time a new link to the container is created
|
||||
(for example, copy structure) for proper management.
|
||||
|
||||
@param[in] ref_interface Valid interface.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE If no error. \n
|
||||
MFX_ERR_NULL_PTR If interface is NULL. \n
|
||||
MFX_ERR_INVALID_HANDLE If mfxRefInterface->Context is invalid (for example NULL). \n
|
||||
MFX_ERR_UNKNOWN Any internal error.
|
||||
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *AddRef)(struct mfxRefInterface* ref_interface);
|
||||
/*! @brief
|
||||
Decrements the internal reference counter of the container. mfxRefInterface::Release should be called after using the
|
||||
mfxRefInterface::AddRef function to add a container or when allocation logic requires it.
|
||||
|
||||
@param[in] ref_interface Valid interface.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE If no error. \n
|
||||
MFX_ERR_NULL_PTR If interface is NULL. \n
|
||||
MFX_ERR_INVALID_HANDLE If mfxRefInterface->Context is invalid (for example NULL). \n
|
||||
MFX_ERR_UNDEFINED_BEHAVIOR If Reference Counter of container is zero before call. \n
|
||||
MFX_ERR_UNKNOWN Any internal error.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *Release)(struct mfxRefInterface* ref_interface);
|
||||
/*! @brief
|
||||
Returns current reference counter of mfxRefInterface structure.
|
||||
|
||||
@param[in] ref_interface Valid interface.
|
||||
@param[out] counter Sets counter to the current reference counter value.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE If no error. \n
|
||||
MFX_ERR_NULL_PTR If interface or counter is NULL. \n
|
||||
MFX_ERR_INVALID_HANDLE If mfxRefInterface->Context is invalid (for example NULL). \n
|
||||
MFX_ERR_UNKNOWN Any internal error.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *GetRefCounter)(struct mfxRefInterface* ref_interface, mfxU32* counter);
|
||||
mfxHDL reserved[4];
|
||||
|
||||
}mfxRefInterface;
|
||||
MFX_PACK_END()
|
||||
|
||||
/* Library initialization and deinitialization */
|
||||
/*!
|
||||
This enumerator itemizes implementation types.
|
||||
The implementation type is a bit OR'ed value of the base type and any decorative flags.
|
||||
@note This enumerator is for legacy dispatcher compatibility only. The new dispatcher does not use it.
|
||||
*/
|
||||
typedef mfxI32 mfxIMPL;
|
||||
/*!
|
||||
The application can use the macro MFX_IMPL_BASETYPE(x) to obtain the base implementation type.
|
||||
*/
|
||||
#define MFX_IMPL_BASETYPE(x) (0x00ff & (x))
|
||||
|
||||
enum {
|
||||
MFX_IMPL_AUTO = 0x0000, /*!< Auto Selection/In or Not Supported/Out. */
|
||||
MFX_IMPL_SOFTWARE = 0x0001, /*!< Pure software implementation. */
|
||||
MFX_IMPL_HARDWARE = 0x0002, /*!< Hardware accelerated implementation (default device). */
|
||||
MFX_IMPL_AUTO_ANY = 0x0003, /*!< Auto selection of any hardware/software implementation. */
|
||||
MFX_IMPL_HARDWARE_ANY = 0x0004, /*!< Auto selection of any hardware implementation. */
|
||||
MFX_IMPL_HARDWARE2 = 0x0005, /*!< Hardware accelerated implementation (2nd device). */
|
||||
MFX_IMPL_HARDWARE3 = 0x0006, /*!< Hardware accelerated implementation (3rd device). */
|
||||
MFX_IMPL_HARDWARE4 = 0x0007, /*!< Hardware accelerated implementation (4th device). */
|
||||
MFX_IMPL_RUNTIME = 0x0008, /*!< This value cannot be used for session initialization. It may be returned by the MFXQueryIMPL
|
||||
function to show that the session has been initialized in run-time mode. */
|
||||
MFX_IMPL_VIA_ANY = 0x0100, /*!< Hardware acceleration can go through any supported OS infrastructure. This is the default value. The default value
|
||||
is used by the legacy Intel(r) Media SDK if none of the MFX_IMPL_VIA_xxx flags are specified by the application. */
|
||||
MFX_IMPL_VIA_D3D9 = 0x0200, /*!< Hardware acceleration goes through the Microsoft* Direct3D* 9 infrastructure. */
|
||||
MFX_IMPL_VIA_D3D11 = 0x0300, /*!< Hardware acceleration goes through the Microsoft* Direct3D* 11 infrastructure. */
|
||||
MFX_IMPL_VIA_VAAPI = 0x0400, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure. */
|
||||
MFX_IMPL_VIA_HDDLUNITE = 0x0500, /*!< Hardware acceleration goes through the HDDL* Unite*. */
|
||||
|
||||
MFX_IMPL_UNSUPPORTED = 0x0000 /*!< One of the MFXQueryIMPL returns. */
|
||||
};
|
||||
|
||||
/* Version Info */
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! The mfxVersion union describes the version of the implementation.*/
|
||||
typedef union {
|
||||
/*! @brief Structure with Major and Minor fields. */
|
||||
/*! @struct Anonymous */
|
||||
struct {
|
||||
/*! @{
|
||||
@name Major and Minor fields
|
||||
Anonymous structure with Major and Minor fields.
|
||||
*/
|
||||
mfxU16 Minor; /*!< Minor number of the implementation. */
|
||||
mfxU16 Major; /*!< Major number of the implementation. */
|
||||
/*! @} */
|
||||
};
|
||||
mfxU32 Version; /*!< Implementation version number. */
|
||||
} mfxVersion;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! The mfxPriority enumerator describes the session priority. */
|
||||
typedef enum
|
||||
{
|
||||
MFX_PRIORITY_LOW = 0, /*!< Low priority: the session operation halts when high priority tasks are executing and more than 75% of the CPU is being used for normal priority tasks.*/
|
||||
MFX_PRIORITY_NORMAL = 1, /*!< Normal priority: the session operation is halted if there are high priority tasks.*/
|
||||
MFX_PRIORITY_HIGH = 2 /*!< High priority: the session operation blocks other lower priority session operations.*/
|
||||
|
||||
} mfxPriority;
|
||||
|
||||
typedef struct _mfxEncryptedData mfxEncryptedData;
|
||||
MFX_PACK_BEGIN_STRUCT_W_L_TYPE()
|
||||
/*! Defines the buffer that holds compressed video data. */
|
||||
typedef struct {
|
||||
/*! @internal :unnamed(union) @endinternal */
|
||||
union {
|
||||
struct {
|
||||
mfxEncryptedData* EncryptedData; /*!< Reserved and must be zero. */
|
||||
mfxExtBuffer **ExtParam; /*!< Array of extended buffers for additional bitstream configuration. See the ExtendedBufferID enumerator for a complete list of extended buffers. */
|
||||
mfxU16 NumExtParam; /*!< The number of extended buffers attached to this structure. */
|
||||
mfxU16 reserved1; /*!< Reserved for future use. */
|
||||
mfxU32 CodecId; /*!< Specifies the codec format identifier in the FourCC code. See the CodecFormatFourCC enumerator for details. This optional parameter is required for the simplified decode initialization. */
|
||||
|
||||
};
|
||||
mfxU32 reserved[6];
|
||||
};
|
||||
/*! Decode time stamp of the compressed bitstream in units of 90KHz. A value of MFX_TIMESTAMP_UNKNOWN indicates that there is no time stamp.
|
||||
|
||||
This value is calculated by the encoder from the presentation time stamp provided by the application in the mfxFrameSurface1 structure and
|
||||
from the frame rate provided by the application during the encoder initialization. */
|
||||
mfxI64 DecodeTimeStamp;
|
||||
mfxU64 TimeStamp; /*!< Time stamp of the compressed bitstream in units of 90KHz. A value of MFX_TIMESTAMP_UNKNOWN indicates that there is no time stamp. */
|
||||
mfxU8* Data; /*!< Bitstream buffer pointer, 32-bytes aligned. */
|
||||
mfxU32 DataOffset; /*!< Next reading or writing position in the bitstream buffer. */
|
||||
mfxU32 DataLength; /*!< Size of the actual bitstream data in bytes. */
|
||||
mfxU32 MaxLength; /*!< Allocated bitstream buffer size in bytes. */
|
||||
|
||||
mfxU16 PicStruct; /*!< Type of the picture in the bitstream. Output parameter. */
|
||||
mfxU16 FrameType; /*!< Frame type of the picture in the bitstream. Output parameter. */
|
||||
mfxU16 DataFlag; /*!< Indicates additional bitstream properties. See the BitstreamDataFlag enumerator for details. */
|
||||
mfxU16 reserved2; /*!< Reserved for future use. */
|
||||
} mfxBitstream;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! Synchronization point object handle. */
|
||||
typedef struct _mfxSyncPoint *mfxSyncPoint;
|
||||
|
||||
/*! The GPUCopy enumerator controls usage of GPU accelerated copying between video and system memory in the legacy Intel(r) Media SDK components. */
|
||||
enum {
|
||||
MFX_GPUCOPY_DEFAULT = 0, /*!< Use default mode for the legacy Intel(r) Media SDK implementation. */
|
||||
MFX_GPUCOPY_ON = 1, /*!< The hint to enable GPU accelerated copying when it is supported by the library.
|
||||
If the library doesn't support GPU accelerated copy the operation will be made by CPU.
|
||||
Buffer caching usage decision is up to runtime to decide, for explicit hints please use MFX_GPUCOPY_SAFE or MFX_GPUCOPY_FAST */
|
||||
MFX_GPUCOPY_OFF = 2, /*!< Disable GPU accelerated copying. */
|
||||
MFX_GPUCOPY_SAFE = 3, /*!< The hint to disable buffer caching for GPU accelerated copying. Actual when GPU accelerated copying is supported by the library. */
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
MFX_GPUCOPY_FAST = 4 /*!< The hint to enable buffer caching for GPU accelerated copying. Actual when GPU accelerated copying is supported by the library. */
|
||||
#endif
|
||||
};
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! Specifies advanced initialization parameters.
|
||||
A zero value in any of the fields indicates that the corresponding field
|
||||
is not explicitly specified.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxIMPL Implementation; /*!< Enumerator that indicates the desired legacy Intel(r) Media SDK implementation. */
|
||||
mfxVersion Version; /*!< Structure which specifies minimum library version or zero, if not specified. */
|
||||
mfxU16 ExternalThreads; /*!< Desired threading mode. Value 0 means internal threading, 1 - external. */
|
||||
/*! @internal :unnamed(union) @endinternal */
|
||||
union {
|
||||
struct {
|
||||
mfxExtBuffer **ExtParam; /*!< Points to an array of pointers to the extra configuration structures; see the ExtendedBufferID enumerator for a list of extended configurations. */
|
||||
mfxU16 NumExtParam; /*!< The number of extra configuration structures attached to this structure. */
|
||||
};
|
||||
mfxU16 reserved2[5];
|
||||
};
|
||||
mfxU16 GPUCopy; /*!< Enables or disables GPU accelerated copying between video and system memory in legacy Intel(r) Media SDK components. See the GPUCopy enumerator for a list of valid values. */
|
||||
mfxU16 reserved[21];
|
||||
} mfxInitParam;
|
||||
MFX_PACK_END()
|
||||
|
||||
enum {
|
||||
MFX_EXTBUFF_THREADS_PARAM = MFX_MAKEFOURCC('T','H','D','P') /*!< mfxExtThreadsParam buffer ID. */
|
||||
};
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Specifies options for threads created by this session. Attached to the
|
||||
mfxInitParam structure during legacy Intel(r) Media SDK session initialization
|
||||
or to mfxInitializationParam by the dispatcher in MFXCreateSession function. */
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUFF_THREADS_PARAM. */
|
||||
|
||||
mfxU16 NumThread; /*!< The number of threads. */
|
||||
mfxI32 SchedulingType; /*!< Scheduling policy for all threads.*/
|
||||
mfxI32 Priority; /*!< Priority for all threads. */
|
||||
mfxU16 reserved[55]; /*!< Reserved for future use. */
|
||||
} mfxExtThreadsParam;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! Deprecated. */
|
||||
enum {
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_UNKNOWN) = 0, /*!< Unknown platform. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_SANDYBRIDGE) = 1, /*!< Intel(r) microarchitecture code name Sandy Bridge. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_IVYBRIDGE) = 2, /*!< Intel(r) microarchitecture code name Ivy Bridge. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_HASWELL) = 3, /*!< Code name Haswell. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_BAYTRAIL) = 4, /*!< Code name Bay Trail. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_BROADWELL) = 5, /*!< Intel(r) microarchitecture code name Broadwell. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_CHERRYTRAIL) = 6, /*!< Code name Cherry Trail. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_SKYLAKE) = 7, /*!< Intel(r) microarchitecture code name Skylake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_APOLLOLAKE) = 8, /*!< Code name Apollo Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_KABYLAKE) = 9, /*!< Code name Kaby Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_GEMINILAKE) = 10, /*!< Code name Gemini Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_COFFEELAKE) = 11, /*!< Code name Coffee Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_CANNONLAKE) = 20, /*!< Code name Cannon Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ICELAKE) = 30, /*!< Code name Ice Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_JASPERLAKE) = 32, /*!< Code name Jasper Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ELKHARTLAKE) = 33, /*!< Code name Elkhart Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_TIGERLAKE) = 40, /*!< Code name Tiger Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ROCKETLAKE) = 42, /*!< Code name Rocket Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ALDERLAKE_S) = 43, /*!< Code name Alder Lake S. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ALDERLAKE_P) = 44, /*!< Code name Alder Lake P. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ARCTICSOUND_P) = 45,
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_XEHP_SDV) = 45, /*!< Code name XeHP SDV. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_DG2) = 46, /*!< Code name DG2. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ATS_M) = 46, /*!< Code name ATS-M, same media functionality as DG2. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ALDERLAKE_N) = 55, /*!< Code name Alder Lake N. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_KEEMBAY) = 50, /*!< Code name Keem Bay. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_METEORLAKE) = 51, /*!< Code name Meteor Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_BATTLEMAGE) = 52, /*!< Code name Battlemage. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_LUNARLAKE) = 53, /*!< Code name Lunar Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_ARROWLAKE) = 54, /*!< Code name Arrow Lake. */
|
||||
MFX_DEPRECATED_ENUM_FIELD_INSIDE(MFX_PLATFORM_MAXIMUM) = 65535, /*!< General code name. */
|
||||
};
|
||||
|
||||
/*! The mfxMediaAdapterType enumerator itemizes types of graphics adapters. */
|
||||
typedef enum
|
||||
{
|
||||
MFX_MEDIA_UNKNOWN = 0xffff, /*!< Unknown type. */
|
||||
MFX_MEDIA_INTEGRATED = 0, /*!< Integrated graphics adapter. */
|
||||
MFX_MEDIA_DISCRETE = 1 /*!< Discrete graphics adapter. */
|
||||
} mfxMediaAdapterType;
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Contains information about hardware platform for the Legacy mode. */
|
||||
typedef struct {
|
||||
MFX_DEPRECATED mfxU16 CodeName; /*!< Deprecated. */
|
||||
mfxU16 DeviceId; /*!< Unique identifier of graphics device. */
|
||||
mfxU16 MediaAdapterType; /*!< Description of graphics adapter type. See the mfxMediaAdapterType enumerator for a list of possible values. */
|
||||
mfxU16 reserved[13]; /*!< Reserved for future use. */
|
||||
} mfxPlatform;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
/*! The mfxResourceType enumerator specifies types of different native data frames and buffers. */
|
||||
typedef enum {
|
||||
MFX_RESOURCE_SYSTEM_SURFACE = 1, /*!< System memory. */
|
||||
MFX_RESOURCE_VA_SURFACE_PTR = 2, /*!< Pointer to VA surface index. */
|
||||
MFX_RESOURCE_VA_SURFACE = MFX_RESOURCE_VA_SURFACE_PTR, /*!< Pointer to VA surface index. */
|
||||
MFX_RESOURCE_VA_BUFFER_PTR = 3, /*!< Pointer to VA buffer index. */
|
||||
MFX_RESOURCE_VA_BUFFER = MFX_RESOURCE_VA_BUFFER_PTR, /*!< Pointer to VA buffer index. */
|
||||
MFX_RESOURCE_DX9_SURFACE = 4, /*!< Pointer to IDirect3DSurface9. */
|
||||
MFX_RESOURCE_DX11_TEXTURE = 5, /*!< Pointer to ID3D11Texture2D. */
|
||||
MFX_RESOURCE_DX12_RESOURCE = 6, /*!< Pointer to ID3D12Resource. */
|
||||
MFX_RESOURCE_DMA_RESOURCE = 7, /*!< DMA resource. */
|
||||
MFX_RESOURCE_HDDLUNITE_REMOTE_MEMORY = 8, /*!< HDDL Unite Remote memory handle. */
|
||||
} mfxResourceType;
|
||||
|
||||
/*! Maximum allowed length of the implementation name. */
|
||||
#define MFX_IMPL_NAME_LEN 32
|
||||
/*! Maximum allowed length of the implementation name. */
|
||||
#define MFX_STRFIELD_LEN 128
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
|
||||
#define MFX_DECEXTDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The mfxDecExtDescription structure represents the extended description of a decoder. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[14]; /*!< Reserved for future use. */
|
||||
mfxU16 NumExtBufferIDs; /*!< Number of supported extended buffer IDs. */
|
||||
mfxU32* ExtBufferIDs; /*!< Pointer to the array of supported extended buffer IDs. */
|
||||
} mfxDecExtDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
#define MFX_DECMEMEXTDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The mfxDecMemExtDescription structure represents the extended description for decoder memory. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[13]; /*!< Reserved for future use. */
|
||||
mfxU16 MaxBitDepth; /*!< Maximum supported bit depth. */
|
||||
mfxU16 NumChromaSubsamplings; /*!< Number of supported output chroma subsamplings. */
|
||||
mfxU16* ChromaSubsamplings; /*!< Pointer to the array of supported output chroma subsamplings. */
|
||||
} mfxDecMemExtDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
#define MFX_DECODERDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 1)
|
||||
#else
|
||||
#define MFX_DECODERDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
#endif
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The mfxDecoderDescription structure represents the description of a decoder. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumCodecs; /*!< Number of supported decoders. */
|
||||
/*! This structure represents the decoder description. */
|
||||
struct decoder {
|
||||
mfxU32 CodecID; /*!< Decoder ID in FourCC format. */
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
mfxU16 reserved[2]; /*!< Reserved for future use. */
|
||||
union {
|
||||
mfxDecExtDescription* DecExtDesc; /*!< Pointer to the extended descriptions of the decoder. */
|
||||
mfxU16 reserved2[4]; /*!< Reserved for future use. */
|
||||
};
|
||||
mfxU16 reserved3[2]; /*!< Reserved for future use. */
|
||||
#else
|
||||
mfxU16 reserved[8]; /*!< Reserved for future use. */
|
||||
#endif
|
||||
mfxU16 MaxcodecLevel; /*!< Maximum supported codec level. See the CodecProfile enumerator for possible values. */
|
||||
mfxU16 NumProfiles; /*!< Number of supported profiles. */
|
||||
/*! This structure represents the codec profile description. */
|
||||
struct decprofile {
|
||||
mfxU32 Profile; /*!< Profile ID. See the CodecProfile enumerator for possible values.*/
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumMemTypes; /*!< Number of supported memory types. */
|
||||
/*! This structure represents the underlying details of the memory type. */
|
||||
struct decmemdesc {
|
||||
mfxResourceType MemHandleType; /*!< Memory handle type. */
|
||||
mfxRange32U Width; /*!< Range of supported image widths. */
|
||||
mfxRange32U Height; /*!< Range of supported image heights. */
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
mfxU16 reserved[2]; /*!< Reserved for future use. */
|
||||
union {
|
||||
mfxDecMemExtDescription* MemExtDesc; /*!< Pointer to the extended descriptions for decoder memory. */
|
||||
mfxU16 reserved2[4]; /*!< Reserved for future use. */
|
||||
};
|
||||
mfxU16 reserved3; /*!< Reserved for future use. */
|
||||
#else
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
#endif
|
||||
mfxU16 NumColorFormats; /*!< Number of supported output color formats. */
|
||||
mfxU32* ColorFormats; /*!< Pointer to the array of supported output color formats (in FOURCC). */
|
||||
} * MemDesc; /*!< Pointer to the array of memory types. */
|
||||
} * Profiles; /*!< Pointer to the array of profiles supported by the codec. */
|
||||
} * Codecs; /*!< Pointer to the array of decoders. */
|
||||
} mfxDecoderDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
|
||||
#define MFX_ENCEXTDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The mfxEncExtDescription structure represents the extended description of an encoder. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[10]; /*!< Reserved for future use. */
|
||||
mfxU16 NumRateControlMethods; /*!< Number of supported bitrate control methods. */
|
||||
mfxU16* RateControlMethods; /*!< Pointer to the array of supported bitrate control methods. */
|
||||
mfxU16 reserved2[11]; /*!< Reserved for future use. */
|
||||
mfxU16 NumExtBufferIDs; /*!< Number of supported extended buffer IDs. */
|
||||
mfxU32* ExtBufferIDs; /*!< Pointer to the array of supported extended buffer IDs. */
|
||||
} mfxEncExtDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
#define MFX_ENCMEMEXTDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The mfxEncMemExtDescription structure represents the extended description for encoder memory. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[13]; /*!< Reserved for future use. */
|
||||
mfxU16 TargetMaxBitDepth; /*!< Maximum supported bit depth. */
|
||||
mfxU16 NumTargetChromaSubsamplings; /*!< Number of supported target chroma subsamplings. */
|
||||
mfxU16* TargetChromaSubsamplings; /*!< Pointer to the array of supported target chroma subsamplings. */
|
||||
} mfxEncMemExtDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
#define MFX_ENCODERDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 1)
|
||||
#else
|
||||
#define MFX_ENCODERDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
#endif
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents an encoder description. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumCodecs; /*!< Number of supported encoders. */
|
||||
/*! This structure represents encoder description. */
|
||||
struct encoder {
|
||||
mfxU32 CodecID; /*!< Encoder ID in FourCC format. */
|
||||
mfxU16 MaxcodecLevel; /*!< Maximum supported codec level. See the CodecProfile enumerator for possible values. */
|
||||
mfxU16 BiDirectionalPrediction; /*!< Indicates B-frames support. */
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
union {
|
||||
mfxEncExtDescription* EncExtDesc; /*!< Pointer to the extended descriptions of the encoder. */
|
||||
mfxU16 reserved2[4]; /*!< Reserved for future use. */
|
||||
};
|
||||
mfxU16 reserved[3]; /*!< Reserved for future use. */
|
||||
#else
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
#endif
|
||||
mfxU16 NumProfiles; /*!< Number of supported profiles. */
|
||||
/*! This structure represents the codec profile description. */
|
||||
struct encprofile {
|
||||
mfxU32 Profile; /*!< Profile ID. See the CodecProfile enumerator for possible values.*/
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumMemTypes; /*!< Number of supported memory types. */
|
||||
/*! This structure represents the underlying details of the memory type. */
|
||||
struct encmemdesc {
|
||||
mfxResourceType MemHandleType; /*!< Memory handle type. */
|
||||
mfxRange32U Width; /*!< Range of supported image widths. */
|
||||
mfxRange32U Height; /*!< Range of supported image heights. */
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
mfxU16 reserved[2]; /*!< Reserved for future use. */
|
||||
union {
|
||||
mfxEncMemExtDescription* MemExtDesc; /*!< Pointer to the extended descriptions for encoder memory. */
|
||||
mfxU16 reserved2[4]; /*!< Reserved for future use. */
|
||||
};
|
||||
mfxU16 reserved3; /*!< Reserved for future use. */
|
||||
#else
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
#endif
|
||||
mfxU16 NumColorFormats; /*!< Number of supported input color formats. */
|
||||
mfxU32* ColorFormats; /*!< Pointer to the array of supported input color formats (in FOURCC). */
|
||||
} * MemDesc; /*!< Pointer to the array of memory types. */
|
||||
} * Profiles; /*!< Pointer to the array of profiles supported by the codec. */
|
||||
} * Codecs; /*!< Pointer to the array of encoders. */
|
||||
} mfxEncoderDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
#define MFX_VPPDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents VPP description. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumFilters; /*!< Number of supported VPP filters. */
|
||||
/*! This structure represents the VPP filters description. */
|
||||
struct filter {
|
||||
mfxU32 FilterFourCC; /*!< Filter ID in FourCC format. */
|
||||
mfxU16 MaxDelayInFrames; /*!< Introduced output delay in frames. */
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumMemTypes; /*!< Number of supported memory types. */
|
||||
/*! This structure represents the underlying details of the memory type. */
|
||||
struct memdesc {
|
||||
mfxResourceType MemHandleType; /*!< Memory handle type. */
|
||||
mfxRange32U Width; /*!< Range of supported image widths. */
|
||||
mfxRange32U Height; /*!< Range of supported image heights. */
|
||||
mfxU16 reserved[7]; /*!< Reserved for future use. */
|
||||
mfxU16 NumInFormats; /*!< Number of supported input color formats. */
|
||||
/*! This structure represents the input color format description. */
|
||||
struct format {
|
||||
mfxU32 InFormat; /*!< Input color in FourCC format. */
|
||||
mfxU16 reserved[5]; /*!< Reserved for future use. */
|
||||
mfxU16 NumOutFormat; /*!< Number of supported output color formats. */
|
||||
mfxU32* OutFormats; /*!< Pointer to the array of supported output color formats (in FOURCC). */
|
||||
} * Formats; /*!< Pointer to the array of supported formats. */
|
||||
} * MemDesc; /*!< Pointer to the array of memory types. */
|
||||
} * Filters; /*!< Pointer to the array of supported filters. */
|
||||
} mfxVPPDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! The current version of mfxDeviceDescription structure. */
|
||||
#define MFX_DEVICEDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 1)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents device description. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[6]; /*!< reserved for future use. */
|
||||
mfxU16 MediaAdapterType; /*!< Graphics adapter type. See the mfxMediaAdapterType enumerator for a list of possible values. */
|
||||
mfxChar DeviceID[MFX_STRFIELD_LEN]; /*!< Null terminated string with device ID. */
|
||||
mfxU16 NumSubDevices; /*!< Number of available uniform sub-devices. Pure software implementation can report 0. */
|
||||
/*! This structure represents sub-device description. */
|
||||
struct subdevices {
|
||||
mfxU32 Index; /*!< Index of the sub-device, started from 0 and increased by 1.*/
|
||||
mfxChar SubDeviceID[MFX_STRFIELD_LEN]; /*!< Null terminated string with unique sub-device ID, mapped to the system ID. */
|
||||
mfxU32 reserved[7]; /*!< reserved for future use. */
|
||||
} * SubDevices; /*!< Pointer to the array of available sub-devices. */
|
||||
} mfxDeviceDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! This enum itemizes implementation type. */
|
||||
typedef enum {
|
||||
MFX_IMPL_TYPE_SOFTWARE = 0x0001, /*!< Pure Software Implementation. */
|
||||
MFX_IMPL_TYPE_HARDWARE = 0x0002, /*!< Hardware Accelerated Implementation. */
|
||||
} mfxImplType;
|
||||
|
||||
/*! This enum itemizes hardware acceleration stack to use. */
|
||||
typedef enum {
|
||||
MFX_ACCEL_MODE_NA = 0, /*!< Hardware acceleration is not applicable. */
|
||||
MFX_ACCEL_MODE_VIA_D3D9 = 0x0200, /*!< Hardware acceleration goes through the Microsoft* Direct3D9* infrastructure. */
|
||||
MFX_ACCEL_MODE_VIA_D3D11 = 0x0300, /*!< Hardware acceleration goes through the Microsoft* Direct3D11* infrastructure. */
|
||||
MFX_ACCEL_MODE_VIA_VAAPI = 0x0400, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure. */
|
||||
MFX_ACCEL_MODE_VIA_VAAPI_DRM_RENDER_NODE
|
||||
= MFX_ACCEL_MODE_VIA_VAAPI, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure with DRM RENDER MODE as default acceleration access point. */
|
||||
MFX_ACCEL_MODE_VIA_VAAPI_DRM_MODESET = 0x0401, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure with DRM MODESET as default acceleration access point. */
|
||||
MFX_ACCEL_MODE_VIA_VAAPI_GLX = 0x0402, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure with OpenGL Extension to the X Window System
|
||||
as default acceleration access point. */
|
||||
MFX_ACCEL_MODE_VIA_VAAPI_X11 = 0x0403, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure with X11 as default acceleration access point. */
|
||||
MFX_ACCEL_MODE_VIA_VAAPI_WAYLAND = 0x0404, /*!< Hardware acceleration goes through the Linux* VA-API infrastructure with Wayland as default acceleration access point. */
|
||||
MFX_ACCEL_MODE_VIA_HDDLUNITE = 0x0500, /*!< Hardware acceleration goes through the HDDL* Unite*. */
|
||||
} mfxAccelerationMode;
|
||||
|
||||
#define MFX_ACCELERATIONMODESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents acceleration modes description. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[2]; /*!< reserved for future use. */
|
||||
mfxU16 NumAccelerationModes; /*!< Number of supported acceleration modes. */
|
||||
mfxAccelerationMode* Mode; /*!< Pointer to the array of supported acceleration modes. */
|
||||
} mfxAccelerationModeDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! Specifies the surface pool allocation policies. */
|
||||
typedef enum {
|
||||
/*! Recommends to limit max pool size by sum of requested surfaces asked by components. */
|
||||
MFX_ALLOCATION_OPTIMAL = 0,
|
||||
|
||||
/*! Dynamic allocation with no limit. */
|
||||
MFX_ALLOCATION_UNLIMITED = 1,
|
||||
|
||||
/*! Max pool size is limited by NumberToPreAllocate + DeltaToAllocateOnTheFly. */
|
||||
MFX_ALLOCATION_LIMITED = 2,
|
||||
|
||||
} mfxPoolAllocationPolicy;
|
||||
|
||||
/*! The current version of mfxPoolPolicyDescription structure. */
|
||||
#define MFX_POOLPOLICYDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents pool policy description. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 reserved[2]; /*!< reserved for future use. */
|
||||
mfxU16 NumPoolPolicies; /*!< Number of supported pool policies. */
|
||||
mfxPoolAllocationPolicy* Policy; /*!< Pointer to the array of supported pool policies. */
|
||||
} mfxPoolPolicyDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*! The current version of mfxImplDescription structure. */
|
||||
#define MFX_IMPLDESCRIPTION_VERSION MFX_STRUCT_VERSION(1, 2)
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents the implementation description. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxImplType Impl; /*!< Impl type: software/hardware. */
|
||||
mfxAccelerationMode AccelerationMode; /*!< Default Hardware acceleration stack to use. OS dependent parameter. Use VA for Linux* and DX* for Windows*. */
|
||||
mfxVersion ApiVersion; /*!< Supported API version. */
|
||||
mfxChar ImplName[MFX_IMPL_NAME_LEN]; /*!< Null-terminated string with implementation name given by vendor. */
|
||||
mfxChar License[MFX_STRFIELD_LEN]; /*!< Null-terminated string with comma-separated list of license names of the implementation. */
|
||||
mfxChar Keywords[MFX_STRFIELD_LEN]; /*!< Null-terminated string with comma-separated list of keywords specific to this implementation that dispatcher can search for. */
|
||||
mfxU32 VendorID; /*!< Standard vendor ID 0x8086 - Intel. */
|
||||
mfxU32 VendorImplID; /*!< Vendor specific number with given implementation ID. */
|
||||
mfxDeviceDescription Dev; /*!< Supported device. */
|
||||
mfxDecoderDescription Dec; /*!< Decoder configuration. */
|
||||
mfxEncoderDescription Enc; /*!< Encoder configuration. */
|
||||
mfxVPPDescription VPP; /*!< VPP configuration. */
|
||||
union
|
||||
{
|
||||
mfxAccelerationModeDescription AccelerationModeDescription; /*!< Supported acceleration modes. */
|
||||
mfxU32 reserved3[4];
|
||||
};
|
||||
mfxPoolPolicyDescription PoolPolicies; /*!< Supported surface pool polices. */
|
||||
mfxU32 reserved[8]; /*!< Reserved for future use. */
|
||||
mfxU32 NumExtParam; /*!< Number of extension buffers. Reserved for future use. Must be 0. */
|
||||
union {
|
||||
mfxExtBuffer **ExtParam; /*!< Array of extension buffers. */
|
||||
mfxU64 Reserved2; /*!< Reserved for future use. */
|
||||
} ExtParams; /*!< Extension buffers. Reserved for future. */
|
||||
} mfxImplDescription;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! This structure represents the list of names of implemented functions. */
|
||||
typedef struct {
|
||||
mfxU16 NumFunctions; /*!< Number of function names in the FunctionsName array. */
|
||||
mfxChar** FunctionsName; /*!< Array of the null-terminated strings. Each string contains name of the implemented function. */
|
||||
} mfxImplementedFunctions;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
#define MFX_EXTENDEDDEVICEID_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Specifies various physical device properties for device matching and identification outside of oneAPI Video Processing Library (oneVPL). */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxU16 VendorID; /*!< PCI vendor ID. */
|
||||
mfxU16 DeviceID; /*!< PCI device ID. */
|
||||
mfxU32 PCIDomain; /*!< PCI bus domain. Equals to '0' if OS doesn't support it or
|
||||
has sequential numbering of buses across domains. */
|
||||
mfxU32 PCIBus; /*!< The number of the bus that the physical device is located on. */
|
||||
mfxU32 PCIDevice; /*!< The index of the physical device on the bus. */
|
||||
mfxU32 PCIFunction; /*!< The function number of the device on the physical device. */
|
||||
mfxU8 DeviceLUID[8]; /*!< LUID of DXGI adapter. */
|
||||
mfxU32 LUIDDeviceNodeMask; /*!< Bitfield identifying the node within a linked
|
||||
device adapter corresponding to the device. */
|
||||
mfxU32 LUIDValid; /*!< Boolean value that will be 1 if DeviceLUID contains a valid LUID
|
||||
and LUIDDeviceNodeMask contains a valid node mask,
|
||||
and 0 if they do not. */
|
||||
mfxU32 DRMRenderNodeNum; /*!< Number of the DRM render node from the path /dev/dri/RenderD\<num\>.
|
||||
Value equals to 0 means that this field doesn't contain valid DRM Render
|
||||
Node number.*/
|
||||
mfxU32 DRMPrimaryNodeNum; /*!< Number of the DRM primary node from the path /dev/dri/card\<num\>.
|
||||
Value equals to 0x7FFFFFFF means that this field doesn't contain valid DRM Primary
|
||||
Node number.*/
|
||||
mfxU16 RevisionID; /*!< PCI revision ID. The value contains microarchitecture version. */
|
||||
mfxU8 reserved1[18]; /*!< Reserved for future use. */
|
||||
mfxChar DeviceName[MFX_STRFIELD_LEN]; /*!< Null-terminated string in utf-8 with the name of the device. */
|
||||
} mfxExtendedDeviceId;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Cross domain structure to define device UUID. It is defined here to check backward compatibility.*/
|
||||
typedef struct {
|
||||
mfxU16 vendor_id; /*!< PCI vendor ID. Same as mfxExtendedDeviceId::VendorID. */
|
||||
mfxU16 device_id; /*!< PCI device ID. Same as mfxExtendedDeviceId::DeviceID. */
|
||||
mfxU16 revision_id; /*!< PCI revision ID. Same as mfxExtendedDeviceId::RevisionID. */
|
||||
mfxU16 pci_domain; /*!< PCI bus domain. Same as mfxExtendedDeviceId::PCIDomain. */
|
||||
mfxU8 pci_bus; /*!< The number of the bus that the physical device is located on. Same as mfxExtendedDeviceId::PCIBus. */
|
||||
mfxU8 pci_dev; /*!< The index of the physical device on the bus. Same as mfxExtendedDeviceId::PCIDevice. */
|
||||
mfxU8 pci_func; /*!< The function number of the device on the physical device. Same as mfxExtendedDeviceId::PCIFunction. */
|
||||
mfxU8 reserved[4]; /*!< Reserved for future use. */
|
||||
mfxU8 sub_device_id; /*!< SubDevice ID.*/
|
||||
} extDeviceUUID;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
/*! The mfxImplCapsDeliveryFormat enumerator specifies delivery format of the implementation capability. */
|
||||
typedef enum {
|
||||
MFX_IMPLCAPS_IMPLDESCSTRUCTURE = 1, /*!< Deliver capabilities as mfxImplDescription structure. */
|
||||
MFX_IMPLCAPS_IMPLEMENTEDFUNCTIONS = 2, /*!< Deliver capabilities as mfxImplementedFunctions structure. */
|
||||
MFX_IMPLCAPS_IMPLPATH = 3, /*!< Deliver pointer to the null-terminated string with the path to the
|
||||
implementation. String is delivered in a form of buffer of
|
||||
mfxChar type. */
|
||||
MFX_IMPLCAPS_DEVICE_ID_EXTENDED = 4, /*!< Deliver extended device ID information as mfxExtendedDeviceId
|
||||
structure.*/
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
MFX_IMPLCAPS_SURFACE_TYPES = 5, /*!< Deliver capabilities as mfxSurfaceTypesSupported structure. */
|
||||
#endif
|
||||
} mfxImplCapsDeliveryFormat;
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! Specifies initialization parameters for API version starting from 2.0.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxAccelerationMode AccelerationMode; /*!< Hardware acceleration stack to use. OS dependent parameter. Use VA for Linux*, DX* for Windows* or HDDL. */
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
mfxU16 DeviceCopy; /*!< Enables or disables device's accelerated copying between device and
|
||||
host. See the GPUCopy enumerator for a list of valid values.
|
||||
This parameter is the equivalent of mfxInitParam::GPUCopy. */
|
||||
mfxU16 reserved[2]; /*!< Reserved for future use. */
|
||||
#else
|
||||
mfxU16 reserved[3]; /*!< Reserved for future use. */
|
||||
#endif
|
||||
mfxU16 NumExtParam; /*!< The number of extra configuration structures attached to this
|
||||
structure. */
|
||||
mfxExtBuffer **ExtParam; /*!< Points to an array of pointers to the extra configuration structures;
|
||||
see the ExtendedBufferID enumerator for a list of extended
|
||||
configurations. */
|
||||
mfxU32 VendorImplID; /*!< Vendor specific number with given implementation ID. Represents
|
||||
the same field from mfxImplDescription. */
|
||||
mfxU32 reserved2[3]; /*!< Reserved for future use. */
|
||||
} mfxInitializationParam;
|
||||
MFX_PACK_END()
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! Represents a name/value pair to indicate requested properties. For use with MFXQueryImplsProperties() */
|
||||
typedef struct {
|
||||
mfxU8* PropName; /*!< Property name string to indicate the requested Property. */
|
||||
mfxVariant PropVar; /*!< Property value corresponding to the property name. */
|
||||
} mfxQueryProperty;
|
||||
MFX_PACK_END()
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif /* __cplusplus */
|
||||
|
||||
#endif
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifndef __MFXDEFS_H__
|
||||
#define __MFXDEFS_H__
|
||||
|
||||
#define MFX_VERSION_MAJOR 2
|
||||
#define MFX_VERSION_MINOR 17
|
||||
|
||||
// MFX_VERSION - version of API that 'assumed' by build may be provided externally
|
||||
// if it omitted then latest stable API derived from Major.Minor is assumed
|
||||
|
||||
|
||||
#if !defined(MFX_VERSION)
|
||||
#define MFX_VERSION (MFX_VERSION_MAJOR * 1000 + MFX_VERSION_MINOR)
|
||||
#else
|
||||
#undef MFX_VERSION_MAJOR
|
||||
#define MFX_VERSION_MAJOR ((MFX_VERSION) / 1000)
|
||||
|
||||
#undef MFX_VERSION_MINOR
|
||||
#define MFX_VERSION_MINOR ((MFX_VERSION) % 1000)
|
||||
#endif
|
||||
|
||||
/*! The corresponding version of the Intel(r) Media SDK legacy API that is used as a basis
|
||||
for the current API. */
|
||||
|
||||
#define MFX_LEGACY_VERSION 1035
|
||||
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif /* __cplusplus */
|
||||
|
||||
/* In preprocessor syntax # symbol has stringize meaning,
|
||||
so to expand some macro to preprocessor pragma we need to use
|
||||
special compiler dependent construction */
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#define MFX_PRAGMA_IMPL(x) __pragma(x)
|
||||
#else
|
||||
#define MFX_PRAGMA_IMPL(x) _Pragma(#x)
|
||||
#endif
|
||||
|
||||
#define MFX_PACK_BEGIN_X(x) MFX_PRAGMA_IMPL(pack(push, x))
|
||||
#define MFX_PACK_END() MFX_PRAGMA_IMPL(pack(pop))
|
||||
|
||||
/* The general rule for alignment is following:
|
||||
- structures with pointers have 4/8 bytes alignment on 32/64 bit systems
|
||||
- structures with fields of type mfxU64/mfxF64 (unsigned long long / double)
|
||||
have alignment 8 bytes on 64 bit and 32 bit Windows, on Linux alignment is 4 bytes
|
||||
- all the rest structures are 4 bytes aligned
|
||||
- there are several exceptions: some structs which had 4-byte alignment were extended
|
||||
with pointer / long type fields; such structs have 4-byte alignment to keep binary
|
||||
compatibility with previously release API */
|
||||
|
||||
#define MFX_PACK_BEGIN_USUAL_STRUCT() MFX_PACK_BEGIN_X(4)
|
||||
|
||||
/* 64-bit LP64 data model */
|
||||
#if defined(_WIN64) || defined(__LP64__)
|
||||
#define MFX_PACK_BEGIN_STRUCT_W_PTR() MFX_PACK_BEGIN_X(8)
|
||||
#define MFX_PACK_BEGIN_STRUCT_W_L_TYPE() MFX_PACK_BEGIN_X(8)
|
||||
/* 32-bit ILP32 data model Windows* (Intel(r) architecture) */
|
||||
#elif defined(_WIN32) || defined(_M_IX86) && !defined(__linux__)
|
||||
#define MFX_PACK_BEGIN_STRUCT_W_PTR() MFX_PACK_BEGIN_X(4)
|
||||
#define MFX_PACK_BEGIN_STRUCT_W_L_TYPE() MFX_PACK_BEGIN_X(8)
|
||||
/* 32-bit ILP32 data model Linux* */
|
||||
#elif defined(__ILP32__) || defined(__arm__)
|
||||
#define MFX_PACK_BEGIN_STRUCT_W_PTR() MFX_PACK_BEGIN_X(4)
|
||||
#define MFX_PACK_BEGIN_STRUCT_W_L_TYPE() MFX_PACK_BEGIN_X(4)
|
||||
#else
|
||||
#error Unknown packing
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
#define MFX_CDECL __cdecl
|
||||
#define MFX_STDCALL __stdcall
|
||||
#else
|
||||
#define MFX_CDECL
|
||||
#define MFX_STDCALL
|
||||
#endif /* _WIN32 */
|
||||
|
||||
#define MFX_INFINITE 0xFFFFFFFF
|
||||
|
||||
#ifndef MFX_DEPRECATED_OFF
|
||||
#if defined(__cplusplus) && __cplusplus >= 201402L
|
||||
#define MFX_DEPRECATED [[deprecated]]
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg [[deprecated]]
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg)
|
||||
#elif defined(__clang__)
|
||||
#define MFX_DEPRECATED __attribute__((deprecated))
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg __attribute__((deprecated))
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg)
|
||||
#elif defined(__INTEL_COMPILER)
|
||||
#if (defined(_WIN32) || defined(_WIN64))
|
||||
#define MFX_DEPRECATED __declspec(deprecated)
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg) __pragma(deprecated(arg))
|
||||
#elif defined(__linux__)
|
||||
#define MFX_DEPRECATED __attribute__((deprecated))
|
||||
#if defined(__cplusplus)
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg __attribute__((deprecated))
|
||||
#else
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg
|
||||
#endif
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg)
|
||||
#endif
|
||||
#elif defined(_MSC_VER) && _MSC_VER > 1200 // VS 6 doesn't support deprecation
|
||||
#define MFX_DEPRECATED __declspec(deprecated)
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg) __pragma(deprecated(arg))
|
||||
#elif defined(__GNUC__)
|
||||
#define MFX_DEPRECATED __attribute__((deprecated))
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg __attribute__((deprecated))
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg)
|
||||
#else
|
||||
#define MFX_DEPRECATED
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg)
|
||||
#endif
|
||||
#else
|
||||
#define MFX_DEPRECATED
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_INSIDE(arg) arg
|
||||
#define MFX_DEPRECATED_ENUM_FIELD_OUTSIDE(arg)
|
||||
#endif
|
||||
|
||||
typedef unsigned char mfxU8; /*!< Unsigned integer, 8 bit type. */
|
||||
typedef char mfxI8; /*!< Signed integer, 8 bit type. */
|
||||
typedef short mfxI16; /*!< Signed integer, 16 bit type. */
|
||||
typedef unsigned short mfxU16; /*!< Unsigned integer, 16 bit type. */
|
||||
typedef unsigned int mfxU32; /*!< Unsigned integer, 32 bit type. */
|
||||
typedef int mfxI32; /*!< Signed integer, 32 bit type. */
|
||||
#if defined( _WIN32 ) || defined ( _WIN64 )
|
||||
typedef unsigned long mfxUL32; /*!< Unsigned integer, 32 bit type. */
|
||||
typedef long mfxL32; /*!< Signed integer, 32 bit type. */
|
||||
#else
|
||||
typedef unsigned int mfxUL32; /*!< Unsigned integer, 32 bit type. */
|
||||
typedef int mfxL32; /*!< Signed integer, 32 bit type. */
|
||||
#endif
|
||||
typedef float mfxF32; /*!< Single-precision floating point, 32 bit type. */
|
||||
typedef double mfxF64; /*!< Double-precision floating point, 64 bit type. */
|
||||
typedef unsigned long long mfxU64; /*!< Unsigned integer, 64 bit type. */
|
||||
typedef long long mfxI64; /*!< Signed integer, 64 bit type. */
|
||||
typedef void* mfxHDL; /*!< Handle type. */
|
||||
typedef mfxHDL mfxMemId; /*!< Memory ID type. */
|
||||
typedef void* mfxThreadTask; /*!< Thread task type. */
|
||||
typedef char mfxChar; /*!< UTF-8 byte. */
|
||||
typedef unsigned short mfxFP16; /*!< Half precision floating point, 16 bit type. */
|
||||
|
||||
/* MFX structures version info */
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Introduce the field Version for any structure.
|
||||
Assumed that any structure changes are backward binary compatible.
|
||||
mfxStructVersion starts from {1,0} for any new API structures. If mfxStructVersion is
|
||||
added to the existent legacy structure (replacing reserved fields) it starts from {1, 1}.
|
||||
*/
|
||||
typedef union {
|
||||
/*! Structure with Major and Minor fields. */
|
||||
/*! @struct Anonymous */
|
||||
struct {
|
||||
/*! @{
|
||||
@name Major and Minor fields
|
||||
Anonymous structure with Major and Minor fields. Minor number is incremented when reserved fields are used. Major number is incremented when the size of structure is increased. */
|
||||
mfxU8 Minor; /*!< Minor number of the correspondent structure. */
|
||||
mfxU8 Major; /*!< Major number of the correspondent structure. */
|
||||
/*! @} */
|
||||
};
|
||||
mfxU16 Version; /*!< Structure version number. */
|
||||
} mfxStructVersion;
|
||||
MFX_PACK_END()
|
||||
|
||||
#define MFX_STRUCT_VERSION(MAJOR, MINOR) (256*(MAJOR) + (MINOR))
|
||||
|
||||
#define MFX_VARIANT_VERSION MFX_STRUCT_VERSION(1, 1)
|
||||
|
||||
/*! The mfxDataType enumerates data type for mfxDataType. */
|
||||
typedef enum {
|
||||
MFX_DATA_TYPE_UNSET = 0, /*!< Undefined type. */
|
||||
MFX_DATA_TYPE_U8, /*!< 8-bit unsigned integer. */
|
||||
MFX_DATA_TYPE_I8, /*!< 8-bit signed integer. */
|
||||
MFX_DATA_TYPE_U16, /*!< 16-bit unsigned integer. */
|
||||
MFX_DATA_TYPE_I16, /*!< 16-bit signed integer. */
|
||||
MFX_DATA_TYPE_U32, /*!< 32-bit unsigned integer. */
|
||||
MFX_DATA_TYPE_I32, /*!< 32-bit signed integer. */
|
||||
MFX_DATA_TYPE_U64, /*!< 64-bit unsigned integer. */
|
||||
MFX_DATA_TYPE_I64, /*!< 64-bit signed integer. */
|
||||
MFX_DATA_TYPE_F32, /*!< 32-bit single precision floating point. */
|
||||
MFX_DATA_TYPE_F64, /*!< 64-bit double precision floating point. */
|
||||
MFX_DATA_TYPE_PTR, /*!< Generic type pointer. */
|
||||
MFX_DATA_TYPE_FP16, /*!< 16-bit half precision floating point. */
|
||||
}mfxDataType;
|
||||
|
||||
/*! The mfxVariantType enumerator data types for mfxVariantType. */
|
||||
typedef enum {
|
||||
MFX_VARIANT_TYPE_UNSET = MFX_DATA_TYPE_UNSET, /*!< Undefined type. */
|
||||
MFX_VARIANT_TYPE_U8 = MFX_DATA_TYPE_U8, /*!< 8-bit unsigned integer. */
|
||||
MFX_VARIANT_TYPE_I8 = MFX_DATA_TYPE_I8, /*!< 8-bit signed integer. */
|
||||
MFX_VARIANT_TYPE_U16 = MFX_DATA_TYPE_U16, /*!< 16-bit unsigned integer. */
|
||||
MFX_VARIANT_TYPE_I16 = MFX_DATA_TYPE_I16, /*!< 16-bit signed integer. */
|
||||
MFX_VARIANT_TYPE_U32 = MFX_DATA_TYPE_U32, /*!< 32-bit unsigned integer. */
|
||||
MFX_VARIANT_TYPE_I32 = MFX_DATA_TYPE_I32, /*!< 32-bit signed integer. */
|
||||
MFX_VARIANT_TYPE_U64 = MFX_DATA_TYPE_U64, /*!< 64-bit unsigned integer. */
|
||||
MFX_VARIANT_TYPE_I64 = MFX_DATA_TYPE_I64, /*!< 64-bit signed integer. */
|
||||
MFX_VARIANT_TYPE_F32 = MFX_DATA_TYPE_F32, /*!< 32-bit single precision floating point. */
|
||||
MFX_VARIANT_TYPE_F64 = MFX_DATA_TYPE_F64, /*!< 64-bit double precision floating point. */
|
||||
MFX_VARIANT_TYPE_PTR = MFX_DATA_TYPE_PTR, /*!< Generic type pointer. */
|
||||
MFX_VARIANT_TYPE_FP16 = MFX_DATA_TYPE_FP16, /*!< 16-bit half precision floating point. */
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
MFX_VARIANT_TYPE_QUERY = 0x00000100, /*!< Bitmask to OR with other variant types when using property-based query API */
|
||||
#endif
|
||||
} mfxVariantType;
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The mfxVariantType enumerator data types for mfxVariant type. */
|
||||
typedef struct {
|
||||
mfxStructVersion Version; /*!< Version of the structure. */
|
||||
mfxVariantType Type; /*!< Value type. */
|
||||
/*! Value data holder. */
|
||||
union data {
|
||||
mfxU8 U8; /*!< mfxU8 data. */
|
||||
mfxI8 I8; /*!< mfxI8 data. */
|
||||
mfxU16 U16; /*!< mfxU16 data. */
|
||||
mfxI16 I16; /*!< mfxI16 data. */
|
||||
mfxU32 U32; /*!< mfxU32 data. */
|
||||
mfxI32 I32; /*!< mfxI32 data. */
|
||||
mfxU64 U64; /*!< mfxU64 data. */
|
||||
mfxI64 I64; /*!< mfxI64 data. */
|
||||
mfxF32 F32; /*!< mfxF32 data. */
|
||||
mfxF64 F64; /*!< mfxF64 data. */
|
||||
mfxFP16 FP16; /*!< mfxFP16 data. */
|
||||
mfxHDL Ptr; /*!< Pointer. When this points to a string the string must be null terminated. */
|
||||
} Data; /*!< Value data member. */
|
||||
} mfxVariant;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Represents a range of unsigned values. */
|
||||
typedef struct {
|
||||
mfxU32 Min; /*!< Minimal value of the range. */
|
||||
mfxU32 Max; /*!< Maximal value of the range. */
|
||||
mfxU32 Step; /*!< Value increment. */
|
||||
} mfxRange32U;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Represents a pair of numbers of type mfxI16. */
|
||||
typedef struct {
|
||||
mfxI16 x; /*!< First number. */
|
||||
mfxI16 y; /*!< Second number. */
|
||||
} mfxI16Pair;
|
||||
MFX_PACK_END()
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! Represents pair of handles of type mfxHDL. */
|
||||
typedef struct {
|
||||
mfxHDL first; /*!< First handle. */
|
||||
mfxHDL second; /*!< Second handle. */
|
||||
} mfxHDLPair;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*********************************************************************************\
|
||||
Error message
|
||||
\*********************************************************************************/
|
||||
/*! @enum mfxStatus Itemizes status codes returned by API functions. */
|
||||
typedef enum
|
||||
{
|
||||
/* no error */
|
||||
MFX_ERR_NONE = 0, /*!< No error. */
|
||||
/* reserved for unexpected errors */
|
||||
MFX_ERR_UNKNOWN = -1, /*!< Unknown error. */
|
||||
|
||||
/* error codes <0 */
|
||||
MFX_ERR_NULL_PTR = -2, /*!< Null pointer. */
|
||||
MFX_ERR_UNSUPPORTED = -3, /*!< Unsupported feature. */
|
||||
MFX_ERR_MEMORY_ALLOC = -4, /*!< Failed to allocate memory. */
|
||||
MFX_ERR_NOT_ENOUGH_BUFFER = -5, /*!< Insufficient buffer at input/output. */
|
||||
MFX_ERR_INVALID_HANDLE = -6, /*!< Invalid handle. */
|
||||
MFX_ERR_LOCK_MEMORY = -7, /*!< Failed to lock the memory block. */
|
||||
MFX_ERR_NOT_INITIALIZED = -8, /*!< Member function called before initialization. */
|
||||
MFX_ERR_NOT_FOUND = -9, /*!< The specified object is not found. */
|
||||
MFX_ERR_MORE_DATA = -10, /*!< Expect more data at input. */
|
||||
MFX_ERR_MORE_SURFACE = -11, /*!< Expect more surface at output. */
|
||||
MFX_ERR_ABORTED = -12, /*!< Operation aborted. */
|
||||
MFX_ERR_DEVICE_LOST = -13, /*!< Lose the hardware acceleration device. */
|
||||
MFX_ERR_INCOMPATIBLE_VIDEO_PARAM = -14, /*!< Incompatible video parameters. */
|
||||
MFX_ERR_INVALID_VIDEO_PARAM = -15, /*!< Invalid video parameters. */
|
||||
MFX_ERR_UNDEFINED_BEHAVIOR = -16, /*!< Undefined behavior. */
|
||||
MFX_ERR_DEVICE_FAILED = -17, /*!< Device operation failure. */
|
||||
MFX_ERR_MORE_BITSTREAM = -18, /*!< Expect more bitstream buffers at output. */
|
||||
MFX_ERR_GPU_HANG = -21, /*!< Device operation failure caused by GPU hang. */
|
||||
MFX_ERR_REALLOC_SURFACE = -22, /*!< Bigger output surface required. */
|
||||
MFX_ERR_RESOURCE_MAPPED = -23, /*!< Write access is already acquired and user requested
|
||||
another write access, or read access with MFX_MEMORY_NO_WAIT flag. */
|
||||
MFX_ERR_NOT_IMPLEMENTED = -24, /*!< Feature or function not implemented. */
|
||||
MFX_ERR_MORE_EXTBUFFER = -25, /*!< Expect additional extended configuration buffer. */
|
||||
|
||||
/* warnings >0 */
|
||||
MFX_WRN_IN_EXECUTION = 1, /*!< The previous asynchronous operation is in execution. */
|
||||
MFX_WRN_DEVICE_BUSY = 2, /*!< The hardware acceleration device is busy. */
|
||||
MFX_WRN_VIDEO_PARAM_CHANGED = 3, /*!< The video parameters are changed during decoding. */
|
||||
MFX_WRN_PARTIAL_ACCELERATION = 4, /*!< Software acceleration is used. */
|
||||
MFX_WRN_INCOMPATIBLE_VIDEO_PARAM = 5, /*!< Incompatible video parameters. */
|
||||
MFX_WRN_VALUE_NOT_CHANGED = 6, /*!< The value is saturated based on its valid range. */
|
||||
MFX_WRN_OUT_OF_RANGE = 7, /*!< The value is out of valid range. */
|
||||
MFX_WRN_FILTER_SKIPPED = 10, /*!< One of requested filters has been skipped. */
|
||||
/* low-delay partial output */
|
||||
MFX_ERR_NONE_PARTIAL_OUTPUT = 12, /*!< Frame is not ready, but bitstream contains partial output. */
|
||||
|
||||
MFX_WRN_ALLOC_TIMEOUT_EXPIRED = 13, /*!< Timeout expired for internal frame allocation. */
|
||||
|
||||
/* threading statuses */
|
||||
MFX_TASK_DONE = MFX_ERR_NONE, /*!< Task has been completed. */
|
||||
MFX_TASK_WORKING = 8, /*!< There is some more work to do. */
|
||||
MFX_TASK_BUSY = 9, /*!< Task is waiting for resources. */
|
||||
|
||||
/* plug-in statuses */
|
||||
MFX_ERR_MORE_DATA_SUBMIT_TASK = -10000, /*!< Return MFX_ERR_MORE_DATA but submit internal asynchronous task. */
|
||||
|
||||
} mfxStatus;
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*! Represents Globally Unique Identifier (GUID) with memory layout
|
||||
compliant to RFC 4122. See https://www.rfc-editor.org/info/rfc4122 for details. */
|
||||
typedef struct
|
||||
{
|
||||
mfxU8 Data[16]; /*!< Array to keep GUID. */
|
||||
} mfxGUID;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
|
||||
// Application
|
||||
#if defined(MFX_DISPATCHER_EXPOSED_PREFIX)
|
||||
|
||||
#include "mfxdispatcherprefixedfunctions.h"
|
||||
|
||||
#endif // MFX_DISPATCHER_EXPOSED_PREFIX
|
||||
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif /* __cplusplus */
|
||||
|
||||
#endif /* __MFXDEFS_H__ */
|
||||
@@ -0,0 +1,271 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifndef __MFXDISPATCHER_H__
|
||||
#define __MFXDISPATCHER_H__
|
||||
|
||||
#include "mfxdefs.h"
|
||||
#include "mfxcommon.h"
|
||||
#include "mfxsession.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*! Loader handle. */
|
||||
typedef struct _mfxLoader *mfxLoader;
|
||||
|
||||
/*! Config handle. */
|
||||
typedef struct _mfxConfig *mfxConfig;
|
||||
|
||||
/*!
|
||||
@brief Creates the loader.
|
||||
@return Loader Loader handle or NULL if failed.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
mfxLoader MFX_CDECL MFXLoad(void);
|
||||
|
||||
/*!
|
||||
@brief Destroys the dispatcher.
|
||||
@param[in] loader Loader handle.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
void MFX_CDECL MFXUnload(mfxLoader loader);
|
||||
|
||||
/*!
|
||||
@brief Creates dispatcher configuration.
|
||||
@details Creates the dispatcher internal configuration, which is used to filter out available implementations.
|
||||
This configuration is used to walk through selected implementations to gather more details and select the appropriate
|
||||
implementation to load. The loader object remembers all created mfxConfig objects and destroys them during the mfxUnload
|
||||
function call.
|
||||
|
||||
Multiple configurations per single mfxLoader object are possible.
|
||||
|
||||
Usage example:
|
||||
@code
|
||||
mfxLoader loader = MFXLoad();
|
||||
mfxConfig cfg = MFXCreateConfig(loader);
|
||||
MFXCreateSession(loader,0,&session);
|
||||
@endcode
|
||||
@param[in] loader Loader handle.
|
||||
@return Config handle or NULL pointer is failed.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
mfxConfig MFX_CDECL MFXCreateConfig(mfxLoader loader);
|
||||
|
||||
/*!
|
||||
@brief Adds additional filter properties (any fields of the mfxImplDescription structure) to the configuration of the loader object.
|
||||
@note Each new call with the same parameter name will overwrite the previously set value. This may invalidate other properties.
|
||||
|
||||
@param[in] config Config handle.
|
||||
@param[in] name Name of the parameter (see mfxImplDescription structure and example).
|
||||
@param[in] value Value of the parameter.
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully.
|
||||
MFX_ERR_NULL_PTR If config is NULL. \n
|
||||
MFX_ERR_NULL_PTR If name is NULL. \n
|
||||
MFX_ERR_NOT_FOUND If name contains unknown parameter name.
|
||||
MFX_ERR_UNSUPPORTED If value data type does not equal the parameter with provided name.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
mfxStatus MFX_CDECL MFXSetConfigFilterProperty(mfxConfig config, const mfxU8* name, mfxVariant value);
|
||||
|
||||
/*!
|
||||
@brief Iterates over filtered out implementations to gather their details. This function allocates memory to store
|
||||
a structure or string corresponding to the type specified by format. For example, if format is set to
|
||||
MFX_IMPLCAPS_IMPLDESCSTRUCTURE, then idesc will return a pointer to a structure of type mfxImplDescription.
|
||||
Use the MFXDispReleaseImplDescription function to free memory allocated to this structure or string.
|
||||
@param[in] loader Loader handle.
|
||||
@param[in] i Index of the implementation.
|
||||
@param[in] format Format in which capabilities need to be delivered. See the mfxImplCapsDeliveryFormat enumerator for more details.
|
||||
@param[out] idesc Pointer to the structure or string corresponding to the requested format.
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. The idesc contains valid information.\n
|
||||
MFX_ERR_NULL_PTR If loader is NULL. \n
|
||||
MFX_ERR_NULL_PTR If idesc is NULL. \n
|
||||
MFX_ERR_NOT_FOUND Provided index is out of possible range. \n
|
||||
MFX_ERR_UNSUPPORTED If requested format is not supported.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
mfxStatus MFX_CDECL MFXEnumImplementations(mfxLoader loader, mfxU32 i, mfxImplCapsDeliveryFormat format, mfxHDL* idesc);
|
||||
|
||||
|
||||
/*!
|
||||
@brief Loads and initializes the implementation.
|
||||
@code
|
||||
mfxLoader loader = MFXLoad();
|
||||
int i=0;
|
||||
while(1) {
|
||||
mfxImplDescription *idesc;
|
||||
MFXEnumImplementations(loader, i, MFX_IMPLCAPS_IMPLDESCSTRUCTURE, (mfxHDL*)&idesc);
|
||||
if(is_good(idesc)) {
|
||||
MFXCreateSession(loader, i,&session);
|
||||
// ...
|
||||
MFXDispReleaseImplDescription(loader, idesc);
|
||||
}
|
||||
else
|
||||
{
|
||||
MFXDispReleaseImplDescription(loader, idesc);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@endcode
|
||||
@param[in] loader Loader handle.
|
||||
@param[in] i Index of the implementation.
|
||||
@param[out] session Pointer to the session handle.
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. The session contains a pointer to the session handle.\n
|
||||
MFX_ERR_NULL_PTR If loader is NULL. \n
|
||||
MFX_ERR_NULL_PTR If session is NULL. \n
|
||||
MFX_ERR_NOT_FOUND Provided index is out of possible range.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
mfxStatus MFX_CDECL MFXCreateSession(mfxLoader loader, mfxU32 i, mfxSession* session);
|
||||
|
||||
/*!
|
||||
@brief
|
||||
Destroys handle allocated by the MFXEnumImplementations function.
|
||||
|
||||
@param[in] loader Loader handle.
|
||||
@param[in] hdl Handle to destroy. Can be equal to NULL.
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE The function completed successfully. \n
|
||||
MFX_ERR_NULL_PTR If loader is NULL. \n
|
||||
MFX_ERR_INVALID_HANDLE Provided hdl handle is not associated with this loader.
|
||||
|
||||
@since This function is available since API version 2.0.
|
||||
*/
|
||||
mfxStatus MFX_CDECL MFXDispReleaseImplDescription(mfxLoader loader, mfxHDL hdl);
|
||||
|
||||
/*!
|
||||
@brief
|
||||
Macro help to return UUID in the common oneAPI format.
|
||||
|
||||
@param[in] devinfo Handle to mfxExtendedDeviceId.
|
||||
@param[in] sub_dev_num SubDevice number. Can be obtained from mfxDeviceDescription::SubDevices::Index. Set to zero if no SubDevices.
|
||||
@param[out] uuid Pointer to UUID.
|
||||
|
||||
*/
|
||||
#define MFX_UUID_COMPUTE_DEVICE_ID(devinfo, sub_dev_num, uuid) \
|
||||
{ \
|
||||
extDeviceUUID t_uuid = { 0 }; \
|
||||
extDeviceUUID* shared_uuid = (extDeviceUUID*)(uuid); \
|
||||
t_uuid.vendor_id = (devinfo)->VendorID; \
|
||||
t_uuid.device_id = (devinfo)->DeviceID; \
|
||||
t_uuid.revision_id = (devinfo)->RevisionID; \
|
||||
t_uuid.pci_domain = (devinfo)->PCIDomain; \
|
||||
t_uuid.pci_bus = (mfxU8)(devinfo)->PCIBus; \
|
||||
t_uuid.pci_dev = (mfxU8)(devinfo)->PCIDevice; \
|
||||
t_uuid.pci_func = (mfxU8)(devinfo)->PCIFunction; \
|
||||
t_uuid.sub_device_id = (mfxU8)(sub_dev_num); \
|
||||
*shared_uuid = t_uuid; \
|
||||
}
|
||||
|
||||
/* Helper macro definitions to add config filter properties. */
|
||||
|
||||
/*! Adds single property of mfxU32 type.
|
||||
@param[in] loader Valid mfxLoader object
|
||||
@param[in] name Property name string
|
||||
@param[in] value Property value
|
||||
*/
|
||||
#define MFX_ADD_PROPERTY_U32(loader, name, value) \
|
||||
{ \
|
||||
mfxVariant impl_value; \
|
||||
mfxConfig cfg = MFXCreateConfig(loader); \
|
||||
impl_value.Version.Version = MFX_VARIANT_VERSION; \
|
||||
impl_value.Type = MFX_VARIANT_TYPE_U32; \
|
||||
impl_value.Data.U32 = value; \
|
||||
MFXSetConfigFilterProperty(cfg, (mfxU8 *)name, impl_value); \
|
||||
}
|
||||
|
||||
/*! Adds single property of mfxU16 type.
|
||||
@param[in] loader Valid mfxLoader object
|
||||
@param[in] name Property name string
|
||||
@param[in] value Property value
|
||||
*/
|
||||
#define MFX_ADD_PROPERTY_U16(loader, name, value) \
|
||||
{ \
|
||||
mfxVariant impl_value = { 0 }; \
|
||||
mfxConfig cfg = MFXCreateConfig(loader); \
|
||||
impl_value.Version.Version = MFX_VARIANT_VERSION; \
|
||||
impl_value.Type = MFX_VARIANT_TYPE_U16; \
|
||||
impl_value.Data.U16 = value; \
|
||||
MFXSetConfigFilterProperty(cfg, (mfxU8 *)name, impl_value); \
|
||||
}
|
||||
|
||||
/*! Adds single property of pointer type.
|
||||
@param[in] loader Valid mfxLoader object
|
||||
@param[in] name Property name string
|
||||
@param[in] value Property value
|
||||
*/
|
||||
#define MFX_ADD_PROPERTY_PTR(loader, name, value) \
|
||||
{ \
|
||||
mfxVariant impl_value = { 0 }; \
|
||||
mfxConfig cfg = MFXCreateConfig(loader); \
|
||||
impl_value.Version.Version = MFX_VARIANT_VERSION; \
|
||||
impl_value.Type = MFX_VARIANT_TYPE_PTR; \
|
||||
impl_value.Data.Ptr = (mfxHDL)value; \
|
||||
MFXSetConfigFilterProperty(cfg, (mfxU8 *)name, impl_value); \
|
||||
}
|
||||
|
||||
/*! Update existing property of mfxU32 type.
|
||||
@param[in] loader Valid mfxLoader object
|
||||
@param[in] config Valid mfxConfig object
|
||||
@param[in] name Property name string
|
||||
@param[in] value Property value
|
||||
*/
|
||||
#define MFX_UPDATE_PROPERTY_U32(loader, config, name, value) \
|
||||
{ \
|
||||
mfxVariant impl_value; \
|
||||
impl_value.Version.Version = MFX_VARIANT_VERSION; \
|
||||
impl_value.Type = MFX_VARIANT_TYPE_U32; \
|
||||
impl_value.Data.U32 = value; \
|
||||
MFXSetConfigFilterProperty(config, (mfxU8 *)name, impl_value); \
|
||||
}
|
||||
|
||||
/*! Update existing property of mfxU16 type.
|
||||
@param[in] loader Valid mfxLoader object
|
||||
@param[in] config Valid mfxConfig object
|
||||
@param[in] name Property name string
|
||||
@param[in] value Property value
|
||||
*/
|
||||
#define MFX_UPDATE_PROPERTY_U16(loader, config, name, value) \
|
||||
{ \
|
||||
mfxVariant impl_value; \
|
||||
impl_value.Version.Version = MFX_VARIANT_VERSION; \
|
||||
impl_value.Type = MFX_VARIANT_TYPE_U16; \
|
||||
impl_value.Data.U16 = value; \
|
||||
MFXSetConfigFilterProperty(config, (mfxU8 *)name, impl_value); \
|
||||
}
|
||||
|
||||
/*! Update existing property of pointer type.
|
||||
@param[in] loader Valid mfxLoader object
|
||||
@param[in] config Valid mfxConfig object
|
||||
@param[in] name Property name string
|
||||
@param[in] value Property value
|
||||
*/
|
||||
#define MFX_UPDATE_PROPERTY_PTR(loader, config, name, value) \
|
||||
{ \
|
||||
mfxVariant impl_value; \
|
||||
impl_value.Version.Version = MFX_VARIANT_VERSION; \
|
||||
impl_value.Type = MFX_VARIANT_TYPE_PTR; \
|
||||
impl_value.Data.Ptr = (mfxHDL)value; \
|
||||
MFXSetConfigFilterProperty(config, (mfxU8 *)name, impl_value); \
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifndef __MFXDISPATCHERPREFIXEDFUNCTIONS_H__
|
||||
#define __MFXDISPATCHERPREFIXEDFUNCTIONS_H__
|
||||
|
||||
// API 1.0 functions
|
||||
#define MFXInit disp_MFXInit
|
||||
#define MFXClose disp_MFXClose
|
||||
#define MFXQueryIMPL disp_MFXQueryIMPL
|
||||
#define MFXQueryVersion disp_MFXQueryVersion
|
||||
|
||||
#define MFXJoinSession disp_MFXJoinSession
|
||||
#define MFXDisjoinSession disp_MFXDisjoinSession
|
||||
#define MFXCloneSession disp_MFXCloneSession
|
||||
#define MFXSetPriority disp_MFXSetPriority
|
||||
#define MFXGetPriority disp_MFXGetPriority
|
||||
|
||||
#define MFXVideoCORE_SetFrameAllocator disp_MFXVideoCORE_SetFrameAllocator
|
||||
#define MFXVideoCORE_SetHandle disp_MFXVideoCORE_SetHandle
|
||||
#define MFXVideoCORE_GetHandle disp_MFXVideoCORE_GetHandle
|
||||
#define MFXVideoCORE_SyncOperation disp_MFXVideoCORE_SyncOperation
|
||||
|
||||
#define MFXVideoENCODE_Query disp_MFXVideoENCODE_Query
|
||||
#define MFXVideoENCODE_QueryIOSurf disp_MFXVideoENCODE_QueryIOSurf
|
||||
#define MFXVideoENCODE_Init disp_MFXVideoENCODE_Init
|
||||
#define MFXVideoENCODE_Reset disp_MFXVideoENCODE_Reset
|
||||
#define MFXVideoENCODE_Close disp_MFXVideoENCODE_Close
|
||||
#define MFXVideoENCODE_GetVideoParam disp_MFXVideoENCODE_GetVideoParam
|
||||
#define MFXVideoENCODE_GetEncodeStat disp_MFXVideoENCODE_GetEncodeStat
|
||||
#define MFXVideoENCODE_EncodeFrameAsync disp_MFXVideoENCODE_EncodeFrameAsync
|
||||
|
||||
#define MFXVideoDECODE_Query disp_MFXVideoDECODE_Query
|
||||
#define MFXVideoDECODE_DecodeHeader disp_MFXVideoDECODE_DecodeHeader
|
||||
#define MFXVideoDECODE_QueryIOSurf disp_MFXVideoDECODE_QueryIOSurf
|
||||
#define MFXVideoDECODE_Init disp_MFXVideoDECODE_Init
|
||||
#define MFXVideoDECODE_Reset disp_MFXVideoDECODE_Reset
|
||||
#define MFXVideoDECODE_Close disp_MFXVideoDECODE_Close
|
||||
#define MFXVideoDECODE_GetVideoParam disp_MFXVideoDECODE_GetVideoParam
|
||||
#define MFXVideoDECODE_GetDecodeStat disp_MFXVideoDECODE_GetDecodeStat
|
||||
#define MFXVideoDECODE_SetSkipMode disp_MFXVideoDECODE_SetSkipMode
|
||||
#define MFXVideoDECODE_GetPayload disp_MFXVideoDECODE_GetPayload
|
||||
#define MFXVideoDECODE_DecodeFrameAsync disp_MFXVideoDECODE_DecodeFrameAsync
|
||||
|
||||
#define MFXVideoVPP_Query disp_MFXVideoVPP_Query
|
||||
#define MFXVideoVPP_QueryIOSurf disp_MFXVideoVPP_QueryIOSurf
|
||||
#define MFXVideoVPP_Init disp_MFXVideoVPP_Init
|
||||
#define MFXVideoVPP_Reset disp_MFXVideoVPP_Reset
|
||||
#define MFXVideoVPP_Close disp_MFXVideoVPP_Close
|
||||
|
||||
#define MFXVideoVPP_GetVideoParam disp_MFXVideoVPP_GetVideoParam
|
||||
#define MFXVideoVPP_GetVPPStat disp_MFXVideoVPP_GetVPPStat
|
||||
#define MFXVideoVPP_RunFrameVPPAsync disp_MFXVideoVPP_RunFrameVPPAsync
|
||||
|
||||
// API 1.1 functions
|
||||
#define MFXVideoUSER_Register disp_MFXVideoUSER_Register
|
||||
#define MFXVideoUSER_Unregister disp_MFXVideoUSER_Unregister
|
||||
#define MFXVideoUSER_ProcessFrameAsync disp_MFXVideoUSER_ProcessFrameAsync
|
||||
|
||||
// API 1.10 functions
|
||||
|
||||
#define MFXVideoENC_Query disp_MFXVideoENC_Query
|
||||
#define MFXVideoENC_QueryIOSurf disp_MFXVideoENC_QueryIOSurf
|
||||
#define MFXVideoENC_Init disp_MFXVideoENC_Init
|
||||
#define MFXVideoENC_Reset disp_MFXVideoENC_Reset
|
||||
#define MFXVideoENC_Close disp_MFXVideoENC_Close
|
||||
#define MFXVideoENC_ProcessFrameAsync disp_MFXVideoENC_ProcessFrameAsync
|
||||
#define MFXVideoVPP_RunFrameVPPAsyncEx disp_MFXVideoVPP_RunFrameVPPAsyncEx
|
||||
#define MFXVideoUSER_Load disp_MFXVideoUSER_Load
|
||||
#define MFXVideoUSER_UnLoad disp_MFXVideoUSER_UnLoad
|
||||
|
||||
// API 1.11 functions
|
||||
|
||||
#define MFXVideoPAK_Query disp_MFXVideoPAK_Query
|
||||
#define MFXVideoPAK_QueryIOSurf disp_MFXVideoPAK_QueryIOSurf
|
||||
#define MFXVideoPAK_Init disp_MFXVideoPAK_Init
|
||||
#define MFXVideoPAK_Reset disp_MFXVideoPAK_Reset
|
||||
#define MFXVideoPAK_Close disp_MFXVideoPAK_Close
|
||||
#define MFXVideoPAK_ProcessFrameAsync disp_MFXVideoPAK_ProcessFrameAsync
|
||||
|
||||
// API 1.13 functions
|
||||
|
||||
#define MFXVideoUSER_LoadByPath disp_MFXVideoUSER_LoadByPath
|
||||
|
||||
// API 1.14 functions
|
||||
#define MFXInitEx disp_MFXInitEx
|
||||
|
||||
// Audio library functions
|
||||
|
||||
// API 1.8 functions
|
||||
|
||||
#define MFXAudioCORE_SyncOperation disp_MFXAudioCORE_SyncOperation
|
||||
#define MFXAudioENCODE_Query disp_MFXAudioENCODE_Query
|
||||
#define MFXAudioENCODE_QueryIOSize disp_MFXAudioENCODE_QueryIOSize
|
||||
#define MFXAudioENCODE_Init disp_MFXAudioENCODE_Init
|
||||
#define MFXAudioENCODE_Reset disp_MFXAudioENCODE_Reset
|
||||
#define MFXAudioENCODE_Close disp_MFXAudioENCODE_Close
|
||||
#define MFXAudioENCODE_GetAudioParam disp_MFXAudioENCODE_GetAudioParam
|
||||
#define MFXAudioENCODE_EncodeFrameAsync disp_MFXAudioENCODE_EncodeFrameAsync
|
||||
|
||||
#define MFXAudioDECODE_Query disp_MFXAudioDECODE_Query
|
||||
#define MFXAudioDECODE_DecodeHeader disp_MFXAudioDECODE_DecodeHeader
|
||||
#define MFXAudioDECODE_Init disp_MFXAudioDECODE_Init
|
||||
#define MFXAudioDECODE_Reset disp_MFXAudioDECODE_Reset
|
||||
#define MFXAudioDECODE_Close disp_MFXAudioDECODE_Close
|
||||
#define MFXAudioDECODE_QueryIOSize disp_MFXAudioDECODE_QueryIOSize
|
||||
#define MFXAudioDECODE_GetAudioParam disp_MFXAudioDECODE_GetAudioParam
|
||||
#define MFXAudioDECODE_DecodeFrameAsync disp_MFXAudioDECODE_DecodeFrameAsync
|
||||
|
||||
// API 1.9 functions
|
||||
|
||||
#define MFXAudioUSER_Register disp_MFXAudioUSER_Register
|
||||
#define MFXAudioUSER_Unregister disp_MFXAudioUSER_Unregister
|
||||
#define MFXAudioUSER_ProcessFrameAsync disp_MFXAudioUSER_ProcessFrameAsync
|
||||
#define MFXAudioUSER_Load disp_MFXAudioUSER_Load
|
||||
#define MFXAudioUSER_UnLoad disp_MFXAudioUSER_UnLoad
|
||||
|
||||
// API 1.19 functions
|
||||
|
||||
#define MFXVideoENC_GetVideoParam disp_MFXVideoENC_GetVideoParam
|
||||
#define MFXVideoPAK_GetVideoParam disp_MFXVideoPAK_GetVideoParam
|
||||
#define MFXVideoCORE_QueryPlatform disp_MFXVideoCORE_QueryPlatform
|
||||
#define MFXVideoUSER_GetPlugin disp_MFXVideoUSER_GetPlugin
|
||||
|
||||
// API 2.0 functions
|
||||
#define MFXMemory_GetSurfaceForVPP disp_MFXMemory_GetSurfaceForVPP
|
||||
#define MFXMemory_GetSurfaceForEncode disp_MFXMemory_GetSurfaceForEncode
|
||||
#define MFXMemory_GetSurfaceForDecode disp_MFXMemory_GetSurfaceForDecode
|
||||
|
||||
#define MFXQueryImplsDescription disp_MFXQueryImplsDescription
|
||||
#define MFXReleaseImplDescription disp_MFXReleaseImplDescription
|
||||
|
||||
#define MFXInitialize disp_MFXInitialize
|
||||
|
||||
// API 2.1 functions
|
||||
#define MFXMemory_GetSurfaceForVPPOut disp_MFXMemory_GetSurfaceForVPPOut
|
||||
|
||||
#define MFXVideoDECODE_VPP_Init disp_MFXVideoDECODE_VPP_Init
|
||||
#define MFXVideoDECODE_VPP_DecodeFrameAsync disp_MFXVideoDECODE_VPP_DecodeFrameAsync
|
||||
#define MFXVideoDECODE_VPP_Reset disp_MFXVideoDECODE_VPP_Reset
|
||||
#define MFXVideoDECODE_VPP_GetChannelParam disp_MFXVideoDECODE_VPP_GetChannelParam
|
||||
#define MFXVideoDECODE_VPP_Close disp_MFXVideoDECODE_VPP_Close
|
||||
|
||||
#define MFXVideoVPP_ProcessFrameAsync disp_MFXVideoVPP_ProcessFrameAsync
|
||||
|
||||
// API 2.15 functions
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
#define MFXQueryImplsProperties disp_MFXQueryImplsProperties
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,458 @@
|
||||
/*############################################################################
|
||||
# Copyright Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
############################################################################*/
|
||||
|
||||
#ifdef ONEVPL_EXPERIMENTAL
|
||||
|
||||
|
||||
#ifndef __MFXENCODESTATS_H__
|
||||
#define __MFXENCODESTATS_H__
|
||||
#include "mfxcommon.h"
|
||||
#include "mfxstructures.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif /* __cplusplus */
|
||||
|
||||
/*!< The enum to specify memory layout for statistics. */
|
||||
typedef enum {
|
||||
MFX_ENCODESTATS_MEMORY_LAYOUT_DEFAULT = 0, /*!< The default memory layout for statistics. */
|
||||
} mfxEncodeBlkStatsMemLayout;
|
||||
|
||||
/*!< The enum to specify mode to gather statistics. */
|
||||
typedef enum {
|
||||
MFX_ENCODESTATS_MODE_DEFAULT = 0, /*!< Encode mode is selected by the implementation. */
|
||||
MFX_ENCODESTATS_MODE_ENCODE = 1, /*!< Full encode mode. */
|
||||
} mfxEncodeStatsMode;
|
||||
|
||||
/*!< Flags to specify what statistics will be reported by the implementation. */
|
||||
enum {
|
||||
MFX_ENCODESTATS_LEVEL_BLK = 0x1, /*!< Block level statistics. */
|
||||
MFX_ENCODESTATS_LEVEL_SLICE = 0x2, /*!< Slice level statistics. */
|
||||
MFX_ENCODESTATS_LEVEL_TILE = 0x4, /*!< Tile level statistics. */
|
||||
MFX_ENCODESTATS_LEVEL_FRAME = 0x8, /*!< Frame level statistics. */
|
||||
};
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!< Specifies H.265 CTU header. */
|
||||
typedef struct {
|
||||
union {
|
||||
struct {
|
||||
mfxU32 CUcountminus1 : 6; /*!< Number of CU per CTU. */
|
||||
mfxU32 MaxDepth : 2; /*!< Max quad-tree depth of CU in CTU. */
|
||||
mfxU32 reserved : 24;
|
||||
} bitfields0;
|
||||
mfxU32 dword0;
|
||||
};
|
||||
mfxU16 CurrXAddr; /*!< Horizontal address of CTU. */
|
||||
mfxU16 CurrYAddr; /*!< Vertical address of CTU. */
|
||||
mfxU32 reserved1;
|
||||
} mfxCTUHeader;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!< Specifies H.265 CU info. */
|
||||
typedef struct {
|
||||
union {
|
||||
struct {
|
||||
mfxU32 CU_Size : 2; /*!< indicates the CU size of the current CU. 0: 8x8 1: 16x16 2: 32x32 3: 64x64 */
|
||||
mfxU32 CU_pred_mode : 1; /*!< indicates the prediction mode for the current CU. 0: intra 1: inter */
|
||||
/*!
|
||||
indicates the PU partition mode for the current CU.
|
||||
0: 2Nx2N
|
||||
1: 2NxN (inter)
|
||||
2: Nx2N (inter)
|
||||
3: NXN (intra only, CU Size=8x8 only. Luma Intra Mode indicates the intra prediction mode for 4x4_0. The additional prediction modes are overloaded on 4x4_1, 4x4_2, 4x4_3 below)
|
||||
4: 2NxnT (inter only)
|
||||
5: 2NxnB (inter only)
|
||||
6: nLx2N (inter only)
|
||||
7: nRx2N (inter only).
|
||||
*/
|
||||
mfxU32 CU_part_mode : 3;
|
||||
mfxU32 InterPred_IDC_MV0 : 2; /*!< indicates the prediction direction for PU0 of the current CU. 0: L0 1: L1 2: Bi 3: reserved */
|
||||
mfxU32 InterPred_IDC_MV1 : 2; /*!< indicates the prediction direction for PU1 of the current CU. 0: L0 1: L1 2: Bi 3: reserved */
|
||||
/*!
|
||||
Final explicit Luma Intra Mode 4x4_0 for NxN.
|
||||
Valid values 0..34
|
||||
Note: CU_part_mode==NxN.
|
||||
*/
|
||||
mfxU32 LumaIntraMode : 6;
|
||||
/*!
|
||||
indicates the final explicit Luma Intra Mode for the CU.
|
||||
0: DM (use Luma mode, from block 0 if NxN)
|
||||
1: reserved
|
||||
2: Planar
|
||||
3: Vertical
|
||||
4: Horizontal
|
||||
5: DC */
|
||||
mfxU32 ChromaIntraMode : 3;
|
||||
mfxU32 reserved : 13;
|
||||
} bitfields0;
|
||||
mfxU32 dword0;
|
||||
};
|
||||
|
||||
union {
|
||||
struct {
|
||||
/*!
|
||||
Final explicit Luma Intra Mode 4x4_1.
|
||||
Valid values 0..34
|
||||
Note: CU_part_mode==NxN.
|
||||
*/
|
||||
mfxU32 LumaIntraMode4x4_1 : 6;
|
||||
/*!
|
||||
Final explicit Luma Intra Mode 4x4_2.
|
||||
Valid values 0..34
|
||||
Note: CU_part_mode==NxN.
|
||||
*/
|
||||
mfxU32 LumaIntraMode4x4_2 : 6;
|
||||
/*!
|
||||
Final explicit Luma Intra Mode 4x4_3.
|
||||
Valid values 0..34
|
||||
Note: CU_part_mode==NxN.
|
||||
*/
|
||||
mfxU32 LumaIntraMode4x4_3 : 6;
|
||||
mfxU32 reserved1 : 14;
|
||||
} bitfields1;
|
||||
mfxU32 dword1;
|
||||
};
|
||||
|
||||
mfxI8 QP; // signed QP value
|
||||
mfxU8 reserved2[3];
|
||||
/*! distortion measure, approximation to SAD.
|
||||
Will deviate significantly (pre, post reconstruction) and due to variation in algorithm.
|
||||
*/
|
||||
mfxU32 SAD;
|
||||
|
||||
/*!
|
||||
These parameters indicate motion vectors that are associated with the PU0/PU1 winners
|
||||
range [-2048.00..2047.75].
|
||||
L0/PU0 - MV[0][0]
|
||||
L0/PU1 - MV[0][1]
|
||||
L1/PU0 - MV[1][0]
|
||||
L1/PU1 - MV[1][1]
|
||||
*/
|
||||
mfxI16Pair MV[2][2];
|
||||
|
||||
union {
|
||||
struct {
|
||||
/*!
|
||||
This parameter indicates the reference index associated with the MV X/Y
|
||||
that is populated in the L0_MV0.X and L0_MV0.Y fields. */
|
||||
mfxU32 L0_MV0_RefID : 4;
|
||||
/*!
|
||||
This parameter indicates the reference index associated with the MV X/Y
|
||||
that is populated in the L0_MV1.X and L0_MV1.Y fields. */
|
||||
mfxU32 L0_MV1_RefID : 4;
|
||||
/*!
|
||||
This parameter indicates the reference index associated with the MV X/Y
|
||||
that is populated in the L1_MV0.X and L1_MV0.Y fields. */
|
||||
mfxU32 L1_MV0_RefID : 4;
|
||||
/*!
|
||||
This parameter indicates the reference index associated with the MV X/Y
|
||||
that is populated in the L1_MV1.X and L1_MV1.Y fields. */
|
||||
mfxU32 L1_MV1_RefID : 4;
|
||||
|
||||
mfxU32 reserved3 : 16;
|
||||
} bitfields8;
|
||||
mfxU32 dword8;
|
||||
};
|
||||
mfxU32 reserved4[10];
|
||||
} mfxCUInfo;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!< Specifies H.265 CTU. */
|
||||
typedef struct {
|
||||
mfxCTUHeader CtuHeader; /*!< H.265 CTU header. */
|
||||
mfxCUInfo CuInfo[64]; /*!< Array of CU. */
|
||||
mfxU32 reserved;
|
||||
} mfxCTUInfo;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_USUAL_STRUCT()
|
||||
/*!
|
||||
The structure describes H.264 stats per MB.
|
||||
*/
|
||||
typedef struct {
|
||||
union {
|
||||
struct {
|
||||
/*!
|
||||
Together with @p IntraMbFlag this parameter specifies macroblock type according to the
|
||||
ISO\*\/IEC\* 14496-10 with the following difference - it stores either intra or inter
|
||||
values according to @p IntraMbFlag, but not intra after inter.
|
||||
Values for P-slices are mapped to B-slice values. For example P_16x8 is coded with
|
||||
B_FWD_16x8 value.
|
||||
*/
|
||||
mfxU32 MBType : 5;
|
||||
/*!
|
||||
This field specifies inter macroblock mode and is ignored for intra MB. It is derived from @p MbType and has next values:
|
||||
@li 0 - 16x16 mode
|
||||
@li 1 - 16x8 mode
|
||||
@li 2 - 8x16 mode
|
||||
@li 3 - 8x8 mode
|
||||
*/
|
||||
mfxU32 InterMBMode : 2;
|
||||
/*!
|
||||
This field specifies intra macroblock mode and is ignored for inter MB. It is derived from @p MbType and has next values:
|
||||
@li 0 - 16x16 mode
|
||||
@li 1 - 8x8 mode
|
||||
@li 2 - 4x4 mode
|
||||
@li 3 - PCM
|
||||
*/
|
||||
mfxU32 IntraMBMode : 2;
|
||||
/*!
|
||||
This flag specifies intra/inter MB type and has next values:
|
||||
0 - Inter prediction MB type
|
||||
1 - Intra prediction MB type
|
||||
*/
|
||||
mfxU32 IntraMBFlag : 1;
|
||||
/*!
|
||||
This field specifies subblock shapes for the current MB. Each block is described by 2 bits starting from lower bits for block 0.
|
||||
|
||||
@li 0 - 8x8
|
||||
@li 1 - 8x4
|
||||
@li 2 - 4x8
|
||||
@li 3 - 4x4
|
||||
*/
|
||||
mfxU32 SubMBShapes : 8;
|
||||
/*!
|
||||
This field specifies prediction modes for the current MB partition blocks. Each block is described by 2 bits starting from lower bits for block 0.
|
||||
@li 0 - Pred_L0
|
||||
@li 1 - Pred_L1
|
||||
@li 2 - BiPred
|
||||
@li 3 - reserved
|
||||
|
||||
Only one prediction value for partition is reported, the rest values are set to zero. For example:
|
||||
@li 16x16 Pred_L1 - 0x01 (only 2 lower bits are used)
|
||||
@li 16x8 Pred_L1 / BiPred - 0x09 (1001b)
|
||||
@li 8x16 BiPred / BiPred - 0x0a (1010b)
|
||||
|
||||
For P MBs this value is always zero.
|
||||
*/
|
||||
mfxU32 SubMBShapeMode : 8;
|
||||
/*!
|
||||
This value specifies chroma intra prediction mode.
|
||||
@li 0 - DC
|
||||
@li 1 - Horizontal
|
||||
@li 2 - Vertical
|
||||
@li 3 - Plane
|
||||
*/
|
||||
mfxU32 ChromaIntraPredMode : 2;
|
||||
mfxU32 reserved : 4;
|
||||
} bitfields0;
|
||||
mfxU32 dword0;
|
||||
} ;
|
||||
/*!
|
||||
Distortion measure, approximation to SAD.
|
||||
Deviate significantly (pre, post reconstruction) and due to variation in algorithm.
|
||||
*/
|
||||
mfxU32 SAD;
|
||||
mfxI8 Qp; /*!< MB QP. */
|
||||
mfxU8 reserved1[3];
|
||||
|
||||
/*!
|
||||
These values specify luma intra prediction modes for current MB. Each element of the array
|
||||
corresponds to 8x8 block and each holds prediction modes for four 4x4 subblocks.
|
||||
Four bits per mode, lowest bits for left top subblock.
|
||||
All 16 prediction modes are always specified. For 8x8 case, block prediction mode is
|
||||
populated to all subblocks of the 8x8 block. For 16x16 case - to all subblocks of the MB.
|
||||
|
||||
Prediction directions for 4x4 and 8x8 blocks:
|
||||
@li 0 - Vertical
|
||||
@li 1 - Horizontal
|
||||
@li 2 - DC
|
||||
@li 3 - Diagonal Down Left
|
||||
@li 4 - Diagonal Down Right
|
||||
@li 5 - Vertical Right
|
||||
@li 6 - Horizontal Down
|
||||
@li 7 - Vertical Left
|
||||
@li 8 - Horizontal Up
|
||||
|
||||
Prediction directions for 16x16 blocks:
|
||||
@li 0 - Vertical
|
||||
@li 1 - Horizontal
|
||||
@li 2 - DC
|
||||
@li 3 - Plane
|
||||
*/
|
||||
mfxU16 LumaIntraMode[4];
|
||||
|
||||
mfxU32 reserved2;
|
||||
} mfxMBInfo;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*!
|
||||
The enum specifies block size.
|
||||
*/
|
||||
typedef enum {
|
||||
MFX_BLOCK_4X4 = 0, /*!< 4x4 block size. */
|
||||
MFX_BLOCK_16X16 = 1, /*!< 16x16 block size. */
|
||||
} mfxBlockSize;
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
The structure describes H.264 and H.265 stats per MB or CTUs.
|
||||
*/
|
||||
typedef struct {
|
||||
union {
|
||||
mfxU32 NumMB; /*!< Number of MBs per frame for H.264. */
|
||||
mfxU32 NumCTU; /*!< number of CTUs per frame for H.265. */
|
||||
};
|
||||
union {
|
||||
mfxCTUInfo *HEVCCTUArray; /*!< Array of CTU statistics. */
|
||||
mfxMBInfo *AVCMBArray; /*!< Array of MB statistics. */
|
||||
};
|
||||
mfxU32 reserved[8];
|
||||
|
||||
} mfxEncodeBlkStats;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_L_TYPE()
|
||||
/*!
|
||||
The structure describes H.264/H.265 frame/slice/tile level statistics.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxF32 PSNRLuma; /*!< PSNR for LUMA samples. */
|
||||
mfxF32 PSNRCb; /*!< PSNR for Chroma (Cb) samples. */
|
||||
mfxF32 PSNRCr; /*!< PSNR for Chroma (Cr) samples. */
|
||||
/*! distortion measure, approximation to SAD.
|
||||
Will deviate significantly (pre, post reconstruction) and due to variation in algorithm.
|
||||
*/
|
||||
mfxU64 SADLuma;
|
||||
mfxF32 Qp; /*!< average frame QP, may have fractional part in case of MBQP. */
|
||||
|
||||
union {
|
||||
mfxU32 NumMB; /*!< Number of MBs per frame for H.264. */
|
||||
mfxU32 NumCTU; /*!< number of CTUs per frame for H.265. */
|
||||
};
|
||||
|
||||
mfxBlockSize BlockSize; /*! For H.264 it is always 16x16 corresponding to MB size.
|
||||
In H.265 it's normalized to 4x4, so for each CU we calculate number of 4x4 which belongs to the block. */
|
||||
|
||||
mfxU32 NumIntraBlock; /*! Number of intra blocks in the frame. The size of block is defined by BlockSize.
|
||||
For H.265 it can be more than number of intra CU. */
|
||||
mfxU32 NumInterBlock; /*! Number of inter blocks in the frame. The size of block is defined by BlockSize.
|
||||
For H.265 it can be more than number of inter CU. */
|
||||
mfxU32 NumSkippedBlock; /*! Number of skipped blocks in the frame. The size of block is defined by BlockSize.
|
||||
For H.265 it can be more than number of skipped CU. */
|
||||
|
||||
mfxU32 reserved[8];
|
||||
|
||||
} mfxEncodeHighLevelStats;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*!
|
||||
Alias for the structure to describe H.264 and H.265 frame level stats.
|
||||
*/
|
||||
typedef mfxEncodeHighLevelStats mfxEncodeFrameStats;
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*!
|
||||
The structure describes H.264 and H.265 stats per Slice or Tile.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxU32 NumElements; /*!< Number of Slices or Tiles per frame for H.264/H.265. */
|
||||
mfxEncodeHighLevelStats *HighLevelStatsArray; /*!< Array of CTU statistics. */
|
||||
mfxU32 reserved[8];
|
||||
|
||||
} mfxEncodeSliceStats;
|
||||
MFX_PACK_END()
|
||||
|
||||
/*!
|
||||
Alias for the structure to describe H.264 and H.265 tile level stats.
|
||||
*/
|
||||
typedef mfxEncodeSliceStats mfxEncodeTileStats;
|
||||
|
||||
|
||||
#define MFX_ENCODESTATSCONTAINER_VERSION MFX_STRUCT_VERSION(1, 0)
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The structure represents reference counted container for output after encoding operation which includes statistics
|
||||
and synchronization primitive for compressed bitstream.
|
||||
The memory is allocated and released by the library.
|
||||
*/
|
||||
typedef struct mfxEncodeStatsContainer {
|
||||
mfxStructVersion Version; /*!< The version of the structure. */
|
||||
mfxRefInterface RefInterface; /*! < Reference counting interface. */
|
||||
/*! @brief
|
||||
Guarantees readiness of the statistics after a function completes.
|
||||
Instead of MFXVideoCORE_SyncOperation which leads to the synchronization of all output objects,
|
||||
users may directly call the mfxEncodeStatsContainer::SynchronizeStatistics function to get output statistics.
|
||||
|
||||
|
||||
@param[in] ref_interface Valid interface.
|
||||
@param[out] wait Wait time in milliseconds.
|
||||
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE If no error. \n
|
||||
MFX_ERR_NULL_PTR If interface is NULL. \n
|
||||
MFX_ERR_INVALID_HANDLE If any of container is not valid object . \n
|
||||
MFX_WRN_IN_EXECUTION If the given timeout is expired and the container is not ready. \n
|
||||
MFX_ERR_ABORTED If the specified asynchronous function aborted due to data dependency on a previous asynchronous function that did not complete. \n
|
||||
MFX_ERR_UNKNOWN Any internal error.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *SynchronizeStatistics)(mfxRefInterface* ref_interface, mfxU32 wait);
|
||||
/*! @brief
|
||||
Guarantees readiness of associated compressed bitstream after a function completes.
|
||||
Instead of MFXVideoCORE_SyncOperation which leads to the synchronization of all output objects,
|
||||
users may directly call the mfxEncodeStatsContainer::SynchronizeStatistics function to get output bitstream.
|
||||
|
||||
|
||||
@param[in] ref_interface Valid interface.
|
||||
@param[out] wait Wait time in milliseconds.
|
||||
|
||||
|
||||
@return
|
||||
MFX_ERR_NONE If no error. \n
|
||||
MFX_ERR_NULL_PTR If interface is NULL. \n
|
||||
MFX_ERR_INVALID_HANDLE If any of container is not valid object . \n
|
||||
MFX_WRN_IN_EXECUTION If the given timeout is expired and the container is not ready. \n
|
||||
MFX_ERR_ABORTED If the specified asynchronous function aborted due to data dependency on a previous asynchronous function that did not complete. \n
|
||||
MFX_ERR_UNKNOWN Any internal error.
|
||||
*/
|
||||
mfxStatus (MFX_CDECL *SynchronizeBitstream)(mfxRefInterface* ref_interface, mfxU32 wait);
|
||||
mfxHDL reserved[4];
|
||||
mfxU32 reserved1[2];
|
||||
mfxU32 DisplayOrder; /*!< To which frame number statistics belong. */
|
||||
mfxEncodeBlkStatsMemLayout MemLayout; /*!< Memory layout for statistics. */
|
||||
mfxEncodeBlkStats *EncodeBlkStats; /*!< Block level statistics. */
|
||||
mfxEncodeSliceStats *EncodeSliceStats; /*!< Slice level statistics. */
|
||||
mfxEncodeTileStats *EncodeTileStats; /*!< Tile level statistics. */
|
||||
mfxEncodeFrameStats *EncodeFrameStats; /*!< Frame level statistics. */
|
||||
mfxU32 reserved2[8];
|
||||
}mfxEncodeStatsContainer;
|
||||
MFX_PACK_END()
|
||||
|
||||
|
||||
MFX_PACK_BEGIN_STRUCT_W_PTR()
|
||||
/*! The extension buffer which should be attached by application for mfxBitstream buffer before
|
||||
encode operation. As result the encoder will allocate memory for statistics and fill appropriate structures.
|
||||
*/
|
||||
typedef struct {
|
||||
mfxExtBuffer Header; /*!< Extension buffer header. Header.BufferId must be equal to MFX_EXTBUFF_ENCODESTATS_BLK. */
|
||||
mfxU16 EncodeStatsFlags; /*!< What statistics is required: block/slice/tile/frame level or any combinations.
|
||||
In case of slice or tile output statistics for one slice or tile will be available only.*/
|
||||
mfxEncodeStatsMode Mode; /*!< What encoding mode should be used to gather statistics. */
|
||||
mfxEncodeStatsContainer *EncodeStatsContainer; /*!< encode output, filled by the implementation. */
|
||||
mfxU32 reserved[8];
|
||||
} mfxExtEncodeStatsOutput;
|
||||
MFX_PACK_END()
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user