From 5b5cb9bf380caade806bf11388456fd12ea1d56e Mon Sep 17 00:00:00 2001 From: Oleksandr Berezovskyi Date: Sat, 12 Sep 2026 10:10:21 +0300 Subject: [PATCH] feat(capture): add the PipeWire capture of the gamescope node Finds the gamescope node, negotiates a small BGRx MemFd format through gamescope's requested_size extension, converts each buffer and publishes it into the frame slot. Runs on the PipeWire main loop in the main thread. --- src/capture.rs | 758 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 759 insertions(+) create mode 100644 src/capture.rs diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..bdc22f1 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,758 @@ +//! PipeWire side (spec 4.2): finds the gamescope node, negotiates a small BGRx MemFd format +//! through gamescope's `requested_size` extension, converts buffers and publishes frames. +//! +//! Everything here runs on the PipeWire main loop in the main thread. + +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, VecDeque}; +use std::fmt::{self, Write as _}; +use std::io::Cursor; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context as _}; +use pipewire as pw; +use pw::properties::properties; +use pw::spa; +use pw::stream::{Stream, StreamFlags, StreamListener, StreamRc, StreamState}; +use spa::buffer::meta::{MetaHeader, MetaHeaderFlags}; +use spa::buffer::ChunkFlags; +use spa::param::format::{FormatProperties, MediaSubtype, MediaType}; +use spa::param::video::{VideoFormat, VideoInfoRaw}; +use spa::param::ParamType; +use spa::pod::deserialize::PodDeserializer; +use spa::pod::serialize::PodSerializer; +use spa::pod::{ChoiceValue, Object, Pod, Property, PropertyFlags, Value}; +use spa::utils::{Choice, ChoiceEnum, ChoiceFlags, Direction, Fraction, Id, Rectangle, SpaTypes}; +use tracing::{debug, error, info, warn}; + +use crate::config::Size; +use crate::convert::{fit_within, BgrxFrame, Converter}; +use crate::pacing::RateLimit; +use crate::slot::{Frame, FrameSlot}; +use crate::stats::{self, Stats}; + +/// gamescope's `SPA_FORMAT_VIDEO_requested_size` (`src/pipewire_gamescope.hpp`). +pub const SPA_FORMAT_VIDEO_REQUESTED_SIZE: u32 = 0x70000; +/// gamescope's `SPA_FORMAT_VIDEO_gamescope_focus_appid`. Deliberately never set. +pub const SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID: u32 = 0x70001; +/// gamescope's `SPA_META_requested_size_scale`. +pub const SPA_META_REQUESTED_SIZE_SCALE: u32 = 0x70000; + +const STREAM_NODE_NAME: &str = "gamescope-hyperhdr"; +const SOURCE_MEDIA_CLASS: &str = "Video/Source"; +const RETRY_DELAY: Duration = Duration::from_secs(1); +/// Period of the housekeeping timer that applies deferred teardowns and (re)creates the stream. +/// A stream must not be destroyed from its own callbacks, and `pw::channel` cannot defer the +/// work: its receiver callback holds the channel lock, so sending from there deadlocks. +const TICK: Duration = Duration::from_millis(100); +/// Explain the silence when a streaming stream has not delivered a frame for this long. +const NO_FRAMES_HINT_AFTER: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone)] +pub struct CaptureConfig { + /// `node.name` of the source. + pub target: String, + /// Size requested from gamescope (`size × supersample`). + pub requested: Size, + /// Frames are downscaled to fit into this size. + pub output_max: Size, +} + +struct Teardown { + generation: u64, + reason: String, + retry_delay: Option, +} + +type Teardowns = Rc>>; + +/// Progress towards the first frame of a stream, shared by its callbacks and the housekeeping +/// timer. gamescope renders a frame for PipeWire only when the picture changes, so on a static +/// screen the first frame can take arbitrarily long. +#[derive(Debug, Default)] +struct FirstFrame { + /// When the stream first reached Streaming. + streaming_since: Cell>, + received: Cell, + hinted: Cell, +} + +struct Active { + node_id: u32, + serial: u64, + generation: u64, + first_frame: Rc, + // Dropped before the stream, so no callbacks run during its destruction. + _listener: StreamListener, + _stream: StreamRc, +} + +struct State { + cfg: CaptureConfig, + core: pw::core::CoreRc, + slot: Arc, + stats: Arc, + teardowns: Teardowns, + /// Matching source nodes: global id → object.serial. + candidates: BTreeMap, + active: Option, + retry_at: Option, + generation: u64, +} + +/// Runs the PipeWire main loop until SIGINT/SIGTERM (`Ok`) or a fatal PipeWire error (`Err`). +/// +/// `on_ready` runs once the signal handlers are installed; start other threads there. +/// PipeWire receives signals through a signalfd and blocks them only in the calling thread, +/// so a thread spawned earlier would still get the default (terminating) disposition. +pub fn run( + cfg: CaptureConfig, + slot: Arc, + stats: Arc, + on_ready: impl FnOnce() -> anyhow::Result<()>, +) -> anyhow::Result<()> { + pw::init(); + let mainloop = + pw::main_loop::MainLoopRc::new(None).context("cannot create the PipeWire main loop")?; + let _signals = [ + (pw::loop_::Signal::INT, "SIGINT"), + (pw::loop_::Signal::TERM, "SIGTERM"), + ] + .map(|(signal, name)| { + let mainloop_weak = mainloop.downgrade(); + mainloop.loop_().add_signal_local(signal, move || { + info!("{name} received, shutting down"); + if let Some(mainloop) = mainloop_weak.upgrade() { + mainloop.quit(); + } + }) + }); + on_ready()?; + + let context = + pw::context::ContextRc::new(&mainloop, None).context("cannot create a PipeWire context")?; + let core = context + .connect_rc(None) + .context("cannot connect to the PipeWire daemon")?; + let registry = core + .get_registry_rc() + .context("cannot get the PipeWire registry")?; + + let fatal: Rc>> = Rc::default(); + let _core_listener = { + let mainloop_weak = mainloop.downgrade(); + let fatal = fatal.clone(); + core.add_listener_local() + .error(move |id, seq, res, message| { + let broken_pipe = std::io::Error::from_raw_os_error(-res).kind() + == std::io::ErrorKind::BrokenPipe; + if id == pw::sys::PW_ID_CORE && broken_pipe { + error!(seq, res, "lost connection to PipeWire: {message}"); + *fatal.borrow_mut() = + Some(format!("lost connection to PipeWire: {message} ({res})")); + if let Some(mainloop) = mainloop_weak.upgrade() { + mainloop.quit(); + } + } else if id == pw::sys::PW_ID_CORE { + // E.g. "unknown resource" after the server destroyed our stream's node + // because its target disappeared. + warn!(seq, res, "PipeWire core error: {message}"); + } else { + // Stream errors are also reported through its state_changed event. + debug!(id, seq, res, "PipeWire error: {message}"); + } + }) + .register() + }; + + let state = Rc::new(RefCell::new(State { + cfg, + core: core.clone(), + slot, + stats, + teardowns: Teardowns::default(), + candidates: BTreeMap::new(), + active: None, + retry_at: None, + generation: 0, + })); + + let _registry_listener = { + let (on_add, on_remove) = (state.clone(), state.clone()); + registry + .add_listener_local() + .global(move |global| { + if global.type_ != pw::types::ObjectType::Node { + return; + } + let Some(props) = global.props else { + return; + }; + let mut s = on_add.borrow_mut(); + if props.get("node.name") != Some(s.cfg.target.as_str()) { + return; + } + let class = props.get("media.class"); + if class != Some(SOURCE_MEDIA_CLASS) { + debug!( + id = global.id, + "ignoring node '{}' with media.class {class:?}", s.cfg.target + ); + return; + } + let serial = props + .get("object.serial") + .and_then(|v| v.parse().ok()) + .unwrap_or(u64::from(global.id)); + info!( + id = global.id, + serial, "found source node '{}'", s.cfg.target + ); + // The next tick picks the newest candidate, after the registry burst. + s.candidates.insert(global.id, serial); + }) + .global_remove(move |id| { + let mut s = on_remove.borrow_mut(); + if let Some(serial) = s.candidates.remove(&id) { + info!(id, serial, "source node removed"); + } + if let Some(active) = s.active.as_ref().filter(|a| a.node_id == id) { + let teardown = Teardown { + generation: active.generation, + reason: "source node removed".to_owned(), + retry_delay: None, + }; + s.teardowns.borrow_mut().push_back(teardown); + } + }) + .register() + }; + + let tick = { + let state = state.clone(); + mainloop.loop_().add_timer(move |_| housekeeping(&state)) + }; + let _ = tick.update_timer(Some(TICK), Some(TICK)); + + info!( + "waiting for a '{}' {SOURCE_MEDIA_CLASS} node", + state.borrow().cfg.target + ); + mainloop.run(); + + let active = state.borrow_mut().active.take(); + drop(active); + + let fatal = fatal.borrow_mut().take(); + match fatal { + Some(reason) => Err(anyhow!(reason)), + None => Ok(()), + } +} + +fn housekeeping(state: &Rc>) { + let teardowns = state.borrow().teardowns.clone(); + loop { + let next = teardowns.borrow_mut().pop_front(); + match next { + Some(t) => teardown(state, t), + None => break, + } + } + reconcile(state); + hint_missing_frames(state); +} + +fn hint_missing_frames(state: &Rc>) { + let s = state.borrow(); + let Some(active) = &s.active else { + return; + }; + let first_frame = &active.first_frame; + if first_frame.received.get() || first_frame.hinted.get() { + return; + } + if first_frame + .streaming_since + .get() + .is_some_and(|since| since.elapsed() >= NO_FRAMES_HINT_AFTER) + { + first_frame.hinted.set(true); + info!( + node_id = active.node_id, + "stream is streaming, but no frames for {} s: gamescope sends frames only when the \ + picture changes; the HyperHDR connection opens with the first frame", + NO_FRAMES_HINT_AFTER.as_secs() + ); + } +} + +fn reconcile(state: &Rc>) { + let mut s = state.borrow_mut(); + if s.active.is_some() + || s.candidates.is_empty() + || s.retry_at.is_some_and(|at| Instant::now() < at) + { + return; + } + let Some((&node_id, &serial)) = s.candidates.iter().max_by_key(|(_, serial)| **serial) else { + return; + }; + if s.candidates.len() > 1 { + warn!( + count = s.candidates.len(), + node_id, + serial, + "several '{}' {SOURCE_MEDIA_CLASS} nodes exist; using the newest (highest object.serial)", + s.cfg.target + ); + } + s.generation += 1; + let generation = s.generation; + match connect_stream(&s, node_id, serial, generation) { + Ok(active) => { + info!(node_id, serial, requested_size = %s.cfg.requested, "stream created, linking to the source"); + s.active = Some(active); + s.retry_at = None; + } + Err(e) => { + error!(node_id, serial, "cannot create the stream: {e:#}"); + s.retry_at = Some(Instant::now() + RETRY_DELAY); + } + } +} + +fn teardown(state: &Rc>, t: Teardown) { + let active = { + let mut s = state.borrow_mut(); + if s.active.as_ref().map(|a| a.generation) != Some(t.generation) { + return; + } + s.retry_at = t.retry_delay.map(|d| Instant::now() + d); + s.active.take() + }; + if let Some(active) = active { + info!( + node_id = active.node_id, + serial = active.serial, + "destroying stream: {}", + t.reason + ); + drop(active); + } + state.borrow().slot.source_gone(); +} + +fn connect_stream(s: &State, node_id: u32, serial: u64, generation: u64) -> anyhow::Result { + let props = properties! { + *pw::keys::MEDIA_TYPE => "Video", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Screen", + *pw::keys::NODE_NAME => STREAM_NODE_NAME, + *pw::keys::NODE_DESCRIPTION => "gamescope → HyperHDR", + "target.object" => serial.to_string(), + "node.dont-fallback" => "true", + *pw::keys::NODE_DONT_RECONNECT => "true", + "node.dont-move" => "true", + }; + let stream = + StreamRc::new(s.core.clone(), STREAM_NODE_NAME, props).context("pw_stream_new failed")?; + + let first_frame = Rc::new(FirstFrame::default()); + let data = StreamData { + node_id, + generation, + requested: s.cfg.requested, + output_max: s.cfg.output_max, + slot: s.slot.clone(), + stats: s.stats.clone(), + teardowns: s.teardowns.clone(), + first_frame: first_frame.clone(), + format: None, + format_events: 0, + first_frame_logged: false, + warned_ignored: false, + converter: Converter::new(), + frame: Frame::default(), + bounds_log: RateLimit::new(Duration::from_secs(10)), + }; + let listener = stream + .add_local_listener_with_user_data(data) + .state_changed(|_, d, old, new| on_state_changed(d, old, new)) + .param_changed(|_, d, id, param| on_param_changed(d, id, param)) + .process(on_process) + .register() + .context("cannot register stream listener")?; + + let pod = enum_format_pod(s.cfg.requested); + if tracing::enabled!(tracing::Level::DEBUG) { + debug!("EnumFormat pod ({} bytes): {}", pod.len(), Hex(&pod)); + } + let mut params = [Pod::from_bytes(&pod).ok_or_else(|| anyhow!("invalid EnumFormat pod"))?]; + stream + .connect( + Direction::Input, + None, + StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS, + &mut params, + ) + .context("pw_stream_connect failed")?; + + Ok(Active { + node_id, + serial, + generation, + first_frame, + _listener: listener, + _stream: stream, + }) +} + +/// The single EnumFormat object offered to gamescope (spec 4.2.4). +pub fn enum_format_pod(requested: Size) -> Vec { + let rect = |width, height| Rectangle { width, height }; + let requested_rect = rect(requested.width, requested.height); + let object = Object { + type_: SpaTypes::ObjectParamFormat.as_raw(), + id: ParamType::EnumFormat.as_raw(), + properties: vec![ + Property::new( + FormatProperties::MediaType.as_raw(), + Value::Id(Id(MediaType::Video.as_raw())), + ), + Property::new( + FormatProperties::MediaSubtype.as_raw(), + Value::Id(Id(MediaSubtype::Raw.as_raw())), + ), + // BGRx only, and no modifier: that rules out gamescope's DMA-BUF offers + // (their modifier is MANDATORY) and NV12. + Property::new( + FormatProperties::VideoFormat.as_raw(), + Value::Id(Id(VideoFormat::BGRx.as_raw())), + ), + Property::new( + FormatProperties::VideoSize.as_raw(), + Value::Choice(ChoiceValue::Rectangle(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Range { + default: requested_rect, + min: rect(1, 1), + max: rect(16384, 16384), + }, + ))), + ), + // Must include 0/1: gamescope offers a variable frame rate. + Property::new( + FormatProperties::VideoFramerate.as_raw(), + Value::Choice(ChoiceValue::Fraction(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Range { + default: Fraction { num: 0, denom: 1 }, + min: Fraction { num: 0, denom: 1 }, + max: Fraction { + num: 1000, + denom: 1, + }, + }, + ))), + ), + // A fixed value, not a choice: gamescope renders into a frame that fits it. + Property { + key: SPA_FORMAT_VIDEO_REQUESTED_SIZE, + flags: PropertyFlags::empty(), + value: Value::Rectangle(requested_rect), + }, + ], + }; + PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(object)) + .expect("serializing into a Vec cannot fail") + .0 + .into_inner() +} + +/// Reads gamescope's `requested_size` from a Format pod. +pub fn requested_size_of(format: &Pod) -> Option { + let (_, Value::Object(object)) = + PodDeserializer::deserialize_any_from(format.as_bytes()).ok()? + else { + return None; + }; + let prop = object + .properties + .into_iter() + .find(|p| p.key == SPA_FORMAT_VIDEO_REQUESTED_SIZE)?; + match prop.value { + Value::Rectangle(r) => Some(r), + Value::Choice(ChoiceValue::Rectangle(Choice( + _, + ChoiceEnum::None(r) + | ChoiceEnum::Range { default: r, .. } + | ChoiceEnum::Step { default: r, .. }, + ))) => Some(r), + _ => None, + } +} + +struct StreamData { + node_id: u32, + generation: u64, + requested: Size, + output_max: Size, + slot: Arc, + stats: Arc, + teardowns: Teardowns, + first_frame: Rc, + /// Negotiated BGRx size, `None` until a usable format is negotiated. + format: Option, + format_events: u32, + first_frame_logged: bool, + warned_ignored: bool, + converter: Converter, + frame: Frame, + bounds_log: RateLimit, +} + +impl StreamData { + fn bounds_error(&mut self, what: impl fmt::Display) { + stats::inc(&self.stats.bounds_errors); + if let Some(suppressed) = self.bounds_log.check(Instant::now()) { + warn!(suppressed, "skipping invalid buffer: {what}"); + } + } +} + +fn on_state_changed(d: &mut StreamData, old: StreamState, new: StreamState) { + let reason = match new { + StreamState::Error(e) => { + warn!(node_id = d.node_id, "stream error: {e}"); + format!("stream error: {e}") + } + StreamState::Unconnected => { + info!(node_id = d.node_id, "stream state {old:?} -> Unconnected"); + "stream unconnected".to_owned() + } + new => { + info!(node_id = d.node_id, "stream state {old:?} -> {new:?}"); + // Renegotiation toggles Streaming/Paused; count from the first Streaming. + if new == StreamState::Streaming && d.first_frame.streaming_since.get().is_none() { + d.first_frame.streaming_since.set(Some(Instant::now())); + } + return; + } + }; + d.teardowns.borrow_mut().push_back(Teardown { + generation: d.generation, + reason, + retry_delay: Some(RETRY_DELAY), + }); +} + +fn on_param_changed(d: &mut StreamData, id: u32, param: Option<&Pod>) { + if id != ParamType::Format.as_raw() { + return; + } + let Some(param) = param else { + info!("format cleared"); + d.format = None; + return; + }; + d.format_events += 1; + d.first_frame_logged = false; + + let mut info = VideoInfoRaw::new(); + if let Err(e) = info.parse(param) { + error!("cannot parse the negotiated format: {e:?}"); + d.format = None; + return; + } + if tracing::enabled!(tracing::Level::DEBUG) { + if let Ok((_, value)) = PodDeserializer::deserialize_any_from(param.as_bytes()) { + debug!("Format pod: {value:?}"); + } + } + if info.format() != VideoFormat::BGRx { + error!( + "negotiated unsupported video format {:?}, ignoring buffers", + info.format() + ); + d.format = None; + return; + } + let size = info.size(); + let framerate = info.framerate(); + let requested = requested_size_of(param).map_or_else( + || "absent".to_owned(), + |r| format!("{}x{}", r.width, r.height), + ); + info!( + event = d.format_events, + "format: BGRx {}x{}, expected stride {}, framerate {}/{}, requested_size {requested}", + size.width, + size.height, + size.width.saturating_mul(4).next_multiple_of(4), + framerate.num, + framerate.denom, + ); + d.format = Some(Size::new(size.width, size.height)); +} + +fn on_process(stream: &Stream, d: &mut StreamData) { + // Drain the queue: only the newest buffer is used, older ones are requeued on drop. + let mut newest = None; + while let Some(buffer) = stream.dequeue_buffer() { + newest = Some(buffer); + } + let Some(mut buffer) = newest else { + return; + }; + let Some(size) = d.format else { + return; + }; + + let header_corrupted = buffer + .find_meta::() + .is_some_and(|h| h.flags().contains(MetaHeaderFlags::CORRUPTED)); + let Some(data) = buffer.datas_mut().first_mut() else { + d.bounds_error("buffer has no data blocks"); + return; + }; + let chunk = data.chunk(); + let (offset, chunk_size, stride) = (chunk.offset(), chunk.size(), chunk.stride()); + // Buffers without data show up around renegotiation; they are skipped like corrupted ones. + if header_corrupted || chunk.flags().contains(ChunkFlags::CORRUPTED) || chunk_size == 0 { + stats::inc(&d.stats.corrupted); + return; + } + let data_type = data.type_(); + let Some(mapped) = data.data() else { + d.bounds_error(format_args!( + "buffer is not mapped (data type {data_type:?})" + )); + return; + }; + let mapped_len = mapped.len(); + + let started = Instant::now(); + let bgrx = match BgrxFrame::new( + mapped, + offset as usize, + chunk_size as usize, + usize::try_from(stride).unwrap_or(0), + size.width as usize, + size.height as usize, + ) { + Ok(frame) => frame, + Err(e) => { + d.bounds_error(format_args!( + "{e} (chunk offset {offset}, size {chunk_size}, stride {stride}; {mapped_len} bytes mapped)" + )); + return; + } + }; + + let requested = d.requested; + if !d.warned_ignored + && (size.width > requested.width.saturating_mul(2) + || size.height > requested.height.saturating_mul(2)) + { + d.warned_ignored = true; + warn!( + "gamescope seems to ignore requested_size {requested}: receiving {size} frames, downscaling on the CPU" + ); + } + + let (width, height) = fit_within( + size.width, + size.height, + d.output_max.width, + d.output_max.height, + ); + d.converter + .convert(&bgrx, width as usize, height as usize, &mut d.frame.rgb); + d.frame.width = width; + d.frame.height = height; + d.stats.record_convert(started.elapsed()); + stats::inc(&d.stats.frames_received); + + if !d.first_frame.received.replace(true) && d.first_frame.hinted.get() { + if let Some(since) = d.first_frame.streaming_since.get() { + info!( + "first frame arrived {:.1?} after the stream started streaming", + since.elapsed() + ); + } + } + if !d.first_frame_logged { + d.first_frame_logged = true; + info!( + "receiving frames: {size} stride {stride} offset {offset} chunk {chunk_size} bytes, sending {width}x{height}" + ); + } + if d.slot.publish(&mut d.frame) { + stats::inc(&d.stats.superseded); + } +} + +struct Hex<'a>(&'a [u8]); + +impl fmt::Display for Hex<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, b) in self.0.iter().enumerate() { + if i > 0 && i % 4 == 0 { + f.write_char(' ')?; + } + write!(f, "{b:02x}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enum_format_pod_contents() { + let bytes = enum_format_pod(Size::new(640, 360)); + let pod = Pod::from_bytes(&bytes).unwrap(); + let (_, value) = PodDeserializer::deserialize_any_from(pod.as_bytes()).unwrap(); + let Value::Object(object) = value else { + panic!("not an object"); + }; + assert_eq!(object.type_, SpaTypes::ObjectParamFormat.as_raw()); + assert_eq!(object.id, ParamType::EnumFormat.as_raw()); + + let keys: Vec = object.properties.iter().map(|p| p.key).collect(); + assert!(!keys.contains(&FormatProperties::VideoModifier.as_raw())); + assert!(!keys.contains(&SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID)); + + let prop = |key: u32| object.properties.iter().find(|p| p.key == key).unwrap(); + assert_eq!( + prop(FormatProperties::VideoFormat.as_raw()).value, + Value::Id(Id(VideoFormat::BGRx.as_raw())) + ); + let custom = prop(SPA_FORMAT_VIDEO_REQUESTED_SIZE); + assert_eq!(custom.flags, PropertyFlags::empty()); + assert_eq!( + custom.value, + Value::Rectangle(Rectangle { + width: 640, + height: 360 + }) + ); + assert!(matches!( + prop(FormatProperties::VideoSize.as_raw()).value, + Value::Choice(ChoiceValue::Rectangle(Choice(_, ChoiceEnum::Range { .. }))) + )); + + assert_eq!( + requested_size_of(pod), + Some(Rectangle { + width: 640, + height: 360 + }) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index d126966..58af0a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! Captures gamescope's PipeWire stream at a small `requested_size` and forwards it to //! HyperHDR's flatbuffers server. +pub mod capture; pub mod config; pub mod convert; pub mod hyperhdr;