bfd64ce871
ci / rust (push) Has been cancelled
Full project rename, decided 2026-06-10: - Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs. - C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h, PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl. PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants). - Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1. WIRE BREAK: clients must be rebuilt from this revision. - Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / …. - Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the persistent identity is unchanged, pinned fingerprints stay valid). - Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated. - scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated. Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of "desktop but no apps/settings" over the stream: plasmashell launched without XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and rendered an empty menu. The script sets the complete KDE session env (menu prefix, KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell. Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS, zero lumen references left outside .git. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
237 lines
8.5 KiB
Rust
237 lines
8.5 KiB
Rust
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. M0 uses the
|
|
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
|
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
|
|
|
use anyhow::Result;
|
|
|
|
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
|
/// format; on wlroots it is commonly packed `RGB` (3 bytes/pixel). The encoder maps these
|
|
/// to an NVENC-accepted input format (`rgb0`/`bgr0`/`rgba`/`bgra`), expanding 3→4 bytes
|
|
/// where needed — no host-side colour conversion.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum PixelFormat {
|
|
/// `[B,G,R,x]`, 4 bpp.
|
|
Bgrx,
|
|
/// `[R,G,B,x]`, 4 bpp.
|
|
Rgbx,
|
|
/// `[B,G,R,A]`, 4 bpp.
|
|
Bgra,
|
|
/// `[R,G,B,A]`, 4 bpp.
|
|
Rgba,
|
|
/// `[R,G,B]`, 3 bpp.
|
|
Rgb,
|
|
/// `[B,G,R]`, 3 bpp.
|
|
Bgr,
|
|
}
|
|
|
|
impl PixelFormat {
|
|
pub fn bytes_per_pixel(self) -> usize {
|
|
match self {
|
|
PixelFormat::Rgb | PixelFormat::Bgr => 3,
|
|
_ => 4,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
|
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the M0/fallback path)
|
|
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
|
pub struct CapturedFrame {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub pts_ns: u64,
|
|
/// Pixel layout of the payload.
|
|
pub format: PixelFormat,
|
|
pub payload: FramePayload,
|
|
}
|
|
|
|
/// Where a captured frame's pixels live.
|
|
pub enum FramePayload {
|
|
/// Tightly-packed CPU pixels in `format`, `width*height*bytes_per_pixel` (no row padding).
|
|
Cpu(Vec<u8>),
|
|
/// A pitched GPU buffer (BGRA-order, on the shared CUDA context) — the zero-copy path. The
|
|
/// dmabuf has already been imported + copied into this owned device buffer.
|
|
#[cfg(target_os = "linux")]
|
|
Cuda(crate::zerocopy::DeviceBuffer),
|
|
}
|
|
|
|
impl CapturedFrame {
|
|
/// True if the frame's pixels are a GPU/CUDA buffer (the zero-copy path).
|
|
pub fn is_cuda(&self) -> bool {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
matches!(self.payload, FramePayload::Cuda(_))
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
|
|
/// over a bounded drop-oldest channel (never block the compositor).
|
|
pub trait Capturer: Send {
|
|
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
|
|
|
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
|
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
|
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
|
/// overrides it to drain its channel without blocking.
|
|
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
|
self.next_frame().map(Some)
|
|
}
|
|
|
|
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
|
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
|
|
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
|
/// duration of a stream, `false` when it ends.
|
|
fn set_active(&self, _active: bool) {}
|
|
}
|
|
|
|
/// A deterministic moving test pattern (BGRx). Lets M0 exercise the encode → file →
|
|
/// `punktfunk_core` path with no live capture session, and produces obviously non-static
|
|
/// content (a sweeping bar + animated gradient) so the encoded output is verifiable.
|
|
pub struct SyntheticCapturer {
|
|
width: u32,
|
|
height: u32,
|
|
fps: u32,
|
|
frame_idx: u64,
|
|
buf: Vec<u8>,
|
|
}
|
|
|
|
impl SyntheticCapturer {
|
|
const BPP: usize = 4; // emits BGRx
|
|
|
|
pub fn new(width: u32, height: u32, fps: u32) -> Self {
|
|
assert!(width > 0 && height > 0 && fps > 0);
|
|
let buf = vec![0u8; width as usize * height as usize * Self::BPP];
|
|
SyntheticCapturer {
|
|
width,
|
|
height,
|
|
fps,
|
|
frame_idx: 0,
|
|
buf,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Capturer for SyntheticCapturer {
|
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
|
let w = self.width as usize;
|
|
let h = self.height as usize;
|
|
let bpp = Self::BPP;
|
|
let t = self.frame_idx;
|
|
// A vertical bar sweeps left→right once every ~2s; the background is a gradient
|
|
// whose phase advances each frame, so every pixel changes frame-to-frame.
|
|
let bar_x = ((t * w as u64) / (self.fps as u64 * 2)) % w as u64;
|
|
let phase = (t % 256) as usize;
|
|
for y in 0..h {
|
|
let row = y * w * bpp;
|
|
for x in 0..w {
|
|
let i = row + x * bpp;
|
|
let on_bar = (x as u64).abs_diff(bar_x) < 8;
|
|
// BGRx byte order: [B, G, R, x]
|
|
self.buf[i] = if on_bar {
|
|
255
|
|
} else {
|
|
((x + phase) & 0xff) as u8
|
|
};
|
|
self.buf[i + 1] = if on_bar {
|
|
255
|
|
} else {
|
|
((y + phase) & 0xff) as u8
|
|
};
|
|
self.buf[i + 2] = if on_bar { 255 } else { ((x + y) & 0xff) as u8 };
|
|
self.buf[i + 3] = 0;
|
|
}
|
|
}
|
|
let pts_ns = self.frame_idx * 1_000_000_000 / self.fps as u64;
|
|
self.frame_idx += 1;
|
|
Ok(CapturedFrame {
|
|
width: self.width,
|
|
height: self.height,
|
|
pts_ns,
|
|
format: PixelFormat::Bgrx,
|
|
payload: FramePayload::Cpu(self.buf.clone()),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A cheap moving test pattern (BGRx) for the streaming path: a pulsing field + a white band
|
|
/// sweeping down, generated with whole-buffer `fill`s so it stays real-time even at 5K.
|
|
pub struct FastSyntheticCapturer {
|
|
width: u32,
|
|
height: u32,
|
|
frame_idx: u64,
|
|
buf: Vec<u8>,
|
|
}
|
|
|
|
impl FastSyntheticCapturer {
|
|
pub fn new(width: u32, height: u32) -> Self {
|
|
assert!(width > 0 && height > 0);
|
|
FastSyntheticCapturer {
|
|
width,
|
|
height,
|
|
frame_idx: 0,
|
|
buf: vec![0u8; width as usize * height as usize * 4],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Capturer for FastSyntheticCapturer {
|
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
|
let (w, h) = (self.width as usize, self.height as usize);
|
|
let row = w * 4;
|
|
let shade = (self.frame_idx % 256) as u8;
|
|
self.buf.fill(shade);
|
|
let band_h = (h / 20).max(1);
|
|
let band_y = (self.frame_idx as usize * 6) % h;
|
|
for y in band_y..(band_y + band_h).min(h) {
|
|
self.buf[y * row..(y + 1) * row].fill(0xff);
|
|
}
|
|
self.frame_idx += 1;
|
|
Ok(CapturedFrame {
|
|
width: self.width,
|
|
height: self.height,
|
|
pts_ns: 0,
|
|
format: PixelFormat::Bgrx,
|
|
payload: FramePayload::Cpu(self.buf.clone()),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal
|
|
/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
|
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
|
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
|
// so use a plain ScreenCast session there.
|
|
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
|
|
linux::PortalCapturer::open(anchored).map(|c| Box::new(c) as Box<dyn Capturer>)
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
|
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
|
}
|
|
|
|
/// Build a capturer from an already-created virtual output (see [`crate::vdisplay`]). Consumes
|
|
/// the output's PipeWire node + optional remote fd + keepalive — the capturer owns the keepalive,
|
|
/// so dropping the capturer releases the virtual output. Compositor-agnostic: works for any
|
|
/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was
|
|
/// created at — native, no scaling.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
|
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
|
anyhow::bail!("virtual-output capture requires Linux")
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod linux;
|