fix(stats): decode stage measures GPU completion; Alt+Enter fullscreen alias
The Vulkan path's receive_frame returns at SUBMISSION (~0.1 ms) — the hardware decodes asynchronously, so the decode stat was truthful but measured the wrong boundary. The pump now ships the frame to the presenter FIRST, then waits the frame's timeline fence (vkWaitSemaphores resolved through the shared device's proc chain) and stamps received→decode-COMPLETE — true NVDEC time at zero pipeline cost, since the presenter's own GPU wait is what actually gates sampling. Software/ VAAPI keep their synchronous stamps. Also: Alt+Enter joins F11 as the fullscreen toggle (some keyboards' Fn layer sends a media key for plain F11 — observed on glass as 'F11 only works with shift'); the shell's shortcuts panel lists both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -587,7 +587,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="title">Toggle fullscreen</property>
|
<property name="title">Toggle fullscreen</property>
|
||||||
<property name="accelerator">F11</property>
|
<property name="accelerator">F11 <Alt>Return</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ pub struct Stats {
|
|||||||
/// `host + network`. An old host never emits 0xCF, so this stays false and the
|
/// `host + network`. An old host never emits 0xCF, so this stays false and the
|
||||||
/// combined stage renders unchanged.
|
/// combined stage renders unchanged.
|
||||||
pub split: bool,
|
pub split: bool,
|
||||||
/// p50 `decode` stage: received → decoded, single-clock client-local (ms).
|
/// p50 `decode` stage: received → decode COMPLETE, single-clock client-local (ms).
|
||||||
|
/// Hardware paths measure GPU completion via the frame's timeline fence (an async
|
||||||
|
/// decoder's submission returning in ~0.1 ms is not "decoded"); software measures
|
||||||
|
/// the synchronous CPU decode.
|
||||||
pub decode_ms: f32,
|
pub decode_ms: f32,
|
||||||
/// Unrecoverable network frame drops this window, and their share of
|
/// Unrecoverable network frame drops this window, and their share of
|
||||||
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
||||||
@@ -343,13 +346,33 @@ fn pump(
|
|||||||
}
|
}
|
||||||
pending_split.push_back((frame.pts_ns, hn / 1000));
|
pending_split.push_back((frame.pts_ns, hn / 1000));
|
||||||
}
|
}
|
||||||
// `decode` stage: received→decoded, single clock, no skew.
|
// Ship the frame FIRST, then settle the decode stat: on the
|
||||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
// Vulkan path receive_frame returns at SUBMISSION (~0.1 ms) and
|
||||||
|
// the hardware decodes asynchronously — waiting the frame's
|
||||||
|
// timeline fence here (after the presenter already has the
|
||||||
|
// frame) measures true received→decode-complete at zero
|
||||||
|
// pipeline cost. Software/VAAPI keep the synchronous stamp.
|
||||||
|
let hw_fence = match &image {
|
||||||
|
DecodedImage::VkFrame(v) => {
|
||||||
|
Some((v.timeline_sem, v.decode_done_value))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let _ = frame_tx.force_send(DecodedFrame {
|
let _ = frame_tx.force_send(DecodedFrame {
|
||||||
pts_ns: frame.pts_ns,
|
pts_ns: frame.pts_ns,
|
||||||
decoded_ns,
|
decoded_ns,
|
||||||
image,
|
image,
|
||||||
});
|
});
|
||||||
|
// `decode` stage: received→decode COMPLETE, single clock.
|
||||||
|
let decode_done_ns = match hw_fence {
|
||||||
|
Some((sem, value))
|
||||||
|
if decoder.wait_hw_decoded(sem, value, 50_000_000) =>
|
||||||
|
{
|
||||||
|
now_ns()
|
||||||
|
}
|
||||||
|
_ => decoded_ns,
|
||||||
|
};
|
||||||
|
decode_us.push(decode_done_ns.saturating_sub(received_ns) / 1000);
|
||||||
}
|
}
|
||||||
Ok(None) => no_output_streak += 1,
|
Ok(None) => no_output_streak += 1,
|
||||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ pub struct VkVideoFrame {
|
|||||||
/// The frame pool's VkFormat (`AVVulkanFramesContext.format[0]`, raw i32) — the
|
/// The frame pool's VkFormat (`AVVulkanFramesContext.format[0]`, raw i32) — the
|
||||||
/// multiplanar format the presenter builds its per-plane views against.
|
/// multiplanar format the presenter builds its per-plane views against.
|
||||||
pub vk_format: i32,
|
pub vk_format: i32,
|
||||||
|
/// The frame's timeline semaphore (raw VkSemaphore; creation-constant) and the
|
||||||
|
/// value FFmpeg's decode submission signals on completion — the pump waits this
|
||||||
|
/// pair AFTER shipping the frame to measure true GPU decode time (zero pipeline
|
||||||
|
/// cost: the presenter already waits the same pair on the GPU).
|
||||||
|
pub timeline_sem: u64,
|
||||||
|
pub decode_done_value: u64,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub color: ColorDesc,
|
pub color: ColorDesc,
|
||||||
@@ -313,6 +319,15 @@ impl Decoder {
|
|||||||
done(Backend::Software(SoftwareDecoder::new(codec_id)?))
|
done(Backend::Software(SoftwareDecoder::new(codec_id)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for a Vulkan-Video frame's GPU decode to complete (timeline semaphore) —
|
||||||
|
/// the pump's decode-stat measurement. `false` = not the Vulkan backend, or timeout.
|
||||||
|
pub fn wait_hw_decoded(&self, timeline_sem: u64, value: u64, timeout_ns: u64) -> bool {
|
||||||
|
match &self.backend {
|
||||||
|
Backend::Vulkan(v) => v.wait_timeline(timeline_sem, value, timeout_ns),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||||||
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
||||||
pub fn take_keyframe_request(&mut self) -> bool {
|
pub fn take_keyframe_request(&mut self) -> bool {
|
||||||
@@ -807,6 +822,10 @@ struct VulkanDecoder {
|
|||||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||||
packet: *mut ffmpeg::ffi::AVPacket,
|
packet: *mut ffmpeg::ffi::AVPacket,
|
||||||
frame: *mut ffmpeg::ffi::AVFrame,
|
frame: *mut ffmpeg::ffi::AVFrame,
|
||||||
|
/// `vkWaitSemaphores` on the shared device — the decode-complete measurement
|
||||||
|
/// (resolved through the same get_proc_addr chain FFmpeg uses).
|
||||||
|
wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
|
||||||
|
vk_device: pf_ffvk::VkDevice,
|
||||||
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
||||||
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
||||||
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
||||||
@@ -926,6 +945,26 @@ impl VulkanDecoder {
|
|||||||
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
|
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vkWaitSemaphores for the pump's decode-complete stat: loader →
|
||||||
|
// vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
|
||||||
|
let gipa = (*hwctx)
|
||||||
|
.get_proc_addr
|
||||||
|
.expect("get_proc_addr was just set above");
|
||||||
|
let gdpa: pf_ffvk::PFN_vkGetDeviceProcAddr = std::mem::transmute(gipa(
|
||||||
|
(*hwctx).inst,
|
||||||
|
c"vkGetDeviceProcAddr".as_ptr(),
|
||||||
|
));
|
||||||
|
let wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores = std::mem::transmute(gdpa
|
||||||
|
.expect("vkGetDeviceProcAddr resolvable")(
|
||||||
|
(*hwctx).act_dev,
|
||||||
|
c"vkWaitSemaphores".as_ptr(),
|
||||||
|
));
|
||||||
|
if wait_semaphores.is_none() {
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("vkWaitSemaphores unresolvable on this device");
|
||||||
|
}
|
||||||
|
let vk_device = (*hwctx).act_dev;
|
||||||
|
|
||||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||||
if codec.is_null() {
|
if codec.is_null() {
|
||||||
ffi::av_buffer_unref(&mut hw_device);
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
@@ -951,6 +990,8 @@ impl VulkanDecoder {
|
|||||||
hw_device,
|
hw_device,
|
||||||
packet: ffi::av_packet_alloc(),
|
packet: ffi::av_packet_alloc(),
|
||||||
frame: ffi::av_frame_alloc(),
|
frame: ffi::av_frame_alloc(),
|
||||||
|
wait_semaphores,
|
||||||
|
vk_device,
|
||||||
_ctx_storage: store,
|
_ctx_storage: store,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -985,6 +1026,27 @@ impl VulkanDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Block until the timeline semaphore reaches `value` (GPU decode complete) or the
|
||||||
|
/// timeout passes. Pure measurement — the presenter's own GPU wait is what gates
|
||||||
|
/// sampling, so a timeout here only degrades the stat, never the picture.
|
||||||
|
fn wait_timeline(&self, sem: u64, value: u64, timeout_ns: u64) -> bool {
|
||||||
|
let sems = [sem as pf_ffvk::VkSemaphore];
|
||||||
|
let values = [value];
|
||||||
|
let info = pf_ffvk::VkSemaphoreWaitInfo {
|
||||||
|
sType: pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO,
|
||||||
|
pNext: std::ptr::null(),
|
||||||
|
flags: 0,
|
||||||
|
semaphoreCount: 1,
|
||||||
|
pSemaphores: sems.as_ptr(),
|
||||||
|
pValues: values.as_ptr(),
|
||||||
|
};
|
||||||
|
// SAFETY: resolved from this device at init; handles outlive the decoder.
|
||||||
|
let r = unsafe {
|
||||||
|
self.wait_semaphores.expect("checked at init")(self.vk_device, &info, timeout_ns)
|
||||||
|
};
|
||||||
|
r == 0 // VK_SUCCESS (VK_TIMEOUT = 2)
|
||||||
|
}
|
||||||
|
|
||||||
/// Lift the decoded `AVVkFrame` into a [`VkVideoFrame`]: clone the AVFrame (the
|
/// Lift the decoded `AVVkFrame` into a [`VkVideoFrame`]: clone the AVFrame (the
|
||||||
/// guard — keeps the image + frames context alive through present) and ship the
|
/// guard — keeps the image + frames context alive through present) and ship the
|
||||||
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
||||||
@@ -1024,12 +1086,18 @@ impl VulkanDecoder {
|
|||||||
ffi::av_frame_free(&mut clone);
|
ffi::av_frame_free(&mut clone);
|
||||||
bail!("multi-image Vulkan frames unsupported (disjoint pool)");
|
bail!("multi-image Vulkan frames unsupported (disjoint pool)");
|
||||||
}
|
}
|
||||||
|
// Safe without the frames lock: the handle is creation-constant and
|
||||||
|
// sem_value was last written by the decode submission on THIS thread.
|
||||||
|
let timeline_sem = (*vkf).sem[0] as u64;
|
||||||
|
let decode_done_value = (*vkf).sem_value[0];
|
||||||
Ok(VkVideoFrame {
|
Ok(VkVideoFrame {
|
||||||
vkframe: vkf as usize,
|
vkframe: vkf as usize,
|
||||||
frames_ctx: fc as usize,
|
frames_ctx: fc as usize,
|
||||||
lock_frame,
|
lock_frame,
|
||||||
unlock_frame,
|
unlock_frame,
|
||||||
vk_format,
|
vk_format,
|
||||||
|
timeline_sem,
|
||||||
|
decode_done_value,
|
||||||
width: (*self.frame).width as u32,
|
width: (*self.frame).width as u32,
|
||||||
height: (*self.frame).height as u32,
|
height: (*self.frame).height as u32,
|
||||||
color: ColorDesc::from_raw(self.frame),
|
color: ColorDesc::from_raw(self.frame),
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ fn main() {
|
|||||||
.allowlist_type("VkPhysicalDeviceVulkan13Features")
|
.allowlist_type("VkPhysicalDeviceVulkan13Features")
|
||||||
// AVVulkanFramesContext.img_flags values (plane views need MUTABLE_FORMAT).
|
// AVVulkanFramesContext.img_flags values (plane views need MUTABLE_FORMAT).
|
||||||
.allowlist_type("VkImageCreateFlagBits")
|
.allowlist_type("VkImageCreateFlagBits")
|
||||||
|
// Timeline-semaphore wait — the pump measures true GPU decode completion.
|
||||||
|
.allowlist_type("VkSemaphoreWaitInfo")
|
||||||
|
.allowlist_type("PFN_vkWaitSemaphores")
|
||||||
|
.allowlist_type("PFN_vkGetDeviceProcAddr")
|
||||||
// …plus nothing else of FFmpeg: the core types these structs reference only
|
// …plus nothing else of FFmpeg: the core types these structs reference only
|
||||||
// ever appear behind pointers here, so keep them opaque instead of duplicating
|
// ever appear behind pointers here, so keep them opaque instead of duplicating
|
||||||
// ffmpeg-sys-next's definitions (callers cast pointers between the crates).
|
// ffmpeg-sys-next's definitions (callers cast pointers between the crates).
|
||||||
|
|||||||
@@ -366,8 +366,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
print_stats = !print_stats;
|
print_stats = !print_stats;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if sc == Scancode::F11 {
|
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||||
|
// plain F11 — the Moonlight-standard alias always exists).
|
||||||
|
let alt_enter = sc == Scancode::Return
|
||||||
|
&& keymod.intersects(Mod::LALTMOD | Mod::RALTMOD);
|
||||||
|
if sc == Scancode::F11 || alt_enter {
|
||||||
fullscreen = !fullscreen;
|
fullscreen = !fullscreen;
|
||||||
|
tracing::debug!(fullscreen, "fullscreen toggle");
|
||||||
if let Err(e) = window.set_fullscreen(fullscreen) {
|
if let Err(e) = window.set_fullscreen(fullscreen) {
|
||||||
tracing::warn!(error = %e, "fullscreen toggle");
|
tracing::warn!(error = %e, "fullscreen toggle");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
|||||||
"kwin" | "kde" => Some(Kwin),
|
"kwin" | "kde" => Some(Kwin),
|
||||||
"mutter" | "gnome" => Some(Mutter),
|
"mutter" | "gnome" => Some(Mutter),
|
||||||
"gamescope" => Some(Gamescope),
|
"gamescope" => Some(Gamescope),
|
||||||
"wlroots" | "sway" => Some(Wlroots),
|
"hyprland" => Some(Hyprland),
|
||||||
|
"wlroots" | "sway" | "river" => Some(Wlroots),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,10 +317,20 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
|
|||||||
};
|
};
|
||||||
let mut errs = Vec::new();
|
let mut errs = Vec::new();
|
||||||
for a in attempts {
|
for a in attempts {
|
||||||
match hyprctl_dispatch(a) {
|
if let Err(e) = hyprctl_dispatch(a) {
|
||||||
Ok(()) => return Ok(()),
|
errs.push(format!("{a:?}: {e:#}"));
|
||||||
Err(e) => errs.push(format!("{e:#}")),
|
continue;
|
||||||
}
|
}
|
||||||
|
// Confirm the monitor actually adopted the mode — some versions print `ok` for a command
|
||||||
|
// they silently ignored (e.g. a Lua form the era doesn't accept), which would otherwise
|
||||||
|
// leave the output at the default 1080p60. If it didn't take, fall through to the other era.
|
||||||
|
if wait_mode_applied(a, name, mode, Duration::from_millis(800)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
errs.push(format!(
|
||||||
|
"{a:?}: dispatched but monitor never adopted {}x{}",
|
||||||
|
mode.width, mode.height
|
||||||
|
));
|
||||||
}
|
}
|
||||||
bail!(
|
bail!(
|
||||||
"hyprctl monitor rule failed on both config eras: {}",
|
"hyprctl monitor rule failed on both config eras: {}",
|
||||||
@@ -328,6 +338,41 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Poll until monitor `name` reports the requested width×height (the rule applies asynchronously),
|
||||||
|
/// up to `timeout`. Logs which command form finally took. Returns `false` on timeout.
|
||||||
|
fn wait_mode_applied(attempt: &[&str], name: &str, mode: Mode, timeout: Duration) -> bool {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if monitor_has_mode(name, mode).unwrap_or(false) {
|
||||||
|
tracing::debug!(output = %name, cmd = ?attempt, "monitor adopted the requested mode");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does monitor `name` currently report `mode`'s resolution in `hyprctl -j monitors`? Checks
|
||||||
|
/// width×height (the capture-critical dimension); the fractional `refreshRate` isn't compared.
|
||||||
|
fn monitor_has_mode(name: &str, mode: Mode) -> Result<bool> {
|
||||||
|
let out = hyprctl(&["-j", "monitors"])?;
|
||||||
|
let monitors: serde_json::Value =
|
||||||
|
serde_json::from_str(&out).context("parse hyprctl -j monitors")?;
|
||||||
|
let Some(arr) = monitors.as_array() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
for m in arr {
|
||||||
|
if m.get("name").and_then(|n| n.as_str()) == Some(name) {
|
||||||
|
let w = m.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let h = m.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
return Ok(w == mode.width as u64 && h == mode.height as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.0`),
|
/// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.0`),
|
||||||
/// cached for the process. A compositor upgrade + restart mid-host-life would leave this stale, but
|
/// cached for the process. A compositor upgrade + restart mid-host-life would leave this stale, but
|
||||||
/// [`set_monitor_rule`] tries both eras regardless, so the cache is an optimization, not correctness.
|
/// [`set_monitor_rule`] tries both eras regardless, so the cache is an optimization, not correctness.
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RU
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. |
|
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset to auto-detect;** set only to force one. |
|
||||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
||||||
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
||||||
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. |
|
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
|
||||||
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
|
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
|
||||||
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
|
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
title: Hyprland
|
||||||
|
description: Configure a punktfunk host on a Hyprland session — headless output via hyprctl, capture via xdg-desktop-portal-hyprland.
|
||||||
|
---
|
||||||
|
|
||||||
|
Hyprland is a **first-class backend.** The host adds a per-client headless output at the client's
|
||||||
|
exact mode with `hyprctl`, captures it through the **xdg-desktop-portal-hyprland (xdph)** ScreenCast
|
||||||
|
portal (zero-copy dmabuf), and injects input via the wlroots virtual pointer/keyboard protocols —
|
||||||
|
which Hyprland still implements even after dropping wlroots in v0.42.
|
||||||
|
|
||||||
|
This is a distinct backend from [Sway / wlroots](/docs/sway): Hyprland has its own IPC (`hyprctl`)
|
||||||
|
and its own portal (xdph), so it is auto-detected and driven separately.
|
||||||
|
|
||||||
|
This page assumes the package is already installed — see [Arch](/docs/arch), [Ubuntu](/docs/ubuntu),
|
||||||
|
or [Fedora](/docs/fedora).
|
||||||
|
|
||||||
|
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||||
|
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||||
|
|
||||||
|
## host.env
|
||||||
|
|
||||||
|
The host auto-detects a Hyprland session, so you usually need nothing here. To force the backend, set
|
||||||
|
these in `~/.config/punktfunk/host.env`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
PUNKTFUNK_COMPOSITOR=hyprland
|
||||||
|
PUNKTFUNK_INPUT_BACKEND=wlr
|
||||||
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
|
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration](/docs/configuration) for the full reference.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **Video** — the host runs `hyprctl output create headless PF-1` and applies a monitor rule for the
|
||||||
|
client's exact mode. Outputs are **named**, so there's no before/after diffing. Both config eras
|
||||||
|
are supported: `hyprctl keyword monitor …` (≤ 0.54) and the Lua `hyprctl eval 'hl.monitor{…}'`
|
||||||
|
(≥ 0.55), selected from `hyprctl version`.
|
||||||
|
- **Capture** — it captures that output through the **xdg-desktop-portal-hyprland (xdph)** ScreenCast
|
||||||
|
portal. To pick the output without a GUI on a headless host, the host writes a managed
|
||||||
|
`~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the
|
||||||
|
new output automatically — no interactive picker dialog to answer.
|
||||||
|
- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual
|
||||||
|
keyboard** protocols (Hyprland kept them). Gamepads and audio are compositor-independent.
|
||||||
|
|
||||||
|
For how long the virtual output lives, and extend-vs-exclusive topology, see
|
||||||
|
[Virtual displays](/docs/virtual-displays).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A running Hyprland session (any recent release; validate on both a ≤ 0.54 and a ≥ 0.55 install).
|
||||||
|
- **xdg-desktop-portal-hyprland (xdph)** installed and running — the host captures through its
|
||||||
|
ScreenCast portal, and steers its custom picker. Without it there is no video.
|
||||||
|
- The ScreenCast interface routed to xdph — see `scripts/headless/portals.conf` (a `[Hyprland]`
|
||||||
|
section pins `org.freedesktop.impl.portal.ScreenCast=hyprland`).
|
||||||
|
|
||||||
|
## Permission system
|
||||||
|
|
||||||
|
Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny
|
||||||
|
direct screencopy and virtual-input clients — and denial is **silent**: capture goes to *black
|
||||||
|
frames* and input is *dropped*, with no error. If you've enabled it, grant the host explicitly in
|
||||||
|
your Hyprland config:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ecosystem {
|
||||||
|
enforce_permissions = true
|
||||||
|
}
|
||||||
|
|
||||||
|
permission = /usr/bin/punktfunk-host, screencopy, allow
|
||||||
|
permission = /usr/bin/punktfunk-host, virtual-pointer, allow
|
||||||
|
permission = /usr/bin/punktfunk-host, virtual-keyboard, allow
|
||||||
|
```
|
||||||
|
|
||||||
|
The host logs a warning at startup when it detects enforcement is on. (Adjust the binary path to
|
||||||
|
where your package installed `punktfunk-host`.)
|
||||||
|
|
||||||
|
## Start the host
|
||||||
|
|
||||||
|
With the backend selected, start the host from **inside your Hyprland session**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user enable --now punktfunk-host
|
||||||
|
journalctl --user -u punktfunk-host -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bring up the console and pair
|
||||||
|
|
||||||
|
Enable the web console, read its login password, and arm PIN pairing — see
|
||||||
|
[The Web Console](/docs/web-console). Then [connect a client](/docs/clients).
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"kde",
|
"kde",
|
||||||
"gnome",
|
"gnome",
|
||||||
"gamescope",
|
"gamescope",
|
||||||
|
"hyprland",
|
||||||
"sway",
|
"sway",
|
||||||
"running-as-a-service",
|
"running-as-a-service",
|
||||||
"virtual-displays",
|
"virtual-displays",
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ is also available. Setup splits along two axes: you **install** the package per
|
|||||||
- [KDE Plasma (KWin)](/docs/kde)
|
- [KDE Plasma (KWin)](/docs/kde)
|
||||||
- [GNOME (Mutter)](/docs/gnome)
|
- [GNOME (Mutter)](/docs/gnome)
|
||||||
- [Steam / gamescope](/docs/gamescope)
|
- [Steam / gamescope](/docs/gamescope)
|
||||||
|
- [Hyprland](/docs/hyprland)
|
||||||
- [Sway / wlroots](/docs/sway)
|
- [Sway / wlroots](/docs/sway)
|
||||||
|
|
||||||
Pick your distro to install, then your desktop to configure — the two are independent. Other
|
Pick your distro to install, then your desktop to configure — the two are independent. The host
|
||||||
wlroots compositors (Hyprland) work but aren't a primary target; the host still needs one of these
|
needs one of these compositor backends to create a virtual display.
|
||||||
compositor backends to create a virtual display.
|
|
||||||
|
|
||||||
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
|
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
|
||||||
> signed installer that registers a service and bundles a virtual-display driver (whose driver-
|
> signed installer that registers a service and bundles a virtual-display driver (whose driver-
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: Sway / wlroots
|
title: Sway / wlroots
|
||||||
description: Configure a punktfunk host on a wlroots compositor (Sway, Hyprland).
|
description: Configure a punktfunk host on a wlroots compositor (Sway, River).
|
||||||
---
|
---
|
||||||
|
|
||||||
The wlroots family can host — but **Sway is the only validated path.** The host adds a per-client
|
Sway (and other wlroots-proper compositors like River) can host: the host adds a per-client headless
|
||||||
headless output at the client's exact mode and captures it through the xdg-desktop-portal-wlr (xdpw)
|
output at the client's exact mode with `swaymsg create_output` and captures it through the
|
||||||
ScreenCast portal, injecting input via the wlroots virtual pointer/keyboard protocols. Hyprland and
|
xdg-desktop-portal-wlr (xdpw) ScreenCast portal, injecting input via the wlroots virtual
|
||||||
other wlroots compositors are best-effort (see [How it works](#how-it-works) for the caveat).
|
pointer/keyboard protocols.
|
||||||
|
|
||||||
|
> On **Hyprland**? It's a separate first-class backend (its own `hyprctl` IPC and xdph portal) —
|
||||||
|
> see [Hyprland](/docs/hyprland). This page is for sway and other wlroots-proper compositors.
|
||||||
|
|
||||||
This is **not a primary target.** It works and is validated live on **sway 1.11** (zero-copy), but it
|
This is **not a primary target.** It works and is validated live on **sway 1.11** (zero-copy), but it
|
||||||
sees far less testing than the KDE and GNOME paths — expect rougher edges. If you have a choice,
|
sees far less testing than the KDE and GNOME paths — expect rougher edges. If you have a choice,
|
||||||
@@ -24,7 +27,7 @@ The host auto-detects a wlroots session, so you usually need nothing here. To fo
|
|||||||
these in `~/.config/punktfunk/host.env`:
|
these in `~/.config/punktfunk/host.env`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, hyprland
|
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr (Hyprland has its own: PUNKTFUNK_COMPOSITOR=hyprland)
|
||||||
PUNKTFUNK_INPUT_BACKEND=wlr
|
PUNKTFUNK_INPUT_BACKEND=wlr
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
||||||
@@ -35,9 +38,8 @@ See [Configuration](/docs/configuration) for the full reference.
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- **Video** — the host adds a headless output at the client's exact mode with `swaymsg create_output`.
|
- **Video** — the host adds a headless output at the client's exact mode with `swaymsg create_output`.
|
||||||
This uses Sway's IPC specifically; other wlroots compositors (Hyprland, …) don't expose an
|
This uses Sway's IPC specifically; other wlroots-proper compositors (River, …) are best-effort on
|
||||||
equivalent, so virtual-output creation isn't wired up for them yet — Sway is the supported wlroots
|
this path. (Hyprland is driven by its own [backend](/docs/hyprland), not this one.)
|
||||||
path today.
|
|
||||||
- **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast
|
- **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast
|
||||||
portal. The host writes a managed chooser config so the output pick is automatic — no interactive
|
portal. The host writes a managed chooser config so the output pick is automatic — no interactive
|
||||||
picker dialog to answer.
|
picker dialog to answer.
|
||||||
@@ -49,7 +51,8 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- A running wlroots session (Sway, Hyprland, …).
|
- A running wlroots-proper session (Sway, River, …). On Hyprland, use the
|
||||||
|
[Hyprland backend](/docs/hyprland) instead.
|
||||||
- **xdg-desktop-portal-wlr (xdpw)** installed and running — the host captures through its ScreenCast
|
- **xdg-desktop-portal-wlr (xdpw)** installed and running — the host captures through its ScreenCast
|
||||||
portal. Without it there is no video.
|
portal. Without it there is no video.
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ What punktfunk does with your monitor layout while it streams.
|
|||||||
|
|
||||||
Per-backend support:
|
Per-backend support:
|
||||||
|
|
||||||
| | KWin | Mutter/GNOME | Sway/wlroots | Windows |
|
| | KWin | Mutter/GNOME | Sway/wlroots · Hyprland | Windows |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Extend | ✅ | ✅ | ✅ | ✅ |
|
| Extend | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
|
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# ~/.config/xdg-desktop-portal/sway-portals.conf (xdg-desktop-portal 1.18+ format)
|
# ~/.config/xdg-desktop-portal/sway-portals.conf (xdg-desktop-portal 1.18+ format)
|
||||||
# Force the wlroots backend to service ScreenCast; gtk handles the rest.
|
# Route ScreenCast to the right wlr-family backend; gtk handles the rest.
|
||||||
|
#
|
||||||
|
# A desktop-specific section (matched against XDG_CURRENT_DESKTOP, which the host sets per session —
|
||||||
|
# `sway` for sway/river, `Hyprland` for Hyprland) wins over [preferred]. So sway hosts capture
|
||||||
|
# through xdg-desktop-portal-wlr (xdpw) and Hyprland hosts through xdg-desktop-portal-hyprland
|
||||||
|
# (xdph) — each the backend the host actually steers.
|
||||||
[preferred]
|
[preferred]
|
||||||
default=gtk
|
default=gtk
|
||||||
org.freedesktop.impl.portal.ScreenCast=wlr
|
org.freedesktop.impl.portal.ScreenCast=wlr
|
||||||
|
|
||||||
|
# Hyprland: XDG_CURRENT_DESKTOP=Hyprland → xdph services ScreenCast (the host steers its custom
|
||||||
|
# picker via ~/.config/hypr/xdph.conf). See docs/hyprland.
|
||||||
|
[Hyprland]
|
||||||
|
org.freedesktop.impl.portal.ScreenCast=hyprland
|
||||||
|
|||||||
Reference in New Issue
Block a user