diff --git a/api/openapi.json b/api/openapi.json
index 5f884a8f..cc07406e 100644
--- a/api/openapi.json
+++ b/api/openapi.json
@@ -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",
diff --git a/crates/punktfunk-host/src/mgmt/gpu.rs b/crates/punktfunk-host/src/mgmt/gpu.rs
index fdeff98e..6f2a237b 100644
--- a/crates/punktfunk-host/src/mgmt/gpu.rs
+++ b/crates/punktfunk-host/src/mgmt/gpu.rs
@@ -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,
+ /// `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,
/// The GPU the next session will use.
selected: Option,
/// 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 {
+ 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
diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs
index 22575c64..1100c6b8 100644
--- a/crates/punktfunk-host/src/mgmt/tests.rs
+++ b/crates/punktfunk-host/src/mgmt/tests.rs
@@ -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(
diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md
index 2f5118ee..47916e23 100644
--- a/docs-site/content/docs/configuration.md
+++ b/docs-site/content/docs/configuration.md
@@ -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,
diff --git a/docs-site/content/docs/windows-host.md b/docs-site/content/docs/windows-host.md
index 4ad9fd74..bb40e861 100644
--- a/docs-site/content/docs/windows-host.md
+++ b/docs-site/content/docs/windows-host.md
@@ -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
diff --git a/web/messages/de.json b/web/messages/de.json
index 975b24af..248b092b 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -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",
diff --git a/web/messages/en.json b/web/messages/en.json
index 4a07ddba..18629e52 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -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",
diff --git a/web/src/sections/Host/GpuCard.tsx b/web/src/sections/Host/GpuCard.tsx
index 0bec8d75..fffe7ad3 100644
--- a/web/src/sections/Host/GpuCard.tsx
+++ b/web/src/sections/Host/GpuCard.tsx
@@ -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 backend→vendor table. Vendor-agnostic pins (software) and unknown/multi-vendor
+ * spellings (vaapi, vulkan, pyrowave) map to nothing: no conflict to warn about.
+ */
+const encoderPinVendor: Record = {
+ 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 ? (
+
+ {m.gpu_encoder_pin_warning({
+ value: pin,
+ vendor,
+ name: state.selected.name,
+ })}
+
+ ) : (
+
+ {m.gpu_encoder_pin_note({ value: pin })}
+
+ );
+};
+
/**
* 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 })}
)}
+ {s?.encoder_pin && }