feat(clients/windows): GPU picker, disconnect shortcut, richer stream HUD
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m16s
apple / screenshots (push) Successful in 5m30s
android / android (push) Successful in 3m21s
ci / web (push) Successful in 52s
ci / rust (push) Successful in 1m26s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
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 4m36s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m45s
docker / deploy-docs (push) Successful in 17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m16s
apple / screenshots (push) Successful in 5m30s
android / android (push) Successful in 3m21s
ci / web (push) Successful in 52s
ci / rust (push) Successful in 1m26s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
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 4m36s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m45s
docker / deploy-docs (push) Successful in 17s
- Settings gains a GPU selector (shown only on multi-GPU boxes): the picked DXGI adapter drives decode + present, persisted as Settings.adapter and applied at the next stream - gpu.rs now caches the shared device keyed by the resolved preference (env PUNKTFUNK_ADAPTER > Settings > the window's monitor's adapter) so a change needs no app restart. - Ctrl+Alt+Shift+D disconnects the session (consumed locally, captured or released): the hook releases capture and trips the session stop flag, plumbed through the stream-page handoff; the pump winds down and the UI navigates back to the host list. - Stream HUD extended: codec chip (HEVC/H.264/AV1), display-side line from the render thread (presents/s + capture-to-decoded vs capture-to-on-glass p50), session line (host name, duration, network-lost frames, skipped backlog frames), and both shortcut hints incl. the new disconnect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -250,7 +250,8 @@ fn connect_with(
|
||||
}
|
||||
gamepad.attach(connector.clone());
|
||||
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||
*shared.handoff.lock().unwrap() =
|
||||
Some((connector, handle.frames.clone(), handle.stop.clone()));
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
|
||||
@@ -95,10 +95,14 @@ impl PartialEq for Svc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
|
||||
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread):
|
||||
/// the connector (input sends), the decoded-frame channel (render thread), and the session's
|
||||
/// stop flag (the disconnect shortcut trips it).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
pub(crate) handoff: Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx)>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) handoff:
|
||||
Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx, Arc<AtomicBool>)>>,
|
||||
pub(crate) target: Mutex<Target>,
|
||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||
/// by the HUD poll thread to drive the overlay.
|
||||
@@ -231,6 +235,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
set_hud.call(stream::HudSample {
|
||||
stats: *shared.stats.lock().unwrap(),
|
||||
captured: crate::input::is_captured(),
|
||||
present: crate::render::present_stats(),
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
|
||||
@@ -166,6 +166,31 @@ pub(crate) fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
|
||||
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
});
|
||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||
let gpus = crate::gpu::adapter_names();
|
||||
let gpu_combo = (gpus.len() > 1).then(|| {
|
||||
let mut names = vec!["Automatic (the display's GPU)".to_string()];
|
||||
names.extend(gpus.iter().cloned());
|
||||
let current = gpus
|
||||
.iter()
|
||||
.position(|n| *n == s.adapter)
|
||||
.map_or(0, |i| i + 1);
|
||||
let gpus = gpus.clone();
|
||||
setting_combo(
|
||||
ctx,
|
||||
"GPU (decode + present, applies to the next stream)",
|
||||
names,
|
||||
current,
|
||||
move |s, i| {
|
||||
s.adapter = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
gpus[i - 1].clone()
|
||||
};
|
||||
},
|
||||
)
|
||||
});
|
||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
@@ -269,15 +294,17 @@ pub(crate) fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
|
||||
section("VIDEO"),
|
||||
settings_card(
|
||||
"Video",
|
||||
"Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \
|
||||
Automatic unless debugging. Run a per-host speed test (host list) before setting a \
|
||||
high bitrate.",
|
||||
vec![
|
||||
decoder_combo.into(),
|
||||
codec_combo.into(),
|
||||
bitrate_box.into(),
|
||||
hdr_toggle.into(),
|
||||
],
|
||||
"Hardware decode (D3D11VA) is far lighter than software — keep it on Automatic \
|
||||
unless debugging. Run a per-host speed test (host list) before setting a high \
|
||||
bitrate.",
|
||||
{
|
||||
let mut controls: Vec<Element> = vec![decoder_combo.into()];
|
||||
if let Some(c) = gpu_combo {
|
||||
controls.push(c.into());
|
||||
}
|
||||
controls.extend([codec_combo.into(), bitrate_box.into(), hdr_toggle.into()]);
|
||||
controls
|
||||
},
|
||||
),
|
||||
section("INPUT"),
|
||||
settings_card(
|
||||
|
||||
@@ -15,12 +15,15 @@ use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// One HUD refresh: the latest session stats plus the input hooks' capture state. Mirrored into
|
||||
/// root state by the poll thread (`pf-hud`) and passed down as a prop.
|
||||
/// One HUD refresh: the latest session stats, the input hooks' capture state, and the render
|
||||
/// thread's display-side window. Mirrored into root state by the poll thread (`pf-hud`) and
|
||||
/// passed down as a prop.
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub(crate) struct HudSample {
|
||||
pub(crate) stats: Stats,
|
||||
pub(crate) captured: bool,
|
||||
/// `(presents/s, skipped/s, capture→presented p50 ms)` — see [`crate::render::present_stats`].
|
||||
pub(crate) present: (u32, u32, f32),
|
||||
}
|
||||
|
||||
/// Props for the stream page: the services plus the live HUD sample that drives the overlay
|
||||
@@ -71,12 +74,12 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts;
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
|
||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||
let mode = connector.mode();
|
||||
let clock_offset = connector.clock_offset_ns;
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||
crate::input::install(connector, mode, inhibit);
|
||||
crate::input::install(connector, mode, inhibit, stop);
|
||||
}
|
||||
Some(|| {
|
||||
RENDER.with(|c| {
|
||||
@@ -91,6 +94,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
});
|
||||
|
||||
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||
grid((
|
||||
swap_chain_panel()
|
||||
.on_ready(|panel| {
|
||||
@@ -128,7 +132,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
}
|
||||
});
|
||||
}),
|
||||
hud_overlay(&props.hud, mode),
|
||||
hud_overlay(&props.hud, mode, &host),
|
||||
))
|
||||
.into()
|
||||
}
|
||||
@@ -146,15 +150,39 @@ fn hud_chip(text: &str, color: Color) -> Border {
|
||||
.padding(edges(8.0, 2.0, 8.0, 2.0))
|
||||
}
|
||||
|
||||
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode
|
||||
/// path · HDR), the fps/throughput/latency line, and the capture-state hint. Layered over the
|
||||
/// `SwapChainPanel` in the same grid cell.
|
||||
fn hud_overlay(hud: &HudSample, mode: Option<Mode>) -> Element {
|
||||
/// The negotiated wire codec's display name (`quic::CODEC_*` bit → label).
|
||||
fn codec_name(bits: u8) -> &'static str {
|
||||
match bits {
|
||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
_ => "HEVC",
|
||||
}
|
||||
}
|
||||
|
||||
/// `mm:ss` (or `h:mm:ss`) session time.
|
||||
fn fmt_uptime(secs: u32) -> String {
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · codec ·
|
||||
/// decode path · HDR), a stream line (decode fps / bitrate / decode time), a glass line (display
|
||||
/// presents + end-to-end latency decoded vs on-glass), a session line (host · time · loss), and
|
||||
/// the shortcut hints. Layered over the `SwapChainPanel` in the same grid cell.
|
||||
fn hud_overlay(hud: &HudSample, mode: Option<Mode>, host: &str) -> Element {
|
||||
let stats = &hud.stats;
|
||||
let (pfps, skipped, glass_ms) = hud.present;
|
||||
let res = mode
|
||||
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
|
||||
.unwrap_or_else(|| "\u{2014}".into());
|
||||
let mut chips: Vec<Element> = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()];
|
||||
let mut chips: Vec<Element> = vec![
|
||||
hud_chip(&res, Color::rgb(235, 235, 235)).into(),
|
||||
hud_chip(codec_name(stats.codec), Color::rgb(180, 190, 255)).into(),
|
||||
];
|
||||
chips.push(if stats.hardware {
|
||||
hud_chip("GPU decode", Color::rgb(120, 220, 150)).into()
|
||||
} else {
|
||||
@@ -163,21 +191,43 @@ fn hud_overlay(hud: &HudSample, mode: Option<Mode>) -> Element {
|
||||
if stats.hdr {
|
||||
chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
|
||||
}
|
||||
let line = format!(
|
||||
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms",
|
||||
stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms
|
||||
let stream_line = format!(
|
||||
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} decode {:.1} ms",
|
||||
stats.fps, stats.mbps, stats.decode_ms
|
||||
);
|
||||
// End-to-end latency (host-clock corrected): capture→decoded from the pump, capture→on-glass
|
||||
// from the render thread's post-Present stamp. `skipped` = newest-wins drops (expected when
|
||||
// the stream outpaces the display); `lost` = unrecoverable network drops.
|
||||
let glass_line = format!(
|
||||
"display {pfps} fps \u{00B7} latency {:.1} ms decoded / {glass_ms:.1} ms on-glass",
|
||||
stats.latency_ms
|
||||
);
|
||||
let mut session_bits: Vec<String> = Vec::new();
|
||||
if !host.is_empty() {
|
||||
session_bits.push(host.to_string());
|
||||
}
|
||||
session_bits.push(fmt_uptime(stats.uptime_secs));
|
||||
session_bits.push(format!("{} lost", stats.dropped));
|
||||
if skipped > 0 {
|
||||
session_bits.push(format!("{skipped} skipped"));
|
||||
}
|
||||
let session_line = session_bits.join(" \u{00B7} ");
|
||||
let hint = if hud.captured {
|
||||
"Ctrl+Alt+Shift+Q releases the mouse"
|
||||
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects"
|
||||
} else {
|
||||
"Click the stream to capture the mouse"
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects"
|
||||
};
|
||||
let dim = |t: &str| {
|
||||
text_block(t)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(210, 210, 210))
|
||||
};
|
||||
border(
|
||||
vstack((
|
||||
hstack(chips).spacing(6.0),
|
||||
text_block(line)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(210, 210, 210)),
|
||||
dim(&stream_line),
|
||||
dim(&glass_line),
|
||||
dim(&session_line),
|
||||
text_block(hint)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(150, 150, 150)),
|
||||
|
||||
+81
-37
@@ -8,11 +8,15 @@
|
||||
//! session pump when it builds the decoder, or the UI thread when it builds the presenter).
|
||||
//!
|
||||
//! **Adapter selection** (matters on hybrid boxes — e.g. an Intel iGPU driving the panel next to
|
||||
//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring) wins; else the
|
||||
//! adapter whose output owns the monitor our window is on — that's the adapter DWM composes that
|
||||
//! monitor with, so presents are copy-free and decode runs on the near GPU; else the default
|
||||
//! adapter. Deliberately NOT "the adapter with the best decoder": if the monitor's adapter can't
|
||||
//! decode the codec we demote to software, which beats a per-frame cross-adapter present copy.
|
||||
//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring, a debugging
|
||||
//! override) wins; else the persisted Settings GPU pick ([`crate::trust::Settings::adapter`], the
|
||||
//! Settings-page selector on multi-GPU boxes); else the adapter whose output owns the monitor our
|
||||
//! window is on — that's the adapter DWM composes that monitor with, so presents are copy-free
|
||||
//! and decode runs on the near GPU; else the default adapter. Deliberately NOT "the adapter with
|
||||
//! the best decoder": if the monitor's adapter can't decode the codec we demote to software,
|
||||
//! which beats a per-frame cross-adapter present copy. The device is cached **keyed by the
|
||||
//! resolved preference**, so a Settings change takes effect at the next session (the pump and the
|
||||
//! presenter both resolve at session start and read the same value) without an app restart.
|
||||
//!
|
||||
//! `PUNKTFUNK_D3D_DEBUG=1` adds the D3D11 debug layer (validation messages in the debugger /
|
||||
//! DebugView) — invaluable for present-path bugs, which D3D11 otherwise drops silently.
|
||||
@@ -27,7 +31,7 @@
|
||||
//! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE_WARP,
|
||||
@@ -55,17 +59,38 @@ pub struct SharedDevice {
|
||||
unsafe impl Send for SharedDevice {}
|
||||
unsafe impl Sync for SharedDevice {}
|
||||
|
||||
static SHARED: OnceLock<Option<SharedDevice>> = OnceLock::new();
|
||||
/// The shared device, cached with the GPU preference it was resolved from (empty = automatic).
|
||||
/// Re-created when the preference changes — in practice only between sessions: within one session
|
||||
/// the decoder and the presenter both call [`shared`] at session start with the same value.
|
||||
static SHARED: Mutex<Option<(String, Arc<SharedDevice>)>> = Mutex::new(None);
|
||||
|
||||
/// The process-wide shared D3D11 device, created on first call. `None` only if D3D11 device
|
||||
/// creation fails for both a hardware adapter and WARP (effectively never — WARP is always present).
|
||||
pub fn shared() -> Option<&'static SharedDevice> {
|
||||
SHARED.get_or_init(create).as_ref()
|
||||
/// The user's decode/present GPU preference: the `PUNKTFUNK_ADAPTER` env (debugging override)
|
||||
/// wins, else the persisted Settings pick; empty = automatic.
|
||||
fn adapter_pref() -> String {
|
||||
std::env::var("PUNKTFUNK_ADAPTER")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| crate::trust::Settings::load().adapter)
|
||||
}
|
||||
|
||||
fn create() -> Option<SharedDevice> {
|
||||
match create_device() {
|
||||
Ok(d) => Some(d),
|
||||
/// The process-shared D3D11 device for the current GPU preference, created (or re-created after
|
||||
/// a preference change) on demand. `None` only if D3D11 device creation fails for both a hardware
|
||||
/// adapter and WARP (effectively never — WARP is always present).
|
||||
pub fn shared() -> Option<Arc<SharedDevice>> {
|
||||
let pref = adapter_pref();
|
||||
let mut cached = SHARED.lock().unwrap();
|
||||
if let Some((key, dev)) = cached.as_ref() {
|
||||
if *key == pref {
|
||||
return Some(dev.clone());
|
||||
}
|
||||
}
|
||||
match create_device(&pref) {
|
||||
Ok(d) => {
|
||||
let d = Arc::new(d);
|
||||
*cached = Some((pref, d.clone()));
|
||||
Some(d)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "shared D3D11 device creation failed — no present/decode");
|
||||
None
|
||||
@@ -87,25 +112,46 @@ fn adapter_name(adapter: &IDXGIAdapter) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an explicit adapter: `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring)
|
||||
/// wins; else the adapter whose output owns the monitor the app window is on (see module docs);
|
||||
/// else `None` → the default adapter (also the headless-CLI path, where no window exists).
|
||||
fn resolve_adapter() -> Option<IDXGIAdapter> {
|
||||
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
|
||||
let adapters: Vec<IDXGIAdapter> = {
|
||||
let mut v = Vec::new();
|
||||
let mut i = 0u32;
|
||||
while let Ok(a) = unsafe { factory.EnumAdapters1(i) } {
|
||||
i += 1;
|
||||
if let Ok(a) = a.cast::<IDXGIAdapter>() {
|
||||
v.push(a);
|
||||
}
|
||||
}
|
||||
v
|
||||
/// Every DXGI adapter, in enumeration order (`PUNKTFUNK_ADAPTER=<index>` uses these indices).
|
||||
fn all_adapters() -> Vec<IDXGIAdapter> {
|
||||
let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut v = Vec::new();
|
||||
let mut i = 0u32;
|
||||
while let Ok(a) = unsafe { factory.EnumAdapters1(i) } {
|
||||
i += 1;
|
||||
if let Ok(a) = a.cast::<IDXGIAdapter>() {
|
||||
v.push(a);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
if let Ok(pref) = std::env::var("PUNKTFUNK_ADAPTER") {
|
||||
let pref = pref.trim();
|
||||
/// Descriptions of the real (hardware, non-WARP) GPUs — the Settings GPU picker's option list.
|
||||
/// The picker only shows when this has more than one entry.
|
||||
pub fn adapter_names() -> Vec<String> {
|
||||
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
|
||||
all_adapters()
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
|
||||
.and_then(|a1| unsafe { a1.GetDesc1() })
|
||||
.map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(adapter_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve an explicit adapter: a non-empty `pref` (index or case-insensitive name substring, from
|
||||
/// env or Settings) wins; else the adapter whose output owns the monitor the app window is on (see
|
||||
/// module docs); else `None` → the default adapter (also the headless-CLI path with no window).
|
||||
fn resolve_adapter(pref: &str) -> Option<IDXGIAdapter> {
|
||||
let adapters = all_adapters();
|
||||
|
||||
if !pref.is_empty() {
|
||||
let found = if let Ok(idx) = pref.parse::<usize>() {
|
||||
adapters.get(idx).cloned()
|
||||
} else {
|
||||
@@ -116,10 +162,8 @@ fn resolve_adapter() -> Option<IDXGIAdapter> {
|
||||
.cloned()
|
||||
};
|
||||
match &found {
|
||||
Some(a) => {
|
||||
tracing::info!(pref, adapter = %adapter_name(a), "PUNKTFUNK_ADAPTER matched")
|
||||
}
|
||||
None => tracing::warn!(pref, "PUNKTFUNK_ADAPTER matched no adapter — using default"),
|
||||
Some(a) => tracing::info!(pref, adapter = %adapter_name(a), "GPU preference matched"),
|
||||
None => tracing::warn!(pref, "GPU preference matched no adapter — using automatic"),
|
||||
}
|
||||
if found.is_some() {
|
||||
return found;
|
||||
@@ -153,12 +197,12 @@ fn resolve_adapter() -> Option<IDXGIAdapter> {
|
||||
None
|
||||
}
|
||||
|
||||
fn create_device() -> Result<SharedDevice> {
|
||||
fn create_device(pref: &str) -> Result<SharedDevice> {
|
||||
// Preference order: the resolved adapter (or the default hardware adapter) with video support
|
||||
// (enables D3D11VA); the same without the VIDEO flag (a driver that rejects it still presents +
|
||||
// software-decodes); finally WARP for the GPU-less box. BGRA_SUPPORT is required for the
|
||||
// composition swapchain in every case. An explicit adapter requires D3D_DRIVER_TYPE_UNKNOWN.
|
||||
let adapter = resolve_adapter();
|
||||
let adapter = resolve_adapter(pref);
|
||||
let attempts: [(Option<&IDXGIAdapter>, D3D_DRIVER_TYPE, bool, bool); 3] = match &adapter {
|
||||
Some(a) => [
|
||||
(Some(a), D3D_DRIVER_TYPE_UNKNOWN, true, true),
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
//! **click on the stream** re-engages it. Losing foreground also releases the lock so the cursor
|
||||
//! is never stranded; regaining it while still captured re-locks. When "capture system
|
||||
//! shortcuts" is off in Settings, Alt+Tab / Alt+Esc / Ctrl+Esc / the Win keys act on the local
|
||||
//! desktop instead of being forwarded.
|
||||
//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed
|
||||
//! locally, works captured or released while our window is foreground): it trips the session's
|
||||
//! stop flag, the pump winds down, and the event loop navigates back to the host list.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
@@ -34,7 +36,7 @@ use std::sync::{Arc, Mutex};
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::VK_Q;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_Q};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
||||
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
|
||||
@@ -46,6 +48,8 @@ use windows::Win32::UI::WindowsAndMessaging::{
|
||||
struct State {
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
/// The session's stop flag (Ctrl+Alt+Shift+D trips it; the pump then ends the session).
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
|
||||
hwnd: isize,
|
||||
/// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q / click-to-capture).
|
||||
@@ -99,11 +103,18 @@ fn set_captured(st: &mut State, on: bool) {
|
||||
|
||||
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
||||
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
|
||||
pub fn install(connector: Arc<NativeClient>, mode: Mode, inhibit_shortcuts: bool) {
|
||||
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||
pub fn install(
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
inhibit_shortcuts: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
let mut st = State {
|
||||
connector,
|
||||
mode,
|
||||
stop,
|
||||
hwnd: hwnd.0 as isize,
|
||||
captured: false,
|
||||
inhibit_shortcuts,
|
||||
@@ -266,6 +277,14 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) ->
|
||||
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Disconnect: Ctrl+Alt+Shift+D (consumed locally). Release capture immediately so
|
||||
// the cursor is free while the session winds down and the UI navigates home.
|
||||
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
|
||||
set_captured(st, false);
|
||||
st.stop.store(true, Ordering::SeqCst);
|
||||
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
if st.captured {
|
||||
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
||||
// neither forwarded nor swallowed.
|
||||
|
||||
@@ -16,6 +16,23 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The last 1-second render window, published for the HUD (one render thread at a time):
|
||||
/// presents/s, frames skipped by the newest-wins drain, and the capture→presented p50 in µs.
|
||||
/// Zeroed when a render thread starts so a new session never shows the previous one's numbers.
|
||||
static PRESENT_FPS: AtomicU32 = AtomicU32::new(0);
|
||||
static PRESENT_SKIPPED: AtomicU32 = AtomicU32::new(0);
|
||||
static PRESENT_P50_US: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// `(presents/s, skipped/s, capture→presented p50 ms)` of the last render window — the HUD's
|
||||
/// display-side line.
|
||||
pub fn present_stats() -> (u32, u32, f32) {
|
||||
(
|
||||
PRESENT_FPS.load(Ordering::Relaxed),
|
||||
PRESENT_SKIPPED.load(Ordering::Relaxed),
|
||||
PRESENT_P50_US.load(Ordering::Relaxed) as f32 / 1000.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// UI-thread → render-thread state. Size is packed into ONE atomic (w<<32|h) so a resize never
|
||||
/// tears into a (new-width, old-height) pair.
|
||||
pub struct RenderShared {
|
||||
@@ -133,6 +150,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
|
||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut window_start = Instant::now();
|
||||
let mut last_dpi_poll = Instant::now();
|
||||
PRESENT_FPS.store(0, Ordering::Relaxed);
|
||||
PRESENT_SKIPPED.store(0, Ordering::Relaxed);
|
||||
PRESENT_P50_US.store(0, Ordering::Relaxed);
|
||||
|
||||
loop {
|
||||
if shared.stop.load(Ordering::SeqCst) {
|
||||
@@ -194,6 +214,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
|
||||
lat_us.sort_unstable();
|
||||
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
|
||||
tracing::debug!(presented, dropped, present_p50_us = p50, "render window");
|
||||
PRESENT_FPS.store(presented, Ordering::Relaxed);
|
||||
PRESENT_SKIPPED.store(dropped, Ordering::Relaxed);
|
||||
PRESENT_P50_US.store(p50, Ordering::Relaxed);
|
||||
window_start = Instant::now();
|
||||
presented = 0;
|
||||
dropped = 0;
|
||||
|
||||
@@ -51,10 +51,17 @@ pub struct Stats {
|
||||
pub decode_ms: f32,
|
||||
/// Median capture→decoded latency over the last window (host-clock corrected).
|
||||
pub latency_ms: f32,
|
||||
/// True when decoding on the GPU (D3D11VA zero-copy) vs. CPU (software).
|
||||
/// True when decoding on the GPU (D3D11VA) vs. CPU (software).
|
||||
pub hardware: bool,
|
||||
/// True when the stream is BT.2020 PQ HDR10 (last decoded frame).
|
||||
pub hdr: bool,
|
||||
/// The negotiated wire codec (a `quic::CODEC_*` bit) — the HUD's codec chip.
|
||||
pub codec: u8,
|
||||
/// Frames lost to unrecoverable network drops since session start (reassembler count; each
|
||||
/// triggers a keyframe re-request).
|
||||
pub dropped: u64,
|
||||
/// Seconds since the stream started.
|
||||
pub uptime_secs: u32,
|
||||
}
|
||||
|
||||
pub enum SessionEvent {
|
||||
@@ -299,6 +306,7 @@ fn pump(
|
||||
|
||||
let clock_offset = connector.clock_offset_ns;
|
||||
let mut total_frames = 0u64;
|
||||
let session_start = Instant::now();
|
||||
let mut window_start = Instant::now();
|
||||
let mut frames_n = 0u32;
|
||||
let mut bytes_n = 0u64;
|
||||
@@ -424,6 +432,9 @@ fn pump(
|
||||
latency_ms: p50 as f32 / 1000.0,
|
||||
hardware,
|
||||
hdr,
|
||||
codec: connector.codec,
|
||||
dropped: last_dropped,
|
||||
uptime_secs: session_start.elapsed().as_secs() as u32,
|
||||
}));
|
||||
window_start = Instant::now();
|
||||
frames_n = 0;
|
||||
|
||||
@@ -144,6 +144,11 @@ pub struct Settings {
|
||||
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
||||
#[serde(default = "default_codec")]
|
||||
pub codec: String,
|
||||
/// Decode/present GPU: the DXGI adapter description to prefer on a multi-GPU box; empty =
|
||||
/// automatic (the adapter driving the window's monitor). Applies from the next session; a
|
||||
/// vanished adapter (eGPU unplugged) falls back to automatic.
|
||||
#[serde(default)]
|
||||
pub adapter: String,
|
||||
}
|
||||
|
||||
fn default_codec() -> String {
|
||||
@@ -177,6 +182,7 @@ impl Default for Settings {
|
||||
hdr_enabled: true,
|
||||
decoder: "auto".into(),
|
||||
codec: "auto".into(),
|
||||
adapter: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user