refactor(windows): remove the legacy in-process builtin stream path
The real Windows client is the spawned punktfunk-session Vulkan binary (pf-client-core); the in-process builtin GUI stream — reachable only via PUNKTFUNK_BUILTIN_STREAM=1 — was dead weight kept alive by nothing and a recurring source of wasted effort. Remove it: delete present/render/input/ audio.rs and the builtin remainder of session/video.rs, rip all the builtin wiring (app/mod, connect, stream), and make connect always spawn. Preserve the two shipped keepers that happened to live in those files by relocating them to a new probe.rs: run_speed_probe (the per-host network speed test used by the Settings speed page and --headless --speed-test) and decodable_codecs (the codec-capability advert on the probe connect). Trim gpu.rs to just the Settings adapter picker (adapter_names + helpers). --headless now supports only --speed-test — the in-process decode/frame-counter went with the pump. Drops the now-orphaned deps opus, wasapi, crossbeam-channel, anyhow; keeps ffmpeg-next (probe::decodable_codecs still needs it). Net 4432 deletions. Statically verified (module wiring, imports, orphaned symbols/deps all clean); the type-level compile runs on the windows-amd64 CI runner, which has the toolchain this non-Windows host lacks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "punktfunk-client-windows"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, D3D11/SwapChainPanel present, FFmpeg decode, WASAPI audio, SDL3 gamepads"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -57,13 +57,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# Video decode (same FFmpeg pin as the host/Linux client) — software HEVC on the GPU-less dev
|
||||
# box; D3D11VA hardware decode is a follow-up for the real-GPU box.
|
||||
# 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"
|
||||
opus = "0.3"
|
||||
|
||||
# Audio render + mic capture (the WASAPI analogue of the Linux client's PipeWire backend).
|
||||
wasapi = "0.23"
|
||||
|
||||
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
|
||||
# built from source via the bundled CMake on Windows (no system SDL3).
|
||||
@@ -71,12 +68,8 @@ sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
|
||||
|
||||
mdns-sd = "0.20"
|
||||
async-channel = "2"
|
||||
# The decoded-frame channel (session pump → render thread): crossbeam because the render loop
|
||||
# blocks with `recv_timeout`, which async-channel has no sync analogue of.
|
||||
crossbeam-channel = "0.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
use super::style::*;
|
||||
use super::{AppCtx, Screen, Svc, Target};
|
||||
use crate::discovery::DiscoveredHost;
|
||||
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||
use crate::video::DecoderPref;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::trust::{self, KnownHost, KnownHosts};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -117,82 +114,6 @@ pub(crate) fn initiate_launch(
|
||||
);
|
||||
}
|
||||
|
||||
/// The mode to request: explicit settings, with `0` fields resolved to the native size/refresh
|
||||
/// of the display our window is on (mirrors the Linux/Swift clients' native-display default).
|
||||
pub(crate) fn resolve_mode(s: &Settings) -> Mode {
|
||||
let mut mode = Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
};
|
||||
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||
if let Some((w, h, hz)) = current_display_mode() {
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (w, h);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = hz;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No display info (headless session, RDP oddities) — a sane floor.
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (1920, 1080);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = 60;
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// The current mode (physical pixels + refresh) of the display our window occupies:
|
||||
/// `MonitorFromWindow` on the foreground window — ours, the user just clicked in it — then
|
||||
/// `EnumDisplaySettingsW(ENUM_CURRENT_SETTINGS)` on that monitor's device. Defaults to the
|
||||
/// primary display when we're not foreground (e.g. a scripted connect).
|
||||
fn current_display_mode() -> Option<(u32, u32, u32)> {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
EnumDisplaySettingsW, GetMonitorInfoW, MonitorFromWindow, DEVMODEW, ENUM_CURRENT_SETTINGS,
|
||||
MONITORINFO, MONITORINFOEXW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
|
||||
unsafe {
|
||||
let monitor = MonitorFromWindow(
|
||||
GetForegroundWindow(),
|
||||
windows::Win32::Graphics::Gdi::MONITOR_DEFAULTTOPRIMARY,
|
||||
);
|
||||
let mut info = MONITORINFOEXW::default();
|
||||
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
|
||||
if !GetMonitorInfoW(
|
||||
monitor,
|
||||
&mut info as *mut MONITORINFOEXW as *mut MONITORINFO,
|
||||
)
|
||||
.as_bool()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
if !EnumDisplaySettingsW(
|
||||
PCWSTR(info.szDevice.as_ptr()),
|
||||
ENUM_CURRENT_SETTINGS,
|
||||
&mut dm,
|
||||
)
|
||||
.as_bool()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// dmDisplayFrequency of 0/1 means "hardware default" — unusable as a mode request.
|
||||
(dm.dmPelsWidth > 0 && dm.dmDisplayFrequency > 1).then_some((
|
||||
dm.dmPelsWidth,
|
||||
dm.dmPelsHeight,
|
||||
dm.dmDisplayFrequency,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
|
||||
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
|
||||
/// plain "Connecting" screen.
|
||||
@@ -220,9 +141,7 @@ pub(crate) struct ConnectOpts {
|
||||
/// so it can't loop.
|
||||
wake_on_fail: bool,
|
||||
/// A library title id (`steam:570`, …) the host launches during the connect handshake —
|
||||
/// the library page's tap-to-play. Spawn mode passes it as `--launch`; the legacy
|
||||
/// in-process path has no launch plumbing (it predates the library and is slated for
|
||||
/// deletion).
|
||||
/// the library page's tap-to-play, passed to the spawned session child as `--launch`.
|
||||
launch: Option<String>,
|
||||
}
|
||||
|
||||
@@ -265,128 +184,11 @@ fn connect_with(
|
||||
opts: ConnectOpts,
|
||||
) {
|
||||
// Session-always: every stream runs in the spawned punktfunk-session Vulkan binary.
|
||||
// The in-process D3D11VA path below stays reachable via the "Streaming engine"
|
||||
// setting / PUNKTFUNK_BUILTIN_STREAM=1 as the A/B baseline until its deletion.
|
||||
if !super::use_builtin_stream(ctx) {
|
||||
return connect_spawn(ctx, target, pin, set_screen, set_status, opts);
|
||||
}
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
};
|
||||
let handle = session::start(SessionParams {
|
||||
host: target.addr.clone(),
|
||||
port: target.port,
|
||||
mode: resolve_mode(&s),
|
||||
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||||
gamepad: gamepad_pref,
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
audio_channels: s.audio_channels,
|
||||
mic_enabled: s.mic_enabled,
|
||||
hdr_enabled: s.hdr_enabled,
|
||||
decoder: DecoderPref::from_name(&s.decoder),
|
||||
preferred_codec: s.preferred_codec(),
|
||||
pin,
|
||||
identity: ctx.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
});
|
||||
set_status.call(String::new());
|
||||
set_screen.call(if opts.awaiting_approval {
|
||||
Screen::RequestAccess
|
||||
} else {
|
||||
Screen::Connecting
|
||||
});
|
||||
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let cancel = opts.cancel;
|
||||
let wake_on_fail = opts.wake_on_fail;
|
||||
let ctx = ctx.clone();
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
let event = match handle.events.recv_blocking() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
};
|
||||
// A cancelled request-access connect that resolved late (the host approved or the park
|
||||
// timed out after the user walked away): tear down silently. Cancel already returned the
|
||||
// UI to the host list; dropping `event` (and with it any connector) closes the connection
|
||||
// without popping a stream or a stray error over the screen a new session may own.
|
||||
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
fingerprint,
|
||||
..
|
||||
} => {
|
||||
if persist_paired || tofu {
|
||||
// Request-access: the operator approved this device, so record the host as a
|
||||
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
|
||||
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
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(), handle.stop.clone()));
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
st.call(msg);
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
||||
// The host ANSWERED, so this never takes the wake fallback.
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
} else if wake_on_fail {
|
||||
// The dial-first attempt to a non-advertising host failed — it may genuinely
|
||||
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
|
||||
// second failure lands on the host list, not back here).
|
||||
wake_and_connect(&ctx, target.clone(), &ss, &st);
|
||||
} else {
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
// `None` = the user ended the session themselves (the disconnect shortcut) —
|
||||
// return to the host list silently; an error banner would read as a failure.
|
||||
st.call(err.unwrap_or_default());
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
|
||||
}
|
||||
});
|
||||
connect_spawn(ctx, target, pin, set_screen, set_status, opts)
|
||||
}
|
||||
|
||||
/// Spawn-mode connect: run the stream in the punktfunk-session binary and translate its
|
||||
/// stdout contract into the same navigation the in-process event loop drove. The child
|
||||
/// stdout contract into the app's connect-flow navigation. The child
|
||||
/// NEVER connects unpinned — a stored/ceremony pin, else the host's advertised
|
||||
/// fingerprint (TOFU: persisted once the child reports ready, which proves the host
|
||||
/// really holds that identity, mirroring the GTK shell); no fingerprint at all routes to
|
||||
@@ -723,9 +525,7 @@ pub(crate) fn request_access_page(
|
||||
.on_click(move || {
|
||||
// Return the UI immediately; trip the flag this request's event loop
|
||||
// captured so it tears down silently when the connect resolves (see
|
||||
// ConnectOpts::cancel). Spawn mode: killing the parked child IS the abort
|
||||
// (builtin mode's in-process connect is blocking with none — it just
|
||||
// resolves/times out later).
|
||||
// ConnectOpts::cancel). Killing the parked session child IS the abort.
|
||||
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||
c.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The Shortcuts screen: a short note on the in-stream capture model plus a reference of the
|
||||
//! keyboard shortcuts — reached from the Shortcuts button on the host list. The Windows
|
||||
//! counterpart of the GTK client's Keyboard Shortcuts window; the bindings themselves live in
|
||||
//! the session window (and [`crate::input`] for the legacy builtin path), so both clients
|
||||
//! document the same set.
|
||||
//! the session window, so both clients document the same set.
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
@@ -10,8 +9,7 @@ use windows_reactor::*;
|
||||
|
||||
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
|
||||
/// does. Read-only — the keyboard bindings live in the session window (`pf-presenter`'s run
|
||||
/// loop; the legacy builtin path's in [`crate::input`]), the controller chord in its gamepad
|
||||
/// service.
|
||||
/// loop), the controller chord in its gamepad service.
|
||||
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("F11 / Alt+Enter", "Toggle fullscreen"),
|
||||
(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The WinUI 3 (windows-reactor) application shell.
|
||||
//!
|
||||
//! Declarative React-like model: this root component routes on a `Screen` value held in
|
||||
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
|
||||
//! Each screen lives in its own submodule:
|
||||
//! `use_async_state` so background threads (discovery, the spawned session's stdout reader) can
|
||||
//! drive navigation. Each screen lives in its own submodule:
|
||||
//!
|
||||
//! * [`hosts`] — saved/discovered/manual host list, plus per-host forget + speed test
|
||||
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
||||
@@ -10,7 +10,7 @@
|
||||
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
|
||||
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen ·
|
||||
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
|
||||
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
|
||||
//! * [`stream`] — the stream status card (the stream itself runs in the spawned session window)
|
||||
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
||||
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
||||
//!
|
||||
@@ -19,9 +19,6 @@
|
||||
//! marks it dirty and re-renders it; an `AsyncSetState` written from a background thread does
|
||||
//! NOT (the child is pruned when its props are unchanged) — so everything thread-driven
|
||||
//! (discovery, HUD stats, speed-test results) is held as *root* state and passed down as props.
|
||||
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
|
||||
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
|
||||
//! present must not go through state/rerender.
|
||||
|
||||
mod connect;
|
||||
mod help;
|
||||
@@ -36,7 +33,6 @@ mod style;
|
||||
|
||||
use crate::discovery::{self, DiscoveredHost};
|
||||
use crate::gamepad::GamepadService;
|
||||
use crate::session::Stats;
|
||||
use crate::trust::{KnownHosts, Settings};
|
||||
use hosts::HostsProps;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -45,7 +41,6 @@ use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use stream::StreamProps;
|
||||
use windows_reactor::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -88,7 +83,7 @@ pub(crate) struct Target {
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
/// hooks (`hosts_page`/`pair_page`/`stream_page`/`speed_page`) is mounted as its own
|
||||
/// hooks (`hosts_page`/`pair_page`/`speed_page`/`library_page`) is mounted as its own
|
||||
/// `component(...)`, so its hooks live in an isolated slot list — calling them on the shared
|
||||
/// parent `cx` would change the hook order whenever the screen changes (reactor's
|
||||
/// Rules-of-Hooks guard aborts).
|
||||
@@ -115,18 +110,12 @@ impl PartialEq for Svc {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Cross-thread shell state driven off the UI thread: the current target, the live spawned
|
||||
/// session child (Disconnect/Cancel kill it) and its latest stats line, plus the connect-flow
|
||||
/// cancel flag and the discovery/library/speed-test generation guards.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
#[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.
|
||||
pub(crate) stats: Mutex<Stats>,
|
||||
/// The live session child (spawn mode) — the status page's Disconnect and the
|
||||
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
|
||||
pub(crate) session: Mutex<crate::spawn::SessionChild>,
|
||||
@@ -157,14 +146,6 @@ pub struct AppCtx {
|
||||
pub(crate) shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
|
||||
/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a
|
||||
/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed
|
||||
/// with the legacy path once the Vulkan session is fully validated.
|
||||
pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool {
|
||||
std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1")
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
@@ -302,10 +283,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
}
|
||||
});
|
||||
|
||||
// HUD sample: the session event loop writes `shared.stats` and the input hooks track capture
|
||||
// state; this poll thread mirrors both into root state so the stream page gets them as a
|
||||
// *prop* (thread-driven state must be root state — see the module docs). The compare in
|
||||
// `AsyncSetState::call` makes the idle case free.
|
||||
// HUD sample: the spawned session child's latest `stats:` line, mirrored into root state so
|
||||
// the stream status page gets it as a *prop* (thread-driven state must be root state — see the
|
||||
// module docs). The compare in `AsyncSetState::call` makes the idle case free.
|
||||
cx.use_effect((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let set_hud = set_hud.clone();
|
||||
@@ -315,10 +295,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
.spawn(move || loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
set_hud.call(stream::HudSample {
|
||||
stats: *shared.stats.lock().unwrap(),
|
||||
captured: crate::input::is_captured(),
|
||||
visible: crate::input::hud_visible(),
|
||||
present: crate::render::present_stats(),
|
||||
stats_line: shared.stats_line.lock().unwrap().clone(),
|
||||
});
|
||||
})
|
||||
@@ -525,16 +501,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
state: library,
|
||||
},
|
||||
),
|
||||
// Spawn mode (the default): the stream runs in the punktfunk-session child's own
|
||||
// window; this screen is a status page (no hooks — inline is sound). The legacy
|
||||
// in-process SwapChainPanel page stays behind the "Streaming engine" setting /
|
||||
// PUNKTFUNK_BUILTIN_STREAM=1.
|
||||
Screen::Stream if !use_builtin_stream(ctx) => stream::session_page(ctx, &hud),
|
||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
||||
// The stream runs in the punktfunk-session child's own window; this screen is a
|
||||
// status page (no hooks — inline is sound).
|
||||
Screen::Stream => stream::session_page(ctx, &hud),
|
||||
};
|
||||
|
||||
// The Stream screen owns the SwapChainPanel + per-frame present; never wrap it in an animated
|
||||
// opacity/offset layer. Everything else slides + fades in on navigation.
|
||||
// The Stream screen is a plain status card (the session child owns the real stream window);
|
||||
// it's shown without the navigation entrance tween. Everything else slides + fades in.
|
||||
if matches!(screen, Screen::Stream) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -302,8 +302,8 @@ pub(crate) fn settings_page(
|
||||
} else {
|
||||
keys.get(sel - 1).cloned()
|
||||
};
|
||||
// Apply live (the in-process service, legacy builtin streams) and persist —
|
||||
// the spawned session reads `forward_pad` at connect.
|
||||
// Apply live to the gamepad service and persist — the spawned session
|
||||
// reads `forward_pad` at connect.
|
||||
svc.set_pinned(key.clone());
|
||||
let mut s = ctx2.settings.lock().unwrap();
|
||||
s.forward_pad = key.unwrap_or_default();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc};
|
||||
use crate::session::run_speed_probe;
|
||||
use crate::probe::run_speed_probe;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
|
||||
|
||||
@@ -1,163 +1,20 @@
|
||||
//! The stream page: a `SwapChainPanel` whose composition swapchain is created (and bound) once on
|
||||
//! the UI thread, then handed — presenter and all — to the dedicated render thread
|
||||
//! ([`crate::render`]), which presents decoded frames at stream cadence. The page itself only
|
||||
//! forwards panel size/DPI changes and draws the status-chip HUD overlay (mode · decode path ·
|
||||
//! HDR · fps/goodput · end-to-end latency + stage equation · capture hint).
|
||||
//! The stream status page: streams run in the spawned `punktfunk-session` child's own window,
|
||||
//! so the shell shows a status card in the app's card language — host header, the child's live
|
||||
//! `stats:` line as a chip row + stage lines, the in-window shortcuts, and a Disconnect.
|
||||
|
||||
use super::style::{edges, uniform};
|
||||
use super::Svc;
|
||||
use crate::present::Presenter;
|
||||
use crate::render::{self, RenderThread};
|
||||
use crate::session::Stats;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// 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.
|
||||
/// One HUD refresh: the session child's latest formatted `stats:` line, mirrored into root state
|
||||
/// by the poll thread (`pf-hud`) and passed down as a prop.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub(crate) struct HudSample {
|
||||
pub(crate) stats: Stats,
|
||||
pub(crate) captured: bool,
|
||||
/// Whether the stats overlay should be shown — the Settings default at stream start, then
|
||||
/// whatever Ctrl+Alt+Shift+S last set (see [`crate::input::hud_visible`]). Carried in the
|
||||
/// sample so a live toggle changes the sample and re-renders the page (the stream page is a
|
||||
/// child component — only a changed prop re-renders it).
|
||||
pub(crate) visible: bool,
|
||||
/// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display
|
||||
/// stage p50) — see [`crate::render::present_stats`].
|
||||
pub(crate) present: crate::render::PresentStats,
|
||||
/// Spawn mode: the session child's latest formatted `stats:` line, for the status
|
||||
/// page. Empty in builtin mode / before the first window.
|
||||
/// The session child's latest formatted `stats:` line, for the status page. Empty before the
|
||||
/// child's first stats window.
|
||||
pub(crate) stats_line: String,
|
||||
}
|
||||
|
||||
/// Props for the stream page: the services plus the live HUD sample that drives the overlay
|
||||
/// (compared by value, so each new sample re-renders the overlay).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct StreamProps {
|
||||
pub(crate) svc: Svc,
|
||||
pub(crate) hud: HudSample,
|
||||
}
|
||||
|
||||
impl PartialEq for StreamProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.svc == other.svc && self.hud == other.hud
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
||||
/// later, once the native panel exists).
|
||||
static PENDING: RefCell<Option<(crate::session::FrameRx, std::sync::Arc<std::sync::atomic::AtomicI64>)>> = const { RefCell::new(None) };
|
||||
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
||||
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The app window's DPI (96 when the window can't be found — then DIPs == pixels). Reactor's
|
||||
/// `on_resize` reports DIPs and exposes no CompositionScale, so the window DPI is the scale.
|
||||
fn window_dpi() -> u32 {
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
|
||||
unsafe {
|
||||
FindWindowW(None, windows::core::w!("Punktfunk"))
|
||||
.ok()
|
||||
.map(|h| GetDpiForWindow(h))
|
||||
.filter(|d| *d > 0)
|
||||
.unwrap_or(96)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let ctx = &props.svc.ctx;
|
||||
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
|
||||
// in a use_ref, stash frames for `on_mounted`, install the input hooks. The cleanup stops the
|
||||
// render thread FIRST (it must not present into a panel that's tearing down), then removes
|
||||
// the input hooks.
|
||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||
cx.use_effect_with_cleanup((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let (inhibit, show_stats) = {
|
||||
let s = ctx.settings.lock().unwrap();
|
||||
(s.inhibit_shortcuts, s.show_stats)
|
||||
};
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||
let mode = connector.mode();
|
||||
let clock_offset = connector.clock_offset_shared();
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
||||
}
|
||||
Some(|| {
|
||||
RENDER.with(|c| {
|
||||
if let Some(mut rt) = c.borrow_mut().take() {
|
||||
rt.stop_and_join();
|
||||
}
|
||||
});
|
||||
PENDING.with(|c| c.borrow_mut().take());
|
||||
crate::input::uninstall();
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||
let mut layers: Vec<Element> = vec![swap_chain_panel()
|
||||
.on_mounted(|panel| {
|
||||
// Placeholder size — the first `on_resize` (fired after the first layout pass)
|
||||
// resizes to the panel's real pixel size.
|
||||
let dpi = window_dpi();
|
||||
match Presenter::new(1280, 720, dpi) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
|
||||
tracing::error!(error = %e, "set_swap_chain");
|
||||
return;
|
||||
}
|
||||
if let Some((frames, clock_offset)) = PENDING.with(|c| c.borrow_mut().take()) {
|
||||
let shared = render::RenderShared::new(1280, 720, dpi);
|
||||
RENDER.with(|cell| {
|
||||
*cell.borrow_mut() =
|
||||
Some(render::spawn(p, frames, shared, clock_offset));
|
||||
});
|
||||
tracing::info!(dpi, "stream presenter bound — render thread started");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "create presenter"),
|
||||
}
|
||||
})
|
||||
.on_resize(|w, h| {
|
||||
// DIPs → physical pixels; the presenter maps back via SetMatrixTransform.
|
||||
let dpi = window_dpi();
|
||||
let px = |v: f64| (v * f64::from(dpi) / 96.0).round() as u32;
|
||||
RENDER.with(|cell| {
|
||||
if let Some(rt) = cell.borrow().as_ref() {
|
||||
rt.shared().set_dpi(dpi);
|
||||
rt.shared().set_size(px(w), px(h));
|
||||
}
|
||||
});
|
||||
})
|
||||
.into()];
|
||||
// The overlay follows the LIVE visibility (Settings default, then Ctrl+Alt+Shift+S): the page
|
||||
// re-renders on every HUD sample (~400 ms), so a toggle takes effect promptly mid-stream.
|
||||
if props.hud.visible {
|
||||
layers.push(hud_overlay(&props.hud, mode, &host));
|
||||
}
|
||||
// Flash the shortcut key set for the first few seconds of every session, regardless of the
|
||||
// HUD setting — so "how do I get back out" is answered the moment the stream comes up (parity
|
||||
// with the GTK client's stream-start hint). Uptime drives it, so it needs no timer/state: the
|
||||
// HUD poll re-renders the page each second and the banner drops once the session passes the
|
||||
// threshold.
|
||||
if props.hud.stats.uptime_secs < START_HINT_SECS {
|
||||
layers.push(start_hint());
|
||||
}
|
||||
grid(layers).into()
|
||||
}
|
||||
|
||||
/// Spawn mode's Stream screen: the stream runs in the punktfunk-session child's own
|
||||
/// window, so the shell shows a status card in the app's card language — monogram +
|
||||
/// host header, the child's live `stats:` line as a chip row + stage lines, the
|
||||
@@ -277,164 +134,3 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// How long the stream-start shortcut banner stays up (seconds of session uptime).
|
||||
const START_HINT_SECS: u32 = 6;
|
||||
|
||||
/// The stream-start shortcut banner: the full client key set on a translucent pill, bottom-centre,
|
||||
/// shown for [`START_HINT_SECS`] at the start of every session (see the call site). Independent of
|
||||
/// the stats overlay, so it appears even with the HUD turned off.
|
||||
fn start_hint() -> Element {
|
||||
border(
|
||||
text_block(
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+Q releases \u{00B7} \
|
||||
Ctrl+Alt+Shift+D disconnects \u{00B7} Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(Color::rgb(235, 235, 235)),
|
||||
)
|
||||
.background(Color::rgb(0, 0, 0))
|
||||
.corner_radius(10.0)
|
||||
.padding(edges(14.0, 8.0, 14.0, 8.0))
|
||||
.opacity(0.82)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Bottom)
|
||||
.margin(edges(0.0, 0.0, 0.0, 28.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A small chip for the dark HUD: coloured text on a translucent dark fill.
|
||||
fn hud_chip(text: &str, color: Color) -> Border {
|
||||
border(
|
||||
text_block(text)
|
||||
.font_size(11.0)
|
||||
.semibold()
|
||||
.foreground(color),
|
||||
)
|
||||
.background(Color::rgb(38, 38, 38))
|
||||
.corner_radius(8.0)
|
||||
.padding(edges(8.0, 2.0, 8.0, 2.0))
|
||||
}
|
||||
|
||||
/// 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), unified stats vocabulary (design/stats-unification.md):
|
||||
/// a chip row (mode · codec · decode path · HDR), a stream line (received fps · goodput ·
|
||||
/// presenter fps), the end-to-end headline (capture→on-glass p50/p95, host-clock corrected), the
|
||||
/// stage equation (= host + network + decode + display when the host reports 0xCF timings, else
|
||||
/// the combined = host+network + decode + display; stage p50s), a session line
|
||||
/// (host · time · loss/skips), 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 present = &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(),
|
||||
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 {
|
||||
hud_chip("CPU decode", Color::rgb(240, 190, 90)).into()
|
||||
});
|
||||
if stats.hdr {
|
||||
chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
|
||||
}
|
||||
// Received fps + goodput, plus the presenter's own rate (Moonlight's "Rendering frame rate"
|
||||
// analog — how often the display actually gets a new frame).
|
||||
let stream_line = format!(
|
||||
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} display {} fps",
|
||||
stats.fps, stats.mbps, present.fps
|
||||
);
|
||||
// The headline: end-to-end capture→displayed, measured directly post-Present (never the sum
|
||||
// of the stage percentiles). `(same-host clock)` flags an uncorrected clock (offset == 0:
|
||||
// same host, or the host skipped the skew handshake).
|
||||
let mut e2e_line = format!(
|
||||
"end-to-end {:.1} ms p50 \u{00B7} {:.1} p95 \u{00B7} capture\u{2192}on-glass",
|
||||
present.e2e_p50_ms, present.e2e_p95_ms
|
||||
);
|
||||
if stats.same_host {
|
||||
e2e_line.push_str(" (same-host clock)");
|
||||
}
|
||||
// The equation: the stages tile the headline interval per frame; the window p50s only
|
||||
// approximately sum (percentiles aren't additive). With per-AU 0xCF host timings the opaque
|
||||
// `host+network` term splits into `host` (host capture→sent) + `network` (the remainder);
|
||||
// an old host emits none and the combined term stays.
|
||||
let stage_line = if stats.split {
|
||||
format!(
|
||||
"= host {:.1} + network {:.1} + decode {:.1} + display {:.1}",
|
||||
stats.host_ms, stats.net_ms, stats.decode_ms, present.display_p50_ms
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"= host+network {:.1} + decode {:.1} + display {:.1}",
|
||||
stats.hostnet_ms, stats.decode_ms, present.display_p50_ms
|
||||
)
|
||||
};
|
||||
let mut session_bits: Vec<String> = Vec::new();
|
||||
if !host.is_empty() {
|
||||
session_bits.push(host.to_string());
|
||||
}
|
||||
// `lost` = unrecoverable network drops (session-cumulative); `skipped` = the render thread's
|
||||
// newest-wins drops last window (expected when the stream outpaces the display).
|
||||
session_bits.push(fmt_uptime(stats.uptime_secs));
|
||||
session_bits.push(format!("{} lost", stats.dropped));
|
||||
if present.skipped > 0 {
|
||||
session_bits.push(format!("{} skipped", present.skipped));
|
||||
}
|
||||
let session_line = session_bits.join(" \u{00B7} ");
|
||||
let hint = if hud.captured {
|
||||
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen"
|
||||
} else {
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen"
|
||||
};
|
||||
let dim = |t: &str| {
|
||||
text_block(t)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(210, 210, 210))
|
||||
};
|
||||
border(
|
||||
vstack((
|
||||
hstack(chips).spacing(6.0),
|
||||
dim(&stream_line),
|
||||
dim(&e2e_line),
|
||||
dim(&stage_line),
|
||||
dim(&session_line),
|
||||
text_block(hint)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(150, 150, 150)),
|
||||
))
|
||||
.spacing(6.0),
|
||||
)
|
||||
.background(Color::rgb(0, 0, 0))
|
||||
.corner_radius(10.0)
|
||||
.padding(uniform(10.0))
|
||||
.opacity(0.82)
|
||||
.horizontal_alignment(HorizontalAlignment::Right)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(12.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
//! Audio: playback (decoded PCM → a WASAPI shared-mode render stream) and the microphone
|
||||
//! uplink (WASAPI capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
|
||||
//!
|
||||
//! The WASAPI analogue of the Linux client's PipeWire backend. Playback mirrors the host's
|
||||
//! virtual-mic producer's adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded
|
||||
//! chunks on the network clock; the WASAPI render thread pulls whole event-driven quanta on
|
||||
//! the device clock. Prime to ~3 quanta before producing, cap the ring so latency stays
|
||||
//! bounded, re-prime after a real drain.
|
||||
//!
|
||||
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated thread
|
||||
//! (the same discipline as the host's `wasapi_cap`); only the channel + stop flag + join
|
||||
//! handle cross the boundary.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const SAMPLE_RATE: usize = 48_000;
|
||||
/// The microphone uplink stays stereo (the host's virtual mic is stereo). The render path is
|
||||
/// multichannel — its channel count + block align are runtime, driven by the host-resolved layout.
|
||||
const CHANNELS: usize = 2;
|
||||
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
||||
const MIC_FRAME: usize = 960;
|
||||
|
||||
pub struct AudioPlayer {
|
||||
pcm_tx: SyncSender<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Spawn the WASAPI render thread for `channels` (2/6/8, canonical wire order
|
||||
/// FL FR FC LFE RL RR SL SR). Failure (no render endpoint on this box) is survivable — the
|
||||
/// caller streams video-only.
|
||||
pub fn spawn(channels: u8) -> Result<AudioPlayer> {
|
||||
// 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop.
|
||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<()>>(1);
|
||||
let stop_t = stop.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx, channels) {
|
||||
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn audio thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)");
|
||||
Ok(AudioPlayer {
|
||||
pcm_tx,
|
||||
stop,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(anyhow!(
|
||||
"wasapi render init timed out (no render endpoint?)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||
/// WASAPI side is wedged (the renderer conceals the gap; never block the session pump).
|
||||
pub fn push(&self, pcm: Vec<f32>) {
|
||||
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
pcm_rx: Receiver<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
channels: u8,
|
||||
) -> Result<()> {
|
||||
if let Err(e) = wasapi::initialize_mta()
|
||||
.ok()
|
||||
.context("CoInitializeEx (MTA)")
|
||||
{
|
||||
let _ = ready.send(Err(e));
|
||||
return Ok(());
|
||||
}
|
||||
let res = (|| -> Result<()> {
|
||||
// F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical
|
||||
// to the old fixed path (mask 0x3, block align 8).
|
||||
let block_align = channels as usize * 4;
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Render)
|
||||
.context("default render endpoint")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
// The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F,
|
||||
// 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire
|
||||
// order, so the render mapping is the identity — no permute. `autoconvert` (below) lets the
|
||||
// audio engine downmix when the endpoint has fewer speakers.
|
||||
let desired = WaveFormat::new(
|
||||
32,
|
||||
32,
|
||||
&SampleType::Float,
|
||||
SAMPLE_RATE,
|
||||
channels as usize,
|
||||
Some(punktfunk_core::audio::wasapi_channel_mask(channels)),
|
||||
);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
let mode = StreamMode::EventsShared {
|
||||
autoconvert: true,
|
||||
buffer_duration_hns: default_period,
|
||||
};
|
||||
audio_client
|
||||
.initialize_client(&desired, &Direction::Render, &mode)
|
||||
.context("initialize render client")?;
|
||||
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
|
||||
let render_client = audio_client
|
||||
.get_audiorenderclient()
|
||||
.context("IAudioRenderClient")?;
|
||||
audio_client.start_stream().context("start render stream")?;
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
||||
let mut ring: VecDeque<u8> = VecDeque::new();
|
||||
let mut primed = false;
|
||||
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Drain everything the pump has queued into the ring.
|
||||
while let Ok(chunk) = pcm_rx.try_recv() {
|
||||
for s in chunk {
|
||||
ring.extend(s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let avail_frames = audio_client
|
||||
.get_available_space_in_frames()
|
||||
.context("available space")? as usize;
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * block_align;
|
||||
|
||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||
let cap = target.max(want_bytes) + want_bytes;
|
||||
if ring.len() > cap {
|
||||
ring.drain(..ring.len() - cap);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
|
||||
out.clear();
|
||||
out.resize(want_bytes, 0);
|
||||
if primed {
|
||||
let n = ring.len().min(want_bytes);
|
||||
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
||||
*dst = b;
|
||||
}
|
||||
}
|
||||
if ring.is_empty() {
|
||||
primed = false;
|
||||
}
|
||||
render_client
|
||||
.write_to_device(avail_frames, &out, None)
|
||||
.context("write_to_device")?;
|
||||
}
|
||||
audio_client.stop_stream().ok();
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(ref e) = res {
|
||||
let _ = ready.send(Err(anyhow!("{e:#}")));
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks, ship
|
||||
/// them as 0xCB datagrams into the host's virtual mic source.
|
||||
pub struct MicStreamer {
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MicStreamer {
|
||||
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_t = stop.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-mic".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = mic_thread(&connector, stop_t) {
|
||||
tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn mic thread")?;
|
||||
Ok(MicStreamer {
|
||||
stop,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MicStreamer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> Result<()> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.context("CoInitializeEx (MTA)")?;
|
||||
|
||||
let mut encoder = opus::Encoder::new(
|
||||
SAMPLE_RATE as u32,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::Voip,
|
||||
)
|
||||
.map_err(|e| anyhow!("opus encoder: {e}"))?;
|
||||
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
|
||||
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Capture)
|
||||
.context("default capture endpoint (no microphone?)")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
let mode = StreamMode::EventsShared {
|
||||
autoconvert: true,
|
||||
buffer_duration_hns: default_period,
|
||||
};
|
||||
audio_client
|
||||
.initialize_client(&desired, &Direction::Capture, &mode)
|
||||
.context("initialize capture client")?;
|
||||
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
|
||||
let capture_client = audio_client
|
||||
.get_audiocaptureclient()
|
||||
.context("IAudioCaptureClient")?;
|
||||
audio_client
|
||||
.start_stream()
|
||||
.context("start capture stream")?;
|
||||
|
||||
let mut bytes: VecDeque<u8> = VecDeque::new();
|
||||
let mut ring: VecDeque<f32> = VecDeque::new();
|
||||
let mut out = vec![0u8; 4000];
|
||||
let mut seq = 0u32;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
match capture_client.get_next_packet_size() {
|
||||
Ok(Some(0)) | Ok(None) => break,
|
||||
Ok(Some(_n)) => {
|
||||
capture_client
|
||||
.read_from_device_to_deque(&mut bytes)
|
||||
.context("read capture")?;
|
||||
}
|
||||
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
|
||||
}
|
||||
}
|
||||
let whole = (bytes.len() / 4) * 4;
|
||||
for c in bytes.drain(..whole).collect::<Vec<u8>>().chunks_exact(4) {
|
||||
ring.push_back(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||
}
|
||||
// Ship every complete 20 ms stereo frame.
|
||||
while ring.len() >= MIC_FRAME * CHANNELS {
|
||||
let pcm: Vec<f32> = ring.drain(..MIC_FRAME * CHANNELS).collect();
|
||||
match encoder.encode_float(&pcm, &mut out) {
|
||||
Ok(len) => {
|
||||
let pts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let _ = connector.send_mic(seq, pts, out[..len].to_vec());
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
audio_client.stop_stream().ok();
|
||||
Ok(())
|
||||
}
|
||||
+7
-230
@@ -1,104 +1,14 @@
|
||||
//! The single Direct3D 11 device shared by the video decoder (D3D11VA hardware decode) and the
|
||||
//! presenter (the `SwapChainPanel` composition swapchain + the present draw).
|
||||
//! DXGI adapter enumeration for the Settings "GPU" picker.
|
||||
//!
|
||||
//! Zero-copy hardware decode requires FFmpeg to decode HEVC into `ID3D11Texture2D`s created by the
|
||||
//! **same** device the presenter binds as shader resources and draws with — a texture from one
|
||||
//! device can't be sampled by another. So the device is created once, here, and both subsystems
|
||||
//! pull it from a process-global `OnceLock` (initialised on whichever thread asks first: the
|
||||
//! 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, 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.
|
||||
//!
|
||||
//! **Thread-safety.** windows-rs COM interfaces are deliberately `!Send`/`!Sync` — thread-safety
|
||||
//! is per-object, not universal. An `ID3D11Device` and its immediate context become free-threaded
|
||||
//! once `ID3D11Multithread::SetMultithreadProtected(TRUE)` is set, which FFmpeg's D3D11VA backend
|
||||
//! does inside `av_hwdevice_ctx_init` (it installs an `ID3D11Multithread`-based default lock when we
|
||||
//! leave `AVD3D11VADeviceContext.lock` null). The decoder then uses FFmpeg's separate
|
||||
//! `ID3D11VideoContext` for decode while the presenter uses the immediate context for draw; under
|
||||
//! multithread protection D3D serialises the two internally, and decode/draw touch disjoint context
|
||||
//! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage.
|
||||
//! Streaming (decode + present) runs in the spawned `punktfunk-session` binary; the shell only
|
||||
//! needs the list of real (hardware) adapters to offer on a multi-GPU box (a hybrid laptop or an
|
||||
//! eGPU). The picked adapter description is persisted (`crate::trust::Settings::adapter`) and read
|
||||
//! by the session child at connect (`PUNKTFUNK_ADAPTER` remains the session binary's env override).
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
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,
|
||||
D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, D3D11_CREATE_DEVICE_FLAG,
|
||||
D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
pub struct SharedDevice {
|
||||
pub device: ID3D11Device,
|
||||
pub context: ID3D11DeviceContext,
|
||||
/// True when this is a real GPU (hardware) adapter — a precondition for D3D11VA decode. WARP
|
||||
/// (the GPU-less dev box) creates fine for present but cannot hardware-decode HEVC, so the
|
||||
/// decoder skips straight to the software path there.
|
||||
pub hardware: bool,
|
||||
}
|
||||
|
||||
// Sound for our usage — see the module docs: the device + immediate context are free-threaded under
|
||||
// the multithread protection FFmpeg installs, and decode (video context) / present (immediate
|
||||
// context) never share mutable context state.
|
||||
unsafe impl Send for SharedDevice {}
|
||||
unsafe impl Sync for SharedDevice {}
|
||||
|
||||
/// 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 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The adapter's human-readable description, for the logs.
|
||||
/// The adapter's human-readable description.
|
||||
fn adapter_name(adapter: &IDXGIAdapter) -> String {
|
||||
unsafe {
|
||||
adapter
|
||||
@@ -112,7 +22,7 @@ fn adapter_name(adapter: &IDXGIAdapter) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every DXGI adapter, in enumeration order (`PUNKTFUNK_ADAPTER=<index>` uses these indices).
|
||||
/// Every DXGI adapter, in enumeration order.
|
||||
fn all_adapters() -> Vec<IDXGIAdapter> {
|
||||
let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } {
|
||||
Ok(f) => f,
|
||||
@@ -144,136 +54,3 @@ pub fn adapter_names() -> Vec<String> {
|
||||
.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 {
|
||||
let needle = pref.to_lowercase();
|
||||
adapters
|
||||
.iter()
|
||||
.find(|a| adapter_name(a).to_lowercase().contains(&needle))
|
||||
.cloned()
|
||||
};
|
||||
match &found {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// The adapter driving the monitor our window sits on: DWM composes that monitor with it, so
|
||||
// presenting from it is copy-free (a hybrid box's other adapter would pay a cross-adapter
|
||||
// copy per frame).
|
||||
let monitor = unsafe {
|
||||
use windows::Win32::Graphics::Gdi::{MonitorFromWindow, MONITOR_DEFAULTTONULL};
|
||||
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
|
||||
let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?;
|
||||
MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL)
|
||||
};
|
||||
if monitor.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
for adapter in &adapters {
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = unsafe { adapter.EnumOutputs(oi) } {
|
||||
oi += 1;
|
||||
if let Ok(desc) = unsafe { output.GetDesc() } {
|
||||
if desc.Monitor == monitor {
|
||||
tracing::info!(adapter = %adapter_name(adapter), "using the window's monitor adapter");
|
||||
return Some(adapter.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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(pref);
|
||||
let attempts: [(Option<&IDXGIAdapter>, D3D_DRIVER_TYPE, bool, bool); 3] = match &adapter {
|
||||
Some(a) => [
|
||||
(Some(a), D3D_DRIVER_TYPE_UNKNOWN, true, true),
|
||||
(Some(a), D3D_DRIVER_TYPE_UNKNOWN, false, true),
|
||||
(None, D3D_DRIVER_TYPE_WARP, false, false),
|
||||
],
|
||||
None => [
|
||||
(None, D3D_DRIVER_TYPE_HARDWARE, true, true),
|
||||
(None, D3D_DRIVER_TYPE_HARDWARE, false, true),
|
||||
(None, D3D_DRIVER_TYPE_WARP, false, false),
|
||||
],
|
||||
};
|
||||
// The debug layer needs the SDK layers installed (Graphics Tools); when they're missing the
|
||||
// creation fails, so each attempt retries without the flag rather than failing the ladder.
|
||||
let debug = std::env::var("PUNKTFUNK_D3D_DEBUG").is_ok_and(|v| v == "1");
|
||||
for (adapter, driver, video, hardware) in attempts {
|
||||
let mut flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
|
||||
if video {
|
||||
flags |= D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
|
||||
}
|
||||
let flag_sets: &[D3D11_CREATE_DEVICE_FLAG] = if debug {
|
||||
&[flags | D3D11_CREATE_DEVICE_DEBUG, flags]
|
||||
} else {
|
||||
&[flags]
|
||||
};
|
||||
for &flags in flag_sets {
|
||||
let mut device = None;
|
||||
let mut context = None;
|
||||
let r = unsafe {
|
||||
D3D11CreateDevice(
|
||||
adapter,
|
||||
driver,
|
||||
None,
|
||||
flags,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
};
|
||||
if r.is_ok() {
|
||||
let (device, context) = (device.unwrap(), context.unwrap());
|
||||
// Make the device + immediate context free-threaded: the decoder (D3D11VA video
|
||||
// context, pump thread) and the presenter (immediate context, render thread) both
|
||||
// touch this device. FFmpeg also sets this during hwdevice init, but doing it up
|
||||
// front keeps the cross-thread `Send`/`Sync` sound from the moment the device exists.
|
||||
if let Ok(mt) = context.cast::<ID3D11Multithread>() {
|
||||
unsafe {
|
||||
let _ = mt.SetMultithreadProtected(true); // returns the prior state; ignore
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
adapter = %adapter.map(adapter_name).unwrap_or_else(|| if hardware {
|
||||
"default".into()
|
||||
} else {
|
||||
"WARP (software)".into()
|
||||
}),
|
||||
video,
|
||||
debug = (flags & D3D11_CREATE_DEVICE_DEBUG).0 != 0,
|
||||
"shared D3D11 device created"
|
||||
);
|
||||
return Ok(SharedDevice {
|
||||
device,
|
||||
context,
|
||||
hardware,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"D3D11CreateDevice failed for both hardware and WARP"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,742 +0,0 @@
|
||||
//! Stream input: Win32 low-level keyboard + mouse hooks forwarding to the host while the WinUI
|
||||
//! window is focused and the pointer is captured.
|
||||
//!
|
||||
//! windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard
|
||||
//! *accelerators* and pointer button-state), which is insufficient for a game stream. So this
|
||||
//! drops below XAML to `WH_KEYBOARD_LL` / `WH_MOUSE_LL`, installed on the UI thread when the
|
||||
//! stream page mounts and removed when it unmounts.
|
||||
//!
|
||||
//! **Pointer lock.** While captured the cursor is *locked* the way a game-streaming client locks
|
||||
//! it (Moonlight/Parsec): the OS cursor is hidden + confined to the window (`ClipCursor`), and
|
||||
//! every physical move is turned into a **relative** delta (`InputKind::MouseMove`) — we read the
|
||||
//! offset from the window centre, ship it (scaled screen→host through the Contain-fit factor, with
|
||||
//! sub-pixel remainder carried so slow drags aren't lost), then warp the cursor back to centre so
|
||||
//! it never reaches a screen edge. This is why the old absolute path froze: swallowing
|
||||
//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate
|
||||
//! snapped to one point. Keys carry the **US-positional VK** for the pressed physical key (the
|
||||
//! punktfunk wire contract shared by every first-party client — see [`scan_to_positional_vk`]):
|
||||
//! the hook's layout-resolved `vkCode` must NOT go on the wire, or a non-US pair re-maps
|
||||
//! positions through two layouts (German: y↔z swapped, ü lands on ö).
|
||||
//!
|
||||
//! **Capture state machine** (parity with the GTK/Swift clients): capture engages at stream
|
||||
//! start, **Ctrl+Alt+Shift+Q** releases it (handing the cursor back to the local desktop), and a
|
||||
//! **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. **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.
|
||||
//! **Ctrl+Alt+Shift+S** toggles the stats overlay live and **F11** toggles fullscreen — both are
|
||||
//! client-local shortcuts (consumed, never forwarded), matching the GTK client's stream key set.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::core::BOOL;
|
||||
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_D, VK_F11, VK_Q, VK_S};
|
||||
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, ClipCursor, EnumChildWindows, GetClientRect, GetForegroundWindow, SetCursor,
|
||||
SetCursorPos, SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK,
|
||||
KBDLLHOOKSTRUCT, LLKHF_EXTENDED, LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||
WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL,
|
||||
WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SYSKEYUP,
|
||||
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
||||
};
|
||||
|
||||
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).
|
||||
captured: bool,
|
||||
/// Forward system shortcuts (Alt+Tab, Win, …) to the host; off = they act locally.
|
||||
inhibit_shortcuts: bool,
|
||||
/// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real
|
||||
/// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition.
|
||||
locked: bool,
|
||||
/// Lock geometry, captured when the lock engages: the confinement rect (screen coordinates,
|
||||
/// also the click-to-capture hit test), its centre (the cursor is warped here after every
|
||||
/// move), and the screen→host scale (the Contain-fit display scale's inverse). Stable while
|
||||
/// locked — the window can't be moved or resized with the cursor confined inside it.
|
||||
clip: RECT,
|
||||
center_x: i32,
|
||||
center_y: i32,
|
||||
scale: f32,
|
||||
/// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away.
|
||||
acc_x: f32,
|
||||
acc_y: f32,
|
||||
/// Modifier state, tracked from the hook's own event stream (see `kbd_proc`).
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
held_keys: HashSet<u8>,
|
||||
held_buttons: HashSet<u32>,
|
||||
}
|
||||
|
||||
// `State` carries no `!Send` handle (hwnd is an `isize`), so the static is sound. The hook procs
|
||||
// run on the same UI thread that installs/removes the hooks, so the lock is uncontended.
|
||||
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||
static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
||||
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
||||
/// Live stats-overlay visibility. Seeded from `Settings::show_stats` at `install`, then toggled by
|
||||
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
||||
/// reads it lock-free to drive the overlay.
|
||||
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||
/// Whether the pointer lock currently wants the OS cursor hidden. Read lock-free by
|
||||
/// [`cursor_subclass_proc`] (which runs on the UI thread inside `WM_SETCURSOR`) so it can override
|
||||
/// WinUI's per-move arrow re-assertion — a one-shot `ShowCursor(false)` alone loses that race
|
||||
/// because the content island re-sets the arrow every time the pointer moves.
|
||||
static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false);
|
||||
/// Our `SetWindowSubclass` id on the WinUI window + its content-island children (any stable value;
|
||||
/// scopes the subclass so install/remove target exactly our proc).
|
||||
const CURSOR_SUBCLASS_ID: usize = 0x7066_6375; // 'pfcu'
|
||||
|
||||
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
||||
pub fn is_captured() -> bool {
|
||||
CAPTURED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the stats overlay should be shown: the Settings default at stream start, then whatever
|
||||
/// Ctrl+Alt+Shift+S last set for the session. Read by the HUD poll thread.
|
||||
pub fn hud_visible() -> bool {
|
||||
HUD_VISIBLE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set the capture intent and engage/release the pointer lock to match.
|
||||
fn set_captured(st: &mut State, on: bool) {
|
||||
st.captured = on;
|
||||
CAPTURED.store(on, Ordering::Relaxed);
|
||||
set_locked(st, on);
|
||||
if !on {
|
||||
flush_held(st); // release held keys/buttons so nothing sticks on the host
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `show_stats` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live.
|
||||
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||
pub fn install(
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
inhibit_shortcuts: bool,
|
||||
show_stats: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
HUD_VISIBLE.store(show_stats, Ordering::Relaxed);
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
let mut st = State {
|
||||
connector,
|
||||
mode,
|
||||
stop,
|
||||
hwnd: hwnd.0 as isize,
|
||||
captured: false,
|
||||
inhibit_shortcuts,
|
||||
locked: false,
|
||||
clip: RECT::default(),
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
scale: 1.0,
|
||||
acc_x: 0.0,
|
||||
acc_y: 0.0,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
held_keys: HashSet::new(),
|
||||
held_buttons: HashSet::new(),
|
||||
};
|
||||
// Capture immediately (the window is foreground at mount, like Moonlight grabbing on stream
|
||||
// start).
|
||||
set_captured(&mut st, true);
|
||||
*STATE.lock().unwrap() = Some(st);
|
||||
unsafe {
|
||||
let hinst = GetModuleHandleW(None).ok();
|
||||
if let Ok(h) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(kbd_proc), hinst.map(Into::into), 0) {
|
||||
KBD_HOOK.store(h.0 as isize, Ordering::SeqCst);
|
||||
}
|
||||
if let Ok(h) = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_proc), hinst.map(Into::into), 0) {
|
||||
MOUSE_HOOK.store(h.0 as isize, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
inhibit_shortcuts,
|
||||
"stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove the hooks, release the pointer lock, and flush any held keys/buttons (so nothing
|
||||
/// sticks down on the host).
|
||||
pub fn uninstall() {
|
||||
unsafe {
|
||||
let k = KBD_HOOK.swap(0, Ordering::SeqCst);
|
||||
if k != 0 {
|
||||
let _ = UnhookWindowsHookEx(HHOOK(k as *mut _));
|
||||
}
|
||||
let m = MOUSE_HOOK.swap(0, Ordering::SeqCst);
|
||||
if m != 0 {
|
||||
let _ = UnhookWindowsHookEx(HHOOK(m as *mut _));
|
||||
}
|
||||
}
|
||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||
// Hand the cursor back + flush held state.
|
||||
set_captured(&mut st, false);
|
||||
// Drop the WM_SETCURSOR subclass so the long-lived app window (reused for the host list
|
||||
// once the stream ends) is left pristine — set_captured already cleared CURSOR_HIDDEN.
|
||||
remove_cursor_subclass(HWND(st.hwnd as *mut _));
|
||||
// Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window
|
||||
// so the GUI (the host list) is never left borderless-fullscreen after the stream ends.
|
||||
exit_fullscreen(HWND(st.hwnd as *mut _));
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held key/button on the host, so nothing sticks down when capture is dropped
|
||||
/// (toggled off) or the session ends.
|
||||
fn flush_held(st: &mut State) {
|
||||
let c = st.connector.clone();
|
||||
for vk in st.held_keys.drain() {
|
||||
send(&c, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
for b in st.held_buttons.drain() {
|
||||
send(&c, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subclass proc on the WinUI window + its content-island children: while the pointer lock wants
|
||||
/// the cursor hidden ([`CURSOR_HIDDEN`]), answer `WM_SETCURSOR` ourselves with `SetCursor(None)`
|
||||
/// and return TRUE — halting WinUI's default handling before it re-asserts the arrow. This is what
|
||||
/// actually keeps the cursor hidden while captured; the sibling `ShowCursor(false)` cannot, because
|
||||
/// WinUI re-sets the arrow on every pointer move (the content island answers `WM_SETCURSOR` itself,
|
||||
/// which a low-level mouse hook never sees). When not hidden, we defer to the chain untouched.
|
||||
unsafe extern "system" fn cursor_subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_id: usize,
|
||||
_ref: usize,
|
||||
) -> LRESULT {
|
||||
if msg == WM_SETCURSOR && CURSOR_HIDDEN.load(Ordering::Relaxed) {
|
||||
unsafe {
|
||||
let _ = SetCursor(None);
|
||||
}
|
||||
return LRESULT(1); // handled — suppress the framework's arrow re-assertion
|
||||
}
|
||||
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_install_cb(child: HWND, _l: LPARAM) -> BOOL {
|
||||
unsafe {
|
||||
let _ = SetWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0);
|
||||
}
|
||||
BOOL(1) // keep enumerating
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_remove_cb(child: HWND, _l: LPARAM) -> BOOL {
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID);
|
||||
}
|
||||
BOOL(1)
|
||||
}
|
||||
|
||||
/// Install [`cursor_subclass_proc`] on the top-level WinUI window and every descendant — the video
|
||||
/// is a composition SwapChainPanel, so the pointer actually sits over WinUI's internal content-
|
||||
/// island child window, which is the window that receives `WM_SETCURSOR`. `EnumChildWindows`
|
||||
/// recurses into all descendants, so one pass covers it. Idempotent (re-installing the same
|
||||
/// id+proc just refreshes it), so it's safe to call on every lock engage.
|
||||
fn install_cursor_subclass(top: HWND) {
|
||||
unsafe {
|
||||
let _ = SetWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0);
|
||||
let _ = EnumChildWindows(Some(top), Some(subclass_install_cb), LPARAM(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove our subclass from the top-level window and every descendant. Called on teardown so the
|
||||
/// long-lived app window (reused for the host list after the stream ends) is left pristine.
|
||||
fn remove_cursor_subclass(top: HWND) {
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID);
|
||||
let _ = EnumChildWindows(Some(top), Some(subclass_remove_cb), LPARAM(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
|
||||
/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition).
|
||||
/// Engaging captures the lock geometry (rect, centre, screen→host scale) — see `State::clip`.
|
||||
fn set_locked(st: &mut State, on: bool) {
|
||||
if on == st.locked {
|
||||
return;
|
||||
}
|
||||
let hwnd = HWND(st.hwnd as *mut _);
|
||||
unsafe {
|
||||
if on {
|
||||
let mut rc = RECT::default();
|
||||
if GetClientRect(hwnd, &mut rc).is_ok() {
|
||||
let mut tl = POINT {
|
||||
x: rc.left,
|
||||
y: rc.top,
|
||||
};
|
||||
let mut br = POINT {
|
||||
x: rc.right,
|
||||
y: rc.bottom,
|
||||
};
|
||||
let _ = ClientToScreen(hwnd, &mut tl);
|
||||
let _ = ClientToScreen(hwnd, &mut br);
|
||||
st.clip = RECT {
|
||||
left: tl.x,
|
||||
top: tl.y,
|
||||
right: br.x,
|
||||
bottom: br.y,
|
||||
};
|
||||
let _ = ClipCursor(Some(&st.clip as *const RECT));
|
||||
st.center_x = (tl.x + br.x) / 2;
|
||||
st.center_y = (tl.y + br.y) / 2;
|
||||
// Screen px → host px: the Contain-fit display scale's inverse, so the host
|
||||
// cursor tracks the physical mouse 1:1 on screen at any window size.
|
||||
let (ww, wh) = ((br.x - tl.x).max(1) as f32, (br.y - tl.y).max(1) as f32);
|
||||
let (vw, vh) = (st.mode.width.max(1) as f32, st.mode.height.max(1) as f32);
|
||||
st.scale = (ww / vw).min(wh / vh).max(0.01);
|
||||
let _ = SetCursorPos(st.center_x, st.center_y);
|
||||
}
|
||||
// Hide the OS cursor. ShowCursor(false) is the coarse gate; the subclass is what
|
||||
// actually holds it hidden against WinUI's per-move arrow re-assertion — see
|
||||
// cursor_subclass_proc / install_cursor_subclass.
|
||||
let _ = ShowCursor(false);
|
||||
CURSOR_HIDDEN.store(true, Ordering::Relaxed);
|
||||
install_cursor_subclass(hwnd);
|
||||
st.acc_x = 0.0;
|
||||
st.acc_y = 0.0;
|
||||
} else {
|
||||
CURSOR_HIDDEN.store(false, Ordering::Relaxed);
|
||||
let _ = ClipCursor(None);
|
||||
let _ = ShowCursor(true);
|
||||
}
|
||||
}
|
||||
st.locked = on;
|
||||
}
|
||||
|
||||
/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
|
||||
/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
|
||||
/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
|
||||
/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
|
||||
/// page's unmount), but a Mutex keeps the static sound + `Sync`.
|
||||
static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// Whether our top-level window is currently borderless-fullscreen. Entering strips
|
||||
/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`].
|
||||
fn is_fullscreen(hwnd: HWND) -> bool {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 }
|
||||
}
|
||||
|
||||
/// Enter borderless fullscreen: remember the window placement, drop the frame
|
||||
/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns
|
||||
/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the
|
||||
/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
|
||||
fn enter_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
let mut wp = WINDOWPLACEMENT {
|
||||
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut mi = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
|
||||
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() {
|
||||
*SAVED_PLACEMENT.lock().unwrap() = Some(wp);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
|
||||
let r = mi.rcMonitor;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we
|
||||
/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop.
|
||||
fn exit_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else {
|
||||
return; // never went fullscreen — nothing to restore
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
|
||||
let _ = SetWindowPlacement(hwnd, &wp);
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into
|
||||
/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too.
|
||||
fn toggle_fullscreen(hwnd: isize) {
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
if is_fullscreen(hwnd) {
|
||||
exit_fullscreen(hwnd);
|
||||
} else {
|
||||
enter_fullscreen(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
let _ = c.send_input(&InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
flags,
|
||||
});
|
||||
}
|
||||
|
||||
/// System shortcuts that act on the LOCAL desktop when "capture system shortcuts" is off:
|
||||
/// the Win keys, Alt+Tab, and Alt/Ctrl+Esc.
|
||||
fn is_system_shortcut(st: &State, vk: u16) -> bool {
|
||||
match vk {
|
||||
0x5B | 0x5C => true, // L/R Win
|
||||
0x09 => st.alt, // Alt+Tab
|
||||
0x1B => st.alt || st.ctrl, // Alt+Esc / Ctrl+Esc
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code == HC_ACTION as i32 {
|
||||
let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
|
||||
let msg = wparam.0 as u32;
|
||||
let up = msg == WM_KEYUP || msg == WM_SYSKEYUP;
|
||||
let vk = kb.vkCode as u16;
|
||||
let mut guard = STATE.lock().unwrap();
|
||||
if let Some(st) = guard.as_mut() {
|
||||
// Track modifier state from the hook's own event stream — reliable even while we
|
||||
// swallow these keys (GetAsyncKeyState doesn't reflect keys suppressed by our own LL
|
||||
// hook, which is why the shortcut never fired). Handles the generic + L/R vk codes.
|
||||
match kb.vkCode {
|
||||
0x11 | 0xA2 | 0xA3 => st.ctrl = !up, // (L/R)CONTROL
|
||||
0x12 | 0xA4 | 0xA5 => st.alt = !up, // (L/R)MENU (Alt)
|
||||
0x10 | 0xA0 | 0xA1 => st.shift = !up, // (L/R)SHIFT
|
||||
_ => {}
|
||||
}
|
||||
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
|
||||
if foreground {
|
||||
// Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded).
|
||||
if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift {
|
||||
let on = !st.captured;
|
||||
set_captured(st, on);
|
||||
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);
|
||||
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session
|
||||
// down immediately instead of holding the keep-alive linger for a reconnect.
|
||||
st.connector.disconnect_quit();
|
||||
st.stop.store(true, Ordering::SeqCst);
|
||||
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Toggle the stats overlay: Ctrl+Alt+Shift+S (consumed locally). Seeded from
|
||||
// Settings at install; this live toggle overrides it for the session — parity
|
||||
// with the GTK client, where `s` flips the OSD without leaving the stream.
|
||||
if !up && vk == VK_S.0 && st.ctrl && st.alt && st.shift {
|
||||
let on = !HUD_VISIBLE.load(Ordering::Relaxed);
|
||||
HUD_VISIBLE.store(on, Ordering::Relaxed);
|
||||
tracing::info!(hud = on, "stats overlay toggled (Ctrl+Alt+Shift+S)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Toggle fullscreen: F11 (consumed locally, no modifiers — a client shortcut,
|
||||
// never a wire key). Works captured or released. The window resize changes the
|
||||
// client rect, so re-lock to recompute the pointer confinement + recentre.
|
||||
if !up && vk == VK_F11.0 {
|
||||
toggle_fullscreen(st.hwnd);
|
||||
if st.locked {
|
||||
set_locked(st, false);
|
||||
set_locked(st, true);
|
||||
}
|
||||
tracing::info!("fullscreen toggled (F11)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
if st.captured {
|
||||
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
||||
// neither forwarded nor swallowed.
|
||||
if !st.inhibit_shortcuts && is_system_shortcut(st, vk) {
|
||||
return unsafe { CallNextHookEx(None, code, wparam, lparam) };
|
||||
}
|
||||
// Wire key: the US-positional VK for this physical key (module docs), derived
|
||||
// from the scancode. `vkCode` is layout-semantic and only passes through for
|
||||
// keys the table doesn't cover — extended keys and everything outside the
|
||||
// typing area, where positional == semantic (plus injected events with
|
||||
// scanCode 0 from remapping tools, best-effort).
|
||||
let ext = (kb.flags.0 & LLKHF_EXTENDED.0) != 0;
|
||||
let v = if ext {
|
||||
vk as u8
|
||||
} else {
|
||||
scan_to_positional_vk(kb.scanCode as u16).unwrap_or(vk as u8)
|
||||
};
|
||||
if up {
|
||||
if st.held_keys.remove(&v) {
|
||||
send(&st.connector, InputKind::KeyUp, v as u32, 0, 0, 0);
|
||||
}
|
||||
} else {
|
||||
st.held_keys.insert(v);
|
||||
send(&st.connector, InputKind::KeyDown, v as u32, 0, 0, 0);
|
||||
}
|
||||
return LRESULT(1); // swallow so it reaches the host, not the local OS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||
}
|
||||
|
||||
/// Whether a screen point lies inside the window's CURRENT client area (the click-to-capture
|
||||
/// hit test — computed fresh per click, since the window can move/resize while released).
|
||||
fn in_client_area(hwnd: isize, pt: POINT) -> bool {
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
let mut rc = RECT::default();
|
||||
if unsafe { GetClientRect(hwnd, &mut rc) }.is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut tl = POINT {
|
||||
x: rc.left,
|
||||
y: rc.top,
|
||||
};
|
||||
let mut br = POINT {
|
||||
x: rc.right,
|
||||
y: rc.bottom,
|
||||
};
|
||||
unsafe {
|
||||
let _ = ClientToScreen(hwnd, &mut tl);
|
||||
let _ = ClientToScreen(hwnd, &mut br);
|
||||
}
|
||||
pt.x >= tl.x && pt.x < br.x && pt.y >= tl.y && pt.y < br.y
|
||||
}
|
||||
|
||||
unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code == HC_ACTION as i32 {
|
||||
let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) };
|
||||
let msg = wparam.0 as u32;
|
||||
let injected = (ms.flags & LLMHF_INJECTED) != 0;
|
||||
let mut guard = STATE.lock().unwrap();
|
||||
if let Some(st) = guard.as_mut() {
|
||||
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
|
||||
let want_lock = st.captured && foreground;
|
||||
if want_lock != st.locked {
|
||||
set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground)
|
||||
}
|
||||
// Click-to-capture: after a Ctrl+Alt+Shift+Q release, a primary click on the stream
|
||||
// re-engages capture. The click is consumed — it starts the grab, it isn't gameplay.
|
||||
if !st.captured
|
||||
&& foreground
|
||||
&& msg == WM_LBUTTONDOWN
|
||||
&& !injected
|
||||
&& in_client_area(st.hwnd, ms.pt)
|
||||
{
|
||||
set_captured(st, true);
|
||||
tracing::info!("capture re-engaged (click on stream)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
if st.locked {
|
||||
// Skip the synthetic move our own SetCursorPos recentre generates.
|
||||
if injected {
|
||||
return unsafe { CallNextHookEx(None, code, wparam, lparam) };
|
||||
}
|
||||
let c = st.connector.clone();
|
||||
match msg {
|
||||
WM_MOUSEMOVE => {
|
||||
let dx = (ms.pt.x - st.center_x) as f32;
|
||||
let dy = (ms.pt.y - st.center_y) as f32;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
st.acc_x += dx / st.scale;
|
||||
st.acc_y += dy / st.scale;
|
||||
let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32);
|
||||
st.acc_x -= hx as f32;
|
||||
st.acc_y -= hy as f32;
|
||||
if hx != 0 || hy != 0 {
|
||||
send(&c, InputKind::MouseMove, 0, hx, hy, 0);
|
||||
}
|
||||
}
|
||||
let _ = unsafe { SetCursorPos(st.center_x, st.center_y) };
|
||||
}
|
||||
WM_LBUTTONDOWN => button(st, 1, true),
|
||||
WM_LBUTTONUP => button(st, 1, false),
|
||||
WM_RBUTTONDOWN => button(st, 3, true),
|
||||
WM_RBUTTONUP => button(st, 3, false),
|
||||
WM_MBUTTONDOWN => button(st, 2, true),
|
||||
WM_MBUTTONUP => button(st, 2, false),
|
||||
WM_XBUTTONDOWN => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), true),
|
||||
WM_XBUTTONUP => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), false),
|
||||
WM_MOUSEWHEEL => send(
|
||||
&c,
|
||||
InputKind::MouseScroll,
|
||||
0,
|
||||
(ms.mouseData >> 16) as i16 as i32,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
WM_MOUSEHWHEEL => send(
|
||||
&c,
|
||||
InputKind::MouseScroll,
|
||||
1,
|
||||
(ms.mouseData >> 16) as i16 as i32,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
return LRESULT(1); // swallow inside the locked window
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||
}
|
||||
|
||||
fn button(st: &mut State, id: u32, down: bool) {
|
||||
let c = st.connector.clone();
|
||||
if down {
|
||||
st.held_buttons.insert(id);
|
||||
send(&c, InputKind::MouseButtonDown, id, 0, 0, 0);
|
||||
} else if st.held_buttons.remove(&id) {
|
||||
send(&c, InputKind::MouseButtonUp, id, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set-1 make scancode → US-positional VK for the layout-**variant** typing area (letters, digit
|
||||
/// row, OEM punctuation, the ISO 102nd key) — the exact inverse of the host injector's positional
|
||||
/// table and the Windows analogue of the Linux client's `evdev_to_vk`. Keys not listed (F-row,
|
||||
/// nav cluster, numpad, modifiers — plus every E0-extended key, which the caller filters out)
|
||||
/// have layout-invariant VKs, so the hook's `vkCode` is already correct for them.
|
||||
fn scan_to_positional_vk(scan: u16) -> Option<u8> {
|
||||
Some(match scan {
|
||||
0x02..=0x0A => (scan - 0x02) as u8 + 0x31, // 1..9
|
||||
0x0B => 0x30, // 0
|
||||
0x0C => 0xBD, // -_ VK_OEM_MINUS (DE: ß)
|
||||
0x0D => 0xBB, // =+ VK_OEM_PLUS
|
||||
0x10 => 0x51, // Q
|
||||
0x11 => 0x57, // W
|
||||
0x12 => 0x45, // E
|
||||
0x13 => 0x52, // R
|
||||
0x14 => 0x54, // T
|
||||
0x15 => 0x59, // Y position (QWERTZ: the Z key)
|
||||
0x16 => 0x55, // U
|
||||
0x17 => 0x49, // I
|
||||
0x18 => 0x4F, // O
|
||||
0x19 => 0x50, // P
|
||||
0x1A => 0xDB, // [{ VK_OEM_4 (DE: ü)
|
||||
0x1B => 0xDD, // ]} VK_OEM_6
|
||||
0x1E => 0x41, // A
|
||||
0x1F => 0x53, // S
|
||||
0x20 => 0x44, // D
|
||||
0x21 => 0x46, // F
|
||||
0x22 => 0x47, // G
|
||||
0x23 => 0x48, // H
|
||||
0x24 => 0x4A, // J
|
||||
0x25 => 0x4B, // K
|
||||
0x26 => 0x4C, // L
|
||||
0x27 => 0xBA, // ;: VK_OEM_1 (DE: ö)
|
||||
0x28 => 0xDE, // '" VK_OEM_7 (DE: ä)
|
||||
0x29 => 0xC0, // `~ VK_OEM_3 (DE: ^)
|
||||
0x2B => 0xDC, // \| VK_OEM_5
|
||||
0x2C => 0x5A, // Z position (QWERTZ: the Y key)
|
||||
0x2D => 0x58, // X
|
||||
0x2E => 0x43, // C
|
||||
0x2F => 0x56, // V
|
||||
0x30 => 0x42, // B
|
||||
0x31 => 0x4E, // N
|
||||
0x32 => 0x4D, // M
|
||||
0x33 => 0xBC, // ,< VK_OEM_COMMA
|
||||
0x34 => 0xBE, // .> VK_OEM_PERIOD
|
||||
0x35 => 0xBF, // /? VK_OEM_2
|
||||
0x56 => 0xE2, // <>| VK_OEM_102 (ISO)
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The German-scramble regression pins: the physical keys a QWERTZ board labels Z/Y/ö/ü must
|
||||
/// leave this client as their US-position VKs, regardless of the local layout's vkCode.
|
||||
#[test]
|
||||
fn positional_pins_for_the_qwertz_scramble() {
|
||||
assert_eq!(scan_to_positional_vk(0x15), Some(0x59)); // QWERTZ Z key → VK_Y (US position)
|
||||
assert_eq!(scan_to_positional_vk(0x2C), Some(0x5A)); // QWERTZ Y key → VK_Z (US position)
|
||||
assert_eq!(scan_to_positional_vk(0x27), Some(0xBA)); // ö key → VK_OEM_1 (US ;: position)
|
||||
assert_eq!(scan_to_positional_vk(0x1A), Some(0xDB)); // ü key → VK_OEM_4 (US [{ position)
|
||||
assert_eq!(scan_to_positional_vk(0x28), Some(0xDE)); // ä key → VK_OEM_7 (US '" position)
|
||||
assert_eq!(scan_to_positional_vk(0x0C), Some(0xBD)); // ß key → VK_OEM_MINUS (US -_ position)
|
||||
}
|
||||
|
||||
/// Keys outside the layout-variant typing area stay un-mapped (vkCode passes through).
|
||||
#[test]
|
||||
fn invariant_keys_fall_through() {
|
||||
for scan in [
|
||||
0x01u16, 0x0E, 0x0F, 0x1C, 0x1D, 0x2A, 0x36, 0x38, 0x39, 0x3B, 0x45, 0x57,
|
||||
] {
|
||||
assert_eq!(scan_to_positional_vk(scan), None, "scan 0x{scan:02X}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Exactly the 48 typing-area keys are covered (10 digits + 26 letters + 12 OEM), and every
|
||||
/// mapping is unique — two physical keys must never collapse onto one wire VK.
|
||||
#[test]
|
||||
fn table_covers_the_typing_area_bijectively() {
|
||||
let mapped: Vec<(u16, u8)> = (0u16..=0xFF)
|
||||
.filter_map(|sc| scan_to_positional_vk(sc).map(|vk| (sc, vk)))
|
||||
.collect();
|
||||
assert_eq!(mapped.len(), 48);
|
||||
let mut vks: Vec<u8> = mapped.iter().map(|&(_, vk)| vk).collect();
|
||||
vks.sort_unstable();
|
||||
vks.dedup();
|
||||
assert_eq!(vks.len(), 48, "duplicate wire VK in the positional table");
|
||||
}
|
||||
}
|
||||
+15
-162
@@ -1,17 +1,16 @@
|
||||
//! `punktfunk-client` — the native Windows punktfunk/1 client.
|
||||
//!
|
||||
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · FFmpeg
|
||||
//! decode · WASAPI audio · SDL3 gamepads · a **WinUI 3** shell (windows-reactor) with the video
|
||||
//! on a `SwapChainPanel` bound to a D3D11 composition swapchain. The trust surface mirrors the
|
||||
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · SDL3
|
||||
//! gamepads · a **WinUI 3** shell (windows-reactor). Streaming (decode + present + audio) runs in
|
||||
//! the spawned `punktfunk-session` Vulkan binary; the shell owns host selection, trust and
|
||||
//! pairing. The trust surface mirrors the
|
||||
//! other native clients: persistent identity, trust-on-first-use, SPAKE2 PIN pairing — all in-app
|
||||
//! (host list, settings, pairing). `--headless` keeps a CLI connect path for tests/measurement.
|
||||
//! (host list, settings, pairing). Streaming runs in the spawned `punktfunk-session` binary;
|
||||
//! `--headless --speed-test` keeps a decode-less CLI measurement path.
|
||||
//!
|
||||
//! Usage:
|
||||
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
|
||||
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
|
||||
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
||||
//! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr]
|
||||
//! (no window; count frames + print stats)
|
||||
//! punktfunk-client --headless --speed-test --connect host[:port]
|
||||
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
|
||||
|
||||
@@ -23,29 +22,19 @@
|
||||
#[cfg(windows)]
|
||||
mod app;
|
||||
#[cfg(windows)]
|
||||
mod audio;
|
||||
#[cfg(windows)]
|
||||
mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gamepad;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod input;
|
||||
#[cfg(windows)]
|
||||
mod present;
|
||||
#[cfg(windows)]
|
||||
mod render;
|
||||
#[cfg(windows)]
|
||||
mod session;
|
||||
mod probe;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
#[cfg(windows)]
|
||||
mod spawn;
|
||||
#[cfg(windows)]
|
||||
mod trust;
|
||||
#[cfg(windows)]
|
||||
mod video;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod wol;
|
||||
@@ -124,13 +113,11 @@ fn set_app_user_model_id() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
||||
/// Windows analogue of `punktfunk-probe`.
|
||||
/// `--headless --speed-test --connect host[:port]`: measure the path over the real data plane and
|
||||
/// print the outcome — the Windows analogue of `punktfunk-probe`. The former in-process
|
||||
/// frame-count connect path went with the legacy builtin stream; real streaming is windowed-only.
|
||||
#[cfg(windows)]
|
||||
fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let arg = |name: &str| -> Option<String> {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
@@ -154,7 +141,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
let fp = trust::KnownHosts::load()
|
||||
.find_by_addr(&host, port)
|
||||
.map(|k| k.fp_hex.clone());
|
||||
match session::run_speed_probe(&host, port, fp.as_deref(), identity) {
|
||||
match probe::run_speed_probe(&host, port, fp.as_deref(), identity) {
|
||||
Ok(r) => {
|
||||
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||||
let recommended = f64::from(r.throughput_kbps / 10 * 7) / 1000.0;
|
||||
@@ -171,144 +158,10 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mode = arg("--mode")
|
||||
.and_then(|m| {
|
||||
let mut it = m.split(['x', 'X']);
|
||||
Some(Mode {
|
||||
width: it.next()?.parse().ok()?,
|
||||
height: it.next()?.parse().ok()?,
|
||||
refresh_hz: it.next()?.parse().ok()?,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
});
|
||||
let bitrate_kbps = arg("--bitrate")
|
||||
.and_then(|b| b.parse::<u32>().ok())
|
||||
.map(|m| m * 1000)
|
||||
.unwrap_or(0);
|
||||
|
||||
let known = trust::KnownHosts::load();
|
||||
let mut pin = arg("--pin")
|
||||
.and_then(|h| trust::parse_hex32(&h))
|
||||
.or_else(|| {
|
||||
known
|
||||
.find_by_addr(&host, port)
|
||||
.and_then(|k| trust::parse_hex32(&k.fp_hex))
|
||||
});
|
||||
if let Some(code) = arg("--pair") {
|
||||
let name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
|
||||
match punktfunk_core::client::NativeClient::pair(
|
||||
&host,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
code.trim(),
|
||||
&name,
|
||||
Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let mut k = trust::KnownHosts::load();
|
||||
k.upsert(trust::KnownHost {
|
||||
name: host.clone(),
|
||||
addr: host.clone(),
|
||||
port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
});
|
||||
let _ = k.save();
|
||||
tracing::info!(fp = %trust::hex(&fp), "paired");
|
||||
pin = Some(fp);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Pairing failed: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let decoder = arg("--decoder")
|
||||
.map(|d| crate::video::DecoderPref::from_name(&d))
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), ?decoder, "connecting (headless)");
|
||||
let handle = session::start(session::SessionParams {
|
||||
host,
|
||||
port,
|
||||
mode,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps,
|
||||
// Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings.
|
||||
audio_channels: 2,
|
||||
mic_enabled: flag("--mic"),
|
||||
hdr_enabled: !flag("--no-hdr"),
|
||||
decoder,
|
||||
// `--codec h264|hevc|av1` sets the soft preference; default auto (host decides).
|
||||
preferred_codec: match arg("--codec").as_deref() {
|
||||
Some("h264") | Some("avc") => punktfunk_core::quic::CODEC_H264,
|
||||
Some("hevc") | Some("h265") => punktfunk_core::quic::CODEC_HEVC,
|
||||
Some("av1") => punktfunk_core::quic::CODEC_AV1,
|
||||
_ => 0,
|
||||
},
|
||||
pin,
|
||||
identity,
|
||||
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||
// GUI-only flow.
|
||||
connect_timeout: Duration::from_secs(15),
|
||||
});
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut frames_seen = 0u64;
|
||||
loop {
|
||||
while let Ok(ev) = handle.events.try_recv() {
|
||||
match ev {
|
||||
session::SessionEvent::Connected {
|
||||
mode, fingerprint, ..
|
||||
} => tracing::info!(?mode, fp = %trust::hex(&fingerprint), "connected"),
|
||||
// With per-AU 0xCF host timings the combined host+network stage splits into
|
||||
// host (capture→sent on the host) + net; an old host emits none → combined only.
|
||||
session::SessionEvent::Stats(s) if s.split => tracing::info!(
|
||||
fps = format!("{:.0}", s.fps),
|
||||
mbps = format!("{:.1}", s.mbps),
|
||||
decode_p50_ms = format!("{:.2}", s.decode_ms),
|
||||
hostnet_p50_ms = format!("{:.2}", s.hostnet_ms),
|
||||
host_p50_ms = format!("{:.2}", s.host_ms),
|
||||
net_p50_ms = format!("{:.2}", s.net_ms),
|
||||
frames_seen,
|
||||
"stats"
|
||||
),
|
||||
session::SessionEvent::Stats(s) => tracing::info!(
|
||||
fps = format!("{:.0}", s.fps),
|
||||
mbps = format!("{:.1}", s.mbps),
|
||||
decode_p50_ms = format!("{:.2}", s.decode_ms),
|
||||
hostnet_p50_ms = format!("{:.2}", s.hostnet_ms),
|
||||
frames_seen,
|
||||
"stats"
|
||||
),
|
||||
session::SessionEvent::Failed { msg, .. } => {
|
||||
tracing::error!(%msg, "connect failed");
|
||||
return;
|
||||
}
|
||||
session::SessionEvent::Ended(err) => {
|
||||
tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
while handle.frames.try_recv().is_ok() {
|
||||
frames_seen += 1;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
tracing::info!(frames_seen, "harness deadline — stopping");
|
||||
handle.stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
// Only --speed-test remains headless: real streaming runs in the windowed app's spawned
|
||||
// punktfunk-session binary, which the deleted in-process frame-count path was replaced by.
|
||||
eprintln!("--headless supports only --speed-test now \u{2014} run the windowed app to stream");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit.
|
||||
|
||||
@@ -1,915 +0,0 @@
|
||||
//! Direct3D11 presenter for a WinUI 3 `SwapChainPanel`. It draws a decoded frame Contain-fit into a
|
||||
//! **composition** flip-model swapchain, which the reactor stream page binds to the panel via
|
||||
//! `SwapChainPanelHandle::set_swap_chain`. After that one UI-thread bind, the presenter lives on
|
||||
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
|
||||
//! the XAML thread.
|
||||
//!
|
||||
//! Two frame sources, ONE Y′CbCr→RGB shader whose conversion rows arrive per frame in a constant
|
||||
//! buffer (`pf_client_core::video::csc_rows` from the frame's CICP signaling — identical colour
|
||||
//! math for both sources, and the stream's signaled matrix/range is honored, not assumed):
|
||||
//!
|
||||
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
|
||||
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
|
||||
//! planes; in D3D11 a planar slice is a single subresource** (unlike D3D12) — into our
|
||||
//! sampleable texture, which per-plane SRVs (R8/R8G8, R16/R16G16) expose to the shaders. The
|
||||
//! source box is mandatory: the decode array is coded-size (e.g. 1920×1088), the target
|
||||
//! display-size (1920×1080), and D3D11 silently drops size-mismatched full-resource copies.
|
||||
//! * **CPU upload** — [`crate::video::CpuFrame`] carries NV12/P010 planes from the software
|
||||
//! decoder; they upload into two dynamic plane textures feeding the same SRV slots/shaders.
|
||||
//!
|
||||
//! **Pacing**: the swapchain is created with `DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT`
|
||||
//! and `SetMaximumFrameLatency(1)` (flagless fallback for odd drivers). The render thread waits
|
||||
//! on the latency waitable before drawing, so at most one present is ever queued (minimum compose
|
||||
//! latency) and a stream faster than the display drops frames *before* any GPU work. Every
|
||||
//! `ResizeBuffers` must re-pass the creation flags — that's `swap_flags`.
|
||||
//!
|
||||
//! **HiDPI**: buffers are sized in physical pixels and `IDXGISwapChain2::SetMatrixTransform`
|
||||
//! (scale 96/DPI) maps them to the panel's DIP coordinate space — without it XAML samples a
|
||||
//! DIP-sized buffer up and the video is blurry at 125/150 % scaling.
|
||||
//!
|
||||
//! **HDR10**: when a frame is BT.2020 PQ the swapchain flips to `R10G10B10A2` +
|
||||
//! `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` (+ HDR10 metadata) via `ResizeBuffers`/
|
||||
//! `SetColorSpace1`; the shader output is already PQ-encoded so the compositor maps PQ→display. SDR
|
||||
//! stays 8-bit B8G8R8A8.
|
||||
//!
|
||||
//! All `windows` types here come from the same windows-rs commit as `windows-reactor`, so the
|
||||
//! `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`.
|
||||
|
||||
use crate::video::{CpuFrame, DecodedFrame, GpuFrame};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use windows::core::{Interface, PCSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0};
|
||||
use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3};
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
ID3DBlob, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SRV_DIMENSION_TEXTURE2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::*;
|
||||
use windows::Win32::Graphics::Dxgi::Common::*;
|
||||
use windows::Win32::Graphics::Dxgi::*;
|
||||
use windows::Win32::System::Threading::WaitForSingleObject;
|
||||
|
||||
// One vertex shader (fullscreen triangle) + ONE pixel shader for every colour combination:
|
||||
// tex0 is the luma plane, tex1 the chroma plane, and the Y′CbCr→RGB conversion arrives as three
|
||||
// constant-buffer rows precomputed on the CPU per frame (`pf_client_core::video::csc_rows` —
|
||||
// bit-depth exact, range expansion + the P010 ×65535/65472 high-bit repack folded in). One shader
|
||||
// honors whatever the stream signals (BT.601/709/2020, full/limited, 8/10-bit) instead of the old
|
||||
// two hardcoded matrices — a BT.601-signaled stream (a Linux host's RGB-input NVENC) used to
|
||||
// render with BT.709 coefficients, a constant hue error. A PQ stream's rows yield PQ-encoded
|
||||
// R′G′B′ passed through as-is to the HDR10 swapchain, exactly as before.
|
||||
const SHADER_HLSL: &str = r#"
|
||||
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
||||
VSOut vs_main(uint vid : SV_VertexID) {
|
||||
float2 uv = float2((vid << 1) & 2, vid & 2);
|
||||
VSOut o;
|
||||
o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
|
||||
o.uv = uv;
|
||||
return o;
|
||||
}
|
||||
Texture2D tex0 : register(t0);
|
||||
Texture2D tex1 : register(t1);
|
||||
SamplerState smp : register(s0);
|
||||
cbuffer Csc : register(b0) {
|
||||
float4 r0; // rgb[i] = dot(ri.xyz, yuv) + ri.w
|
||||
float4 r1;
|
||||
float4 r2;
|
||||
};
|
||||
|
||||
float4 ps_yuv(VSOut i) : SV_Target {
|
||||
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||
// texels to re-align (the same correction the Apple client applies). Self-disables when the
|
||||
// plane widths match (a full-size 4:4:4 chroma plane has no subsampling to correct).
|
||||
float lw, lh, cw, ch;
|
||||
tex0.GetDimensions(lw, lh);
|
||||
tex1.GetDimensions(cw, ch);
|
||||
float2 cuv = i.uv;
|
||||
if (cw < lw) { cuv.x += 0.25 / cw; }
|
||||
float3 yuv = float3(tex0.Sample(smp, i.uv).r, tex1.Sample(smp, cuv).rg);
|
||||
float3 rgb = float3(dot(r0.xyz, yuv) + r0.w,
|
||||
dot(r1.xyz, yuv) + r1.w,
|
||||
dot(r2.xyz, yuv) + r2.w);
|
||||
return float4(saturate(rgb), 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
|
||||
/// textures). Redraws (resize, letterbox) re-present it — the CSC constant buffer still holds
|
||||
/// this frame's rows, and the swapchain mode was latched by `set_hdr` when the frame arrived.
|
||||
struct Bound {
|
||||
y: ID3D11ShaderResourceView,
|
||||
c: ID3D11ShaderResourceView,
|
||||
}
|
||||
|
||||
pub struct Presenter {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
vs: ID3D11VertexShader,
|
||||
ps_yuv: ID3D11PixelShader,
|
||||
/// Dynamic constant buffer holding the bound frame's three CSC rows (`csc_rows`), rewritten
|
||||
/// on every bind (colour signaling can flip in-band, e.g. the host's SDR→HDR re-init).
|
||||
csc_buf: ID3D11Buffer,
|
||||
sampler: ID3D11SamplerState,
|
||||
swap: IDXGISwapChain1,
|
||||
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
|
||||
swap_flags: u32,
|
||||
/// The frame-latency waitable (owned; closed in `Drop`), `None` on the flagless fallback.
|
||||
waitable: Option<HANDLE>,
|
||||
rtv: Option<ID3D11RenderTargetView>,
|
||||
/// GPU path: sampleable copy target for the decoded slice — `(tex, w, h, ten_bit)`, recreated
|
||||
/// when the decoded size/bit depth changes. Format must equal the decode array's (NV12/P010).
|
||||
sample_tex: Option<(ID3D11Texture2D, u32, u32, bool)>,
|
||||
/// The last GPU frame, held until the NEXT bind so its decode surface stays out of the reuse
|
||||
/// pool at least until this frame's copy has been queued ahead of any later decoder write.
|
||||
gpu_frame: Option<GpuFrame>,
|
||||
/// CPU path: dynamic luma + chroma plane textures + their SRVs — `(y, uv, y_srv, uv_srv, w, h,
|
||||
/// ten_bit)`, recreated when the decoded size/bit depth changes.
|
||||
#[allow(clippy::type_complexity)]
|
||||
plane_tex: Option<(
|
||||
ID3D11Texture2D,
|
||||
ID3D11Texture2D,
|
||||
ID3D11ShaderResourceView,
|
||||
ID3D11ShaderResourceView,
|
||||
u32,
|
||||
u32,
|
||||
bool,
|
||||
)>,
|
||||
bound: Option<Bound>,
|
||||
/// Source frame dimensions, for the Contain-fit letterbox.
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
/// Panel (swapchain) size in physical pixels + the window DPI, updated on resize.
|
||||
panel_w: u32,
|
||||
panel_h: u32,
|
||||
dpi: u32,
|
||||
/// Whether the swapchain is currently in 10-bit HDR10 (R10G10B10A2 + ST.2084) mode.
|
||||
hdr: bool,
|
||||
/// The source's static HDR mastering metadata received over the protocol (`0xCE`), applied via
|
||||
/// `SetHDRMetaData` so the display tone-maps from the real grade instead of a generic 1000-nit
|
||||
/// guess. `None` until the first update arrives (then the generic baseline is used).
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
}
|
||||
|
||||
/// Latest source HDR mastering metadata, written by the session pump (`session.rs`, the sole
|
||||
/// `next_hdr_meta` consumer) and read by the render thread before each present — decoupled so the
|
||||
/// presenter doesn't need the connector. One session at a time on the client, so a single slot.
|
||||
pub static LATEST_HDR_META: std::sync::Mutex<Option<punktfunk_core::quic::HdrMeta>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
impl Presenter {
|
||||
/// Create the presenter on the process-wide shared D3D11 device (the one the decoder uses), plus
|
||||
/// the composition swapchain + shaders, sized to the panel in physical pixels at `dpi`.
|
||||
pub fn new(width: u32, height: u32, dpi: u32) -> Result<Presenter> {
|
||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||
let device = shared.device.clone();
|
||||
let context = shared.context.clone();
|
||||
let (vs, ps_yuv, sampler) = build_pipeline(&device)?;
|
||||
// The per-frame CSC rows (three float4s). Dynamic: rewritten with Map-discard on bind.
|
||||
let csc_desc = D3D11_BUFFER_DESC {
|
||||
ByteWidth: 48,
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let csc_buf = unsafe {
|
||||
let mut b = None;
|
||||
device
|
||||
.CreateBuffer(&csc_desc, None, Some(&mut b))
|
||||
.context("CreateBuffer (CSC rows)")?;
|
||||
b.ok_or_else(|| anyhow!("null CSC constant buffer"))?
|
||||
};
|
||||
let (swap, swap_flags) =
|
||||
create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
||||
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
|
||||
// when the compositor is ready to take it — the newest-wins drain happens after the wait.
|
||||
let waitable = (swap_flags & DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT.0 as u32
|
||||
!= 0)
|
||||
.then(|| unsafe {
|
||||
let sc2: IDXGISwapChain2 = swap.cast().ok()?;
|
||||
sc2.SetMaximumFrameLatency(1).ok()?;
|
||||
let h = sc2.GetFrameLatencyWaitableObject();
|
||||
(!h.is_invalid()).then_some(h)
|
||||
})
|
||||
.flatten();
|
||||
let p = Presenter {
|
||||
device,
|
||||
context,
|
||||
vs,
|
||||
ps_yuv,
|
||||
csc_buf,
|
||||
sampler,
|
||||
swap,
|
||||
swap_flags,
|
||||
waitable,
|
||||
rtv: None,
|
||||
sample_tex: None,
|
||||
gpu_frame: None,
|
||||
plane_tex: None,
|
||||
bound: None,
|
||||
src_w: 1,
|
||||
src_h: 1,
|
||||
panel_w: width.max(1),
|
||||
panel_h: height.max(1),
|
||||
dpi: dpi.max(96),
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
};
|
||||
p.apply_dpi_matrix();
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Block until the swapchain can take another present (≤ `timeout_ms`). True when a present
|
||||
/// slot is free; also true on the flagless fallback (no throttle available, just present).
|
||||
pub fn wait_present_slot(&self, timeout_ms: u32) -> bool {
|
||||
match self.waitable {
|
||||
Some(h) => unsafe { WaitForSingleObject(h, timeout_ms) == WAIT_OBJECT_0 },
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the source HDR mastering metadata (from the `0xCE` plane). Stored for the next HDR
|
||||
/// swapchain switch, and applied immediately if already presenting HDR. A no-op when unchanged
|
||||
/// (so it's cheap to call every frame from the render loop).
|
||||
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
|
||||
if self.hdr_meta == Some(meta) {
|
||||
return;
|
||||
}
|
||||
self.hdr_meta = Some(meta);
|
||||
if self.hdr {
|
||||
unsafe { self.apply_hdr_metadata() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`.
|
||||
pub fn swap_chain(&self) -> &IDXGISwapChain1 {
|
||||
&self.swap
|
||||
}
|
||||
|
||||
/// Resize the back buffers to the panel's new size in physical pixels at `dpi` (drops the
|
||||
/// stale RTV, re-applies the DIP↔pixel matrix).
|
||||
pub fn resize(&mut self, width: u32, height: u32, dpi: u32) {
|
||||
let dpi = dpi.max(96);
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| (width == self.panel_w && height == self.panel_h && dpi == self.dpi)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.rtv = None; // release all back-buffer refs before ResizeBuffers
|
||||
unsafe {
|
||||
if let Err(e) = self.swap.ResizeBuffers(
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT_UNKNOWN,
|
||||
DXGI_SWAP_CHAIN_FLAG(self.swap_flags as i32),
|
||||
) {
|
||||
tracing::warn!(error = %e, "ResizeBuffers failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.panel_w = width;
|
||||
self.panel_h = height;
|
||||
self.dpi = dpi;
|
||||
self.apply_dpi_matrix();
|
||||
}
|
||||
|
||||
/// Map the pixel-sized buffers into the panel's DIP coordinate space (scale 96/DPI) — XAML
|
||||
/// otherwise stretches whatever size the buffers are to the panel's DIP bounds (blurry).
|
||||
fn apply_dpi_matrix(&self) {
|
||||
let s = 96.0 / self.dpi as f32;
|
||||
if let Ok(sc2) = self.swap.cast::<IDXGISwapChain2>() {
|
||||
let m = DXGI_MATRIX_3X2_F {
|
||||
_11: s,
|
||||
_22: s,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = unsafe { sc2.SetMatrixTransform(&m) } {
|
||||
tracing::warn!(error = %e, "SetMatrixTransform failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Present one decoded frame (Contain-fit) — or, when `frame` is `None`, re-present the last
|
||||
/// one (or black). Called from the render thread. Takes the frame by value: the GPU path
|
||||
/// retains the decoder surface until the next bind.
|
||||
pub fn present(&mut self, frame: Option<DecodedFrame>) {
|
||||
match frame {
|
||||
Some(DecodedFrame::Cpu(c)) => {
|
||||
if c.hdr != self.hdr {
|
||||
self.set_hdr(c.hdr);
|
||||
}
|
||||
if let Err(e) = self.upload(&c) {
|
||||
tracing::warn!(error = %e, "frame upload failed");
|
||||
}
|
||||
}
|
||||
Some(DecodedFrame::Gpu(g)) => {
|
||||
if g.hdr != self.hdr {
|
||||
self.set_hdr(g.hdr);
|
||||
}
|
||||
if let Err(e) = self.bind_gpu(g) {
|
||||
tracing::warn!(error = %e, "GPU frame bind failed");
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.draw();
|
||||
}
|
||||
|
||||
/// Copy the decoded slice into our sampleable texture and build per-plane SRVs over it. The
|
||||
/// decode array is decoder-only (NVIDIA won't bind a decoder array as a shader resource), so
|
||||
/// it can't be sampled directly — one GPU-to-GPU copy makes the frame sampleable on every
|
||||
/// vendor. D3D11 planar semantics: the slice is ONE subresource (both planes copy together),
|
||||
/// and the source box is display-size (the array is coded-size; a full-resource copy would
|
||||
/// size-mismatch and be silently dropped).
|
||||
fn bind_gpu(&mut self, g: GpuFrame) -> Result<()> {
|
||||
let src: ID3D11Texture2D = unsafe {
|
||||
let raw = g.texture_ptr();
|
||||
ID3D11Texture2D::from_raw_borrowed(&raw)
|
||||
.ok_or_else(|| anyhow!("null D3D11 texture"))?
|
||||
.clone()
|
||||
};
|
||||
self.ensure_sample_tex(g.width, g.height, g.ten_bit)?;
|
||||
let dst = self.sample_tex.as_ref().unwrap().0.clone();
|
||||
// Even-aligned luma coordinates (NV12/P010 chroma is 2×2 subsampled).
|
||||
let src_box = D3D11_BOX {
|
||||
left: 0,
|
||||
top: 0,
|
||||
front: 0,
|
||||
right: g.width & !1,
|
||||
bottom: g.height & !1,
|
||||
back: 1,
|
||||
};
|
||||
unsafe {
|
||||
self.context
|
||||
.CopySubresourceRegion(&dst, 0, 0, 0, 0, &src, g.index, Some(&src_box));
|
||||
}
|
||||
let (fy, fc) = plane_formats(g.ten_bit);
|
||||
let y = self.plane_srv(&dst, fy)?;
|
||||
let c = self.plane_srv(&dst, fc)?;
|
||||
self.write_csc_rows(g.color, g.ten_bit)?;
|
||||
self.src_w = g.width;
|
||||
self.src_h = g.height;
|
||||
self.bound = Some(Bound { y, c });
|
||||
// Hold the frame until the next bind: its decode surface stays out of the reuse pool
|
||||
// until this copy is queued ahead of any later decoder write (previous frame drops here).
|
||||
self.gpu_frame = Some(g);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the sampleable copy texture matches the decoded frame's size + bit depth (NV12 for
|
||||
/// 8-bit, P010 for 10-bit — the same format as the decode array, a `CopySubresourceRegion`
|
||||
/// requirement), recreating it on a change.
|
||||
fn ensure_sample_tex(&mut self, w: u32, h: u32, ten_bit: bool) -> Result<()> {
|
||||
if matches!(&self.sample_tex, Some((_, tw, th, tb)) if *tw == w && *th == h && *tb == ten_bit)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: if ten_bit {
|
||||
DXGI_FORMAT_P010
|
||||
} else {
|
||||
DXGI_FORMAT_NV12
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let tex = unsafe {
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D (sample target)")?;
|
||||
t.ok_or_else(|| anyhow!("null sample texture"))?
|
||||
};
|
||||
self.sample_tex = Some((tex, w, h, ten_bit));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A shader-resource view over one plane of a single (non-array) NV12/P010 texture — the
|
||||
/// R8/R8G8 (or R16/R16G16) format selects the luma vs. chroma plane (the D3D11 video
|
||||
/// sub-format trick).
|
||||
fn plane_srv(
|
||||
&self,
|
||||
tex: &ID3D11Texture2D,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<ID3D11ShaderResourceView> {
|
||||
let desc = D3D11_SHADER_RESOURCE_VIEW_DESC {
|
||||
Format: format,
|
||||
ViewDimension: D3D_SRV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_SRV {
|
||||
MostDetailedMip: 0,
|
||||
MipLevels: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
unsafe {
|
||||
let mut srv = None;
|
||||
self.device
|
||||
.CreateShaderResourceView(tex, Some(&desc), Some(&mut srv))
|
||||
.context("CreateShaderResourceView (plane)")?;
|
||||
srv.ok_or_else(|| anyhow!("null SRV"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a software-decoded frame's two planes into the dynamic plane textures (created to
|
||||
/// match size/bit depth), feeding the same SRV slots + shaders as the GPU path.
|
||||
fn upload(&mut self, frame: &CpuFrame) -> Result<()> {
|
||||
let (w, h) = (frame.width, frame.height);
|
||||
let rebuild = !matches!(&self.plane_tex,
|
||||
Some((.., tw, th, tb)) if *tw == w && *th == h && *tb == frame.ten_bit);
|
||||
if rebuild {
|
||||
let (fy, fc) = plane_formats(frame.ten_bit);
|
||||
let y = self.dynamic_tex(w, h, fy)?;
|
||||
let uv = self.dynamic_tex(w.div_ceil(2), h.div_ceil(2), fc)?;
|
||||
let y_srv = self.plane_srv(&y, fy)?;
|
||||
let uv_srv = self.plane_srv(&uv, fc)?;
|
||||
self.plane_tex = Some((y, uv, y_srv, uv_srv, w, h, frame.ten_bit));
|
||||
}
|
||||
let (y, uv, y_srv, uv_srv, ..) = self.plane_tex.as_ref().unwrap();
|
||||
let bytes = if frame.ten_bit { 2 } else { 1 };
|
||||
self.map_rows(y, &frame.y, frame.y_stride, w as usize * bytes, h as usize)?;
|
||||
self.map_rows(
|
||||
uv,
|
||||
&frame.uv,
|
||||
frame.uv_stride,
|
||||
w.div_ceil(2) as usize * 2 * bytes,
|
||||
h.div_ceil(2) as usize,
|
||||
)?;
|
||||
let (y_srv, uv_srv) = (y_srv.clone(), uv_srv.clone());
|
||||
self.write_csc_rows(frame.color, frame.ten_bit)?;
|
||||
self.src_w = w;
|
||||
self.src_h = h;
|
||||
self.bound = Some(Bound {
|
||||
y: y_srv,
|
||||
c: uv_srv,
|
||||
});
|
||||
self.gpu_frame = None; // drop any held GPU frame
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dynamic_tex(&self, w: u32, h: u32, format: DXGI_FORMAT) -> Result<ID3D11Texture2D> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
unsafe {
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D (plane)")?;
|
||||
t.ok_or_else(|| anyhow!("null plane texture"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute the bound frame's Y′CbCr→RGB rows from its CICP signaling and Map-discard them
|
||||
/// into the CSC constant buffer. `ten_bit` selects the 10-bit code points AND the P010
|
||||
/// high-bit repack (the plane SRVs are R16/R16G16 UNORM for 10-bit).
|
||||
fn write_csc_rows(&self, color: pf_client_core::video::ColorDesc, ten_bit: bool) -> Result<()> {
|
||||
let rows = pf_client_core::video::csc_rows(color, if ten_bit { 10 } else { 8 }, ten_bit);
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(
|
||||
&self.csc_buf,
|
||||
0,
|
||||
D3D11_MAP_WRITE_DISCARD,
|
||||
0,
|
||||
Some(&mut mapped),
|
||||
)
|
||||
.context("Map CSC constant buffer")?;
|
||||
std::ptr::copy_nonoverlapping(
|
||||
rows.as_ptr() as *const u8,
|
||||
mapped.pData as *mut u8,
|
||||
48, // [[f32; 4]; 3]
|
||||
);
|
||||
self.context.Unmap(&self.csc_buf, 0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
|
||||
fn map_rows(
|
||||
&self,
|
||||
tex: &ID3D11Texture2D,
|
||||
src: &[u8],
|
||||
src_pitch: usize,
|
||||
row_bytes: usize,
|
||||
rows: usize,
|
||||
) -> Result<()> {
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||
.context("Map plane texture")?;
|
||||
let dst = mapped.pData as *mut u8;
|
||||
let dst_pitch = mapped.RowPitch as usize;
|
||||
let n = row_bytes.min(src_pitch);
|
||||
for r in 0..rows {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
src.as_ptr().add(r * src_pitch),
|
||||
dst.add(r * dst_pitch),
|
||||
n,
|
||||
);
|
||||
}
|
||||
self.context.Unmap(tex, 0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
let Ok(rtv) = self.rtv() else {
|
||||
return;
|
||||
};
|
||||
let (pw, ph) = (self.panel_w, self.panel_h);
|
||||
unsafe {
|
||||
let c = &self.context;
|
||||
c.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 1.0]);
|
||||
if let Some(bound) = &self.bound {
|
||||
// Contain-fit viewport: scale to the smaller axis, centre, letterbox the rest.
|
||||
let (ww, wh, vfw, vfh) = (
|
||||
pw as f32,
|
||||
ph as f32,
|
||||
self.src_w.max(1) as f32,
|
||||
self.src_h.max(1) as f32,
|
||||
);
|
||||
let scale = (ww / vfw).min(wh / vfh);
|
||||
let (dw, dh) = (vfw * scale, vfh * scale);
|
||||
let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.0);
|
||||
c.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
|
||||
let vp = D3D11_VIEWPORT {
|
||||
TopLeftX: ox,
|
||||
TopLeftY: oy,
|
||||
Width: dw,
|
||||
Height: dh,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
c.RSSetViewports(Some(&[vp]));
|
||||
c.IASetInputLayout(None);
|
||||
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
c.VSSetShader(&self.vs, None);
|
||||
c.PSSetShader(&self.ps_yuv, None);
|
||||
c.PSSetConstantBuffers(0, Some(&[Some(self.csc_buf.clone())]));
|
||||
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
|
||||
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||
c.Draw(3, 0);
|
||||
}
|
||||
let _ = self.swap.Present(1, DXGI_PRESENT(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the swapchain between 8-bit SDR (B8G8R8A8, BT.709) and 10-bit HDR10 (R10G10B10A2,
|
||||
/// ST.2084 PQ BT.2020). `ResizeBuffers` changes the back-buffer format in place, so the panel
|
||||
/// binding (`set_swap_chain`) stays valid — no rebind. Both frame sources already produce
|
||||
/// PQ-encoded BT.2020 for HDR, so the colour space is all the compositor needs.
|
||||
fn set_hdr(&mut self, on: bool) {
|
||||
self.rtv = None; // release back-buffer refs before ResizeBuffers
|
||||
let format = if on {
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
};
|
||||
unsafe {
|
||||
if let Err(e) = self.swap.ResizeBuffers(
|
||||
0,
|
||||
self.panel_w,
|
||||
self.panel_h,
|
||||
format,
|
||||
DXGI_SWAP_CHAIN_FLAG(self.swap_flags as i32),
|
||||
) {
|
||||
tracing::warn!(error = %e, "ResizeBuffers for HDR switch failed");
|
||||
return;
|
||||
}
|
||||
let colorspace = if on {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
|
||||
} else {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||
};
|
||||
if let Ok(sc3) = self.swap.cast::<IDXGISwapChain3>() {
|
||||
// Only set a colour space the swapchain accepts for present (on an SDR desktop the
|
||||
// DWM still tone-maps HDR10 → SDR, so leaving the default there is fine).
|
||||
if let Ok(support) = sc3.CheckColorSpaceSupport(colorspace) {
|
||||
if support & DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG_PRESENT.0 as u32 != 0 {
|
||||
if let Err(e) = sc3.SetColorSpace1(colorspace) {
|
||||
// A silent failure here presents PQ content as SDR gamma (crushed/dark) —
|
||||
// surface it instead of swallowing it.
|
||||
tracing::warn!(error = %e, ?colorspace, "SetColorSpace1 failed");
|
||||
}
|
||||
} else if on {
|
||||
tracing::warn!("swapchain rejects BT.2020 PQ present colour space (SDR display?) — DWM tone-maps");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hdr = on;
|
||||
if on {
|
||||
self.apply_hdr_metadata();
|
||||
}
|
||||
}
|
||||
self.apply_dpi_matrix(); // belt-and-braces: keep the DIP mapping across the format switch
|
||||
tracing::info!(hdr = on, "swapchain colour mode switched");
|
||||
}
|
||||
|
||||
/// Push the current `DXGI_HDR_METADATA_HDR10` to the swapchain. Uses the source's received
|
||||
/// mastering metadata when known, else a generic HDR10 baseline. Caller ensures HDR mode.
|
||||
unsafe fn apply_hdr_metadata(&self) {
|
||||
if let Ok(sc4) = self.swap.cast::<IDXGISwapChain4>() {
|
||||
let md = self
|
||||
.hdr_meta
|
||||
.map(hdr_meta_to_dxgi)
|
||||
.unwrap_or_else(generic_hdr10_metadata);
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
&md as *const DXGI_HDR_METADATA_HDR10 as *const u8,
|
||||
std::mem::size_of::<DXGI_HDR_METADATA_HDR10>(),
|
||||
);
|
||||
if let Err(e) = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes)) {
|
||||
tracing::warn!(error = %e, "SetHDRMetaData failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rtv(&mut self) -> Result<ID3D11RenderTargetView> {
|
||||
if self.rtv.is_none() {
|
||||
let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? };
|
||||
let rtv = unsafe {
|
||||
let mut v = None;
|
||||
self.device
|
||||
.CreateRenderTargetView(&back, None, Some(&mut v))
|
||||
.context("CreateRenderTargetView")?;
|
||||
v.unwrap()
|
||||
};
|
||||
self.rtv = Some(rtv);
|
||||
}
|
||||
Ok(self.rtv.clone().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.waitable.take() {
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Luma + chroma plane view formats for NV12 (8-bit) vs P010 (10-in-16-bit).
|
||||
fn plane_formats(ten_bit: bool) -> (DXGI_FORMAT, DXGI_FORMAT) {
|
||||
if ten_bit {
|
||||
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
|
||||
} else {
|
||||
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
|
||||
}
|
||||
}
|
||||
|
||||
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
|
||||
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
|
||||
/// created with (every `ResizeBuffers` must re-pass them).
|
||||
fn create_composition_swapchain(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(IDXGISwapChain1, u32)> {
|
||||
let dxdev: IDXGIDevice = device.cast().context("IDXGIDevice cast")?;
|
||||
let factory: IDXGIFactory2 = unsafe {
|
||||
let adapter = dxdev.GetAdapter().context("GetAdapter")?;
|
||||
adapter.GetParent().context("GetParent (IDXGIFactory2)")?
|
||||
};
|
||||
let mut desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
Stereo: false.into(),
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
Scaling: DXGI_SCALING_STRETCH,
|
||||
SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL,
|
||||
// IGNORE (opaque), not PREMULTIPLIED: the video fills the panel with opaque RGB either way.
|
||||
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
|
||||
Flags: DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT.0 as u32,
|
||||
};
|
||||
unsafe {
|
||||
match factory.CreateSwapChainForComposition(device, &desc, None) {
|
||||
Ok(sc) => Ok((sc, desc.Flags)),
|
||||
Err(e) => {
|
||||
// Odd driver/WARP combinations can reject the waitable — fall back to plain
|
||||
// Present(1) pacing rather than failing the stream page.
|
||||
tracing::warn!(error = %e, "waitable swapchain rejected — creating without");
|
||||
desc.Flags = 0;
|
||||
let sc = factory
|
||||
.CreateSwapChainForComposition(device, &desc, None)
|
||||
.context("CreateSwapChainForComposition")?;
|
||||
Ok((sc, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_pipeline(
|
||||
device: &ID3D11Device,
|
||||
) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
|
||||
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
||||
let yuv_blob = compile(SHADER_HLSL, "ps_yuv", "ps_5_0")?;
|
||||
unsafe {
|
||||
let mut vs = None;
|
||||
device
|
||||
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
||||
.context("CreateVertexShader")?;
|
||||
let mut ps_yuv = None;
|
||||
device
|
||||
.CreatePixelShader(blob_bytes(&yuv_blob), None, Some(&mut ps_yuv))
|
||||
.context("CreatePixelShader (yuv)")?;
|
||||
let sdesc = D3D11_SAMPLER_DESC {
|
||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
MaxLOD: D3D11_FLOAT32_MAX,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sampler = None;
|
||||
device
|
||||
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
||||
.context("CreateSamplerState")?;
|
||||
Ok((vs.unwrap(), ps_yuv.unwrap(), sampler.unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
fn compile(src: &str, entry: &str, target: &str) -> Result<ID3DBlob> {
|
||||
let entry_c = std::ffi::CString::new(entry).unwrap();
|
||||
let target_c = std::ffi::CString::new(target).unwrap();
|
||||
let mut code = None;
|
||||
let mut errors = None;
|
||||
let r = unsafe {
|
||||
D3DCompile(
|
||||
src.as_ptr() as *const _,
|
||||
src.len(),
|
||||
PCSTR::null(),
|
||||
None,
|
||||
None,
|
||||
PCSTR(entry_c.as_ptr() as *const u8),
|
||||
PCSTR(target_c.as_ptr() as *const u8),
|
||||
D3DCOMPILE_OPTIMIZATION_LEVEL3,
|
||||
0,
|
||||
&mut code,
|
||||
Some(&mut errors),
|
||||
)
|
||||
};
|
||||
if r.is_err() {
|
||||
let msg = errors
|
||||
.as_ref()
|
||||
.map(|b| unsafe {
|
||||
let p = b.GetBufferPointer() as *const u8;
|
||||
let n = b.GetBufferSize();
|
||||
String::from_utf8_lossy(std::slice::from_raw_parts(p, n)).to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
return Err(anyhow!("D3DCompile {entry}: {msg}"));
|
||||
}
|
||||
code.ok_or_else(|| anyhow!("D3DCompile produced no bytecode"))
|
||||
}
|
||||
|
||||
fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
|
||||
unsafe {
|
||||
let p = blob.GetBufferPointer() as *const u8;
|
||||
let n = blob.GetBufferSize();
|
||||
std::slice::from_raw_parts(p, n)
|
||||
}
|
||||
}
|
||||
|
||||
/// True if any attached display is currently in HDR (BT.2020 PQ) mode. The client advertises HDR
|
||||
/// caps only when this holds, so an SDR display gets a proper 8-bit BT.709 stream instead of PQ it
|
||||
/// would mis-tone-map (the washed-out/dark failure); an HDR display self-tone-maps from the
|
||||
/// mastering metadata. Coarse — checks ANY output, not the app's specific monitor; a mid-session
|
||||
/// monitor move to/from HDR is a follow-up (the `Reconfigure` downgrade).
|
||||
pub fn display_supports_hdr() -> bool {
|
||||
unsafe {
|
||||
let factory: IDXGIFactory1 = match CreateDXGIFactory1() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut ai = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||
ai += 1;
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||
oi += 1;
|
||||
if let Ok(o6) = output.cast::<IDXGIOutput6>() {
|
||||
if let Ok(desc) = o6.GetDesc1() {
|
||||
if desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The HDR display's colour volume from `IDXGIOutput6::GetDesc1` — the first output currently in
|
||||
/// HDR (BT.2020 PQ) mode, as [`HdrMeta`](punktfunk_core::quic::HdrMeta) for `Hello::display_hdr`.
|
||||
/// The host writes this volume into its virtual display's EDID, so host apps tone-map to THIS
|
||||
/// panel and the PQ stream needs no client-side rescue. Chromaticities come as CIE xy floats
|
||||
/// (×50000 → ST.2086 units, G/B/R order); luminances as nits floats (max ×10000 → 0.0001-cd/m²
|
||||
/// units); `MaxFullFrameLuminance` → MaxFALL (whole nits); MaxCLL stays 0 (a display has no
|
||||
/// content light level). Same ANY-output coarseness as [`display_supports_hdr`] — the session
|
||||
/// gates on that check first, so both look at the same panel in the single-HDR-display case.
|
||||
pub fn display_hdr_volume() -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
let to_2086 = |v: f32| (v * 50000.0).round().clamp(0.0, 65535.0) as u16;
|
||||
unsafe {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut ai = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||
ai += 1;
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||
oi += 1;
|
||||
let Ok(o6) = output.cast::<IDXGIOutput6>() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(desc) = o6.GetDesc1() else { continue };
|
||||
if desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||
continue;
|
||||
}
|
||||
return Some(punktfunk_core::quic::HdrMeta {
|
||||
// ST.2086 order is G, B, R.
|
||||
display_primaries: [
|
||||
[to_2086(desc.GreenPrimary[0]), to_2086(desc.GreenPrimary[1])],
|
||||
[to_2086(desc.BluePrimary[0]), to_2086(desc.BluePrimary[1])],
|
||||
[to_2086(desc.RedPrimary[0]), to_2086(desc.RedPrimary[1])],
|
||||
],
|
||||
white_point: [to_2086(desc.WhitePoint[0]), to_2086(desc.WhitePoint[1])],
|
||||
max_display_mastering_luminance: (desc.MaxLuminance.max(0.0) * 10_000.0).round()
|
||||
as u32,
|
||||
min_display_mastering_luminance: (desc.MinLuminance.max(0.0) * 10_000.0).round()
|
||||
as u32,
|
||||
max_cll: 0,
|
||||
max_fall: desc.MaxFullFrameLuminance.max(0.0).round() as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
||||
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
||||
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||
DXGI_HDR_METADATA_HDR10 {
|
||||
RedPrimary: [35400, 14600],
|
||||
GreenPrimary: [8500, 39850],
|
||||
BluePrimary: [6550, 2300],
|
||||
WhitePoint: [15635, 16450],
|
||||
MaxMasteringLuminance: 1000,
|
||||
MinMasteringLuminance: 1, // 0.0001-nit units → 0.0001 nits
|
||||
MaxContentLightLevel: 1000,
|
||||
MaxFrameAverageLightLevel: 400,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the protocol's [`HdrMeta`](punktfunk_core::quic::HdrMeta) to `DXGI_HDR_METADATA_HDR10`.
|
||||
/// Two careful conversions: HdrMeta stores primaries in **ST.2086 G,B,R order**, DXGI wants
|
||||
/// **R,G,B**; and HdrMeta mastering luminance is in **0.0001-cd/m² units** while DXGI's
|
||||
/// `MaxMasteringLuminance` is in **whole nits** (MinMasteringLuminance stays 0.0001-nit). Chromaticity
|
||||
/// units (1/50000) and MaxCLL/MaxFALL (nits) match 1:1.
|
||||
fn hdr_meta_to_dxgi(m: punktfunk_core::quic::HdrMeta) -> DXGI_HDR_METADATA_HDR10 {
|
||||
let [g, b, r] = m.display_primaries; // ST.2086 order
|
||||
DXGI_HDR_METADATA_HDR10 {
|
||||
RedPrimary: r,
|
||||
GreenPrimary: g,
|
||||
BluePrimary: b,
|
||||
WhitePoint: m.white_point,
|
||||
MaxMasteringLuminance: m.max_display_mastering_luminance / 10_000, // 0.0001-nit → nit
|
||||
MinMasteringLuminance: m.min_display_mastering_luminance, // already 0.0001-nit
|
||||
MaxContentLightLevel: m.max_cll,
|
||||
MaxFrameAverageLightLevel: m.max_fall,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Network speed-test probe — the GUI's per-host "Test Network Speed…" ([`crate::app`]'s
|
||||
//! speed page) and the `--headless --speed-test` CLI.
|
||||
//!
|
||||
//! Split out of the former in-process session module: the shared spawned-`punktfunk-session`
|
||||
//! binary owns real streaming now, but the speed test is a shell-side, decode-less measurement
|
||||
//! 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.
|
||||
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
|
||||
}
|
||||
|
||||
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
|
||||
/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is
|
||||
/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to
|
||||
/// completion. Run on a worker thread.
|
||||
pub fn run_speed_probe(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: Option<&str>,
|
||||
identity: (String, String),
|
||||
) -> Result<punktfunk_core::client::ProbeOutcome, String> {
|
||||
// Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU.
|
||||
let pin = fp_hex.and_then(crate::trust::parse_hex32);
|
||||
let c = NativeClient::connect(
|
||||
addr,
|
||||
port,
|
||||
Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps: host default
|
||||
0, // video_caps: probe connect, nothing is decoded
|
||||
2, // audio_channels: stereo baseline
|
||||
decodable_codecs(),
|
||||
0, // preferred_codec: no preference
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
None, // launch: no game
|
||||
pin,
|
||||
Some(identity),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.map_err(|e| format!("connect: {e:?}"))?;
|
||||
c.request_probe(3_000_000, 2_000)
|
||||
.map_err(|e| format!("probe: {e:?}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if c.probe_result().done {
|
||||
// Let the last UDP shards land before tearing down.
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
return Ok(c.probe_result());
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("probe timed out".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! The dedicated video render thread: decoded frames flow session pump → bounded channel → here →
|
||||
//! `Presenter::present`. Presenting off the XAML thread means UI jank (layout, input, dialogs)
|
||||
//! never stalls video, and a filled present queue never blocks the UI thread — the two failure
|
||||
//! modes of the old present-from-`on_rendering` design.
|
||||
//!
|
||||
//! Pacing: block on the channel (the host paces the stream), then on the swapchain's
|
||||
//! frame-latency waitable (≤1 queued present — see `present.rs`), then drain to the NEWEST frame
|
||||
//! so a stream faster than the display drops backlog before any GPU work. The UI thread only
|
||||
//! writes panel size/DPI into [`RenderShared`] atomics; the loop applies them before the next
|
||||
//! draw (and redraws the held frame after a resize — fresh back buffers are blank).
|
||||
|
||||
use crate::present::Presenter;
|
||||
use crate::session::{FrameRx, FrameTimes};
|
||||
use crossbeam_channel::RecvTimeoutError;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, 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, the end-to-end (capture→on-glass)
|
||||
/// p50/p95 and the `display` stage (decoded→displayed) p50, all stamped post-`Present()`, 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 E2E_P50_US: AtomicU64 = AtomicU64::new(0);
|
||||
static E2E_P95_US: AtomicU64 = AtomicU64::new(0);
|
||||
static DISPLAY_P50_US: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The last render window's glass-side numbers (see the statics above) — the HUD's headline
|
||||
/// (end-to-end) and trailing stage (display) come from here.
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub struct PresentStats {
|
||||
/// Presents per second (includes resize redraws of a held frame).
|
||||
pub fps: u32,
|
||||
/// Frames dropped by the newest-wins drain this window (client-side pacing skips).
|
||||
pub skipped: u32,
|
||||
/// End-to-end capture→displayed p50, ms (host-clock corrected, measured directly).
|
||||
pub e2e_p50_ms: f32,
|
||||
/// End-to-end capture→displayed p95, ms.
|
||||
pub e2e_p95_ms: f32,
|
||||
/// `display` stage p50, ms: decoded → displayed, single-clock client-local.
|
||||
pub display_p50_ms: f32,
|
||||
}
|
||||
|
||||
pub fn present_stats() -> PresentStats {
|
||||
PresentStats {
|
||||
fps: PRESENT_FPS.load(Ordering::Relaxed),
|
||||
skipped: PRESENT_SKIPPED.load(Ordering::Relaxed),
|
||||
e2e_p50_ms: E2E_P50_US.load(Ordering::Relaxed) as f32 / 1000.0,
|
||||
e2e_p95_ms: E2E_P95_US.load(Ordering::Relaxed) as f32 / 1000.0,
|
||||
display_p50_ms: DISPLAY_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 {
|
||||
size_px: AtomicU64,
|
||||
dpi: AtomicU32,
|
||||
stop: AtomicBool,
|
||||
}
|
||||
|
||||
impl RenderShared {
|
||||
pub fn new(width: u32, height: u32, dpi: u32) -> Arc<RenderShared> {
|
||||
Arc::new(RenderShared {
|
||||
size_px: AtomicU64::new(pack(width, height)),
|
||||
dpi: AtomicU32::new(dpi),
|
||||
stop: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_size(&self, width: u32, height: u32) {
|
||||
self.size_px.store(pack(width, height), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_dpi(&self, dpi: u32) {
|
||||
self.dpi.store(dpi, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> (u32, u32, u32) {
|
||||
let s = self.size_px.load(Ordering::Relaxed);
|
||||
((s >> 32) as u32, s as u32, self.dpi.load(Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
fn pack(w: u32, h: u32) -> u64 {
|
||||
((w as u64) << 32) | h as u64
|
||||
}
|
||||
|
||||
/// Handle owned by the stream page; stops + joins the thread on unmount (and on drop, so a
|
||||
/// navigation away can't leak a presenting thread).
|
||||
pub struct RenderThread {
|
||||
shared: Arc<RenderShared>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RenderThread {
|
||||
pub fn shared(&self) -> &Arc<RenderShared> {
|
||||
&self.shared
|
||||
}
|
||||
|
||||
pub fn stop_and_join(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenderThread {
|
||||
fn drop(&mut self) {
|
||||
self.stop_and_join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the presenter (COM interfaces, `!Send` by default) onto the render thread. Sound here:
|
||||
/// the shared device + immediate context are multithread-protected (see `crate::gpu`), D3D/DXGI
|
||||
/// objects are apartment-agile, and after this one handoff the swapchain/RTV/context calls happen
|
||||
/// on exactly the render thread — the same single-owner discipline as `SharedDevice`.
|
||||
struct SendPresenter(Presenter);
|
||||
unsafe impl Send for SendPresenter {}
|
||||
|
||||
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
|
||||
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
|
||||
/// (same math as the pump's host+network stage). A live handle (loaded per present) so
|
||||
/// mid-stream clock re-syncs keep the number honest after an NTP step / drift.
|
||||
pub fn spawn(
|
||||
presenter: Presenter,
|
||||
frames: FrameRx,
|
||||
shared: Arc<RenderShared>,
|
||||
clock_offset_ns: Arc<AtomicI64>,
|
||||
) -> RenderThread {
|
||||
let boxed = SendPresenter(presenter);
|
||||
let shared_w = shared.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-render".into())
|
||||
.spawn(move || run(boxed, frames, shared_w, clock_offset_ns))
|
||||
.expect("spawn render thread");
|
||||
RenderThread {
|
||||
shared,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The window DPI, polled ~1 Hz as belt-and-braces for a monitor move that changes DPI without a
|
||||
/// `SizeChanged` (same DIP size on both screens). `None` when the window isn't up (headless).
|
||||
fn poll_window_dpi() -> Option<u32> {
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
|
||||
unsafe {
|
||||
let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?;
|
||||
match GetDpiForWindow(hwnd) {
|
||||
0 => None,
|
||||
d => Some(d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
presenter: SendPresenter,
|
||||
frames: FrameRx,
|
||||
shared: Arc<RenderShared>,
|
||||
clock_offset_ns: Arc<AtomicI64>,
|
||||
) {
|
||||
let mut p = presenter.0;
|
||||
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
|
||||
let mut presented = 0u32;
|
||||
let mut dropped = 0u32;
|
||||
// 1 s tumbling windows: end-to-end (capture→displayed) and the display stage
|
||||
// (decoded→displayed), sampled post-Present. Percentiles only (spec: stats-unification.md).
|
||||
let mut e2e_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut display_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);
|
||||
E2E_P50_US.store(0, Ordering::Relaxed);
|
||||
E2E_P95_US.store(0, Ordering::Relaxed);
|
||||
DISPLAY_P50_US.store(0, Ordering::Relaxed);
|
||||
|
||||
loop {
|
||||
if shared.stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let first = match frames.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(f) => Some(f),
|
||||
Err(RecvTimeoutError::Timeout) => None,
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
|
||||
if last_dpi_poll.elapsed() >= Duration::from_secs(1) {
|
||||
last_dpi_poll = Instant::now();
|
||||
if let Some(dpi) = poll_window_dpi() {
|
||||
shared.set_dpi(dpi);
|
||||
}
|
||||
}
|
||||
let snap = shared.snapshot();
|
||||
let resized = snap != applied && snap.0 > 0 && snap.1 > 0;
|
||||
if resized {
|
||||
p.resize(snap.0, snap.1, snap.2);
|
||||
applied = snap;
|
||||
}
|
||||
if first.is_none() && !resized {
|
||||
continue; // nothing new to show — don't burn GPU re-presenting a static frame
|
||||
}
|
||||
|
||||
// Throttle to the compositor: with ≤1 present outstanding this returns as DWM frees a
|
||||
// slot, and frames decoded meanwhile are drained below so the newest is what's drawn.
|
||||
if !p.wait_present_slot(1000) {
|
||||
tracing::debug!("frame-latency waitable timed out — presenting anyway");
|
||||
}
|
||||
let mut newest = first;
|
||||
while let Ok(f) = frames.try_recv() {
|
||||
if newest.is_some() {
|
||||
dropped += 1;
|
||||
}
|
||||
newest = Some(f);
|
||||
}
|
||||
|
||||
// The session pump is the sole 0xCE consumer and stashes the latest here (rare updates).
|
||||
if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() {
|
||||
p.set_hdr_metadata(meta);
|
||||
}
|
||||
|
||||
let times: Option<FrameTimes> = newest.as_ref().map(|(_, t)| *t);
|
||||
p.present(newest.map(|(f, _)| f));
|
||||
presented += 1;
|
||||
if let Some(t) = times {
|
||||
// The `displayed` point: post-Present() on this thread (the honest best-effort
|
||||
// presentation instant on Windows — endpoint label `capture→on-glass`).
|
||||
let displayed_ns = now_ns();
|
||||
// End-to-end = capture → displayed, host-clock corrected, measured directly
|
||||
// (never the sum of stage percentiles). Clamped (0, 10 s).
|
||||
let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128
|
||||
- t.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
e2e_us.push(e2e / 1000);
|
||||
}
|
||||
// `display` stage = decoded → displayed, single-clock client-local.
|
||||
let disp = displayed_ns.saturating_sub(t.decoded_ns);
|
||||
if disp < 10_000_000_000 {
|
||||
display_us.push(disp / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
e2e_us.sort_unstable();
|
||||
display_us.sort_unstable();
|
||||
let p50 = |v: &[u64]| v.get(v.len() / 2).copied().unwrap_or(0);
|
||||
// p95 = sorted[min(len*95/100, len-1)] — the empty-window case falls to 0 via `get`.
|
||||
let p95 = |v: &[u64]| {
|
||||
v.get((v.len() * 95 / 100).min(v.len().saturating_sub(1)))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
tracing::debug!(
|
||||
presented,
|
||||
dropped,
|
||||
e2e_p50_us = p50(&e2e_us),
|
||||
e2e_p95_us = p95(&e2e_us),
|
||||
display_p50_us = p50(&display_us),
|
||||
"render window"
|
||||
);
|
||||
PRESENT_FPS.store(presented, Ordering::Relaxed);
|
||||
PRESENT_SKIPPED.store(dropped, Ordering::Relaxed);
|
||||
E2E_P50_US.store(p50(&e2e_us), Ordering::Relaxed);
|
||||
E2E_P95_US.store(p95(&e2e_us), Ordering::Relaxed);
|
||||
DISPLAY_P50_US.store(p50(&display_us), Ordering::Relaxed);
|
||||
window_start = Instant::now();
|
||||
presented = 0;
|
||||
dropped = 0;
|
||||
e2e_us.clear();
|
||||
display_us.clear();
|
||||
}
|
||||
}
|
||||
tracing::info!("render thread exiting");
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
|
||||
//! pull + Opus decode, stats), feeding the UI over channels. The UI keeps the
|
||||
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
|
||||
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
|
||||
//! video+audio here, rumble+hidout on the gamepad thread.
|
||||
//!
|
||||
//! Ported from the GTK Linux client; the platform-specific pieces are the video decoder
|
||||
//! (software-only here) and the audio backend (WASAPI). The pump body is identical.
|
||||
|
||||
use crate::audio;
|
||||
use crate::video::{DecodedFrame, Decoder, DecoderPref};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::PunktfunkError;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct SessionParams {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub mode: Mode,
|
||||
pub compositor: CompositorPref,
|
||||
pub gamepad: GamepadPref,
|
||||
pub bitrate_kbps: u32,
|
||||
/// Requested audio channel count (2/6/8); the host echoes the resolved value.
|
||||
pub audio_channels: u8,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream.
|
||||
pub hdr_enabled: bool,
|
||||
/// Which video decode backend to use (auto/hardware/software).
|
||||
pub decoder: DecoderPref,
|
||||
/// The user's preferred video codec (a `quic::CODEC_*` bit, `0` = auto). Soft — the host honors
|
||||
/// it when it can emit it, else falls back; the resolved codec drives the decoder.
|
||||
pub preferred_codec: u8,
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
pub pin: Option<[u8; 32]>,
|
||||
pub identity: (String, String),
|
||||
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub struct Stats {
|
||||
/// AUs received (reassembled) per second — actual-elapsed-time denominator.
|
||||
pub fps: f32,
|
||||
/// Received payload goodput (excludes FEC overhead).
|
||||
pub mbps: f32,
|
||||
/// `decode` stage p50 over the last 1 s window: received → decoded, client-local clock.
|
||||
pub decode_ms: f32,
|
||||
/// `host+network` stage p50 over the last 1 s window: capture (`pts_ns`) → received,
|
||||
/// host-clock corrected via `clock_offset_ns`.
|
||||
pub hostnet_ms: f32,
|
||||
/// `host` stage p50 (host capture→sent, from the per-AU 0xCF host-timing plane). Valid only
|
||||
/// when `split` — an old host emits no 0xCF and the HUD keeps the combined stage.
|
||||
pub host_ms: f32,
|
||||
/// `network` stage p50 (`hostnet − host`, tiled per frame before taking the percentile).
|
||||
/// Valid only when `split`.
|
||||
pub net_ms: f32,
|
||||
/// True when any 0xCF host timings matched received AUs this window — the HUD then renders
|
||||
/// `host + network` instead of the combined `host+network` term.
|
||||
pub split: bool,
|
||||
/// True when `clock_offset_ns == 0` (host didn't answer the skew handshake / same host) —
|
||||
/// the HUD appends `(same-host clock)` to the end-to-end line.
|
||||
pub same_host: bool,
|
||||
/// 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 {
|
||||
Connected {
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
fingerprint: [u8; 32],
|
||||
},
|
||||
/// `trust_rejected` is set when the connect failed the TLS trust check (a `Crypto`
|
||||
/// error): for a pinned connect this is the fingerprint-changed signal, so the UI can
|
||||
/// offer a re-pair (PIN) path rather than a dead-end error.
|
||||
Failed {
|
||||
msg: String,
|
||||
trust_rejected: bool,
|
||||
},
|
||||
Ended(Option<String>),
|
||||
Stats(Stats),
|
||||
}
|
||||
|
||||
/// Per-frame measurement points carried with a decoded frame to the render thread: the host
|
||||
/// capture clock (`pts_ns`) and our local `decoded` stamp (wall-clock ns). Post-`Present()` the
|
||||
/// render thread derives the `display` stage (displayed − decoded, single-clock) and the
|
||||
/// end-to-end headline (displayed + clock_offset − pts) from them.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FrameTimes {
|
||||
pub pts_ns: u64,
|
||||
pub decoded_ns: u64,
|
||||
}
|
||||
|
||||
/// Decoded frames + their measurement points, session pump → render thread (crossbeam so that
|
||||
/// thread can block with a timeout — async-channel has no `recv_timeout`).
|
||||
pub type FrameRx = crossbeam_channel::Receiver<(DecodedFrame, FrameTimes)>;
|
||||
|
||||
pub struct SessionHandle {
|
||||
pub events: async_channel::Receiver<SessionEvent>,
|
||||
pub frames: FrameRx,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
|
||||
/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is
|
||||
/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to
|
||||
/// completion. Run on a worker thread.
|
||||
pub fn run_speed_probe(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: Option<&str>,
|
||||
identity: (String, String),
|
||||
) -> Result<punktfunk_core::client::ProbeOutcome, String> {
|
||||
// Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU.
|
||||
let pin = fp_hex.and_then(crate::trust::parse_hex32);
|
||||
let c = NativeClient::connect(
|
||||
addr,
|
||||
port,
|
||||
Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps: host default
|
||||
0, // video_caps: probe connect, nothing is decoded
|
||||
2, // audio_channels: stereo baseline
|
||||
crate::video::decodable_codecs(),
|
||||
0, // preferred_codec: no preference
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
None, // launch: no game
|
||||
pin,
|
||||
Some(identity),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.map_err(|e| format!("connect: {e:?}"))?;
|
||||
c.request_probe(3_000_000, 2_000)
|
||||
.map_err(|e| format!("probe: {e:?}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if c.probe_result().done {
|
||||
// Let the last UDP shards land before tearing down.
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
return Ok(c.probe_result());
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("probe timed out".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(params: SessionParams) -> SessionHandle {
|
||||
let (ev_tx, ev_rx) = async_channel::unbounded();
|
||||
// Tiny frame queue, newest wins: the pump displaces the oldest when the renderer lags (it
|
||||
// keeps a Receiver clone for exactly that).
|
||||
let (frame_tx, frame_rx) = crossbeam_channel::bounded(2);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_w = stop.clone();
|
||||
let frame_rx_pump = frame_rx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-session".into())
|
||||
.spawn(move || pump(params, ev_tx, frame_tx, frame_rx_pump, stop_w))
|
||||
.expect("spawn session thread");
|
||||
SessionHandle {
|
||||
events: ev_rx,
|
||||
frames: frame_rx,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||
/// via the shared layout table.
|
||||
enum AudioDec {
|
||||
Stereo(opus::Decoder),
|
||||
Surround(opus::MSDecoder),
|
||||
}
|
||||
|
||||
impl AudioDec {
|
||||
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||
if channels == 2 {
|
||||
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||
48_000,
|
||||
opus::Channels::Stereo,
|
||||
)?))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||
48_000, l.streams, l.coupled, l.mapping,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_float(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
out: &mut [f32],
|
||||
fec: bool,
|
||||
) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump(
|
||||
params: SessionParams,
|
||||
ev_tx: async_channel::Sender<SessionEvent>,
|
||||
frame_tx: crossbeam_channel::Sender<(DecodedFrame, FrameTimes)>,
|
||||
frame_rx: FrameRx,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
|
||||
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
|
||||
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
|
||||
// rather than PQ the panel would mis-tone-map (washed-out/dark). The presenter handles BT.2020
|
||||
// PQ frames (P010 / X2BGR10).
|
||||
let hdr_active = params.hdr_enabled && crate::present::display_supports_hdr();
|
||||
if params.hdr_enabled && !hdr_active {
|
||||
tracing::info!("HDR enabled in settings but no HDR display detected — requesting SDR");
|
||||
}
|
||||
// With HDR active, also report the panel's real colour volume (GetDesc1): the host writes it
|
||||
// into its virtual display's EDID, so host apps tone-map to THIS panel and the PQ stream
|
||||
// arrives already inside its volume — the client presents it untouched.
|
||||
// PUNKTFUNK_CLIENT_PEAK_NITS pins a synthetic volume for A/B runs.
|
||||
let display_hdr = if hdr_active {
|
||||
let vol = punktfunk_core::client::display_hdr_env_override()
|
||||
.or_else(crate::present::display_hdr_volume);
|
||||
if let Some(m) = vol {
|
||||
tracing::info!(
|
||||
max_nits = m.max_display_mastering_luminance / 10_000,
|
||||
min_millinits = m.min_display_mastering_luminance / 10,
|
||||
max_fall = m.max_fall,
|
||||
"advertising this display's HDR volume to the host"
|
||||
);
|
||||
}
|
||||
vol
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
params.mode,
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
if hdr_active {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
display_hdr,
|
||||
None, // launch: the Windows client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
let trust_rejected = matches!(e, PunktfunkError::Crypto);
|
||||
let msg = match e {
|
||||
PunktfunkError::Crypto => {
|
||||
"Host identity rejected — wrong fingerprint, or the host requires pairing"
|
||||
.to_string()
|
||||
}
|
||||
PunktfunkError::Timeout => "Connection timed out".to_string(),
|
||||
other => format!("Connect failed: {other:?}"),
|
||||
};
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Connected {
|
||||
connector: connector.clone(),
|
||||
mode: connector.mode(),
|
||||
fingerprint: connector.host_fingerprint,
|
||||
});
|
||||
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
let mut decoder = match Decoder::new(params.decoder, codec_id) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut hardware = decoder.is_hardware();
|
||||
let mut hdr = false;
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||
// resolves stereo is decoded as stereo.
|
||||
let channels = connector.audio_channels;
|
||||
let player = audio::AudioPlayer::spawn(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||
.ok();
|
||||
let mut opus_dec = AudioDec::new(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||
.ok();
|
||||
let _mic = params
|
||||
.mic_enabled
|
||||
.then(|| {
|
||||
audio::MicStreamer::spawn(connector.clone())
|
||||
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
|
||||
// Force an immediate IDR (with in-band parameter sets) rather than waiting for the host's own
|
||||
// first keyframe — under infinite GOP a late/missed IDR means the decoder sits on
|
||||
// "PPS id out of range" (a black screen) until one arrives.
|
||||
let _ = connector.request_keyframe();
|
||||
|
||||
// Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
let clock_offset_live = connector.clock_offset_shared();
|
||||
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;
|
||||
// 1 s tumbling stage windows (spec: design/stats-unification.md — percentiles, never means).
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Host/network split (Phase 2): received AUs awaiting their 0xCF host timing, `(pts_ns,
|
||||
// hostnet_us)`, matched as the datagrams arrive. Bounded — an old host never sends any.
|
||||
let mut pending_split: std::collections::VecDeque<(u64, u64)> =
|
||||
std::collections::VecDeque::with_capacity(256);
|
||||
let mut host_us_w: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut net_us_w: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
|
||||
let end: Option<String> = loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break None;
|
||||
}
|
||||
match connector.next_frame(Duration::from_millis(4)) {
|
||||
Ok(frame) => {
|
||||
// The `received` point: AU fully reassembled, handed to us, before decode.
|
||||
let received_ns = now_ns();
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||
// invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers with a cheap
|
||||
// clean P-frame instead of a full IDR. The frames_dropped keyframe path below is the
|
||||
// backstop for when the recovery frame itself is lost.
|
||||
let _ = connector.note_frame_index(frame.frame_index);
|
||||
// fps = AUs received per second, Mb/s = received goodput (spec: counted at the
|
||||
// received point, not the decoded one).
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
||||
let clock_offset = clock_offset_live.load(Ordering::Relaxed);
|
||||
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if hostnet > 0 && hostnet < 10_000_000_000 {
|
||||
hostnet_us.push(hostnet / 1000);
|
||||
// Remember this AU for the 0xCF match below (host/network split).
|
||||
pending_split.push_back((frame.pts_ns, hostnet / 1000));
|
||||
if pending_split.len() > 256 {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
// A D3D11VA→software demotion (see `Decoder::decode`) starts a FRESH decoder that
|
||||
// has none of the stream's parameter sets; under infinite GOP it would sit on
|
||||
// "PPS id out of range" forever. Detect the transition and force a new IDR so the
|
||||
// rebuilt decoder resynchronizes immediately.
|
||||
let was_hw = decoder.is_hardware();
|
||||
let decoded = decoder.decode(&frame.data);
|
||||
if was_hw && !decoder.is_hardware() {
|
||||
tracing::info!("decoder demoted to software — requesting keyframe to resync");
|
||||
let _ = connector.request_keyframe();
|
||||
}
|
||||
match decoded {
|
||||
Ok(Some(decoded)) => {
|
||||
// The `decoded` point: decoder output frame available.
|
||||
let decoded_ns = now_ns();
|
||||
total_frames += 1;
|
||||
hdr = decoded.hdr();
|
||||
// The backend can demote D3D11VA → software mid-session on a hardware error.
|
||||
hardware = decoder.is_hardware();
|
||||
if total_frames == 1 {
|
||||
let (w, h) = decoded.dims();
|
||||
tracing::info!(
|
||||
width = w,
|
||||
height = h,
|
||||
path = if hardware { "d3d11va" } else { "software" },
|
||||
hdr,
|
||||
"first frame decoded"
|
||||
);
|
||||
}
|
||||
// `decode` stage: received → decoded, single-clock client-local.
|
||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
||||
// Newest wins: displace the oldest queued frame when the renderer lags.
|
||||
if let Err(crossbeam_channel::TrySendError::Full(item)) =
|
||||
frame_tx.try_send((
|
||||
decoded,
|
||||
FrameTimes {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
},
|
||||
))
|
||||
{
|
||||
let _ = frame_rx.try_recv();
|
||||
let _ = frame_tx.try_send(item);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||
Err(e) => break Some(format!("session: {e:?}")),
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder conceals the
|
||||
// reference-missing delta frames that follow and returns Ok, so keying off a decode error
|
||||
// rarely fires. Request an IDR when the drop count climbs, throttled.
|
||||
let dropped = connector.frames_dropped();
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!(dropped, "requested keyframe (loss recovery)");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
|
||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the HDR static-metadata plane (0xCE): the source's real mastering display + content
|
||||
// light level. Stash the latest for the UI-thread presenter to apply via SetHDRMetaData —
|
||||
// this pump is the sole consumer of the plane. Rare (start + on change/keyframe).
|
||||
while let Ok(meta) = connector.next_hdr_meta(Duration::ZERO) {
|
||||
*crate::present::LATEST_HDR_META.lock().unwrap() = Some(meta);
|
||||
}
|
||||
|
||||
// Drain the per-AU host-timing plane (0xCF) and match by pts: `host` = the host's own
|
||||
// capture→sent, `network` = our capture→received minus it — the two tile per frame
|
||||
// (design/stats-unification.md Phase 2). An old host never emits any; `split` stays false
|
||||
// and the HUD keeps the combined `host+network` stage.
|
||||
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
|
||||
let (_, hn_us) = pending_split.remove(i).unwrap();
|
||||
host_us_w.push(t.host_us as u64);
|
||||
net_us_w.push(hn_us.saturating_sub(t.host_us as u64));
|
||||
}
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
hostnet_us.sort_unstable();
|
||||
decode_us.sort_unstable();
|
||||
host_us_w.sort_unstable();
|
||||
net_us_w.sort_unstable();
|
||||
let p50 = |v: &[u64]| v.get(v.len() / 2).copied().unwrap_or(0);
|
||||
let (hostnet_p50, decode_p50) = (p50(&hostnet_us), p50(&decode_us));
|
||||
let (host_p50, net_p50) = (p50(&host_us_w), p50(&net_us_w));
|
||||
let split = !host_us_w.is_empty();
|
||||
tracing::debug!(
|
||||
fps = frames_n,
|
||||
hostnet_p50_us = hostnet_p50,
|
||||
host_p50_us = host_p50,
|
||||
net_p50_us = net_p50,
|
||||
split,
|
||||
decode_p50_us = decode_p50,
|
||||
total_frames,
|
||||
"stream window"
|
||||
);
|
||||
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
|
||||
fps: frames_n as f32 / secs,
|
||||
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
|
||||
decode_ms: decode_p50 as f32 / 1000.0,
|
||||
hostnet_ms: hostnet_p50 as f32 / 1000.0,
|
||||
host_ms: host_p50 as f32 / 1000.0,
|
||||
net_ms: net_p50 as f32 / 1000.0,
|
||||
split,
|
||||
same_host: clock_offset_live.load(Ordering::Relaxed) == 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;
|
||||
bytes_n = 0;
|
||||
hostnet_us.clear();
|
||||
decode_us.clear();
|
||||
host_us_w.clear();
|
||||
net_us_w.clear();
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
total_frames,
|
||||
reason = end.as_deref().unwrap_or("user"),
|
||||
"session ended"
|
||||
);
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||
}
|
||||
@@ -6,10 +6,6 @@
|
||||
//! [`SpawnEvent`]s a reader thread hands to the app's navigation closure: spinner until
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
||||
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
||||
//!
|
||||
//! The legacy in-process D3D11VA presenter remains reachable via the
|
||||
//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the
|
||||
//! developer A/B baseline until its deletion.
|
||||
|
||||
use std::io::BufRead as _;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
//!
|
||||
//! The shell is the settings file's only writer; the session only reads it. The shell's
|
||||
//! former private `Settings` copy (≤ 0.8.4: `show_hud`, `engine`) is gone — old files
|
||||
//! still load via a serde alias in core, and the legacy in-process presenter is now
|
||||
//! reachable only through `PUNKTFUNK_BUILTIN_STREAM=1` (see `app::use_builtin_stream`).
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, load_or_create_identity, parse_hex32, touch_last_used, KnownHost, KnownHosts,
|
||||
|
||||
@@ -1,653 +0,0 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the D3D11 presenter.
|
||||
//!
|
||||
//! Two backends, picked at session start (override via [`DecoderPref`] / the Settings UI):
|
||||
//!
|
||||
//! * **D3D11VA** (any GPU — the vendor-agnostic DXVA path on NVIDIA/AMD/Intel): libavcodec decodes
|
||||
//! on the GPU into an `ID3D11Texture2D` decode array (decoder-only bind — NVIDIA rejects a
|
||||
//! decoder array that is also a shader resource). The presenter copies each decoded slice into
|
||||
//! its own sampleable NV12/P010 texture and converts YUV→RGB in a shader — one cheap GPU-to-GPU
|
||||
//! copy per frame (no swscale, no CPU readback). The decode array is created by the process-wide
|
||||
//! shared device ([`crate::gpu`]) the presenter also draws with, so the copy stays on-GPU. This
|
||||
//! is the big latency/throughput win over software.
|
||||
//! * **Software**: libavcodec on the CPU + swscale to the same planar layout the hardware path
|
||||
//! produces (NV12, or P010 for 10-bit) — the presenter uploads the two planes and runs the SAME
|
||||
//! YUV→RGB shaders, so hw/sw color math is identical. The fallback on a GPU-less box (WARP),
|
||||
//! when D3D11VA init fails, or when a mid-session hardware error demotes us — the host's
|
||||
//! IDR/RFI recovery resynchronizes on the next keyframe either way.
|
||||
//!
|
||||
//! D3D11VA viability is settled **before the session's first frame** by two probes: the adapter
|
||||
//! must expose the negotiated codec's DXVA decode profile ([`decode_profile_supported`] — hwaccel
|
||||
//! init otherwise only fails at the first AU, burning the IDR), and it must be able to create the
|
||||
//! decode surface pool ([`d3d11va_decode_supported`]). Either failing commits to software decode
|
||||
//! from frame one (a clean, gap-free stream) instead of dying mid-stream.
|
||||
//!
|
||||
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no B-frames, in-band
|
||||
//! parameter sets on every IDR), so decode is strictly one-in/one-out.
|
||||
//!
|
||||
//! HDR is detected in-band from the decoded frame's transfer characteristic (`SMPTE2084` / PQ in the
|
||||
//! HEVC VUI) — the same signal every other punktfunk client keys off — not from a protocol field.
|
||||
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
use ffmpeg::software::scaling;
|
||||
use ffmpeg::util::frame::Video as AvFrame;
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use pf_client_core::video::ColorDesc;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use windows::core::{Interface, GUID};
|
||||
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11VideoDevice};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_FORMAT_NV12, DXGI_FORMAT_P010};
|
||||
|
||||
/// Which decode backend to use; the Settings UI persists this as a string.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum DecoderPref {
|
||||
/// Try D3D11VA, fall back to software.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force D3D11VA (error out if unavailable, for debugging).
|
||||
Hardware,
|
||||
/// Force software decode.
|
||||
Software,
|
||||
}
|
||||
|
||||
impl DecoderPref {
|
||||
pub fn from_name(s: &str) -> DecoderPref {
|
||||
match s {
|
||||
"hardware" => DecoderPref::Hardware,
|
||||
"software" => DecoderPref::Software,
|
||||
_ => DecoderPref::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DecodedFrame {
|
||||
Cpu(CpuFrame),
|
||||
Gpu(GpuFrame),
|
||||
}
|
||||
|
||||
impl DecodedFrame {
|
||||
pub fn dims(&self) -> (u32, u32) {
|
||||
match self {
|
||||
DecodedFrame::Cpu(c) => (c.width, c.height),
|
||||
DecodedFrame::Gpu(g) => (g.width, g.height),
|
||||
}
|
||||
}
|
||||
pub fn hdr(&self) -> bool {
|
||||
match self {
|
||||
DecodedFrame::Cpu(c) => c.hdr,
|
||||
DecodedFrame::Gpu(g) => g.hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A software-decoded frame in the same planar layout the hardware path produces: an NV12 (or
|
||||
/// P010 for 10-bit) luma plane + interleaved chroma plane, each with its swscale row stride
|
||||
/// (≥ the row bytes — swscale pads rows for SIMD). The presenter uploads them into two dynamic
|
||||
/// plane textures sampled by the same shaders as the D3D11VA path.
|
||||
pub struct CpuFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Luma plane (`W×H` samples, 1 byte each; 2 for 10-bit) + its row stride in bytes.
|
||||
pub y: Vec<u8>,
|
||||
pub y_stride: usize,
|
||||
/// Interleaved chroma plane (`⌈W/2⌉×⌈H/2⌉` UV pairs) + its row stride in bytes.
|
||||
pub uv: Vec<u8>,
|
||||
pub uv_stride: usize,
|
||||
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
|
||||
pub ten_bit: bool,
|
||||
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects the swapchain colour space.
|
||||
pub hdr: bool,
|
||||
/// The frame's CICP signaling (HEVC VUI → `AVFrame`), read per-frame — the presenter derives
|
||||
/// its Y′CbCr→RGB constant buffer from it (`csc_rows`), so a BT.601-signaled stream (a Linux
|
||||
/// host's RGB-input NVENC) no longer renders with BT.709 coefficients.
|
||||
pub color: ColorDesc,
|
||||
}
|
||||
|
||||
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
|
||||
/// wrote this frame into. The presenter copies the slice into its own sampleable texture and
|
||||
/// converts YUV→RGB in a pixel shader. The underlying surface stays alive — and out of the decoder's
|
||||
/// reuse pool — for exactly as long as `guard` (an `av_frame_clone` of the decoded frame) lives.
|
||||
pub struct GpuFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Texture-array slice this frame occupies (`AVFrame::data[1]`).
|
||||
pub index: u32,
|
||||
/// The decode pool is P010 (10 bits in the high bits) vs NV12 — from the frames context's
|
||||
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
|
||||
/// source array exactly for `CopySubresourceRegion`.
|
||||
pub ten_bit: bool,
|
||||
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects the swapchain colour
|
||||
/// space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
|
||||
pub hdr: bool,
|
||||
/// Per-frame CICP signaling — see [`CpuFrame::color`].
|
||||
pub color: ColorDesc,
|
||||
guard: D3d11FrameGuard,
|
||||
}
|
||||
|
||||
impl GpuFrame {
|
||||
/// The decoder's D3D11 texture array holding this frame's slice, borrowed from the live cloned
|
||||
/// `AVFrame`. Construct the windows-rs interface on the thread that will use it (the render
|
||||
/// thread): COM interfaces are `!Send`, but the raw pointer is fine to carry across threads.
|
||||
pub fn texture_ptr(&self) -> *mut c_void {
|
||||
unsafe { (*self.guard.0).data[0] as *mut c_void }
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns a cloned decoded `AVFrame` (which refs the D3D11 surface in the decoder pool). Dropping it
|
||||
/// releases the surface back for reuse. The clone is plain refcounted data; freeing it from the
|
||||
/// render thread is fine.
|
||||
pub struct D3d11FrameGuard(*mut ffmpeg::ffi::AVFrame);
|
||||
unsafe impl Send for D3d11FrameGuard {}
|
||||
impl Drop for D3d11FrameGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
enum Backend {
|
||||
D3d11va(D3d11vaDecoder),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
|
||||
pub struct Decoder {
|
||||
backend: Backend,
|
||||
/// The negotiated codec, so a mid-session D3D11VA→software demotion rebuilds for the same codec.
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
}
|
||||
|
||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
match wire {
|
||||
punktfunk_core::quic::CODEC_H264 => ffmpeg::codec::Id::H264,
|
||||
punktfunk_core::quic::CODEC_AV1 => ffmpeg::codec::Id::AV1,
|
||||
_ => ffmpeg::codec::Id::HEVC,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Deliberately NOT gated on the DXVA profiles: software decode covers anything FFmpeg can.
|
||||
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
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn new(pref: DecoderPref, codec_id: ffmpeg::codec::Id) -> Result<Decoder> {
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if pref != DecoderPref::Software {
|
||||
match D3d11vaDecoder::new(codec_id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(?codec_id, "D3D11VA hardware decode active");
|
||||
return Ok(Decoder {
|
||||
backend: Backend::D3d11va(d),
|
||||
codec_id,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
if pref == DecoderPref::Hardware {
|
||||
return Err(e.context("decoder=hardware but D3D11VA failed"));
|
||||
}
|
||||
tracing::info!(reason = %e, "D3D11VA unavailable — software decode");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Decoder {
|
||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||
codec_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// True for the GPU hardware backend (shown in the stream HUD).
|
||||
pub fn is_hardware(&self) -> bool {
|
||||
matches!(self.backend, Backend::D3d11va(_))
|
||||
}
|
||||
|
||||
/// Feed one access unit; returns the decoded frame (the host's streams are one-in/one-out). A
|
||||
/// software decode error after packet loss is survivable — keep feeding. A D3D11VA error demotes
|
||||
/// to software for the rest of the session (the next IDR resynchronizes).
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
|
||||
match &mut self.backend {
|
||||
Backend::D3d11va(d) => match d.decode(au) {
|
||||
Ok(f) => Ok(f.map(DecodedFrame::Gpu)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "D3D11VA decode failed — falling back to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DXVA decode-profile probe --------------------------------------------------------
|
||||
|
||||
/// DXVA decode-profile GUIDs (`dxva.h`), defined locally so no extra windows-rs feature or
|
||||
/// metadata surface is pulled in for four constants.
|
||||
const PROFILE_H264_VLD_NOFGT: GUID = GUID::from_u128(0x1b81be68_a0c7_11d3_b984_00c04f2e73c5);
|
||||
const PROFILE_HEVC_VLD_MAIN: GUID = GUID::from_u128(0x5b11d51b_2f4c_4452_bcc3_09f2a1160cc0);
|
||||
const PROFILE_HEVC_VLD_MAIN10: GUID = GUID::from_u128(0x107af0e0_ef1a_4d19_aba8_67a163073d13);
|
||||
const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59_d6b8a6da5d2a);
|
||||
|
||||
/// Does the shared device's adapter expose a DXVA decode profile for `codec_id`? Checked before
|
||||
/// building the FFmpeg hwdevice because hwaccel selection (`get_format`) only runs on the FIRST
|
||||
/// access unit — an unsupported profile would otherwise burn the opening IDR and recover through
|
||||
/// the mid-stream demotion path instead of committing to software up front. Also logs (once) the
|
||||
/// adapter's full profile list plus Main10 availability — the forensics for a new GPU/driver.
|
||||
fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id) -> Result<()> {
|
||||
let video: ID3D11VideoDevice = device
|
||||
.cast()
|
||||
.context("device lacks ID3D11VideoDevice (created without VIDEO_SUPPORT)")?;
|
||||
let profiles: Vec<GUID> = unsafe {
|
||||
let n = video.GetVideoDecoderProfileCount();
|
||||
(0..n)
|
||||
.filter_map(|i| video.GetVideoDecoderProfile(i).ok())
|
||||
.collect()
|
||||
};
|
||||
log_profiles_once(&profiles);
|
||||
|
||||
let (wanted, format, name): (GUID, DXGI_FORMAT, &str) = match codec_id {
|
||||
ffmpeg::codec::Id::H264 => (PROFILE_H264_VLD_NOFGT, DXGI_FORMAT_NV12, "H.264 VLD NoFGT"),
|
||||
ffmpeg::codec::Id::HEVC => (PROFILE_HEVC_VLD_MAIN, DXGI_FORMAT_NV12, "HEVC Main"),
|
||||
ffmpeg::codec::Id::AV1 => (PROFILE_AV1_VLD_PROFILE0, DXGI_FORMAT_NV12, "AV1 Profile 0"),
|
||||
other => bail!("no DXVA profile known for {other:?}"),
|
||||
};
|
||||
let ok = profiles.contains(&wanted)
|
||||
&& unsafe { video.CheckVideoDecoderFormat(&wanted, format) }
|
||||
.map(|b| b.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
bail!("adapter exposes no {name} decode profile");
|
||||
}
|
||||
// 10-bit (a mid-session HDR upgrade needs Main10): informational — if it's missing the
|
||||
// decode error → software demotion + keyframe re-request path covers the switch.
|
||||
if codec_id == ffmpeg::codec::Id::HEVC {
|
||||
let main10 = profiles.contains(&PROFILE_HEVC_VLD_MAIN10)
|
||||
&& unsafe { video.CheckVideoDecoderFormat(&PROFILE_HEVC_VLD_MAIN10, DXGI_FORMAT_P010) }
|
||||
.map(|b| b.as_bool())
|
||||
.unwrap_or(false);
|
||||
tracing::info!(main10, "HEVC Main10 (10-bit/HDR) decode profile");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-time dump of the adapter's DXVA decode profiles.
|
||||
fn log_profiles_once(profiles: &[GUID]) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
let list: Vec<String> = profiles.iter().map(|g| format!("{g:?}")).collect();
|
||||
tracing::info!(count = profiles.len(), profiles = ?list, "adapter DXVA decode profiles");
|
||||
}
|
||||
}
|
||||
|
||||
// --- software backend ---------------------------------------------------------------
|
||||
|
||||
struct SoftwareDecoder {
|
||||
decoder: ffmpeg::decoder::Video,
|
||||
/// Rebuilt whenever the decoded format/size **or output format** changes (mid-stream
|
||||
/// `Reconfigure`, or an 8↔10-bit flip): `(ctx, src_fmt, w, h, dst_fmt)`.
|
||||
sws: Option<(scaling::Context, Pixel, u32, u32, Pixel)>,
|
||||
}
|
||||
|
||||
impl SoftwareDecoder {
|
||||
fn new(codec_id: ffmpeg::codec::Id) -> Result<SoftwareDecoder> {
|
||||
let codec = ffmpeg::decoder::find(codec_id)
|
||||
.ok_or_else(|| anyhow!("no {codec_id:?} decoder in libavcodec"))?;
|
||||
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
|
||||
unsafe {
|
||||
let raw = ctx.as_mut_ptr();
|
||||
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
// Slice threading adds no frame delay (frame threading adds thread_count-1).
|
||||
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
|
||||
(*raw).thread_count = 0; // auto
|
||||
}
|
||||
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||
Ok(SoftwareDecoder { decoder, sws: None })
|
||||
}
|
||||
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
|
||||
let packet = ffmpeg::Packet::copy(au);
|
||||
self.decoder
|
||||
.send_packet(&packet)
|
||||
.map_err(|e| anyhow!("send_packet: {e}"))?;
|
||||
let mut frame = AvFrame::empty();
|
||||
let mut out = None;
|
||||
while self.decoder.receive_frame(&mut frame).is_ok() {
|
||||
out = Some(self.convert(&frame)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Convert the decoded planar YUV to the hardware path's layout: NV12 for 8-bit, P010 for
|
||||
/// 10-bit — a chroma interleave (and 10→16-high-bits shift), NOT a colour conversion. The
|
||||
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
|
||||
/// D3D11VA path, so software frames are bit-comparable with hardware ones.
|
||||
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||
// SAFETY: `frame` wraps a live decoded AVFrame for the duration of this call.
|
||||
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
|
||||
let hdr = color.is_pq();
|
||||
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
|
||||
let ten_bit = unsafe {
|
||||
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
|
||||
!desc.is_null() && (*desc).comp[0].depth > 8
|
||||
};
|
||||
let dst = if ten_bit { Pixel::P010LE } else { Pixel::NV12 };
|
||||
let rebuild = !matches!(&self.sws, Some((_, f, sw, sh, d)) if *f == fmt && *sw == w && *sh == h && *d == dst);
|
||||
if rebuild {
|
||||
let ctx = scaling::Context::get(fmt, w, h, dst, w, h, scaling::Flags::POINT)
|
||||
.context("swscale context")?;
|
||||
self.sws = Some((ctx, fmt, w, h, dst));
|
||||
}
|
||||
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||
let mut conv = AvFrame::empty();
|
||||
sws.run(frame, &mut conv).map_err(|e| anyhow!("sws: {e}"))?;
|
||||
Ok(CpuFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
y: conv.data(0).to_vec(),
|
||||
y_stride: conv.stride(0),
|
||||
uv: conv.data(1).to_vec(),
|
||||
uv_stride: conv.stride(1),
|
||||
ten_bit,
|
||||
hdr,
|
||||
color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- D3D11VA backend ------------------------------------------------------------------
|
||||
//
|
||||
// Raw FFI: ffmpeg-next has no hwaccel wrappers. The COM-typed hwcontext structs are declared here
|
||||
// (stable FFmpeg public ABI) rather than relied on from ffmpeg-sys bindgen — the generic
|
||||
// AVHWDeviceContext / AVHWFramesContext (whose payload is an opaque `void *hwctx`) come from
|
||||
// ffmpeg-sys, and we cast `hwctx` to the structs below. All owned pointers are freed in Drop;
|
||||
// decoded surfaces transfer out through D3d11FrameGuard.
|
||||
|
||||
const AVERROR_EAGAIN: i32 = -11; // -EAGAIN
|
||||
|
||||
/// D3D11VA decode surface pool depth: the zero-reorder DPB (1–2 refs) + the bounded decoded channel
|
||||
/// (2) + the frame the presenter currently holds (until its copy flushes) + one in-flight decode —
|
||||
/// 12 is comfortable. A GPU that can't create the pool at all is gated out by
|
||||
/// `d3d11va_decode_supported` and the session uses software decode.
|
||||
const DECODE_POOL_SIZE: i32 = 12;
|
||||
|
||||
/// `hwcontext_d3d11va.h` — `AVHWDeviceContext::hwctx`. Leaving `lock` null makes FFmpeg install an
|
||||
/// `ID3D11Multithread` default lock + set multithread protection on `device_context` during init,
|
||||
/// which is what lets the presenter share this device's immediate context from the render thread.
|
||||
#[repr(C)]
|
||||
struct AVD3D11VADeviceContext {
|
||||
device: *mut c_void, // ID3D11Device*
|
||||
device_context: *mut c_void, // ID3D11DeviceContext*
|
||||
video_device: *mut c_void, // ID3D11VideoDevice*
|
||||
video_context: *mut c_void, // ID3D11VideoContext*
|
||||
lock: *mut c_void, // void (*)(void*)
|
||||
unlock: *mut c_void, // void (*)(void*)
|
||||
lock_ctx: *mut c_void,
|
||||
}
|
||||
|
||||
/// `hwcontext_d3d11va.h` — `AVHWFramesContext::hwctx`. The header is explicit: "The user must at
|
||||
/// least set D3D11_BIND_DECODER if the frames context is to be used for video decoding" — a
|
||||
/// user-built frames context gets NO default (BindFlags 0 → `CreateTexture2D` E_INVALIDARG); the
|
||||
/// automatic OR-in lives only in libavcodec's own frames-param path, which we bypass.
|
||||
#[repr(C)]
|
||||
struct AVD3D11VAFramesContext {
|
||||
texture: *mut c_void, // ID3D11Texture2D* (null → FFmpeg allocates the pool)
|
||||
bind_flags: u32, // UINT BindFlags
|
||||
misc_flags: u32, // UINT MiscFlags
|
||||
texture_infos: *mut c_void, // AVD3D11FrameDescriptor* (FFmpeg-managed)
|
||||
}
|
||||
|
||||
/// `D3D11_BIND_DECODER` — the decode pool's ONLY bind flag. Adding `D3D11_BIND_SHADER_RESOURCE`
|
||||
/// is what NVIDIA rejects on a decoder texture ARRAY; the presenter samples via its own copy.
|
||||
const BIND_DECODER: u32 = 0x200;
|
||||
|
||||
fn averr(what: &str, code: i32) -> anyhow::Error {
|
||||
anyhow!("{what}: {}", ffmpeg::Error::from(code))
|
||||
}
|
||||
|
||||
/// libavcodec's `get_format` callback: pick the D3D11 hw surface format and nothing else.
|
||||
/// Deliberately does NOT build a frames context — with `hw_device_ctx` set and `hw_frames_ctx`
|
||||
/// left null, libavcodec derives the decode pool itself (`ff_decode_get_hw_frames_ctx`), applying
|
||||
/// every vendor quirk: DXVA surface alignment (128 for HEVC/AV1), DPB-based pool sizing, and the
|
||||
/// decoder-only `D3D11_BIND_DECODER` flags. A hand-built context validated on NVIDIA was rejected
|
||||
/// by Intel at the first `SubmitDecoderBuffers` (E_INVALIDARG) — the vendor-proof path is the one
|
||||
/// the ffmpeg CLI/mpv ship. Returning anything but `AV_PIX_FMT_D3D11` aborts hardware decode →
|
||||
/// the session demotes to software.
|
||||
unsafe extern "C" fn get_format_d3d11(
|
||||
avctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
mut list: *const ffmpeg::ffi::AVPixelFormat,
|
||||
) -> ffmpeg::ffi::AVPixelFormat {
|
||||
use ffmpeg::ffi::*;
|
||||
unsafe {
|
||||
if (*avctx).hw_device_ctx.is_null() {
|
||||
return AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
while *list != AVPixelFormat::AV_PIX_FMT_NONE {
|
||||
if *list == AVPixelFormat::AV_PIX_FMT_D3D11 {
|
||||
return AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
}
|
||||
list = list.add(1);
|
||||
}
|
||||
AVPixelFormat::AV_PIX_FMT_NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Predict whether D3D11VA decode will work by doing EXACTLY what the decoder's `get_format` does —
|
||||
/// allocate an `AVHWFramesContext` (decoder-only pool, no shader-resource bind) and initialize it,
|
||||
/// which creates the real NV12 decode surface array. On a GPU/driver that can't create the pool this
|
||||
/// fails here, up front, so the session commits to software decode from the first frame (a clean,
|
||||
/// gap-free stream) rather than decoding the IDR then dying mid-stream on a texture error that a
|
||||
/// software demotion can't reliably recover from (the host's infinite GOP won't re-send an IDR).
|
||||
unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool {
|
||||
use ffmpeg::ffi::*;
|
||||
unsafe {
|
||||
let frames_ref = av_hwframe_ctx_alloc(hw_device);
|
||||
if frames_ref.is_null() {
|
||||
return false;
|
||||
}
|
||||
let frames = (*frames_ref).data as *mut AVHWFramesContext;
|
||||
(*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
(*frames).width = 1920;
|
||||
(*frames).height = 1152; // 128-aligned 1080p surface (the HEVC DXVA alignment, see get_format)
|
||||
(*frames).initial_pool_size = DECODE_POOL_SIZE;
|
||||
// Decoder-only — matches get_format exactly.
|
||||
let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*fhw).bind_flags = BIND_DECODER;
|
||||
let r = av_hwframe_ctx_init(frames_ref);
|
||||
let mut fr = frames_ref;
|
||||
av_buffer_unref(&mut fr);
|
||||
r >= 0
|
||||
}
|
||||
}
|
||||
|
||||
struct D3d11vaDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
}
|
||||
|
||||
// Single-owner pointers, only touched from the session pump thread.
|
||||
unsafe impl Send for D3d11vaDecoder {}
|
||||
|
||||
impl D3d11vaDecoder {
|
||||
fn new(codec_id: ffmpeg::codec::Id) -> Result<D3d11vaDecoder> {
|
||||
use ffmpeg::ffi;
|
||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||
if !shared.hardware {
|
||||
bail!("shared device is WARP (no hardware video decode)");
|
||||
}
|
||||
// The adapter must expose the codec's DXVA profile — checked here, not at the first AU.
|
||||
decode_profile_supported(&shared.device, codec_id)?;
|
||||
unsafe {
|
||||
// Build a D3D11VA hwdevice context around the *shared* device, so decoded textures live
|
||||
// on the same device the presenter samples + draws with.
|
||||
let hw_device =
|
||||
ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
|
||||
if hw_device.is_null() {
|
||||
bail!("av_hwdevice_ctx_alloc(D3D11VA) failed");
|
||||
}
|
||||
let devctx = (*hw_device).data as *mut ffi::AVHWDeviceContext;
|
||||
let d3dctx = (*devctx).hwctx as *mut AVD3D11VADeviceContext;
|
||||
// Hand FFmpeg an owned ref to the device + immediate context (it Releases them when the
|
||||
// hwdevice ctx is freed). `into_raw()` transfers a +1 ref without releasing.
|
||||
(*d3dctx).device = shared.device.clone().into_raw();
|
||||
(*d3dctx).device_context = shared.context.clone().into_raw();
|
||||
// lock left null → FFmpeg installs the ID3D11Multithread default lock in init.
|
||||
let r = ffi::av_hwdevice_ctx_init(hw_device);
|
||||
if r < 0 {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
|
||||
// Up-front viability probe (see `d3d11va_decode_supported`): a GPU/driver that can't
|
||||
// create the decode surface pool commits to software NOW, so it decodes cleanly from the
|
||||
// first frame instead of failing mid-stream (which a demotion can't reliably recover).
|
||||
if !d3d11va_decode_supported(hw_device) {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("GPU can't create the D3D11VA decode surface pool — using software decode");
|
||||
}
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
// hwaccel: threads only add latency.
|
||||
(*ctx).thread_count = 1;
|
||||
// On top of the DPB-based pool libavcodec sizes for us: the bounded decoded channel
|
||||
// (2) + the frame the presenter holds until its copy flushes + margin.
|
||||
(*ctx).extra_hw_frames = 4;
|
||||
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
Ok(D3d11vaDecoder {
|
||||
ctx,
|
||||
hw_device,
|
||||
packet: ffi::av_packet_alloc(),
|
||||
frame: ffi::av_frame_alloc(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<GpuFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
let r = ffi::av_new_packet(self.packet, au.len() as i32);
|
||||
if r < 0 {
|
||||
return Err(averr("av_new_packet", r));
|
||||
}
|
||||
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
|
||||
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
|
||||
ffi::av_packet_unref(self.packet);
|
||||
if r < 0 {
|
||||
return Err(averr("send_packet", r));
|
||||
}
|
||||
let mut out = None;
|
||||
loop {
|
||||
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
|
||||
if r == AVERROR_EAGAIN {
|
||||
break;
|
||||
}
|
||||
if r < 0 {
|
||||
return Err(averr("receive_frame", r));
|
||||
}
|
||||
out = Some(self.lift()?); // newest wins; older guards drop here
|
||||
ffi::av_frame_unref(self.frame);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the decoded D3D11 surface into a `GpuFrame`. `data[0]` is the texture array, `data[1]`
|
||||
/// the slice index. We `av_frame_clone` so the surface stays referenced (kept out of the reuse
|
||||
/// pool) until the presenter drops the guard.
|
||||
unsafe fn lift(&mut self) -> Result<GpuFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
||||
bail!("decoder returned a software frame (no D3D11 surface)");
|
||||
}
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame for the duration of this call.
|
||||
let color = ColorDesc::from_raw(self.frame);
|
||||
let hdr = color.is_pq();
|
||||
let ten_bit = {
|
||||
let hwfc = (*self.frame).hw_frames_ctx;
|
||||
!hwfc.is_null()
|
||||
&& (*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format
|
||||
== ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
};
|
||||
let cloned = ffi::av_frame_clone(self.frame);
|
||||
if cloned.is_null() {
|
||||
bail!("av_frame_clone failed");
|
||||
}
|
||||
let frame = GpuFrame {
|
||||
width: (*self.frame).width as u32,
|
||||
height: (*self.frame).height as u32,
|
||||
index: (*self.frame).data[1] as usize as u32,
|
||||
ten_bit,
|
||||
hdr,
|
||||
color,
|
||||
guard: D3d11FrameGuard(cloned),
|
||||
};
|
||||
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for D3d11vaDecoder {
|
||||
fn drop(&mut self) {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time dump of the first decoded surface's layout — so a new GPU/driver combination's real
|
||||
/// format (slice index range, HDR/bit-depth) is visible in the logs without a debugger.
|
||||
fn log_layout_once(width: u32, height: u32, index: u32, hdr: bool, ten_bit: bool) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
slice = index,
|
||||
hdr,
|
||||
ten_bit,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user