forked from unom/punktfunk
merge: bring current main into the audio-substrate branch
Two conflicts, both unions of independent removals/fixes: main fixed the same three install.rs SAFETY comments this branch fixed (main's phrasing kept), and the runner provisioning drops BOTH env lines — main removed PF_FFVK_VULKAN_INCLUDE (pf-ffvk is gone since the FFmpeg replacement), this branch removed VBCABLE_DIR (the retirement).
This commit is contained in:
@@ -119,10 +119,15 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
|
||||
# (FFmpeg's hwcontext_vulkan.h includes it).
|
||||
# No libvulkan-dev: nothing here compiles or links against Vulkan (ash dlopens
|
||||
# libvulkan and pf-vkdecode binds nothing at build time), so neither the compile nor
|
||||
# dpkg-shlibdeps — which resolves DT_NEEDED sonames only — ever asks for it. The
|
||||
# client's `Depends: libvulkan1` is added by hand in packaging/debian/build-client-deb.sh
|
||||
# precisely because a dlopen is invisible to shlibdeps.
|
||||
# No libav*-dev: the client links no FFmpeg since M10 (§6 of
|
||||
# design/client-native-decode.md).
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
- name: Cache keys
|
||||
|
||||
@@ -148,7 +148,12 @@ jobs:
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
# Gated like Build/Push: only the docker CLI needs this login (Reconcile and Tag-for-release
|
||||
# authenticate via curl -u), so a cache-hit job with nothing to push must not be able to fail
|
||||
# on a login it never uses — proven on run 16013, where a host with a misconfigured daemon
|
||||
# failed exactly here on a hit=true leg.
|
||||
- name: Log in to the LAN registry
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
@@ -236,7 +241,9 @@ jobs:
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
# Same gate as the builders job above: the login only serves Push.
|
||||
- name: Log in to the LAN registry
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
|
||||
@@ -34,7 +34,10 @@ on:
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
|
||||
# binary's dependency closure must be listed here.
|
||||
# binary's dependency closure must be listed here — including the native decode rungs, or a
|
||||
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
|
||||
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
|
||||
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
@@ -42,6 +45,9 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-vaadec/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
@@ -128,7 +134,7 @@ jobs:
|
||||
# authselect trigger fires — so this line alone was never the fix for the failures
|
||||
# below. See the retry.sh bump for the real cause.
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
|
||||
#
|
||||
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
|
||||
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
|
||||
@@ -147,7 +153,7 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
# This job was the fleet's single heaviest network consumer: every run re-downloaded
|
||||
# the GNOME runtime + SDK + llvm/rust/ffmpeg extensions (multi-GB from Flathub) and
|
||||
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
|
||||
# every crate source. Both live in well-defined directories, both are idempotently
|
||||
# verified/extended by the steps below, and the central cache server restores them
|
||||
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
|
||||
@@ -251,7 +257,8 @@ jobs:
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
|
||||
# extensions. (No codec extension: the client links no FFmpeg — see the
|
||||
# manifest header.)
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
|
||||
@@ -96,9 +96,12 @@ jobs:
|
||||
- name: Prep
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
|
||||
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
|
||||
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers.
|
||||
# The host's Vulkan encode hand-rolls its structs, pyrowave-sys bindgens its own vendored
|
||||
# copy, and both host and client reach Vulkan through ash, which dlopens the loader. (The
|
||||
# HDR gamescope leg further down does need them, and pulls them itself via `dnf builddep
|
||||
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
||||
|
||||
@@ -141,8 +141,10 @@ jobs:
|
||||
# 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
|
||||
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
|
||||
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
|
||||
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
|
||||
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
|
||||
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
|
||||
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
|
||||
#
|
||||
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
||||
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
|
||||
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
|
||||
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
|
||||
# BOM/MAX_PATH runner gotchas.
|
||||
#
|
||||
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
|
||||
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
|
||||
# (windows-host.yml) is unchanged.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -49,7 +53,9 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
@@ -80,12 +86,10 @@ jobs:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg-arm64
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
@@ -94,7 +98,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
@@ -102,12 +106,9 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
|
||||
# crates (see windows.yml). FFMPEG_DIR selects the arch's import libs + is read by
|
||||
# pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
|
||||
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
|
||||
# and pack-msix.ps1 no longer copies runtime DLLs from one.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
|
||||
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
|
||||
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
|
||||
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
|
||||
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
|
||||
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
|
||||
# no-op once already present, so any runner with that label works with no manual dispatch step
|
||||
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
|
||||
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
|
||||
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
|
||||
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
|
||||
# flip when skia-binaries adds the target.
|
||||
#
|
||||
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
|
||||
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
|
||||
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
|
||||
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
|
||||
# `amf-qsv` leg link-imports them.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
|
||||
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
|
||||
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
|
||||
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
|
||||
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
|
||||
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
|
||||
# so fmt + test run only for x64.
|
||||
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
|
||||
#
|
||||
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
|
||||
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
|
||||
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# / per-arch vars are set in a step:
|
||||
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||
@@ -29,7 +32,6 @@
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
|
||||
#
|
||||
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
|
||||
@@ -55,7 +57,9 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
@@ -67,7 +71,9 @@ on:
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
@@ -110,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
@@ -120,21 +126,13 @@ jobs:
|
||||
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
||||
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
||||
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
||||
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
||||
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
||||
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
||||
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
||||
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
|
||||
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
|
||||
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
|
||||
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
|
||||
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td"
|
||||
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
@@ -152,7 +150,10 @@ jobs:
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
|
||||
# dependency but only LINTS the packages it is given, so a decode crate that starts the
|
||||
# run but is missing from this list would be gated by nothing.
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
@@ -160,9 +161,9 @@ jobs:
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
|
||||
|
||||
+5
-3
@@ -46,9 +46,11 @@ sudo apt install build-essential clang libclang-dev pkg-config cmake \
|
||||
libvulkan-dev
|
||||
```
|
||||
|
||||
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
|
||||
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
|
||||
PipeWire — and is not a substitute for the list above.)
|
||||
(The last two groups are the Linux client shell and the Vulkan session presenter; skip them only
|
||||
if you never build those crates. `libvulkan-dev` is for the LOADER's pkg-config/soname — ash
|
||||
dlopens it, and the client links no FFmpeg at all, so no libav*-dev appears here.
|
||||
`scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway, PipeWire —
|
||||
and is not a substitute for the list above.)
|
||||
|
||||
## Before you push
|
||||
|
||||
|
||||
Generated
+308
-41
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter",
|
||||
"env_filter 0.1.4",
|
||||
"log",
|
||||
]
|
||||
|
||||
@@ -204,6 +204,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_matches"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -341,6 +347,26 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atomig"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0f41f4bb89f5c6450325e283fb78c4a3d042181b54f3855ee2f872919f9863"
|
||||
dependencies = [
|
||||
"atomig-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomig-macro"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49c98dba06b920588de7d63f6acc23f1e6a9fade5fd6198e564506334fb5a4f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audiopus_sys"
|
||||
version = "0.2.2"
|
||||
@@ -446,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
@@ -475,6 +501,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
@@ -538,6 +570,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -556,7 +594,7 @@ version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -885,6 +923,15 @@ dependencies = [
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cros-codecs"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -991,6 +1038,37 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1101,6 +1179,29 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter 2.0.0",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1193,7 +1294,7 @@ version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"ffmpeg-sys-next",
|
||||
"libc",
|
||||
]
|
||||
@@ -1584,7 +1685,7 @@ version = "0.22.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1777,7 +1878,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
"zerocopy 0.8.52",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2127,6 +2228,42 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2291,7 +2428,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"cc",
|
||||
"convert_case",
|
||||
"cookie-factory",
|
||||
@@ -2486,6 +2623,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"log",
|
||||
]
|
||||
|
||||
@@ -2493,7 +2631,7 @@ dependencies = [
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -2517,7 +2655,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2530,7 +2668,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2848,6 +2986,14 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.24.0"
|
||||
@@ -2876,19 +3022,26 @@ dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"libc",
|
||||
"libloading",
|
||||
"mdns-sd",
|
||||
"openh264",
|
||||
"opus",
|
||||
"pf-ffvk",
|
||||
"pf-bitstream",
|
||||
"pf-dxvadec",
|
||||
"pf-update-check",
|
||||
"pf-vaadec",
|
||||
"pf-vkdecode",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rand 0.9.4",
|
||||
"rav1d",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"wasapi",
|
||||
@@ -2935,6 +3088,16 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
"pf-vkdecode",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.24.0"
|
||||
@@ -2959,15 +3122,6 @@ dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.24.0"
|
||||
@@ -3042,7 +3196,7 @@ dependencies = [
|
||||
"ash",
|
||||
"async-channel",
|
||||
"pf-client-core",
|
||||
"pf-ffvk",
|
||||
"pf-vkdecode",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"tracing",
|
||||
@@ -3069,13 +3223,22 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
"pf-vkdecode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3102,6 +3265,17 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.24.0"
|
||||
@@ -3153,7 +3327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
@@ -3207,7 +3381,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -3251,6 +3425,21 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3272,7 +3461,7 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
"zerocopy 0.8.52",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3311,7 +3500,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
@@ -3388,7 +3577,6 @@ name = "punktfunk-client-windows"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3431,7 +3619,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"zerocopy",
|
||||
"zerocopy 0.8.52",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3726,6 +3914,36 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1d"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1932f060d5e7bd49dc9f8b272c1dc5e9ce0ffe141c28be900265d3989b36c9ed"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"atomig",
|
||||
"bitflags 2.13.0",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"nasm-rs",
|
||||
"parking_lot",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"strum",
|
||||
"to_method",
|
||||
"zerocopy 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3777,7 +3995,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3934,7 +4152,7 @@ version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -3973,7 +4191,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -4127,7 +4345,7 @@ version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
"sdl3-image-sys",
|
||||
"sdl3-mixer-sys",
|
||||
@@ -4222,7 +4440,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -4428,7 +4646,7 @@ version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"lazy_static",
|
||||
"skia-bindings",
|
||||
]
|
||||
@@ -4521,6 +4739,28 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4724,6 +4964,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "to_method"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
@@ -5301,7 +5547,7 @@ version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
@@ -5313,7 +5559,7 @@ version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
@@ -5325,7 +5571,7 @@ version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -5338,7 +5584,7 @@ version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -5690,7 +5936,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"widestring",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -6182,13 +6428,34 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.7.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"zerocopy-derive 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
"zerocopy-derive 0.8.52",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.7.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-1
@@ -5,11 +5,12 @@ members = [
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-bitstream",
|
||||
"crates/pf-bitstream/vendor/cros-codecs",
|
||||
"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-update",
|
||||
@@ -23,6 +24,9 @@ members = [
|
||||
"crates/pf-capture",
|
||||
"crates/pf-inject",
|
||||
"crates/pf-vdisplay",
|
||||
"crates/pf-vkdecode",
|
||||
"crates/pf-dxvadec",
|
||||
"crates/pf-vaadec",
|
||||
"crates/pyrowave-sys",
|
||||
"crates/libvpl-sys",
|
||||
"clients/probe",
|
||||
|
||||
@@ -84,7 +84,9 @@ mid-stream mode renegotiation and a wall-clock skew handshake so latency stays v
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||
weaknesses). The host is managed through a REST API and web console. The **host** builds against
|
||||
FFmpeg 7 or 8; the **clients** link no FFmpeg at all — they decode natively (Vulkan Video, DXVA,
|
||||
VAAPI, VideoToolbox, MediaCodec, openh264 + rav1d).
|
||||
|
||||
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
|
||||
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
@@ -187,10 +189,13 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · native decode ladder · audio · SDL3 gamepads · trust · discovery
|
||||
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
|
||||
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
|
||||
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
|
||||
pf-bitstream/ H.264 / H.265 / AV1 bitstream parsing + per-AU decode plans — the one parser every native rung submits from
|
||||
pf-vkdecode/ native Vulkan Video decode (H.264 / H.265 / AV1) on the presenter's own device
|
||||
pf-dxvadec/ native DXVA buffer layouts + AuPlan → picparams conversion (the Windows D3D11VA rung)
|
||||
pf-vaadec/ native libva buffer layouts + AuPlan → picparams conversion (the Linux VAAPI rung)
|
||||
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
|
||||
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
|
||||
clients/
|
||||
@@ -245,9 +250,10 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
notice ship in the installed `licenses/` folder).
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows **host** build also
|
||||
bundles FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
notice ship in the installed `licenses/` folder). The **clients** bundle no FFmpeg — they link
|
||||
none.
|
||||
|
||||
### Trademarks
|
||||
|
||||
|
||||
+565
-222
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -4,10 +4,22 @@
|
||||
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
|
||||
#
|
||||
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
|
||||
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
|
||||
# dependency silently entering the linked set. All entries
|
||||
# carries anything not listed here — the regression guard against a copyleft dependency silently
|
||||
# entering the linked set. All entries
|
||||
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
|
||||
#
|
||||
# ⚠ KNOW THE LIMIT OF THIS GATE. cargo-about walks the CARGO graph, so it sees CRATES. A native
|
||||
# library linked through a permissively-licensed `-sys` crate is INVISIBLE to it, licence and all.
|
||||
# FFmpeg is precisely that shape: `ffmpeg-sys-next` is WTFPL and passes cleanly, while the LGPL
|
||||
# libavcodec/libavutil/swscale it link-imports — and which the Windows host installer bundles as
|
||||
# DLLs — never appear in the harvest at all. This gate did not catch FFmpeg entering the tree and
|
||||
# would not catch the next such library. Copyleft arriving as C behind a -sys crate is a REVIEW
|
||||
# question, not a CI one; the LGPL obligations we do carry are discharged by hand (the notice files
|
||||
# and the replaceable-DLL linkage, see packaging/windows/punktfunk-host.iss).
|
||||
#
|
||||
# Since M10 this is a HOST-only concern: the client links no FFmpeg, so for every client artifact
|
||||
# the crate graph and the linked set finally coincide and the gate means what it appears to mean.
|
||||
#
|
||||
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
|
||||
# which is what produced the committed baseline when cargo-about is unavailable offline.
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ FROM docker.io/library/archlinux:base-devel
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
|
||||
# they now no-op as `--needed` guards.
|
||||
# vulkan-headers rides the first list only because arch.yml's copy does; the package it actually
|
||||
# serves is the gamescope companion (packaging/gamescope/PKGBUILD makedepends). punktfunk itself
|
||||
# needs no system Vulkan headers — pyrowave-sys bindgens its own vendored copy and ash dlopens the
|
||||
# loader — but arch.yml builds gamescope with `makepkg -d`, so an absent makedepend would not be
|
||||
# reported as a missing dependency, only as a compile failure. Keep it.
|
||||
RUN pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
|
||||
@@ -27,8 +27,10 @@ RUN dnf -y install \
|
||||
mesa-libGL-devel mesa-libgbm-devel \
|
||||
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||
gtk4-devel libadwaita-devel SDL3-devel \
|
||||
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
|
||||
vulkan-headers \
|
||||
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers
|
||||
# (pyrowave-sys bindgens its own vendored copy; host and client both reach Vulkan through
|
||||
# ash, which dlopens the loader), and packaging/rpm/punktfunk.spec BuildRequires none.
|
||||
# rpm.yml's HDR gamescope leg needs them and pulls them with `dnf builddep gamescope`.
|
||||
&& dnf clean all
|
||||
|
||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||
|
||||
@@ -29,15 +29,16 @@ RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.l
|
||||
&& dpkg --add-architecture arm64
|
||||
|
||||
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
|
||||
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon). No
|
||||
# Vulkan dev package: nothing compiles or links against Vulkan — ash dlopens the loader, and
|
||||
# pyrowave-sys bindgens its own vendored headers.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
crossbuild-essential-arm64 \
|
||||
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 \
|
||||
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
|
||||
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
|
||||
|
||||
@@ -22,8 +22,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl-dev libegl-dev libgbm-dev \
|
||||
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
|
||||
libvulkan-dev \
|
||||
# No libvulkan-dev: nothing in the workspace compiles or links against Vulkan (pyrowave-sys
|
||||
# bindgens its own vendored headers, and both host and client reach Vulkan through ash, which
|
||||
# dlopens the loader), so neither the build nor deb.yml's dpkg-shlibdeps ever asks for it.
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -126,6 +127,8 @@ fun GamepadSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
|
||||
@@ -159,7 +162,7 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
@@ -445,12 +448,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
@@ -598,6 +602,18 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
|
||||
// mirror FROM, which a TV box lacks.
|
||||
if (hasGyroscope) {
|
||||
toggle(
|
||||
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
|
||||
"When the controller has no gyro of its own, send this phone's motion " +
|
||||
"sensors as controller 1's — for clip-on pads without one.",
|
||||
s.gyroOnPhone,
|
||||
) { update(s.copy(gyroOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
|
||||
@@ -158,6 +158,16 @@ data class Settings(
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
/**
|
||||
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
|
||||
* none of its own — for clip-on gamepads without an IMU, where the phone body moves with the
|
||||
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
|
||||
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
|
||||
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
|
||||
* link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices
|
||||
* without a gyroscope (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val gyroOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
@@ -300,6 +310,7 @@ class SettingsStore(context: Context) {
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
@@ -340,6 +351,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
@@ -390,6 +402,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_GYRO_ON_PHONE = "gyro_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
|
||||
@@ -77,6 +77,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -888,6 +889,18 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
|
||||
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
if (hasGyroscope) {
|
||||
ToggleRow(
|
||||
title = "Gyro from this phone",
|
||||
subtitle = "When the controller has no gyro, send this phone's motion " +
|
||||
"sensors as controller 1's",
|
||||
checked = s.gyroOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
|
||||
@@ -67,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
@@ -455,6 +456,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
|
||||
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
|
||||
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
|
||||
// down whenever a capture link — USB DualSense / SC2, pads with a real IMU — holds
|
||||
// pad 0), so it composes with the captures below without coordination here.
|
||||
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
|
||||
DeviceGyro(context, handle, router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
@@ -587,6 +598,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
|
||||
@@ -123,7 +123,9 @@ class GamepadPaletteTest {
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
val rows = buildSettingsRows(
|
||||
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
|
||||
) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
@@ -137,7 +139,9 @@ class GamepadPaletteTest {
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun rows() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
@@ -24,6 +24,7 @@ class GamepadSettingsRowsTest {
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a
|
||||
* controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input
|
||||
* motion plane — for clip-on and third-party pads that ship without a gyro, where the phone body
|
||||
* is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s
|
||||
* rumble-on-phone mirror with the data flowing the other way.
|
||||
*
|
||||
* On Android the only motion sources are the capture links (USB DualSense / SC2 — pads with a
|
||||
* real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly
|
||||
* [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing —
|
||||
* two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot
|
||||
* at all (motion never creates a host pad; a controller must have arrived first).
|
||||
*
|
||||
* Two properties this class enforces itself:
|
||||
* - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) —
|
||||
* sensor batching would trade the exact latency gyro aim exists to avoid;
|
||||
* - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the
|
||||
* host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
* producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
*
|
||||
* Units are the wire contract (mirrors `pf-client-core`'s constants): gyro rad/s → 20 LSB/°·s,
|
||||
* accel m/s² → g → 10000 LSB/g. Android's accelerometer reads specific force (+1 g on the up
|
||||
* axis at rest), which is the DualSense report's own convention — no sign flip. The one thing
|
||||
* the phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
|
||||
* the wire wants the controller frame the player sees (x right, y up, z out of the screen), so
|
||||
* each sample is rotated by the current display rotation — a phone clipped landscape must yaw
|
||||
* when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`;
|
||||
* correctable in one place if on-glass says otherwise.
|
||||
*/
|
||||
class DeviceGyro(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter,
|
||||
) : SensorEventListener {
|
||||
|
||||
private val sensorManager: SensorManager? =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
|
||||
/** For the live rotation; null on contexts without a display association (then portrait). */
|
||||
private val display: Display? = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(WindowManager::class.java)?.defaultDisplay
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private val thread = HandlerThread("pf-phone-gyro")
|
||||
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */
|
||||
private val lastAccel = intArrayOf(0, ACCEL_LSB_PER_G, 0)
|
||||
|
||||
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
|
||||
private var wasWriting = false
|
||||
|
||||
/** Register the listeners; a device without a gyroscope makes this a no-op. */
|
||||
fun start() {
|
||||
val sm = sensorManager ?: return
|
||||
val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
thread.start()
|
||||
val h = Handler(thread.looper)
|
||||
// ~200 Hz requested (the framework clamps to what the hardware offers), zero report
|
||||
// latency: batching is poison for gyro aim.
|
||||
sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister and join the sensor thread, then park the host pad's rotation at zero if this
|
||||
* mirror was the live writer. Call BEFORE the router is released / the handle freed —
|
||||
* teardown-ordered like the feedback threads.
|
||||
*/
|
||||
fun stop() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val rotation = display?.rotation ?: Surface.ROTATION_0
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
for (i in 0..2) {
|
||||
lastAccel[i] = (v[i] / GRAVITY * ACCEL_LSB_PER_G)
|
||||
.roundToInt().coerceIn(-32768, 32767)
|
||||
}
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
return
|
||||
}
|
||||
wasWriting = true
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0,
|
||||
(v[0] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
|
||||
(v[1] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
|
||||
(v[2] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
|
||||
lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free-fall. */
|
||||
private fun sendZero() {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Whether this device can source motion at all — gates the settings rows (a TV box
|
||||
* without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */
|
||||
fun available(context: Context): Boolean =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null
|
||||
|
||||
/** ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz). */
|
||||
private const val SAMPLING_PERIOD_US = 5000
|
||||
|
||||
/** The wire contract (pf-client-core `GYRO_LSB_PER_RAD_S`): 20 LSB/°·s from rad/s. */
|
||||
const val GYRO_LSB_PER_RAD_S = 20f * 180f / Math.PI.toFloat()
|
||||
|
||||
/** The wire contract (pf-client-core `ACCEL_LSB_PER_G`). */
|
||||
const val ACCEL_LSB_PER_G = 10_000
|
||||
|
||||
/** pf-client-core's `G`. */
|
||||
const val GRAVITY = 9.80665f
|
||||
|
||||
/**
|
||||
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
* same way under an in-plane rotation) into the controller frame for [rotation]
|
||||
* ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge,
|
||||
* +y top, +z out of the screen); the controller frame keeps +z (the screen always faces
|
||||
* the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 =
|
||||
* the device physically turned counter-clockwise, top to the player's LEFT.
|
||||
*/
|
||||
fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) {
|
||||
Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x
|
||||
Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = −x
|
||||
Surface.ROTATION_180 -> floatArrayOf(-x, -y, z)
|
||||
else -> floatArrayOf(x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,19 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad] is held by a capture-link slot ([ExternalPad] — USB DualSense /
|
||||
* SC2), whose motion arrives from the pad's OWN IMU. The phone-gyro mirror stands down for
|
||||
* those: two motion writers on one wire pad would fight. Synthetic ids are negative
|
||||
* ([EXTERNAL_ID_BASE]); real [InputDevice] ids are positive. Read from the phone-gyro thread
|
||||
* (the slot table is concurrent).
|
||||
*/
|
||||
fun padHasOwnMotion(pad: Int): Boolean =
|
||||
slots.any { (id, slot) -> slot.index == pad && id < 0 }
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.Surface
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone-gyro mirror's device→controller frame remap and its wire-unit constants
|
||||
* ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is
|
||||
* plain math. The matrix is derived (like the wire scale constants) — if on-glass says an axis
|
||||
* is wrong, fix [DeviceGyro.remap] AND these expectations together.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DeviceGyroTest {
|
||||
/** A distinct value per axis so a swapped or flipped component can't cancel out. */
|
||||
private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList()
|
||||
|
||||
@Test
|
||||
fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0))
|
||||
|
||||
@Test
|
||||
fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180))
|
||||
|
||||
/** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT:
|
||||
* player-right = device-bottom (−y), player-up = device-right (+x); z never changes. */
|
||||
@Test
|
||||
fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90))
|
||||
|
||||
/** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = −x. */
|
||||
@Test
|
||||
fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270))
|
||||
|
||||
/** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */
|
||||
@Test
|
||||
fun handednessPreserved() {
|
||||
for (r in listOf(
|
||||
Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270,
|
||||
)) {
|
||||
val x = DeviceGyro.remap(r, 1f, 0f, 0f)
|
||||
val y = DeviceGyro.remap(r, 0f, 1f, 0f)
|
||||
assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** The wire contract, shared with pf-client-core / the Swift client: 20 LSB/°·s means
|
||||
* 1 rad/s ⇒ ~1145.9 raw; 1 g ⇒ 10000 raw. */
|
||||
@Test
|
||||
fun wireUnitConstants() {
|
||||
assertEquals(20f * 180f / Math.PI.toFloat(), DeviceGyro.GYRO_LSB_PER_RAD_S, 0f)
|
||||
assertEquals(1145.9156f, DeviceGyro.GYRO_LSB_PER_RAD_S, 0.001f)
|
||||
assertEquals(10_000, DeviceGyro.ACCEL_LSB_PER_G)
|
||||
}
|
||||
}
|
||||
@@ -384,7 +384,13 @@ struct ContentView: View {
|
||||
.frame(minWidth: 940, minHeight: 620)
|
||||
}
|
||||
#else
|
||||
.fullScreenCover(item: $libraryTarget) { host in
|
||||
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
|
||||
// of GamepadHomeView's in-place layers (the console shell — no bottom-up cover), so the
|
||||
// proxy hides the target from the cover while that mode owns it; every writer (Y on a
|
||||
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
|
||||
// controller arriving or leaving mid-browse hands the open library to whichever
|
||||
// presentation the new mode owns.
|
||||
.fullScreenCover(item: touchLibraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
@@ -401,6 +407,14 @@ struct ContentView: View {
|
||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||||
/// the library in place (see the cover's comment).
|
||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
||||
Binding(
|
||||
get: { gamepadUIActive ? nil : libraryTarget },
|
||||
set: { libraryTarget = $0 })
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
@@ -558,7 +572,8 @@ struct ContentView: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -574,7 +589,8 @@ struct ContentView: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
|
||||
@@ -83,6 +83,11 @@ struct ConnectOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay's text/glyph colour: the palette's ink in the console takeover — over a pale
|
||||
/// aurora, literal white was the one console surface that stayed white-on-white — and white
|
||||
/// in the touch modal, whose branch is deliberately forced dark over a black scrim.
|
||||
private var overlayFG: Color { gamepadUI ? ink.fg : .white }
|
||||
|
||||
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
||||
// The takeover carries larger type than the compact modal.
|
||||
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
||||
@@ -90,21 +95,24 @@ struct ConnectOverlay: View {
|
||||
VStack(spacing: gamepadUI ? 16 : 14) {
|
||||
switch phase {
|
||||
case .connecting(let name):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
ProgressView().controlSize(.large).tint(overlayFG)
|
||||
Text("Connecting to \(name)")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Establishing a secure connection…")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.font(.geist(bodySize, relativeTo: .caption))
|
||||
.foregroundStyle(overlayFG.opacity(0.6))
|
||||
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
||||
case .waking(let w) where w.timedOut:
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
||||
.font(.system(size: gamepadUI ? 40 : 34))
|
||||
.foregroundStyle(overlayFG.opacity(0.9))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.font(.geist(bodySize, relativeTo: .caption))
|
||||
.foregroundStyle(overlayFG.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
@@ -112,12 +120,13 @@ struct ConnectOverlay: View {
|
||||
}
|
||||
.padding(.top, 6)
|
||||
case .waking(let w):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
ProgressView().controlSize(.large).tint(overlayFG)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
||||
.font(.geistFixed(bodySize)).foregroundStyle(overlayFG.opacity(0.6))
|
||||
.monospacedDigit()
|
||||
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered).padding(.top, 6)
|
||||
|
||||
@@ -14,7 +14,15 @@ import SwiftUI
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
let onAdd: (StoredHost) -> Void
|
||||
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
|
||||
/// falls back to the environment dismiss. Declared AFTER `onAdd` so the existing trailing-
|
||||
/// closure call sites keep binding to it, not to this.
|
||||
var close: (() -> Void)?
|
||||
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||
/// connect takeover is up (see GamepadSettingsView's twin).
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@@ -36,8 +44,8 @@ struct GamepadAddHostView: View {
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() },
|
||||
isActive: editing == nil
|
||||
onBack: { performClose() },
|
||||
isActive: controllerActive && editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -45,7 +53,8 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
Text("Add Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
@@ -54,14 +63,14 @@ struct GamepadAddHostView: View {
|
||||
+ "for everything else.")
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
@@ -73,7 +82,10 @@ struct GamepadAddHostView: View {
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||
.background { GamepadFormBackground() }
|
||||
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
@@ -81,6 +93,18 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
|
||||
// tray — the remote and the pad both drive it natively. Same `editing` state as the
|
||||
@@ -141,22 +165,10 @@ struct GamepadAddHostView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Cancel")
|
||||
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
||||
/// the environment dismiss under a macOS sheet / tvOS cover.
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
@@ -237,7 +249,7 @@ struct GamepadAddHostView: View {
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(port) ?? 9777))
|
||||
dismiss()
|
||||
performClose()
|
||||
default:
|
||||
openKeyboard(id)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
/// something is presented on top so only the front-most carousel consumes the gamepad.
|
||||
var isActive: Bool = true
|
||||
@ViewBuilder let card: (Item) -> Card
|
||||
/// Whether the cards are worth showing off yet — the entrance holds until this is true. The
|
||||
/// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything
|
||||
/// whose cards are ready the moment they mount leaves it alone.
|
||||
var contentReady: Bool = true
|
||||
/// Builds one card. The `CardEntrance` handed along is the card's share of the strip's
|
||||
/// arrival, and the caller MUST apply it (`.modifier(entrance)`) *underneath* its own
|
||||
/// `.scrollTransition` — see `CardEntrance` for why that placement is load-bearing.
|
||||
@ViewBuilder let card: (Item, CardEntrance) -> Card
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
@@ -83,6 +90,26 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// confirm and end-stop events (moves trigger on `cursor`).
|
||||
@State private var activateTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
/// The strip's entrance, as ONE timeline: 0 = every card still away, 1 = every card landed
|
||||
/// (see `CardEntrance`, which slices its own window out of this). Animated exactly once per
|
||||
/// mount — a strip that re-played its entrance every time a screen popped off the top of it
|
||||
/// would be noise, and the shell's push/pop carries that motion already. So it plays when a
|
||||
/// screen is entered: the launcher when the gamepad UI comes up, the coverflow each time the
|
||||
/// library opens (its layer mounts fresh).
|
||||
///
|
||||
/// One animated Double rather than a Bool behind per-card `.animation(_:value:)` modifiers,
|
||||
/// because those modifiers wrap the caller's card — INCLUDING its `.scrollTransition` — and a
|
||||
/// delayed spring flipping while the scroll view was still settling captured the transition's
|
||||
/// own per-frame phase updates, stranding the centred card in a half-receded state until the
|
||||
/// next scroll re-drove it. Nothing here wraps the card in an animation at all.
|
||||
@State private var entranceProgress: Double = 0
|
||||
/// Which card the entrance fans out from — the cursor as it stood when the strip was armed,
|
||||
/// so a restored selection assembles around where the eye already is instead of sweeping in
|
||||
/// from the left.
|
||||
@State private var entranceAnchor = 0
|
||||
/// The entrance has been scheduled; it plays exactly once per mount.
|
||||
@State private var entranceArmed = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
|
||||
/// (longer than a move animation, so overlapping held-stick moves never let it through).
|
||||
@@ -94,24 +121,27 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
// Enumerated for the entrance stagger only — identity stays `item.id`,
|
||||
// which is what `.scrollTargetLayout()` and `scrollPosition` key on.
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
#if os(tvOS)
|
||||
// A focusable Button per card: the focus engine does the navigating
|
||||
// (remote swipes and pad dpad alike), select activates. The bare style
|
||||
// below keeps the tile's own look — the `.scrollTransition` center pop
|
||||
// is the focus treatment, since focus and center track each other.
|
||||
Button { activate(item) } label: {
|
||||
card(item)
|
||||
card(item, entrance(idx))
|
||||
.frame(width: itemWidth)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedID, equals: item.id)
|
||||
.id(item.id)
|
||||
#else
|
||||
card(item)
|
||||
card(item, entrance(idx))
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
.id(item.id) // explicit scroll-target identity for scrollPosition
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -165,7 +195,10 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
armEntrance()
|
||||
}
|
||||
// The cards became worth showing (the library's covers got their art) — play now.
|
||||
.onChange(of: contentReady) { _, _ in armEntrance() }
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
@@ -200,9 +233,55 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
reconcile()
|
||||
wire()
|
||||
// A strip that mounted empty (its content arrived after) still gets its entrance.
|
||||
armEntrance()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entrance
|
||||
|
||||
/// Run the entrance, once, as soon as the strip is mounted AND its cards are worth showing.
|
||||
///
|
||||
/// Deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` lands in the
|
||||
/// same transaction as the view's insertion, where SwiftUI runs with animations disabled — so
|
||||
/// the cards would simply BE there. Note the failure mode is benign either way: progress
|
||||
/// reaching 1 without animating leaves every card at exact identity, never stranded.
|
||||
private func armEntrance() {
|
||||
guard !entranceArmed, contentReady, !items.isEmpty else { return }
|
||||
entranceArmed = true
|
||||
// After `reconcile`, so the fan-out anchors on the seeded/restored cursor.
|
||||
entranceAnchor = cursor
|
||||
// Not just the next runloop turn (a change made inside `onAppear` lands in the
|
||||
// insertion's transaction, where animations are disabled) but a couple of frames: the
|
||||
// GeometryReader's first pass can report no width at all, so the strip has to lay out
|
||||
// for real and the scroll view has to centre itself on the cursor before this starts.
|
||||
// Cards are invisible until then (progress 0 ⇒ opacity 0), so the wait never shows.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
// Linear on purpose: the master timeline is a clock, and each card eases its OWN
|
||||
// slice of it (see `CardEntrance`) — a spring here would warp every card's curve.
|
||||
withAnimation(
|
||||
reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total)
|
||||
) {
|
||||
entranceProgress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The card's share of the strip's entrance: it swings in on the drum, the anchored card
|
||||
/// landing first and its neighbours fanning outward to either side.
|
||||
private func entrance(_ idx: Int) -> CardEntrance {
|
||||
// Capped so a several-hundred-title library never queues a card behind a visibly long
|
||||
// wait — everything past the cap lands together, well off-screen anyway.
|
||||
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
|
||||
return CardEntrance(
|
||||
progress: entranceProgress,
|
||||
start: delay / CardEntrance.total,
|
||||
// Never zero: the anchor is the card the eye is ON, so it must swing like the rest —
|
||||
// giving it "no rotation" left the one card you actually watch merely sliding up.
|
||||
side: idx < entranceAnchor ? -1 : 1,
|
||||
reduceMotion: reduceMotion)
|
||||
}
|
||||
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
@@ -346,4 +425,87 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a card arrives when its strip does: turned away on the drum, small, low and invisible —
|
||||
/// then it swings flat, grows and rises into place on a spring soft enough to overshoot. Cards to
|
||||
/// the left of the anchor hinge on their trailing edge and cards to its right on their leading
|
||||
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
|
||||
/// itself only grows, since it is already facing you. Each card carries its own delay (see
|
||||
/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a
|
||||
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
|
||||
/// speaks, so the arrival and the scrolling feel like one object.
|
||||
///
|
||||
/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
|
||||
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
|
||||
/// on the OUTSIDE moves the very thing the transition is measuring: every card read as far from
|
||||
/// centre for the whole travel, its phase pinned at fully-receded, and the centred card only
|
||||
/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the
|
||||
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
|
||||
///
|
||||
/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS
|
||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||
/// unstaggered cross-fade.
|
||||
struct CardEntrance: ViewModifier, Animatable {
|
||||
/// How long ONE card takes to travel, and the most any card waits before it starts.
|
||||
static let perCard: Double = 0.6
|
||||
static let maxDelay: Double = 0.42
|
||||
/// The master timeline the carousel animates 0 → 1.
|
||||
static var total: Double { perCard + maxDelay }
|
||||
|
||||
/// The interpolated master progress. `Animatable` is the whole point: SwiftUI hands this
|
||||
/// modifier a fresh value every frame and re-runs `body`, so the card's transforms are a pure
|
||||
/// FUNCTION of the clock. No `.animation` modifier wraps the card, so nothing here can catch
|
||||
/// the caller's `.scrollTransition` mid-scroll and strand it.
|
||||
var progress: Double
|
||||
/// Where this card's window opens on that timeline, 0…1.
|
||||
let start: Double
|
||||
/// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor),
|
||||
/// +1 hinged on its leading edge (right of it). Never 0 — every card turns, including the
|
||||
/// centred one.
|
||||
let side: Double
|
||||
let reduceMotion: Bool
|
||||
|
||||
var animatableData: Double {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// This card's own 0…1, sliced out of the master clock.
|
||||
let span = Self.perCard / Self.total
|
||||
let raw = min(max((progress - start) / span, 0), 1)
|
||||
// The travel eases out with a whisker of overshoot, so a card settles rather than stops.
|
||||
let travel = Self.easeOutBack(raw)
|
||||
// The fade is FAR quicker than the travel — it finishes in the first third of the window.
|
||||
// Sharing one curve meant the card spent its whole swing at near-zero opacity and only
|
||||
// the last few degrees ever showed, which is why this read as a small slide.
|
||||
let fade = Self.easeOut(min(raw / 0.34, 1))
|
||||
// Deep turn, well down, well shrunk — the card is genuinely edge-on and travelling. The
|
||||
// sign matches the coverflow's own recede (right of centre turns negative about its
|
||||
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
|
||||
// it instead of swinging the opposite way.
|
||||
let away = reduceMotion ? 0 : 1 - travel
|
||||
return content
|
||||
.opacity(reduceMotion ? raw : fade)
|
||||
.scaleEffect(1 - 0.26 * away)
|
||||
.rotation3DEffect(
|
||||
.degrees(side * -64 * away),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
anchor: .center,
|
||||
perspective: 0.65)
|
||||
.offset(y: 34 * away)
|
||||
}
|
||||
|
||||
/// `1 - (1-t)³`, with a small overshoot past 1 before it settles.
|
||||
private static func easeOutBack(_ t: Double) -> Double {
|
||||
let c1 = 1.2, c3 = c1 + 1
|
||||
let u = t - 1
|
||||
return 1 + c3 * u * u * u + c1 * u * u
|
||||
}
|
||||
|
||||
private static func easeOut(_ t: Double) -> Double {
|
||||
let u = 1 - t
|
||||
return 1 - u * u * u
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -23,22 +23,47 @@ func buttonGlyph(
|
||||
|
||||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
|
||||
/// at all, so the iOS value hugs the top edge there.
|
||||
/// at all. The other values follow the console shell's rhythm (title top = 18 design units,
|
||||
/// k-floored to 10 for a landscape phone): the title needs air to the screen edge or the whole
|
||||
/// header reads pressed against the bezel, which the tab strip's extra band made obvious.
|
||||
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||
#if os(macOS)
|
||||
26
|
||||
#elseif os(tvOS)
|
||||
24
|
||||
#else
|
||||
compact ? 4 : 10
|
||||
compact ? 18 : 28
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Padding under a gamepad screen's pinned header block (title, and the tab strip where there is
|
||||
/// one) before the content: the console leaves ~14 units of air under its tab pills, and without
|
||||
/// it the first row sits shoulder-to-shoulder with the header.
|
||||
func gamepadTitleBottomPadding(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
16
|
||||
#else
|
||||
compact ? 8 : 12
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Spacing between a header's stacked elements (title over tab strip / subtitle).
|
||||
func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
13
|
||||
#else
|
||||
compact ? 6 : 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||||
/// in-hand compact-aware sizes elsewhere.
|
||||
/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict
|
||||
/// on the smaller first cut was "way too small" once the title moved off-centre.
|
||||
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
compact ? 20 : 30
|
||||
compact ? 24 : 34
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -58,8 +83,7 @@ enum GamepadFormMetrics {
|
||||
static let rowCorner: CGFloat = 18
|
||||
static let rowMaxWidth: CGFloat = 920
|
||||
static let detailFont: CGFloat = 19
|
||||
static let closeFont: CGFloat = 20
|
||||
static let closeSide: CGFloat = 48
|
||||
static let bandWidth: CGFloat = 380
|
||||
#else
|
||||
static let headerFont: CGFloat = 12
|
||||
static let labelFont: CGFloat = 16
|
||||
@@ -72,8 +96,8 @@ enum GamepadFormMetrics {
|
||||
static let rowCorner: CGFloat = 14
|
||||
static let rowMaxWidth: CGFloat = 620
|
||||
static let detailFont: CGFloat = 13
|
||||
static let closeFont: CGFloat = 14
|
||||
static let closeSide: CGFloat = 34
|
||||
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
|
||||
static let bandWidth: CGFloat = 240
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -147,8 +171,21 @@ struct GamepadHintBar: View {
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Quiet the field for a form screen (see the type comment).
|
||||
var calm = false
|
||||
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
|
||||
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
|
||||
/// it during a push/pop — the console does the same with its `bg_mix` — and every
|
||||
/// calm-dependent factor below rides an `.opacity` modifier, which animates reliably where
|
||||
/// re-built gradient stops do not.
|
||||
var calmMix: Double
|
||||
|
||||
/// The Bool spelling every non-shell call site uses (see the type comment for `calm`).
|
||||
init(calm: Bool = false) {
|
||||
calmMix = calm ? 1 : 0
|
||||
}
|
||||
|
||||
init(calmMix: Double) {
|
||||
self.calmMix = calmMix
|
||||
}
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@@ -184,21 +221,22 @@ struct GamepadScreenBackground: View {
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply…
|
||||
.opacity(calm ? 0.6 : 1)
|
||||
if calm {
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
.opacity(1 - 0.4 * calmMix)
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
// Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing,
|
||||
// and an always-present layer is what lets the mix animate instead of popping.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4 * calmMix)
|
||||
.blendMode(.plusLighter)
|
||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||
// crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
|
||||
// screen's rows run out toward the edges, where crushing them just eats the list.
|
||||
EllipticalGradient(
|
||||
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
|
||||
colors: [.clear, scrim.opacity(0.42 * strength)],
|
||||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||
.opacity(1 - 0.5 * calmMix)
|
||||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||
// works on the field itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
// blur), so it stays a gradient, just a light one.
|
||||
@@ -342,20 +380,39 @@ struct GamepadTrayScrim: View {
|
||||
// to keep the pinned title legible, so it has to frost dark under white ink and
|
||||
// light under dark ink.
|
||||
.environment(\.colorScheme, ink.isLight ? .light : .dark)
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||||
// Sink the material's grey luminance lift toward the palette's shade (black on a
|
||||
// dark field — field ask: the frost read GREY over the aurora). Inside the mask, so
|
||||
// the tint dissolves with the blur.
|
||||
.overlay(ink.shade(0.35))
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a
|
||||
// line. The strong region sits deep (0.65) because the first stretch of the gradient
|
||||
// now runs over the fixed 80 pt outer overhang below.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black, location: 0),
|
||||
.init(color: .black.opacity(0.9), location: 0.5),
|
||||
.init(color: .black.opacity(0.92), location: 0.65),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: fromEdge, endPoint: toContent)
|
||||
}
|
||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||
// text always sits on the strong part, rows blur out before they reach it.
|
||||
.padding(edge == .top ? .bottom : .top, -32)
|
||||
.ignoresSafeArea()
|
||||
// text always sits on the strong part, rows blur out before they reach it. The bottom
|
||||
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
|
||||
// the field verdict on the short reach was rows colliding visibly with the legend.
|
||||
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
|
||||
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
|
||||
// beat after insertion (outside any geometry group and outside this view's own
|
||||
// transaction), which is exactly the pop the field kept seeing — vertically first,
|
||||
// then, once the vertical runway became padding, on the X axis alone (the landscape
|
||||
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
|
||||
// so the overhang simply draws.
|
||||
.padding(edge == .top ? .top : .bottom, -80)
|
||||
.padding(.horizontal, -80)
|
||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
||||
// geometry would ride the push's transaction and visibly grow into place. The
|
||||
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ struct GamepadHomeView: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
let connect: (StoredHost, ProfileSelection) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (StoredHost, String) -> Void
|
||||
|
||||
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
||||
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
||||
@@ -93,29 +96,56 @@ struct GamepadHomeView: View {
|
||||
private let compact = false // no size classes on macOS; the window minimum keeps room
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var showSettings = false
|
||||
@State private var showAddHost = false
|
||||
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
|
||||
/// the controller — a double-tapped A can't push two screens, and the held button that
|
||||
/// caused the change is long released before the next poller starts (whose own
|
||||
/// `needsSnapshot` seed swallows it if not).
|
||||
@State private var transitioning = false
|
||||
/// Guards the gate's release against an interrupted transition: only the newest hold clears.
|
||||
@State private var transitionEpoch = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
hero(for: geo.size)
|
||||
// The in-place shell (see GamepadShell.swift): the launcher is the base layer, the
|
||||
// current sub-screen a transparent layer over it, both over ONE persistent backdrop
|
||||
// that never unmounts — a push slides the screen up out of a fade while the launcher
|
||||
// recedes underneath, the console's own choreography. On macOS/tvOS `topScreen` is
|
||||
// constantly nil and this ZStack degenerates to the plain launcher, presented over by
|
||||
// the sheets/covers below exactly as before.
|
||||
ZStack {
|
||||
homeLayer
|
||||
.opacity(covered ? 0 : 1)
|
||||
.scaleEffect(covered ? GamepadShellMotion.underScale : 1)
|
||||
// The covers used to swallow touch; the recessed layer must too.
|
||||
.allowsHitTesting(!covered)
|
||||
#if os(iOS)
|
||||
if let screen = topScreen {
|
||||
screenLayer(screen)
|
||||
// Settle the screen's internal layout before the insertion animates, so
|
||||
// descendants never lerp from a half-resolved first frame. (Not sufficient
|
||||
// for the tray blurs on its own — safe-area expansion resolves outside a
|
||||
// geometry group; GamepadTrayScrim pins its own geometry too.)
|
||||
.geometryGroup()
|
||||
.zIndex(1)
|
||||
.id(screen.id)
|
||||
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
titleBar
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
|
||||
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
|
||||
// derived id catches every writer. Reduce Motion snaps.
|
||||
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
|
||||
// ONE living field for every layer, still a `.background` (the layout rule in this
|
||||
// file's header). Its calm is CHASED between the launcher's aurora and the form
|
||||
// screens' quiet, never crossfaded per screen — the console's `bg_mix`.
|
||||
.background {
|
||||
GamepadScreenBackground(calmMix: calmTarget)
|
||||
.animation(reduceMotion ? nil : GamepadShellMotion.calm, value: calmTarget)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
@@ -129,6 +159,17 @@ struct GamepadHomeView: View {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: topScreenID) { _, _ in
|
||||
transitionEpoch += 1
|
||||
let epoch = transitionEpoch
|
||||
transitioning = true
|
||||
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
|
||||
if epoch == transitionEpoch { transitioning = false }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
|
||||
// X, and historically tvOS maps a pad's X to this same press — the poll and this command
|
||||
// double-firing just sets the same Bool twice.
|
||||
@@ -136,8 +177,9 @@ struct GamepadHomeView: View {
|
||||
.onPlayPauseCommand { showSettings = true }
|
||||
#endif
|
||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
// gate above). macOS has no fullScreenCover — they are generously sized sheets over the
|
||||
// dimmed launcher; tvOS keeps its focus-engine covers. iOS needs nothing here: the
|
||||
// shell's layers above ARE the presentation.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store)
|
||||
@@ -148,7 +190,7 @@ struct GamepadHomeView: View {
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
#elseif os(tvOS)
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
@@ -156,6 +198,110 @@ struct GamepadHomeView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - The shell's layers (see GamepadShell.swift)
|
||||
|
||||
/// The launcher itself — everything the pre-shell body was, minus the backdrop (hoisted to
|
||||
/// the shell) and the presentation modifiers (below).
|
||||
private var homeLayer: some View {
|
||||
GeometryReader { geo in
|
||||
hero(for: geo.size)
|
||||
}
|
||||
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
titleBar
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The screen the shell shows over the launcher — derived from the same triggers every
|
||||
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
||||
private var topScreen: GamepadScreen? {
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let host = libraryTarget { return .library(host) }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder private func screenLayer(_ screen: GamepadScreen) -> some View {
|
||||
// The layer owns the controller only once the push settles and nothing rides over the
|
||||
// shell (the connect/wake takeover is an overlay in ContentView, above these layers).
|
||||
let active = !transitioning && waker.waking == nil && model.phase != .connecting
|
||||
Group {
|
||||
switch screen {
|
||||
case .settings:
|
||||
GamepadSettingsView(
|
||||
store: store,
|
||||
close: { if !transitioning { showSettings = false } },
|
||||
controllerActive: active)
|
||||
case .addHost:
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .library(let host):
|
||||
GamepadLibraryScreen(
|
||||
store: store, host: host,
|
||||
onLaunch: { launchTitle(host, $0) },
|
||||
close: { if !transitioning { libraryTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
}
|
||||
.environment(\.gamepadHostedInShell, true)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var covered: Bool {
|
||||
#if os(iOS)
|
||||
topScreen != nil
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var topScreenID: String? {
|
||||
#if os(iOS)
|
||||
topScreen?.id
|
||||
#else
|
||||
nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The backdrop's calm target: 1 under a form screen, 0 under the launcher/library. The
|
||||
/// macOS sheets / tvOS covers mount their own calmed field, so the launcher behind them
|
||||
/// keeps its aurora — exactly what shipped.
|
||||
private var calmTarget: Double {
|
||||
#if os(iOS)
|
||||
topScreen?.isForm == true ? 1 : 0
|
||||
#else
|
||||
0
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||
/// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||
/// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||
/// takeover's Connecting phase; `waker.waking` its Waking phase. On iOS the shell adds the
|
||||
/// transition's input drop, during which NOBODY polls.
|
||||
private var homeOwnsController: Bool {
|
||||
#if os(iOS)
|
||||
topScreen == nil && !transitioning
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
@@ -181,32 +327,27 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
// Leading title (a console heading, not a floating label — field ask), chip trailing.
|
||||
// The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip;
|
||||
// a leading title needs none of it — the flexible frame keeps the two apart, and the
|
||||
// title shrinks a little before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
statusChip
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
/// compact-height phone gives the pixels to the carousel instead.
|
||||
@ViewBuilder private var statusChip: some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,14 +370,9 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
isActive: homeOwnsController
|
||||
) { tile, entrance in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
|
||||
}
|
||||
.frame(height: cardHeight + 40)
|
||||
}
|
||||
@@ -245,8 +381,12 @@ struct GamepadHomeView: View {
|
||||
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
|
||||
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
|
||||
/// brightness/saturation + a depth blur on the recessed neighbors.
|
||||
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
|
||||
private func hostCard(
|
||||
_ tile: HomeTile, size: CGSize, entrance: CardEntrance
|
||||
) -> some View {
|
||||
GamepadHostTile(tile: tile, size: size)
|
||||
// Beneath the scroll transition, never around it — see CardEntrance.
|
||||
.modifier(entrance)
|
||||
.scrollTransition { content, phase in
|
||||
let d = CGFloat(min(abs(phase.value), 1))
|
||||
let scale = 1 - d * 0.12
|
||||
@@ -402,10 +542,15 @@ private struct GamepadHostTile: View {
|
||||
.foregroundStyle(ink.fg(0.5))
|
||||
}
|
||||
if tile.isOnline {
|
||||
// Status colours stay palette-independent (a pip must not change meaning
|
||||
// with the wallpaper) — only the glow softens on a pale field, where it
|
||||
// reads as a smudge at full strength.
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.fill(GamepadInk.onlineGreen)
|
||||
.frame(width: Self.pipSide, height: Self.pipSide)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
.shadow(
|
||||
color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7),
|
||||
radius: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,7 +586,7 @@ private struct GamepadHostTile: View {
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||
}
|
||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
|
||||
}
|
||||
|
||||
private var monogramBadge: some View {
|
||||
|
||||
@@ -37,6 +37,12 @@ struct GamepadInk: Equatable, Sendable {
|
||||
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
|
||||
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
|
||||
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
|
||||
/// The glass base at `alpha` — what a surface's material is washed with so it carries the
|
||||
/// palette's hue (the console fills its panels with exactly this colour).
|
||||
func glass(_ alpha: Double) -> Color { glass.opacity(alpha) }
|
||||
/// A drop shadow: always black — a white shadow is not a shadow — but softened on a pale
|
||||
/// field, where full-strength black under every card reads as a smear rather than depth.
|
||||
func shadow(_ alpha: Double) -> Color { .black.opacity(alpha * (isLight ? 0.4 : 1)) }
|
||||
|
||||
static func of(_ p: GamepadPalette) -> GamepadInk {
|
||||
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
|
||||
@@ -60,6 +66,10 @@ struct GamepadInk: Equatable, Sendable {
|
||||
|
||||
/// The shipped dark look — what a preview or a test composition gets.
|
||||
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
|
||||
/// The online pip — deliberately NOT palette-derived: a status colour must not change
|
||||
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
|
||||
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
|
||||
}
|
||||
|
||||
private struct GamepadInkKey: EnvironmentKey {
|
||||
|
||||
@@ -111,7 +111,9 @@ struct GamepadKeyboard: View {
|
||||
.font(.geist(15, .semibold, relativeTo: .callout))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(focused ? Color.black : ink.fg)
|
||||
// The focused keycap sits on `ink.accent`, so `onAccent` is what reads on it — a dark
|
||||
// accent palette got black-on-dark with the old literal black.
|
||||
.foregroundStyle(focused ? ink.onAccent : ink.fg)
|
||||
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// The library as one of the gamepad shell's in-place layers (iOS): console chrome — a pinned
|
||||
// title and a close ✕ styled like the settings screen's — around the shared LibraryView, whose
|
||||
// gamepad branch renders the coverflow. The cover presentation used to get its title and Close
|
||||
// from the wrapping NavigationStack's bar; a shell layer has no bar, so this restores both in
|
||||
// the console's own grammar. Everything data-shaped (the fetch, the loading/error/empty states,
|
||||
// the image session lifecycle) stays LibraryView's.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
|
||||
struct GamepadLibraryScreen: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@ObservedObject var store: HostStore
|
||||
let host: StoredHost
|
||||
let onLaunch: (String) -> Void
|
||||
let close: () -> Void
|
||||
var controllerActive = true
|
||||
|
||||
/// `.compact` in a landscape phone window — tighter chrome, like every gamepad screen.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
|
||||
var body: some View {
|
||||
LibraryView(
|
||||
store: store, host: host, onLaunch: onLaunch,
|
||||
onClose: close, controllerActive: controllerActive)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
// Leading, like every gamepad heading — no close chrome, B is the exit (the
|
||||
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
|
||||
Text("\(host.displayName) — Library")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
// A hardware keyboard's Esc still closes, without chrome.
|
||||
.background {
|
||||
Button("Close") { close() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
// The gamepad UI's screen-shell vocabulary (iOS): which screen sits over the launcher, and the
|
||||
// console push/pop choreography that presents it. On iOS the launcher's sub-screens (settings,
|
||||
// add-host, library) are NOT system covers — they are transparent layers composited in
|
||||
// GamepadHomeView's ZStack over ONE persistent living backdrop, exactly the model
|
||||
// `pf-console-ui`'s shell renders on the desktop clients: a push slides the incoming screen up
|
||||
// out of a fade while the outgoing one recedes; a pop mirrors it; the field underneath never
|
||||
// moves and never leaves. A system `fullScreenCover` — an opaque sheet sliding up from the
|
||||
// bottom edge, mounting its own backdrop — was exactly the wrong grammar for a console.
|
||||
// (macOS keeps its windowed sheets and tvOS its focus-engine covers; this file's motion
|
||||
// constants are iOS-only in practice, but compile everywhere for the shared call sites.)
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
/// The screen the shell currently shows over the launcher. Derived, not stored: the presentation
|
||||
/// triggers (`showSettings`, `showAddHost`, `libraryTarget`) stay authoritative on every
|
||||
/// platform — this enum is just their iOS rendering. Depth is ≤ 1 by construction (the settings
|
||||
/// pin picker is an in-screen layer, and every trigger is only reachable from the launcher), so
|
||||
/// there is no stack to model.
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case library(StoredHost)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
case .library(let host): return "library-\(host.id.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The backdrop's calm target while this screen is up: the form screens quiet the field
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The console shell's motion constants, mapped to SwiftUI. Source of truth:
|
||||
/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`).
|
||||
enum GamepadShellMotion {
|
||||
/// One transition, both layers — the console's `TRANSITION_S`.
|
||||
static let duration: TimeInterval = 0.26
|
||||
/// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points.
|
||||
static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration)
|
||||
/// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same
|
||||
/// ease-out at 0.30 s lands within a few percent of it and settles together with the screen.
|
||||
static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30)
|
||||
/// The push/pop travel — the console's `36 * k`, k-floored for a landscape phone.
|
||||
static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 }
|
||||
/// The incoming screen grows from this; the revealed launcher grows back from `underScale`.
|
||||
static let inScale: CGFloat = 0.985
|
||||
static let underScale: CGFloat = 0.96
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
/// The console push/pop for the top layer. Insertion: up out of a fade, growing from 0.985.
|
||||
/// Removal: down into a fade at full size (the console's pop leaves scale alone). The
|
||||
/// launcher's recede underneath is NOT a transition — it never unmounts — it is the
|
||||
/// `covered` opacity/scale in GamepadHomeView, animated in the same transaction.
|
||||
///
|
||||
/// Known deviation from the console: a pop there re-reveals the launcher from α 0.4; a
|
||||
/// SwiftUI opacity animates from 0. Same duration, same landing — the revealed screen just
|
||||
/// reads a beat later in the fade, not worth an explicitly-driven progress machine.
|
||||
static func gamepadScreen(slide: CGFloat) -> AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .opacity
|
||||
.combined(with: .offset(y: slide))
|
||||
.combined(with: .scale(scale: GamepadShellMotion.inScale)),
|
||||
removal: .opacity.combined(with: .offset(y: slide)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamepadHostedInShellKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// True for a screen mounted as one of the shell's layers: it must NOT mount its own
|
||||
/// backdrop (the shell's single persistent field is behind everything already — a second
|
||||
/// one would double the mesh cost and break the "field never moves" illusion). The same
|
||||
/// screens presented as macOS sheets / tvOS covers read the default `false` and keep
|
||||
/// mounting their own, exactly as before.
|
||||
var gamepadHostedInShell: Bool {
|
||||
get { self[GamepadHostedInShellKey.self] }
|
||||
set { self[GamepadHostedInShellKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -26,6 +26,11 @@ struct LibraryCoverflowView: View {
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
var onDismiss: (() -> Void)?
|
||||
/// Whether the carousel owns the controller — the in-place shell gates it (mid-transition,
|
||||
/// and under the connect takeover after A launches a title, where this coverflow used to
|
||||
/// keep polling underneath). Cover/sheet presentations keep the default.
|
||||
var controllerActive = true
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
|
||||
@@ -36,6 +41,18 @@ struct LibraryCoverflowView: View {
|
||||
private let compact = false // no size classes on macOS
|
||||
#endif
|
||||
@State private var selection: String?
|
||||
/// How many covers have settled (art loaded, or every candidate exhausted).
|
||||
@State private var artSettled = 0
|
||||
/// The backstop below has fired: play the entrance regardless of what the art is doing.
|
||||
@State private var artWaitOver = false
|
||||
|
||||
/// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and
|
||||
/// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for
|
||||
/// the first few covers — every poster is fetched in parallel, so those land together and
|
||||
/// cover the visible strip. The wait is capped: a slow or artless library still animates.
|
||||
private var contentReady: Bool {
|
||||
artWaitOver || artSettled >= min(4, games.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
@@ -46,10 +63,19 @@ struct LibraryCoverflowView: View {
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// Hosted in the shell, the field is the shell's own persistent aurora (the library is
|
||||
// an aurora screen — the calm mix simply stays 0, so nothing even chases).
|
||||
.background {
|
||||
if !hostedInShell { GamepadScreenBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
// The entrance's backstop (see `contentReady`).
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
artWaitOver = true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||
@@ -81,9 +107,11 @@ struct LibraryCoverflowView: View {
|
||||
spacing: 34,
|
||||
onActivate: { onLaunch?($0.id) },
|
||||
onBack: { onDismiss?() },
|
||||
shoulderJump: 5
|
||||
) { game in
|
||||
cover(game, width: coverWidth, height: coverHeight)
|
||||
shoulderJump: 5,
|
||||
isActive: controllerActive,
|
||||
contentReady: contentReady
|
||||
) { game, entrance in
|
||||
cover(game, width: coverWidth, height: coverHeight, entrance: entrance)
|
||||
}
|
||||
.frame(height: coverHeight + 44)
|
||||
}
|
||||
@@ -92,18 +120,26 @@ struct LibraryCoverflowView: View {
|
||||
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
|
||||
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
|
||||
/// scale/rotation/opacity ramp already makes the centered cover prominent.
|
||||
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
private func cover(
|
||||
_ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, session: imageSession,
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
// `solid`: a frosted chip can't sample a backdrop through this card's own
|
||||
// composited transform, so it would only show up on the centred card.
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
|
||||
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
|
||||
// Beneath the scroll transition, never around it — see CardEntrance.
|
||||
.modifier(entrance)
|
||||
.scrollTransition { content, phase in
|
||||
let v = phase.value
|
||||
let d = CGFloat(min(abs(v), 1))
|
||||
|
||||
@@ -12,6 +12,13 @@ struct LibraryView: View {
|
||||
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
||||
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
||||
var onLaunch: ((String) -> Void)? = nil
|
||||
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover
|
||||
/// presentation — falls back to the environment dismiss.
|
||||
var onClose: (() -> Void)? = nil
|
||||
/// Whether the gamepad coverflow owns the controller — the shell gates it during a push/pop
|
||||
/// and while the connect takeover is up. Presentations that cover the launcher keep the
|
||||
/// default (their being up IS the launcher's gate).
|
||||
var controllerActive = true
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var games: [GameEntry] = []
|
||||
@@ -58,6 +65,17 @@ struct LibraryView: View {
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = nil
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// B closes the library even before the coverflow exists (loading / error / empty):
|
||||
// the coverflow's carousel owns B once games render; until then this zero-size
|
||||
// listener does — without it a controller-only user is trapped on an error screen
|
||||
// (the gamepad screens carry no close chrome).
|
||||
.background {
|
||||
if gamepadUIActive && games.isEmpty {
|
||||
LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@@ -72,7 +90,8 @@ struct LibraryView: View {
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
onDismiss: { dismiss() })
|
||||
onDismiss: { (onClose ?? { dismiss() })() },
|
||||
controllerActive: controllerActive)
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
@@ -202,6 +221,30 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same
|
||||
/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows
|
||||
/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up.
|
||||
private struct LibraryBackCatcher: View {
|
||||
let active: Bool
|
||||
let onBack: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.onAppear {
|
||||
input.onBack = onBack
|
||||
if active { input.start() }
|
||||
}
|
||||
.onChange(of: active) { _, nowActive in
|
||||
if nowActive { input.start() } else { input.stop() }
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
|
||||
@@ -17,16 +17,29 @@ struct StoreBadge: View {
|
||||
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
|
||||
/// without reading the title.
|
||||
var isLauncher: Bool = false
|
||||
/// Fill the chip with a flat wash instead of a frosted material.
|
||||
///
|
||||
/// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them
|
||||
/// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an
|
||||
/// offscreen composite — so the frost stayed blank on every card and only appeared on the one
|
||||
/// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat
|
||||
/// wash has no backdrop to sample: it is simply always there. (Deliberately black, not
|
||||
/// palette ink: the chip sits on cover art, whose colours the palette has no business
|
||||
/// fighting.)
|
||||
var solid: Bool = false
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
if isLauncher { return AnyShapeStyle(Color.brand) }
|
||||
return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: Capsule())
|
||||
.background(fill, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +71,10 @@ struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let session: URLSession?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
var onLoaded: (() -> Void)?
|
||||
@State private var index = 0
|
||||
@State private var image: PlatformImage?
|
||||
|
||||
@@ -67,19 +84,30 @@ struct PosterImage: View {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity)
|
||||
} else if index < candidates.count {
|
||||
ZStack { placeholder; ProgressView() }
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
placeholder
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
// Art crosses over its placeholder instead of replacing it between two frames. Cover
|
||||
// fetches land one by one, so without this a freshly opened library is a run of cards
|
||||
// visibly snapping from grey to artwork after the strip has already settled.
|
||||
.animation(.easeOut(duration: 0.3), value: image != nil)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.task(id: index) { await loadCurrent() }
|
||||
}
|
||||
|
||||
private func loadCurrent() async {
|
||||
guard index < candidates.count else { return }
|
||||
// Past the end: the placeholder IS the final look, so this poster has settled.
|
||||
guard index < candidates.count else {
|
||||
onLoaded?()
|
||||
return
|
||||
}
|
||||
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||
let loaded = PlatformImage(data: data)
|
||||
else {
|
||||
@@ -87,6 +115,7 @@ struct PosterImage: View {
|
||||
return
|
||||
}
|
||||
image = loaded
|
||||
onLoaded?()
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
|
||||
@@ -243,7 +243,7 @@ private struct ShotGamepadHome: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ private struct ShotConnect: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
|
||||
@@ -224,9 +224,18 @@ struct StreamHUDView: View {
|
||||
|
||||
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
|
||||
///
|
||||
/// On iOS it also has to CLEAR THE CORNER. A rounded corner of radius `r` pulls the card's
|
||||
/// edge inward by `r − √(r² − (r−y)²)` at a distance `y` below the top, so the first and last
|
||||
/// lines of a padded stack sit inside the arc unless the padding keeps pace with the radius.
|
||||
/// At `0.45 · r` that intrusion stays well inside the padding across the whole range this
|
||||
/// card can wear (≈4.6 pt of arc against 12.6 pt of padding at the 28 pt cap), so no line
|
||||
/// ever runs into the curve.
|
||||
private var cardPadding: CGFloat {
|
||||
#if os(tvOS)
|
||||
return 16
|
||||
#elseif os(iOS)
|
||||
return max(10, cardCornerRadius * 0.45)
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
@@ -246,13 +255,20 @@ struct StreamHUDView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The card's corner radius. On iOS it's concentric with the physical display corner —
|
||||
/// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the
|
||||
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
|
||||
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
|
||||
/// The card's corner radius. On iOS it aims to be concentric with the physical display
|
||||
/// corner — `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform
|
||||
/// right around the corner instead of a small-radius card cutting into the very rounded
|
||||
/// glass — but that aim is BOUNDED by what a card this small can actually carry.
|
||||
///
|
||||
/// Unbounded, a modern phone (~62 pt of display radius) asked for a 48 pt corner on a card
|
||||
/// whose lines sit 10 pt from the edge: the arc reaches ~19 pt inward at the first line, so
|
||||
/// the top and bottom lines rendered INSIDE the curve. Concentricity is only a virtue while
|
||||
/// the radius is small next to the card; past that it is just a blob eating its own text.
|
||||
/// 28 pt is the most this card's stack can wear (with `cardPadding` scaling alongside), and
|
||||
/// devices whose display radius asks for less than that still get a truly concentric corner.
|
||||
private var cardCornerRadius: CGFloat {
|
||||
#if os(iOS)
|
||||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||||
return min(28, max(12, DeviceMetrics.displayCornerRadius - edgeInset))
|
||||
#elseif os(tvOS)
|
||||
return 16 // scales with the roomier padding
|
||||
#else
|
||||
|
||||
@@ -58,8 +58,8 @@ struct AcknowledgementsView: View {
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"Punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
+ "Video decoding uses the system's own VideoToolbox framework, so nothing "
|
||||
+ "is bundled for it — and no Punktfunk client bundles FFmpeg on any platform."
|
||||
)
|
||||
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// The gamepad settings' "select" value as a REAL band: the options sit side by side on a drum
|
||||
// segment curving about a vertical axis — the current one faces you flat, and a step rotates the
|
||||
// next one in with perspective. The old presentation animated a single Text keyed by its value
|
||||
// (an old-out/new-in crossfade that merely implied motion), which fell apart under fast repeated
|
||||
// steps: each press restarted the fade. Here the drum's position is one continuous value driven
|
||||
// by a spring, and SwiftUI's spring retargeting preserves velocity — rapid presses accumulate
|
||||
// into one accelerating travel instead of five restarted crossfades.
|
||||
//
|
||||
// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first
|
||||
// option waiting to the right of the last one, which left/right can't reach (adjust clamps) —
|
||||
// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped
|
||||
// sides with every step. So positions are fixed: option i sits i steps from the start, the ends
|
||||
// are the ends, and A's wrap from the last option travels BACK across the list to the first.
|
||||
// Options other than the facing one exist only while the drum is actually moving — at rest a row
|
||||
// shows exactly its value (a resting neighbour under a long label rendered as overlapping,
|
||||
// unreadable text).
|
||||
//
|
||||
// The band is purely presentational: stepping semantics (left/right clamps with a boundary thud,
|
||||
// A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row
|
||||
// closures. Font and ink come from the environment — the row applies the same value font/colour
|
||||
// it always did, and the drum's own opacity ramp multiplies on top.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadOptionBand: View {
|
||||
let options: [String]
|
||||
/// The committed selection — the caller's clamp/wrap already applied.
|
||||
let selection: Int
|
||||
let focused: Bool
|
||||
/// The band's footprint, FIXED by the row: a step must never reflow the row (the old
|
||||
/// free-width value shifted the chevrons with every label), and the drum needs its stage
|
||||
/// even when the facing label is short.
|
||||
let width: CGFloat
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Where the drum rests, in option steps — always chasing `Double(selection)`; only the
|
||||
/// spring's interpolation ever puts it between integers.
|
||||
@State private var drumPosition: Double
|
||||
|
||||
init(options: [String], selection: Int, focused: Bool, width: CGFloat) {
|
||||
self.options = options
|
||||
self.selection = selection
|
||||
self.focused = focused
|
||||
self.width = width
|
||||
_drumPosition = State(initialValue: Double(selection))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if reduceMotion {
|
||||
// No drum, no travel: today's quiet crossfade, minus even the 14 pt slip.
|
||||
ZStack {
|
||||
Text(current)
|
||||
.lineLimit(1)
|
||||
.id(selection)
|
||||
.transition(.opacity)
|
||||
}
|
||||
.animation(.smooth(duration: 0.2), value: selection)
|
||||
} else {
|
||||
Drum(
|
||||
options: options,
|
||||
rotation: drumPosition,
|
||||
target: drumPosition,
|
||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
||||
radius: width * 0.72)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black, location: 0.12),
|
||||
.init(color: .black, location: 0.88),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
||||
// The options list itself can mutate under the drum (a custom resolution appears, a
|
||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||
// without a travel.
|
||||
.onChange(of: options.count) { _, _ in snap() }
|
||||
// One element to VoiceOver — the neighbour texts are rendering, not content.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(current)
|
||||
}
|
||||
|
||||
private var current: String {
|
||||
options.indices.contains(selection) ? options[selection] : ""
|
||||
}
|
||||
|
||||
/// A step (or A's wrap — which on a linear band is a fast travel back to the start) springs
|
||||
/// the drum; anything else (an external write from the touch settings, a re-derived options
|
||||
/// list) re-seats it — a travel to a value the user didn't step to would read as the UI
|
||||
/// acting on its own.
|
||||
private func step(from old: Int, to new: Int) {
|
||||
let wrapped = options.count > 1 && old == options.count - 1 && new == 0
|
||||
guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() }
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
|
||||
drumPosition = Double(new)
|
||||
}
|
||||
}
|
||||
|
||||
private func snap() {
|
||||
var tx = Transaction()
|
||||
tx.disablesAnimations = true
|
||||
withTransaction(tx) { drumPosition = Double(selection) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED
|
||||
/// rotation every frame of the spring — each option's offset/scale/opacity follows the real arc,
|
||||
/// and options along the travel genuinely enter and leave mid-flight. (A plain `.animation` on
|
||||
/// independent modifiers can't do that: each modifier would lerp its own endpoints and the
|
||||
/// in-between options would never appear.)
|
||||
private struct Drum: View, Animatable {
|
||||
let options: [String]
|
||||
/// The interpolated drum position, in option steps.
|
||||
var rotation: Double
|
||||
/// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The
|
||||
/// distance between them is "how mid-flight are we" — the neighbours exist exactly as long
|
||||
/// as the drum is moving, fading continuously as it lands, so a resting row is one flat
|
||||
/// Text and a long label never sits under a resting neighbour.
|
||||
let target: Double
|
||||
/// Drum radius in points (from the band width — see the caller).
|
||||
let radius: Double
|
||||
|
||||
var animatableData: Double {
|
||||
get { rotation }
|
||||
set { rotation = newValue }
|
||||
}
|
||||
|
||||
/// Angular pitch between adjacent options on the drum.
|
||||
private static let stepAngle = 34.0 * .pi / 180.0
|
||||
|
||||
var body: some View {
|
||||
let flight = min(1, abs(rotation - target) * 3)
|
||||
let content = ZStack {
|
||||
ForEach(0..<options.count, id: \.self) { i in
|
||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||
// ends are the ends (nothing waits beyond the last option).
|
||||
let d = Double(i) - rotation
|
||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||
option(i, distance: d, gate: flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Flatten the transform stack while travelling — the 10-foot GPU already made these
|
||||
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
|
||||
content.drawingGroup()
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
||||
let angle = d * Self.stepAngle
|
||||
let depth = cos(angle)
|
||||
// The facing option never gates: a resting row still shows its value.
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
||||
Text(options[i])
|
||||
.lineLimit(1)
|
||||
.scaleEffect(0.70 + 0.30 * depth)
|
||||
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
|
||||
.offset(x: radius * sin(angle))
|
||||
.opacity(alpha)
|
||||
.zIndex(depth)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -47,10 +47,18 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
|
||||
/// falls back to the environment dismiss. See `performClose`.
|
||||
var close: (() -> Void)?
|
||||
/// Whether this screen owns the controller. The shell holds it false during a push/pop (the
|
||||
/// console's input drop) and while the connect takeover is up; a system presentation never
|
||||
/// needs the gate and keeps the default.
|
||||
var controllerActive = true
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -85,6 +93,7 @@ struct GamepadSettingsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
@AppStorage(DefaultsKey.gyroFromDevice) private var gyroFromDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
@@ -126,7 +135,8 @@ struct GamepadSettingsView: View {
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { back() },
|
||||
onShoulder: { step(tabBy: $0) }
|
||||
onShoulder: { step(tabBy: $0) },
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -134,18 +144,20 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like a console section heading — centred read as a floating label,
|
||||
// and a gamepad UI needs no close chrome next to it (B is the exit).
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
@@ -167,8 +179,12 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
||||
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||
// applies here too, so this screen previews the row you're stepping.
|
||||
.background { GamepadFormBackground() }
|
||||
// applies here too, so this screen previews the row you're stepping. Hosted in the
|
||||
// shell, the field is the SHELL's (one persistent backdrop, calm-chased) — mounting a
|
||||
// second would double the mesh and snap where the shell crossfades.
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
@@ -177,6 +193,18 @@ struct GamepadSettingsView: View {
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Close") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
|
||||
@@ -229,10 +257,12 @@ struct GamepadSettingsView: View {
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||
// in and out — the highlight travels the way the press did.
|
||||
// in and out — the highlight travels the way the press did. A Liquid Glass
|
||||
// surface (accent-tinted through consoleGlass), so the strip wears the same
|
||||
// material language as the rows it sits above.
|
||||
if selected {
|
||||
Capsule()
|
||||
.fill(ink.accent(0.85))
|
||||
Color.clear
|
||||
.consoleGlass(Capsule(), tint: ink.accent(0.85))
|
||||
.matchedGeometryEffect(id: "tab", in: tabHighlight)
|
||||
}
|
||||
}
|
||||
@@ -274,22 +304,10 @@ struct GamepadSettingsView: View {
|
||||
focusID = landing
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Close settings")
|
||||
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
||||
/// the environment dismiss under a macOS sheet / tvOS cover.
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
@@ -337,7 +355,7 @@ struct GamepadSettingsView: View {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
performClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,24 +381,31 @@ struct GamepadSettingsView: View {
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
// Deliberately not `.push`: that travels the whole container width, loud
|
||||
// and visibly outside the row. The ZStack is the stable home the
|
||||
// removed/inserted texts transition within.
|
||||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
if let labels = row.optionLabels, let idx = row.selectedIndex {
|
||||
// A choice row's value is a REAL band — the options ride a rotating
|
||||
// drum, so fast repeated steps spin it instead of restarting a fade.
|
||||
GamepadOptionBand(
|
||||
options: labels, selection: idx, focused: focused, width: bandWidth)
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
|
||||
.lineLimit(1)
|
||||
.id(row.value)
|
||||
.transition(.asymmetric(
|
||||
insertion: .offset(x: slide).combined(with: .opacity),
|
||||
removal: .offset(x: -slide).combined(with: .opacity)))
|
||||
} else {
|
||||
// The flat rows (profile pin counts, placeholders) keep the quiet slip:
|
||||
// keyed by the value so a change slides the new string in following the
|
||||
// user's motion, crossfading over ~14 pt. The ZStack is the stable home
|
||||
// the removed/inserted texts transition within.
|
||||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
|
||||
.lineLimit(1)
|
||||
.id(row.value)
|
||||
.transition(.asymmetric(
|
||||
insertion: .offset(x: slide).combined(with: .opacity),
|
||||
removal: .offset(x: -slide).combined(with: .opacity)))
|
||||
}
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
}
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
@@ -410,6 +435,17 @@ struct GamepadSettingsView: View {
|
||||
rows.first { $0.id == focusID }?.detail ?? " "
|
||||
}
|
||||
|
||||
/// The option band's fixed stage. A portrait phone is the one place the full 240 pt starves
|
||||
/// the row's label (everywhere else the 620 pt row cap leaves room to spare), so it alone
|
||||
/// narrows the stage.
|
||||
private var bandWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
|
||||
#else
|
||||
GamepadFormMetrics.bandWidth
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Row model
|
||||
|
||||
private struct Row: Identifiable {
|
||||
@@ -422,6 +458,11 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// A choice row's full option list (labels only — the tags stay inside the closures)
|
||||
/// and where its drum currently rests. nil ⇒ the value renders as plain text (toggles,
|
||||
/// actions, profiles — a two-position switch is not a drum; see GamepadOptionBand).
|
||||
var optionLabels: [String]?
|
||||
var selectedIndex: Int?
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
@@ -649,6 +690,22 @@ struct GamepadSettingsView: View {
|
||||
value: $rumbleOnDevice),
|
||||
at: at + 1)
|
||||
}
|
||||
// The phone-gyro mirror sits beside the rumble mirror: same clip-on-pad audience,
|
||||
// opposite data direction. Hidden where the device has no motion hardware; engages
|
||||
// in-session only while player 1's controller reports no rotation rate of its own.
|
||||
if DeviceGyro.isAvailable,
|
||||
let anchor = list.firstIndex(where: { $0.id == "deviceRumble" })
|
||||
?? list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceGyro", tab: .controller,
|
||||
icon: "gyroscope",
|
||||
label: "Gyro from this device",
|
||||
detail: "When the controller has no gyro, send this device's motion "
|
||||
+ "sensors as player 1's — for clip-on pads without one of their own.",
|
||||
value: $gyroFromDevice),
|
||||
at: anchor + 1)
|
||||
}
|
||||
#endif
|
||||
return list + profileRows
|
||||
}
|
||||
@@ -710,6 +767,8 @@ struct GamepadSettingsView: View {
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
optionLabels: ["Off", "Pinned"],
|
||||
selectedIndex: pinned ? 1 : 0,
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
@@ -776,6 +835,10 @@ struct GamepadSettingsView: View {
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
// The band mounts only once the value is a known option — the "—" of an unknown
|
||||
// current renders flat, and the first step's snap-to-first seats the drum.
|
||||
optionLabels: index != nil ? options.map(\.label) : nil,
|
||||
selectedIndex: index,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Unknown current value: snap to the first option on any step.
|
||||
@@ -803,6 +866,10 @@ struct GamepadSettingsView: View {
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
// Toggles ride the band too (field ask): Off sits left of On, matching the
|
||||
// directional semantics below, so a right-step slides On in from the right.
|
||||
optionLabels: ["Off", "On"],
|
||||
selectedIndex: value.wrappedValue ? 1 : 0,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
|
||||
@@ -712,6 +712,15 @@ extension SettingsView {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
}
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: hidden where the
|
||||
// device has no motion hardware, engages only while the player-1 controller
|
||||
// reports no rotation rate of its own.
|
||||
if !inProfileScope, DeviceGyro.isAvailable {
|
||||
described("When the controller has no gyro of its own, sends this device's "
|
||||
+ "motion sensors as player 1's — for clip-on pads without one.") {
|
||||
Toggle("Gyro from this device", isOn: $gyroFromDevice)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
if !inProfileScope {
|
||||
|
||||
@@ -91,6 +91,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||
@AppStorage(DefaultsKey.gyroFromDevice) var gyroFromDevice = false
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
|
||||
@@ -70,12 +70,17 @@ extension View {
|
||||
|
||||
// MARK: - Console glass (gamepad host tiles + settings rows)
|
||||
|
||||
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and
|
||||
/// Liquid Glass tuned for the gamepad UI's "console" surfaces — the host-carousel tiles and
|
||||
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
|
||||
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
|
||||
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
|
||||
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces
|
||||
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
|
||||
/// glass toward a color (the palette accent on the focused / primary surface); `interactive` makes
|
||||
/// it flex on press.
|
||||
///
|
||||
/// Every tier is WASHED with the palette's `ink.glass` — the same surface colour the console
|
||||
/// fills its panels with — so switching the background palette recolours the surfaces, not just
|
||||
/// the text on them. The wash alphas are tune-on-device values with one fixed direction: the
|
||||
/// pale palettes' white frost needs MORE body than the dark glass (the console's 0.66-vs-0.62
|
||||
/// pair), because a thin white wash over a colourful field reads as haze, not as a surface.
|
||||
private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var tint: Color?
|
||||
@@ -86,16 +91,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
||||
/// The palette wash over the material tiers (the material itself supplies the blur body).
|
||||
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
|
||||
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
@@ -104,7 +112,14 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -112,8 +127,13 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
#if !os(tvOS)
|
||||
@available(iOS 26, macOS 26, *)
|
||||
private var glass: Glass {
|
||||
var g: Glass = .regular
|
||||
if let tint { g = g.tint(tint) }
|
||||
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
|
||||
// it: mixed 60 % toward the caller's (the focused row must still read accented on
|
||||
// every palette) over the palette base. If device QA finds the mixed focus wash too
|
||||
// weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit.
|
||||
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
|
||||
var g: Glass = .regular.tint(
|
||||
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
|
||||
if interactive { g = g.interactive() }
|
||||
return g
|
||||
}
|
||||
@@ -121,9 +141,51 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||
/// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
||||
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
||||
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Console floating glass (the gamepad screens' close buttons)
|
||||
|
||||
/// `glassBackground` for a floating control INSIDE the gamepad UI (the close ✕): same shape
|
||||
/// contract, but washed with the palette's ink and frosted to the palette's scheme — plain
|
||||
/// `glassBackground` follows the SYSTEM appearance, which leaves the frost dark under dark ink
|
||||
/// when a pale palette is up. The non-gamepad floating surfaces (the HUD, the trust card, the
|
||||
/// touch connect modal) keep plain `glassBackground`: they sit over video or the touch UI,
|
||||
/// where the palette means nothing.
|
||||
private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var interactive = false
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||
content
|
||||
.glassEffect(
|
||||
(interactive ? Glass.regular.interactive() : .regular)
|
||||
.tint(ink.glass(ink.isLight ? 0.60 : 0.45)),
|
||||
in: shape)
|
||||
.environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Palette-washed floating glass for the gamepad screens' own controls. Same fallback story
|
||||
/// as `glassBackground` (`.regularMaterial` pre-26), plus the ink wash and scheme flip.
|
||||
func consoleGlassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
|
||||
modifier(ConsoleGlassBackground(shape: shape, interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,19 +483,52 @@ public final class SessionAudio {
|
||||
}
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable (logged) — keep the session audible on the plain playback
|
||||
// engine rather than playing through an idle voice processor.
|
||||
|
||||
// The capture side must be PULLED, and only the render graph pulls anything. An input
|
||||
// node carrying nothing but a tap is not part of that graph, so on the combined engine
|
||||
// nobody drove it: the IO unit came up (the recording indicator lit for a beat, then went
|
||||
// out as the input went idle) and NOT ONE BUFFER ever reached the tap — no error, no
|
||||
// failed start, just a session that quietly sent no microphone at all. Routing the input
|
||||
// through a silent sink puts it in the graph, which is what Apple's own voice-processing
|
||||
// sample does. The split path never needed it: a capture-only engine has the input node
|
||||
// AS its graph, so it is pulled by definition — which is why this only broke when the
|
||||
// combined topology became the default.
|
||||
//
|
||||
// `outputVolume = 0` on the sink: the mic has to reach the graph, never the speaker. At
|
||||
// any audible volume this is a microphone wired straight to the earpiece.
|
||||
let micSink = AVAudioMixerNode()
|
||||
engine.attach(micSink)
|
||||
micSink.outputVolume = 0
|
||||
engine.connect(engine.inputNode, to: micSink, format: nil)
|
||||
engine.connect(micSink, to: engine.mainMixerNode, format: nil)
|
||||
|
||||
// BEFORE the tap reads a format. Enabling voice processing swaps the engine's IO unit
|
||||
// for the VPIO one and renegotiates its formats, and until the engine is prepared the
|
||||
// input node can still report the pre-swap state — 0 Hz / 0 channels included, which
|
||||
// `installMicTap` (correctly) refuses as "no usable input device". Preparing first means
|
||||
// the chain is built against what the voice processor will actually emit.
|
||||
engine.prepare()
|
||||
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable on the VOICE-PROCESSED engine (logged). The mic outranks the
|
||||
// echo cancellation, so fall back to the split path — its own engine, no voice
|
||||
// processor, the topology that shipped before AEC existed — rather than dropping the
|
||||
// uplink for the rest of the session. (The sibling failure above, where the voice
|
||||
// processor won't engage at all, already does exactly this; this arm used to give up
|
||||
// on the mic instead, which is how a whole session could go silent uplink-only.)
|
||||
engine.stop()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
// Same rule: a working mic without echo cancellation beats no mic at all.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
@@ -533,8 +566,16 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
|
||||
// Prepared before the tap reads a format, for the same reason the combined path does it:
|
||||
// a node that hasn't been through `prepare()` can still report the pre-negotiation
|
||||
// format (0 Hz / 0 channels on a device that is perfectly fine), which reads downstream
|
||||
// as "no microphone".
|
||||
engine.prepare()
|
||||
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
|
||||
log.error("mic uplink unavailable — this session sends no microphone audio")
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// The opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): when player 1's forwarded
|
||||
// controller has no rotation sensor of its own, THIS device's IMU speaks for it on the wire's
|
||||
// motion plane — for clip-on and third-party pads that ship without a gyro, where the phone
|
||||
// body is rigidly attached to (or simply is) the thing in the player's hands. The sibling of
|
||||
// `GamepadFeedback`'s rumble-on-device mirror, with the data flowing the other way.
|
||||
//
|
||||
// GamepadCapture owns the engage/stand-down decision (it knows the pad-0 slot and whether its
|
||||
// controller reports a rotation rate); this class only turns CoreMotion on and off and converts
|
||||
// samples. Two invariants it enforces itself:
|
||||
// - one motion writer per pad: samples go out only between `start` and `stop`, and capture
|
||||
// suppresses pad 0's controller-motion forwarding while this runs;
|
||||
// - no stale rotation: `stop` sends a single zero-gyro sample after the last real one, so the
|
||||
// host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
// producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
//
|
||||
// Samples are CMDeviceMotion (sensor-fused: bias-corrected rotation rate, gravity split from
|
||||
// user acceleration) at the ~100 Hz CoreMotion ceiling — below a DualSense's 250 Hz, but the
|
||||
// host's motion plane is event-driven, not cadence-locked, so a slower producer just means
|
||||
// fewer samples. Units and axis semantics match `GamepadCapture.forwardMotion` exactly (the
|
||||
// `GamepadWire` constants; accel = gravity + user acceleration — the same convention, so a
|
||||
// future sign/scale correction lands in one place for both sources). The one thing the phone
|
||||
// adds is a frame remap: CoreMotion reports in the device's portrait frame, while the wire
|
||||
// wants the controller frame the player sees (x right, y up, z out of the screen), so each
|
||||
// sample is rotated by the current interface orientation — a phone clipped landscape must yaw
|
||||
// when the player yaws, not roll.
|
||||
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Device-frame → controller-frame axis remap for one interface orientation. CoreMotion's
|
||||
/// frame is fixed to the portrait device (+x right edge, +y top, +z out of the screen); the
|
||||
/// controller frame keeps +z (the screen always faces the player) and rotates x/y so they
|
||||
/// mean "player's right" and "player's up". Derived, like the wire scale constants — pinned
|
||||
/// by `DeviceGyroRemapTests`, correctable in one place if on-glass says otherwise.
|
||||
/// File-scope rather than nested so the sample thread can use it without actor isolation.
|
||||
enum DeviceGyroRemap {
|
||||
case identity
|
||||
/// Upside-down portrait: both in-plane axes flip.
|
||||
case flipped
|
||||
/// Landscape, device top to the player's LEFT (interface `.landscapeRight`):
|
||||
/// player-right = device-bottom, player-up = device-right.
|
||||
case topLeft
|
||||
/// Landscape, device top to the player's RIGHT (interface `.landscapeLeft`).
|
||||
case topRight
|
||||
|
||||
init(_ orientation: UIInterfaceOrientation) {
|
||||
switch orientation {
|
||||
case .portraitUpsideDown: self = .flipped
|
||||
case .landscapeRight: self = .topLeft
|
||||
case .landscapeLeft: self = .topRight
|
||||
default: self = .identity
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
/// same way under an in-plane rotation) into the controller frame.
|
||||
func apply(x: Float, y: Float, z: Float) -> (x: Float, y: Float, z: Float) {
|
||||
switch self {
|
||||
case .identity: return (x, y, z)
|
||||
case .flipped: return (-x, -y, z)
|
||||
case .topLeft: return (-y, x, z)
|
||||
case .topRight: return (y, -x, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class DeviceGyro {
|
||||
/// Whether this device can source motion at all — gates the settings rows (a device
|
||||
/// without an IMU would make the toggle a silent no-op, the rumble mirror's rule).
|
||||
/// One shared probe: Apple recommends a single `CMMotionManager` per app, and the
|
||||
/// settings UI asking per-render must not allocate one each time.
|
||||
public static let isAvailable: Bool = CMMotionManager().isDeviceMotionAvailable
|
||||
|
||||
/// Everything the sample thread touches, behind one lock: the orientation remap (written
|
||||
/// on main when the device rotates), the last converted accel, and whether a real sample
|
||||
/// went out (so `stop` knows it owes the wire a zero). Kept off the actor deliberately —
|
||||
/// `forward` runs on the delivery queue.
|
||||
private final class SampleState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var remap: DeviceGyroRemap = .identity
|
||||
var sentSample = false
|
||||
/// Re-sent with the closing zero-gyro sample so "rotation stopped" doesn't also
|
||||
/// overwrite a plausible gravity vector with free-fall.
|
||||
var lastAccel: (Int16, Int16, Int16) = (0, 0, 0)
|
||||
}
|
||||
|
||||
/// Ship one converted sample (wire pad 0). Must be thread-safe — invoked from the
|
||||
/// delivery queue (`PunktfunkConnection.sendMotion` locks internally).
|
||||
private let send: @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
|
||||
private let motion = CMMotionManager()
|
||||
/// Dedicated serial delivery queue — deliberately NOT main (the controller path's
|
||||
/// main-queue delivery is a known jitter source; the mirror starts clean).
|
||||
private let queue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "punktfunk.device-gyro"
|
||||
q.maxConcurrentOperationCount = 1
|
||||
return q
|
||||
}()
|
||||
|
||||
private let state = SampleState()
|
||||
private var orientationObserver: NSObjectProtocol?
|
||||
|
||||
/// Whether the mirror is between `start` and `stop` — read by GamepadCapture to keep the
|
||||
/// controller path off pad 0's motion while this runs.
|
||||
public private(set) var isRunning = false
|
||||
|
||||
public init(
|
||||
send: @escaping @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
) {
|
||||
self.send = send
|
||||
}
|
||||
|
||||
/// Begin sourcing pad-0 motion from this device. Idempotent.
|
||||
public func start() {
|
||||
guard !isRunning, motion.isDeviceMotionAvailable else { return }
|
||||
isRunning = true
|
||||
updateRemap()
|
||||
// Interface orientation only changes alongside a device-orientation notification, so
|
||||
// this is the one signal needed; re-reading the scene keeps a rotation lock stable.
|
||||
orientationObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.updateRemap() }
|
||||
}
|
||||
// CoreMotion's practical ceiling; requesting faster just clamps.
|
||||
motion.deviceMotionUpdateInterval = 1.0 / 100.0
|
||||
motion.startDeviceMotionUpdates(to: queue) { [state, send] m, _ in
|
||||
guard let m else { return }
|
||||
Self.forward(m, state: state, send: send)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop sourcing and, if anything was sent, park the host pad's rotation at zero. The
|
||||
/// zero rides the same serial queue as the samples, so it is guaranteed last — without
|
||||
/// blocking the caller.
|
||||
public func stop() {
|
||||
guard isRunning else { return }
|
||||
isRunning = false
|
||||
motion.stopDeviceMotionUpdates()
|
||||
if let o = orientationObserver {
|
||||
NotificationCenter.default.removeObserver(o)
|
||||
orientationObserver = nil
|
||||
}
|
||||
queue.addOperation { [state, send] in
|
||||
state.lock.lock()
|
||||
let owed = state.sentSample
|
||||
state.sentSample = false
|
||||
let accel = state.lastAccel
|
||||
state.lock.unlock()
|
||||
if owed { send((0, 0, 0), accel) }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemap() {
|
||||
let o = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.interfaceOrientation ?? .portrait
|
||||
state.lock.lock()
|
||||
state.remap = DeviceGyroRemap(o)
|
||||
state.lock.unlock()
|
||||
}
|
||||
|
||||
/// Runs on the delivery queue: remap, scale, ship.
|
||||
nonisolated private static func forward(
|
||||
_ m: CMDeviceMotion, state: SampleState,
|
||||
send: (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
) {
|
||||
state.lock.lock()
|
||||
let r = state.remap
|
||||
state.lock.unlock()
|
||||
let rot = r.apply(
|
||||
x: Float(m.rotationRate.x), y: Float(m.rotationRate.y), z: Float(m.rotationRate.z))
|
||||
// Same total-acceleration convention as GamepadCapture.forwardMotion.
|
||||
let acc = r.apply(
|
||||
x: Float(m.gravity.x + m.userAcceleration.x),
|
||||
y: Float(m.gravity.y + m.userAcceleration.y),
|
||||
z: Float(m.gravity.z + m.userAcceleration.z))
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
let gyro = (
|
||||
GamepadWire.motionRaw(rot.x, scale: gs),
|
||||
GamepadWire.motionRaw(rot.y, scale: gs),
|
||||
GamepadWire.motionRaw(rot.z, scale: gs)
|
||||
)
|
||||
let accel = (
|
||||
GamepadWire.motionRaw(acc.x, scale: as_),
|
||||
GamepadWire.motionRaw(acc.y, scale: as_),
|
||||
GamepadWire.motionRaw(acc.z, scale: as_)
|
||||
)
|
||||
state.lock.lock()
|
||||
state.lastAccel = accel
|
||||
state.sentSample = true
|
||||
state.lock.unlock()
|
||||
send(gyro, accel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -67,6 +67,13 @@ public final class GamepadCapture {
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
/// A motion sample went out on this pad — `flush` then owes the wire a zero-gyro
|
||||
/// sample: the host holds motion as STATE and re-emits it, so a nonzero angular
|
||||
/// velocity left behind reads as endless rotation (the gyro-sweep latch).
|
||||
var motionSent = false
|
||||
/// The last accel sent, re-used by the flush zero so "rotation stopped" doesn't
|
||||
/// also replace a plausible gravity vector with free-fall.
|
||||
var lastAccel: (Int16, Int16, Int16) = (0, 0, 0)
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
@@ -153,6 +160,15 @@ public final class GamepadCapture {
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
#if os(iOS)
|
||||
/// Opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): while player 1's forwarded
|
||||
/// controller has no rotation sensor, this device's IMU sources pad 0's motion instead —
|
||||
/// for clip-on pads without a gyro. Session-scoped (the setting is read once here); nil
|
||||
/// when off, unavailable, or forwarding is off (the mirror is wire-only, so with nothing
|
||||
/// to send there is nothing to mirror). Engage/stand-down lives in `updateDeviceGyro`.
|
||||
private let deviceGyro: DeviceGyro?
|
||||
#endif
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
@@ -162,6 +178,17 @@ public final class GamepadCapture {
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
#if os(iOS)
|
||||
if forwarding, DeviceGyro.isAvailable,
|
||||
UserDefaults.standard.bool(forKey: DefaultsKey.gyroFromDevice) {
|
||||
deviceGyro = DeviceGyro { [weak connection] gyro, accel in
|
||||
// Thread-safe (sendMotion locks); pad 0 by the same rule as the rumble mirror.
|
||||
connection?.sendMotion(pad: 0, gyro: gyro, accel: accel)
|
||||
}
|
||||
} else {
|
||||
deviceGyro = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -187,6 +214,9 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated {
|
||||
self?.suspended = true
|
||||
self?.releaseAll()
|
||||
// The mirror pauses with capture (its stop parks the host pad's rotation
|
||||
// at zero — an overlay pull-down must not leave the game spinning).
|
||||
self?.updateDeviceGyro()
|
||||
}
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
@@ -199,11 +229,15 @@ public final class GamepadCapture {
|
||||
for slot in self.slots {
|
||||
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||
}
|
||||
self.updateDeviceGyro()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
#if os(iOS)
|
||||
deviceGyro?.stop()
|
||||
#endif
|
||||
closeAllSlots()
|
||||
forwardedSub = nil
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
@@ -224,6 +258,8 @@ public final class GamepadCapture {
|
||||
}
|
||||
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||
updateEscapeChord()
|
||||
// Pad 0 may have changed hands — re-evaluate whether this device's IMU speaks for it.
|
||||
updateDeviceGyro()
|
||||
}
|
||||
|
||||
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||
@@ -561,6 +597,13 @@ public final class GamepadCapture {
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
guard !suspended else { return }
|
||||
#if os(iOS)
|
||||
// While the phone-gyro mirror speaks for pad 0, the controller's own motion —
|
||||
// necessarily rotation-less, that's the engage condition — stays off the wire:
|
||||
// two writers on one pad's motion state would fight, and this accel-only stream
|
||||
// would keep stomping the mirror's gyro with zeros.
|
||||
if slot.pad == 0, deviceGyro?.isRunning == true { return }
|
||||
#endif
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
|
||||
slot.lastMotionNs = now
|
||||
@@ -579,18 +622,35 @@ public final class GamepadCapture {
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs)
|
||||
),
|
||||
accel: (
|
||||
GamepadWire.motionRaw(ax, scale: as_),
|
||||
GamepadWire.motionRaw(ay, scale: as_),
|
||||
GamepadWire.motionRaw(az, scale: as_)
|
||||
))
|
||||
let gyro = (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs)
|
||||
)
|
||||
let accel = (
|
||||
GamepadWire.motionRaw(ax, scale: as_),
|
||||
GamepadWire.motionRaw(ay, scale: as_),
|
||||
GamepadWire.motionRaw(az, scale: as_)
|
||||
)
|
||||
if wire != nil {
|
||||
slot.motionSent = true
|
||||
slot.lastAccel = accel
|
||||
}
|
||||
wire?.sendMotion(pad: UInt8(slot.pad), gyro: gyro, accel: accel)
|
||||
}
|
||||
|
||||
/// Engage or stand down the phone-gyro mirror: it speaks for pad 0 exactly while a
|
||||
/// forwarded controller holds that index but can't rotate for itself — no `GCMotion`,
|
||||
/// or a motion object without a rotation rate (gravity-only pads, e.g. an Xbox pad on
|
||||
/// iOS). Re-evaluated on every reconcile and on suspend/resume; `DeviceGyro.stop`
|
||||
/// parks the host pad's rotation at zero, so standing down never strands a spin.
|
||||
private func updateDeviceGyro() {
|
||||
#if os(iOS)
|
||||
guard let gyro = deviceGyro else { return }
|
||||
let pad0 = slots.first { $0.pad == 0 }
|
||||
let wants = !suspended && pad0 != nil && pad0!.controller.motion?.hasRotationRate != true
|
||||
if wants { gyro.start() } else { gyro.stop() }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||
@@ -634,6 +694,14 @@ public final class GamepadCapture {
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
// Motion is host-side STATE, re-emitted until replaced — a nonzero angular velocity
|
||||
// left behind reads as endless rotation (the gyro-sweep latch: Control Center
|
||||
// pull-down froze the last sample for as long as the overlay stayed up). Rest means
|
||||
// zero rotation; the last accel is kept so gravity doesn't become free-fall.
|
||||
if slot.motionSent {
|
||||
slot.motionSent = false
|
||||
wire?.sendMotion(pad: UInt8(slot.pad), gyro: (0, 0, 0), accel: slot.lastAccel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -193,6 +193,14 @@ public enum DefaultsKey {
|
||||
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||
/// has a haptic actuator (no iPad/Mac/TV).
|
||||
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||
/// Use this device's own gyroscope as player 1's motion when the forwarded controller has
|
||||
/// none of its own — for clip-on and third-party pads without an IMU, where the device body
|
||||
/// moves with the player's hands. The rumble mirror's sibling, data flowing the other way.
|
||||
/// Off by default (opt-in); read once per session by `GamepadCapture`, whose `DeviceGyro`
|
||||
/// mirror engages only while pad 0's controller reports no rotation rate (a real gyro pad
|
||||
/// always wins). The toggle is shown only where the device has motion hardware
|
||||
/// (`DeviceGyro.isAvailable`).
|
||||
public static let gyroFromDevice = "punktfunk.gyroFromDevice"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Pins the phone-gyro mirror's device→controller frame remap (DeviceGyro.swift). The matrix is
|
||||
// derived (like the wire scale constants), so these tests are the contract: if on-glass says an
|
||||
// axis is wrong, fix the enum AND these expectations together.
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class DeviceGyroRemapTests: XCTestCase {
|
||||
/// A distinct vector per axis so a swapped or flipped component can't cancel out.
|
||||
private let v: (x: Float, y: Float, z: Float) = (1, 2, 3)
|
||||
|
||||
func testPortraitIsIdentity() {
|
||||
let r = DeviceGyroRemap.identity.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [1, 2, 3])
|
||||
}
|
||||
|
||||
func testUpsideDownFlipsInPlane() {
|
||||
let r = DeviceGyroRemap.flipped.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [-1, -2, 3])
|
||||
}
|
||||
|
||||
/// Device top to the player's LEFT: player-right = device-bottom (−y), player-up =
|
||||
/// device-right (+x). z (out of the screen) never changes — the screen faces the player.
|
||||
func testTopLeftLandscape() {
|
||||
let r = DeviceGyroRemap.topLeft.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [-2, 1, 3])
|
||||
}
|
||||
|
||||
/// Device top to the player's RIGHT: player-right = device-top (+y), player-up =
|
||||
/// device-left (−x).
|
||||
func testTopRightLandscape() {
|
||||
let r = DeviceGyroRemap.topRight.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [2, -1, 3])
|
||||
}
|
||||
|
||||
/// Interface orientation → remap: `.landscapeRight` means the Home edge is on the
|
||||
/// player's right, i.e. the device top points LEFT (and vice versa).
|
||||
func testOrientationMapping() {
|
||||
XCTAssertEqual(DeviceGyroRemap(.portrait), .identity)
|
||||
XCTAssertEqual(DeviceGyroRemap(.portraitUpsideDown), .flipped)
|
||||
XCTAssertEqual(DeviceGyroRemap(.landscapeRight), .topLeft)
|
||||
XCTAssertEqual(DeviceGyroRemap(.landscapeLeft), .topRight)
|
||||
XCTAssertEqual(DeviceGyroRemap(.unknown), .identity)
|
||||
}
|
||||
|
||||
/// Every remap must stay a proper rotation (right-handed): x̂ × ŷ = ẑ after mapping.
|
||||
func testHandednessPreserved() {
|
||||
for remap in [DeviceGyroRemap.identity, .flipped, .topLeft, .topRight] {
|
||||
let x = remap.apply(x: 1, y: 0, z: 0)
|
||||
let y = remap.apply(x: 0, y: 1, z: 0)
|
||||
// Cross product of the two mapped in-plane basis vectors.
|
||||
let cross = (
|
||||
x: x.y * 0 - 0 * y.y, y: 0 * y.x - x.x * 0, z: x.x * y.y - x.y * y.x
|
||||
)
|
||||
XCTAssertEqual(cross.z, 1, "left-handed remap: \(remap)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "punktfunk-client-linux"
|
||||
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, FFmpeg decode, PipeWire audio, SDL3 gamepads"
|
||||
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, PipeWire audio, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
+13
-8
@@ -12,9 +12,11 @@ Built in Rust end to end (no C ABI): the shell shares its plumbing with the sess
|
||||
|
||||
## Features
|
||||
|
||||
- **Zero-copy hardware decode** — the session presenter decodes via **Vulkan Video** on every GPU
|
||||
vendor (including NVIDIA), falling back to FFmpeg VAAPI → DRM-PRIME dmabuf and then software when
|
||||
Vulkan Video is unavailable.
|
||||
- **Zero-copy hardware decode, and it's ours** — the session presenter decodes with Punktfunk's own
|
||||
decoders; no FFmpeg is linked or bundled. **Vulkan Video** (`pf-vkdecode`, decoding onto the
|
||||
presenter's own device) leads on NVIDIA and AMD, **VAAPI** (`pf-vaadec` driving a dlopen'd libva,
|
||||
exporting DRM-PRIME dmabufs) leads on Intel, whichever isn't first is the fallback, and an
|
||||
OpenH264/rav1d CPU rung is last.
|
||||
- **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no
|
||||
scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN.
|
||||
- **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host.
|
||||
@@ -50,8 +52,11 @@ Per-device install steps and pairing walkthrough:
|
||||
|
||||
## Build & run from source
|
||||
|
||||
Requires GTK ≥ 4.16, libadwaita ≥ 1.5, FFmpeg 7 or 8 (with VAAPI for hardware decode), PipeWire,
|
||||
and SDL3 (with hidapi) development packages.
|
||||
Requires GTK ≥ 4.16, libadwaita ≥ 1.5, PipeWire, and SDL3 (with hidapi) development packages,
|
||||
plus a C compiler (the CPU rung builds OpenH264 from source). No *decoder* development package
|
||||
is needed: libva and the Vulkan loader are both opened at runtime rather than linked, so
|
||||
hardware decode is a fact about the box you **run** on — a Vulkan loader and your GPU's driver,
|
||||
and libva for the VAAPI rung — not about the one you build on.
|
||||
|
||||
```sh
|
||||
# from the repo root
|
||||
@@ -85,9 +90,9 @@ src/
|
||||
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
||||
```
|
||||
|
||||
The UI-agnostic plumbing — session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads +
|
||||
keymap, trust store, mDNS discovery, library client, Wake-on-LAN — lives in
|
||||
`crates/pf-client-core`, shared with the Vulkan session binary.
|
||||
The UI-agnostic plumbing — session pump, the native decode ladder (Vulkan Video · VAAPI ·
|
||||
OpenH264/rav1d), PipeWire audio, SDL3 gamepads + keymap, trust store, mDNS discovery, library
|
||||
client, Wake-on-LAN — lives in `crates/pf-client-core`, shared with the Vulkan session binary.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -728,7 +728,11 @@ const CODEC_LABELS: &[&str] = &[
|
||||
"AV1",
|
||||
"PyroWave (wired LAN)",
|
||||
];
|
||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
||||
// Stored decoder-preference values. `native-*` since M10 — the bare "vulkan"/"vaapi"
|
||||
// named libavcodec's rungs, which are deleted; a store still holding them is migrated on
|
||||
// read (`pf_client_core::video::migrate_decoder_pref`) and simply matches no entry here
|
||||
// until the user re-picks. The labels below are unchanged and still true.
|
||||
const DECODERS: &[&str] = &["auto", "native-vulkan", "native-vaapi", "software"];
|
||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||
@@ -773,16 +777,26 @@ const APP_LICENSE: &str = concat!(
|
||||
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||||
include_str!("../../../LICENSE-APACHE"),
|
||||
);
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||
/// Third-party software notices for the Rust crates THIS CLIENT links — the shell, the
|
||||
/// session streamer, the headless CLI and the update helper (generated by
|
||||
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog, and
|
||||
/// shipped as /usr/share/doc/punktfunk-client/THIRD-PARTY-NOTICES.txt by the packages).
|
||||
///
|
||||
/// Deliberately the client-scoped file and not the workspace-wide one at the repo root:
|
||||
/// that root file is the HOST's, it still carries `ffmpeg-next` and the full FFmpeg licence
|
||||
/// text — and after M10 this app links no FFmpeg at all, which is exactly what the section
|
||||
/// below it claims.
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
/// The dynamically linked system libraries — not in the crate notices, since they aren't
|
||||
/// crates. Their full texts ship with each project rather than being vendored here.
|
||||
const SYSTEM_LIBRARY_NOTICES: &str =
|
||||
"This application dynamically links system libraries under their own licenses, including \
|
||||
FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), and SDL 3 (Zlib). \
|
||||
Their full license texts are available from each project.";
|
||||
GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), and SDL 3 (Zlib). \
|
||||
Their full license texts are available from each project. Video decoding uses the \
|
||||
system's own Vulkan Video and VAAPI drivers (loaded at runtime, never linked), with \
|
||||
OpenH264 and rav1d — both BSD-2-Clause, and both in the Rust crate notices — as the \
|
||||
CPU fallback; no FFmpeg is linked or bundled.";
|
||||
|
||||
/// Show the About dialog (app license + the third-party-software Legal section) — reached
|
||||
/// from the primary menu (app.rs `win.about`).
|
||||
@@ -806,7 +820,7 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
.license_type(gtk::License::Custom)
|
||||
.license(license.as_str())
|
||||
.build();
|
||||
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||||
// The native (GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||||
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
|
||||
about.add_legal_section(
|
||||
"Third-party software (Rust crates)",
|
||||
@@ -1628,7 +1642,11 @@ pub fn show_scoped(
|
||||
mouse_row.set_selected(mouse_i);
|
||||
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i as usize]);
|
||||
compositor_row.set_selected(index::compositor(s));
|
||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||
// Migrated for the LOOKUP only (the store is left alone): a pre-M10 settings file
|
||||
// holds `vulkan`/`vaapi`, which match no entry — the combo would show Automatic and
|
||||
// a save would silently rewrite the user's hardware preference to `auto`.
|
||||
let dec_stored = pf_client_core::video::migrate_decoder_pref(&s.decoder);
|
||||
let dec_i = DECODERS.iter().position(|&d| d == dec_stored).unwrap_or(0);
|
||||
decoder_row.set_selected(dec_i as u32);
|
||||
stats_row.set_selected(index::stats(s));
|
||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||
|
||||
@@ -24,7 +24,6 @@ pyrowave = ["pf-client-core/pyrowave", "pf-presenter/pyrowave"]
|
||||
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
|
||||
# stats on stdout only.
|
||||
ui = ["dep:pf-console-ui", "dep:serde_json"]
|
||||
|
||||
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
||||
# binary.
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
|
||||
@@ -49,19 +49,67 @@ path + per-stage latency equation); any tier but Off also emits the stdout mirro
|
||||
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
|
||||
only, no Skia anywhere in the dependency tree.
|
||||
|
||||
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software on Linux,
|
||||
Vulkan Video → D3D11VA → software on Windows): FFmpeg's Vulkan Video decoder runs on the
|
||||
presenter's own device where the stack supports it (every vendor, zero copy); VAAPI
|
||||
dmabufs import per-plane elsewhere (D3D11VA textures on Windows); software is the
|
||||
universal fallback. 10-bit Main10 and HDR10 are advertised
|
||||
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
|
||||
Decode follows the Settings preference (auto is vendor-ordered: Vulkan Video → VAAPI →
|
||||
software on Linux, Vulkan Video → D3D11VA → software on Windows, with VAAPI/D3D11VA first
|
||||
on Intel — every rung native since M10; see "Decode rungs" below): the Vulkan decoder runs
|
||||
on the presenter's own device where the stack supports it (every vendor, zero copy); VAAPI
|
||||
dmabufs import per-plane elsewhere (D3D11VA textures on Windows); software is the universal
|
||||
fallback. 10-bit Main10 and HDR10 are advertised (`VIDEO_CAP_10BIT|HDR`): P010 decodes
|
||||
through the Vulkan and VAAPI/D3D11VA paths (the CPU rung is 8-bit by contract and refuses
|
||||
10-bit rather than mis-scaling it), and PQ streams present
|
||||
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
|
||||
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
|
||||
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
|
||||
policy.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
|
||||
## Decode rungs (M10: native only)
|
||||
|
||||
**This binary contains no FFmpeg.** `auto` walks native rungs — pf-vkdecode over Vulkan
|
||||
Video, then the platform's own (pf-dxvadec on Windows, pf-vaadec on Linux), then the CPU
|
||||
rung (openh264/rav1d). The libavcodec rungs that used to sit under each of them are
|
||||
deleted, along with `pf-ffvk` and the `ffmpeg-next` dependency.
|
||||
|
||||
Two of the native rungs have never decoded a frame on real hardware (native VAAPI at all;
|
||||
native D3D11VA's AV1 leg). They run anyway — with the libavcodec twins gone, the only
|
||||
thing below them is the CPU, so barring them would cost the session hardware decode
|
||||
outright rather than move it one rung down. What replaces the safety net is the log: every
|
||||
session names the rung it landed on with its evidence state,
|
||||
|
||||
decode rung active rung=native-vulkan codec=HEVC hardware_verified=true evidence=...
|
||||
|
||||
…and that line is a **WARNING** when nothing has ever decoded a frame through the
|
||||
rung/codec pair the session chose. `pf-client-core`'s `video.rs` module docs carry the full
|
||||
table; read any field report about M10 against it.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=native-vulkan|native-vaapi|native-d3d11va|software`
|
||||
(a pin skips the vendor order, which is how a lab run reaches a rung `auto` will not pick
|
||||
on this device; a pinned rung that cannot open still falls through to the standard ladder,
|
||||
loudly; `native-vaapi` also takes `PUNKTFUNK_VAAPI_DEVICE=/dev/dri/renderDNNN` to choose
|
||||
the GPU). The pre-M10 spellings `vulkan`/`vaapi`/`d3d11va` named the libavcodec rungs
|
||||
specifically; they are MIGRATED onto the native rung for the same hardware family, with a
|
||||
`warn` line saying so — every desktop Settings UI offered those values, so refusing them
|
||||
would end a session over a dropdown someone picked long ago.
|
||||
`PUNKTFUNK_PRESENT_MODE=
|
||||
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
|
||||
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
|
||||
demotion to software on healthy hardware).
|
||||
|
||||
`PUNKTFUNK_AU_FAULT=drop|truncate|flip[:period]` deliberately corrupts decoder input on the
|
||||
native Vulkan lane (default period 60 — one AU a second at 60 fps; inert everywhere else, and
|
||||
inert entirely if the value doesn't parse). `drop` swallows the AU, so the next one references a
|
||||
picture that was never decoded — the bitstream planner catches it immediately. `truncate` delivers
|
||||
a picture whose slice data stops mid-frame and `flip` alters one byte deep in the payload: both
|
||||
parse perfectly, so only the driver's per-frame decode-status query can see them, and neither is
|
||||
visible at all on a driver without `queryResultStatusSupport`. Watch the
|
||||
result on the Detailed stats line's `integrity:` term (`damaged` = concealment the planner caught,
|
||||
`refused` = AUs the decoder rejected outright, `driver-failed` = the hardware's own verdict, `run`
|
||||
= consecutive frames with no picture, `worst run` = the longest such stretch of the session — the
|
||||
once-a-second `run` sample misses the bad moment almost every time — and `no driver status` = this
|
||||
device cannot answer the driver question at all). A session that lands on any other lane says so
|
||||
in the log rather than faulting silently.
|
||||
|
||||
Note that `PUNKTFUNK_AU_DUMP` records the AU as it arrived from the HOST, while the fault injector
|
||||
runs later, at the native decoder's own entry. On a faulted run the dump is therefore the clean
|
||||
bitstream — reconstruct the damaged bytes from the spec if you need them (the injector is pure and
|
||||
deterministic).
|
||||
|
||||
@@ -266,6 +266,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
ActionOutcome::Start(Box::new(params))
|
||||
}
|
||||
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||
// Also run-loop-side: the clipboard belongs to SDL, which this callback
|
||||
// has no handle on. Unreachable in practice — listed so adding an action
|
||||
// to the enum keeps failing loudly here instead of falling into a
|
||||
// wildcard that silently drops it.
|
||||
OverlayAction::CopyText(_) => ActionOutcome::Handled,
|
||||
OverlayAction::Quit => ActionOutcome::Quit,
|
||||
}
|
||||
});
|
||||
@@ -286,6 +291,21 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A console row key → its index in the known-hosts store. The key is the pinned
|
||||
/// fingerprint when there is one, else `addr:port` (see the row builder), and a pinned
|
||||
/// CARD's key carries the profile id past a NUL — the console strips that before it
|
||||
/// sends a command, so nothing here has to.
|
||||
fn index_for_key(known: &trust::KnownHosts, key: &str) -> Option<usize> {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
})
|
||||
}
|
||||
|
||||
fn host_display_name(name: &str, addr: &str) -> String {
|
||||
if name.trim().is_empty() {
|
||||
addr.to_string()
|
||||
@@ -483,6 +503,48 @@ impl ServiceState {
|
||||
}
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now
|
||||
}
|
||||
ConsoleCmd::UpdateHost {
|
||||
key,
|
||||
name,
|
||||
addr,
|
||||
port,
|
||||
} => {
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let Some(h) = index_for_key(&known, &key).and_then(|i| known.hosts.get_mut(i))
|
||||
else {
|
||||
tracing::warn!(%key, "edit for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
// Edited IN PLACE rather than removed and re-added: the fingerprint, the
|
||||
// learned MAC, the pinned cards and the profile binding all hang off this
|
||||
// entry, and re-adding would silently unpair a host the user only renamed.
|
||||
h.name = if name.trim().is_empty() {
|
||||
addr.clone()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
h.addr = addr;
|
||||
h.port = port;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60); // the address moved
|
||||
}
|
||||
ConsoleCmd::ForgetHost { key } => {
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let Some(i) = index_for_key(&known, &key) else {
|
||||
tracing::warn!(%key, "forget for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
let gone = known.hosts.remove(i);
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
tracing::info!(name = %gone.name, addr = %gone.addr, "host forgotten");
|
||||
// It may still be advertising, in which case it comes straight back as a
|
||||
// DISCOVERED row — unsaved and unpaired, which is the honest state.
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
ConsoleCmd::Wake { key, then_connect } => {
|
||||
if let Some(c) = self.wake_cancel.take() {
|
||||
c.store(true, Ordering::SeqCst);
|
||||
@@ -534,14 +596,7 @@ impl ServiceState {
|
||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||
// a repeated press inside one refresh window can't double-pin.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
});
|
||||
let idx = index_for_key(&known, &key);
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||
return;
|
||||
|
||||
+196
-8
@@ -346,9 +346,13 @@ mod session_main {
|
||||
bitrate_kbps: settings.bitrate_kbps,
|
||||
audio_channels: settings.audio_channels,
|
||||
preferred_codec: settings.preferred_codec(),
|
||||
// Nothing excluded on a fresh dial. Only the run loop's codec-fallback retry
|
||||
// sets this, and it does so on a CLONE of these params — a Settings-level
|
||||
// "never use HEVC" would be `preferred_codec`, not this.
|
||||
exclude_codecs: 0,
|
||||
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
||||
// MULTI_SLICE is decoder truth for THIS embedder: every desktop decode stack
|
||||
// (FFmpeg software, VAAPI, D3D11VA, Vulkan Video) handles AUs carrying several
|
||||
// (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying several
|
||||
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
|
||||
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||
@@ -357,12 +361,14 @@ mod session_main {
|
||||
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
|
||||
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
|
||||
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
|
||||
// formats (hardware RExt decode where the driver offers it — NVIDIA today) and
|
||||
// swscale converts anything else for the software rung, with the decoder ladder
|
||||
// demoting on its own. No capability probe gates the bit — software decode is the
|
||||
// guaranteed floor — but the cost is VISIBLE, not silent: the Detailed stats
|
||||
// overlay prints the resolved chroma ("4:4:4→4:2:0" when the host declined) and
|
||||
// the decode path frames actually took.
|
||||
// formats (hardware RExt decode where the driver offers it — NVIDIA today),
|
||||
// with the decoder ladder demoting on its own. No capability probe gates the
|
||||
// bit — but note (M8) that the software rung below it is 4:2:0 8-bit ONLY and
|
||||
// refuses anything else rather than mis-scaling it, so on a box whose hardware
|
||||
// 4:4:4 decode fails the floor is a codec fallback, not a converted picture.
|
||||
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
|
||||
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
|
||||
// frames actually took.
|
||||
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
| if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
@@ -488,7 +494,8 @@ mod session_main {
|
||||
///
|
||||
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
|
||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=native-vaapi` still overrides the decoder
|
||||
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
@@ -503,6 +510,65 @@ mod session_main {
|
||||
);
|
||||
}
|
||||
|
||||
/// The driver's own answers about video images, printed with nothing in front of
|
||||
/// them (`--probe-decode`).
|
||||
///
|
||||
/// Passing the five conjuncts above only says Vulkan Video EXISTS on a device; this
|
||||
/// says whether the zero-copy pipeline can be BUILT on it — a different question
|
||||
/// with, on at least one shipping driver, a different answer. Verbatim on purpose:
|
||||
/// the Intel Arc refusal was twice diagnosed from punktfunk's own error text and
|
||||
/// twice the diagnosis was wrong, and what broke it open both times was reading what
|
||||
/// the driver actually said.
|
||||
fn print_video_formats(a: &pf_presenter::vk::AdapterDecode) {
|
||||
use pf_presenter::vk::probe::{describe_create_flags, describe_usage};
|
||||
for p in &a.formats {
|
||||
println!(" {} (wants {:?}):", p.profile, p.wanted);
|
||||
for u in &p.usages {
|
||||
let answer = match &u.formats {
|
||||
Err(e) => format!("query failed: {e:?}"),
|
||||
Ok(entries) if entries.is_empty() => "no formats offered".to_string(),
|
||||
Ok(entries) => entries
|
||||
.iter()
|
||||
.map(|f| {
|
||||
format!(
|
||||
"{:?} usage={} create={} {:?} {:?}",
|
||||
f.format,
|
||||
describe_usage(f.image_usage),
|
||||
describe_create_flags(f.image_create_flags),
|
||||
f.image_type,
|
||||
f.image_tiling,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
};
|
||||
println!(" {:<24} {answer}", u.label);
|
||||
// The second opinion, printed only where it differs from the video
|
||||
// format query. Worded as "also asked" rather than "disagrees" on
|
||||
// purpose: measured on both vendors this call answers "creatable" for
|
||||
// combinations the video query rejects (NVIDIA included, for SAMPLED
|
||||
// alone), so it does not honour the profile list and a difference here
|
||||
// is NOT the driver contradicting itself. Printed anyway because the
|
||||
// question gets re-asked by everyone who reads a refusal.
|
||||
let listed = u
|
||||
.wanted_entry(p.wanted)
|
||||
.is_some_and(|f| f.image_usage.contains(u.usage));
|
||||
if listed != u.image_format_support.is_ok() {
|
||||
let second = match &u.image_format_support {
|
||||
Ok(()) => "creatable".to_string(),
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
println!(
|
||||
" {:<24} (also asked: \
|
||||
vkGetPhysicalDeviceImageFormatProperties2 says {second} — that \
|
||||
call does not honour the profile list; not authority)",
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
@@ -530,6 +596,128 @@ mod session_main {
|
||||
};
|
||||
}
|
||||
|
||||
// `--probe-decode`: per-adapter Vulkan Video decode capability, then exit. Human
|
||||
// output on purpose — this is a triage tool, not a picker source, which is also
|
||||
// why it is a separate flag: `--list-adapters` is parsed line-by-line by the
|
||||
// desktop shells' GPU picker and must keep printing bare names.
|
||||
if arg_flag("--probe-decode") {
|
||||
return match pf_presenter::vk::probe_decode() {
|
||||
Ok(adapters) => {
|
||||
if adapters.is_empty() {
|
||||
println!("no Vulkan physical devices");
|
||||
}
|
||||
for (i, a) in adapters.iter().enumerate() {
|
||||
// The bracketed number is the PUNKTFUNK_VK_DEVICE value, and the
|
||||
// FIRST listed entry is what
|
||||
// a default run presents on — the decoder shares that device, so
|
||||
// on a hybrid box this line is usually the answer.
|
||||
let kind = if a.discrete { "discrete" } else { "integrated" };
|
||||
// `a.index`, NOT the loop position. This list is sorted
|
||||
// discrete-first for reading, but PUNKTFUNK_VK_DEVICE indexes the
|
||||
// raw enumeration, which puts the iGPU first on some hybrids —
|
||||
// printing the loop position would name the other GPU on exactly
|
||||
// the machines this flag is for. The `i == 0` marker is still the
|
||||
// loop position, because sorted-first IS what pick_device lands on
|
||||
// when nothing overrides it.
|
||||
println!(
|
||||
"[{}] {} ({kind}){}",
|
||||
a.index,
|
||||
a.name,
|
||||
if i == 0 { " <- default presenter" } else { "" }
|
||||
);
|
||||
println!(
|
||||
" vulkan video decode: {}",
|
||||
if a.usable { "YES" } else { "no" }
|
||||
);
|
||||
// Name every bit, and ACCOUNT for the ones we cannot name. The
|
||||
// 5070 Ti reports 0xF — four bits — while punktfunk decodes three
|
||||
// codecs, so the first version of this line printed three names
|
||||
// beside a four-bit mask and looked complete. VP9 (bit 3) is a
|
||||
// real decode operation this client has no rung for; a codec the
|
||||
// tool cannot name must not silently vanish from a mask it prints,
|
||||
// or the reader is left to trust that the words cover the number.
|
||||
const OPS: [(u32, &str); 4] = [
|
||||
(0x1, "H.264"),
|
||||
(0x2, "H.265"),
|
||||
(0x4, "AV1"),
|
||||
(0x8, "VP9 (no punktfunk rung)"),
|
||||
];
|
||||
let mut codecs: Vec<String> = OPS
|
||||
.iter()
|
||||
.filter(|(bit, _)| a.codec_ops & bit != 0)
|
||||
.map(|(_, n)| (*n).to_string())
|
||||
.collect();
|
||||
let named: u32 = OPS.iter().map(|(b, _)| b).sum();
|
||||
let unknown = a.codec_ops & !named;
|
||||
if unknown != 0 {
|
||||
codecs.push(format!("unrecognised bits 0x{unknown:X}"));
|
||||
}
|
||||
println!(
|
||||
" driver decode ops: {}",
|
||||
if codecs.is_empty() {
|
||||
format!("none (0x{:X})", a.codec_ops)
|
||||
} else {
|
||||
format!("{} (0x{:X})", codecs.join(", "), a.codec_ops)
|
||||
}
|
||||
);
|
||||
if !a.usable {
|
||||
// Say which conjunct failed. "no" with no reason is the thing
|
||||
// this whole flag exists to stop.
|
||||
let mut why: Vec<String> = Vec::new();
|
||||
if !a.api_1_3 {
|
||||
why.push("device is not Vulkan 1.3".into());
|
||||
}
|
||||
if !a.features_ok {
|
||||
why.push(
|
||||
"missing samplerYcbcrConversion / timelineSemaphore / \
|
||||
synchronization2"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if a.decode_family.is_none() {
|
||||
why.push("no queue family advertises VIDEO_DECODE".into());
|
||||
}
|
||||
if !a.base_missing.is_empty() {
|
||||
why.push(format!("missing {}", a.base_missing.join(", ")));
|
||||
}
|
||||
if a.codec_exts.is_empty() {
|
||||
why.push("no VK_KHR_video_decode_{h264,h265,av1} extension".into());
|
||||
}
|
||||
println!(" why not: {}", why.join("; "));
|
||||
} else {
|
||||
println!(" extensions: {}", a.codec_exts.join(", "));
|
||||
}
|
||||
print_video_formats(a);
|
||||
}
|
||||
if adapters.len() > 1 {
|
||||
// The single most common misreading of this output: seeing a
|
||||
// capable GPU listed and concluding the decoder will use it.
|
||||
// Vulkan Video decodes on the PRESENTER's device, and the decoder
|
||||
// preference does not move the presenter.
|
||||
println!();
|
||||
println!(
|
||||
"Vulkan Video decodes on the presenter's device. PUNKTFUNK_DECODER \
|
||||
picks the rung,"
|
||||
);
|
||||
println!(
|
||||
"not the GPU — move the presenter with PUNKTFUNK_VK_DEVICE=<index \
|
||||
above> or"
|
||||
);
|
||||
println!(
|
||||
"PUNKTFUNK_VK_ADAPTER=<name substring>, which is the safer knob \
|
||||
where two"
|
||||
);
|
||||
println!("adapters share a name.");
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("probe-decode: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
|
||||
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
|
||||
// same enumeration the GTK shell probes.
|
||||
|
||||
@@ -78,11 +78,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
"winuser",
|
||||
] }
|
||||
|
||||
# FFmpeg — used only to enumerate which codecs this client can decode (probe::decodable_codecs),
|
||||
# advertised to the host on the speed-test connect. Same pin as the host/Linux client. (Real
|
||||
# decode + present live in the spawned punktfunk-session binary.)
|
||||
ffmpeg-next = "8"
|
||||
|
||||
# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service
|
||||
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
|
||||
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The native **Windows** app for streaming a punktfunk host to your PC. A modern WinUI 3 app that
|
||||
discovers hosts on your network, pairs with a PIN, and streams at your display's own resolution and
|
||||
refresh rate — with a hardware-accelerated D3D11 video path and HDR.
|
||||
refresh rate — with hardware-accelerated video decode and HDR.
|
||||
|
||||
It's **pure Rust**: the UI is WinUI 3 driven through [windows-reactor](https://github.com/microsoft/windows-rs)
|
||||
(a declarative, React-like framework), and it links the shared **`punktfunk-core`** directly to speak
|
||||
@@ -10,9 +10,11 @@ the fast **`punktfunk/1`** protocol.
|
||||
|
||||
## Features
|
||||
|
||||
- **Hardware decode, GPU present** — FFmpeg HEVC with a **D3D11VA zero-copy path** (decoder and
|
||||
presenter share one D3D11 device; NV12/P010 textures sampled straight into a `SwapChainPanel`
|
||||
composition swapchain), with a robust software-decode fallback.
|
||||
- **Hardware decode, GPU present** — Punktfunk's own decoders, no FFmpeg anywhere in the client:
|
||||
**Vulkan Video** (`pf-vkdecode`) leads on NVIDIA and AMD, **D3D11VA** (`pf-dxvadec` driving
|
||||
`ID3D11VideoDecoder`) leads on Intel, whichever isn't first is the fallback, and an
|
||||
OpenH264/rav1d CPU rung is last. Either hardware rung hands its surface to the Vulkan presenter
|
||||
without a CPU copy.
|
||||
- **HDR10** — advertise 10-bit/HDR, detect PQ in-band, and flip the swapchain to `R10G10B10A2` +
|
||||
ST.2084 with HDR10 metadata.
|
||||
- **Your display's native mode** — the host builds a virtual display at exactly your WxH@Hz.
|
||||
@@ -42,9 +44,9 @@ A stock [Moonlight](https://moonlight-stream.org/) client also works over GameSt
|
||||
## Build from source
|
||||
|
||||
Windows-only (the crate builds as a stub on other platforms so the workspace stays green). You need
|
||||
the MSVC toolchain, an `FFMPEG_DIR` FFmpeg tree, and CMake (SDL3 builds from source). The Windows
|
||||
App SDK runtime bootstrap is staged next to the exe by `windows-reactor-setup` from this crate's
|
||||
own `build.rs` — no extra environment needed.
|
||||
the MSVC toolchain and CMake (SDL3 builds from source) — nothing else: decode is native since M10,
|
||||
so there is no `FFMPEG_DIR` to point anywhere, and the Windows App SDK runtime bootstrap is staged
|
||||
next to the exe by `windows-reactor-setup` from this crate's own `build.rs`.
|
||||
|
||||
```sh
|
||||
cargo build -p punktfunk-client-windows --target x86_64-pc-windows-msvc
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,9 @@ touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; the matrix points `FFMPEG_DIR`
|
||||
at the runner's ARM64 FFmpeg tree, `C:\Users\Public\ffmpeg-arm64`). Artifacts are arch-suffixed
|
||||
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
|
||||
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
|
||||
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
|
||||
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale.
|
||||
@@ -25,10 +26,17 @@ stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
| `punktfunk-session.exe` | the release build — the Vulkan session client the shell spawns for every stream (sibling resolution, `src/spawn.rs`). Skia links statically; `vulkan-1.dll` is a GPU-driver component, never bundled. ARM64 builds it `--no-default-features` (no Skia console UI) until rust-skia ships aarch64-pc-windows-msvc prebuilts |
|
||||
| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | staged by the client's `build.rs` via `windows-reactor-setup::as_framework_dependent()` |
|
||||
| `SDL3.dll` | auto-staged by the `sdl3` crate |
|
||||
| `avcodec/avformat/avutil/swscale/swresample/...-*.dll` | `FFMPEG_DIR\bin` |
|
||||
| `licenses\*` | the project's MIT/Apache texts + the generated `THIRD-PARTY-NOTICES.txt` (MSIX has no installer EULA page, so attribution ships as files) |
|
||||
| `Assets\*.png` | checked-in tile/store logos (rasterized from `packaging/flatpak/io.unom.Punktfunk.svg`) |
|
||||
| `AppxManifest.xml` | the template here, with `{VERSION}`/`{PUBLISHER}` substituted |
|
||||
|
||||
**No FFmpeg DLLs.** The client decodes natively since M10 (`pf-vkdecode` / `pf-dxvadec` /
|
||||
OpenH264+rav1d — punktfunk-planning `design/client-native-decode.md` §6), so nothing here
|
||||
link-imports `libav*` and the wildcard `avcodec/avformat/avutil/swscale/swresample-*.dll` copy is
|
||||
gone, along with the FFmpeg LGPL notice that accompanied it — shipping that notice now would claim
|
||||
a dependency the package doesn't have. The **host** installer is unchanged:
|
||||
`packaging/windows/pack-host-installer.ps1` still ships those DLLs for its AMF/QSV encode path.
|
||||
|
||||
### Why an "unpackaged" WinUI app packages cleanly
|
||||
|
||||
`main` calls `windows_reactor::bootstrap()`, which runs `MddBootstrapInitialize2` with
|
||||
@@ -87,8 +95,7 @@ cargo build --release -p punktfunk-client-windows --target x86_64-pc-windows-msv
|
||||
pwsh -File clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version 0.2.0.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
|
||||
|
||||
# arm64 (cross-compiled; point FFMPEG_DIR at the ARM64 tree)
|
||||
$env:FFMPEG_DIR = 'C:\Users\Public\ffmpeg-arm64'
|
||||
# arm64 (cross-compiled; no extra environment — the client links no FFmpeg)
|
||||
cargo build --release -p punktfunk-client-windows --target aarch64-pc-windows-msvc
|
||||
pwsh -File clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version 0.2.0.0 -Arch arm64 -TargetDir C:\t\aarch64-pc-windows-msvc\release -OutDir C:\t\msix
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Builds a packaging layout from a release `cargo build` output (exe + the reactor/SDL3 auto-staged
|
||||
DLLs + resources.pri + FFmpeg DLLs + the checked-in Assets + the manifest), runs makeappx, and
|
||||
DLLs + resources.pri + the checked-in Assets + the manifest), runs makeappx, and
|
||||
signs with signtool. Idempotent; safe to re-run.
|
||||
|
||||
NO FFmpeg DLLs since M10 (design/client-native-decode.md §6): the client decodes natively
|
||||
(pf-vkdecode / pf-dxvadec / openh264+rav1d) and link-imports no libav* at all, so the
|
||||
wildcard copy and its LGPL notice are gone with it. The HOST installer is unchanged —
|
||||
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
|
||||
|
||||
Signing cert precedence:
|
||||
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
|
||||
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
|
||||
@@ -22,8 +27,7 @@
|
||||
.EXAMPLE
|
||||
# x64 (default arch):
|
||||
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
|
||||
# arm64 (point -TargetDir + FFMPEG_DIR at the ARM64 build/tree):
|
||||
$env:FFMPEG_DIR='C:\Users\Public\ffmpeg-arm64'
|
||||
# arm64 (point -TargetDir at the ARM64 build):
|
||||
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -Arch arm64 -TargetDir C:\t-a64\aarch64-pc-windows-msvc\release -OutDir C:\t-a64\msix
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
@@ -31,7 +35,6 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, e.g. 0.2.137.0
|
||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
|
||||
[string]$FfmpegBin = $(if ($env:FFMPEG_DIR) { Join-Path $env:FFMPEG_DIR 'bin' } else { 'C:\Users\Public\ffmpeg\bin' }),
|
||||
[string]$OutDir = (Join-Path $TargetDir 'msix'),
|
||||
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
|
||||
@@ -83,28 +86,32 @@ foreach ($f in $required) {
|
||||
Copy-Item $src (Join-Path $layout $f) -Force
|
||||
}
|
||||
|
||||
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct).
|
||||
# These are unmodified BtbN *lgpl-shared* builds, linked dynamically (replaceable DLLs) — FFmpeg is
|
||||
# used under the LGPL v2.1+; the license text + notice ship in licenses\ below.
|
||||
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
|
||||
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
|
||||
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
|
||||
|
||||
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): FFmpeg's LGPL
|
||||
# notice + license text, the project's own MIT/Apache texts, and the generated third-party notices.
|
||||
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): the
|
||||
# project's own MIT/Apache texts plus the generated third-party notices, which is where every
|
||||
# vendored/statically-linked dependency's attribution lives (openh264 BSD-2, rav1d BSD-2, …).
|
||||
#
|
||||
# The FFmpeg LGPL notice + license texts that used to be copied here went with the DLLs at M10:
|
||||
# nothing in this package links libav* any more, so shipping an LGPL notice would be claiming a
|
||||
# dependency that is not there.
|
||||
#
|
||||
# For the same reason the notices come from clients/windows/ and NOT from the repo root: the root
|
||||
# file is workspace-wide, it is what the HOST ships out of, and it still lists ffmpeg-next plus the
|
||||
# full FFmpeg licence text. The client-scoped file (same generator, `--packages
|
||||
# punktfunk-client-windows,punktfunk-client-session,punktfunk-cli`) is the one that describes what
|
||||
# is actually inside this .msix — and it is the same file the app's Licenses page shows.
|
||||
$licDir = Join-Path $layout 'licenses'
|
||||
New-Item -ItemType Directory -Force -Path $licDir | Out-Null
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||
Copy-Item (Join-Path $repoRoot 'packaging\windows\licenses\FFmpeg-LGPL-NOTICE.txt') $licDir -Force -ErrorAction SilentlyContinue
|
||||
foreach ($n in @('THIRD-PARTY-NOTICES.txt', 'LICENSE-MIT', 'LICENSE-APACHE')) {
|
||||
$clientRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
foreach ($n in @('LICENSE-MIT', 'LICENSE-APACHE')) {
|
||||
$p = Join-Path $repoRoot $n
|
||||
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||
}
|
||||
$ffRoot = Split-Path $FfmpegBin -Parent
|
||||
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
|
||||
$p = Join-Path $ffRoot $lic
|
||||
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||
$notices = Join-Path $clientRoot 'THIRD-PARTY-NOTICES.txt'
|
||||
if (-not (Test-Path $notices)) {
|
||||
throw "missing $notices — run scripts/gen-third-party-notices.sh (it generates the per-client copies)"
|
||||
}
|
||||
Copy-Item $notices $licDir -Force
|
||||
|
||||
# tile/store assets
|
||||
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
||||
|
||||
@@ -12,9 +12,15 @@ const APP_LICENSE: &str = concat!(
|
||||
"\n\n================================ Apache-2.0 ================================\n\n",
|
||||
include_str!("../../../../LICENSE-APACHE"),
|
||||
);
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
|
||||
/// Third-party software notices for the Rust crates THIS CLIENT links — the shell, the
|
||||
/// session streamer and the headless CLI (generated by scripts/gen-third-party-notices.sh;
|
||||
/// the MSIX ships the same file under licenses/).
|
||||
///
|
||||
/// Deliberately the client-scoped file and not the workspace-wide one at the repo root:
|
||||
/// that root file is the HOST's, it still carries `ffmpeg-next` and the full FFmpeg licence
|
||||
/// text — and after M10 this app bundles no FFmpeg at all, so printing that attribution
|
||||
/// three lines under a card saying so would be a false statement to the user's face.
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
|
||||
@@ -46,9 +52,10 @@ pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
|
||||
vstack((
|
||||
text_block("Bundled components").font_size(15.0).semibold(),
|
||||
text_block(
|
||||
"FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
|
||||
license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
|
||||
Windows App SDK (Microsoft) are also linked.",
|
||||
"SDL 3 (Zlib) and the Windows App SDK (Microsoft) are linked; their notices ship \
|
||||
in the installed licenses\\ folder. Video decoding uses Windows' own DXVA and \
|
||||
Vulkan Video, with OpenH264 and rav1d (both BSD-2-Clause) as the CPU fallback — \
|
||||
no FFmpeg is bundled.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.wrap()
|
||||
|
||||
@@ -45,12 +45,14 @@ fn render_scale_label(scale: f64) -> String {
|
||||
}
|
||||
}
|
||||
/// 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.
|
||||
// A stored legacy value that matches no preset (the D3D11VA-era "hardware", and since M10
|
||||
// the bare "vulkan"/"d3d11va" that named libavcodec's rungs) shows as Automatic — which is
|
||||
// how the session's ladder reads "hardware", and near enough for the other two, which
|
||||
// `pf_client_core::video::migrate_decoder_pref` maps onto the entries below anyway.
|
||||
const DECODERS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||
("vulkan", "Hardware (Vulkan Video)"),
|
||||
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
||||
("native-vulkan", "Hardware (Vulkan Video)"),
|
||||
("native-d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
||||
("software", "Software (CPU)"),
|
||||
];
|
||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||
@@ -862,7 +864,11 @@ pub(crate) fn settings_page(
|
||||
);
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||
// Migrated for the LOOKUP only (the store is left alone): a pre-M10 settings file
|
||||
// holds `vulkan`/`d3d11va`, which match no preset — the combo would show Automatic and
|
||||
// a save would silently rewrite the user's hardware preference to `auto`.
|
||||
let stored_decoder = pf_client_core::video::migrate_decoder_pref(&s.decoder);
|
||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == stored_decoder);
|
||||
let decoder_combo = setting_combo(ctx, scope, (rev, set_rev), dec_names, dec_i, |s, i| {
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
});
|
||||
|
||||
@@ -81,8 +81,13 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(|c| {
|
||||
// The `stats:` decode-path tags (see pf-client-core's session
|
||||
// pump). M10 removed the `vulkan`/`vaapi`/`d3d11va` tags with their
|
||||
// rungs; a hardware rung is now always a `native-*` one.
|
||||
let kind = match c {
|
||||
"vulkan" | "vaapi" => Pill::Good,
|
||||
"native-vulkan" | "native-vaapi" | "native-d3d11va" | "pyrowave" => {
|
||||
Pill::Good
|
||||
}
|
||||
"software" => Pill::Info,
|
||||
_ => Pill::Neutral,
|
||||
};
|
||||
|
||||
@@ -6,26 +6,27 @@
|
||||
//! over the real data plane, so it stays here. [`decodable_codecs`] rode along for the same
|
||||
//! reason — the probe connect still advertises which codecs this client can decode.
|
||||
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||
/// The `quic` codec bitfield this client can decode. Advertised to the host so it never emits
|
||||
/// a codec we can't decode.
|
||||
///
|
||||
/// It is pf-client-core's [`decodable_codecs`](pf_client_core::video::decodable_codecs) —
|
||||
/// the codecs the SESSION BINARY's rungs speak, which is the process that actually decodes.
|
||||
/// This shell used to walk libavcodec's registry (`ffmpeg::decoder::find` per id) for the
|
||||
/// same answer; that was wrong in two ways even before M10 deleted the dependency. It
|
||||
/// described the decoders in THIS process, which decodes nothing, and it answered "a
|
||||
/// decoder exists" where the question is "a rung can keep up" — the AV1-on-CPU promise
|
||||
/// `decodable_codecs_for` exists to refuse.
|
||||
///
|
||||
/// ⚠ Deliberately the DEVICE-FREE answer ([`decodable_codecs`], not
|
||||
/// `decodable_codecs_for`): this connect creates no presenter and has no `VulkanDecodeDevice`
|
||||
/// to gate AV1 on, and it decodes nothing — the codec it advertises is never exercised. A
|
||||
/// real session's Hello is built in the session binary, with the device in hand.
|
||||
pub fn decodable_codecs() -> u8 {
|
||||
let _ = ffmpeg::init();
|
||||
let mut bits = 0u8;
|
||||
for (id, bit) in [
|
||||
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
|
||||
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
|
||||
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
|
||||
] {
|
||||
if ffmpeg::decoder::find(id).is_some() {
|
||||
bits |= bit;
|
||||
}
|
||||
}
|
||||
bits
|
||||
pf_client_core::video::decodable_codecs()
|
||||
}
|
||||
|
||||
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
{
|
||||
"type": "library",
|
||||
"name": "FFmpeg",
|
||||
"version": "7.x/8.x (system-provided on Linux; replaceable DLLs bundled with the Windows packages)",
|
||||
"description": "Dynamically linked libav* decode/encode; LGPL notice at packaging/windows/licenses/FFmpeg-LGPL-NOTICE.txt",
|
||||
"version": "7.x/8.x (HOST only \u2014 system-provided on Linux; replaceable DLLs bundled with the Windows host installer)",
|
||||
"description": "Dynamically linked libav* ENCODE for the host (pf-encode: NVENC-libav, VAAPI, AMF/QSV); LGPL notice at packaging/windows/licenses/FFmpeg-LGPL-NOTICE.txt. No punktfunk CLIENT links FFmpeg since M10 \u2014 client decode is Vulkan Video / DXVA / VAAPI / VideoToolbox / MediaCodec with openh264 + rav1d as the CPU floor.",
|
||||
"licenses": [{ "license": { "id": "LGPL-2.1-or-later" } }],
|
||||
"externalReferences": [{ "type": "website", "url": "https://ffmpeg.org" }]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "pf-bitstream"
|
||||
description = "Client-side bitstream layer for native decode: AU parsing, POC/DPB/reference derivation and per-AU DecodePlans (H.264/HEVC/AV1) on the vendored cros-codecs parsers — the layer libavcodec used to be (design/client-native-decode.md §3.1)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cros-codecs = { path = "vendor/cros-codecs" }
|
||||
tracing = "0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
//! The client's bitstream layer for native decode (design/client-native-decode.md §3.1):
|
||||
//! everything a stateless hardware decoder needs to know about an AU before submission —
|
||||
//! parsed headers, POC, DPB state, reference lists (including MMCO/LTR, which the hosts'
|
||||
//! RFI recovery actively uses), recovery-point SEI — derived once here and consumed by
|
||||
//! every backend (Vulkan `StdVideo*`, DXVA picparams, libva buffers).
|
||||
//!
|
||||
//! Parsing primitives come from the vendored cros-codecs parser layer
|
||||
//! (`vendor/cros-codecs`, see its PROVENANCE.md); this crate owns what upstream keeps in
|
||||
//! its Linux-only `decoder::stateless` half — the per-AU orchestration — plus the pieces
|
||||
//! upstream lacks (SEI payload parsing: their parsers classify SEI NALUs but never read
|
||||
//! them).
|
||||
//!
|
||||
//! Scope discipline: punktfunk clients decode punktfunk hosts — zero-reorder, no
|
||||
//! B-frames, progressive, parameter sets from encoders we control. Implement to spec
|
||||
//! where cheap; reject-with-log outside that envelope rather than half-decode.
|
||||
//!
|
||||
//! Nothing in this crate may touch a GPU API, an OS handle, or the network: CPU-only by
|
||||
//! construction, so its tests run on every CI leg including macOS. And no `unsafe`,
|
||||
//! compiler-enforced — this layer exists to replace C parsers; it does not get to
|
||||
//! reintroduce their failure mode.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod av1;
|
||||
pub mod h264;
|
||||
pub mod h265;
|
||||
pub mod sei;
|
||||
|
||||
// The vendor-pinning smoke tests below assert against byte counts and golden values from
|
||||
// the vendored snapshot's own test vectors; a cros-codecs re-sync that shifts parser
|
||||
// behavior must trip HERE, in our tree, not in a decode session.
|
||||
#[cfg(test)]
|
||||
mod vendor_smoke {
|
||||
use std::io::Cursor;
|
||||
|
||||
use cros_codecs::bitstream_utils::IvfIterator;
|
||||
use cros_codecs::codec::av1::parser::ObuAction;
|
||||
use cros_codecs::codec::av1::parser::ParsedObu;
|
||||
use cros_codecs::codec::h264::parser::Nalu as H264Nalu;
|
||||
use cros_codecs::codec::h264::parser::Parser as H264Parser;
|
||||
use cros_codecs::codec::h265::parser::Nalu as H265Nalu;
|
||||
use cros_codecs::codec::h265::parser::Parser as H265Parser;
|
||||
|
||||
const H264_25FPS: &[u8] =
|
||||
include_bytes!("../vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264");
|
||||
const H265_25FPS: &[u8] =
|
||||
include_bytes!("../vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265");
|
||||
const AV1_25FPS: &[u8] =
|
||||
include_bytes!("../vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1");
|
||||
const VP9_25FPS: &[u8] =
|
||||
include_bytes!("../vendor/cros-codecs/src/codec/vp9/test_data/test-25fps.vp9");
|
||||
|
||||
#[test]
|
||||
fn h264_parses_the_vendored_vector_to_its_goldens() {
|
||||
let mut cursor = Cursor::new(H264_25FPS);
|
||||
let mut parser = H264Parser::default();
|
||||
let (mut nalus, mut sps, mut slices) = (0u32, 0u32, 0u32);
|
||||
let mut coded = (0u32, 0u32);
|
||||
while let Ok(nalu) = H264Nalu::next(&mut cursor) {
|
||||
nalus += 1;
|
||||
if let Ok(s) = parser.parse_sps(&nalu) {
|
||||
sps += 1;
|
||||
coded = (
|
||||
(s.pic_width_in_mbs_minus1 as u32 + 1) * 16,
|
||||
(s.pic_height_in_map_units_minus1 as u32 + 1) * 16,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if parser.parse_pps(&nalu).is_ok() {
|
||||
continue;
|
||||
}
|
||||
if parser.parse_slice_header(nalu).is_ok() {
|
||||
slices += 1;
|
||||
}
|
||||
}
|
||||
// 759 is upstream's own golden for this stream (chromium h264_parser_unittest lineage).
|
||||
assert_eq!(nalus, 759);
|
||||
assert_eq!(sps, 4);
|
||||
assert_eq!(slices, 500);
|
||||
assert_eq!(coded, (320, 240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h265_parses_the_vendored_vector() {
|
||||
let mut cursor = Cursor::new(H265_25FPS);
|
||||
let mut parser = H265Parser::default();
|
||||
let (mut nalus, mut sps, mut slices) = (0u32, 0u32, 0u32);
|
||||
while let Ok(nalu) = H265Nalu::next(&mut cursor) {
|
||||
nalus += 1;
|
||||
if parser.parse_sps(&nalu).is_ok() {
|
||||
sps += 1;
|
||||
continue;
|
||||
}
|
||||
if parser.parse_pps(&nalu).is_ok() {
|
||||
continue;
|
||||
}
|
||||
if parser.parse_slice_header(nalu).is_ok() {
|
||||
slices += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(nalus, 254);
|
||||
assert_eq!(sps, 1);
|
||||
assert_eq!(slices, 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_walks_obus_and_maintains_ref_slots_across_the_stream() {
|
||||
let mut parser = cros_codecs::codec::av1::parser::Parser::default();
|
||||
let (mut obus, mut frames) = (0u32, 0u32);
|
||||
for packet in IvfIterator::new(AV1_25FPS) {
|
||||
let mut consumed = 0;
|
||||
while let Ok(action) = parser.read_obu(&packet[consumed..]) {
|
||||
let obu = match action {
|
||||
ObuAction::Process(obu) => obu,
|
||||
ObuAction::Drop(n) => {
|
||||
consumed += n as usize;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
consumed += obu.bytes_used;
|
||||
obus += 1;
|
||||
// `ref_frame_update` is the parser's ref-slot bookkeeping; without it,
|
||||
// inter frames fail with "Reference is invalid" — the parser validates
|
||||
// reference integrity rather than trusting the stream.
|
||||
match parser.parse_obu(obu).expect("parse_obu") {
|
||||
ParsedObu::FrameHeader(fh) => {
|
||||
frames += 1;
|
||||
parser.ref_frame_update(&fh).expect("ref slot update");
|
||||
}
|
||||
ParsedObu::Frame(f) => {
|
||||
frames += 1;
|
||||
parser.ref_frame_update(&f.header).expect("ref slot update");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 525 is upstream's own golden (cross-checked against GStreamer's OBU walk).
|
||||
assert_eq!(obus, 525);
|
||||
assert_eq!(frames, 274);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vp9_splits_superframes_and_parses_headers() {
|
||||
let mut parser = cros_codecs::codec::vp9::parser::Parser::default();
|
||||
let (mut chunks, mut frames) = (0u32, 0u32);
|
||||
for packet in IvfIterator::new(VP9_25FPS) {
|
||||
chunks += 1;
|
||||
frames += parser
|
||||
.parse_chunk(packet.as_ref())
|
||||
.expect("vp9 chunk")
|
||||
.len() as u32;
|
||||
}
|
||||
assert_eq!(chunks, 250);
|
||||
// > chunks proves superframe splitting engaged.
|
||||
assert_eq!(frames, 269);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! SEI payload parsing — the piece the vendored parser layer lacks: upstream classifies
|
||||
//! SEI NALUs but never reads a payload. punktfunk needs exactly one payload type per
|
||||
//! codec: the recovery point SEI, which hosts emit on RFI recovery so the client knows
|
||||
//! where a decode-from-here point lands. Every other payload type is skipped by its
|
||||
//! declared size.
|
||||
//!
|
||||
//! Both codecs put the recovery point at payload type 6 with the same D.1 message
|
||||
//! framing, but the payload syntax differs: H.264 (D.1.8/D.2.8) counts recovery in
|
||||
//! `frame_num` increments (`recovery_frame_cnt`, ue(v)) and carries a slice-group bit
|
||||
//! pair; H.265 (D.2.8/D.3.8) counts in picture order (`recovery_poc_cnt`, se(v) — it
|
||||
//! can be negative) and has no slice-group field. Hence two parsers over one shared
|
||||
//! message walk.
|
||||
|
||||
/// Recovery point SEI (D.2.8).
|
||||
///
|
||||
/// `recovery_frame_cnt` counts in `frame_num` increments from the AU carrying the SEI to
|
||||
/// the picture at which output is exact (`exact_match`) or approximate. `broken_link` set
|
||||
/// means pictures before the recovery point may be visually broken and must not be shown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RecoveryPoint {
|
||||
pub recovery_frame_cnt: u32,
|
||||
pub exact_match: bool,
|
||||
pub broken_link: bool,
|
||||
}
|
||||
|
||||
/// Recovery point SEI, H.265 flavour (D.3.8).
|
||||
///
|
||||
/// `recovery_poc_cnt` is the POC delta from the picture carrying the SEI to the
|
||||
/// recovery-point picture — se(v)-coded, so unlike H.264's `recovery_frame_cnt` it can
|
||||
/// be NEGATIVE (a recovery point among leading pictures). `exact_match`/`broken_link`
|
||||
/// keep their H.264 semantics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RecoveryPointHevc {
|
||||
pub recovery_poc_cnt: i32,
|
||||
pub exact_match: bool,
|
||||
pub broken_link: bool,
|
||||
}
|
||||
|
||||
/// Parse the first recovery point SEI message out of an H.264 SEI NALU.
|
||||
///
|
||||
/// `sei_payload` are the bytes of the NALU after its one-byte NAL header, emulation
|
||||
/// prevention bytes still in place (they are removed here — 7.4.1 RBSP extraction).
|
||||
/// `Ok(None)` means the NALU parsed cleanly but carries no recovery point.
|
||||
pub fn parse_recovery_point(sei_payload: &[u8]) -> Result<Option<RecoveryPoint>, String> {
|
||||
let rbsp = strip_emulation_prevention(sei_payload);
|
||||
let Some(payload) = first_recovery_point_payload(&rbsp)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut r = BitCursor::new(payload);
|
||||
let recovery_frame_cnt = r.read_ue()?;
|
||||
let exact_match = r.read_bit()? != 0;
|
||||
let broken_link = r.read_bit()? != 0;
|
||||
// changing_slice_group_idc u(2): parsed to keep the reader honest, unused —
|
||||
// slice groups are outside every profile punktfunk hosts emit.
|
||||
let _changing_slice_group_idc = r.read_bits(2)?;
|
||||
Ok(Some(RecoveryPoint {
|
||||
recovery_frame_cnt,
|
||||
exact_match,
|
||||
broken_link,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse the first recovery point SEI message out of an H.265 prefix SEI NALU.
|
||||
///
|
||||
/// `sei_payload` are the bytes of the NALU after its TWO-byte NAL header (H.265 NALU
|
||||
/// headers are 16 bits), emulation prevention still in place. Only prefix SEI NALUs
|
||||
/// (type 39) can carry a recovery point — D.2.1 lists it as prefix-only, so suffix SEI
|
||||
/// NALUs (type 40) need never reach here.
|
||||
pub fn parse_recovery_point_hevc(sei_payload: &[u8]) -> Result<Option<RecoveryPointHevc>, String> {
|
||||
let rbsp = strip_emulation_prevention(sei_payload);
|
||||
let Some(payload) = first_recovery_point_payload(&rbsp)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut r = BitCursor::new(payload);
|
||||
let recovery_poc_cnt = r.read_se()?;
|
||||
let exact_match = r.read_bit()? != 0;
|
||||
let broken_link = r.read_bit()? != 0;
|
||||
Ok(Some(RecoveryPointHevc {
|
||||
recovery_poc_cnt,
|
||||
exact_match,
|
||||
broken_link,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Walk the D.1 SEI message framing (shared verbatim between H.264 and H.265) and
|
||||
/// return the payload bytes of the first recovery point message (payload type 6 in
|
||||
/// both codecs), if any. `rbsp` is already emulation-prevention-stripped.
|
||||
fn first_recovery_point_payload(rbsp: &[u8]) -> Result<Option<&[u8]>, String> {
|
||||
let mut i = 0usize;
|
||||
while i < rbsp.len() && !is_rbsp_trailing(rbsp, i) {
|
||||
// D.1: payload type and size are ff-coded — 0xFF bytes each add 255 until a
|
||||
// non-0xFF byte terminates the value. The run length is unbounded, so the type
|
||||
// accumulates saturating: an adversarial ~16M-byte 0xFF run must not overflow
|
||||
// (a saturated type simply never matches 6). The size accumulator is a usize
|
||||
// whose use is bounds-checked below.
|
||||
let mut payload_type = 0u32;
|
||||
while i < rbsp.len() && rbsp[i] == 0xFF {
|
||||
payload_type = payload_type.saturating_add(255);
|
||||
i += 1;
|
||||
}
|
||||
if i >= rbsp.len() {
|
||||
return Err("truncated SEI payload type".into());
|
||||
}
|
||||
payload_type = payload_type.saturating_add(u32::from(rbsp[i]));
|
||||
i += 1;
|
||||
|
||||
let mut payload_size = 0usize;
|
||||
while i < rbsp.len() && rbsp[i] == 0xFF {
|
||||
payload_size += 255;
|
||||
i += 1;
|
||||
}
|
||||
if i >= rbsp.len() {
|
||||
return Err("truncated SEI payload size".into());
|
||||
}
|
||||
payload_size += usize::from(rbsp[i]);
|
||||
i += 1;
|
||||
|
||||
let end = i
|
||||
.checked_add(payload_size)
|
||||
.filter(|&end| end <= rbsp.len())
|
||||
.ok_or_else(|| "SEI payload overruns the NALU".to_string())?;
|
||||
|
||||
if payload_type == 6 {
|
||||
return Ok(Some(&rbsp[i..end]));
|
||||
}
|
||||
|
||||
i = end;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 7.4.1: within the RBSP, `00 00 03` encodes two zero bytes; the `03` is the emulation
|
||||
/// prevention byte and is dropped.
|
||||
fn strip_emulation_prevention(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut zeros = 0usize;
|
||||
for &byte in data {
|
||||
if zeros >= 2 && byte == 0x03 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
zeros = if byte == 0 { zeros + 1 } else { 0 };
|
||||
out.push(byte);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `more_rbsp_data()` at a byte-aligned message boundary: the remainder is trailing bits
|
||||
/// iff it is the stop bit (0x80) followed by nothing but zero bytes.
|
||||
fn is_rbsp_trailing(rbsp: &[u8], i: usize) -> bool {
|
||||
rbsp[i] == 0x80 && rbsp[i + 1..].iter().all(|&b| b == 0)
|
||||
}
|
||||
|
||||
/// Minimal MSB-first bit reader over an already-unescaped RBSP slice. The vendored
|
||||
/// `BitReader` is `pub(crate)` to the vendored crate, so this crate carries its own.
|
||||
struct BitCursor<'a> {
|
||||
data: &'a [u8],
|
||||
/// Position in bits from the start of `data`.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitCursor<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
|
||||
fn read_bit(&mut self) -> Result<u32, String> {
|
||||
let byte = *self
|
||||
.data
|
||||
.get(self.pos / 8)
|
||||
.ok_or("SEI payload out of bits")?;
|
||||
let bit = (byte >> (7 - self.pos % 8)) & 1;
|
||||
self.pos += 1;
|
||||
Ok(u32::from(bit))
|
||||
}
|
||||
|
||||
fn read_bits(&mut self, count: usize) -> Result<u32, String> {
|
||||
debug_assert!(count <= 31);
|
||||
let mut out = 0u32;
|
||||
for _ in 0..count {
|
||||
out = (out << 1) | self.read_bit()?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// ue(v), spec 9.1.
|
||||
fn read_ue(&mut self) -> Result<u32, String> {
|
||||
let mut leading_zeros = 0usize;
|
||||
while self.read_bit()? == 0 {
|
||||
leading_zeros += 1;
|
||||
if leading_zeros > 31 {
|
||||
return Err("invalid exp-Golomb code in SEI payload".into());
|
||||
}
|
||||
}
|
||||
let suffix = self.read_bits(leading_zeros)?;
|
||||
((1u32 << leading_zeros) - 1)
|
||||
.checked_add(suffix)
|
||||
.ok_or_else(|| "exp-Golomb value overflows u32".to_string())
|
||||
}
|
||||
|
||||
/// se(v), spec 9.1.1: the ue(v) code point k maps to (−1)^(k+1) · ⌈k/2⌉.
|
||||
fn read_se(&mut self) -> Result<i32, String> {
|
||||
let k = self.read_ue()?;
|
||||
let magnitude = k.div_ceil(2);
|
||||
let magnitude =
|
||||
i32::try_from(magnitude).map_err(|_| "exp-Golomb value overflows i32".to_string())?;
|
||||
Ok(if k % 2 == 1 { magnitude } else { -magnitude })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_minimal_recovery_point_message_parses_to_its_field_values() {
|
||||
// Message: type 6, size 1. Payload bits: ue(0)='1', exact=0, broken=0, csg=00,
|
||||
// then payload alignment '1' + zeros -> 0b1000_0100. NALU trailing 0x80.
|
||||
let sei = [0x06, 0x01, 0x84, 0x80];
|
||||
assert_eq!(
|
||||
parse_recovery_point(&sei).unwrap(),
|
||||
Some(RecoveryPoint {
|
||||
recovery_frame_cnt: 0,
|
||||
exact_match: false,
|
||||
broken_link: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_frame_cnt_and_both_flags_round_trip_through_the_bit_reader() {
|
||||
// ue(5)='00110', exact=1, broken=1, csg=00, alignment -> 0b0011_0110 0b0100_0000.
|
||||
let sei = [0x06, 0x02, 0x36, 0x40, 0x80];
|
||||
assert_eq!(
|
||||
parse_recovery_point(&sei).unwrap(),
|
||||
Some(RecoveryPoint {
|
||||
recovery_frame_cnt: 5,
|
||||
exact_match: true,
|
||||
broken_link: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_messages_and_ff_coded_types_are_skipped_to_reach_the_recovery_point() {
|
||||
// First message: ff-coded payload type 255 (0xFF 0x00), size 1, payload 0x55.
|
||||
// Second message: type 5 (user data), size 3. Third: the recovery point.
|
||||
let sei = [
|
||||
0xFF, 0x00, 0x01, 0x55, // type 255
|
||||
0x05, 0x03, 0xAA, 0xBB, 0xCC, // type 5
|
||||
0x06, 0x01, 0x84, // recovery point
|
||||
0x80,
|
||||
];
|
||||
assert_eq!(
|
||||
parse_recovery_point(&sei).unwrap(),
|
||||
Some(RecoveryPoint {
|
||||
recovery_frame_cnt: 0,
|
||||
exact_match: false,
|
||||
broken_link: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention_bytes_inside_the_payload_are_removed_before_reading() {
|
||||
// Unescaped payload (7 bytes): ue with a 22-zero prefix => recovery_frame_cnt
|
||||
// 2^22-1 = 4194303, exact=1, broken=0, csg=00, alignment. Its first bytes are
|
||||
// 00 00 02, which the escaper must have written as 00 00 03 02 on the wire.
|
||||
let sei = [
|
||||
0x06, 0x07, 0x00, 0x00, 0x03, 0x02, 0x00, 0x00, 0x04, 0x40, 0x80,
|
||||
];
|
||||
assert!(sei.windows(3).any(|w| w == [0x00, 0x00, 0x03]));
|
||||
assert_eq!(
|
||||
parse_recovery_point(&sei).unwrap(),
|
||||
Some(RecoveryPoint {
|
||||
recovery_frame_cnt: 4194303,
|
||||
exact_match: true,
|
||||
broken_link: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sei_nalu_without_a_recovery_point_yields_none_not_an_error() {
|
||||
let sei = [0x05, 0x01, 0x00, 0x80];
|
||||
assert_eq!(parse_recovery_point(&sei).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payload_size_overrunning_the_nalu_is_a_parse_error() {
|
||||
let sei = [0x06, 0x0A, 0x00];
|
||||
assert!(parse_recovery_point(&sei).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hevc_recovery_point_parses_its_se_coded_poc_count() {
|
||||
// recovery_poc_cnt se(0) = '1', exact = 0, broken = 0, payload alignment:
|
||||
// 0b1001_0000.
|
||||
let sei = [0x06, 0x01, 0x90, 0x80];
|
||||
assert_eq!(
|
||||
parse_recovery_point_hevc(&sei).unwrap(),
|
||||
Some(RecoveryPointHevc {
|
||||
recovery_poc_cnt: 0,
|
||||
exact_match: false,
|
||||
broken_link: false
|
||||
})
|
||||
);
|
||||
|
||||
// se(-1) = '011' (ue code point 2), exact = 1, broken = 0, alignment:
|
||||
// 0b0111_0100 — the negative range H.264's ue(v) syntax cannot express.
|
||||
let sei = [0x06, 0x01, 0x74, 0x80];
|
||||
assert_eq!(
|
||||
parse_recovery_point_hevc(&sei).unwrap(),
|
||||
Some(RecoveryPointHevc {
|
||||
recovery_poc_cnt: -1,
|
||||
exact_match: true,
|
||||
broken_link: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hevc_parser_skips_earlier_messages_and_reports_absence_as_none() {
|
||||
// User-data message first, then the recovery point (poc_cnt se(3): ue code
|
||||
// point 5 = '00110', exact = 1, broken = 1, alignment: 0b0011_0111).
|
||||
let sei = [
|
||||
0x05, 0x02, 0xAA, 0xBB, // type 5
|
||||
0x06, 0x01, 0x37, // recovery point
|
||||
0x80,
|
||||
];
|
||||
assert_eq!(
|
||||
parse_recovery_point_hevc(&sei).unwrap(),
|
||||
Some(RecoveryPointHevc {
|
||||
recovery_poc_cnt: 3,
|
||||
exact_match: true,
|
||||
broken_link: true
|
||||
})
|
||||
);
|
||||
|
||||
let sei = [0x05, 0x01, 0x00, 0x80];
|
||||
assert_eq!(parse_recovery_point_hevc(&sei).unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Corpus replay: walk a captured real-host stream through the planners.
|
||||
//!
|
||||
//! The M0 capture hook (`PUNKTFUNK_DUMP_VIDEO=<dir>` on any desktop client) writes
|
||||
//! the exact decoder input of a live session — `au-<stamp>.<codec>` plus an `.idx`
|
||||
//! sidecar carrying `offset len flags complete` per AU. This harness feeds those AUs
|
||||
//! back through [`pf_bitstream::h264::H264Planner`] / [`pf_bitstream::h265::H265Planner`]
|
||||
//! and asserts the planner survives a REAL host stream: every AU plans (bar the
|
||||
//! deliberate skips), no panic, and the warnings are only the ones a clean capture may
|
||||
//! legitimately produce.
|
||||
//!
|
||||
//! Why this exists separately from the vendored conformance vectors: those prove we
|
||||
//! match the spec's own test streams, and the on-glass sessions prove the whole pipe —
|
||||
//! but between the two sits "does the planner handle what OUR five host encoder
|
||||
//! families actually emit", which is the question the corpus was captured to answer.
|
||||
//! For HEVC this is the ONLY pre-wiring validation against real host output (the
|
||||
//! client's HEVC rung is still being built), so it runs long before M3 finishes.
|
||||
//!
|
||||
//! Ignored by default: captures are hundreds of megabytes and live outside the repo.
|
||||
//! Run one explicitly —
|
||||
//!
|
||||
//! ```text
|
||||
//! PF_CORPUS=/path/to/au-1785970273.h265 \
|
||||
//! cargo test -p pf-bitstream --test corpus_replay -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! The `.idx` sidecar is found next to the data file (`<data>.idx`); the codec comes
|
||||
//! from the extension, matching the capture hook's own naming convention.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One captured access unit: its byte range in the data file, plus the wire bits the
|
||||
/// byte stream itself cannot carry.
|
||||
struct CapturedAu {
|
||||
offset: usize,
|
||||
len: usize,
|
||||
/// The wire `flags` byte (`USER_FLAG_*`) — kept for the RFI/intra-refresh legs,
|
||||
/// which discriminate on it.
|
||||
_flags: u32,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// Parse the `.idx` sidecar: one `offset len flags complete` line per AU, `#` comments
|
||||
/// and blank lines skipped (the hook writes none today, but a hand-trimmed corpus file
|
||||
/// is a thing a human will produce).
|
||||
///
|
||||
/// A malformed FINAL line is dropped with a note instead of failing: ending a capture
|
||||
/// means killing the client, so the last buffered line is routinely half-written (the
|
||||
/// hook's own docs call a truncated last AU acceptable). Anywhere else a malformed line
|
||||
/// means the sidecar is corrupt and the run must not quietly replay a subset.
|
||||
fn read_index(path: &Path) -> Vec<CapturedAu> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("cannot read the index sidecar {}: {e}", path.display()));
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#'))
|
||||
.collect();
|
||||
let last = lines.len().saturating_sub(1);
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
match parse_index_line(line) {
|
||||
Some(au) => out.push(au),
|
||||
None if n == last => {
|
||||
println!("note: dropping a truncated final index line ({line:?})");
|
||||
}
|
||||
None => panic!("index line {n} is malformed: {line:?}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One `offset len flags complete` line, or `None` when it is not four parsable fields.
|
||||
fn parse_index_line(line: &str) -> Option<CapturedAu> {
|
||||
let mut it = line.split_whitespace();
|
||||
let num = |raw: &str| -> Option<u64> {
|
||||
match raw.strip_prefix("0x") {
|
||||
Some(hex) => u64::from_str_radix(hex, 16).ok(),
|
||||
None => raw.parse().ok(),
|
||||
}
|
||||
};
|
||||
let offset = num(it.next()?)?;
|
||||
let len = num(it.next()?)?;
|
||||
let flags = num(it.next()?)?;
|
||||
let complete = num(it.next()?)?;
|
||||
Some(CapturedAu {
|
||||
offset: offset as usize,
|
||||
len: len as usize,
|
||||
_flags: flags as u32,
|
||||
complete: complete != 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The capture named by `PF_CORPUS`, or `None` when the variable is unset.
|
||||
fn corpus_from_env() -> Option<(PathBuf, Vec<u8>, Vec<CapturedAu>)> {
|
||||
let path = PathBuf::from(std::env::var_os("PF_CORPUS")?);
|
||||
let data = std::fs::read(&path)
|
||||
.unwrap_or_else(|e| panic!("cannot read the capture {}: {e}", path.display()));
|
||||
let mut idx = path.clone().into_os_string();
|
||||
idx.push(".idx");
|
||||
let mut index = read_index(Path::new(&idx));
|
||||
// Same truncation story on the data side: the final AU's bytes may not all have
|
||||
// reached the file before the client died. Drop AUs the data cannot cover — but
|
||||
// only from the tail, so a short file can never silently hide a middle gap.
|
||||
let covered = index
|
||||
.iter()
|
||||
.take_while(|au| au.offset.saturating_add(au.len) <= data.len())
|
||||
.count();
|
||||
if covered < index.len() {
|
||||
println!(
|
||||
"note: dropping {} index entr{} past the end of the data file (truncated capture)",
|
||||
index.len() - covered,
|
||||
if index.len() - covered == 1 {
|
||||
"y"
|
||||
} else {
|
||||
"ies"
|
||||
},
|
||||
);
|
||||
index.truncate(covered);
|
||||
}
|
||||
assert!(!index.is_empty(), "the capture's index is empty");
|
||||
Some((path, data, index))
|
||||
}
|
||||
|
||||
/// Per-AU outcome tally — what the run reports and asserts on.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
planned: usize,
|
||||
skipped: usize,
|
||||
errors: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
partial: usize,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
/// A clean capture of a healthy session must plan every complete AU. Errors are
|
||||
/// hard failures; warnings are printed and capped — `MissingReference` on a stream
|
||||
/// that never lost a packet would mean the planner invented a gap.
|
||||
fn assert_clean(&self, total: usize) {
|
||||
println!(
|
||||
"planned {} / skipped {} / partial-AUs-ignored {} / errors {} / warnings {} \
|
||||
(of {total} captured AUs)",
|
||||
self.planned,
|
||||
self.skipped,
|
||||
self.partial,
|
||||
self.errors.len(),
|
||||
self.warnings.len(),
|
||||
);
|
||||
for w in self.warnings.iter().take(20) {
|
||||
println!(" warning: {w}");
|
||||
}
|
||||
for e in self.errors.iter().take(20) {
|
||||
println!(" ERROR: {e}");
|
||||
}
|
||||
assert!(
|
||||
self.errors.is_empty(),
|
||||
"{} AUs failed to plan — first: {}",
|
||||
self.errors.len(),
|
||||
self.errors[0],
|
||||
);
|
||||
assert!(
|
||||
self.warnings.is_empty(),
|
||||
"{} planner warnings on a clean capture — first: {}",
|
||||
self.warnings.len(),
|
||||
self.warnings[0],
|
||||
);
|
||||
assert!(self.planned > 0, "no AU planned at all");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a capture: PF_CORPUS=<au-file> (see the module docs)"]
|
||||
fn a_captured_host_stream_replays_through_the_planner() {
|
||||
let Some((path, data, index)) = corpus_from_env() else {
|
||||
panic!("PF_CORPUS is unset — see the module docs for the invocation");
|
||||
};
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
println!(
|
||||
"replaying {} ({} bytes, {} AUs, codec {ext})",
|
||||
path.display(),
|
||||
data.len(),
|
||||
index.len(),
|
||||
);
|
||||
|
||||
let mut tally = Tally::default();
|
||||
// The planners take one COMPLETE AU. A partial AU (the wire's shard split) is the
|
||||
// pump's business, not the planner's — count and skip rather than feed a fragment.
|
||||
let complete: Vec<&CapturedAu> = index.iter().filter(|au| au.complete).collect();
|
||||
tally.partial = index.len() - complete.len();
|
||||
|
||||
match ext.as_str() {
|
||||
"h265" => {
|
||||
let mut planner = pf_bitstream::h265::H265Planner::new();
|
||||
for (i, au) in complete.iter().enumerate() {
|
||||
let bytes = &data[au.offset..au.offset + au.len];
|
||||
match planner.plan_au(bytes) {
|
||||
Ok(plan) => {
|
||||
tally.planned += 1;
|
||||
for w in &plan.warnings {
|
||||
tally.warnings.push(format!("AU {i}: {w:?}"));
|
||||
}
|
||||
}
|
||||
// The spec's own skip (8.1.3): decode nothing, show nothing, the
|
||||
// stream is healthy — never an error (the WP-2 contract note).
|
||||
Err(pf_bitstream::h265::PlanError::RaslSkipped { .. }) => tally.skipped += 1,
|
||||
Err(e) => tally.errors.push(format!("AU {i}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
"h264" => {
|
||||
let mut planner = pf_bitstream::h264::H264Planner::new();
|
||||
for (i, au) in complete.iter().enumerate() {
|
||||
let bytes = &data[au.offset..au.offset + au.len];
|
||||
match planner.plan_au(bytes) {
|
||||
Ok(plan) => {
|
||||
tally.planned += 1;
|
||||
for w in &plan.warnings {
|
||||
tally.warnings.push(format!("AU {i}: {w:?}"));
|
||||
}
|
||||
}
|
||||
Err(e) => tally.errors.push(format!("AU {i}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
other => panic!("no planner for a .{other} capture (h264/h265 only today)"),
|
||||
}
|
||||
|
||||
tally.assert_clean(index.len());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Vendored snapshot — see PROVENANCE.md. Deliberately NOT opted into workspace lints
|
||||
# or workspace package inheritance: upstream code stays as close to pristine as the
|
||||
# trim allows, so re-syncing against the AOSP tree stays a diff, not an archaeology dig.
|
||||
[package]
|
||||
name = "cros-codecs"
|
||||
version = "0.0.5"
|
||||
license = "BSD-3-Clause"
|
||||
description = "Vendored cros-codecs parser layer (codec module only) for pf-bitstream"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
|
||||
# Upstream's in-tree unit tests (kept — they are the conformance goldens) want these.
|
||||
[dev-dependencies]
|
||||
env_logger = "0.11"
|
||||
serde_json = "1"
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
Copyright 2022 The ChromiumOS Authors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Vendored: cros-codecs (parser layer only)
|
||||
|
||||
- **Upstream:** <https://android.googlesource.com/platform/system/cros-codecs/> (the
|
||||
authoritative AOSP tree). Snapshot taken from the read-only GitHub mirror
|
||||
<https://github.com/chromeos/cros-codecs>, branch `main`,
|
||||
commit **`5ff6d693ffae0b36935b8fc13092c733b4c2646f`**, fetched 2026-08-05.
|
||||
- **License:** BSD-3-Clause (`LICENSE`, copied verbatim). Attribution headers retained
|
||||
in every source file.
|
||||
- **Why vendored, not a crates.io dependency:** the GitHub repo is a read-only mirror
|
||||
and the crates.io release lags it; a pinned, reviewed snapshot is the supply-chain
|
||||
posture punktfunk already uses elsewhere (`clients/android/native/vendor/ndk`,
|
||||
`punktfunk-host/vendor/usbip-sim`). Decision of record:
|
||||
punktfunk-planning `design/client-native-decode.md` §8.1.
|
||||
|
||||
## What was taken
|
||||
|
||||
`src/codec/{h264,h265,av1,vp9}` (parsers, DPBs, picture types, NALU/OBU machinery,
|
||||
their `test_data` vectors — they double as punktfunk's conformance corpus),
|
||||
`src/bitstream_utils.rs`, `LICENSE`. Upstream designed the `codec` module for exactly
|
||||
this extraction — its module doc: "There shall be no dependencies from other modules of
|
||||
this crate to this module, so that it can be turned into a crate of its own if needed
|
||||
in the future."
|
||||
|
||||
## What was left behind
|
||||
|
||||
- `decoder/`, `encoder/`, `backend/`, `c2_wrapper/`, `video_frame`, `image_processing`,
|
||||
`utils` — the Linux-only halves (libva/v4l2/gbm/nix). punktfunk's `pf-bitstream` +
|
||||
`pf-vkdecode` occupy that layer.
|
||||
- `codec/vp8` — VP9 has no dependency on it (verified) and no punktfunk host will ever
|
||||
emit VP8.
|
||||
|
||||
## Deviations from pristine upstream
|
||||
|
||||
1. `src/lib.rs` — rewritten: keeps only the module decls and `Resolution` /
|
||||
`ResolutionRoundMode` (the sole root items `codec` references), both copied verbatim;
|
||||
adds crate-level `#![allow(clippy::all, mismatched_lifetime_syntaxes)]` — vendored
|
||||
code is not held to the workspace lint bar (CI's `-D warnings` legs would fail on
|
||||
upstream style otherwise).
|
||||
2. `src/codec.rs` — one line removed (`pub mod vp8;`).
|
||||
3. `Cargo.toml` — rewritten: `log` is the only dependency the vendored subset needs,
|
||||
plus `env_logger`/`serde_json` dev-dependencies for upstream's in-tree tests.
|
||||
4. `cargo fmt` normalization under the workspace's rustfmt config (mechanical only).
|
||||
5. **Zero-unsafe, enforced**: `#![forbid(unsafe_code)]` added to lib.rs. Upstream's codec
|
||||
module had exactly one production `unsafe` (h264/dpb.rs `build_ref_pic_lists`: ref→index
|
||||
via pointer `offset_from`) — replaced with a safe `position(ptr::eq)` over the ≤16-entry
|
||||
DPB — and three test-only `mem::zeroed()` asserts, replaced with `Default::default()`
|
||||
(`PredWeightTable` derives `Default`; all-integer struct, identical value). The layer
|
||||
facing untrusted bytes is now compiler-verified free of unsafe — the property that
|
||||
motivates replacing libavcodec's C parsers in the first place.
|
||||
6. `src/codec/h264/picture.rs` — `PictureData::new_from_slice`: `display_resolution`
|
||||
computed as `visible_rect.max` instead of `max - min`. `Sps::visible_rectangle()`
|
||||
returns the crop offset in `min` and the visible *size* in `max` (see its
|
||||
definition: `max.x = width - crop_left - crop_right`); upstream's subtraction
|
||||
double-counts the left/top crop and, worse, panics on u32 underflow for a
|
||||
large-but-parser-valid `frame_crop_left_offset` (e.g. 100 crop units on a 320-wide
|
||||
SPS). Found by pf-bitstream's conformance-window tests; upstream never hits it
|
||||
because real encoders crop right/bottom only. **Reported upstream 2026-08-06:
|
||||
<https://github.com/chromeos/cros-codecs/issues/99>.**
|
||||
|
||||
7. `src/codec/h265/parser.rs` — `parse_slice_header`: reject
|
||||
`num_long_term_sps + num_long_term_pics > 16` before the long-term RPS loop.
|
||||
Upstream bounds the pair only by `MAX_LONG_TERM_REF_PIC_SETS` (32) combined, while
|
||||
every long-term array in `SliceHeader` (`poc_lsb_lt`, `used_by_curr_pic_lt`,
|
||||
`delta_poc_msb_present_flag`, `delta_poc_msb_cycle_lt`, `lt_idx_sps`) is `[_; 16]`
|
||||
— a hostile slice header with 17+ entries panics the parser with an
|
||||
index-out-of-bounds (bounds checks stay on in release). Found by pf-bitstream's
|
||||
H.265 planner review; regression-tested there
|
||||
(`a_hostile_long_term_count_is_a_parse_error_not_a_panic`). **Reported upstream
|
||||
2026-08-06: <https://github.com/chromeos/cros-codecs/issues/100>.**
|
||||
|
||||
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
|
||||
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
|
||||
@@ -0,0 +1,788 @@
|
||||
// Copyright 2024 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
use std::io::Write;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::codec::h264::parser::Nalu as H264Nalu;
|
||||
use crate::codec::h265::parser::Nalu as H265Nalu;
|
||||
|
||||
/// A bit reader for codec bitstreams. It properly handles emulation-prevention
|
||||
/// bytes and stop bits for H264.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BitReader<'a> {
|
||||
/// A reference into the next unread byte in the stream.
|
||||
data: Cursor<&'a [u8]>,
|
||||
/// Contents of the current byte. First unread bit starting at position 8 -
|
||||
/// num_remaining_bits_in_curr_bytes.
|
||||
curr_byte: u8,
|
||||
/// Number of bits remaining in `curr_byte`
|
||||
num_remaining_bits_in_curr_byte: usize,
|
||||
/// Used in emulation prevention byte detection.
|
||||
prev_two_bytes: u16,
|
||||
/// Number of emulation prevention bytes (i.e. 0x000003) we found.
|
||||
num_epb: usize,
|
||||
/// Whether or not we need emulation prevention logic.
|
||||
needs_epb: bool,
|
||||
/// How many bits have been read so far.
|
||||
position: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum GetByteError {
|
||||
OutOfBits,
|
||||
}
|
||||
|
||||
impl fmt::Display for GetByteError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "reader ran out of bits")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ReadBitsError {
|
||||
TooManyBitsRequested(usize),
|
||||
GetByte(GetByteError),
|
||||
ConversionFailed,
|
||||
}
|
||||
|
||||
impl fmt::Display for ReadBitsError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ReadBitsError::TooManyBitsRequested(bits) => {
|
||||
write!(f, "more than 31 ({}) bits were requested", bits)
|
||||
}
|
||||
ReadBitsError::GetByte(_) => write!(f, "failed to advance the current byte"),
|
||||
ReadBitsError::ConversionFailed => {
|
||||
write!(f, "failed to convert read input to target type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GetByteError> for ReadBitsError {
|
||||
fn from(err: GetByteError) -> Self {
|
||||
ReadBitsError::GetByte(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
pub fn new(data: &'a [u8], needs_epb: bool) -> Self {
|
||||
Self {
|
||||
data: Cursor::new(data),
|
||||
curr_byte: Default::default(),
|
||||
num_remaining_bits_in_curr_byte: Default::default(),
|
||||
prev_two_bytes: 0xffff,
|
||||
num_epb: Default::default(),
|
||||
needs_epb: needs_epb,
|
||||
position: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single bit from the stream.
|
||||
pub fn read_bit(&mut self) -> Result<bool, String> {
|
||||
let bit = self.read_bits::<u32>(1)?;
|
||||
match bit {
|
||||
1 => Ok(true),
|
||||
0 => Ok(false),
|
||||
_ => panic!("Unexpected value {}", bit),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read up to 31 bits from the stream. Note that we don't want to read 32
|
||||
/// bits even though we're returning a u32 because that would break the
|
||||
/// read_bits_signed() function. 31 bits should be overkill for compressed
|
||||
/// header parsing anyway.
|
||||
pub fn read_bits<U: TryFrom<u32>>(&mut self, num_bits: usize) -> Result<U, String> {
|
||||
if num_bits > 31 {
|
||||
return Err(ReadBitsError::TooManyBitsRequested(num_bits).to_string());
|
||||
}
|
||||
|
||||
let mut bits_left = num_bits;
|
||||
let mut out = 0u32;
|
||||
|
||||
while self.num_remaining_bits_in_curr_byte < bits_left {
|
||||
out |= (self.curr_byte as u32) << (bits_left - self.num_remaining_bits_in_curr_byte);
|
||||
bits_left -= self.num_remaining_bits_in_curr_byte;
|
||||
self.move_to_next_byte().map_err(|err| err.to_string())?;
|
||||
}
|
||||
|
||||
out |= (self.curr_byte >> (self.num_remaining_bits_in_curr_byte - bits_left)) as u32;
|
||||
out &= (1 << num_bits) - 1;
|
||||
self.num_remaining_bits_in_curr_byte -= bits_left;
|
||||
self.position += num_bits as u64;
|
||||
|
||||
U::try_from(out).map_err(|_| ReadBitsError::ConversionFailed.to_string())
|
||||
}
|
||||
|
||||
/// Reads a two's complement signed integer of length |num_bits|.
|
||||
pub fn read_bits_signed<U: TryFrom<i32>>(&mut self, num_bits: usize) -> Result<U, String> {
|
||||
let mut out: i32 = self
|
||||
.read_bits::<u32>(num_bits)?
|
||||
.try_into()
|
||||
.map_err(|_| ReadBitsError::ConversionFailed.to_string())?;
|
||||
if out >> (num_bits - 1) != 0 {
|
||||
out |= -1i32 ^ ((1 << num_bits) - 1);
|
||||
}
|
||||
|
||||
U::try_from(out).map_err(|_| ReadBitsError::ConversionFailed.to_string())
|
||||
}
|
||||
|
||||
/// Reads an unsigned integer from the stream and checks if the stream is byte aligned.
|
||||
pub fn read_bits_aligned<U: TryFrom<u32>>(&mut self, num_bits: usize) -> Result<U, String> {
|
||||
if self.num_remaining_bits_in_curr_byte % 8 != 0 {
|
||||
return Err("Attempted unaligned read_le()".into());
|
||||
}
|
||||
|
||||
Ok(self.read_bits(num_bits).map_err(|err| err.to_string())?)
|
||||
}
|
||||
|
||||
/// Skip `num_bits` bits from the stream.
|
||||
pub fn skip_bits(&mut self, mut num_bits: usize) -> Result<(), String> {
|
||||
while num_bits > 0 {
|
||||
let n = std::cmp::min(num_bits, 31);
|
||||
self.read_bits::<u32>(n)?;
|
||||
num_bits -= n;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the amount of bits left in the stream
|
||||
pub fn num_bits_left(&mut self) -> usize {
|
||||
let cur_pos = self.data.position();
|
||||
// This should always be safe to unwrap.
|
||||
let end_pos = self.data.seek(SeekFrom::End(0)).unwrap();
|
||||
let _ = self.data.seek(SeekFrom::Start(cur_pos));
|
||||
((end_pos - cur_pos) as usize) * 8 + self.num_remaining_bits_in_curr_byte
|
||||
}
|
||||
|
||||
/// Returns the number of emulation-prevention bytes read so far.
|
||||
pub fn num_epb(&self) -> usize {
|
||||
self.num_epb
|
||||
}
|
||||
|
||||
/// Whether the stream still has RBSP data. Implements more_rbsp_data(). See
|
||||
/// the spec for more details.
|
||||
pub fn has_more_rsbp_data(&mut self) -> bool {
|
||||
if self.num_remaining_bits_in_curr_byte == 0 && self.move_to_next_byte().is_err() {
|
||||
// no more data at all in the rbsp
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the next bit is the stop bit, then we should only see unset bits
|
||||
// until the end of the data.
|
||||
if (self.curr_byte & ((1 << (self.num_remaining_bits_in_curr_byte - 1)) - 1)) != 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
let orig_pos = self.data.position();
|
||||
while let Ok(_) = self.data.read_exact(&mut buf) {
|
||||
if buf[0] != 0 {
|
||||
self.data.set_position(orig_pos);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Reads an Unsigned Exponential golomb coding number from the next bytes in the
|
||||
/// bitstream. This may advance the state of position within the bitstream even if the
|
||||
/// read operation is unsuccessful. See H264 Annex B specification 9.1 for details.
|
||||
pub fn read_ue<U: TryFrom<u32>>(&mut self) -> Result<U, String> {
|
||||
let mut num_bits = 0;
|
||||
|
||||
while self.read_bits::<u32>(1)? == 0 {
|
||||
num_bits += 1;
|
||||
if num_bits > 31 {
|
||||
return Err("invalid stream".into());
|
||||
}
|
||||
}
|
||||
|
||||
let value = ((1u32 << num_bits) - 1)
|
||||
.checked_add(self.read_bits::<u32>(num_bits)?)
|
||||
.ok_or::<String>("read number cannot fit in 32 bits".into())?;
|
||||
|
||||
U::try_from(value).map_err(|_| "conversion error".into())
|
||||
}
|
||||
|
||||
pub fn read_ue_bounded<U: TryFrom<u32>>(&mut self, min: u32, max: u32) -> Result<U, String> {
|
||||
let ue = self.read_ue()?;
|
||||
if ue > max || ue < min {
|
||||
Err(format!(
|
||||
"Value out of bounds: expected {} - {}, got {}",
|
||||
min, max, ue
|
||||
))
|
||||
} else {
|
||||
Ok(U::try_from(ue).map_err(|_| String::from("Conversion error"))?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_ue_max<U: TryFrom<u32>>(&mut self, max: u32) -> Result<U, String> {
|
||||
self.read_ue_bounded(0, max)
|
||||
}
|
||||
|
||||
/// Reads a signed exponential golomb coding number. Instead of using two's
|
||||
/// complement, this scheme maps even integers to positive numbers and odd
|
||||
/// integers to negative numbers. The least significant bit indicates the
|
||||
/// sign. See H264 Annex B specification 9.1.1 for details.
|
||||
pub fn read_se<U: TryFrom<i32>>(&mut self) -> Result<U, String> {
|
||||
let ue = self.read_ue::<u32>()? as i32;
|
||||
|
||||
if ue % 2 == 0 {
|
||||
Ok(U::try_from(-(ue / 2)).map_err(|_| String::from("Conversion error"))?)
|
||||
} else {
|
||||
Ok(U::try_from(ue / 2 + 1).map_err(|_| String::from("Conversion error"))?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_se_bounded<U: TryFrom<i32>>(&mut self, min: i32, max: i32) -> Result<U, String> {
|
||||
let se = self.read_se()?;
|
||||
if se < min || se > max {
|
||||
Err(format!(
|
||||
"Value out of bounds, expected between {}-{}, got {}",
|
||||
min, max, se
|
||||
))
|
||||
} else {
|
||||
Ok(U::try_from(se).map_err(|_| String::from("Conversion error"))?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read little endian multi-byte integer.
|
||||
pub fn read_le<U: TryFrom<u32>>(&mut self, num_bits: u8) -> Result<U, String> {
|
||||
let mut t = 0;
|
||||
|
||||
for i in 0..num_bits {
|
||||
let byte = self.read_bits_aligned::<u32>(8)?;
|
||||
t += byte << (i * 8)
|
||||
}
|
||||
|
||||
Ok(U::try_from(t).map_err(|_| String::from("Conversion error"))?)
|
||||
}
|
||||
|
||||
/// Return the position of this bitstream in bits.
|
||||
pub fn position(&self) -> u64 {
|
||||
self.position
|
||||
}
|
||||
|
||||
fn get_byte(&mut self) -> Result<u8, GetByteError> {
|
||||
let mut buf = [0u8; 1];
|
||||
self.data
|
||||
.read_exact(&mut buf)
|
||||
.map_err(|_| GetByteError::OutOfBits)?;
|
||||
Ok(buf[0])
|
||||
}
|
||||
|
||||
fn move_to_next_byte(&mut self) -> Result<(), GetByteError> {
|
||||
let mut byte = self.get_byte()?;
|
||||
|
||||
if self.needs_epb {
|
||||
if self.prev_two_bytes == 0 && byte == 0x03 {
|
||||
// We found an epb
|
||||
self.num_epb += 1;
|
||||
// Read another byte
|
||||
byte = self.get_byte()?;
|
||||
// We need another 3 bytes before another epb can happen.
|
||||
self.prev_two_bytes = 0xffff;
|
||||
}
|
||||
self.prev_two_bytes = (self.prev_two_bytes << 8) | u16::from(byte);
|
||||
}
|
||||
|
||||
self.num_remaining_bits_in_curr_byte = 8;
|
||||
self.curr_byte = byte;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator over IVF packets.
|
||||
pub struct IvfIterator<'a> {
|
||||
cursor: Cursor<&'a [u8]>,
|
||||
}
|
||||
|
||||
impl<'a> IvfIterator<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
let mut cursor = Cursor::new(data);
|
||||
|
||||
// Skip the IVH header entirely.
|
||||
cursor.seek(std::io::SeekFrom::Start(32)).unwrap();
|
||||
|
||||
Self { cursor }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for IvfIterator<'a> {
|
||||
type Item = &'a [u8];
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// Make sure we have a header.
|
||||
let mut len_buf = [0u8; 4];
|
||||
self.cursor.read_exact(&mut len_buf).ok()?;
|
||||
let len = ((len_buf[3] as usize) << 24)
|
||||
| ((len_buf[2] as usize) << 16)
|
||||
| ((len_buf[1] as usize) << 8)
|
||||
| (len_buf[0] as usize);
|
||||
|
||||
// Skip PTS.
|
||||
self.cursor.seek(std::io::SeekFrom::Current(8)).ok()?;
|
||||
|
||||
let start = self.cursor.position() as usize;
|
||||
let _ = self
|
||||
.cursor
|
||||
.seek(std::io::SeekFrom::Current(len as i64))
|
||||
.ok()?;
|
||||
let end = self.cursor.position() as usize;
|
||||
|
||||
Some(&self.cursor.get_ref()[start..end])
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for synthesizing IVF file header
|
||||
pub struct IvfFileHeader {
|
||||
pub magic: [u8; 4],
|
||||
pub version: u16,
|
||||
pub header_size: u16,
|
||||
pub codec: [u8; 4],
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub framerate: u32,
|
||||
pub timescale: u32,
|
||||
pub frame_count: u32,
|
||||
pub unused: u32,
|
||||
}
|
||||
|
||||
impl Default for IvfFileHeader {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
magic: Self::MAGIC,
|
||||
version: 0,
|
||||
header_size: 32,
|
||||
codec: Self::CODEC_VP9,
|
||||
width: 320,
|
||||
height: 240,
|
||||
framerate: 1,
|
||||
timescale: 1000,
|
||||
frame_count: 1,
|
||||
unused: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IvfFileHeader {
|
||||
pub const MAGIC: [u8; 4] = *b"DKIF";
|
||||
pub const CODEC_VP8: [u8; 4] = *b"VP80";
|
||||
pub const CODEC_VP9: [u8; 4] = *b"VP90";
|
||||
pub const CODEC_AV1: [u8; 4] = *b"AV01";
|
||||
|
||||
pub fn new(codec: [u8; 4], width: u16, height: u16, framerate: u32, frame_count: u32) -> Self {
|
||||
let default = Self::default();
|
||||
|
||||
Self {
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
framerate: framerate * default.timescale,
|
||||
frame_count,
|
||||
..default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IvfFileHeader {
|
||||
/// Writes header into writer
|
||||
pub fn writo_into(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||
writer.write_all(&self.magic)?;
|
||||
writer.write_all(&self.version.to_le_bytes())?;
|
||||
writer.write_all(&self.header_size.to_le_bytes())?;
|
||||
writer.write_all(&self.codec)?;
|
||||
writer.write_all(&self.width.to_le_bytes())?;
|
||||
writer.write_all(&self.height.to_le_bytes())?;
|
||||
writer.write_all(&self.framerate.to_le_bytes())?;
|
||||
writer.write_all(&self.timescale.to_le_bytes())?;
|
||||
writer.write_all(&self.frame_count.to_le_bytes())?;
|
||||
writer.write_all(&self.unused.to_le_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for synthesizing IVF frame header
|
||||
pub struct IvfFrameHeader {
|
||||
pub frame_size: u32,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
impl IvfFrameHeader {
|
||||
/// Writes header into writer
|
||||
pub fn writo_into(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||
writer.write_all(&self.frame_size.to_le_bytes())?;
|
||||
writer.write_all(&self.timestamp.to_le_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator NALUs in a bitstream.
|
||||
pub struct NalIterator<'a, Nalu>(Cursor<&'a [u8]>, PhantomData<Nalu>);
|
||||
|
||||
impl<'a, Nalu> NalIterator<'a, Nalu> {
|
||||
pub fn new(stream: &'a [u8]) -> Self {
|
||||
Self(Cursor::new(stream), PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NalIterator<'a, H264Nalu<'a>> {
|
||||
type Item = Cow<'a, [u8]>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
H264Nalu::next(&mut self.0).map(|n| n.data).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NalIterator<'a, H265Nalu<'a>> {
|
||||
type Item = Cow<'a, [u8]>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
H265Nalu::next(&mut self.0).map(|n| n.data).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BitWriterError {
|
||||
InvalidBitCount,
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for BitWriterError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
BitWriterError::InvalidBitCount => write!(f, "invalid bit count"),
|
||||
BitWriterError::Io(x) => write!(f, "{}", x.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for BitWriterError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
BitWriterError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type BitWriterResult<T> = std::result::Result<T, BitWriterError>;
|
||||
|
||||
pub struct BitWriter<W: Write> {
|
||||
out: W,
|
||||
nth_bit: u8,
|
||||
curr_byte: u8,
|
||||
}
|
||||
|
||||
impl<W: Write> BitWriter<W> {
|
||||
pub fn new(writer: W) -> Self {
|
||||
Self {
|
||||
out: writer,
|
||||
curr_byte: 0,
|
||||
nth_bit: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes fixed bit size integer (up to 32 bit)
|
||||
pub fn write_f<T: Into<u32>>(&mut self, bits: usize, value: T) -> BitWriterResult<usize> {
|
||||
let value = value.into();
|
||||
|
||||
if bits > 32 {
|
||||
return Err(BitWriterError::InvalidBitCount);
|
||||
}
|
||||
|
||||
let mut written = 0;
|
||||
for bit in (0..bits).rev() {
|
||||
let bit = (1 << bit) as u32;
|
||||
|
||||
self.write_bit((value & bit) == bit)?;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Takes a single bit that will be outputed to [`std::io::Write`]
|
||||
pub fn write_bit(&mut self, bit: bool) -> BitWriterResult<()> {
|
||||
self.curr_byte |= (bit as u8) << (7u8 - self.nth_bit);
|
||||
self.nth_bit += 1;
|
||||
|
||||
if self.nth_bit == 8 {
|
||||
self.out.write_all(&[self.curr_byte])?;
|
||||
self.nth_bit = 0;
|
||||
self.curr_byte = 0;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Immediately outputs any cached bits to [`std::io::Write`]
|
||||
pub fn flush(&mut self) -> BitWriterResult<()> {
|
||||
if self.nth_bit != 0 {
|
||||
self.out.write_all(&[self.curr_byte])?;
|
||||
self.nth_bit = 0;
|
||||
self.curr_byte = 0;
|
||||
}
|
||||
|
||||
self.out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if ['Self`] hold data that wasn't written to [`std::io::Write`]
|
||||
pub fn has_data_pending(&self) -> bool {
|
||||
self.nth_bit != 0
|
||||
}
|
||||
|
||||
pub(crate) fn inner(&self) -> &W {
|
||||
&self.out
|
||||
}
|
||||
|
||||
pub(crate) fn inner_mut(&mut self) -> &mut W {
|
||||
&mut self.out
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> Drop for BitWriter<W> {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.flush() {
|
||||
log::error!("Unable to flush bits {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ivf_file_header() {
|
||||
let mut hdr = IvfFileHeader {
|
||||
version: 0,
|
||||
codec: IvfFileHeader::CODEC_VP9,
|
||||
width: 256,
|
||||
height: 256,
|
||||
framerate: 30_000,
|
||||
timescale: 1_000,
|
||||
frame_count: 1,
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
hdr.writo_into(&mut buf).unwrap();
|
||||
|
||||
const EXPECTED: [u8; 32] = [
|
||||
0x44, 0x4b, 0x49, 0x46, 0x00, 0x00, 0x20, 0x00, 0x56, 0x50, 0x39, 0x30, 0x00, 0x01,
|
||||
0x00, 0x01, 0x30, 0x75, 0x00, 0x00, 0xe8, 0x03, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
assert_eq!(&buf, &EXPECTED);
|
||||
|
||||
hdr.width = 1920;
|
||||
hdr.height = 800;
|
||||
hdr.framerate = 24;
|
||||
hdr.timescale = 1;
|
||||
hdr.frame_count = 100;
|
||||
|
||||
buf.clear();
|
||||
hdr.writo_into(&mut buf).unwrap();
|
||||
|
||||
const EXPECTED2: [u8; 32] = [
|
||||
0x44, 0x4b, 0x49, 0x46, 0x00, 0x00, 0x20, 0x00, 0x56, 0x50, 0x39, 0x30, 0x80, 0x07,
|
||||
0x20, 0x03, 0x18, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
assert_eq!(&buf, &EXPECTED2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ivf_frame_header() {
|
||||
let mut hdr = IvfFrameHeader {
|
||||
frame_size: 199249,
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
hdr.writo_into(&mut buf).unwrap();
|
||||
|
||||
const EXPECTED: [u8; 12] = [
|
||||
0x51, 0x0a, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
assert_eq!(&buf, &EXPECTED);
|
||||
|
||||
hdr.timestamp = 1;
|
||||
hdr.frame_size = 52;
|
||||
|
||||
buf.clear();
|
||||
hdr.writo_into(&mut buf).unwrap();
|
||||
|
||||
const EXPECTED2: [u8; 12] = [
|
||||
0x34, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
assert_eq!(&buf, &EXPECTED2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitwriter_f1() {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
{
|
||||
let mut writer = BitWriter::new(&mut buf);
|
||||
writer.write_f(1, true).unwrap();
|
||||
writer.write_f(1, false).unwrap();
|
||||
writer.write_f(1, false).unwrap();
|
||||
writer.write_f(1, false).unwrap();
|
||||
writer.write_f(1, true).unwrap();
|
||||
writer.write_f(1, true).unwrap();
|
||||
writer.write_f(1, true).unwrap();
|
||||
writer.write_f(1, true).unwrap();
|
||||
}
|
||||
assert_eq!(buf, vec![0b10001111u8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitwriter_f3() {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
{
|
||||
let mut writer = BitWriter::new(&mut buf);
|
||||
writer.write_f(3, 0b100u8).unwrap();
|
||||
writer.write_f(3, 0b101u8).unwrap();
|
||||
writer.write_f(3, 0b011u8).unwrap();
|
||||
}
|
||||
assert_eq!(buf, vec![0b10010101u8, 0b10000000u8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitwriter_f4() {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
{
|
||||
let mut writer = BitWriter::new(&mut buf);
|
||||
writer.write_f(4, 0b1000u8).unwrap();
|
||||
writer.write_f(4, 0b1011u8).unwrap();
|
||||
}
|
||||
assert_eq!(buf, vec![0b10001011u8]);
|
||||
}
|
||||
|
||||
// These tests are adapted from the chromium tests at media/video/h264_bit_reader_unitttest.cc
|
||||
|
||||
#[test]
|
||||
fn read_stream_without_escape_and_trailing_zero_bytes() {
|
||||
const RBSP: [u8; 6] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xa0];
|
||||
|
||||
let mut reader = BitReader::new(&RBSP, true);
|
||||
assert_eq!(reader.read_bits::<u32>(1).unwrap(), 0);
|
||||
assert_eq!(reader.num_bits_left(), 47);
|
||||
assert!(reader.has_more_rsbp_data());
|
||||
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x02);
|
||||
assert_eq!(reader.num_bits_left(), 39);
|
||||
assert!(reader.has_more_rsbp_data());
|
||||
|
||||
assert_eq!(reader.read_bits::<u32>(31).unwrap(), 0x23456789);
|
||||
assert_eq!(reader.num_bits_left(), 8);
|
||||
assert!(reader.has_more_rsbp_data());
|
||||
|
||||
assert_eq!(reader.read_bits::<u32>(1).unwrap(), 1);
|
||||
assert_eq!(reader.num_bits_left(), 7);
|
||||
assert!(reader.has_more_rsbp_data());
|
||||
|
||||
assert_eq!(reader.read_bits::<u32>(1).unwrap(), 0);
|
||||
assert_eq!(reader.num_bits_left(), 6);
|
||||
assert!(!reader.has_more_rsbp_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_byte_stream() {
|
||||
const RBSP: [u8; 1] = [0x18];
|
||||
|
||||
let mut reader = BitReader::new(&RBSP, true);
|
||||
assert_eq!(reader.num_bits_left(), 8);
|
||||
assert!(reader.has_more_rsbp_data());
|
||||
assert_eq!(reader.read_bits::<u32>(4).unwrap(), 1);
|
||||
assert!(!reader.has_more_rsbp_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_bit_occupy_full_byte() {
|
||||
const RBSP: [u8; 2] = [0xab, 0x80];
|
||||
|
||||
let mut reader = BitReader::new(&RBSP, true);
|
||||
assert_eq!(reader.num_bits_left(), 16);
|
||||
assert!(reader.has_more_rsbp_data());
|
||||
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0xab);
|
||||
assert_eq!(reader.num_bits_left(), 8);
|
||||
|
||||
assert!(!reader.has_more_rsbp_data());
|
||||
}
|
||||
|
||||
// Check that read_ue behaves properly with input at the limits.
|
||||
#[test]
|
||||
fn read_ue() {
|
||||
// Regular value.
|
||||
let mut reader = BitReader::new(&[0b0001_1010], true);
|
||||
assert_eq!(reader.read_ue::<u32>().unwrap(), 12);
|
||||
assert_eq!(reader.data.position(), 1);
|
||||
assert_eq!(reader.num_remaining_bits_in_curr_byte, 1);
|
||||
|
||||
// 0 value.
|
||||
let mut reader = BitReader::new(&[0b1000_0000], true);
|
||||
assert_eq!(reader.read_ue::<u32>().unwrap(), 0);
|
||||
assert_eq!(reader.data.position(), 1);
|
||||
assert_eq!(reader.num_remaining_bits_in_curr_byte, 7);
|
||||
|
||||
// No prefix stop bit.
|
||||
let mut reader = BitReader::new(&[0b0000_0000], true);
|
||||
reader.read_ue::<u32>().unwrap_err();
|
||||
|
||||
// u32 max value: 31 0-bits, 1 bit marker, 31 bits 1-bits.
|
||||
let mut reader = BitReader::new(
|
||||
&[
|
||||
0b0000_0000,
|
||||
0b0000_0000,
|
||||
0b0000_0000,
|
||||
0b0000_0001,
|
||||
0b1111_1111,
|
||||
0b1111_1111,
|
||||
0b1111_1111,
|
||||
0b1111_1110,
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert_eq!(reader.read_ue::<u32>().unwrap(), 0xffff_fffe);
|
||||
assert_eq!(reader.data.position(), 8);
|
||||
assert_eq!(reader.num_remaining_bits_in_curr_byte, 1);
|
||||
}
|
||||
|
||||
// Check that emulation prevention is being handled correctly.
|
||||
#[test]
|
||||
fn skip_epb_when_enabled() {
|
||||
let mut reader = BitReader::new(&[0x00, 0x00, 0x03, 0x01], false);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x03);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x01);
|
||||
|
||||
let mut reader = BitReader::new(&[0x00, 0x00, 0x03, 0x01], true);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
|
||||
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_signed_bits() {
|
||||
let mut reader = BitReader::new(&[0b1111_0000], false);
|
||||
assert_eq!(reader.read_bits_signed::<i32>(4).unwrap(), -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2023 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
//! Parsers for various kinds of encoded streams.
|
||||
//!
|
||||
//! This module does not provide any actual decoding tools - that's the job of the
|
||||
//! [crate::decoder] module. However the parsers of this module are heavily used in order to
|
||||
//! implement stateless decoding.
|
||||
//!
|
||||
//! There shall be no dependencies from other modules of this crate to this module, so that it
|
||||
//! can be turned into a crate of its own if needed in the future.
|
||||
|
||||
pub mod av1;
|
||||
pub mod h264;
|
||||
pub mod h265;
|
||||
pub mod vp9;
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2023 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
mod helpers;
|
||||
pub mod parser;
|
||||
pub mod reader;
|
||||
pub mod synthesizer;
|
||||
pub mod writer;
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright 2023 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
use crate::codec::av1::parser::NUM_REF_FRAMES;
|
||||
|
||||
const DIV_LUT: [i32; 257] = [
|
||||
16384, 16320, 16257, 16194, 16132, 16070, 16009, 15948, 15888, 15828, 15768, 15709, 15650,
|
||||
15592, 15534, 15477, 15420, 15364, 15308, 15252, 15197, 15142, 15087, 15033, 14980, 14926,
|
||||
14873, 14821, 14769, 14717, 14665, 14614, 14564, 14513, 14463, 14413, 14364, 14315, 14266,
|
||||
14218, 14170, 14122, 14075, 14028, 13981, 13935, 13888, 13843, 13797, 13752, 13707, 13662,
|
||||
13618, 13574, 13530, 13487, 13443, 13400, 13358, 13315, 13273, 13231, 13190, 13148, 13107,
|
||||
13066, 13026, 12985, 12945, 12906, 12866, 12827, 12788, 12749, 12710, 12672, 12633, 12596,
|
||||
12558, 12520, 12483, 12446, 12409, 12373, 12336, 12300, 12264, 12228, 12193, 12157, 12122,
|
||||
12087, 12053, 12018, 11984, 11950, 11916, 11882, 11848, 11815, 11782, 11749, 11716, 11683,
|
||||
11651, 11619, 11586, 11555, 11523, 11491, 11460, 11429, 11398, 11367, 11336, 11305, 11275,
|
||||
11245, 11215, 11185, 11155, 11125, 11096, 11067, 11038, 11009, 10980, 10951, 10923, 10894,
|
||||
10866, 10838, 10810, 10782, 10755, 10727, 10700, 10673, 10645, 10618, 10592, 10565, 10538,
|
||||
10512, 10486, 10460, 10434, 10408, 10382, 10356, 10331, 10305, 10280, 10255, 10230, 10205,
|
||||
10180, 10156, 10131, 10107, 10082, 10058, 10034, 10010, 9986, 9963, 9939, 9916, 9892, 9869,
|
||||
9846, 9823, 9800, 9777, 9754, 9732, 9709, 9687, 9664, 9642, 9620, 9598, 9576, 9554, 9533, 9511,
|
||||
9489, 9468, 9447, 9425, 9404, 9383, 9362, 9341, 9321, 9300, 9279, 9259, 9239, 9218, 9198, 9178,
|
||||
9158, 9138, 9118, 9098, 9079, 9059, 9039, 9020, 9001, 8981, 8962, 8943, 8924, 8905, 8886, 8867,
|
||||
8849, 8830, 8812, 8793, 8775, 8756, 8738, 8720, 8702, 8684, 8666, 8648, 8630, 8613, 8595, 8577,
|
||||
8560, 8542, 8525, 8508, 8490, 8473, 8456, 8439, 8422, 8405, 8389, 8372, 8355, 8339, 8322, 8306,
|
||||
8289, 8273, 8257, 8240, 8224, 8208, 8192,
|
||||
];
|
||||
|
||||
const DIV_LUT_BITS: u32 = 8;
|
||||
const DIV_LUT_PREC_BITS: u32 = 14;
|
||||
|
||||
/// Implements FloorLog2(x), which is defined to be the floor of the base 2
|
||||
/// logarithm of the input x.
|
||||
///
|
||||
/// The input x will always be an integer, and will always be greater than or equal to 1.
|
||||
/// This function extracts the location of the most significant bit in x.
|
||||
pub fn floor_log2(mut x: u32) -> u32 {
|
||||
assert!(x > 0);
|
||||
let mut s = 0;
|
||||
|
||||
while x != 0 {
|
||||
x >>= 1;
|
||||
s += 1;
|
||||
}
|
||||
|
||||
s - 1
|
||||
}
|
||||
|
||||
/// Implements 5.9.3. Get relative distance function
|
||||
pub fn get_relative_dist(enable_order_hint: bool, order_hint_bits: i32, a: i32, b: i32) -> i32 {
|
||||
if !enable_order_hint {
|
||||
0
|
||||
} else {
|
||||
let diff = a - b;
|
||||
let m = 1 << (order_hint_bits - 1);
|
||||
(diff & (m - 1)) - (diff & m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements find_latest_backward from section 7.8.
|
||||
pub fn find_latest_backward(
|
||||
shifted_order_hints: &[i32; NUM_REF_FRAMES],
|
||||
used_frame: &[bool; NUM_REF_FRAMES],
|
||||
cur_frame_hint: i32,
|
||||
latest_order_hint: &mut i32,
|
||||
) -> i32 {
|
||||
let mut _ref = -1;
|
||||
|
||||
for i in 0..NUM_REF_FRAMES {
|
||||
let hint = shifted_order_hints[i];
|
||||
if !used_frame[i] && hint >= cur_frame_hint && (_ref < 0 || hint >= *latest_order_hint) {
|
||||
_ref = i as i32;
|
||||
*latest_order_hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
_ref
|
||||
}
|
||||
|
||||
/// Implements find_earliest_backward from section 7.8.
|
||||
pub fn find_earliest_backward(
|
||||
shifted_order_hints: &[i32; NUM_REF_FRAMES],
|
||||
used_frame: &[bool; NUM_REF_FRAMES],
|
||||
cur_frame_hint: i32,
|
||||
earliest_order_hint: &mut i32,
|
||||
) -> i32 {
|
||||
let mut _ref = -1;
|
||||
|
||||
for i in 0..NUM_REF_FRAMES {
|
||||
let hint = shifted_order_hints[i];
|
||||
if !used_frame[i] && hint >= cur_frame_hint && (_ref < 0 || hint < *earliest_order_hint) {
|
||||
_ref = i as i32;
|
||||
*earliest_order_hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
_ref
|
||||
}
|
||||
|
||||
/// Implements find_latest_forward from section 7.8.
|
||||
pub fn find_latest_forward(
|
||||
shifted_order_hints: &[i32; NUM_REF_FRAMES],
|
||||
used_frame: &[bool; NUM_REF_FRAMES],
|
||||
cur_frame_hint: i32,
|
||||
latest_order_hint: &mut i32,
|
||||
) -> i32 {
|
||||
let mut _ref = -1;
|
||||
for i in 0..NUM_REF_FRAMES {
|
||||
let hint = shifted_order_hints[i];
|
||||
if !used_frame[i] && hint < cur_frame_hint && (_ref < 0 || hint >= *latest_order_hint) {
|
||||
_ref = i as i32;
|
||||
*latest_order_hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
_ref
|
||||
}
|
||||
|
||||
pub fn tile_log2(blk_size: u32, target: u32) -> u32 {
|
||||
let mut k = 0;
|
||||
|
||||
while (blk_size << k) < target {
|
||||
k += 1;
|
||||
}
|
||||
|
||||
k
|
||||
}
|
||||
|
||||
pub fn clip3(x: i32, y: i32, z: i32) -> i32 {
|
||||
if z < x {
|
||||
x
|
||||
} else if z > y {
|
||||
y
|
||||
} else {
|
||||
z
|
||||
}
|
||||
}
|
||||
|
||||
/// 5.9.29
|
||||
pub fn inverse_recenter(r: i32, v: i32) -> i32 {
|
||||
if v > 2 * r {
|
||||
v
|
||||
} else if v & 1 != 0 {
|
||||
r - ((v + 1) >> 1)
|
||||
} else {
|
||||
r + (v >> 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements Round2. See 4.7: mathematical functions.
|
||||
pub fn round2(x: u32, n: u32) -> u32 {
|
||||
(x + 2u32.pow(n - 1)) / 2u32.pow(n)
|
||||
}
|
||||
|
||||
/// Implements Round2Signed. See 4.7: mathematical functions.
|
||||
pub fn round2signed(x: i32, n: u32) -> Result<i32, String> {
|
||||
if x >= 0 {
|
||||
i32::try_from(round2(x as u32, n)).map_err(|e| e.to_string())
|
||||
} else {
|
||||
let x = x as i64;
|
||||
let val = i32::try_from(round2(-x as u32, n)).map_err(|e| e.to_string())?;
|
||||
Ok(-val)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements 7.11.3.7. Resolve divisor process
|
||||
pub fn resolve_divisor(d: i32) -> Result<(u32, i32), String> {
|
||||
let abs_d = u32::try_from(d.abs()).unwrap(); // abs cannot return a negative
|
||||
let n = floor_log2(abs_d);
|
||||
let e = abs_d - (1 << n);
|
||||
|
||||
let f = if n > DIV_LUT_BITS {
|
||||
round2(e, n - DIV_LUT_BITS)
|
||||
} else {
|
||||
e << (DIV_LUT_BITS - n)
|
||||
};
|
||||
|
||||
let div_shift = n + DIV_LUT_PREC_BITS;
|
||||
let div_factor = if d < 0 {
|
||||
-DIV_LUT[f as usize]
|
||||
} else {
|
||||
DIV_LUT[f as usize]
|
||||
};
|
||||
|
||||
Ok((div_shift, div_factor))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
// Copyright 2023 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
use crate::bitstream_utils::BitReader;
|
||||
|
||||
use crate::codec::av1::helpers;
|
||||
|
||||
use super::parser::AnnexBState;
|
||||
|
||||
pub(crate) struct Reader<'a>(pub BitReader<'a>);
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self(BitReader::new(data, false))
|
||||
}
|
||||
|
||||
/// Implements uvlc(): Variable length unsigned n-bit number appearing
|
||||
/// directly in the bitstream. See 4.10.3
|
||||
pub fn read_uvlc(&mut self) -> Result<u32, String> {
|
||||
let mut leading_zeroes = 0;
|
||||
loop {
|
||||
let done = self.0.read_bit()?;
|
||||
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
|
||||
leading_zeroes += 1;
|
||||
}
|
||||
|
||||
if leading_zeroes >= 32 {
|
||||
return Ok(u32::MAX);
|
||||
}
|
||||
|
||||
let value = self.0.read_bits::<u32>(leading_zeroes)?;
|
||||
Ok(value + (1 << leading_zeroes) - 1)
|
||||
}
|
||||
|
||||
/// Implements leb128(): Unsigned integer represented by a variable number
|
||||
/// of little-endian bytes. See 4.10.5
|
||||
pub fn read_leb128(&mut self) -> Result<u32, String> {
|
||||
let mut value = 0u64;
|
||||
|
||||
for i in 0..8 {
|
||||
let byte = u64::from(self.0.read_bits_aligned::<u32>(8)?);
|
||||
value |= (byte & 0x7f) << (i * 7);
|
||||
|
||||
if byte & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(value as u32)
|
||||
}
|
||||
|
||||
/// Implements su(n): Signed integer converted from an n bits unsigned
|
||||
/// integer in the bitstream. (The unsigned integer corresponds to the
|
||||
/// bottom n bits of the signed integer.). See 4.10.6
|
||||
pub fn read_su(&mut self, num_bits: usize) -> Result<i32, String> {
|
||||
let mut value: i32 = self
|
||||
.0
|
||||
.read_bits::<u32>(num_bits)?
|
||||
.try_into()
|
||||
.map_err(|_| String::from("Read more than 31 signed bits!"))?;
|
||||
let sign_mask = 1 << (num_bits - 1);
|
||||
|
||||
if (value & sign_mask) != 0 {
|
||||
value -= 2 * sign_mask;
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Implements ns(n): Unsigned encoded integer with maximum number of values
|
||||
/// n (i.e. output in range 0..n-1). See 4.10.7
|
||||
pub fn read_ns(&mut self, num_bits: usize) -> Result<u32, String> {
|
||||
let w = helpers::floor_log2(num_bits as u32) + 1;
|
||||
let m = (1 << w) - num_bits as u32;
|
||||
let v = self.0.read_bits::<u32>(
|
||||
usize::try_from(w).map_err(|_| String::from("Invalid num_bits"))? - 1,
|
||||
)?;
|
||||
|
||||
if v < m.into() {
|
||||
return Ok(v);
|
||||
}
|
||||
|
||||
let extra_bit = self.0.read_bit()?;
|
||||
Ok((v << 1) - u32::from(m) + u32::from(extra_bit))
|
||||
}
|
||||
|
||||
/// Implements 5.9.13: Delta quantizer syntax.
|
||||
pub fn read_delta_q(&mut self) -> Result<i32, String> {
|
||||
let delta_coded = self.0.read_bit()?;
|
||||
|
||||
if delta_coded {
|
||||
self.read_su(7)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn more_data_in_bitstream(&mut self) -> bool {
|
||||
self.0.num_bits_left() > 0
|
||||
}
|
||||
|
||||
pub(crate) fn consumed(&self, start_pos: u32) -> u32 {
|
||||
(self.0.position() / 8) as u32 - start_pos
|
||||
}
|
||||
|
||||
/// Get the length of the current OBU in AnnexB format.
|
||||
pub fn current_annexb_obu_length(
|
||||
&mut self,
|
||||
annexb_state: &mut AnnexBState,
|
||||
) -> Result<Option<usize>, String> {
|
||||
if !self.more_data_in_bitstream() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
#[allow(clippy::comparison_chain)]
|
||||
if annexb_state.temporal_unit_consumed == annexb_state.temporal_unit_size {
|
||||
annexb_state.temporal_unit_size = 0;
|
||||
} else if annexb_state.temporal_unit_consumed > annexb_state.temporal_unit_size {
|
||||
return Err(format!(
|
||||
"temporal_unit_size is {} but we consumed {} bytes",
|
||||
annexb_state.temporal_unit_size, annexb_state.temporal_unit_consumed,
|
||||
));
|
||||
}
|
||||
|
||||
if annexb_state.temporal_unit_size == 0 {
|
||||
annexb_state.temporal_unit_size = self.read_leb128()?;
|
||||
if annexb_state.temporal_unit_size == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let start_pos = self.consumed(0);
|
||||
|
||||
#[allow(clippy::comparison_chain)]
|
||||
if annexb_state.frame_unit_consumed == annexb_state.frame_unit_size {
|
||||
annexb_state.frame_unit_size = 0;
|
||||
} else if annexb_state.frame_unit_consumed > annexb_state.frame_unit_size {
|
||||
return Err(format!(
|
||||
"frame_unit_size is {} but we consumed {} bytes",
|
||||
annexb_state.frame_unit_size, annexb_state.frame_unit_consumed,
|
||||
));
|
||||
}
|
||||
|
||||
if annexb_state.frame_unit_size == 0 {
|
||||
annexb_state.frame_unit_size = self.read_leb128()?;
|
||||
if annexb_state.frame_unit_size == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
annexb_state.temporal_unit_consumed += self.consumed(start_pos);
|
||||
}
|
||||
|
||||
let start_pos = self.consumed(0);
|
||||
let obu_length = self.read_leb128()?;
|
||||
let consumed = self.consumed(start_pos);
|
||||
|
||||
annexb_state.temporal_unit_consumed += consumed;
|
||||
annexb_state.frame_unit_consumed += consumed;
|
||||
|
||||
Ok(Some(obu_length.try_into().unwrap()))
|
||||
}
|
||||
|
||||
/// Implements 5.3.4.
|
||||
pub fn read_trailing_bits(&mut self, mut num_bits: u64) -> Result<(), String> {
|
||||
let trailing_one_bit = self.0.read_bit()?;
|
||||
num_bits -= 1;
|
||||
|
||||
if !trailing_one_bit {
|
||||
return Err("bad padding: trailing_one_bit is not set".into());
|
||||
}
|
||||
|
||||
while num_bits > 0 {
|
||||
let trailing_zero_bit = self.0.read_bit()?;
|
||||
if trailing_zero_bit {
|
||||
return Err("bad padding: trailing_zero_bit is set".into());
|
||||
}
|
||||
num_bits -= 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_subexp(&mut self, num_syms: i32) -> Result<u32, String> {
|
||||
let mut i = 0;
|
||||
let mut mk = 0;
|
||||
let k = 3;
|
||||
|
||||
loop {
|
||||
let b2 = if i != 0 { k + i - 1 } else { k };
|
||||
let a = 1 << b2;
|
||||
if num_syms <= mk + 3 * a {
|
||||
let num_bits = num_syms - mk;
|
||||
let subexp_final_bits = self.read_ns(num_bits as usize)?;
|
||||
return Ok(subexp_final_bits);
|
||||
} else {
|
||||
let subexp_more_bits = self.0.read_bit()?;
|
||||
if subexp_more_bits {
|
||||
i += 1;
|
||||
mk += a;
|
||||
} else {
|
||||
let num_bits = b2 as usize;
|
||||
let subexp_bits = self.0.read_bits::<u32>(num_bits)?;
|
||||
return Ok(subexp_bits + mk as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements 5.9.27.
|
||||
pub fn decode_unsigned_subexp_with_ref(&mut self, mx: i32, r: i32) -> Result<u32, String> {
|
||||
let v = self.decode_subexp(mx)?;
|
||||
if (r << 1) <= mx {
|
||||
Ok(helpers::inverse_recenter(r, v.try_into().unwrap())
|
||||
.try_into()
|
||||
.unwrap())
|
||||
} else {
|
||||
let res = mx - 1 - helpers::inverse_recenter(mx - 1 - r, v.try_into().unwrap());
|
||||
Ok(res.try_into().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements 5.9.26.
|
||||
pub fn decode_signed_subexp_with_ref(
|
||||
&mut self,
|
||||
low: i32,
|
||||
high: i32,
|
||||
r: i32,
|
||||
) -> Result<i32, String> {
|
||||
let x = self.decode_unsigned_subexp_with_ref(high - low, r - low)?;
|
||||
Ok(i32::try_from(x).unwrap() + low)
|
||||
}
|
||||
|
||||
/// Implements 5.3.5 Byte alignment syntax
|
||||
pub fn byte_alignment(&mut self) -> Result<(), String> {
|
||||
while (self.0.position() & 7) != 0 {
|
||||
self.0.read_bit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Clone for Reader<'a> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Generates the CRCs for all .av1 files in the current directory using ffmpeg.
|
||||
|
||||
for f in `ls *.av1`; do
|
||||
ffmpeg -i $f -pix_fmt nv12 -f framehash -hash crc32 - |grep -v '^#' |awk '{print $6}' >$f.crc
|
||||
ffmpeg -i $f -pix_fmt nv12 -f framehash -hash md5 - |grep -v '^#' |awk '{print $6}' >$f.md5
|
||||
done
|
||||
BIN
Binary file not shown.
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"profile": "AV1PROFILE_PROFILE_MAIN",
|
||||
"width": 320,
|
||||
"height": 240,
|
||||
"frame_rate": 25,
|
||||
"num_frames": 250,
|
||||
"num_fragments": 250,
|
||||
"md5_checksums": [
|
||||
"83dab175e49c33a6e3ece5c3758d1bf6",
|
||||
"cedb4e25453dba430cb6ee830c1643f3",
|
||||
"ef14d142df162819eee800f3930a9e95",
|
||||
"11849ccb72cfdabc8f70e33271cd916f",
|
||||
"7c9856ed61566f399a1eda2243e199ca",
|
||||
"9394ed10354e986ecdfa2b753b921d2b",
|
||||
"573005df5ba8f982980c1f427dfb472e",
|
||||
"a96d9a913b1712f37f73b6c47b577cdf",
|
||||
"91fc52b076badd1e6cbddb9bca5c5cc3",
|
||||
"6253dde984bc5282f01005d7ebb55fe9",
|
||||
"ad88146fe374423e7c597a922049c76c",
|
||||
"ca7d1ddea7269e476b43a805bd5dd50b",
|
||||
"e3d1eeec3cbfa363a2222d274890be84",
|
||||
"fd7902a1b7352e04dc3940a010482a67",
|
||||
"cada3ee9e33c11c99f89c3f439e2723e",
|
||||
"1723ee290ae8be930f2904cb5aaf9de4",
|
||||
"bf48d536e70a6a0a6549e221a833ebe4",
|
||||
"f13b2e454420b1e0e5ab0adbbf2ce72f",
|
||||
"31c799b4bb0971b798970e23b906cd25",
|
||||
"3d925424bf645a225caf64757944a6e5",
|
||||
"ac55ba724f54dd068350fd2818064dd5",
|
||||
"a7125b8b0dc1e464e76ee64956897d22",
|
||||
"f2a00db38d83cc778fa9fbdb3f4515e3",
|
||||
"c804823f3401558a0283c4e77888af20",
|
||||
"662d71a06319faef70a0981b664481d2",
|
||||
"ad3a221a5f1f9d1a615733155a194385",
|
||||
"8b071335d6cee4b227782415b1cb8a13",
|
||||
"f3d98bfac8e5083233b88386b539b790",
|
||||
"739c73e71590e64db0629911039962b4",
|
||||
"0944990b0ada4012cc686b06833264ef",
|
||||
"77e306874ec1b0c91668f8df0953831b",
|
||||
"ca376820c5248cccb221ec8cb4b0eb9e",
|
||||
"86a72cec3aaf50e393880ac8d4139921",
|
||||
"b892ef1ff0c169b683b35cbaa9462ee3",
|
||||
"b5feafe6c294d29adb1b05138803be36",
|
||||
"3c3b150801f2dc48d300380c9a932890",
|
||||
"16b06dfb6e426a1b6d88e17de83f5e2c",
|
||||
"b1633b1c661a645bd1b04317b30fdbfa",
|
||||
"56d08d66ad8042ba295c3f86025d44cf",
|
||||
"78f6697fbb3af79dbd614f495c372031",
|
||||
"afb700399aacde4c8c895def06fd8594",
|
||||
"e7ae993d18af5c58047196d8369eee3e",
|
||||
"65d5d77181388229606972ea99f5191a",
|
||||
"bd336774b22d502d8fdca07d881d737e",
|
||||
"5cabd4479d94c040f86892fa41860ca9",
|
||||
"9ab949d5ede2f25889a9612b39f6f158",
|
||||
"bb54bb8d7782c4b2d63f201a8d338e7d",
|
||||
"9bf73fc10b4bb19021fd07586a0401d1",
|
||||
"a37621cda632ac5f58e03272d226e53b",
|
||||
"5c3ca20aa646d72b69f6e56592fd2f0b",
|
||||
"b8ff47ec622ce73e70cfc913a3f8116e",
|
||||
"1d020f74b9d3adf17b3f3da6992fcb52",
|
||||
"0236c4d4a9aa68ba59eeaeb6b010db26",
|
||||
"78a8e16aebbe5fc3d1e6051153031a51",
|
||||
"78a54c1182b8ce007a2c10d17915272d",
|
||||
"c7ac4f87168bbddd01155f54c79bc132",
|
||||
"e074be8ece629e08d6d93029b8d34ce2",
|
||||
"edfba91c624f4cdc558c693b33165542",
|
||||
"f3d005151909922bfed7a905693de7c0",
|
||||
"80118fa45af8b3e486928aae49d85b94",
|
||||
"e7663b97fc9b26348876d5fa64a54d5d",
|
||||
"69200a2a34bd4beccf4d473b4a4976f3",
|
||||
"82f6c63a1d87037c08fba3bf16ef9bf8",
|
||||
"6e3033f25eb56670a3c45c12822f340c",
|
||||
"6085ac2c582fd3048c240d3dc46e3455",
|
||||
"b4bbf1a7ae3f57b1d8f0ed65070e010b",
|
||||
"e7f0aad67af5c7719eb89e457cefe6b4",
|
||||
"6d5fdc1d68e136ce9095696be59b617b",
|
||||
"bf15759321de7b21ec3c1bd2cb98ca69",
|
||||
"fc4d67815a94c2e18e7d8b0b7d866641",
|
||||
"0d96425a800252d500fd25da7dccb94d",
|
||||
"5438c9e83cc90f030ee9b1876ec28bdc",
|
||||
"a88915fd11ad765299cef49c8e65e8ab",
|
||||
"4995a3442fa74849f3c45888037f9b42",
|
||||
"82693761e531127d2a98cdc5615cdb92",
|
||||
"07b9601bebc32dc33fd806f90cee586f",
|
||||
"d68df0d6b1dae0e4428a17717723dbe0",
|
||||
"2851422da5bcceebf57ae37a02a1fb57",
|
||||
"ae9ff5d20ea6ba6105fb5ed3e0109834",
|
||||
"a0f956663545e54acbaecf684d0841ff",
|
||||
"23589649e474e155583e6742fa79098c",
|
||||
"9031fbd94bd7a0508b48f8081018d447",
|
||||
"b72920bce007e8f0584d615529dda39f",
|
||||
"0638d4c62dbf43087b4831f2e39314fe",
|
||||
"7d34a23c9b78a61da7a60c520784e204",
|
||||
"0a3cf1af9095a7cb2787cf79ed257c59",
|
||||
"1c05ab5abe793c14ee53ea80e170d6cc",
|
||||
"02d0b03fe10b1b6675b86390fe317778",
|
||||
"7639df2f62c4f958a90337b19a591530",
|
||||
"6080ee3519bd5dd88fbb4050459c126b",
|
||||
"75ce7282839cda34f7ca6111d6353cdb",
|
||||
"2214093ad2bb512a223808ed78fe3369",
|
||||
"eb9bfad6ef08a5f28b531203db3fcb43",
|
||||
"cf4fd68dd57df907f35cc55fe5930fb8",
|
||||
"0aaf799fc8509acd1168cd667f789c9c",
|
||||
"e36ebe2332eb5a9a0ddd03469493d3cb",
|
||||
"49ec5c818b0fcc02fbe3f5636417a2da",
|
||||
"8430b81f1a90c325d116cec424d3c32c",
|
||||
"c67e1e1f7fec9f9ab688f1d54cf2f4d4",
|
||||
"fd98fabb3db5dc2d841dc50af7395130",
|
||||
"8af0c8f486057cbae86939fa248c8238",
|
||||
"e1888b1f32d2991682261f28f860cb27",
|
||||
"9c0455744df622675a6d5232d790f5b1",
|
||||
"acc73e6665f54bdaa98acead49be5f65",
|
||||
"5c6e6167e6c05411a22c348a2c807fc5",
|
||||
"89d09cb95dc2e212efed266b4b687b35",
|
||||
"9d35702693b699ac0ccb4bffc559e2e8",
|
||||
"afbeb04e4e00a1b0a4407b4a34815a63",
|
||||
"6f3515d4331d83abfd1dbdfb2de78189",
|
||||
"be6cb0da9149ad5d68269d0ed657cd38",
|
||||
"9e9f498bc71df3097caa266089a77fba",
|
||||
"40d9198beea17a254ee6b74544317a7a",
|
||||
"e2f04358338ae115635e621cf229df56",
|
||||
"5da22d2d7c5c304fc6198f809206de1d",
|
||||
"4951c1ad5ea945a098c6a4d7fad163fa",
|
||||
"5a25300d1ef28b60f9e137b47b2e4364",
|
||||
"5954dd49f459ea4c0984dba30076db89",
|
||||
"820a838b57e43271a55f2f17c3e0676f",
|
||||
"b486477fe004ed8ee213afe8ecdb35c6",
|
||||
"0715a96ada76fe2d713cdfc2b9abf11d",
|
||||
"c30544fc39df1c7876043115ceed355f",
|
||||
"1f7ce18debf0e339db20c68c1f0bf8b8",
|
||||
"e10229f1f8a95d2e59a086e5d6ac4faa",
|
||||
"8ad6c73b38d32fd1712a1fd67750d364",
|
||||
"30fdf5b06ae3fd2c0df9e631ea1e1048",
|
||||
"0026b1e7db5e25651ecaaf19685b2dc2",
|
||||
"0b53f3f2ca17d00138b4678580d63ab6",
|
||||
"cb08de2030a42826ef573082188d3614",
|
||||
"e14242c776b3a32da936d1f6484e29ba",
|
||||
"41a43d3ee9021bcda09efa1b218cc65a",
|
||||
"9b884d243e8b330edb8f569e416960f1",
|
||||
"5737c4f611cdc9871df094ba51a6a6d5",
|
||||
"9d47768c1a15c0b20d28545ad5e6ca53",
|
||||
"8c981af4e6c3432a330b6d8e330bc03a",
|
||||
"195910783821f51cb3dad54f651c1a75",
|
||||
"659833dc03dc5f929eb140e188f2b1f6",
|
||||
"f2f30eaa72c5a093d67cebfda15fac73",
|
||||
"3c521e3025340933cf826027a994114b",
|
||||
"469b72a8db2ac77c70a792c8035b0288",
|
||||
"e2cf1d7930d44341c5c363b0ceaa6c84",
|
||||
"469d4283cca04e78ac2cb9407531db67",
|
||||
"02416a6c77174796ecbd61adab5177b9",
|
||||
"f89077b3dfb54119157e04dff24b79bd",
|
||||
"2d1a8fc14b649e343288b5972abe1fdf",
|
||||
"3d059186dab49cd8d13584dc365862db",
|
||||
"765ef42f457dee16234df6992ec74cd2",
|
||||
"957589c357d82631477177e6250c713c",
|
||||
"2473af8395d08e9396216c082f854d6f",
|
||||
"6a117d16f8e8ffe87aebec05844d6a0a",
|
||||
"8e467cfcbd66ed80fca648248af56b3f",
|
||||
"71e39c4304e09688750aa85e9d040f4a",
|
||||
"3071ee85d87ef8029619d4777c799c2b",
|
||||
"cc86f09e3f228001e806fd3afe5e1271",
|
||||
"3828673cf67828240005f7bf9fe37412",
|
||||
"12700b04cc22fba7688ae1709e48b886",
|
||||
"a1045aa13b0007d23f22ad8e83c4bdb0",
|
||||
"a12e6c2d2805336c407417b7503670fe",
|
||||
"11dd1907c6785eccbedeba6a5708c516",
|
||||
"3fb8be1276b83db1ccd8206cc69ec736",
|
||||
"980ce03ae4e6f2eceb314f37290e0dd2",
|
||||
"c39395d9b6108f1bd4ab1f004c44a5f0",
|
||||
"a7244c89efd3b00611c92ebaf98793e8",
|
||||
"fbede5a2957c023216e79cc31ab32946",
|
||||
"bb40544a3adfe992e07a8eb3e5bf6966",
|
||||
"0f71f57100699e95fff51dabde86cb42",
|
||||
"6ba085efefacf385728423ca99bd4629",
|
||||
"4df2d956234cb2129ebefdd90f28185a",
|
||||
"d0a0d4263ef4f32d59b004752a5b94af",
|
||||
"a9deed8bf550f4ee370730e1d993d4dc",
|
||||
"eca65ba94930bf78854ce991c05e3d9c",
|
||||
"4eef9fa5bca7ee74e97a0ac6e12e5f53",
|
||||
"84e97d688ba1922f9ed71d72cea67259",
|
||||
"407dc974664f93be14c53a2b291cc422",
|
||||
"31eb9e589470cfbe4e72e1c441d56e3f",
|
||||
"ea4f696ad2ae150a1f0e622874f67fca",
|
||||
"7552fea18e053b4771c0490015488291",
|
||||
"6863cd478581244fff72f6a0021a9fb8",
|
||||
"9118deacdd49db27c317eeec868cd870",
|
||||
"50290c87c5ee1c558bd304fc0f4a15ea",
|
||||
"5aef6d78538151648450fd391c9deaec",
|
||||
"a7e3feb23e01e0b4556446e01b2d81eb",
|
||||
"6918645d2b0bd511247c6ed372bf213e",
|
||||
"2f9661df4d114ab9f3a98a255a8177f2",
|
||||
"baaea6f9f05a7a2bc10721f1afe6f57b",
|
||||
"366ab99c236a8e84e658bad69d82a1ba",
|
||||
"f950a6508c051ef32e4c04be56fcc719",
|
||||
"9e586c73c3b9daebc608600efbee33b0",
|
||||
"e9fb7690077e8cbac15d5564130a8d7c",
|
||||
"ef3c8d2b5376753c26a795bf403cdcc1",
|
||||
"be46eec37b67dfc8077705ce89588a7c",
|
||||
"dba1b08cfeddbcf4e8d79621ee343bd4",
|
||||
"a4a612bcb6c33c7799433c998c5f12d1",
|
||||
"5f45fa214bc860825510d034f1ff26de",
|
||||
"1766dd6fe6080c408a4baca135609f1c",
|
||||
"34dc16d50d7afeb93a874563f1f5e4fd",
|
||||
"0a5314f217972a9271c4792921f61153",
|
||||
"7c228f1f5ccc6f7307526531f79a307d",
|
||||
"188544479f5a44b6a90b6be62e33611a",
|
||||
"9cb9be5574c6a2c06862a3ed605c68b5",
|
||||
"845b033cc82472d8e647f2a3bb3eb653",
|
||||
"2c6b66c68107fd13395a1ed5f0639355",
|
||||
"0436424e24e562c52ab7e7063b04c129",
|
||||
"21248608746bb252faa347028f5343d7",
|
||||
"81607476ae05bbf7386dea9d8eac352b",
|
||||
"fadc83171e49e779ebe5d81769049cef",
|
||||
"e768c96238a8cf14caf8d03420981265",
|
||||
"c57aa4d8e2f7c9101868246de4de1c13",
|
||||
"b405a01f8eb1d4ffade0e49f593e5a8f",
|
||||
"76f02bab5b932bf4bcf4ce06bdf0e42c",
|
||||
"51b09ce7117e15cf654ad76e13263f0c",
|
||||
"45f650b845e31b87b522574cc7afde80",
|
||||
"858605e70124ef7130742b713b079ad9",
|
||||
"5d955329a007742ecab7153d2e69b262",
|
||||
"c4139450cb6ffcac62b764d62083bc31",
|
||||
"8b13de43cffa8d12dd17b8e749a375b2",
|
||||
"b000fbdb48be876302bc336aeb6deba1",
|
||||
"88e2b7abbc33f70231889113582f8f8c",
|
||||
"d9f31d4cfeee31da719b3567d5d11a19",
|
||||
"7c2a6cc9be9ce8cb96b5a1616909941b",
|
||||
"954c2be78496f0235ab137a98a5ae11a",
|
||||
"9efd13fe15e8900f67a6c76103c9ce78",
|
||||
"b04aedbee4993ff955ebba1dcb8a04e1",
|
||||
"83b045a2a43445f97d82c1faaf33b332",
|
||||
"1cf416bf7bf4a2124932d4b1bb0aca94",
|
||||
"ac17d329b587b065512269fa3def8279",
|
||||
"fcf2b7ab8b7d53741c737f75c3587a6f",
|
||||
"0aaaaca0aa165a6786e2161043aa1e72",
|
||||
"8d19a0848fad7b32a15522cc0b24b4d1",
|
||||
"be0a1be3f90ff47b80be31be021c28f1",
|
||||
"c9e423c59186d93100c56ad8f14c0fb4",
|
||||
"052dbb80693750c716acce06f3ad7266",
|
||||
"6a2c8002df49d3832b7e1abd70af542f",
|
||||
"92bec3aa91edfd2cc5dee7d1f66ac189",
|
||||
"579d29110e7a7a71152aedb9a462c79f",
|
||||
"b0bb435df9dd211dd5da3c547c8887b8",
|
||||
"fb7a3cf05afd06668287f5dcc9c20a55",
|
||||
"bbd27b9b555f361478f9516e839077a5",
|
||||
"a74dc95fe982709b25434bcc53a91973",
|
||||
"27f82418568440caea8df7bcdf56eae7",
|
||||
"d5ff54f75c8fa6d60428816945ee5e89",
|
||||
"4b6677c4cc9866f477ecda5618411c8d",
|
||||
"d56046f25113a82b090bf9a91291587c",
|
||||
"cb56aa0f4343c7da821c04e55e8af21a",
|
||||
"c633ea8a4c20ea4d70c8c241d0293e9b",
|
||||
"dd2b157ce8a010a61908170ecb3e31b0",
|
||||
"3c3509c92b03a702ac1f4a28b5abd3d5",
|
||||
"d1400e54b8b61241cabad5291d6e3a43",
|
||||
"c29470cd6afb0aee4ae4a8d0622a468d",
|
||||
"e35764f7bd48746d37478b2d6f3f2755",
|
||||
"3dcf1fd38fbcc1d45f98574b17ac5710"
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
+250
@@ -0,0 +1,250 @@
|
||||
6aea6152
|
||||
cb4a90b3
|
||||
fd83e35f
|
||||
074bd081
|
||||
216fcc04
|
||||
c73ca1e4
|
||||
fbfb2a30
|
||||
cd587935
|
||||
e8bc2912
|
||||
051517b7
|
||||
f3e9831d
|
||||
812e7bba
|
||||
3e2054e6
|
||||
7446385e
|
||||
8b75d043
|
||||
f930d9a7
|
||||
7bf6b591
|
||||
253c5389
|
||||
11a25f1f
|
||||
c5101d08
|
||||
ee1c0aae
|
||||
ce055a9f
|
||||
0ed4a046
|
||||
aa0a72c3
|
||||
87f7a598
|
||||
aa3a422e
|
||||
0ee0e533
|
||||
5ce3d683
|
||||
cc7e88e5
|
||||
3e1c7774
|
||||
bd6708d3
|
||||
0ba6a0ee
|
||||
ac00d2da
|
||||
f1222e65
|
||||
3c6aadc8
|
||||
a28e7327
|
||||
fd2d0d8c
|
||||
e0a791bf
|
||||
90a33d7e
|
||||
63ae28ce
|
||||
26230e31
|
||||
215e9021
|
||||
6ca1295b
|
||||
01604116
|
||||
15f8d3c9
|
||||
6f28a1d4
|
||||
8d54633d
|
||||
08cccd41
|
||||
0b082923
|
||||
0aaae5a1
|
||||
98310e98
|
||||
33f12578
|
||||
9fd072e0
|
||||
36a1220e
|
||||
a278c894
|
||||
828dcf3f
|
||||
3573505c
|
||||
861874b7
|
||||
dd78b1fd
|
||||
310ca9a0
|
||||
74d670f3
|
||||
93c48c50
|
||||
814991b1
|
||||
fca836a7
|
||||
9299e121
|
||||
a8d3b267
|
||||
661a1c05
|
||||
50038462
|
||||
bc866fe1
|
||||
46e2d826
|
||||
7035f78c
|
||||
00c2ed17
|
||||
6b64a0c6
|
||||
315f4761
|
||||
df4c3ef7
|
||||
7ad4996a
|
||||
3185972f
|
||||
19af5f17
|
||||
01c4e3a3
|
||||
3728bd7e
|
||||
16adc384
|
||||
5798878c
|
||||
d0ac34ee
|
||||
56f76f8a
|
||||
2b6f3f47
|
||||
4a5ec1a9
|
||||
75f0027c
|
||||
7a1dd1f6
|
||||
50c0f14f
|
||||
978d00e7
|
||||
a1c430b8
|
||||
7c67432a
|
||||
4615b4cb
|
||||
c53c7308
|
||||
876d9f98
|
||||
b0015dd1
|
||||
73288a1e
|
||||
d843f40a
|
||||
4127f309
|
||||
3adbe013
|
||||
9e84104c
|
||||
9f98680d
|
||||
380a36e2
|
||||
2202f414
|
||||
ca931e02
|
||||
182b20e1
|
||||
815a6be2
|
||||
59cc5d53
|
||||
0a05d420
|
||||
d53b5f2d
|
||||
98c613aa
|
||||
e4de4fc1
|
||||
2b8f6d29
|
||||
48ce9586
|
||||
d6e822dd
|
||||
c8674577
|
||||
75dfa0e9
|
||||
7ad6ed39
|
||||
f7725767
|
||||
a44e0f11
|
||||
bcd743cd
|
||||
1ccf1835
|
||||
8ceb6153
|
||||
0a43af09
|
||||
79bab263
|
||||
855dd65a
|
||||
a37d34fe
|
||||
be063b4d
|
||||
aec5df0c
|
||||
7b12399b
|
||||
c5ad7294
|
||||
2f609050
|
||||
8bb2dcba
|
||||
c19aa763
|
||||
700eb99b
|
||||
11bf5174
|
||||
e74d5eda
|
||||
f54e54ef
|
||||
0173e328
|
||||
162372d6
|
||||
3c8f18a3
|
||||
ce8579c7
|
||||
91bdbf86
|
||||
745edb27
|
||||
df783664
|
||||
e7462cfd
|
||||
30d8d761
|
||||
f15474e6
|
||||
4c5bb874
|
||||
27c36dd2
|
||||
c40981c0
|
||||
1cc4b7c0
|
||||
ebd96a40
|
||||
a5f4d31e
|
||||
cd1a957a
|
||||
0b0d8e1d
|
||||
c5d30b99
|
||||
d7d6b75e
|
||||
0ea512b8
|
||||
dd12fced
|
||||
29eab68d
|
||||
e4bb47d1
|
||||
e3060770
|
||||
0483e6ca
|
||||
296b084f
|
||||
d11abff6
|
||||
c7589394
|
||||
b187d62e
|
||||
2e217b3c
|
||||
b516603b
|
||||
287c8b71
|
||||
2f24a994
|
||||
7ea617d0
|
||||
7ba0024e
|
||||
7067ae3e
|
||||
95650cbd
|
||||
7b2e7deb
|
||||
d1b6da6d
|
||||
76f22a5a
|
||||
9ce6fac7
|
||||
1e5cf6c7
|
||||
bbe20b47
|
||||
2f2cc450
|
||||
3f0297f3
|
||||
a2084503
|
||||
06487daa
|
||||
5d06ae48
|
||||
02cbba1b
|
||||
b386b4f4
|
||||
47623370
|
||||
1acb6e9d
|
||||
5b55fd00
|
||||
70d43ae3
|
||||
8a0703c0
|
||||
4d62a79b
|
||||
e358a27d
|
||||
b89fcec0
|
||||
59508160
|
||||
f2c9f57d
|
||||
2b57ec09
|
||||
9427c163
|
||||
91169e76
|
||||
eda7cda1
|
||||
289c7bc0
|
||||
821dc9d5
|
||||
b003d6ce
|
||||
3d8dbc53
|
||||
d6c3324a
|
||||
76a7b9c9
|
||||
a4d4b21b
|
||||
51ce1f2c
|
||||
49cc8861
|
||||
b4fc2309
|
||||
c435862e
|
||||
3bceba0f
|
||||
7ee0c219
|
||||
cb04facd
|
||||
b2fad23b
|
||||
2b8cf4f1
|
||||
fcdcbad2
|
||||
f3c038c6
|
||||
71e639e9
|
||||
282325bf
|
||||
52c64dc8
|
||||
9ebe8705
|
||||
f8679dc3
|
||||
906116a2
|
||||
9f740960
|
||||
3cad2af8
|
||||
c7765bb4
|
||||
c8f38650
|
||||
1e5d7d36
|
||||
3374dc4c
|
||||
e83476e5
|
||||
6a9c0d00
|
||||
08b8cb58
|
||||
bd74ea9e
|
||||
399bce4c
|
||||
1577335d
|
||||
cdbbea48
|
||||
d1373c35
|
||||
cc62212a
|
||||
8a5e59e2
|
||||
75f5f297
|
||||
8c5f3776
|
||||
fce09105
|
||||
92b2d17e
|
||||
416e8530
|
||||
968d1450
|
||||
d20688a8
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
ce5386b1beaa89f73a668d3720717fb4
|
||||
8f5d21bf3d78d9c242609bdd8df6cfd6
|
||||
7d75a95c82171c8cf4b09d694d4cbef9
|
||||
20d4bebdc51702ee7f40e187747696c7
|
||||
9c55dc575db2a189c5576537afca2c54
|
||||
68a15a8cdbc0aeaf10dff65db4d1a810
|
||||
dfd03584b10852eb16901d52578d8f8e
|
||||
a9a0cffed57bba344c9366ce91408e26
|
||||
9fdf7ac0fc07f2391da56462faf3e2e0
|
||||
b83d2da8f82e1a06d26bbb514b471f4b
|
||||
17c92a58fb68d0b3b02cb5b9105dbb37
|
||||
1e5bd3c05fb67f2aefc01611ed43d117
|
||||
eb287667a027f3e5325bfb27652021a6
|
||||
67e388816af98b99e4b9134516205c52
|
||||
35cba13cd8a6f23d6dfd95803af0886b
|
||||
45966e4a1b83dce6a2b0c259fea10cae
|
||||
416a95236e46a7bb0f11cf1147631979
|
||||
f23ab59d3c4efbb8eb2d80d67a2e5136
|
||||
ab7bce374a3468c03483418ccffb52de
|
||||
bdaaf22c6c4e58d393d7c051d4d0ae8a
|
||||
6f7de2de3e8d79c726f0f2c2fe52ee58
|
||||
d2fc7067797bbffe3765a238ff803b85
|
||||
769dbc781b43759287da0b2888e8338b
|
||||
2abd608af57978290e24c4cd62e4904f
|
||||
4fb54d7b0a71a4d3ca95b56d7b3d9a81
|
||||
262ba91321a0edac67c01e6ddcc2e4f7
|
||||
4c9c2d967fb853ecdc6042f223a7d138
|
||||
f21ccd6a4eb5f561b0a0eff9cb1956ec
|
||||
3c206ed277864249e7eaade75e71c815
|
||||
7167fed00621472662b19a0215dd00c8
|
||||
76e6f142e710ff5d7f6797d3fc1b8dfd
|
||||
4d814bb1fec0a2bca9d92140d5734ae0
|
||||
65b576bf469b182c8954591fd15dd21c
|
||||
abb7e8f11617aa0c9bf6870e537b008d
|
||||
02b86ea6b2f8699097d6899ab1f073c4
|
||||
610786c84b9fc118679afb09404c15ae
|
||||
7feb8fae65767f848ebbefa730f15d68
|
||||
12677768a4266571e85b750b51ef9fc8
|
||||
5b6541b178f6a885d0439b381e501967
|
||||
352cc90ecc1f3e26c19970f106c475fa
|
||||
9c5215a9fce00f0ec30615649171f8e4
|
||||
27e593a4b30c0b64939c62e5e0488e5a
|
||||
d8a5fd54958527ecefc642f6730e4164
|
||||
754dfeb4337108e534ba70bab429777a
|
||||
821ad11c37ffda3ad5438b57a9523e9f
|
||||
add12a889a5e08a5358e2e0e086fe522
|
||||
fa100278f49cbb7f9c9d7dc5e12fc0e1
|
||||
ef46a151df454084d0f0bc644adeb47d
|
||||
c02f8023a4be8a3249bcf6e1b223dffa
|
||||
41a11da85806e85fa86a9de41e1cd93f
|
||||
e792d89b5df357d1b03b1812a762832c
|
||||
90f72aead8124bc6ce51495b4b99da75
|
||||
af25c00b1990b778b50f529ae81efa30
|
||||
900f66f0e222a8d253153711a23a5ee9
|
||||
0850cad30d59e1b5e5e2943e6b916d0a
|
||||
03ca961e9876ee96b14711c44a9f2a69
|
||||
fa6bf9c30dd8618fdab499e9dd217582
|
||||
4e09e08e1f99bf210d4757281ab4c836
|
||||
dbf2bc5355a6846a028a7b3140fdef34
|
||||
b38e1973cabab58f8137cf66fc7fa3aa
|
||||
0ee4998cda155ca2823f5c9336a62cc2
|
||||
4f9918b4782a59a0361619389bd22d54
|
||||
c5c82a59505a9888b95e012a068b3254
|
||||
5eabc71dca6f57407ba306db416c5031
|
||||
41b8b1155bf6144aeb822acbff051e1a
|
||||
12e7d40ccd4298400cf3b5577370bff8
|
||||
2f22b7979183c9c3b26838c7e84a49a8
|
||||
68af2e17a487001d9374f6b38ed950af
|
||||
3a55bc482844ecc366e10544c38cc890
|
||||
6a08d19bbecb554837c9922b9818455a
|
||||
b2f95f48c1214a5dfb8f4528afcbf901
|
||||
c3c4fc391a1a040c71cb65a8c654a5b1
|
||||
86786c55d138bb9a4a4165522cd15253
|
||||
b329ece66a9dab6bebab9e3687629e7c
|
||||
7d6d818e7fe9a9b335c72ee6aedf9980
|
||||
317956c14cb119eda584de03dbf089cc
|
||||
d095660e8dd16899be9e916e0ff7946c
|
||||
12571880bd56bed1bbbd034094fabbaa
|
||||
7f702375e8c43036e36ddc07b543afb2
|
||||
e254d4c9854b28db694fb5477144551e
|
||||
24a4b250bc0461ad4894334838fbb645
|
||||
16b3dca0bfcad463498a62bf8a3f06a8
|
||||
77dbe27a373d87a6fc1012caa166a0a5
|
||||
1b304ea04baf90e1bf11d6296f64f933
|
||||
42c94efb8ab8ceb6ff98497ef0981c66
|
||||
1074398b6c210e833b4135c7d2caeb91
|
||||
3bce8712a7ba7b037a2cc85e4f72aea4
|
||||
42c1bc84350e82cbead374b41732d8d5
|
||||
76f8fc8e26d1b64e8afb8f263ec3f795
|
||||
ef9f8b042eb888f5408ccb500c9a157f
|
||||
d46505a331887bf49e157f8de4c17a98
|
||||
0be7df49394337bb277b63a6b9147520
|
||||
deb977415048b0b7fa1fd02878c1b10e
|
||||
a0395e32b4cc279f8545b791cee5d394
|
||||
c7dcd8741840799886a1f3b5208c0b36
|
||||
02b1297f58b9e699f232b9ed7e8bbcd0
|
||||
48852b209e405d7aaab50c5810448579
|
||||
d8a5598b2e7183d72cd3a81e54f301ce
|
||||
dfe2e4af7518f0d1f04fd825069d575b
|
||||
e1a70679c95173e6a1504b7c52dd0d3c
|
||||
16ae515dfb1268052bfe330a1cbbbffa
|
||||
f17c090f2140cfd3060558ceefe4d1bd
|
||||
58b1ba1fbbd76670c9218a846a8f6baf
|
||||
f543d8380c2d095f9b62d4ffaaee7467
|
||||
1b16d3e1d688a5a905ff22680ad803cb
|
||||
12e16d58422d5a5899379f11c6ca5b9a
|
||||
f1986c3e4a4545356656f76c27db0ae7
|
||||
324a3d45f631aed6c758888254ae07ad
|
||||
49532c06798f685e2a41dc683b081f79
|
||||
95934c5a6e2cc8de08c8b8342936cf38
|
||||
67d89790392f18908c24cc36bbe50362
|
||||
c4f963cd490f9241f4959cca6a6d35ce
|
||||
b21db693ba10898ec3205cb0cb343a02
|
||||
72e63227e74f2473a44da365fa9bc02f
|
||||
220d247b6c0f9512256dfcdf95582148
|
||||
86c290279ffa61ca615f1f8f8877e807
|
||||
6651d56c548fd4c5e8b2c86354c28ca3
|
||||
d863321ec53cd582d34a8ee5e88a5576
|
||||
f262bda775cf67026d8d226620c9d4aa
|
||||
15816d939647890021ac31aa1c3b2f75
|
||||
229b295a4bc44d8522580dd4383812d0
|
||||
833afe87641e34e60f2d127f44a7cbeb
|
||||
62cc76ec3a853878ebfff04ebbc8addc
|
||||
40fca93e2d6a976de641cdbfb56257b5
|
||||
65d654e9145527f8fcd24608d64f68f1
|
||||
1b2b0c68b6d9305f46642109b92a234b
|
||||
85aa859b29f2caeb60343cf53718e451
|
||||
3aa590c6c8a4dee128f1e7fcb2b82a2a
|
||||
699cb26687934746b722e2dfbc1d2c82
|
||||
9cc9b7f51cbdbaf15bc8eec9c1a1cf7c
|
||||
b1be900fe6a6eb21a563754420c5f38e
|
||||
47976686aff65b42661ae0fb2685f462
|
||||
6b3f96a1be97c4c2a371d5935672096a
|
||||
ac9b60c754c95ccbaf56f68544712dd6
|
||||
7dab91b68788d2ed61cd40d3073091a8
|
||||
35b367c591e71f11ddcd9dcc75be79e7
|
||||
d3321d793265630e81e6d52b20e58f44
|
||||
9d97047182efd2ba0594a569f6f2ce04
|
||||
14c772a342a28bd6785bb722e67fc767
|
||||
288802e107c373c9d28eeb3f8726214e
|
||||
1c9d8ff04c346a59ea4f3a349dc4b891
|
||||
2c857a65df4619d06f424ddcccbd2221
|
||||
10a0b3e4e7825d21fc22bcff34a3b26c
|
||||
78c8a5ee6e6880a3af222bfc27de62a1
|
||||
9e16c4346a69ff9127bb6c9287d98859
|
||||
1f1d95fa30c9c3a58f41d9657aa0e6ec
|
||||
5e8c6b3b4690da7f4175abe288d0c509
|
||||
0bf315da2ec1e135963cc4c9cf73ea1a
|
||||
2992fdf295438ec9f8dc7979b6d4e5ff
|
||||
a3d43afab0119aa3b6e38a81d244c388
|
||||
889d049dc64457637ad089e18e718a23
|
||||
46016c611b9323fe8f4206bd393a6741
|
||||
f555b79be75ba2a1a741f467192b693e
|
||||
242d4b4c323c7c1e9648de52ec8d7c2c
|
||||
383d2ee4d28919f193f28448ba28ddc6
|
||||
2c7cd221bd1025547fc654ca6717553d
|
||||
e16de72e05b548758832102ffb39d242
|
||||
5cecf3fc2823225f0f1748a1fedadf35
|
||||
4e709cb904317a43ffd94739281d32bc
|
||||
b6dfd4657668ed2c1ebaff9fa92702ab
|
||||
3c618cd95706cc25a0565fc0ed00e352
|
||||
dca8dbc91708a94525f16c064454a2ce
|
||||
fe3fbff45e30dcc3fa4f57ae78114f7f
|
||||
0f7bc1eab5cf9faeaa127c0243675253
|
||||
19b6fca6e3f0d039074753265436d2c5
|
||||
fce950fc2ec2526c0b29b6869ff58dee
|
||||
4c9c0b19668dc20a9fac84e13c590deb
|
||||
8fe06eb38ee870ffd7cb9f065476ac78
|
||||
515ce9eaa9a6970f809475e416325123
|
||||
6d58868e827dd9dca698c5b24380fea7
|
||||
072951a0e1d06aa829d716a86ae498fe
|
||||
07f23add152c44f754eac930093e6b59
|
||||
e98798eb29c162ddfff7ec72aa7b65dc
|
||||
62f4e1ca0294a3944224fb4a4280ed56
|
||||
28fc910d3b7a926f63e7bfaf18878cc1
|
||||
40065fe5c0e32a16622988bc1485a038
|
||||
b2623ec75309e28c6c0a4b179e4acd6d
|
||||
3ea6b991014bde2185be7a6c92745c28
|
||||
5fde25c7fe79a9c0e2b86eb1878de02c
|
||||
5bf42dc3006f4c601d01ee4820e44d38
|
||||
44500b37f18b2eb499894bc1ff1efdcc
|
||||
f5bfe3352c4281083083e51ef1fe0a72
|
||||
abf9c6ba639f68d9b8f8c0d9d97a19d3
|
||||
75f6dc9ccbc6a1381785bf9440a95744
|
||||
4db0fd27cac20f59718fe00c5dc13202
|
||||
9e0d611017698f2d1716f8d9d81a8bf7
|
||||
13c7015f10b37b3dbe1618cde5edc2ee
|
||||
70e2a0182471290a46961a0d62611053
|
||||
47ba942fc3396104b3731ea40eb97339
|
||||
bf10238458969233c0062b09a68f22a7
|
||||
115b7375a9aa156b8203dd88acdc5672
|
||||
1b39aa22702c993aa1e7606e9afad9c5
|
||||
c85e67c08e02538f151c6276662ba69b
|
||||
89dcf41c5adb944bcd46ca3f67666a75
|
||||
90fd81f9b572ede873313f4faa58da4b
|
||||
00badd56e7d5b2dddb10ef7de05be515
|
||||
a13c3404ac033c9fce8a6435f2a4a416
|
||||
af83e8f7af9785c96ff61c354417aab4
|
||||
5176b27945cc7686520b1425144dc4d3
|
||||
e5f248be2c0e83ad0a6bfd2eff759f6e
|
||||
cd693cce87898dafa5c68dbaeeb151fa
|
||||
2a47245fc3da1229e3dc288f216c4e20
|
||||
c6f8e88fbf5566000f8f86b0d4bf9020
|
||||
3e88c5f7b571a292c998cac33b41215b
|
||||
2a2e8271ecba2cec040ef343317e2ae9
|
||||
8e693282602bde3452061affde3ce514
|
||||
992acab07cf4d6aa5b95b4c4cc33cf0c
|
||||
6db1ad3494be9c3c9758b00d956bd2cb
|
||||
824d130a16d7bc2c6d6b26c31c2a0e05
|
||||
d286bd8f5cdfd65b695815ff68bb191f
|
||||
4c5bbeecda37bf4fb6dd79f266aac3d6
|
||||
c4f64e991e13dfc3913246f730c31e87
|
||||
459d8fd53975479aa977c61dc3ef1b9e
|
||||
ece59558539b52684433b54bf2c7e214
|
||||
4362122d7ed40dd5ad0886cdf901539e
|
||||
66ffe4fb31a5e4fa9462a3aa57224476
|
||||
86586a04e75ebde21903f407e6037e50
|
||||
93b2e3af91cb24ffafa6418c76cc1ce7
|
||||
41ca4598aac764fa944d8f578b2ab5ab
|
||||
6ef7ce05836d63520925817c220a5274
|
||||
c4e902503f3d5602ba2454907d7809f7
|
||||
8f3cb447682bebd3a230740e11d35dff
|
||||
4340ec4c3e62118b2749264393fc78ee
|
||||
8178fbeab83126f2111ab74c23d241c9
|
||||
db298bcb2a999b7ac2037991ef078d32
|
||||
f586895d6dca606e2d62dfdc92cc2021
|
||||
b5aaf4b91dd7b6cda3a2635c603c4500
|
||||
0f024b59afa957b59f5ebb9b63ff1b1a
|
||||
41acc378b08b5d9b7da2f644b79aebdf
|
||||
cd173062829e6e86eb094b8d40d16579
|
||||
ef55ddf0fa94dcb4f275033bb55e8466
|
||||
48a3efa1f9d8291d99ce434fc5474fb6
|
||||
731a649c764fa964d6e2ce640b34c25a
|
||||
5f9ac7b1dca56c8dab9ce1f92341094b
|
||||
e9b0ee7355299aaf55cce1e240050125
|
||||
8a285fc7c728cdfa1f4b651c64eab7e5
|
||||
05823b86e463ad514a4cd7eacd9c01bb
|
||||
0ce34318119eb0e8921b5e798789bea0
|
||||
e46f4c793b42136b1eefbece7b867052
|
||||
1331c7fcae627f290299b978299a9b48
|
||||
285c5783029a7b23374e91cce19df0de
|
||||
670fba4fad181308c15778f38e619646
|
||||
46a3350a39c21f45bafed75d339ed101
|
||||
b1d6f3230e58d2647d1aa4a5446061ed
|
||||
f6eb5936ec9bb3212b14716da8329839
|
||||
1ecb00343900a550ad33ba10777df23d
|
||||
4b9f70244adda8e0a42f4da7341b8d84
|
||||
6caafeb26793a011ebef352ef1d1aa34
|
||||
723ab0797281e7ee36109b53140a324d
|
||||
7a5e9be8c7c8307aa22c8b48708424a2
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2024 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::bitstream_utils::BitWriter;
|
||||
use crate::bitstream_utils::BitWriterError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ObuWriterError {
|
||||
BitWriterError(BitWriterError),
|
||||
UnalignedLeb128,
|
||||
}
|
||||
|
||||
impl fmt::Display for ObuWriterError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ObuWriterError::BitWriterError(x) => write!(f, "{}", x.to_string()),
|
||||
ObuWriterError::UnalignedLeb128 => {
|
||||
write!(f, "attempted to write leb128 on unaligned position")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BitWriterError> for ObuWriterError {
|
||||
fn from(err: BitWriterError) -> Self {
|
||||
ObuWriterError::BitWriterError(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ObuWriterResult<T> = std::result::Result<T, ObuWriterError>;
|
||||
|
||||
pub struct ObuWriter<W: Write>(BitWriter<W>);
|
||||
|
||||
impl<W: Write> ObuWriter<W> {
|
||||
pub fn new(writer: W) -> Self {
|
||||
Self(BitWriter::new(writer))
|
||||
}
|
||||
|
||||
/// Writes fixed bit size integer. Corresponds to `f(n)` in AV1 spec defined in 4.10.2.
|
||||
pub fn write_f<T: Into<u32>>(&mut self, bits: usize, value: T) -> ObuWriterResult<usize> {
|
||||
self.0
|
||||
.write_f(bits, value)
|
||||
.map_err(ObuWriterError::BitWriterError)
|
||||
}
|
||||
|
||||
/// Writes variable length unsigned n-bit number. Corresponds to `uvlc()` in AV1 spec
|
||||
/// defined in 4.10.3.
|
||||
pub fn write_uvlc<T: Into<u32>>(&mut self, value: T) -> ObuWriterResult<usize> {
|
||||
let value: u32 = value.into();
|
||||
if value == u32::MAX {
|
||||
return self.write_f(32, 0u32);
|
||||
}
|
||||
|
||||
let value = value + 1;
|
||||
let leading_zeros = (32 - value.leading_zeros()) as usize;
|
||||
|
||||
Ok(self.write_f(leading_zeros - 1, 0u32)? + self.write_f(leading_zeros, value)?)
|
||||
}
|
||||
|
||||
/// Writes unsigned little-endian n-byte integer. Corresponds to `le(n)` in AV1 spec
|
||||
/// defined in 4.10.4.
|
||||
pub fn write_le<T: Into<u32>>(&mut self, n: usize, value: T) -> ObuWriterResult<usize> {
|
||||
let value: u32 = value.into();
|
||||
let mut value = value.to_le();
|
||||
|
||||
for _ in 0..n {
|
||||
self.write_f(4, value & 0xff)?;
|
||||
value >>= 8;
|
||||
}
|
||||
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Writes unsigned integer represented by a variable number of little-endian bytes.
|
||||
/// Corresponds to `leb128()` in AV1 spec defined in 4.10.4.
|
||||
///
|
||||
/// Note: Despite the name, the AV1 4.10.4 limits the value to [`u32::MAX`] = (1 << 32) - 1.
|
||||
pub fn write_leb128<T: Into<u32>>(
|
||||
&mut self,
|
||||
value: T,
|
||||
min_bytes: usize,
|
||||
) -> ObuWriterResult<usize> {
|
||||
if !self.aligned() {
|
||||
return Err(ObuWriterError::UnalignedLeb128);
|
||||
}
|
||||
|
||||
let value: u32 = value.into();
|
||||
let mut value: u32 = value.to_le();
|
||||
let mut bytes = 0;
|
||||
|
||||
for _ in 0..8 {
|
||||
bytes += 1;
|
||||
|
||||
if value >= 0x7f || bytes < min_bytes {
|
||||
self.write_f(8, 0x80 | (value & 0x7f))?;
|
||||
value >>= 7;
|
||||
} else {
|
||||
self.write_f(8, value & 0x7f)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(value < 0x7f);
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn write_su<T: Into<i32>>(&mut self, bits: usize, value: T) -> ObuWriterResult<usize> {
|
||||
let mut value: i32 = value.into();
|
||||
if value < 0 {
|
||||
value += 1 << bits;
|
||||
}
|
||||
|
||||
assert!(value >= 0);
|
||||
self.write_f(bits, value.unsigned_abs())
|
||||
}
|
||||
|
||||
pub fn aligned(&self) -> bool {
|
||||
!self.0.has_data_pending()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::codec::av1::reader::Reader;
|
||||
|
||||
const TEST_VECTOR: &[u32] = &[
|
||||
// some random test values
|
||||
u32::MAX,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
10,
|
||||
20,
|
||||
7312,
|
||||
8832,
|
||||
10123,
|
||||
47457,
|
||||
21390213,
|
||||
u32::MIN,
|
||||
u32::MAX - 1,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_uvlc() {
|
||||
for &value in TEST_VECTOR {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
|
||||
ObuWriter::new(&mut buf).write_uvlc(value).unwrap();
|
||||
|
||||
if value == u32::MAX {
|
||||
// force stop uvlc
|
||||
buf.push(0x80);
|
||||
}
|
||||
|
||||
let read = Reader::new(&buf).read_uvlc().unwrap();
|
||||
|
||||
assert_eq!(read, value, "failed testing {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leb128() {
|
||||
for &value in TEST_VECTOR {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
|
||||
ObuWriter::new(&mut buf).write_leb128(value, 0).unwrap();
|
||||
let read = Reader::new(&buf).read_leb128().unwrap();
|
||||
|
||||
assert_eq!(read, value, "failed testing {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_su() {
|
||||
let vector = TEST_VECTOR
|
||||
.iter()
|
||||
.map(|e| *e as i32)
|
||||
.chain(TEST_VECTOR.iter().map(|e| -(*e as i32)));
|
||||
|
||||
for value in vector {
|
||||
let bits = 32 - value.abs().leading_zeros() as usize + 1; // For sign
|
||||
if bits >= 32 {
|
||||
// Skip too big nubmers
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut buf = Vec::<u8>::new();
|
||||
|
||||
ObuWriter::new(&mut buf).write_su(bits, value).unwrap();
|
||||
|
||||
let read = Reader::new(&buf).read_su(bits as usize).unwrap();
|
||||
|
||||
assert_eq!(read, value, "failed testing {}", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2023 The ChromiumOS Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
pub mod dpb;
|
||||
pub mod nalu;
|
||||
pub mod nalu_writer;
|
||||
pub mod parser;
|
||||
pub mod picture;
|
||||
pub mod synthesizer;
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user