feat(mgmt/web): surface the host.env encoder pin so a conflicting GPU choice isn't invisible

listGpus gains encoder_pin (PUNKTFUNK_ENCODER when it actually pins something —
unset/auto stay null), and the console's GPU card shows it: a muted note when
the pin is compatible, an amber warning naming the pinned vendor and the next
session's GPU when it contradicts the selection (the host overrides such a pin
at session open — without this field the selection just looked broken). Docs
updated to say the adapter wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 16:53:19 +02:00
co-authored by Claude Fable 5
parent 751d1de506
commit 6c2c8b6eab
8 changed files with 97 additions and 3 deletions
+8 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.18.0"
"version": "0.19.2"
},
"paths": {
"/api/v1/clients": {
@@ -4506,6 +4506,13 @@
}
]
},
"encoder_pin": {
"type": [
"string",
"null"
],
"description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken."
},
"env_override": {
"type": [
"string",
+30
View File
@@ -66,6 +66,11 @@ pub(crate) struct GpuState {
/// `PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is
/// `auto`; a manual preference overrides it.
env_override: Option<String>,
/// `PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`
/// (e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected
/// GPU is overridden at session open — the adapter wins — so the console can warn that the
/// pin is stale rather than letting the selection look broken.
encoder_pin: Option<String>,
/// The GPU the next session will use.
selected: Option<ApiSelectedGpu>,
/// The GPU live sessions use right now (absent while nothing is streaming).
@@ -143,11 +148,36 @@ pub(crate) fn gpu_state() -> GpuState {
.render_adapter
.clone()
.filter(|s| !s.is_empty()),
encoder_pin: encoder_pin_of(&pf_host_config::config().encoder_pref),
selected,
active,
}
}
/// The `PUNKTFUNK_ENCODER` value worth surfacing to the console: `None` for unset/empty and for
/// an explicit `auto` (both mean "derive from the selected adapter" — nothing is pinned).
fn encoder_pin_of(pref: &str) -> Option<String> {
match pref {
"" | "auto" => None,
pin => Some(pin.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Only a real pin surfaces to the console — the `auto` spellings pin nothing, and showing
/// them would put a permanent scary note under every default install's GPU card.
#[test]
fn encoder_pin_surfaces_only_real_pins() {
assert_eq!(encoder_pin_of(""), None);
assert_eq!(encoder_pin_of("auto"), None);
assert_eq!(encoder_pin_of("qsv"), Some("qsv".into()));
assert_eq!(encoder_pin_of("software"), Some("software".into()));
}
}
/// GPU inventory and selection
///
/// Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session
+7
View File
@@ -1234,6 +1234,13 @@ async fn gpu_endpoints_list_and_validate() {
assert_eq!(s, StatusCode::OK);
assert!(b["gpus"].is_array());
assert!(b["mode"].is_string());
// The host.env encoder pin is part of the schema (null when nothing is pinned) — the
// console warns off it when a pin contradicts the selected GPU (the pin is overridden at
// session open, and without this field the selection would just look broken).
assert!(
b.as_object().unwrap().contains_key("encoder_pin"),
"listGpus must carry encoder_pin"
);
// Unknown mode → 400.
let (s, _) = send(
+1 -1
View File
@@ -38,7 +38,7 @@ redundant or stale.
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). On a multi-GPU Windows box a forced hardware backend whose vendor contradicts the selected GPU (web-console preference) is **overridden** — the adapter wins and the host logs a warning; remove the stale pin. |
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
Resolution and refresh are **not** set here — **the client chooses them.** When a device connects,
+3 -1
View File
@@ -85,7 +85,9 @@ and enter the PIN on your [client](/docs/clients). The host's own management API
The service reads `%ProgramData%\punktfunk\host.env`. The defaults work out of the box; common knobs:
- `PUNKTFUNK_ENCODER=auto``auto` picks NVENC/AMF/QSV by GPU vendor. Force one with `nvenc`, `amf`,
`qsv`, or `sw` (software).
`qsv`, or `sw` (software). On a multi-GPU box the [web console's GPU preference](/docs/web-console)
wins: a forced backend whose vendor doesn't match the selected GPU is ignored (the host logs a
warning) — remove the stale line rather than fighting it.
- `PUNKTFUNK_HOST_CMD` — the service runs `serve --gamestream` by default (native punktfunk/1 **plus**
the GameStream/Moonlight-compat planes). Set it to `serve` for a **secure native-only** host with no
GameStream surface (GameStream pairs over plain HTTP and uses weaker legacy encryption — trusted LAN
+2
View File
@@ -56,6 +56,8 @@
"gpu_none": "Keine GPUs erkannt.",
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
"host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
+2
View File
@@ -56,6 +56,8 @@
"gpu_none": "No GPUs detected.",
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
"host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
+44
View File
@@ -38,6 +38,49 @@ export const GpuSection: FC = () => {
const fmtVram = (mb: number) =>
mb >= 1024 ? `${Math.round(mb / 1024)} GiB` : `${mb} MiB`;
/**
* The vendor an explicit `PUNKTFUNK_ENCODER` pin can open on (display name) the console mirror
* of the host's backendvendor table. Vendor-agnostic pins (software) and unknown/multi-vendor
* spellings (vaapi, vulkan, pyrowave) map to nothing: no conflict to warn about.
*/
const encoderPinVendor: Record<string, string> = {
nvenc: "NVIDIA",
nvidia: "NVIDIA",
cuda: "NVIDIA",
hw: "NVIDIA",
amf: "AMD",
amd: "AMD",
qsv: "Intel",
intel: "Intel",
};
/**
* The host.env encoder pin, surfaced so a conflicting GPU choice doesn't just look broken: amber
* when the pin's vendor contradicts the next session's GPU (the host overrides the pin at session
* open the stale pin should be removed), a muted note otherwise.
*/
const EncoderPinNote: FC<{ state: GpuState; pin: string }> = ({
state,
pin,
}) => {
const vendor = encoderPinVendor[pin];
const conflicting =
vendor && state.selected && state.selected.vendor !== vendor.toLowerCase();
return conflicting && state.selected ? (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.gpu_encoder_pin_warning({
value: pin,
vendor,
name: state.selected.name,
})}
</p>
) : (
<p className="text-xs text-muted-foreground">
{m.gpu_encoder_pin_note({ value: pin })}
</p>
);
};
/**
* GPU list in the compositors-card style: per-GPU badges for the manual pick ("Preferred"), what
* the next session will use ("Next session"), and what live sessions encode on right now
@@ -139,6 +182,7 @@ export const GpuCard: FC<{
{m.gpu_env_note({ value: s.env_override })}
</p>
)}
{s?.encoder_pin && <EncoderPinNote state={s} pin={s.encoder_pin} />}
</QueryState>
</CardContent>
</Card>