Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a811ee49d7 | |||
| 0c9d3ee33f | |||
| 9da38b8d9c | |||
| df70bf00f9 | |||
| 9e6ab9b94d | |||
| 5977b4b9f1 | |||
| 0a617e7779 | |||
| 75d1322b8a | |||
| 55e7f3fca9 | |||
| bbe4380b41 | |||
| aedee2a4e3 | |||
| c28b10a5b9 | |||
| 78d018ae2f | |||
| 1b58130e68 | |||
| 12d4b025f7 | |||
| 871ebb31ce | |||
| 600693914f | |||
| 4d89dcd3d7 | |||
| 97c5778a36 | |||
| 22b352c1da | |||
| 15233a68cf | |||
| 2a637eaf3f | |||
| 6c976e9dc5 | |||
| c8ee4b9902 | |||
| 1197415216 | |||
| 18a5d93ae3 | |||
| 09849906e9 | |||
| 691c064a37 | |||
| 34519566ba | |||
| 86d9f49473 | |||
| 2064c0780c | |||
| f439b69451 | |||
| 1eef55016d | |||
| 61118cbdd4 | |||
| 22a61e0b48 | |||
| 13b1f36d4a | |||
| 9e6fc6e071 | |||
| 570ff504ad | |||
| e8b64ffe43 | |||
| 5d0e23d6a5 | |||
| ffa63a74f2 | |||
| 716875dd09 | |||
| da0578771e | |||
| 391f8fb9f7 | |||
| ef736cb9d7 | |||
| 4ef90d586d | |||
| 93c8dc4712 | |||
| f012ebbcba | |||
| 27a5d8daac | |||
| f6c6e4e594 | |||
| 0992548de7 | |||
| 94ca4041ca | |||
| 845a97601d | |||
| 85dd2bb077 | |||
| 3d9b329084 | |||
| 9a36ea2132 | |||
| 1de83ba51d | |||
| ccc4b08d45 | |||
| b168790e0a | |||
| 6824c1cc0c | |||
| 85bc5b9a3f | |||
| 6ea036766a | |||
| 3495d189e1 | |||
| c42ce88921 | |||
| 2e3208f75e | |||
| 47587827ec | |||
| e06ab59652 | |||
| f2a58f3a91 | |||
| 87114ab186 | |||
| aaa3dcec32 | |||
| 991d28909b | |||
| 9ea5c2a129 | |||
| 880634b4c1 | |||
| 265554b755 | |||
| cb7091e1d5 | |||
| dd462787ec | |||
| 63efe0ecd5 | |||
| 384f8e00aa | |||
| 46c0e0e483 | |||
| f7ca641d76 | |||
| 2067b5ac81 | |||
| 09600163e2 | |||
| ea23408d1d | |||
| 9bc70e59fc | |||
| 393b47a062 | |||
| 329cf7b5d5 | |||
| 68bcfdac3e | |||
| ff55d0a608 | |||
| ecfa71212d | |||
| 07e2836601 | |||
| 6ac7134e7c | |||
| 6d2e738070 | |||
| 09e2043ce0 | |||
| a513186424 | |||
| df6a5325d8 | |||
| 14c5e7c11c | |||
| 6a0a97b702 | |||
| 4cae1b8bb8 | |||
| 45c29a99d5 | |||
| a738de6cd8 | |||
| b45323c0be | |||
| 1a7e3a6e4f | |||
| 55e59458a2 | |||
| f910d23fb2 | |||
| c95e9125b9 | |||
| c2b9b32904 | |||
| 0899e53903 | |||
| 32ffe7d634 | |||
| 8374dfedf3 | |||
| e62cd5448e | |||
| 4ed5b88407 | |||
| 4ffa2665ac | |||
| 2def3ef49e | |||
| 571e22bc0f | |||
| ce085b8e3b | |||
| 405b005a0d | |||
| 89a08f83af | |||
| 5748706631 | |||
| 2ae5cf98ee | |||
| e61d655b1e | |||
| 7099266594 | |||
| fd8a062e2c | |||
| 3c38a5f0e8 | |||
| 1f519d44f9 | |||
| abecb5226c | |||
| 11045a0f70 | |||
| d466e3e2b2 | |||
| 532b313b8c | |||
| d381cdf7f4 | |||
| f901bedf22 | |||
| 44b71e7460 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -153,9 +153,9 @@ jobs:
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
|
||||
@@ -23,10 +23,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,6 +45,10 @@ 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/**'
|
||||
@@ -89,6 +98,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 +129,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
|
||||
@@ -128,7 +144,7 @@ jobs:
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
|
||||
@@ -38,3 +38,8 @@ CLAUDE.md
|
||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||
.flatpak-repo/
|
||||
.flatpak-build/
|
||||
|
||||
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
|
||||
/result
|
||||
/result-*
|
||||
.direnv/
|
||||
|
||||
Generated
+275
-20
@@ -560,6 +560,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"
|
||||
@@ -1141,6 +1147,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"
|
||||
@@ -1988,6 +2003,19 @@ 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",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -2145,7 +2173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2248,6 +2276,14 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2277,7 +2313,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2386,6 +2422,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"
|
||||
@@ -2754,9 +2800,29 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"libc",
|
||||
"pf-driver-proto",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
"pf-win-display",
|
||||
"pf-zerocopy",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2779,8 +2845,26 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
name = "pf-clipboard"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"futures-util",
|
||||
"image",
|
||||
"libc",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,9 +2883,32 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"ffmpeg-next",
|
||||
"libc",
|
||||
"libloading",
|
||||
"libvpl-sys",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
"pf-zerocopy",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2809,8 +2916,73 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
name = "pf-frame"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
"pf-zerocopy",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"pf-capture",
|
||||
"pf-driver-proto",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
"reis",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"usbip-sim",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2823,6 +2995,62 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"libc",
|
||||
"pf-driver-proto",
|
||||
"pf-encode",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"pf-win-display",
|
||||
"punktfunk-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2895,6 +3123,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"
|
||||
@@ -2994,7 +3235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3010,7 +3251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3026,7 +3267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3041,7 +3282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3060,7 +3301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3091,7 +3332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3103,9 +3344,9 @@ dependencies = [
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"cbc",
|
||||
"ffmpeg-next",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -3116,14 +3357,22 @@ dependencies = [
|
||||
"log",
|
||||
"mac_address",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"parking_lot",
|
||||
"pf-capture",
|
||||
"pf-clipboard",
|
||||
"pf-driver-proto",
|
||||
"pf-encode",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
"pf-inject",
|
||||
"pf-paths",
|
||||
"pf-vdisplay",
|
||||
"pf-win-display",
|
||||
"pf-zerocopy",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
@@ -3165,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3179,24 +3428,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"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.13.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+13
-1
@@ -6,11 +6,23 @@ members = [
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-client-core",
|
||||
"crates/pf-clipboard",
|
||||
"crates/pf-presenter",
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pf-paths",
|
||||
"crates/pf-host-config",
|
||||
"crates/pf-gpu",
|
||||
"crates/pf-zerocopy",
|
||||
"crates/pf-frame",
|
||||
"crates/pf-win-display",
|
||||
"crates/pf-encode",
|
||||
"crates/pf-capture",
|
||||
"crates/pf-inject",
|
||||
"crates/pf-vdisplay",
|
||||
"crates/pyrowave-sys",
|
||||
"crates/libvpl-sys",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
@@ -36,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1005
-4
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
@@ -14,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
|
||||
@@ -135,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),
|
||||
@@ -169,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)
|
||||
@@ -191,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"
|
||||
@@ -249,11 +262,25 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||
val caps = display.hdrCapabilities ?: return false
|
||||
return caps.supportedHdrTypes.any {
|
||||
val types = buildSet {
|
||||
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
||||
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
|
||||
// (Pixel-class panels included), which would make a genuinely HDR display advertise
|
||||
// no-HDR and pin the whole session to 8-bit SDR.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
display.mode.supportedHdrTypes.forEach { add(it) }
|
||||
}
|
||||
// Union the legacy query defensively — the supported one on minSdk 31, and some vendors
|
||||
// populate only this on newer APIs.
|
||||
@Suppress("DEPRECATION")
|
||||
display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) }
|
||||
}
|
||||
// HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it.
|
||||
val supported = types.any {
|
||||
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||
}
|
||||
Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported")
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
@@ -265,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.
|
||||
|
||||
@@ -39,6 +39,7 @@ internal fun StatsOverlay(
|
||||
s: DoubleArray,
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
codecLabel: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
@@ -66,7 +67,7 @@ internal fun StatsOverlay(
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
if (detailed) {
|
||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
if (latValid) {
|
||||
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
||||
@@ -151,14 +152,15 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
|
||||
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||
* Android decoder is always HEVC (`video/hevc`).
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
|
||||
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
|
||||
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
|
||||
*/
|
||||
private fun videoFeedLine(s: DoubleArray): String? {
|
||||
private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
|
||||
if (s.size < 14) return null
|
||||
val bitDepth = s[10].toInt()
|
||||
val primaries = s[11].toInt()
|
||||
@@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? {
|
||||
2 -> "4:2:2"
|
||||
else -> "4:2:0"
|
||||
}
|
||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
val codec = codecLabel.ifEmpty { "HEVC" }
|
||||
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
@@ -99,6 +100,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
// Codec is resolved at the handshake (Welcome) — fixed for the session, so read its
|
||||
// label once up front (before the first snapshot renders the video-feed line).
|
||||
if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle)
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
@@ -366,7 +370,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
codecLabel = "HEVC",
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@ class GamepadFeedback(
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||
// A new host renews far below this, so it never actually holds this long there.
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -95,19 +92,19 @@ class GamepadFeedback(
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
||||
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
||||
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
||||
// long one-shot.
|
||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
durationMs,
|
||||
backstopMs,
|
||||
)
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
@@ -212,12 +209,13 @@ class GamepadFeedback(
|
||||
|
||||
/**
|
||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
||||
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
||||
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
||||
* addressed to wire pad [pad]. `durationMs` is the engine command's backstop — the one-shot's
|
||||
* self-termination net under a stalled poll thread; the engine emits explicit zero commands at
|
||||
* every policy stop (lease expiry, legacy staleness, session close), so cancel-on-zero is the
|
||||
* real stop mechanism.
|
||||
*/
|
||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high backstopMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||
|
||||
@@ -161,6 +161,14 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
|
||||
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
|
||||
* [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it.
|
||||
* Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoCodecLabel(handle: Long): String
|
||||
|
||||
/**
|
||||
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
||||
//! The event-driven async MediaCodec decode loop (default) + its feeder/dispatch/present helpers.
|
||||
|
||||
use ndk::data_space::DataSpace;
|
||||
use ndk::media::media_codec::{AsyncNotifyCallback, MediaCodec, MediaCodecDirection};
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||
use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
||||
|
||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
||||
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
|
||||
/// loop never inflates the decode stage.
|
||||
struct OutputReady {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
}
|
||||
|
||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
OutputAvailable {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
Error { fatal: bool },
|
||||
}
|
||||
|
||||
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
|
||||
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
|
||||
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
|
||||
/// latency the sync loop left on the table). The callbacks run on the codec's internal looper thread
|
||||
/// and only *push events* — every `AMediaCodec` buffer op stays on this thread, which owns the codec,
|
||||
/// sidestepping the self-reference that would arise from a callback calling back into the codec it's
|
||||
/// stored in. A small `pf-decode-feed` thread blocks on the network so this loop never does.
|
||||
pub(super) fn run_async(
|
||||
client: Arc<NativeClient>,
|
||||
window: NativeWindow,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
opts: DecodeOptions,
|
||||
) {
|
||||
let DecodeOptions {
|
||||
decoder_name,
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
let mime = codec_mime(client.codec);
|
||||
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::error!("decode: no {mime} decoder on this device");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let codec_name = codec.name().unwrap_or_default();
|
||||
stats.set_decoder(&codec_name, ll_feature);
|
||||
log::info!(
|
||||
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
|
||||
);
|
||||
|
||||
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
|
||||
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
|
||||
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
|
||||
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
|
||||
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
|
||||
{
|
||||
let out_tx = ev_tx.clone();
|
||||
let in_tx = ev_tx.clone();
|
||||
let fmt_tx = ev_tx.clone();
|
||||
let err_tx = ev_tx.clone();
|
||||
let cb = AsyncNotifyCallback {
|
||||
on_input_available: Some(Box::new(move |idx| {
|
||||
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
|
||||
})),
|
||||
on_output_available: Some(Box::new(move |idx, info| {
|
||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||
index: idx,
|
||||
pts_us: info.presentation_time_us().max(0) as u64,
|
||||
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||
// decode stage ends when the frame actually became available — not after the
|
||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||
decoded_ns: now_realtime_ns(),
|
||||
});
|
||||
})),
|
||||
on_format_changed: Some(Box::new(move |_fmt| {
|
||||
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
|
||||
})),
|
||||
on_error: Some(Box::new(move |e, code, _detail| {
|
||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||
if fatal {
|
||||
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
||||
} else {
|
||||
log::warn!("decode: codec error {e:?} (recoverable)");
|
||||
}
|
||||
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||
})),
|
||||
};
|
||||
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
|
||||
log::error!("decode: set_async_notify_callback failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the low-latency format (identical keys to the sync path).
|
||||
let mut format = MediaFormat::new();
|
||||
format.set_str("mime", mime);
|
||||
format.set_i32("width", mode.width as i32);
|
||||
format.set_i32("height", mode.height as i32);
|
||||
format.set_i32(
|
||||
"max-input-size",
|
||||
(mode.width * mode.height).max(2_000_000) as i32,
|
||||
);
|
||||
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||
if client.color.is_hdr() {
|
||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||
Ok(meta) => {
|
||||
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||
log::error!("decode: configure failed: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = codec.start() {
|
||||
log::error!("decode: start failed: {e}");
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"decode: decoder started (async) at {}x{}",
|
||||
mode.width,
|
||||
mode.height
|
||||
);
|
||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||
// off, every form factor gets the original soft seamless hint.
|
||||
if mode.refresh_hz > 0
|
||||
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||
{
|
||||
log::debug!(
|
||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||
mode.refresh_hz
|
||||
);
|
||||
}
|
||||
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md). Receipt stamps (keyed by the
|
||||
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
|
||||
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||
let feeder = {
|
||||
let client = client.clone();
|
||||
let stats = stats.clone();
|
||||
let in_flight = in_flight.clone();
|
||||
let clock_offset = clock_offset.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
let ev_tx = ev_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-decode-feed".into())
|
||||
.spawn(move || {
|
||||
feeder_loop(
|
||||
client,
|
||||
stats,
|
||||
measure_decode,
|
||||
in_flight,
|
||||
clock_offset,
|
||||
shutdown,
|
||||
ev_tx,
|
||||
);
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
drop(ev_tx); // only the feeder + callbacks keep the channel alive now
|
||||
|
||||
// ADPF: same as the sync path — register this thread now, create the session lazily on the first
|
||||
// presented frame (by when the pump + audio + feeder threads have registered their tids too).
|
||||
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||
1_000_000_000i64 / mode.refresh_hz as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
client.register_hot_thread();
|
||||
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||
let mut hint_tried = false;
|
||||
|
||||
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
||||
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
||||
let mut ready: Vec<OutputReady> = Vec::new();
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||
let mut work_accum_ns: i64 = 0;
|
||||
let mut fatal = false;
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) && !fatal {
|
||||
// Block for the next event (idle wait — excluded from the work tally). The short timeout
|
||||
// drives loss-recovery housekeeping when the pipeline is momentarily quiet.
|
||||
let ev0 = match ev_rx.recv_timeout(Duration::from_millis(5)) {
|
||||
Ok(ev) => Some(ev),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
let work_t0 = Instant::now();
|
||||
let mut fmt_dirty = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
if let Some(ev) = ev0 {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
// presentation across a decode burst, and batched feeding.
|
||||
while let Ok(ev) = ev_rx.try_recv() {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
feed_ready(
|
||||
&codec,
|
||||
&client,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut fed,
|
||||
&mut oversized_dropped,
|
||||
);
|
||||
let had_output = !ready.is_empty();
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if had_output {
|
||||
if !hint_tried {
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
||||
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
||||
if low_latency_mode {
|
||||
boost_hot_threads(&tids);
|
||||
}
|
||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
||||
log::info!(
|
||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||
if hint.is_some() {
|
||||
"active"
|
||||
} else {
|
||||
"unavailable"
|
||||
},
|
||||
tids.len(),
|
||||
);
|
||||
}
|
||||
if let Some(h) = &hint {
|
||||
h.report_actual(work_accum_ns);
|
||||
}
|
||||
work_accum_ns = 0;
|
||||
if rendered > 0 && rendered % 300 == 0 {
|
||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||
}
|
||||
}
|
||||
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
||||
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
||||
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
||||
// throttle so a multi-frame recovery gap can't flood the control stream.
|
||||
let now = Instant::now();
|
||||
if aus_dropped > 0 {
|
||||
gate.arm(now);
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
}
|
||||
}
|
||||
|
||||
let _ = codec.stop();
|
||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||
if let Some(j) = feeder {
|
||||
let _ = j.join();
|
||||
}
|
||||
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||
if let Some(ud) = render_cb {
|
||||
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||
unsafe { release_render_callback(ud) };
|
||||
}
|
||||
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
|
||||
}
|
||||
|
||||
/// The `pf-decode-feed` thread: block on the connector for the next access unit so the async loop
|
||||
/// never has to. Records the `received` HUD stat (receipt point) — including the Phase-2 host/network
|
||||
/// split from any matching 0xCF host timings — then hands the AU to the loop via the event channel.
|
||||
/// Exits when `shutdown` is set, the session closes, or the loop's receiver is gone.
|
||||
fn feeder_loop(
|
||||
client: Arc<NativeClient>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
measure_decode: bool,
|
||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||
) {
|
||||
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// 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();
|
||||
{
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if g.len() > IN_FLIGHT_CAP {
|
||||
g.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
}
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||
break; // the decode loop is gone
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {} // timeout — re-check shutdown and poll again
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||
/// dropped on overflow (the caller then requests a keyframe).
|
||||
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
||||
fn dispatch_event(
|
||||
ev: DecodeEvent,
|
||||
pending_aus: &mut VecDeque<Frame>,
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
fmt_dirty: &mut bool,
|
||||
fatal: &mut bool,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) -> bool {
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
pending_aus.push_back(f);
|
||||
if pending_aus.len() > FRAME_PARK_CAP {
|
||||
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||
return true;
|
||||
}
|
||||
}
|
||||
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
||||
DecodeEvent::OutputAvailable {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
} => ready.push(OutputReady {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
*fatal = true;
|
||||
} else {
|
||||
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
||||
// arm the freeze so the concealed output it recovers into is held off the screen.
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||
fn feed_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
pending_aus: &mut VecDeque<Frame>,
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
fed: &mut u64,
|
||||
oversized_dropped: &mut u64,
|
||||
) {
|
||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||
let idx = free_inputs.pop_front().unwrap();
|
||||
let frame = pending_aus.pop_front().unwrap();
|
||||
let pts_us = frame.pts_ns / 1000;
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
// The slot was never queued, so it stays ours — recycle it for the next AU.
|
||||
free_inputs.push_front(idx);
|
||||
*oversized_dropped += 1;
|
||||
log::warn!(
|
||||
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||
au.len(),
|
||||
dst.len(),
|
||||
*oversized_dropped
|
||||
);
|
||||
let _ = client.request_keyframe();
|
||||
continue;
|
||||
}
|
||||
let n = au.len();
|
||||
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
||||
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||
}
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
||||
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
||||
} else {
|
||||
*fed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
||||
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
||||
/// drained.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
rendered: &mut u64,
|
||||
discarded: &mut u64,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) {
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
||||
// while visible) — both consume the receipt map, so enter for either.
|
||||
if stats.enabled() || measure_decode {
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||
let now = Instant::now();
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
if stats.enabled() {
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
||||
}
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Display/frame-rendered tracking, render-callback registration, HDR dataspace mapping.
|
||||
|
||||
use ndk::data_space::DataSpace;
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::latency::now_realtime_ns;
|
||||
use super::RENDERED_CAP;
|
||||
|
||||
/// `CLOCK_MONOTONIC` now in nanoseconds — the base of the `systemNano` render timestamp the
|
||||
/// `OnFrameRendered` callback reports (Android's `System.nanoTime`), read only to re-base that
|
||||
/// stamp onto `CLOCK_REALTIME` (see [`on_frame_rendered`]).
|
||||
fn now_monotonic_ns() -> i128 {
|
||||
let mut ts = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
ts.tv_sec as i128 * 1_000_000_000 + ts.tv_nsec as i128
|
||||
}
|
||||
|
||||
/// State shared between the decode loop and the `AMediaCodec` `OnFrameRendered` callback (which
|
||||
/// fires on a codec-internal thread): rendered frames awaiting their render timestamp, so the HUD
|
||||
/// gets the spec's `display` stage (decoded→displayed) and the `capture→displayed` end-to-end
|
||||
/// headline (`design/stats-unification.md` — this replaces Android's v1 `capture→decoded`
|
||||
/// endpoint whenever the platform delivers render callbacks).
|
||||
pub(super) struct DisplayTracker {
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||
/// callback early-outs) while the overlay is hidden.
|
||||
rendered: Mutex<VecDeque<(u64, i128)>>,
|
||||
}
|
||||
|
||||
impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
||||
/// Caller gates on the HUD being visible.
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
||||
let mut g = self
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.push_back((pts_us, decoded_ns));
|
||||
if g.len() > RENDERED_CAP {
|
||||
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
|
||||
/// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
|
||||
/// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
|
||||
/// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
|
||||
/// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
|
||||
/// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
|
||||
/// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
|
||||
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
|
||||
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
|
||||
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
|
||||
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
|
||||
pub(super) fn install_render_callback(
|
||||
codec: &MediaCodec,
|
||||
tracker: &Arc<DisplayTracker>,
|
||||
) -> Option<*const DisplayTracker> {
|
||||
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
|
||||
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
|
||||
type SetOnFrameRenderedFn = unsafe extern "C" fn(
|
||||
*mut ndk_sys::AMediaCodec,
|
||||
ndk_sys::AMediaCodecOnFrameRendered,
|
||||
*mut c_void,
|
||||
) -> ndk_sys::media_status_t;
|
||||
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
|
||||
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
|
||||
// returns null when the symbol is absent (device below API 33), checked before transmuting the
|
||||
// non-null pointer to its fn-pointer type.
|
||||
let set_on_frame_rendered = unsafe {
|
||||
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||
if sym.is_null() {
|
||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||
};
|
||||
let ud = Arc::into_raw(tracker.clone());
|
||||
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
|
||||
// (reclaimed only after the codec is deleted, per this function's contract).
|
||||
let status = unsafe {
|
||||
set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
|
||||
};
|
||||
if status == ndk_sys::media_status_t::AMEDIA_OK {
|
||||
Some(ud)
|
||||
} else {
|
||||
log::warn!("decode: setOnFrameRenderedCallback failed ({status:?}) — no display stage");
|
||||
// SAFETY: registration failed, so the codec never took the reference — reclaim it now.
|
||||
unsafe { drop(Arc::from_raw(ud)) };
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaim [`install_render_callback`]'s leaked `Arc` refcount.
|
||||
///
|
||||
/// # Safety
|
||||
/// Call exactly once, and only after the codec the callback was registered on has been dropped —
|
||||
/// deleting the codec stops its internal threads, so no callback can still be running (or run
|
||||
/// later) against this pointer.
|
||||
pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||
drop(Arc::from_raw(ud));
|
||||
}
|
||||
|
||||
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
|
||||
/// thread once per output frame actually placed on the output surface, with SurfaceFlinger's
|
||||
/// render timestamp. That timestamp (`system_nano`) is on `CLOCK_MONOTONIC`, so it is re-based
|
||||
/// onto `CLOCK_REALTIME` here — against monotonic-now at callback time, which also cancels any lag
|
||||
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
||||
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
||||
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
||||
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
||||
/// unwind out of an `extern "C"` fn would abort the process.
|
||||
unsafe extern "C" fn on_frame_rendered(
|
||||
_codec: *mut ndk_sys::AMediaCodec,
|
||||
userdata: *mut c_void,
|
||||
media_time_us: i64,
|
||||
system_nano: i64,
|
||||
) {
|
||||
let t = &*(userdata as *const DisplayTracker);
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||
}
|
||||
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||
let pts_us = media_time_us.max(0) as u64;
|
||||
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
||||
// discipline as `note_decoded_pts`.
|
||||
let mut decoded_ns = None;
|
||||
{
|
||||
let mut g = t
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while let Some(&(p, d)) = g.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own callback
|
||||
}
|
||||
g.pop_front();
|
||||
if p == pts_us {
|
||||
decoded_ns = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us);
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
/// streams leave the default alone). The AMediaCodec analogue of the sync loop's `OutputFormatChanged`
|
||||
/// handling; safe to call repeatedly (`applied_ds` dedups).
|
||||
pub(super) fn apply_hdr_dataspace(
|
||||
codec: &MediaCodec,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
) {
|
||||
if let Some(ds) = hdr_dataspace(codec) {
|
||||
if *applied_ds != Some(ds) {
|
||||
match window.set_buffers_data_space(ds) {
|
||||
Ok(()) => {
|
||||
*applied_ds = Some(ds);
|
||||
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("decode: set_buffers_data_space({ds}) failed (non-fatal): {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
|
||||
pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
let fmt = codec.output_format();
|
||||
let full_range = fmt.i32("color-range") == Some(1);
|
||||
match fmt.i32("color-transfer") {
|
||||
Some(6) => Some(if full_range {
|
||||
DataSpace::Bt2020Pq
|
||||
} else {
|
||||
DataSpace::Bt2020ItuPq
|
||||
}),
|
||||
Some(7) => Some(if full_range {
|
||||
DataSpace::Bt2020Hlg
|
||||
} else {
|
||||
DataSpace::Bt2020ItuHlg
|
||||
}),
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Decode-latency bookkeeping: realtime clock + decoded-pts / user-flags stat recording.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
||||
/// capture `pts_ns` after the skew offset is applied.
|
||||
pub(super) fn now_realtime_ns() -> i128 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// HUD `decoded` point for one dequeued output frame, keyed by the echoed `presentationTimeUs`:
|
||||
/// build the end-to-end (capture→decoded, skew-corrected, clamped to (0, 10 s)) and `decode`
|
||||
/// (received→decoded, single-clock local, ≥ 0) samples and hand them to
|
||||
/// [`crate::stats::VideoStats::note_decoded`]. The pts keys the receipt stamp in `in_flight`;
|
||||
/// entries older than it are evicted (decode order == input order here — low-latency, no
|
||||
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||
/// stamp (async loop).
|
||||
pub(super) fn note_decoded_pts(
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) {
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
in_flight.pop_front();
|
||||
if p == pts_us {
|
||||
received_ns = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||
// Adaptive bitrate: the `decode` stage (received→decoded, single-clock local) IS the decoder-
|
||||
// backlog signal — the only bottleneck the host-side network signals can't see (a fast LAN
|
||||
// feeding a slower mobile decoder). Report it whenever the controller is armed, regardless of
|
||||
// the HUD; `report_decode_us` is a cheap accumulate the pump windows.
|
||||
if measure_decode {
|
||||
if let Some(us) = decode_us {
|
||||
client.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
}
|
||||
// HUD histogram: only while the overlay is visible (a measure-only caller enters here for the
|
||||
// ABR report alone). `end-to-end` = capture→decoded (skew-corrected) tiles the `decode` stage.
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time to
|
||||
// < 1 µs — negligible against the ms-scale figures shown.
|
||||
if stats.enabled() {
|
||||
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
||||
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
||||
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
||||
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
||||
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
||||
pub(super) fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
||||
while let Some(&(p, f)) = map.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
map.pop_front();
|
||||
if p == pts_us {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
||||
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
||||
//!
|
||||
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
||||
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
||||
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
||||
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
||||
|
||||
mod async_loop;
|
||||
mod display;
|
||||
mod latency;
|
||||
mod setup;
|
||||
mod sync_loop;
|
||||
|
||||
use async_loop::run_async;
|
||||
pub(crate) use setup::{codec_label, codec_mime};
|
||||
use sync_loop::run_sync;
|
||||
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cap on AUs parked in the async loop awaiting a free codec input slot. Matches the connector's
|
||||
/// own frame-channel depth; on sustained overflow the oldest is dropped and a keyframe requested
|
||||
/// (same recovery as a reassembler drop). In steady state this stays near-empty.
|
||||
const FRAME_PARK_CAP: usize = 16;
|
||||
|
||||
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
|
||||
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
|
||||
const IN_FLIGHT_CAP: usize = 64;
|
||||
|
||||
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
|
||||
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
|
||||
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||
const PENDING_SPLIT_CAP: usize = 256;
|
||||
|
||||
/// Cap on rendered frames parked in [`DisplayTracker`] awaiting their `OnFrameRendered` render
|
||||
/// timestamp: the callback trails its release by at most a vsync or two, so anything this deep
|
||||
/// means the platform stopped delivering render callbacks (allowed under load, per the docs) and
|
||||
/// gets evicted.
|
||||
const RENDERED_CAP: usize = 64;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
||||
/// original pipeline, kept as the per-device escape hatch).
|
||||
const USE_ASYNC_DECODE: bool = true;
|
||||
|
||||
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
||||
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
|
||||
pub(crate) struct DecodeOptions {
|
||||
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
|
||||
/// let the platform resolve the default decoder for the MIME.
|
||||
pub decoder_name: Option<String>,
|
||||
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
||||
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||
pub ll_feature: bool,
|
||||
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
|
||||
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
||||
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
||||
/// escape hatch.
|
||||
pub low_latency_mode: bool,
|
||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||
pub is_tv: bool,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
/// Both run until `shutdown` is set or the session closes.
|
||||
pub fn run(
|
||||
client: Arc<NativeClient>,
|
||||
window: NativeWindow,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
opts: DecodeOptions,
|
||||
) {
|
||||
if opts.low_latency_mode && USE_ASYNC_DECODE {
|
||||
run_async(client, window, shutdown, stats, opts);
|
||||
} else {
|
||||
run_sync(client, window, shutdown, stats, opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Codec creation, low-latency config, thread/frame-rate tuning, HDR static-info encode.
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
|
||||
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
||||
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
||||
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
||||
/// emitted HEVC).
|
||||
pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
||||
punktfunk_core::quic::CODEC_AV1 => "video/av01",
|
||||
_ => "video/hevc",
|
||||
}
|
||||
}
|
||||
|
||||
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
|
||||
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
|
||||
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
|
||||
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
|
||||
pub(crate) fn codec_label(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "HEVC",
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
||||
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
||||
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
||||
pub(super) fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
||||
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
|
||||
if let Some(c) = MediaCodec::from_codec_name(name) {
|
||||
return Some(c);
|
||||
}
|
||||
log::warn!(
|
||||
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
|
||||
);
|
||||
}
|
||||
MediaCodec::from_decoder_type(mime)
|
||||
}
|
||||
|
||||
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
||||
///
|
||||
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
||||
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
||||
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
||||
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
||||
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
||||
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
||||
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
||||
///
|
||||
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
||||
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
|
||||
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
|
||||
/// standard key + clock hint + being ranked first in `VideoDecoders`.
|
||||
pub(super) fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
|
||||
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
|
||||
format.set_i32("low-latency", 1);
|
||||
if !aggressive {
|
||||
// The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by
|
||||
// other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate
|
||||
// sentinel — decode each frame at max clocks rather than pacing to the frame rate.
|
||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||
return;
|
||||
}
|
||||
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
|
||||
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
|
||||
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
|
||||
// does the same, and also relies on it for Amazon's Amlogic fork.
|
||||
format.set_i32("vdec-lowlatency", 1);
|
||||
let name = codec_name.to_ascii_lowercase();
|
||||
let is = |prefix: &str| name.starts_with(prefix);
|
||||
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
|
||||
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
|
||||
// vendor twin.
|
||||
if is("omx.qcom") || is("c2.qti") {
|
||||
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
|
||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||
}
|
||||
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
|
||||
if is("omx.exynos") || is("c2.exynos") {
|
||||
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
|
||||
}
|
||||
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
|
||||
if is("omx.amlogic") || is("c2.amlogic") {
|
||||
format.set_i32("vendor.low-latency.enable", 1);
|
||||
}
|
||||
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
|
||||
if is("omx.hisi") || is("c2.hisi") {
|
||||
format.set_i32(
|
||||
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
|
||||
1,
|
||||
);
|
||||
format.set_i32(
|
||||
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
|
||||
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
|
||||
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
|
||||
// (see `VideoDecoders`) is their treatment.
|
||||
//
|
||||
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
|
||||
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
|
||||
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
|
||||
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
|
||||
// plain realtime `priority` hint instead.
|
||||
if decoder_supports_max_operating_rate(&name) {
|
||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||
} else {
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
|
||||
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
|
||||
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
|
||||
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
|
||||
/// hint above.
|
||||
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
||||
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
|
||||
}
|
||||
|
||||
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
|
||||
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
|
||||
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
|
||||
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
|
||||
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
|
||||
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
|
||||
pub(super) fn boost_hot_threads(tids: &[i32]) {
|
||||
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
|
||||
let self_tid = unsafe { libc::gettid() };
|
||||
for &tid in tids {
|
||||
if tid == self_tid {
|
||||
continue;
|
||||
}
|
||||
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
|
||||
// syscall; a refusal is reported via the return value, not UB.
|
||||
unsafe {
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
||||
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
||||
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
||||
pub(super) fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls. PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the same idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -10) != 0 {
|
||||
log::warn!(
|
||||
"decode: setpriority(-10) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
|
||||
/// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
|
||||
/// floor, so both are dlsym-resolved at runtime (a hard import of a >floor symbol makes
|
||||
/// `dlopen`/`System.load` fail on every API-28/29 device, even where this path is never hit —
|
||||
/// mirrors [`crate::adpf`]):
|
||||
/// - On a **TV** (`is_tv`): `ANativeWindow_setFrameRateWithChangeStrategy` (**API 31**) with
|
||||
/// `changeFrameRateStrategy = ALWAYS`, which actively drives the HDMI output into the matching
|
||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||
/// phone. Falls through to the 2-arg hint on API 30.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
||||
///
|
||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||
/// decline.
|
||||
pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bool {
|
||||
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||
// int32_t ANativeWindow_setFrameRateWithChangeStrategy(
|
||||
// ANativeWindow*, float frameRate, int8_t compatibility, int8_t changeFrameRateStrategy)
|
||||
type SetFrameRateStrategyFn = unsafe extern "C" fn(*mut c_void, f32, i8, i8) -> i32;
|
||||
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||
// process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
|
||||
// symbol's API level), checked before transmuting the non-null pointer to its fn-pointer type.
|
||||
// `window.ptr()` is the live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return false;
|
||||
}
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
if is_tv {
|
||||
let sym = libc::dlsym(
|
||||
lib,
|
||||
c"ANativeWindow_setFrameRateWithChangeStrategy".as_ptr(),
|
||||
);
|
||||
if !sym.is_null() {
|
||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
||||
}
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||
if sym.is_null() {
|
||||
return false; // device API < 30 — no per-surface frame-rate hint
|
||||
}
|
||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
|
||||
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
|
||||
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
|
||||
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
|
||||
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
|
||||
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
|
||||
/// MaxCLL/MaxFALL (nits) match 1:1.
|
||||
pub(super) fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
||||
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
|
||||
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||||
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||||
let fields: [u16; 12] = [
|
||||
r[0],
|
||||
r[1],
|
||||
g[0],
|
||||
g[1],
|
||||
b_[0],
|
||||
b_[1], // R, G, B primaries
|
||||
m.white_point[0],
|
||||
m.white_point[1], // white point
|
||||
max_nits,
|
||||
min_units, // max (nits) / min (0.0001-nit) display luminance
|
||||
m.max_cll,
|
||||
m.max_fall, // MaxCLL / MaxFALL (nits)
|
||||
];
|
||||
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
||||
for (i, v) in fields.iter().enumerate() {
|
||||
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! The synchronous MediaCodec decode loop (the original poll path) + its feed/drain helpers.
|
||||
|
||||
use ndk::data_space::DataSpace;
|
||||
use ndk::media::media_codec::{
|
||||
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||
OutputBuffer,
|
||||
};
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::display::{
|
||||
hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||
use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::{DecodeOptions, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
||||
|
||||
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
|
||||
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
||||
/// only blocking wait is a short output dequeue while input is backed up.
|
||||
pub(super) fn run_sync(
|
||||
client: Arc<NativeClient>,
|
||||
window: NativeWindow,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
opts: DecodeOptions,
|
||||
) {
|
||||
let DecodeOptions {
|
||||
decoder_name,
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). AMediaCodec needs no
|
||||
// out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
||||
let mime = codec_mime(client.codec);
|
||||
let codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::error!("decode: no {mime} decoder on this device");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it fell
|
||||
// back) drives both the HUD label and which vendor low-latency keys apply below.
|
||||
let codec_name = codec.name().unwrap_or_default();
|
||||
stats.set_decoder(&codec_name, ll_feature);
|
||||
log::info!(
|
||||
"decode: codec mime = {mime}, decoder = {codec_name} (low-latency feature: {ll_feature})"
|
||||
);
|
||||
|
||||
let mut format = MediaFormat::new();
|
||||
format.set_str("mime", mime);
|
||||
format.set_i32("width", mode.width as i32);
|
||||
format.set_i32("height", mode.height as i32);
|
||||
// Generous input buffer so a large keyframe AU is never truncated.
|
||||
format.set_i32(
|
||||
"max-input-size",
|
||||
(mode.width * mode.height).max(2_000_000) as i32,
|
||||
);
|
||||
// Standard + per-SoC vendor low-latency keys and the clock hints, gated on the resolved decoder
|
||||
// name and the master toggle (see `configure_low_latency`).
|
||||
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||
|
||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||
// MediaCodec wants it BEFORE configure(), and the host sends a 0xCE right after the handshake,
|
||||
// so it's typically already queued; wait briefly otherwise. The Surface DataSpace (applied on
|
||||
// OutputFormatChanged below) carries transfer/primaries regardless — this adds the luminance the
|
||||
// tone-mapper needs. A non-HDR display still gets sensible SurfaceFlinger tone-mapping.
|
||||
if client.color.is_hdr() {
|
||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||
Ok(meta) => {
|
||||
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||
log::error!("decode: configure failed: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = codec.start() {
|
||||
log::error!("decode: start failed: {e}");
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"decode: {mime} decoder started at {}x{}",
|
||||
mode.width,
|
||||
mode.height
|
||||
);
|
||||
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
||||
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||
// off, every form factor gets the original soft seamless hint.
|
||||
if mode.refresh_hz > 0
|
||||
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||
{
|
||||
log::debug!(
|
||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||
mode.refresh_hz
|
||||
);
|
||||
}
|
||||
|
||||
// ADPF: hint the platform that the whole video pipeline — this pf-decode feed/drain/present
|
||||
// loop, the core's data-plane pump (UDP receive + FEC reassembly), and the audio thread — runs a
|
||||
// per-frame real-time workload, so the CPU governor keeps those threads on fast cores at high
|
||||
// clocks instead of down-clocking between frames or parking them on a little core. Snapdragon's
|
||||
// ADPF backend responds well to this. We register this thread now but create the session lazily
|
||||
// on the first presented frame: by then the pump + audio threads have registered their ids too,
|
||||
// and ADPF `createSession` rejects a set with any not-yet-live/dead tid. No-op below API 33.
|
||||
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||
1_000_000_000i64 / mode.refresh_hz as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
client.register_hot_thread(); // this decode thread → the pipeline's hot-thread set
|
||||
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||
let mut hint_tried = false;
|
||||
// Accumulates the loop's productive (feed+drain) time between displayed frames; reported to ADPF
|
||||
// once per rendered frame against the frame-period target.
|
||||
let mut work_accum_ns: i64 = 0;
|
||||
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||
// round-trip) and we only pop the next one once it's queued.
|
||||
let mut pending: Option<Frame> = None;
|
||||
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
||||
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
||||
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
||||
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
||||
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
||||
// re-anchor signalling the platform decoder doesn't expose.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||
// HUD flags it "(same-host clock)").
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||
// for the `decode` stage. Fed while the HUD is visible OR the adaptive-bitrate controller wants
|
||||
// the decode signal (`measure_decode`) — the decoder-backlog bottleneck the network can't see.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||
// where receipts are recorded and matched by pts; `network = hostnet − host` (saturating).
|
||||
// Only fed while the HUD is visible; an old host never sends a 0xCF, so entries just age out.
|
||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
||||
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
// One thread feeds AND drains: the NDK AMediaCodec wrapper isn't documented thread-safe for
|
||||
// cross-thread feed/drain, so instead of splitting threads the loop decouples the two — input
|
||||
// dequeue is non-blocking (never stalls presentation of already-decoded frames) and the only
|
||||
// blocking wait is a short output dequeue while input is backed up (decoder progress is exactly
|
||||
// what frees the next input buffer).
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
if pending.is_none() {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
||||
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
if fed == 0 {
|
||||
let p = &frame.data;
|
||||
log::info!(
|
||||
"decode: first AU {} bytes, head {:02x?}",
|
||||
p.len(),
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||
// 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();
|
||||
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
|
||||
}
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts.
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) =
|
||||
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = Some(frame);
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
}
|
||||
// Time the productive work (feed + drain) only — the `next_frame` poll wait above is idle
|
||||
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||
let work_t0 = Instant::now();
|
||||
if let Some(frame) = pending.take() {
|
||||
if feed(
|
||||
&codec,
|
||||
&client,
|
||||
&frame.data,
|
||||
frame.pts_ns / 1000,
|
||||
&mut oversized_dropped,
|
||||
) {
|
||||
fed += 1;
|
||||
if fed % 300 == 0 {
|
||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||
}
|
||||
} else {
|
||||
// No input buffer free — transient back-pressure. Keep the AU and let `drain` block
|
||||
// briefly below; a released output buffer is what recycles an input slot.
|
||||
pending = Some(frame);
|
||||
}
|
||||
}
|
||||
// Drain every iteration. When input is blocked, wait ~2 ms on output so the loop rides
|
||||
// decoder progress instead of busy-spinning against a full input queue.
|
||||
let wait = if pending.is_some() {
|
||||
Duration::from_millis(2)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (r, d) = drain(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&window,
|
||||
&mut applied_ds,
|
||||
wait,
|
||||
&stats,
|
||||
&mut in_flight,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
rendered += r;
|
||||
discarded += d;
|
||||
|
||||
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
|
||||
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
|
||||
// the short output-dequeue wait is included in the tally — for a latency-first client,
|
||||
// biasing the governor toward "boost" is the desired behaviour. Cheap when `hint` is None
|
||||
// (one `Instant` diff, no report).
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if r > 0 {
|
||||
if !hint_tried {
|
||||
// First presented frame: the pump + audio threads have registered their ids by now.
|
||||
// Build one ADPF session over the whole pipeline's thread set (empty below API 33,
|
||||
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
||||
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
||||
if low_latency_mode {
|
||||
boost_hot_threads(&tids);
|
||||
}
|
||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
||||
log::info!(
|
||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||
if hint.is_some() {
|
||||
"active"
|
||||
} else {
|
||||
"unavailable"
|
||||
},
|
||||
tids.len(),
|
||||
);
|
||||
}
|
||||
if let Some(h) = &hint {
|
||||
h.report_actual(work_accum_ns);
|
||||
}
|
||||
work_accum_ns = 0;
|
||||
}
|
||||
|
||||
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
|
||||
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
||||
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
||||
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||
let now = Instant::now();
|
||||
if gate.poll(client.frames_dropped(), now)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = codec.stop();
|
||||
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||
if let Some(ud) = render_cb {
|
||||
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||
unsafe { release_render_callback(ud) };
|
||||
}
|
||||
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
|
||||
}
|
||||
|
||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
|
||||
/// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
|
||||
/// corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||
fn feed(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
au: &[u8],
|
||||
pts_us: u64,
|
||||
oversized_dropped: &mut u64,
|
||||
) -> bool {
|
||||
match codec.dequeue_input_buffer(Duration::ZERO) {
|
||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||
let n = {
|
||||
let dst = buf.buffer_mut();
|
||||
if au.len() > dst.len() {
|
||||
*oversized_dropped += 1;
|
||||
log::warn!(
|
||||
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||
au.len(),
|
||||
dst.len(),
|
||||
*oversized_dropped
|
||||
);
|
||||
let _ = client.request_keyframe();
|
||||
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
|
||||
} else {
|
||||
let n = au.len();
|
||||
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
|
||||
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
|
||||
// the cast write initializes exactly `dst[..n]`.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
au.as_ptr(),
|
||||
dst.as_mut_ptr().cast::<u8>(),
|
||||
n,
|
||||
);
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||
log::warn!("decode: queue_input_buffer: {e}");
|
||||
}
|
||||
true
|
||||
}
|
||||
Ok(DequeuedInputBufferResult::TryAgainLater) => false, // caller keeps the AU pending
|
||||
Err(e) => {
|
||||
log::warn!("decode: dequeue_input_buffer: {e}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dequeue every ready output buffer and present only the NEWEST (render = true), discarding the
|
||||
/// rest (render = false) — when decode falls behind, a back-to-back burst of stale frames on glass
|
||||
/// is worse than skipping straight to the freshest one (the Apple client's 1-slot newest-ready
|
||||
/// ring, ported). `first_wait` is the timeout for the first dequeue only: zero normally, ~2 ms when
|
||||
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
|
||||
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
|
||||
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
|
||||
///
|
||||
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
||||
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
||||
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
||||
/// `in_flight` (single-clock local difference, no skew involved). The presented frame's
|
||||
/// `(pts, decoded stamp)` is additionally parked in `tracker` for the OnFrameRendered callback —
|
||||
/// the `display` stage's other endpoint.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||
fn drain(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
first_wait: Duration,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) -> (u64, u64) {
|
||||
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
||||
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
||||
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||
let mut held_present = true;
|
||||
let mut discarded: u64 = 0;
|
||||
let mut wait = first_wait;
|
||||
loop {
|
||||
match codec.dequeue_output_buffer(wait) {
|
||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
||||
wait = Duration::ZERO;
|
||||
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
||||
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
||||
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let flags = take_flags(recovery_flags, pts_us);
|
||||
held_present =
|
||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||
let meta = if stats.enabled() || measure_decode {
|
||||
// The dequeue IS the sync loop's decoded-availability instant.
|
||||
let decoded_ns = now_realtime_ns();
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
in_flight,
|
||||
clock_offset,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
);
|
||||
// The tracker's `display` stage is a HUD concern — park only when visible.
|
||||
stats.enabled().then_some((pts_us, decoded_ns))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((stale, _)) = held.replace((buf, meta)) {
|
||||
// A newer frame is ready — drop the held one without rendering.
|
||||
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||
}
|
||||
discarded += 1;
|
||||
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||
}
|
||||
}
|
||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||
// The decoder has parsed the SPS and now reports the stream's real colour signalling
|
||||
// (the AMediaCodec analogue of VideoToolbox's format description on the Apple client).
|
||||
// If it's HDR (BT.2020 PQ/HLG), tell the Surface so the compositor/display switch to
|
||||
// HDR; SDR streams leave the default dataspace alone. The decoder itself picks a
|
||||
// Main10 path from the SPS — no profile override needed. Keep looping (buffers
|
||||
// follow, and any held buffer stays held across this event).
|
||||
wait = Duration::ZERO;
|
||||
if let Some(ds) = hdr_dataspace(codec) {
|
||||
if *applied_ds != Some(ds) {
|
||||
match window.set_buffers_data_space(ds) {
|
||||
Ok(()) => {
|
||||
*applied_ds = Some(ds);
|
||||
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"decode: set_buffers_data_space({ds}) failed (non-fatal): {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TryAgainLater / OutputBuffersChanged — nothing more to dequeue now.
|
||||
Ok(_) => break,
|
||||
Err(e) => {
|
||||
log::warn!("decode: dequeue_output_buffer: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
||||
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
||||
// on glass) and count it as a discard rather than a display.
|
||||
let mut rendered = 0;
|
||||
if let Some((buf, meta)) = held {
|
||||
match codec.release_output_buffer(buf, held_present) {
|
||||
Ok(()) if held_present => {
|
||||
rendered = 1;
|
||||
if let Some((pts_us, decoded_ns)) = meta {
|
||||
tracker.note_rendered(pts_us, decoded_ns);
|
||||
}
|
||||
}
|
||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||
}
|
||||
}
|
||||
(rendered, discarded)
|
||||
}
|
||||
@@ -24,14 +24,19 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
const TAG_TRIGGER: u8 = 0x03;
|
||||
const TAG_HID_RAW: u8 = 0x05;
|
||||
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
||||
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
||||
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
||||
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
||||
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
||||
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next EFFECTIVE
|
||||
/// rumble command from the core's shared policy engine (`design/rumble-root-fix.md` §D). The
|
||||
/// engine owns ALL rumble policy — v2 lease expiry, legacy-host staleness (a uniform 1 s, ending
|
||||
/// the old 60 s Android exposure), connection-close drain zeros — so Kotlin applies commands
|
||||
/// verbatim: `(0, 0)` = cancel now, non-zero = one-shot at this level.
|
||||
///
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bits 32..47 = the
|
||||
/// command's `backstop_ms` (≤ 5000 — the one-shot duration, i.e. the hardware net under a stalled
|
||||
/// poll thread; the engine emits explicit zeros at every policy stop, so it is never the stop
|
||||
/// mechanism), bits 16..31 = `low`, bits 0..15 = `high` (0..=0xFFFF). `-1` on timeout / session
|
||||
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
||||
/// poll thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
@@ -43,24 +48,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
|
||||
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_command is
|
||||
// &self on the Sync connector — safe alongside the decode/audio/input threads. Kotlin
|
||||
// stops these poll threads (and joins them — unbounded) before nativeClose frees the
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||
Ok((pad, low, high, ttl)) => {
|
||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
||||
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
||||
let (lease_flag, ttl_bits) = match ttl {
|
||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||
None => (0, 0),
|
||||
};
|
||||
(jlong::from(pad & 0xF) << 49)
|
||||
| lease_flag
|
||||
| ttl_bits
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(cmd.low) << 16)
|
||||
| jlong::from(cmd.high)
|
||||
}
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
|
||||
@@ -102,6 +102,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
||||
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
|
||||
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
|
||||
@@ -19,5 +19,32 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
dropped; a bounded timer auto-disconnects. Never silence-as-keepalive (App Review 2.5.4).
|
||||
tvOS ignores/tolerates the key; macOS is not gated by it. -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<!-- Live Activities (iOS/iPadOS): the Lock-Screen / Dynamic-Island session surface. Updated
|
||||
locally (pushType nil) from the alive app process — no aps-environment. tvOS/macOS ignore it. -->
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<!-- Deep links: punktfunk://connect/<host-uuid>[?launch=<GameEntry.id>]. Emitted by the
|
||||
launcher widget and Siri/Shortcuts; routed by ContentView.onOpenURL into the existing
|
||||
connect path. Shared across all three targets (tvOS/macOS accept it harmlessly). -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>io.unom.punktfunk.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -73,5 +73,15 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||
</array>
|
||||
|
||||
<!-- App Group: same shared UserDefaults suite as iOS (Config/Punktfunk.entitlements). Shared
|
||||
here so a single HostStore code path (UserDefaults(suiteName:)) works on every platform;
|
||||
macOS widgets that read it arrive with M5. macOS App Groups use the plain group id under
|
||||
the App Store profile; a Developer-ID-signed build wants the team-prefixed form — the
|
||||
Dev-ID codesign step in release.yml must verify this value against the Dev-ID profile. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -20,5 +20,14 @@
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
<!-- App Group: the shared UserDefaults suite (group.io.unom.punktfunk) that both the app and
|
||||
the Widget/Live-Activity extension read — the saved-host store moved there so a launcher
|
||||
widget can see it (HostStore reads UserDefaults(suiteName:)). Must be registered on the
|
||||
developer portal and enabled in the provisioning profile for BOTH app ids
|
||||
(io.unom.punktfunk + io.unom.punktfunk.widgets). tvOS carries the key harmlessly. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,13 +9,20 @@ let package = Package(
|
||||
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
||||
products: [
|
||||
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
|
||||
// Dependency-free foundation (stored-host model + JSON codec, settings keys, App-Group
|
||||
// constant, deep-link grammar, Live Activity attributes). A separate PRODUCT so the widget
|
||||
// extension — which must never link PunktfunkKit (Rust staticlib + presentation layer) —
|
||||
// can link this and nothing else. PunktfunkKit re-exports it (see SharedReexport.swift).
|
||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||
],
|
||||
targets: [
|
||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||
// No dependencies by design — an extension process links this alone.
|
||||
.target(name: "PunktfunkShared"),
|
||||
.target(
|
||||
name: "PunktfunkKit",
|
||||
dependencies: ["PunktfunkCore"],
|
||||
dependencies: ["PunktfunkCore", "PunktfunkShared"],
|
||||
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||
// app, which links the PunktfunkKit product. Refresh with
|
||||
@@ -43,7 +50,8 @@ let package = Package(
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(
|
||||
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
|
||||
name: "PunktfunkKitTests",
|
||||
dependencies: ["PunktfunkKit", "PunktfunkShared", "PunktfunkCore"],
|
||||
resources: [
|
||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||
|
||||
@@ -11,14 +11,56 @@
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||
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, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E29556A7300948BA009F939C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA000000000000000000000D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E2955696300948B9009F939C;
|
||||
remoteInfo = PunktfunkWidgetsExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PunktfunkWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2955699300948B9009F939C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
E295569B300948B9009F939C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PunktfunkWidgetsExtension.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
AA0000000000000000000002 /* App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
@@ -30,6 +72,14 @@
|
||||
path = Sources/PunktfunkClient;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */,
|
||||
);
|
||||
path = PunktfunkWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -58,14 +108,27 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955694300948B9009F939C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */,
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */,
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA0000000000000000000007 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */,
|
||||
AA0000000000000000000002 /* App */,
|
||||
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||
E2955698300948B9009F939C /* Frameworks */,
|
||||
AA0000000000000000000008 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -76,10 +139,20 @@
|
||||
AA0000000000000000000001 /* Punktfunk.app */,
|
||||
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2955698300948B9009F939C /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2955699300948B9009F939C /* WidgetKit.framework */,
|
||||
E295569B300948B9009F939C /* SwiftUI.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -114,10 +187,12 @@
|
||||
BB000000000000000000000B /* Sources */,
|
||||
BB0000000000000000000004 /* Frameworks */,
|
||||
BB000000000000000000000C /* Resources */,
|
||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E29556A8300948BA009F939C /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000002 /* App */,
|
||||
@@ -156,6 +231,29 @@
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */;
|
||||
buildPhases = (
|
||||
E2955693300948B9009F939C /* Sources */,
|
||||
E2955694300948B9009F939C /* Frameworks */,
|
||||
E2955695300948B9009F939C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||
);
|
||||
name = PunktfunkWidgetsExtension;
|
||||
packageProductDependencies = (
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */,
|
||||
);
|
||||
productName = PunktfunkWidgetsExtension;
|
||||
productReference = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -163,11 +261,15 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2700;
|
||||
LastUpgradeCheck = 2700;
|
||||
TargetAttributes = {
|
||||
AA0000000000000000000009 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
E2955696300948B9009F939C = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
||||
@@ -190,6 +292,7 @@
|
||||
AA0000000000000000000009 /* Punktfunk */,
|
||||
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -216,6 +319,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955695300948B9009F939C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -240,8 +350,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955693300948B9009F939C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E29556A8300948BA009F939C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||
targetProxy = E29556A7300948BA009F939C /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA0000000000000000000010 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -564,6 +689,97 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E29556AB300948BA009F939C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
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;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E29556AC300948BA009F939C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
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;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -603,6 +819,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E29556AB300948BA009F939C /* Debug */,
|
||||
E29556AC300948BA009F939C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
@@ -636,6 +861,10 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkKit;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
|
||||
+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>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
|
||||
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
|
||||
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
|
||||
// connect path (trust policy / WoL / approval all apply).
|
||||
//
|
||||
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
|
||||
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
|
||||
// (HostStore → WidgetCenter.reloadTimelines).
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct HostsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let hosts: [StoredHost]
|
||||
}
|
||||
|
||||
struct HostsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> HostsEntry {
|
||||
HostsEntry(date: .now, hosts: [])
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
|
||||
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
|
||||
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
|
||||
// changes (a new host, a fresh connect reordering by recency).
|
||||
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
|
||||
completion(Timeline(entries: [entry], policy: .never))
|
||||
}
|
||||
|
||||
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct HostsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
|
||||
HostsWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Punktfunk Hosts")
|
||||
.description("Quick-launch your recent streaming hosts.")
|
||||
.supportedFamilies([
|
||||
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
struct HostsWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: HostsEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
MediumHostsView(hosts: entry.hosts)
|
||||
case .accessoryCircular:
|
||||
CircularHostView(host: entry.hosts.first)
|
||||
case .accessoryRectangular:
|
||||
RectangularHostView(host: entry.hosts.first)
|
||||
default: // systemSmall + fallback
|
||||
SmallHostView(host: entry.hosts.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep link that connects to a stored host.
|
||||
private func connectURL(_ host: StoredHost) -> URL {
|
||||
DeepLink.connect(host: host.id, launchID: nil).url
|
||||
}
|
||||
|
||||
private struct SmallHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
if let host {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(connectURL(host))
|
||||
} else {
|
||||
EmptyHostView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediumHostsView: View {
|
||||
let hosts: [StoredHost]
|
||||
var body: some View {
|
||||
if hosts.isEmpty {
|
||||
EmptyHostView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Punktfunk")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(.tint)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(.tint)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct RectangularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
Text(host?.displayName ?? "Punktfunk")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyHostView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "play.tv")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Punktfunk to add a host.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets,
|
||||
// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and
|
||||
// NOTHING else — never PunktfunkKit (Rust staticlib + presentation layer would blow the widget
|
||||
// process's ~30 MB budget).
|
||||
//
|
||||
// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets
|
||||
// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension
|
||||
// target you add pointing at this folder — see design/apple-live-activities-and-widgets.md §M1 and
|
||||
// the GUI checklist.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
|
||||
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
|
||||
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
|
||||
//
|
||||
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
|
||||
// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via
|
||||
// Text(timerInterval:) — no per-second push.
|
||||
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
struct PunktfunkSessionLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
|
||||
LockScreenView(context: context)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen banner
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
.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)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: View {
|
||||
let state: PunktfunkSessionAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
switch state.stage {
|
||||
case .streaming:
|
||||
EmptyView()
|
||||
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)
|
||||
} else {
|
||||
badge("Running in background", .orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
badge("Reconnecting…", .yellow)
|
||||
case .ending:
|
||||
badge("Session ended", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func badge(_ text: String, _ color: Color) -> some View {
|
||||
Text(text).font(.caption2).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||
private struct EndButton: View {
|
||||
var body: some View {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
}
|
||||
.tint(.red)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
|
||||
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||
struct ApprovalRequest {
|
||||
let host: StoredHost
|
||||
let advertisedFingerprint: Data?
|
||||
}
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.renderScale) private var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@@ -51,6 +52,15 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
@State private var showAddHost = false
|
||||
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or
|
||||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
||||
/// "Connection failed" one, which is for actual connect errors).
|
||||
@State private var deepLinkNotice: String?
|
||||
#if os(iOS)
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@State private var liveActivity = SessionActivityController()
|
||||
#endif
|
||||
@State private var pairingTarget: StoredHost?
|
||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||
@@ -92,6 +102,14 @@ struct ContentView: View {
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
/// Background keep-alive (Settings → General, iOS-only). Default OFF (today's freeze-on-background
|
||||
/// is the default). When on, backgrounding a live session keeps audio + the connection alive and
|
||||
/// drops video, auto-disconnecting after `backgroundTimeoutMinutes`.
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10
|
||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
@@ -113,7 +131,62 @@ struct ContentView: View {
|
||||
.onAppear {
|
||||
seedDefaultModeIfNeeded()
|
||||
autoConnectIfAsked()
|
||||
#if os(iOS)
|
||||
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
|
||||
#endif
|
||||
}
|
||||
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
|
||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||
// parallel session — this drives the one `model` ContentView owns.
|
||||
.onOpenURL { handleDeepLink($0) }
|
||||
#if os(iOS)
|
||||
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
||||
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background:
|
||||
if backgroundKeepAlive, model.phase == .streaming {
|
||||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||||
}
|
||||
case .active:
|
||||
model.exitBackground()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// Live Activity lifecycle, driven from the model's published state.
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
if let host = model.activeHost {
|
||||
liveActivity.begin(
|
||||
hostID: host.id, hostName: host.displayName,
|
||||
launchTitle: nil, // no live foreground-app title mid-session (v1)
|
||||
modeLine: currentModeLine(), startedAt: Date())
|
||||
}
|
||||
case .idle:
|
||||
liveActivity.end()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: model.isBackgrounded) { _, backgrounded in
|
||||
liveActivity.update {
|
||||
$0.stage = backgrounded ? .background : .streaming
|
||||
$0.backgroundDeadline = model.backgroundDeadline
|
||||
}
|
||||
}
|
||||
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
||||
// this — tear the session down deliberately (quit-close the host).
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
||||
model.disconnect(deliberate: true)
|
||||
}
|
||||
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
|
||||
// as a widget tap.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
||||
if let url = note.object as? URL { handleDeepLink(url) }
|
||||
}
|
||||
#endif
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
@@ -151,6 +224,9 @@ struct ContentView: View {
|
||||
#if !os(tvOS)
|
||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||
isStreaming: model.connection != nil,
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
disconnect: { model.disconnect() }))
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@@ -262,6 +338,59 @@ struct ContentView: View {
|
||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||
.alert("Can't open", isPresented: deepLinkNoticePresented) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation flag for the informational deep-link alert. Extracted from the `.alert` call so
|
||||
/// the manual get/set Binding type-checks on its own instead of inflating the body chain's
|
||||
/// budget (adding it inline tips SwiftUI's per-expression limit — see the split sections idiom).
|
||||
private var deepLinkNoticePresented: Binding<Bool> {
|
||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
||||
private func currentModeLine() -> String {
|
||||
guard let c = model.connection else { return "" }
|
||||
let codec: String
|
||||
switch c.videoCodec {
|
||||
case .h264: codec = "H.264"
|
||||
case .hevc: codec = "HEVC"
|
||||
case .av1: codec = "AV1"
|
||||
case .pyrowave: codec = "PyroWave"
|
||||
}
|
||||
var line = "\(c.width)×\(c.height)"
|
||||
if c.refreshHz > 0 { line += " @\(c.refreshHz)" }
|
||||
line += " · \(codec)"
|
||||
if c.isHDR { line += " · HDR" }
|
||||
return line
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
|
||||
/// unknown host → notice + no-op; a live session is up → ignore if it's the same host, else
|
||||
/// tell the user to end the current one first (NEVER tear down a live session on a background
|
||||
/// tap); otherwise the normal `connect` — trust policy, WoL and the approval sheet all apply.
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
|
||||
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
return
|
||||
}
|
||||
if model.phase != .idle {
|
||||
guard model.activeHost?.id == hostID else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
connect(host, launchID: launchID)
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
@@ -610,6 +739,17 @@ struct ContentView: View {
|
||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||
/// The stream mode to request = the chosen resolution × the render scale, aspect-preserved,
|
||||
/// even, and clamped to the codec's max dimension. > 1 supersamples for sharpness (the presenter
|
||||
/// downscales the larger decoded frame to this display); < 1 renders under native and upscales.
|
||||
/// The match-window path applies the SAME scale to the live window size in `MatchWindowFollower`.
|
||||
private func scaledMode() -> (width: UInt32, height: UInt32) {
|
||||
RenderScale.apply(
|
||||
baseWidth: width, baseHeight: height,
|
||||
scale: renderScale,
|
||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||
}
|
||||
|
||||
private func startSessionDirect(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||
@@ -621,7 +761,7 @@ struct ContentView: View {
|
||||
if let approvalReq { awaitingApproval = approvalReq }
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
width: scaledMode().width, height: scaledMode().height,
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||
@@ -804,7 +944,7 @@ struct ContentView: View {
|
||||
}
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
width: scaledMode().width, height: scaledMode().height,
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: pref,
|
||||
gamepad: pad,
|
||||
@@ -815,71 +955,3 @@ struct ContentView: View {
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
||||
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
||||
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
||||
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
||||
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
||||
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
||||
/// says otherwise).
|
||||
private struct FullscreenController: NSViewRepresentable {
|
||||
let active: Bool
|
||||
@Binding var isFullscreen: Bool
|
||||
|
||||
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
||||
/// and removed on dismantle.
|
||||
final class Coordinator {
|
||||
var observers: [NSObjectProtocol] = []
|
||||
weak var observedWindow: NSWindow?
|
||||
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
let want = active
|
||||
let isFullscreen = $isFullscreen
|
||||
let coordinator = context.coordinator
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
observeTransitions(of: window, coordinator: coordinator)
|
||||
let isFull = window.styleMask.contains(.fullScreen)
|
||||
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
||||
if want != isFull { window.toggleFullScreen(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
||||
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
||||
/// no black gap in either direction.
|
||||
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
||||
guard coordinator.observedWindow !== window else { return }
|
||||
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||
coordinator.observers.removeAll()
|
||||
coordinator.observedWindow = window
|
||||
let isFullscreen = $isFullscreen
|
||||
for (name, value) in [
|
||||
(NSWindow.willEnterFullScreenNotification, true),
|
||||
(NSWindow.didExitFullScreenNotification, false),
|
||||
] {
|
||||
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||
forName: name, object: window, queue: .main
|
||||
) { _ in
|
||||
isFullscreen.wrappedValue = value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||
private struct ApprovalRequest {
|
||||
let host: StoredHost
|
||||
let advertisedFingerprint: Data?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
||||
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
||||
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
||||
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
||||
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
||||
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
||||
/// says otherwise).
|
||||
struct FullscreenController: NSViewRepresentable {
|
||||
let active: Bool
|
||||
@Binding var isFullscreen: Bool
|
||||
|
||||
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
||||
/// and removed on dismantle.
|
||||
final class Coordinator {
|
||||
var observers: [NSObjectProtocol] = []
|
||||
weak var observedWindow: NSWindow?
|
||||
/// The last `active` value we DROVE the window to. We toggle only when `active` itself
|
||||
/// changes (stream start/end) — never to correct a mismatch — so a deliberate mid-session
|
||||
/// toggle (⌃⌘F / the green button) isn't snapped back on the next SwiftUI update.
|
||||
var lastActive: Bool?
|
||||
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
let want = active
|
||||
let isFullscreen = $isFullscreen
|
||||
let coordinator = context.coordinator
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
observeTransitions(of: window, coordinator: coordinator)
|
||||
let isFull = window.styleMask.contains(.fullScreen)
|
||||
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
||||
// Drive the window only on an `active` EDGE (stream start/end), not to close a mismatch —
|
||||
// so a user's ⌃⌘F / green-button toggle stays put. First pass (lastActive == nil) just
|
||||
// records the state without toggling, so mounting never yanks a window into fullscreen.
|
||||
if coordinator.lastActive != want {
|
||||
coordinator.lastActive = want
|
||||
if want != isFull { window.toggleFullScreen(nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
||||
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
||||
/// no black gap in either direction.
|
||||
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
||||
guard coordinator.observedWindow !== window else { return }
|
||||
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||
coordinator.observers.removeAll()
|
||||
coordinator.observedWindow = window
|
||||
let isFullscreen = $isFullscreen
|
||||
for (name, value) in [
|
||||
(NSWindow.willEnterFullScreenNotification, true),
|
||||
(NSWindow.didExitFullScreenNotification, false),
|
||||
] {
|
||||
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||
forName: name, object: window, queue: .main
|
||||
) { _ in
|
||||
isFullscreen.wrappedValue = value
|
||||
})
|
||||
}
|
||||
// The Stream menu's "Toggle Fullscreen" (⌃⌘F) and InputCapture's captured-state interception
|
||||
// both post this; flip the KEY window only (posted app-wide, object nil). The transition
|
||||
// observers above then mirror the real state back into the binding.
|
||||
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .punktfunkToggleFullscreen, object: nil, queue: .main
|
||||
) { [weak window] _ in
|
||||
guard let window, window.isKeyWindow else { return }
|
||||
window.toggleFullScreen(nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -20,6 +20,12 @@ struct AddHostSheet: View {
|
||||
@State private var address: String
|
||||
@State private var port: Int
|
||||
@State private var mac: String
|
||||
#if os(macOS)
|
||||
/// Share the clipboard with this host (macOS sessions only; design
|
||||
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
|
||||
/// advertises the capability at connect.
|
||||
@State private var clipboardSync: Bool
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case name, address, port, mac
|
||||
@@ -41,6 +47,9 @@ struct AddHostSheet: View {
|
||||
_port = State(initialValue: Int(existing?.port ?? 9777))
|
||||
let stored = existing?.macAddresses ?? []
|
||||
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
||||
#if os(macOS)
|
||||
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -96,6 +105,9 @@ struct AddHostSheet: View {
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Toggle("Share clipboard with this host", isOn: $clipboardSync)
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
@@ -147,6 +159,11 @@ struct AddHostSheet: View {
|
||||
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||
host.port = UInt16(clamping: port)
|
||||
host.macAddresses = Self.parseMacs(mac)
|
||||
#if os(macOS)
|
||||
// nil when off: the key stays absent from the saved JSON (forward-compat, and "never
|
||||
// opted in" and "opted out" read the same — off).
|
||||
host.clipboardSync = clipboardSync ? true : nil
|
||||
#endif
|
||||
onSave(host)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
|
||||
// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process
|
||||
// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
//
|
||||
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
|
||||
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
|
||||
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
|
||||
// PunktfunkShared so the widget's configuration intent can share it.
|
||||
|
||||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
|
||||
/// Load a full saved host (MACs, address) from the shared App-Group store by id — HostEntity only
|
||||
/// carries id + name.
|
||||
private func loadStoredHost(_ id: UUID) -> StoredHost? {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return nil }
|
||||
return hosts.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
|
||||
/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval
|
||||
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
|
||||
struct ConnectToHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Connect to Host"
|
||||
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
|
||||
var launchID: String?
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let url = DeepLink.connect(host: host.id, launchID: launchID).url
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
||||
/// home, wake the tower") without foregrounding the app.
|
||||
struct WakeHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Wake Host"
|
||||
static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.")
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else {
|
||||
throw IntentError.noWakeAddress
|
||||
}
|
||||
PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message
|
||||
/// show as the intent's failure text.
|
||||
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case noWakeAddress
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .noWakeAddress:
|
||||
// One string LITERAL — LocalizedStringResource is ExpressibleByStringLiteral, but a
|
||||
// `"…" + "…"` concatenation is a runtime String it can't convert.
|
||||
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk can learn it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
||||
/// well under the 10-shortcut cap.
|
||||
struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: ConnectToHostIntent(),
|
||||
phrases: [
|
||||
"Connect to \(\.$host) in \(.applicationName)",
|
||||
"Stream \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||
AppShortcut(
|
||||
intent: WakeHostIntent(),
|
||||
phrases: [
|
||||
"Wake \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Wake Host", systemImageName: "power")
|
||||
AppShortcut(
|
||||
intent: EndStreamIntent(),
|
||||
phrases: [
|
||||
"End the \(.applicationName) stream",
|
||||
],
|
||||
shortTitle: "End Stream", systemImageName: "stop.fill")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,89 @@
|
||||
// Owns the ActivityKit Live Activity lifecycle for a streaming session (iPhone/iPad only). Driven
|
||||
// by ContentView from the session model's published state (phase / isBackgrounded / deadline) so
|
||||
// none of this leaks into the cross-platform SessionModel. Local updates only (`pushType: nil`) —
|
||||
// the app process is alive whenever there's a session to report, so there's no push token plumbing.
|
||||
//
|
||||
// Gated os(iOS): ActivityKit is iPhone/iPad only. Minimum deployment is iOS 17, so no @available
|
||||
// guards are needed (Activity has existed since 16.1).
|
||||
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
// PunktfunkKit re-exports PunktfunkShared (@_exported), so the app target sees PunktfunkSessionAttributes
|
||||
// without linking the Shared product directly — same pattern as StoredHost in HostStore.
|
||||
import PunktfunkKit
|
||||
|
||||
@MainActor
|
||||
final class SessionActivityController {
|
||||
private var activity: Activity<PunktfunkSessionAttributes>?
|
||||
/// The last pushed state, so an update can mutate one field and keep the rest (notably
|
||||
/// `startedAt`, which the Lock-Screen timer ticks from).
|
||||
private var state: PunktfunkSessionAttributes.ContentState?
|
||||
|
||||
/// How far past the next expected update to mark the content stale — a frozen opt-out session
|
||||
/// then greys out instead of showing a lying clock.
|
||||
private static let staleWindow: TimeInterval = 90
|
||||
|
||||
var isActive: Bool { activity != nil }
|
||||
|
||||
/// End any Activity left over from a previous launch that was killed mid-session. Call once at
|
||||
/// app start (ContentView.onAppear).
|
||||
static func sweepOrphans() {
|
||||
Task {
|
||||
for activity in Activity<PunktfunkSessionAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Live Activity for a freshly-streaming session. No-op if the user disabled Live
|
||||
/// Activities for the app, or one is already up.
|
||||
func begin(hostID: UUID, hostName: String, launchTitle: String?, modeLine: String, startedAt: Date) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled, activity == nil else { return }
|
||||
let attributes = PunktfunkSessionAttributes(
|
||||
hostID: hostID, hostName: hostName, launchTitle: launchTitle)
|
||||
let initial = PunktfunkSessionAttributes.ContentState(
|
||||
stage: .streaming, startedAt: startedAt, modeLine: modeLine)
|
||||
state = initial
|
||||
do {
|
||||
activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: content(initial),
|
||||
pushType: nil)
|
||||
} catch {
|
||||
activity = nil
|
||||
state = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesced update: mutate the running state in place (keeps `startedAt` etc.) and push once.
|
||||
/// No-op when there's no live Activity.
|
||||
func update(_ mutate: (inout PunktfunkSessionAttributes.ContentState) -> Void) {
|
||||
guard let activity, var next = state else { return }
|
||||
mutate(&next)
|
||||
state = next
|
||||
Task { await activity.update(content(next)) }
|
||||
}
|
||||
|
||||
/// End with a final "ended" state, dismissed a few seconds later.
|
||||
func end() {
|
||||
guard let activity, var final = state else {
|
||||
self.activity = nil
|
||||
state = nil
|
||||
return
|
||||
}
|
||||
self.activity = nil
|
||||
state = nil
|
||||
final.stage = .ending
|
||||
final.backgroundDeadline = nil
|
||||
Task {
|
||||
await activity.end(content(final), dismissalPolicy: .after(.now + 4))
|
||||
}
|
||||
}
|
||||
|
||||
private func content(_ s: PunktfunkSessionAttributes.ContentState)
|
||||
-> ActivityContent<PunktfunkSessionAttributes.ContentState> {
|
||||
ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -139,6 +139,18 @@ final class SessionModel: ObservableObject {
|
||||
private var audio: SessionAudio?
|
||||
private var gamepadCapture: GamepadCapture?
|
||||
private var gamepadFeedback: GamepadFeedback?
|
||||
#if os(macOS)
|
||||
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created
|
||||
/// by `beginStreaming` when the per-host toggle is on and the host advertises
|
||||
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
|
||||
private var clipboardSync: ClipboardSync?
|
||||
#endif
|
||||
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu
|
||||
/// item's title and the settings footnote. Always false off-macOS.
|
||||
@Published private(set) var clipboardEnabled = false
|
||||
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
||||
/// (backend unavailable / policy disabled / …); 0 = OK.
|
||||
@Published private(set) var clipboardReason: UInt8 = 0
|
||||
#if os(tvOS)
|
||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||
@@ -148,6 +160,16 @@ final class SessionModel: ObservableObject {
|
||||
|
||||
var isBusy: Bool { phase != .idle }
|
||||
|
||||
/// True while a streaming session is running in the background under the opt-in keep-alive
|
||||
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
|
||||
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
|
||||
@Published private(set) var isBackgrounded = false
|
||||
/// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) — drives the
|
||||
/// Live Activity countdown. Set alongside `backgroundTimer`.
|
||||
@Published private(set) var backgroundDeadline: Date?
|
||||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
||||
private var backgroundTimer: DispatchSourceTimer?
|
||||
|
||||
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
||||
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
||||
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
||||
@@ -332,6 +354,48 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background keep-alive (opt-in, iOS)
|
||||
|
||||
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
|
||||
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
|
||||
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
|
||||
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
|
||||
func enterBackground(timeoutMinutes: Int) {
|
||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||||
isBackgrounded = true
|
||||
conn.setVideoDropped(true)
|
||||
audio?.setMicMuted(true)
|
||||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
||||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
||||
let minutes = max(1, timeoutMinutes)
|
||||
backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||
timer.schedule(deadline: .now() + .seconds(minutes * 60))
|
||||
timer.setEventHandler { [weak self] in
|
||||
// The timer fires on `.main`, so the actor's executor is the main thread here.
|
||||
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
|
||||
}
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = timer
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor —
|
||||
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
|
||||
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
|
||||
func exitBackground() {
|
||||
guard isBackgrounded else { return }
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
audio?.setMicMuted(false)
|
||||
if let conn = connection {
|
||||
conn.setVideoDropped(false)
|
||||
conn.requestKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||
func confirmTrust() -> Data? {
|
||||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||||
@@ -349,6 +413,11 @@ final class SessionModel: ObservableObject {
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -361,6 +430,12 @@ final class SessionModel: ObservableObject {
|
||||
#endif
|
||||
let feedback = gamepadFeedback
|
||||
gamepadFeedback = nil
|
||||
#if os(macOS)
|
||||
let clipboard = clipboardSync
|
||||
clipboardSync = nil
|
||||
#endif
|
||||
clipboardEnabled = false
|
||||
clipboardReason = 0
|
||||
if let conn = connection {
|
||||
// Drain-thread teardown waits the pullers out and close() waits out in-flight
|
||||
// polls + joins the Rust worker threads — keep all of it off the main actor,
|
||||
@@ -368,6 +443,9 @@ final class SessionModel: ObservableObject {
|
||||
Task.detached {
|
||||
audio?.stop()
|
||||
feedback?.stop()
|
||||
#if os(macOS)
|
||||
clipboard?.stop() // disables sync on the wire while the connection is still up
|
||||
#endif
|
||||
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
||||
if deliberate { conn.disconnectQuit() }
|
||||
conn.close()
|
||||
@@ -376,6 +454,9 @@ final class SessionModel: ObservableObject {
|
||||
Task.detached {
|
||||
audio?.stop()
|
||||
feedback?.stop()
|
||||
#if os(macOS)
|
||||
clipboard?.stop()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
connection = nil
|
||||
@@ -450,6 +531,14 @@ final class SessionModel: ObservableObject {
|
||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||
feedback.start()
|
||||
gamepadFeedback = feedback
|
||||
#if os(macOS)
|
||||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
||||
// hosts never see a ClipControl). Same trust gate as audio — nothing is announced
|
||||
// during the trust prompt.
|
||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
let pointer = SiriRemotePointer(connection: conn)
|
||||
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
@@ -458,6 +547,40 @@ final class SessionModel: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Create + start the session's clipboard bridge and route its host acks into the published
|
||||
/// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips
|
||||
/// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain).
|
||||
private func startClipboardSync(_ conn: PunktfunkConnection) {
|
||||
let sync = ClipboardSync(connection: conn)
|
||||
sync.onState = { [weak self] enabled, _, reason in
|
||||
Task { @MainActor in
|
||||
self?.clipboardEnabled = enabled
|
||||
self?.clipboardReason = reason
|
||||
}
|
||||
}
|
||||
sync.start()
|
||||
clipboardSync = sync
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Flip clipboard sync mid-session (the Stream menu). Off → on requires the host cap; on →
|
||||
/// off tears the bridge down (off-main — the drain join must not block the main actor) and
|
||||
/// tells the host, which drops any selection we own there. No-op off-macOS or while idle.
|
||||
func toggleClipboardSync() {
|
||||
#if os(macOS)
|
||||
guard let conn = connection, phase == .streaming else { return }
|
||||
if let sync = clipboardSync {
|
||||
clipboardSync = nil
|
||||
clipboardEnabled = false
|
||||
clipboardReason = 0
|
||||
Task.detached { sync.stop() }
|
||||
} else if conn.hostSupportsClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startStatsTimer() {
|
||||
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
||||
latencySplit.reset() // no stale receipts/samples from a previous session
|
||||
|
||||
@@ -21,6 +21,12 @@ import SwiftUI
|
||||
/// `.focusedSceneValue` so the Scene-level commands can drive it.
|
||||
struct SessionFocus {
|
||||
var isStreaming: Bool
|
||||
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item —
|
||||
/// macOS-only UI, but the fact is platform-neutral).
|
||||
var clipboardAvailable: Bool
|
||||
/// Clipboard sync is live (host-acked) — drives the item's Stop/Share title.
|
||||
var clipboardOn: Bool
|
||||
var toggleClipboard: () -> Void
|
||||
var disconnect: () -> Void
|
||||
}
|
||||
|
||||
@@ -58,6 +64,15 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true)
|
||||
#if os(macOS)
|
||||
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
|
||||
// when the host doesn't advertise the cap (older host / operator policy off).
|
||||
Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") {
|
||||
session?.toggleClipboard()
|
||||
}
|
||||
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
||||
#endif
|
||||
Divider()
|
||||
Button("Disconnect") { session?.disconnect() }
|
||||
.keyboardShortcut("d", modifiers: [.control, .option, .shift])
|
||||
|
||||
@@ -440,6 +440,34 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -49,6 +49,8 @@ struct SettingsView: View {
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
@@ -242,6 +244,7 @@ struct SettingsView: View {
|
||||
pointerSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
|
||||
@@ -11,32 +11,13 @@
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
struct StoredHost: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var address: String
|
||||
var port: UInt16 = 9777
|
||||
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||
var pinnedSHA256: Data?
|
||||
/// Last time a streaming session actually started (nil until the first one).
|
||||
var lastConnected: Date?
|
||||
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
var macAddresses: [String]?
|
||||
|
||||
var displayName: String { name.isEmpty ? address : name }
|
||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
|
||||
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
|
||||
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
|
||||
|
||||
extension StoredHost {
|
||||
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
||||
@@ -86,8 +67,14 @@ final class HostStore: ObservableObject {
|
||||
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||
@Published var probedOnline: Set<StoredHost.ID> = []
|
||||
|
||||
/// The App-Group suite — shared with the Widget/Live-Activity extension so a launcher widget
|
||||
/// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see
|
||||
/// `AppGroup.defaults`).
|
||||
private let defaults = AppGroup.defaults
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: Self.key),
|
||||
Self.migrateToAppGroupIfNeeded()
|
||||
if let data = defaults.data(forKey: Self.key),
|
||||
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||
hosts = decoded
|
||||
} else {
|
||||
@@ -95,6 +82,20 @@ final class HostStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before
|
||||
/// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no
|
||||
/// hosts yet but standard does. The old value is LEFT in place — during a staged TestFlight
|
||||
/// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from
|
||||
/// the not-yet-updated app. Remove the standard copy a release later.
|
||||
private static func migrateToAppGroupIfNeeded() {
|
||||
let suite = AppGroup.defaults
|
||||
let standard = UserDefaults.standard
|
||||
guard suite !== standard else { return } // un-entitled fallback: nothing to migrate
|
||||
guard suite.data(forKey: key) == nil,
|
||||
let legacy = standard.data(forKey: key) else { return }
|
||||
suite.set(legacy, forKey: key)
|
||||
}
|
||||
|
||||
func add(_ host: StoredHost) {
|
||||
hosts.append(host)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ final class HostStore: ObservableObject {
|
||||
|
||||
func markConnected(_ hostID: UUID) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||
hosts[i].lastConnected = Date()
|
||||
hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget
|
||||
}
|
||||
|
||||
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||
@@ -158,7 +159,17 @@ final class HostStore: ObservableObject {
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
UserDefaults.standard.set(data, forKey: Self.key)
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
||||
}
|
||||
|
||||
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
|
||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
||||
/// `.never`-refresh entries and relies on this push.
|
||||
private func reloadHostsWidget() {
|
||||
#if canImport(WidgetKit) && os(iOS)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,23 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio
|
||||
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
|
||||
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
|
||||
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
|
||||
/// — this stops the actual capture, which is the privacy-relevant part. Main thread.
|
||||
public func setMicMuted(_ muted: Bool) {
|
||||
stateLock.lock()
|
||||
let capture = captureEngine
|
||||
stateLock.unlock()
|
||||
guard let capture else { return }
|
||||
if muted {
|
||||
capture.pause()
|
||||
} else if !flag.isStopped {
|
||||
try? capture.start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2).
|
||||
//
|
||||
// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy:
|
||||
//
|
||||
// * **Local copy → host**: a changeCount poll announces the *format list* (`clipOffer`); the
|
||||
// bytes cross only when a host app pastes (a `.fetchRequest` event, answered from the live
|
||||
// pasteboard by `clipServe`).
|
||||
// * **Host copy → local**: a `.remoteOffer` writes one NSPasteboardItem whose
|
||||
// NSPasteboardItemDataProvider fires only when a Mac app actually pastes — the provider then
|
||||
// blocks (on its provider thread, never main) on a `clipFetch` round-trip.
|
||||
//
|
||||
// Password-manager respect: pasteboards marked `org.nspasteboard.ConcealedType` or
|
||||
// `org.nspasteboard.TransientType` are never announced, never fetchable. Echo suppression: the
|
||||
// changeCount of every write WE make is recorded so the announce poll skips it (§3.4).
|
||||
//
|
||||
// Phase 1 formats only (text / RTF / HTML / PNG). Files (NSFilePromiseProvider) ride Phase 2.
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// One live session's clipboard bridge. Created by the session model when streaming begins on a
|
||||
/// host that advertises `HOST_CAP_CLIPBOARD` and whose per-host toggle is on; `stop()` before the
|
||||
/// connection closes. All pasteboard traffic runs on one dedicated drain thread plus the
|
||||
/// AppKit-owned provider threads (paste fulfillment).
|
||||
public final class ClipboardSync: NSObject {
|
||||
/// Wire MIME ↔ NSPasteboard type for the Phase-1 vocabulary (§3.5), in announce order.
|
||||
private static let wireToPasteboard: [(wire: String, type: NSPasteboard.PasteboardType)] = [
|
||||
("text/plain;charset=utf-8", .string),
|
||||
("text/rtf", .rtf),
|
||||
("text/html", .html),
|
||||
("image/png", .png),
|
||||
]
|
||||
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
||||
/// with these — see nspasteboard.org).
|
||||
private static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
|
||||
private static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
|
||||
|
||||
/// How long a blocked paste waits for the host's bytes before providing nothing (§5.2).
|
||||
private static let fetchTimeout: TimeInterval = 10
|
||||
/// Serve chunk size for host-side pastes of our data (bounds the per-call ABI copy).
|
||||
private static let serveChunk = 4 << 20
|
||||
|
||||
private let connection: PunktfunkConnection
|
||||
/// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files —
|
||||
/// always 0 in Phase 1).
|
||||
private let controlFlags: UInt8
|
||||
|
||||
/// Host `.state` updates, delivered on the main queue — drives the toggle/footnote UI.
|
||||
public var onState: ((_ enabled: Bool, _ policy: UInt8, _ reason: UInt8) -> Void)?
|
||||
|
||||
// Drain-thread state (touched only on the drain thread once started).
|
||||
private var offerSeq: UInt32 = 0
|
||||
private var lastSeenChangeCount = 0
|
||||
/// The changeCount of the last pasteboard write WE made (echo suppression + "do we still
|
||||
/// own the pasteboard" on teardown/clear).
|
||||
private var ownedChangeCount = -1
|
||||
/// The host offer currently installed on the local pasteboard (nil = none).
|
||||
private var installedRemoteSeq: UInt32?
|
||||
|
||||
/// Outbound fetches a blocked paste is waiting on. Guarded by `fetchLock` — appended by the
|
||||
/// drain thread (`.data` events), consumed by AppKit's provider threads.
|
||||
private struct PendingFetch {
|
||||
var buffer = Data()
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
var failed = false
|
||||
}
|
||||
private let fetchLock = NSLock()
|
||||
private var pendingFetches: [UInt32: PendingFetch] = [:]
|
||||
|
||||
private final class StopFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var stopped = false
|
||||
func stop() {
|
||||
lock.lock()
|
||||
stopped = true
|
||||
lock.unlock()
|
||||
}
|
||||
var isStopped: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return stopped
|
||||
}
|
||||
}
|
||||
private let flag = StopFlag()
|
||||
private let drainDone = DispatchSemaphore(value: 0)
|
||||
private var started = false
|
||||
/// Set by the app-activation observer, cleared by the drain loop: the user may have copied
|
||||
/// elsewhere and is coming back to paste — announce immediately instead of waiting out the
|
||||
/// poll interval.
|
||||
private final class OneShot: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var raised = false
|
||||
func raise() {
|
||||
lock.lock()
|
||||
raised = true
|
||||
lock.unlock()
|
||||
}
|
||||
func takeIfRaised() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let was = raised
|
||||
raised = false
|
||||
return was
|
||||
}
|
||||
}
|
||||
private let checkNow = OneShot()
|
||||
private var activationObserver: NSObjectProtocol?
|
||||
|
||||
public init(connection: PunktfunkConnection, allowFiles: Bool = false) {
|
||||
self.connection = connection
|
||||
self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2
|
||||
_ = allowFiles
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit { flag.stop() }
|
||||
|
||||
/// Enable sync with the host and start the drain thread. The host answers the enable with a
|
||||
/// `.state` event (surfaced via `onState`) — `BACKEND_UNAVAILABLE` et al. arrive there.
|
||||
public func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
connection.clipControl(enabled: true, flags: controlFlags)
|
||||
// Baseline: whatever is on the pasteboard when sync starts is announced immediately —
|
||||
// the "copy first, then connect and paste" flow must work.
|
||||
lastSeenChangeCount = -1
|
||||
activationObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
|
||||
) { [checkNow] _ in checkNow.raise() }
|
||||
let connection = self.connection
|
||||
let flag = self.flag
|
||||
let thread = Thread { [weak self] in
|
||||
var lastAnnounceCheck = Date.distantPast
|
||||
while !flag.isStopped {
|
||||
// Drain events (bounded burst so a chatty host can't starve the announce poll).
|
||||
var drained = 0
|
||||
while drained < 32, !flag.isStopped {
|
||||
let ev: PunktfunkConnection.ClipEvent?
|
||||
do {
|
||||
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
|
||||
} catch {
|
||||
flag.stop() // session closed
|
||||
break
|
||||
}
|
||||
guard let ev else { break }
|
||||
drained += 1
|
||||
self?.handle(ev)
|
||||
}
|
||||
// Announce poll: every 500 ms, or immediately after app activation (§5.2).
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastAnnounceCheck) >= 0.5
|
||||
|| self?.checkNow.takeIfRaised() == true
|
||||
{
|
||||
lastAnnounceCheck = now
|
||||
self?.announceIfChanged()
|
||||
}
|
||||
}
|
||||
self?.drainDone.signal()
|
||||
}
|
||||
thread.name = "punktfunk-clipboard"
|
||||
thread.qualityOfService = .utility
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Disable sync and join the drain thread. Called off-main before `connection.close()`
|
||||
/// (the same discipline as the audio/feedback drains). If the local pasteboard still holds
|
||||
/// our remote-offer items, they are cleared — their providers die with us.
|
||||
public func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
if let obs = activationObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
activationObserver = nil
|
||||
}
|
||||
connection.clipControl(enabled: false, flags: 0)
|
||||
flag.stop()
|
||||
drainDone.wait()
|
||||
// Fail every paste still blocked on us so no provider thread waits out its timeout.
|
||||
fetchLock.lock()
|
||||
for (_, pending) in pendingFetches {
|
||||
pending.done.signal()
|
||||
}
|
||||
pendingFetches.removeAll()
|
||||
fetchLock.unlock()
|
||||
let pb = NSPasteboard.general
|
||||
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
|
||||
pb.clearContents()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local copy → host (announce)
|
||||
|
||||
/// Announce the local pasteboard's format list when it changed (skipping our own writes and
|
||||
/// concealed/transient pasteboards). Runs on the drain thread.
|
||||
private func announceIfChanged() {
|
||||
let pb = NSPasteboard.general
|
||||
let count = pb.changeCount
|
||||
guard count != lastSeenChangeCount else { return }
|
||||
lastSeenChangeCount = count
|
||||
if count == ownedChangeCount { return } // our own write (a remote offer) — never echo
|
||||
installedRemoteSeq = nil // a local copy replaced the host's offer
|
||||
let types = pb.types ?? []
|
||||
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
|
||||
offerSeq &+= 1
|
||||
var kinds = Self.wireToPasteboard
|
||||
.filter { types.contains($0.type) }
|
||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
||||
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots
|
||||
// TIFF) and only sometimes PNG — announce the portable `image/png` whenever ANY
|
||||
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5).
|
||||
if !kinds.contains(where: { $0.mime == "image/png" }),
|
||||
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic"))
|
||||
{
|
||||
kinds.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)
|
||||
}
|
||||
|
||||
// MARK: - Event handling (drain thread)
|
||||
|
||||
private func handle(_ ev: PunktfunkConnection.ClipEvent) {
|
||||
switch ev {
|
||||
case let .state(enabled, policy, reason):
|
||||
if let onState {
|
||||
DispatchQueue.main.async { onState(enabled, policy, reason) }
|
||||
}
|
||||
case let .remoteOffer(seq, kinds):
|
||||
installRemoteOffer(seq: seq, kinds: kinds)
|
||||
case let .fetchRequest(reqId, seq, _, mime):
|
||||
serveFetch(reqId: reqId, seq: seq, mime: mime)
|
||||
case let .data(xferId, chunk, last):
|
||||
fetchLock.lock()
|
||||
if var pending = pendingFetches[xferId] {
|
||||
pending.buffer.append(chunk)
|
||||
pendingFetches[xferId] = pending
|
||||
if last {
|
||||
pendingFetches[xferId]?.done.signal()
|
||||
}
|
||||
}
|
||||
fetchLock.unlock()
|
||||
case let .cancelled(id), let .error(id, _):
|
||||
fetchLock.lock()
|
||||
if var pending = pendingFetches[id] {
|
||||
pending.failed = true
|
||||
pendingFetches[id] = pending
|
||||
pending.done.signal()
|
||||
}
|
||||
fetchLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host copy → local (lazy install + blocked-paste fetch)
|
||||
|
||||
/// Write one NSPasteboardItem advertising the host's formats, each backed by a lazy data
|
||||
/// provider — bytes cross only when a Mac app pastes. Empty `kinds` = the host cleared its
|
||||
/// clipboard: drop our item if it's still current.
|
||||
private func installRemoteOffer(seq: UInt32, kinds: [PunktfunkConnection.ClipKind]) {
|
||||
let pb = NSPasteboard.general
|
||||
let types = kinds.compactMap { kind in
|
||||
Self.wireToPasteboard.first(where: { $0.wire == kind.mime })?.type
|
||||
}
|
||||
guard !types.isEmpty else {
|
||||
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
|
||||
pb.clearContents()
|
||||
ownedChangeCount = pb.changeCount
|
||||
lastSeenChangeCount = pb.changeCount
|
||||
}
|
||||
installedRemoteSeq = nil
|
||||
return
|
||||
}
|
||||
let item = NSPasteboardItem()
|
||||
item.setDataProvider(RemoteOfferProvider(sync: self, seq: seq), forTypes: types)
|
||||
pb.clearContents()
|
||||
pb.writeObjects([item])
|
||||
installedRemoteSeq = seq
|
||||
ownedChangeCount = pb.changeCount
|
||||
lastSeenChangeCount = pb.changeCount
|
||||
}
|
||||
|
||||
/// Blocked-paste fulfillment: fetch one wire format of host offer `seq` and wait (provider
|
||||
/// thread) for the drain thread to assemble the chunks. Nil on timeout/cancel/error — the
|
||||
/// paste then provides nothing rather than hanging (§3.4).
|
||||
///
|
||||
/// `fetchLock` is held ACROSS the `clipFetch` so the pending entry exists before the drain
|
||||
/// thread can process the first `.data` event (its `handle` takes `fetchLock` after
|
||||
/// releasing the connection's clipboard lock — no cycle).
|
||||
fileprivate func fetchBlocking(seq: UInt32, wireMime: String) -> Data? {
|
||||
fetchLock.lock()
|
||||
guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else {
|
||||
fetchLock.unlock()
|
||||
return nil
|
||||
}
|
||||
pendingFetches[xferId] = PendingFetch()
|
||||
let done = pendingFetches[xferId]!.done
|
||||
fetchLock.unlock()
|
||||
let outcome = done.wait(timeout: .now() + Self.fetchTimeout)
|
||||
fetchLock.lock()
|
||||
let pending = pendingFetches.removeValue(forKey: xferId)
|
||||
fetchLock.unlock()
|
||||
if outcome == .timedOut {
|
||||
connection.clipCancel(id: xferId)
|
||||
return nil
|
||||
}
|
||||
guard let pending, !pending.failed else { return nil }
|
||||
return pending.buffer
|
||||
}
|
||||
|
||||
// MARK: - Host paste of our data (serve)
|
||||
|
||||
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the
|
||||
/// local clipboard changed since that announce) is cancelled — never serve mismatched bytes.
|
||||
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
|
||||
let pb = NSPasteboard.general
|
||||
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
|
||||
let data = Self.readWireData(pb, mime)
|
||||
else {
|
||||
connection.clipCancel(id: reqId)
|
||||
return
|
||||
}
|
||||
var offset = 0
|
||||
while offset < data.count {
|
||||
let end = min(offset + Self.serveChunk, data.count)
|
||||
connection.clipServe(
|
||||
reqId: reqId, data: data.subdata(in: offset..<end), last: end == data.count)
|
||||
offset = end
|
||||
}
|
||||
if data.isEmpty {
|
||||
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;
|
||||
/// the fetch then blocks this provider thread (never main) until the host's bytes arrive or the
|
||||
/// timeout provides nothing. One provider per installed remote offer — a dead sync (weak) or a
|
||||
/// superseded offer provides nothing.
|
||||
private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider {
|
||||
private weak var sync: ClipboardSync?
|
||||
private let seq: UInt32
|
||||
|
||||
init(sync: ClipboardSync, seq: UInt32) {
|
||||
self.sync = sync
|
||||
self.seq = seq
|
||||
}
|
||||
|
||||
func pasteboard(
|
||||
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
|
||||
provideDataForType type: NSPasteboard.PasteboardType
|
||||
) {
|
||||
guard let sync,
|
||||
let wire = wireMime(for: type),
|
||||
let data = sync.fetchBlocking(seq: seq, wireMime: wire)
|
||||
else { return }
|
||||
item.setData(data, forType: type)
|
||||
}
|
||||
|
||||
private func wireMime(for type: NSPasteboard.PasteboardType) -> String? {
|
||||
switch type {
|
||||
case .string: return "text/plain;charset=utf-8"
|
||||
case .rtf: return "text/rtf"
|
||||
case .html: return "text/html"
|
||||
case .png: return "image/png"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -11,6 +11,9 @@
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the
|
||||
// dependency-free widget extension can share them; PunktfunkKit re-exports the module.
|
||||
import PunktfunkShared
|
||||
|
||||
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||
public struct Artwork: Codable, Hashable, Sendable {
|
||||
@@ -64,10 +67,6 @@ public enum LibraryError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The management API's default port — adjacent to the GameStream block; matches
|
||||
/// `mgmt::DEFAULT_PORT` on the host.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
/// Stateless fetcher for a host's library.
|
||||
public enum LibraryClient {
|
||||
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
||||
|
||||
@@ -196,6 +196,10 @@ public final class PunktfunkConnection {
|
||||
/// Same role for the host-timing (0xCF) puller — its own plane in the core, drained
|
||||
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
|
||||
private let statsLock = NSLock()
|
||||
/// Same role for the shared-clipboard drain thread (`nextClipboard` — its own plane in the
|
||||
/// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too:
|
||||
/// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple.
|
||||
private let clipboardLock = NSLock()
|
||||
|
||||
/// Negotiated session mode (host-confirmed).
|
||||
public private(set) var width: UInt32 = 0
|
||||
@@ -346,6 +350,16 @@ public final class PunktfunkConnection {
|
||||
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
|
||||
/// never need it.
|
||||
public private(set) var shardPayload: UInt32 = 1408
|
||||
|
||||
/// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` /
|
||||
/// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say.
|
||||
public private(set) var hostCaps: UInt8 = 0
|
||||
/// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) — the gate for
|
||||
/// offering the clipboard toggle. Absent on an older host, or one whose operator policy
|
||||
/// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark.
|
||||
public var hostSupportsClipboard: Bool {
|
||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
|
||||
}
|
||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||
@@ -461,6 +475,9 @@ public final class PunktfunkConnection {
|
||||
var shard: UInt32 = 1408
|
||||
_ = punktfunk_connection_shard_payload(handle, &shard)
|
||||
shardPayload = shard
|
||||
var caps: UInt8 = 0
|
||||
_ = punktfunk_connection_host_caps(handle, &caps)
|
||||
hostCaps = caps
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
@@ -534,6 +551,23 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Background-keep-alive video drop (opt-in). While true, both video pumps keep DRAINING
|
||||
/// `nextAU()` (so QUIC flow control and host pacing stay healthy) but DISCARD each AU before any
|
||||
/// VideoToolbox/Metal decode or render — the crash/jetsam-safe way to hold a backgrounded
|
||||
/// session (audio keeps rendering; no GPU work off-screen). Set on `SessionModel.enterBackground`,
|
||||
/// cleared on `exitBackground` (which then requests a fresh IDR; the pump's re-anchor gate
|
||||
/// auto-arms on the resumed frame-index gap). Its own tiny lock — read on the pump thread every
|
||||
/// iteration, written on the main actor; never contends the ABI/plane locks.
|
||||
private let videoDropLock = NSLock()
|
||||
private var videoDropped = false
|
||||
public var isVideoDropped: Bool {
|
||||
videoDropLock.lock(); defer { videoDropLock.unlock() }
|
||||
return videoDropped
|
||||
}
|
||||
public func setVideoDropped(_ dropped: Bool) {
|
||||
videoDropLock.lock(); videoDropped = dropped; videoDropLock.unlock()
|
||||
}
|
||||
|
||||
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||
@@ -760,6 +794,34 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the
|
||||
/// uniform replacement for per-platform rumble policy. The engine owns every decision
|
||||
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
|
||||
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
|
||||
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the
|
||||
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
|
||||
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
|
||||
{
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
|
||||
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return (pad, low, high, backstop)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||
@@ -970,10 +1032,12 @@ public final class PunktfunkConnection {
|
||||
audioLock.lock()
|
||||
feedbackLock.lock()
|
||||
statsLock.lock()
|
||||
clipboardLock.lock()
|
||||
abiLock.lock()
|
||||
let h = handle
|
||||
handle = nil
|
||||
abiLock.unlock()
|
||||
clipboardLock.unlock()
|
||||
statsLock.unlock()
|
||||
feedbackLock.unlock()
|
||||
audioLock.unlock()
|
||||
@@ -1035,6 +1099,163 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_send_rich_input(h, &rich)
|
||||
}
|
||||
|
||||
// MARK: - Shared clipboard (design/clipboard-and-file-transfer.md §5)
|
||||
|
||||
/// One advertised clipboard format in a lazy offer — the format list crosses the wire,
|
||||
/// the bytes only on a fetch.
|
||||
public struct ClipKind: Sendable, Equatable {
|
||||
public let mime: String
|
||||
/// Best-effort size in bytes; `0` = unknown.
|
||||
public let sizeHint: UInt64
|
||||
public init(mime: String, sizeHint: UInt64 = 0) {
|
||||
self.mime = mime
|
||||
self.sizeHint = sizeHint
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared-clipboard event from `nextClipboard`. The drain thread turns these into
|
||||
/// NSPasteboard operations (`ClipboardSync`).
|
||||
public enum ClipEvent: Sendable, Equatable {
|
||||
/// The host copied: its lazy format list (empty = the host clipboard was cleared).
|
||||
/// Fetch a format with `clipFetch(seq:mime:)` when a local app pastes.
|
||||
case remoteOffer(seq: UInt32, kinds: [ClipKind])
|
||||
/// Host ack / policy / backend update for `clipControl` (`CLIP_REASON_*`).
|
||||
case state(enabled: Bool, policy: UInt8, reason: UInt8)
|
||||
/// The host is pasting OUR offered data — answer with `clipServe(reqId:...)`.
|
||||
case fetchRequest(reqId: UInt32, seq: UInt32, fileIndex: UInt32, mime: String)
|
||||
/// Bytes for a fetch we started (`last` = final chunk).
|
||||
case data(xferId: UInt32, chunk: Data, last: Bool)
|
||||
/// A transfer was cancelled (either side).
|
||||
case cancelled(id: UInt32)
|
||||
/// A transfer failed (`status` = a PunktfunkStatus code).
|
||||
case error(id: UInt32, status: Int32)
|
||||
}
|
||||
|
||||
/// Enable/disable the shared clipboard for this session. Opt-in: nothing is announced or
|
||||
/// served until enabled. The host answers with a `.state` event carrying the resolved
|
||||
/// outcome (its operator policy is authoritative). Best-effort — a dropped call on a
|
||||
/// closing session is fine.
|
||||
public func clipControl(enabled: Bool, flags: UInt8 = 0) {
|
||||
clipboardLock.lock()
|
||||
defer { clipboardLock.unlock() }
|
||||
guard let h = liveHandle() else { return }
|
||||
_ = punktfunk_connection_clipboard_control(h, enabled, flags)
|
||||
}
|
||||
|
||||
/// Announce that the local pasteboard changed — the lazy format-list offer (`seq` monotonic,
|
||||
/// newest wins; empty `kinds` clears the host side). The bytes cross only if the host fetches.
|
||||
public func clipOffer(seq: UInt32, kinds: [ClipKind]) {
|
||||
clipboardLock.lock()
|
||||
defer { clipboardLock.unlock() }
|
||||
guard let h = liveHandle() else { return }
|
||||
guard !kinds.isEmpty else {
|
||||
_ = punktfunk_connection_clipboard_offer(h, seq, nil, 0)
|
||||
return
|
||||
}
|
||||
// The C array borrows NUL-terminated strings for the duration of the call only.
|
||||
let cStrings = kinds.map { strdup($0.mime) }
|
||||
defer { cStrings.forEach { free($0) } }
|
||||
let arr = zip(cStrings, kinds).map {
|
||||
PunktfunkClipKind(mime: $0.map { UnsafePointer($0) }, size_hint: $1.sizeHint)
|
||||
}
|
||||
_ = arr.withUnsafeBufferPointer {
|
||||
punktfunk_connection_clipboard_offer(h, seq, $0.baseAddress, UInt(arr.count))
|
||||
}
|
||||
}
|
||||
|
||||
/// Start pulling one format of the host's offer `seq` (a local app is pasting). Returns the
|
||||
/// transfer id echoed on the resulting `.data`/`.error`/`.cancelled` events, or nil when the
|
||||
/// session is closing.
|
||||
public func clipFetch(seq: UInt32, mime: String, fileIndex: UInt32 = UInt32.max) -> UInt32? {
|
||||
clipboardLock.lock()
|
||||
defer { clipboardLock.unlock() }
|
||||
guard let h = liveHandle() else { return nil }
|
||||
var xfer: UInt32 = 0
|
||||
let rc = mime.withCString {
|
||||
punktfunk_connection_clipboard_fetch(h, seq, $0, fileIndex, &xfer)
|
||||
}
|
||||
return rc == statusOK ? xfer : nil
|
||||
}
|
||||
|
||||
/// Provide bytes answering a `.fetchRequest` (the host is pasting our offered data). Call
|
||||
/// repeatedly to stream; `last = true` completes the transfer. An empty final chunk is fine.
|
||||
public func clipServe(reqId: UInt32, data: Data, last: Bool) {
|
||||
clipboardLock.lock()
|
||||
defer { clipboardLock.unlock() }
|
||||
guard let h = liveHandle() else { return }
|
||||
if data.isEmpty {
|
||||
_ = punktfunk_connection_clipboard_serve(h, reqId, nil, 0, last)
|
||||
} else {
|
||||
data.withUnsafeBytes { p in
|
||||
_ = punktfunk_connection_clipboard_serve(
|
||||
h, reqId, p.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a clipboard transfer by id — an outbound fetch's `xferId` or an inbound
|
||||
/// `.fetchRequest`'s `reqId`.
|
||||
public func clipCancel(id: UInt32) {
|
||||
clipboardLock.lock()
|
||||
defer { clipboardLock.unlock() }
|
||||
guard let h = liveHandle() else { return }
|
||||
_ = punktfunk_connection_clipboard_cancel(h, id)
|
||||
}
|
||||
|
||||
/// Pull the next shared-clipboard event; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Drain from a single dedicated thread (`ClipboardSync`) — the event's borrowed
|
||||
/// payload is copied into the returned `ClipEvent` before the next poll can overwrite it.
|
||||
public func nextClipboard(timeoutMs: UInt32) throws -> ClipEvent? {
|
||||
clipboardLock.lock()
|
||||
defer { clipboardLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
var ev = PunktfunkClipEvent()
|
||||
let rc = punktfunk_connection_next_clipboard(h, &ev, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return Self.decodeClipEvent(ev)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a raw C clip event (whose `data` borrows a per-connection slot) into an owned Swift
|
||||
/// value. Unknown kinds (a newer core) decode to nil and are skipped by the drain.
|
||||
private static func decodeClipEvent(_ ev: PunktfunkClipEvent) -> ClipEvent? {
|
||||
let payload = ev.data.map { Data(bytes: $0, count: Int(ev.len)) } ?? Data()
|
||||
switch Int32(ev.kind) {
|
||||
case PUNKTFUNK_CLIP_REMOTE_OFFER:
|
||||
// One `mime\tsize_hint\n` line per advertised format.
|
||||
let kinds = String(decoding: payload, as: UTF8.self)
|
||||
.split(separator: "\n")
|
||||
.compactMap { line -> ClipKind? in
|
||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
||||
guard let mime = parts.first, !mime.isEmpty else { return nil }
|
||||
let hint = parts.count > 1 ? UInt64(parts[1]) ?? 0 : 0
|
||||
return ClipKind(mime: String(mime), sizeHint: hint)
|
||||
}
|
||||
return .remoteOffer(seq: ev.transfer_id, kinds: kinds)
|
||||
case PUNKTFUNK_CLIP_STATE:
|
||||
return .state(enabled: ev.enabled != 0, policy: ev.policy, reason: ev.reason)
|
||||
case PUNKTFUNK_CLIP_FETCH_REQUEST:
|
||||
return .fetchRequest(
|
||||
reqId: ev.transfer_id, seq: ev.seq, fileIndex: ev.file_index,
|
||||
mime: String(decoding: payload, as: UTF8.self))
|
||||
case PUNKTFUNK_CLIP_DATA:
|
||||
return .data(xferId: ev.transfer_id, chunk: payload, last: ev.last != 0)
|
||||
case PUNKTFUNK_CLIP_CANCELLED:
|
||||
return .cancelled(id: ev.transfer_id)
|
||||
case PUNKTFUNK_CLIP_ERROR:
|
||||
return .error(id: ev.transfer_id, status: ev.status)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||
|
||||
@@ -23,6 +23,7 @@ import Combine
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
public final class GamepadFeedback {
|
||||
private let connection: PunktfunkConnection
|
||||
@@ -154,21 +155,18 @@ public final class GamepadFeedback {
|
||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||
// rumble/HID latency low while leaving the lock free between polls.
|
||||
//
|
||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
||||
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
||||
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
||||
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
||||
// Rumble arrives as EFFECTIVE commands from the core's shared policy engine
|
||||
// (design/rumble-root-fix.md §D): the engine owns leases, legacy staleness,
|
||||
// and close-drain zeros, and its per-pad mailbox already coalesces — a
|
||||
// stalled drain wakes to ONE current-level command per pad, and a stop can
|
||||
// never be shed by a queue. Apply verbatim, in order.
|
||||
var rumbleBurst = 0
|
||||
while rumbleBurst < 64, !flag.isStopped,
|
||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
||||
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
||||
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
||||
self?.routeRumble(
|
||||
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
|
||||
rumbleBurst += 1
|
||||
}
|
||||
for (pad, n) in newestByPad {
|
||||
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||
var burst = 0
|
||||
@@ -217,15 +215,15 @@ public final class GamepadFeedback {
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
||||
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
|
||||
/// live renderer — one that just left the forwarded set — is dropped.
|
||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
|
||||
let renderer = withRouting { rumbleByPad[pad] }
|
||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||
renderer?.apply(low: low, high: high)
|
||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
||||
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
|
||||
}
|
||||
|
||||
private func withRouting<R>(_ body: () -> R) -> R {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
@MainActor
|
||||
public final class GamepadManager: ObservableObject {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
|
||||
@@ -23,23 +23,6 @@ enum RumbleTuning {
|
||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||
/// zero is never throttled.
|
||||
static let minRebakeSeconds: TimeInterval = 0.025
|
||||
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
|
||||
/// the **legacy-host fallback only** — an old host sends no self-termination lease, so its
|
||||
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
|
||||
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
|
||||
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
|
||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
||||
|
||||
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
|
||||
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
|
||||
static let noTTL: UInt32 = .max
|
||||
|
||||
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
|
||||
/// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination
|
||||
/// lease in seconds (render the level for at most this long unless the host renews it).
|
||||
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
|
||||
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
|
||||
}
|
||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||
/// refresh must never rebuild a player.
|
||||
static let levelEpsilon: Float = 1.0 / 256.0
|
||||
@@ -110,13 +93,15 @@ enum RumbleTuning {
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
|
||||
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
|
||||
/// slider level indefinitely.
|
||||
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||
/// the call sites' intent.
|
||||
struct Policy {
|
||||
let staleAfter: TimeInterval?
|
||||
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
|
||||
static let manual = Policy(staleAfter: nil)
|
||||
static let session = Policy()
|
||||
static let manual = Policy()
|
||||
}
|
||||
|
||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||
@@ -160,13 +145,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
||||
/// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the
|
||||
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
|
||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
||||
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
|
||||
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the
|
||||
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
|
||||
private var envelopeDeadline: DispatchTime?
|
||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||
@@ -247,17 +228,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
||||
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
||||
/// lapses mid-effect.
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
queue.async {
|
||||
self.lastCommand = .now()
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
|
||||
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
|
||||
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
|
||||
self.envelopeDeadline = .now() + lease
|
||||
} else {
|
||||
self.envelopeDeadline = nil
|
||||
}
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
@@ -275,7 +248,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
self.ticker?.cancel()
|
||||
self.ticker = nil
|
||||
self.target = (0, 0)
|
||||
self.envelopeDeadline = nil
|
||||
self.wasActive = false
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
@@ -331,25 +303,11 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
healthSink?(problem)
|
||||
}
|
||||
|
||||
/// Watchdog + housekeeping heartbeat while audible.
|
||||
/// Housekeeping heartbeat while audible: segment re-arm, HID keepalive, backoff retries.
|
||||
/// Every liveness decision (lease expiry, legacy-host staleness, session close) lives in the
|
||||
/// core policy engine now — it emits explicit zero commands, so the renderer never guesses
|
||||
/// when a level should end.
|
||||
private func tick() {
|
||||
if let deadline = envelopeDeadline {
|
||||
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
|
||||
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
|
||||
// — the whole point of the envelope model: the motor can't outlive the host's intent.
|
||||
if target != (0, 0), DispatchTime.now() >= deadline {
|
||||
log.warning("rumble: envelope expired unrenewed — silencing")
|
||||
target = (0, 0)
|
||||
envelopeDeadline = nil
|
||||
}
|
||||
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
||||
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
|
||||
// channel (or host) died while a motor was on. A direct-connected pad would have been
|
||||
// stopped by its game long ago — force the same outcome.
|
||||
log.warning(
|
||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
||||
target = (0, 0)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import UIKit
|
||||
|
||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// PunktfunkShared holds what the app AND the widget extension both need — the stored-host model,
|
||||
// the settings-key names, the App-Group constant, the deep-link grammar, and the Live Activity
|
||||
// attributes — in a module that links neither the Rust core nor the presentation layer.
|
||||
//
|
||||
// Re-export it so every existing consumer of PunktfunkKit (`import PunktfunkKit`) keeps seeing
|
||||
// `StoredHost`, `DefaultsKey`, `punktfunkDefaultMgmtPort`, `DeepLink`, etc. with no call-site churn.
|
||||
// (Files INSIDE PunktfunkKit still `import PunktfunkShared` explicitly — Swift imports are
|
||||
// file-scoped; the re-export only reaches downstream modules.)
|
||||
@_exported import PunktfunkShared
|
||||
@@ -8,6 +8,7 @@
|
||||
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||
/// rename the cases freely, never the strings.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
#if os(tvOS)
|
||||
import UIKit
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Metal
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||
@@ -431,6 +432,15 @@ public final class Stage2Pipeline {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain one AU (flow control + host pacing) and discard it
|
||||
// BEFORE any VideoToolbox decode or Metal render — no GPU work off-screen. The
|
||||
// decoder session is left intact; exitBackground requests a fresh IDR and the
|
||||
// re-anchor gate arms on the resumed frame-index gap so concealed frames are
|
||||
// withheld until it lands.
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||
@@ -686,6 +696,13 @@ public final class Stage2Pipeline {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain + discard before the Metal wavelet decode
|
||||
// (PyroWave is all-intra, so the resumed frame heals on its own — no IDR
|
||||
// request needed, just no GPU work off-screen).
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
onFrame?(au)
|
||||
if let newest = newestIndex,
|
||||
|
||||
@@ -63,6 +63,14 @@ final class StreamPump {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain one AU to keep QUIC flow control + host pacing
|
||||
// healthy, then discard it BEFORE any decode/enqueue — no VideoToolbox/Metal work
|
||||
// off-screen. Skips all recovery/gate bookkeeping too; exitBackground requests a
|
||||
// fresh IDR and the re-anchor gate re-arms on the resumed frame-index gap.
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import AVFoundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// The App-Group foundation shared by the app and its extensions (Widgets / Live Activity).
|
||||
//
|
||||
// PunktfunkShared is deliberately dependency-free: it links NEITHER PunktfunkKit (which drags in
|
||||
// the Rust staticlib + presentation layer) NOR any Apple UI framework. A widget process gets ~30 MB,
|
||||
// so everything an extension needs — the stored-host model + its JSON codec, the settings-key names,
|
||||
// the deep-link grammar, and (later) the Live Activity attributes — lives here and here only.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The one App-Group identifier, matched by `Config/*.entitlements`
|
||||
/// (`com.apple.security.application-groups`). Registered on the developer portal for both the app
|
||||
/// id (`io.unom.punktfunk`) and the widget extension id (`io.unom.punktfunk.widgets`).
|
||||
public enum AppGroup {
|
||||
public static let suiteName = "group.io.unom.punktfunk"
|
||||
|
||||
/// The shared defaults suite. Non-nil in a correctly-entitled process; falls back to
|
||||
/// `.standard` if the group is somehow unavailable (unsigned `swift run`, a misprovisioned
|
||||
/// build) so the app still functions single-process rather than crashing — the widget just
|
||||
/// won't see the same store there.
|
||||
public static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// The `punktfunk://` deep-link grammar — the single builder/parser shared by the widget (which
|
||||
// emits links via `widgetURL`/`Link`) and the app (`ContentView.onOpenURL`, which routes them into
|
||||
// the existing connect path). Keeping both sides on one type means the wire format can't drift.
|
||||
//
|
||||
// Grammar (v1):
|
||||
// punktfunk://connect/<host-uuid> — connect to a stored host
|
||||
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> — connect and ask the host to launch it
|
||||
//
|
||||
// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded
|
||||
// on parse, so ids with reserved characters survive the round trip.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DeepLink: Equatable {
|
||||
/// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any.
|
||||
case connect(host: UUID, launchID: String?)
|
||||
|
||||
public static let scheme = "punktfunk"
|
||||
|
||||
/// Build the canonical URL for a route. Non-optional: every route is representable.
|
||||
public var url: URL {
|
||||
switch self {
|
||||
case let .connect(host, launchID):
|
||||
var comps = URLComponents()
|
||||
comps.scheme = Self.scheme
|
||||
comps.host = "connect"
|
||||
comps.path = "/\(host.uuidString)"
|
||||
if let launchID, !launchID.isEmpty {
|
||||
comps.queryItems = [URLQueryItem(name: "launch", value: launchID)]
|
||||
}
|
||||
// URLComponents percent-encodes the query value; force-unwrap is safe for a URL we
|
||||
// fully control (scheme/host/path are all valid).
|
||||
return comps.url!
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of
|
||||
/// case in the scheme and of a trailing slash on the path.
|
||||
public init?(_ url: URL) {
|
||||
guard url.scheme?.lowercased() == Self.scheme else { return nil }
|
||||
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
switch comps.host?.lowercased() {
|
||||
case "connect":
|
||||
// Path is "/<uuid>"; strip the leading slash and any trailing one.
|
||||
let raw = comps.path
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let host = UUID(uuidString: raw) else { return nil }
|
||||
let launch = comps.queryItems?
|
||||
.first(where: { $0.name == "launch" })?.value
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self = .connect(host: host, launchID: launch)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-1
@@ -1,7 +1,8 @@
|
||||
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
|
||||
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
|
||||
// reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These
|
||||
// live in PunktfunkKit because both the app and the kit's views read them.
|
||||
// live in the dependency-free PunktfunkShared module (re-exported by PunktfunkKit) because the app,
|
||||
// the kit's views, AND the widget extension all read them — the widget needs `DefaultsKey.hosts`.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -20,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"
|
||||
@@ -68,6 +77,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
|
||||
@@ -111,6 +135,16 @@ public enum DefaultsKey {
|
||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||
public static let autoWake = "punktfunk.autoWake"
|
||||
/// iOS/iPadOS: keep a streaming session ALIVE when the app is backgrounded (audio background
|
||||
/// mode). Off by default (today's freeze-on-background is the default). When on, backgrounding a
|
||||
/// live session keeps audio playing and the QUIC/pump live while DROPPING video decode, and a
|
||||
/// bounded timer (`backgroundTimeoutMinutes`) auto-disconnects if the user doesn't return. Read
|
||||
/// by ContentView's scenePhase driver. Hidden on tvOS/macOS.
|
||||
public static let backgroundKeepAlive = "punktfunk.backgroundKeepAlive"
|
||||
/// iOS/iPadOS: minutes a backgrounded keep-alive session runs before auto-disconnecting (a
|
||||
/// battery/thermal/bandwidth backstop). Default 10; the UI offers 1/5/10/30. The auto-disconnect
|
||||
/// is non-deliberate (host linger kept), so a late return reconnects fast. Read on enterBackground.
|
||||
public static let backgroundTimeoutMinutes = "punktfunk.backgroundTimeoutMinutes"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
@@ -120,4 +154,21 @@ extension Notification.Name {
|
||||
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
||||
/// 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` —
|
||||
/// the intent lives in PunktfunkShared and can't reach the app's `SessionModel` directly.
|
||||
public static let punktfunkEndActiveSession = Notification.Name("io.unom.punktfunk.end-active-session")
|
||||
|
||||
/// Posted by the Connect App Intent (Siri/Shortcuts) with a `punktfunk://` URL as `object`:
|
||||
/// the app routes it through the SAME `.onOpenURL` handler a widget tap uses (one router, one
|
||||
/// set of guards). The intent uses `openAppWhenRun`, so the app is foregrounded to receive it.
|
||||
public static let punktfunkOpenDeepLink = Notification.Name("io.unom.punktfunk.open-deep-link")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The saved-host as an App Intents entity — the parameter type for the Connect/Wake intents and
|
||||
// the configurable single-host widget. Lives in the shared module (not the app) because widget
|
||||
// *configuration* intents execute in the EXTENSION process, so the entity can't be app-only.
|
||||
//
|
||||
// AppIntents is genuinely available on macOS (13+), so this is gated on `canImport(AppIntents)`
|
||||
// (unlike ActivityKit, whose macOS types are unavailable) — it compiles on every platform and the
|
||||
// entity query reads the same shared App-Group store the widget does.
|
||||
|
||||
#if canImport(AppIntents)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
public struct HostEntity: AppEntity, Identifiable {
|
||||
public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Host")
|
||||
public static let defaultQuery = HostEntityQuery()
|
||||
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
|
||||
public init(id: UUID, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public init(_ host: StoredHost) {
|
||||
self.id = host.id
|
||||
self.name = host.displayName
|
||||
}
|
||||
|
||||
public var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
}
|
||||
|
||||
public struct HostEntityQuery: EntityQuery {
|
||||
public init() {}
|
||||
|
||||
public func entities(for identifiers: [UUID]) async throws -> [HostEntity] {
|
||||
Self.loadHosts().filter { identifiers.contains($0.id) }.map(HostEntity.init)
|
||||
}
|
||||
|
||||
/// Sorted most-recent first — Siri/Shortcuts and the widget config picker suggest recent hosts.
|
||||
public func suggestedEntities() async throws -> [HostEntity] {
|
||||
Self.loadHosts().map(HostEntity.init)
|
||||
}
|
||||
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
// The Live Activity's attributes — the ONE type that must be identical in the app (which starts
|
||||
// and updates the Activity) and the widget extension (which renders it). Hence it lives in the
|
||||
// dependency-free shared module.
|
||||
//
|
||||
// Gated on `os(iOS)`, NOT `canImport(ActivityKit)`: ActivityKit *imports* on macOS but its types
|
||||
// are `@available(macOS, unavailable)`, so canImport would wrongly admit this on the macOS build.
|
||||
// Live Activities are iPhone/iPad only (iPadOS reports os(iOS)).
|
||||
//
|
||||
// Naming/shape is a runtime contract: an Activity started by one build is decoded by the extension
|
||||
// of the same build, so keep `ContentState` Codable-stable across releases the way `StoredHost` is.
|
||||
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
public struct PunktfunkSessionAttributes: ActivityAttributes {
|
||||
// Static for the Activity's whole life (set at request time).
|
||||
public let hostID: UUID
|
||||
public let hostName: String
|
||||
/// The title of the launched game, if the session started from the library; nil for a plain
|
||||
/// host connect (nothing tracks the live foreground app mid-session).
|
||||
public let launchTitle: String?
|
||||
|
||||
public init(hostID: UUID, hostName: String, launchTitle: String?) {
|
||||
self.hostID = hostID
|
||||
self.hostName = hostName
|
||||
self.launchTitle = launchTitle
|
||||
}
|
||||
|
||||
public struct ContentState: Codable, Hashable {
|
||||
public enum Stage: String, Codable, Hashable {
|
||||
case streaming // foreground, live
|
||||
case background // backgrounded keep-alive (countdown running)
|
||||
case reconnecting // post-loss re-anchor hold
|
||||
case ending // torn down — final state before dismissal
|
||||
}
|
||||
|
||||
public var stage: Stage
|
||||
/// Session start — drives `Text(timerInterval:)` for a free client-side ticking clock (no
|
||||
/// per-second push needed).
|
||||
public var startedAt: Date
|
||||
/// e.g. "2560×1440 @120 · HEVC · HDR". Updated only when it actually changes.
|
||||
public var modeLine: String
|
||||
/// Coarse, updated sparsely (every ~30 s) — never the 1 Hz stats firehose.
|
||||
public var latencyMs: Int?
|
||||
public var mbps: Double?
|
||||
/// While backgrounded: when the keep-alive auto-disconnect fires — drives the countdown.
|
||||
public var backgroundDeadline: Date?
|
||||
|
||||
public init(
|
||||
stage: Stage, startedAt: Date, modeLine: String,
|
||||
latencyMs: Int? = nil, mbps: Double? = nil, backgroundDeadline: Date? = nil
|
||||
) {
|
||||
self.stage = stage
|
||||
self.startedAt = startedAt
|
||||
self.modeLine = modeLine
|
||||
self.latencyMs = latencyMs
|
||||
self.mbps = mbps
|
||||
self.backgroundDeadline = backgroundDeadline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind string for the Live Activity — kept next to the attributes so app + extension agree.
|
||||
public enum PunktfunkActivity {
|
||||
public static let kind = "PunktfunkSession"
|
||||
}
|
||||
#endif
|
||||
@@ -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,30 @@
|
||||
// App Intents that must compile into BOTH the app and the widget extension live here in the shared
|
||||
// module. Today that's `EndStreamIntent` — the Live Activity's "End stream" button (a
|
||||
// LiveActivityIntent runs in the APP's process) which M4 also surfaces to Siri/Shortcuts.
|
||||
//
|
||||
// Gated on os(iOS): LiveActivityIntent is part of ActivityKit's world (iPhone/iPad only). The M4
|
||||
// Connect/Wake intents that need the app's router live in the app target, not here.
|
||||
|
||||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
/// Ends the active streaming session. Backs the Live Activity's End button and the Shortcuts /
|
||||
/// Siri "End the Punktfunk stream" phrase. `perform()` runs in the app's process (LiveActivityIntent)
|
||||
/// — it posts `.punktfunkEndActiveSession`, which the app's SessionModel owner observes and turns
|
||||
/// into `disconnect(deliberate: true)` (the user explicitly ended it → quit-close the host).
|
||||
@available(iOS 17.0, *)
|
||||
public struct EndStreamIntent: LiveActivityIntent {
|
||||
public static let title: LocalizedStringResource = "End Punktfunk Stream"
|
||||
public static let description = IntentDescription("Ends the active Punktfunk streaming session.")
|
||||
|
||||
public init() {}
|
||||
|
||||
public func perform() async throws -> some IntentResult {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkEndActiveSession, object: nil)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
// The saved-host model + its on-disk JSON wire format — the widget/extension depends on BOTH, so
|
||||
// they live in the dependency-free shared module. The `ObservableObject` store that wraps them
|
||||
// (`HostStore`, with add/remove/pin/reachability) stays in the app target; discovery-join helpers
|
||||
// (`matches`, `advertises`) stay there too because they reference PunktfunkKit's `DiscoveredHost`.
|
||||
//
|
||||
// Wire-format stability: the JSON encoding of `StoredHost` is now a shared contract between the app
|
||||
// (writer) and the widget (reader). The `PunktfunkSharedTests` codec round-trip pins it — do not
|
||||
// rename the coding keys or make a stored `Optional` non-optional (older saved JSON must still
|
||||
// decode; synthesized Decodable treats a missing Optional as nil).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The management-API port default (distinct from the data-plane `port`). Lives here (not in
|
||||
/// PunktfunkKit's LibraryClient, which re-exports it) so `StoredHost.effectiveMgmtPort` can resolve
|
||||
/// it without the shared module taking a dependency on the kit.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
public struct StoredHost: Identifiable, Codable, Hashable {
|
||||
public var id = UUID()
|
||||
public var name: String
|
||||
public var address: String
|
||||
public var port: UInt16 = 9777
|
||||
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||
public var pinnedSHA256: Data?
|
||||
/// Last time a streaming session actually started (nil until the first one).
|
||||
public var lastConnected: Date?
|
||||
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
public var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
public var macAddresses: [String]?
|
||||
/// Share the clipboard with this host (macOS sessions; design/clipboard-and-file-transfer.md
|
||||
/// §5.3). Opt-in per host: nil/false = off (nil also keeps older saved JSON decoding — same
|
||||
/// forward-compat reason as `mgmtPort`). Honored only when the host advertises
|
||||
/// `HOST_CAP_CLIPBOARD`.
|
||||
public var clipboardSync: Bool?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
|
||||
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
|
||||
macAddresses: [String]? = nil, clipboardSync: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.pinnedSHA256 = pinnedSHA256
|
||||
self.lastConnected = lastConnected
|
||||
self.mgmtPort = mgmtPort
|
||||
self.macAddresses = macAddresses
|
||||
self.clipboardSync = clipboardSync
|
||||
}
|
||||
|
||||
public var displayName: String { name.isEmpty ? address : name }
|
||||
public var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
public var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
@@ -52,13 +52,6 @@ final class RumbleTuningTests: XCTestCase {
|
||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
||||
}
|
||||
|
||||
func testPolicies() {
|
||||
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
||||
// holds a level indefinitely.
|
||||
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
||||
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
||||
}
|
||||
|
||||
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
@@ -75,45 +68,22 @@ final class RumbleTuningTests: XCTestCase {
|
||||
renderer.stop()
|
||||
}
|
||||
|
||||
func testLeaseSecondsInterpretsWireTTL() {
|
||||
// The legacy no-lease sentinel → nil (fall back to the staleness watchdog).
|
||||
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
|
||||
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
|
||||
// A real lease → its duration in seconds (non-nil for any ttl != noTTL).
|
||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
|
||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
|
||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9)
|
||||
}
|
||||
|
||||
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
|
||||
// The whole point of v2: a host-supplied lease silences the motor faster than the
|
||||
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
|
||||
// default 400 ms TTL is well under that, on every platform.
|
||||
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
|
||||
XCTAssertNotNil(defaultTTL)
|
||||
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
|
||||
// The ticker must be able to observe an expired lease promptly (well within one TTL).
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
|
||||
}
|
||||
|
||||
/// A v2 envelope with a short TTL, left unrenewed, must self-silence — the renderer's core
|
||||
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
|
||||
func testEnvelopeExpiresWhenUnrenewed() {
|
||||
/// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at
|
||||
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
renderer.retarget(nil)
|
||||
// A 100 ms lease, then no renewal — the ticker (50 ms) must silence it on its own.
|
||||
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
// No assertion on private state; this exercises the expiry path + serial-queue teardown
|
||||
renderer.apply(low: 0x8000, high: 0x8000)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
renderer.apply(low: 0, high: 0)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
// No assertion on private state; this exercises the stop path + serial-queue teardown
|
||||
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
||||
renderer.stop()
|
||||
}
|
||||
|
||||
func testTuningRelationsTheDesignDependsOn() {
|
||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
||||
// but trip well before a stuck rumble reads as "still going".
|
||||
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
||||
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
||||
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
||||
// segment boundary and gap.
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
@@ -123,9 +93,8 @@ final class RumbleTuningTests: XCTestCase {
|
||||
// The rebake throttle must be far under the host refresh period, or refreshed level
|
||||
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
||||
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
||||
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
||||
// watchdog, or those deadlines could be overshot by a full period.
|
||||
// The ticker (which lands throttled levels) must outpace the HID keepalive, or its
|
||||
// deadline could be overshot by a full period.
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// PunktfunkShared is now a wire contract between the app (writer) and the widget extension +
|
||||
// deep-link senders (readers). These pin the two formats that cross that boundary:
|
||||
// • the `StoredHost` JSON codec — the widget decodes the exact bytes the app persisted, and
|
||||
// older saved JSON (missing `mgmtPort` / `macAddresses`) must still decode;
|
||||
// • the `punktfunk://` deep-link grammar — the widget builds URLs the app parses.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
import PunktfunkShared
|
||||
|
||||
final class SharedFoundationTests: XCTestCase {
|
||||
// MARK: - StoredHost JSON codec
|
||||
|
||||
func testStoredHostRoundTrips() throws {
|
||||
let host = StoredHost(
|
||||
id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
|
||||
name: "Tower", address: "192.168.1.173", port: 9777,
|
||||
pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||
lastConnected: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true)
|
||||
|
||||
let data = try JSONEncoder().encode(host)
|
||||
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
|
||||
XCTAssertEqual(decoded, host)
|
||||
}
|
||||
|
||||
/// Older saved hosts predate `mgmtPort`/`macAddresses` — a missing key must decode to nil, not
|
||||
/// throw (synthesized Decodable treats a missing Optional as nil). This is the forward-compat
|
||||
/// guarantee the widget depends on when reading a store written by any prior build.
|
||||
func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws {
|
||||
let json = """
|
||||
{"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let decoded = try JSONDecoder().decode(StoredHost.self, from: json)
|
||||
XCTAssertEqual(decoded.name, "Old")
|
||||
XCTAssertNil(decoded.mgmtPort)
|
||||
XCTAssertNil(decoded.macAddresses)
|
||||
XCTAssertNil(decoded.pinnedSHA256)
|
||||
XCTAssertNil(decoded.lastConnected)
|
||||
XCTAssertNil(decoded.clipboardSync)
|
||||
// Resolvers fall back cleanly.
|
||||
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
|
||||
XCTAssertEqual(decoded.wakeMacs, [])
|
||||
XCTAssertEqual(decoded.displayName, "Old")
|
||||
}
|
||||
|
||||
func testStoredHostDisplayNameFallsBackToAddress() {
|
||||
let host = StoredHost(name: "", address: "10.0.0.9")
|
||||
XCTAssertEqual(host.displayName, "10.0.0.9")
|
||||
}
|
||||
|
||||
// MARK: - DeepLink grammar
|
||||
|
||||
func testDeepLinkConnectRoundTrips() {
|
||||
let id = UUID()
|
||||
let link = DeepLink.connect(host: id, launchID: nil)
|
||||
let parsed = DeepLink(link.url)
|
||||
XCTAssertEqual(parsed, link)
|
||||
XCTAssertEqual(parsed, .connect(host: id, launchID: nil))
|
||||
}
|
||||
|
||||
func testDeepLinkConnectWithLaunchRoundTrips() {
|
||||
let id = UUID()
|
||||
// A store-qualified GameEntry.id with a reserved char must survive percent-encoding.
|
||||
let launch = "steam:570"
|
||||
let link = DeepLink.connect(host: id, launchID: launch)
|
||||
XCTAssertEqual(DeepLink(link.url), .connect(host: id, launchID: launch))
|
||||
}
|
||||
|
||||
func testDeepLinkParsesCanonicalString() throws {
|
||||
let id = UUID()
|
||||
let url = try XCTUnwrap(URL(string: "punktfunk://connect/\(id.uuidString)"))
|
||||
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
|
||||
}
|
||||
|
||||
func testDeepLinkRejectsForeignSchemeAndBadHost() throws {
|
||||
let id = UUID()
|
||||
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "https://connect/\(id.uuidString)"))))
|
||||
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://connect/not-a-uuid"))))
|
||||
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://bogus/\(id.uuidString)"))))
|
||||
}
|
||||
|
||||
func testDeepLinkSchemeIsCaseInsensitive() throws {
|
||||
let id = UUID()
|
||||
let url = try XCTUnwrap(URL(string: "PUNKTFUNK://connect/\(id.uuidString)"))
|
||||
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -17,6 +17,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",
|
||||
@@ -304,6 +318,18 @@ pub fn show(
|
||||
"",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let scale_names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
.map(|&s| render_scale_label(s))
|
||||
.collect();
|
||||
let scale_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Render scale",
|
||||
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
|
||||
(< 1×) for a lighter host — this device resamples to the window",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
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");
|
||||
@@ -346,6 +372,7 @@ pub fn show(
|
||||
.build();
|
||||
stream.add(res_row.widget());
|
||||
stream.add(hz_row.widget());
|
||||
stream.add(scale_row.widget());
|
||||
stream.add(&bitrate_row);
|
||||
stream.add(compositor_row.widget());
|
||||
stream.add(decoder_row.widget());
|
||||
@@ -500,6 +527,11 @@ pub fn show(
|
||||
res_row.set_selected(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);
|
||||
@@ -545,6 +577,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 =
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -175,6 +175,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 =
|
||||
|
||||
@@ -127,6 +127,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,
|
||||
@@ -372,6 +386,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 =
|
||||
|
||||
@@ -19,6 +19,20 @@ 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.
|
||||
@@ -193,6 +207,24 @@ pub(crate) fn settings_page(
|
||||
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];
|
||||
})
|
||||
.tooltip(
|
||||
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
|
||||
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
|
||||
);
|
||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
@@ -441,6 +473,7 @@ pub(crate) fn settings_page(
|
||||
settings_card(vec![
|
||||
res_combo.into(),
|
||||
hz_combo.into(),
|
||||
scale_combo.into(),
|
||||
fullscreen_toggle.into(),
|
||||
comp_combo.into(),
|
||||
]),
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user