feat(config): add the CLI and environment configuration
Parses the WxH sizes, clamps them against the HyperHDR message limit and carries the HyperHDR server constants (max message size, default timeout).
This commit is contained in:
+288
@@ -0,0 +1,288 @@
|
|||||||
|
//! Command line and environment configuration.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
/// Largest message the HyperHDR flatbuffers server accepts (`FlatBuffersServerConnection::readyRead`).
|
||||||
|
pub const HYPERHDR_MAX_MESSAGE: usize = 10_000_000;
|
||||||
|
/// Default `timeout` of the HyperHDR flatbuffers server.
|
||||||
|
pub const HYPERHDR_DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
const MAX_SIZE_DIM: u32 = 1920;
|
||||||
|
const MAX_REQUEST_DIM: u32 = 3840;
|
||||||
|
/// Generous upper bound of the flatbuffers overhead of an `Image` request plus framing.
|
||||||
|
const IMAGE_MESSAGE_OVERHEAD: usize = 256;
|
||||||
|
|
||||||
|
/// A `WxH` frame size.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Size {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Size {
|
||||||
|
pub const fn new(width: u32, height: u32) -> Self {
|
||||||
|
Self { width, height }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Size {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let (w, h) = s
|
||||||
|
.split_once(['x', 'X'])
|
||||||
|
.ok_or_else(|| format!("expected WxH, got {s:?}"))?;
|
||||||
|
let dim = |v: &str| {
|
||||||
|
v.trim()
|
||||||
|
.parse::<u32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|v| *v > 0)
|
||||||
|
.ok_or_else(|| format!("invalid dimension {v:?} in {s:?}"))
|
||||||
|
};
|
||||||
|
Ok(Size::new(dim(w)?, dim(h)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Size {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}x{}", self.width, self.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture gamescope's PipeWire stream and feed it to HyperHDR's flatbuffers server.
|
||||||
|
#[derive(Parser, Debug, Clone)]
|
||||||
|
#[command(version, about)]
|
||||||
|
pub struct Config {
|
||||||
|
/// HyperHDR host (IP address or name)
|
||||||
|
#[arg(long, env = "GSHDR_HOST")]
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// HyperHDR flatbuffers server port
|
||||||
|
#[arg(long, env = "GSHDR_PORT", default_value_t = 19400)]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Source priority in HyperHDR (50..250); must be lower than the USB grabber's (240)
|
||||||
|
#[arg(long, env = "GSHDR_PRIORITY", default_value_t = 150,
|
||||||
|
value_parser = clap::value_parser!(i32).range(50..=250))]
|
||||||
|
pub priority: i32,
|
||||||
|
|
||||||
|
/// Source name shown in HyperHDR (the server appends "@<client ip>")
|
||||||
|
#[arg(long, env = "GSHDR_ORIGIN", default_value = "gamescope")]
|
||||||
|
pub origin: String,
|
||||||
|
|
||||||
|
/// Maximum size of the frame sent to HyperHDR; the aspect ratio is taken from the frame
|
||||||
|
#[arg(long, env = "GSHDR_SIZE", default_value = "320x180")]
|
||||||
|
pub size: Size,
|
||||||
|
|
||||||
|
/// Request size x N from gamescope and area-average down by N on the CPU (1..4)
|
||||||
|
#[arg(long, env = "GSHDR_SUPERSAMPLE", default_value_t = 1,
|
||||||
|
value_parser = clap::value_parser!(u32).range(1..=4))]
|
||||||
|
pub supersample: u32,
|
||||||
|
|
||||||
|
/// Upper bound of the send rate (1..120)
|
||||||
|
#[arg(long, env = "GSHDR_MAX_FPS", default_value_t = 30,
|
||||||
|
value_parser = clap::value_parser!(u32).range(1..=120))]
|
||||||
|
pub max_fps: u32,
|
||||||
|
|
||||||
|
/// Re-send the last frame after this many milliseconds without new frames
|
||||||
|
#[arg(long, env = "GSHDR_KEEPALIVE_MS", default_value_t = 1000,
|
||||||
|
value_parser = clap::value_parser!(u64).range(1..))]
|
||||||
|
pub keepalive_ms: u64,
|
||||||
|
|
||||||
|
/// Clear the priority after this many seconds without new frames (0 = never)
|
||||||
|
#[arg(long, env = "GSHDR_IDLE_CLEAR_SECS", default_value_t = 0)]
|
||||||
|
pub idle_clear_secs: u64,
|
||||||
|
|
||||||
|
/// node.name of the source node
|
||||||
|
#[arg(long, env = "GSHDR_TARGET", default_value = "gamescope")]
|
||||||
|
pub target: String,
|
||||||
|
|
||||||
|
/// TCP connect timeout
|
||||||
|
#[arg(long, default_value_t = 2000, value_parser = clap::value_parser!(u64).range(1..))]
|
||||||
|
pub connect_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// A write blocked for longer than this means the connection is dead
|
||||||
|
#[arg(long, default_value_t = 2000, value_parser = clap::value_parser!(u64).range(1..))]
|
||||||
|
pub write_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Log level or tracing filter expression
|
||||||
|
#[arg(long, env = "RUST_LOG", default_value = "info")]
|
||||||
|
pub log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("--host must not be empty")]
|
||||||
|
EmptyHost,
|
||||||
|
#[error("--target must not be empty")]
|
||||||
|
EmptyTarget,
|
||||||
|
#[error("--size {0}: each dimension must be <= {MAX_SIZE_DIM}")]
|
||||||
|
SizeTooLarge(Size),
|
||||||
|
#[error("--size {size} x --supersample {supersample} = {requested}: each dimension must be <= {MAX_REQUEST_DIM}")]
|
||||||
|
RequestTooLarge {
|
||||||
|
size: Size,
|
||||||
|
supersample: u32,
|
||||||
|
requested: Size,
|
||||||
|
},
|
||||||
|
#[error("--size {size}: an image message would be up to {bytes} bytes, HyperHDR accepts less than {HYPERHDR_MAX_MESSAGE}")]
|
||||||
|
MessageTooLarge { size: Size, bytes: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Checks cross-field constraints. Returns warnings that do not prevent startup.
|
||||||
|
pub fn validate(&self) -> Result<Vec<String>, ConfigError> {
|
||||||
|
if self.host.trim().is_empty() {
|
||||||
|
return Err(ConfigError::EmptyHost);
|
||||||
|
}
|
||||||
|
if self.target.is_empty() {
|
||||||
|
return Err(ConfigError::EmptyTarget);
|
||||||
|
}
|
||||||
|
let size = self.size;
|
||||||
|
if size.width > MAX_SIZE_DIM || size.height > MAX_SIZE_DIM {
|
||||||
|
return Err(ConfigError::SizeTooLarge(size));
|
||||||
|
}
|
||||||
|
let requested = self.requested_size();
|
||||||
|
if requested.width > MAX_REQUEST_DIM || requested.height > MAX_REQUEST_DIM {
|
||||||
|
return Err(ConfigError::RequestTooLarge {
|
||||||
|
size,
|
||||||
|
supersample: self.supersample,
|
||||||
|
requested,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bytes = size.width as usize * size.height as usize * 3 + IMAGE_MESSAGE_OVERHEAD;
|
||||||
|
if bytes >= HYPERHDR_MAX_MESSAGE {
|
||||||
|
return Err(ConfigError::MessageTooLarge { size, bytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if self.keepalive() >= HYPERHDR_DEFAULT_TIMEOUT {
|
||||||
|
warnings.push(format!(
|
||||||
|
"--keepalive-ms {} is not below HyperHDR's default connection timeout ({} s); \
|
||||||
|
the server may drop the connection while the picture is static",
|
||||||
|
self.keepalive_ms,
|
||||||
|
HYPERHDR_DEFAULT_TIMEOUT.as_secs()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size requested from gamescope via `requested_size`.
|
||||||
|
pub fn requested_size(&self) -> Size {
|
||||||
|
Size::new(
|
||||||
|
self.size.width.saturating_mul(self.supersample),
|
||||||
|
self.size.height.saturating_mul(self.supersample),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keepalive(&self) -> Duration {
|
||||||
|
Duration::from_millis(self.keepalive_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn idle_clear(&self) -> Option<Duration> {
|
||||||
|
(self.idle_clear_secs > 0).then(|| Duration::from_secs(self.idle_clear_secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min_send_interval(&self) -> Duration {
|
||||||
|
Duration::from_secs(1) / self.max_fps
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_timeout(&self) -> Duration {
|
||||||
|
Duration::from_millis(self.connect_timeout_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_timeout(&self) -> Duration {
|
||||||
|
Duration::from_millis(self.write_timeout_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<Config, clap::Error> {
|
||||||
|
let mut argv = vec!["gamescope-hyperhdr", "--host", "192.0.2.10"];
|
||||||
|
argv.extend_from_slice(args);
|
||||||
|
Config::try_parse_from(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn size_parsing() {
|
||||||
|
assert_eq!("320x180".parse::<Size>(), Ok(Size::new(320, 180)));
|
||||||
|
assert_eq!("64X36".parse::<Size>(), Ok(Size::new(64, 36)));
|
||||||
|
assert!("320".parse::<Size>().is_err());
|
||||||
|
assert!("0x180".parse::<Size>().is_err());
|
||||||
|
assert!("320x-1".parse::<Size>().is_err());
|
||||||
|
assert!("axb".parse::<Size>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults() {
|
||||||
|
let cfg = parse(&[]).unwrap();
|
||||||
|
assert_eq!(cfg.port, 19400);
|
||||||
|
assert_eq!(cfg.priority, 150);
|
||||||
|
assert_eq!(cfg.origin, "gamescope");
|
||||||
|
assert_eq!(cfg.size, Size::new(320, 180));
|
||||||
|
assert_eq!(cfg.supersample, 1);
|
||||||
|
assert_eq!(cfg.max_fps, 30);
|
||||||
|
assert_eq!(cfg.keepalive(), Duration::from_secs(1));
|
||||||
|
assert_eq!(cfg.idle_clear(), None);
|
||||||
|
assert_eq!(cfg.target, "gamescope");
|
||||||
|
assert_eq!(cfg.validate(), Ok(vec![]));
|
||||||
|
assert_eq!(cfg.requested_size(), Size::new(320, 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_checks() {
|
||||||
|
assert!(parse(&["--priority", "49"]).is_err());
|
||||||
|
assert!(parse(&["--priority", "251"]).is_err());
|
||||||
|
assert!(parse(&["--priority", "250"]).is_ok());
|
||||||
|
assert!(parse(&["--supersample", "0"]).is_err());
|
||||||
|
assert!(parse(&["--supersample", "5"]).is_err());
|
||||||
|
assert!(parse(&["--max-fps", "0"]).is_err());
|
||||||
|
assert!(parse(&["--max-fps", "121"]).is_err());
|
||||||
|
assert!(parse(&["--keepalive-ms", "0"]).is_err());
|
||||||
|
assert!(parse(&["--size", "320x0"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation() {
|
||||||
|
let cfg = parse(&["--size", "1921x100"]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.validate(),
|
||||||
|
Err(ConfigError::SizeTooLarge(Size::new(1921, 100)))
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg = parse(&["--size", "1920x1080", "--supersample", "3"]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
cfg.validate(),
|
||||||
|
Err(ConfigError::RequestTooLarge { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let cfg = parse(&["--size", "1920x1080", "--supersample", "2"]).unwrap();
|
||||||
|
assert_eq!(cfg.validate(), Ok(vec![]));
|
||||||
|
assert_eq!(cfg.requested_size(), Size::new(3840, 2160));
|
||||||
|
|
||||||
|
let cfg = parse(&["--size", "1920x1920"]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
cfg.validate(),
|
||||||
|
Err(ConfigError::MessageTooLarge { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let cfg = parse(&["--keepalive-ms", "5000"]).unwrap();
|
||||||
|
assert_eq!(cfg.validate().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let cfg = parse(&["--target", ""]).unwrap();
|
||||||
|
assert_eq!(cfg.validate(), Err(ConfigError::EmptyTarget));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derived_durations() {
|
||||||
|
let cfg = parse(&["--max-fps", "60", "--idle-clear-secs", "7"]).unwrap();
|
||||||
|
assert_eq!(cfg.min_send_interval(), Duration::from_secs(1) / 60);
|
||||||
|
assert_eq!(cfg.idle_clear(), Some(Duration::from_secs(7)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Captures gamescope's PipeWire stream at a small `requested_size` and forwards it to
|
//! Captures gamescope's PipeWire stream at a small `requested_size` and forwards it to
|
||||||
//! HyperHDR's flatbuffers server.
|
//! HyperHDR's flatbuffers server.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
pub mod hyperhdr;
|
pub mod hyperhdr;
|
||||||
|
|||||||
Reference in New Issue
Block a user