feat(host,web): multi-GPU selection — GPU inventory + preference API, web-console GPU card
apple / swift (push) Successful in 1m9s
ci / rust (push) Successful in 1m50s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 57s
decky / build-publish (push) Successful in 11s
android / android (push) Successful in 3m13s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 3m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
windows-host / package (push) Successful in 7m35s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m53s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m58s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m54s
docker / deploy-docs (push) Successful in 18s

- new crate::gpu (compiled on all platforms so the OpenAPI doc stays
  platform-independent): DXGI / sysfs GPU inventory with reboot-stable ids
  (PCI vendor:device + occurrence — LUIDs are per-boot), persisted auto/manual
  preference (<config>/gpu-settings.json, atomic temp+rename with in-memory
  rollback), one selection with precedence console preference >
  PUNKTFUNK_RENDER_ADAPTER > max VRAM and graceful fallback when the preferred
  GPU is absent, plus a live "in use" record (RAII session guard wrapped around
  every encoder open_video returns)
- fix: windows_gpu_vendor derived the encoder backend from DXGI adapter 0
  instead of the selected render adapter — on a hybrid box (e.g. Intel iGPU at
  index 0 + NVIDIA dGPU) the backend could disagree with the GPU the capture
  ring / IddCx render pin sit on. The NVENC 4:4:4 probe now also runs on the
  selected adapter (was: OS default), the codec/4:4:4 probe caches are keyed
  per selected GPU (were process-lifetime OnceLocks), and an explicit
  PUNKTFUNK_ENCODER conflicting with the selected GPU's vendor warns up front
- mgmt API: GET /api/v1/gpus (inventory + mode + preferred + next-session
  selection with reason + in-use GPU/backend/session-count) and
  PUT /api/v1/gpus/preference (validates mode/gpu_id before writing);
  openapi.json regenerated; the vdisplay render pin now also engages for a
  console preference (not just the env pin)
- web console: GPU card on the Host page — list with vendor + VRAM,
  Automatic / Prefer controls, Preferred / Next session / "In use · backend"
  badges, missing-preferred-GPU warning and env-pin note; en + de messages
- Linux: a matched manual preference picks the VAAPI render node and the
  NVENC-vs-VAAPI auto choice; auto mode is exactly the previous behavior

Validated live on the hybrid laptop (RTX 3500 Ada + Intel Arc Pro, which
enumerates twice — the occurrence ids disambiguate): enumerate, prefer,
bad-id 400, restart persistence, auto-restore keeping the stored pick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 13:57:18 +02:00
parent 40fefd73ca
commit 019f2677a7
17 changed files with 1881 additions and 200 deletions
+10
View File
@@ -37,6 +37,16 @@
"compositor_available": "Verfügbar",
"compositor_unavailable": "Nicht verfügbar",
"compositor_default": "Standard",
"host_gpus": "GPUs",
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
"gpu_automatic": "Automatisch",
"gpu_prefer": "Bevorzugen",
"gpu_preferred": "Bevorzugt",
"gpu_in_use": "In Benutzung · {backend}",
"gpu_next_session": "Nächste Sitzung",
"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.",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
+10
View File
@@ -37,6 +37,16 @@
"compositor_available": "Available",
"compositor_unavailable": "Unavailable",
"compositor_default": "Default",
"host_gpus": "GPUs",
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
"gpu_automatic": "Automatic",
"gpu_prefer": "Prefer",
"gpu_preferred": "Preferred",
"gpu_in_use": "In use · {backend}",
"gpu_next_session": "Next session",
"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.",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
+146
View File
@@ -0,0 +1,146 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import type { FC } from "react";
import {
getListGpusQueryKey,
useListGpus,
useSetGpuPreference,
} from "@/api/gen/gpu/gpu";
import type { GpuState } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/**
* Container: the host's GPU inventory + selection. Polls (a stream starting/stopping moves the
* "In use" badge; an eGPU can appear) and applies auto/preferred choices via the mgmt API. A
* preference applies to the NEXT session — the help text says so.
*/
export const GpuSection: FC = () => {
const qc = useQueryClient();
const gpus = useListGpus({ query: { refetchInterval: 5_000 } });
const setPref = useSetGpuPreference();
const apply = (mode: "auto" | "manual", gpuId?: string) =>
setPref.mutate(
{ data: { mode, gpu_id: gpuId ?? null } },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListGpusQueryKey() }),
},
);
return <GpuCard state={gpus} onApply={apply} busy={setPref.isPending} />;
};
const fmtVram = (mb: number) =>
mb >= 1024 ? `${Math.round(mb / 1024)} GiB` : `${mb} MiB`;
/**
* 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
* ("In use · NVENC"), plus an Automatic/Prefer control pair.
*/
export const GpuCard: FC<{
state: Loadable<GpuState>;
onApply: (mode: "auto" | "manual", gpuId?: string) => void;
busy: boolean;
}> = ({ state, onApply, busy }) => {
const s = state.data;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-4">
<span>{m.host_gpus()}</span>
{s && s.gpus.length > 0 && (
<Button
size="sm"
variant={s.mode === "auto" ? "default" : "outline"}
disabled={busy || s.mode === "auto"}
onClick={() => onApply("auto")}
>
{m.gpu_automatic()}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_gpus_help()}</p>
<QueryState
isLoading={state.isLoading}
error={state.error}
refetch={state.refetch}
>
{s &&
(s.gpus.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.gpu_none()}</p>
) : (
<ul className="divide-y rounded-md border">
{s.gpus.map((g) => {
const isActive = s.active?.id === g.id;
const isSelected = s.selected?.id === g.id;
const isPreferred =
s.mode === "manual" && s.preferred_id === g.id;
return (
<li
key={g.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{g.name}</span>
{isPreferred && (
<Badge variant="secondary">
{m.gpu_preferred()}
</Badge>
)}
{isActive && s.active ? (
<Badge variant="success">
{m.gpu_in_use({
backend: s.active.backend.toUpperCase(),
})}
</Badge>
) : (
isSelected && (
<Badge variant="default">
{m.gpu_next_session()}
</Badge>
)
)}
</div>
<code className="text-xs text-muted-foreground">
{g.vendor}
{g.vram_mb > 0 ? ` · ${fmtVram(g.vram_mb)}` : ""}
{` · ${g.id}`}
</code>
</div>
<Button
size="sm"
variant="outline"
disabled={busy || isPreferred}
onClick={() => onApply("manual", g.id)}
>
{m.gpu_prefer()}
</Button>
</li>
);
})}
</ul>
))}
{s?.selected?.source === "preference_missing" && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.gpu_missing_warning({ name: s.preferred_name ?? "?" })}
</p>
)}
{s?.env_override && s.mode === "auto" && (
<p className="text-xs text-muted-foreground">
{m.gpu_env_note({ value: s.env_override })}
</p>
)}
</QueryState>
</CardContent>
</Card>
);
};
+4 -1
View File
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { GpuSection } from "./GpuCard";
import { HostView } from "./view";
export const SectionHost: FC = () => {
@@ -8,5 +9,7 @@ export const SectionHost: FC = () => {
const host = useGetHostInfo();
const compositors = useListCompositors();
return <HostView host={host} compositors={compositors} />;
return (
<HostView host={host} compositors={compositors} gpu={<GpuSection />} />
);
};
+6 -2
View File
@@ -1,5 +1,5 @@
import Section from "@unom/ui/section";
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import { QueryState } from "@/components/query-state";
@@ -11,7 +11,9 @@ import { m } from "@/paraglide/messages";
export const HostView: FC<{
host: Loadable<HostInfo>;
compositors: Loadable<AvailableCompositor[]>;
}> = ({ host, compositors }) => {
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
gpu?: ReactNode;
}> = ({ host, compositors, gpu }) => {
const h = host.data;
return (
<Section maxWidth={false}>
@@ -77,6 +79,8 @@ export const HostView: FC<{
)}
</QueryState>
{gpu}
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>