From 6241715c5720089d5b9faba54f40bb187c729cae Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 29 Jun 2025 22:39:17 -0500 Subject: [PATCH] AV fix - more or less --- client/Cargo.toml | 3 +- client/src/app.rs | 5 +- client/src/input/inputs.rs | 2 +- client/src/layout.rs | 14 ++-- client/src/output/audio.rs | 4 +- client/src/output/display.rs | 54 +++++++++++++++ client/src/output/layout.rs | 61 +++++++++++++++++ client/src/output/mod.rs | 4 +- client/src/output/video.rs | 128 ++++++++++++++++++++++++++++------- server/src/video.rs | 2 +- 10 files changed, 239 insertions(+), 38 deletions(-) create mode 100644 client/src/output/display.rs create mode 100644 client/src/output/layout.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 75238f5..03e2de4 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -20,8 +20,9 @@ futures = "0.3" evdev = "0.13" gstreamer = { version = "0.23", features = ["v1_22"] } gstreamer-app = "0.23" -gstreamer-video = "0.23" +gstreamer-video = { package = "gstreamer-video", version = "0.23" } gstreamer-gl-wayland = "0.23" +gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] } winit = "0.30" raw-window-handle = "0.6" serde = { version = "1.0", features = ["derive"] } diff --git a/client/src/app.rs b/client/src/app.rs index 5a73808..68b5326 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -70,10 +70,10 @@ impl LesavkaClientApp { let kbd_loop = self.stream_loop_keyboard(hid_ep.clone()); let mou_loop = self.stream_loop_mouse(hid_ep.clone()); - /*────────── optional 30 s auto-exit in dev mode */ + /*───────── optional 300 s auto-exit in dev mode */ let suicide = async { if self.dev_mode { - tokio::time::sleep(Duration::from_secs(30)).await; + tokio::time::sleep(Duration::from_secs(300)).await; warn!("💀 dev-mode timeout"); std::process::exit(0); } else { @@ -85,6 +85,7 @@ impl LesavkaClientApp { let (video_tx, mut video_rx) = tokio::sync::mpsc::unbounded_channel::(); std::thread::spawn(move || { + gtk::init().expect("GTK initialisation failed"); let el = EventLoopBuilder::<()>::new().with_any_thread(true).build().unwrap(); let win0 = MonitorWindow::new(0).expect("win0"); let win1 = MonitorWindow::new(1).expect("win1"); diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index e0f2a91..9aa2574 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -49,7 +49,7 @@ impl InputAggregator { continue; } - // ─── open the event node read‑write *without* unsafe ────────── + // ─── open the event node read-write *without* unsafe ────────── let mut dev = match Device::open(&path) { Ok(d) => d, Err(e) => { diff --git a/client/src/layout.rs b/client/src/layout.rs index d3e50d3..1a3ec60 100644 --- a/client/src/layout.rs +++ b/client/src/layout.rs @@ -1,4 +1,4 @@ -// client/src/layout.rs – Wayland‑only window placement utilities +// client/src/layout.rs – Wayland-only window placement utilities #![forbid(unsafe_code)] use std::process::Command; @@ -9,13 +9,13 @@ use serde_json::Value; #[derive(Clone, Copy)] pub enum Layout { SideBySide, // two halves on the current monitor - FullLeft, // left eye full‑screen, right hidden - FullRight, // right eye full‑screen, left hidden + FullLeft, // left eye full-screen, right hidden + FullRight, // right eye full-screen, left hidden } -/// Move/resize a window titled “Lesavka‑eye‑{eye}” using `swaymsg`. +/// Move/resize a window titled “Lesavka-eye-{eye}” using `swaymsg`. fn place_window(eye: u32, x: i32, y: i32, w: i32, h: i32) { - let title = format!("Lesavka‑eye‑{eye}"); + let title = format!("Lesavka-eye-{eye}"); let cmd = format!( r#"[title="^{title}$"] resize set {w} {h}; move position {x} {y}"# ); @@ -27,7 +27,7 @@ fn place_window(eye: u32, x: i32, y: i32, w: i32, h: i32) { } } -/// Apply a layout on the **currently‑focused** output. +/// Apply a layout on the **currently-focused** output. /// No `?` operators → the function can stay `-> ()`. pub fn apply(layout: Layout) { let out = match Command::new("swaymsg") @@ -57,7 +57,7 @@ pub fn apply(layout: Layout) { } Layout::FullLeft => { place_window(0, x, y, w, h); - place_window(1, x + w * 2, y + h * 2, 1, 1); // off‑screen + place_window(1, x + w * 2, y + h * 2, 1, 1); // off-screen } Layout::FullRight => { place_window(1, x, y, w, h); diff --git a/client/src/output/audio.rs b/client/src/output/audio.rs index 7c306a4..2e5124d 100644 --- a/client/src/output/audio.rs +++ b/client/src/output/audio.rs @@ -14,12 +14,12 @@ impl AudioOut { pub fn new() -> anyhow::Result { gst::init()?; - // Auto‑audiosink picks PipeWire / Pulse etc. + // Auto-audiosink picks PipeWire / Pulse etc. const PIPE: &str = "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ queue leaky=downstream ! aacparse ! avdec_aac ! audioresample ! autoaudiosink"; - // `parse_launch()` returns `gst::Element`; down‑cast manually and + // `parse_launch()` returns `gst::Element`; down-cast manually and // map the error into anyhow ourselves (no `?` on the downcast). let pipeline: gst::Pipeline = gst::parse::launch(PIPE)? .downcast::() diff --git a/client/src/output/display.rs b/client/src/output/display.rs new file mode 100644 index 0000000..ac7a6c1 --- /dev/null +++ b/client/src/output/display.rs @@ -0,0 +1,54 @@ +// client/src/output/display.rs + +use gtk::prelude::ListModelExt; +use gtk::prelude::*; +use gtk::gdk; +use tracing::debug; + +#[derive(Clone, Debug)] +pub struct MonitorInfo { + pub geometry: gdk::Rectangle, + pub scale_factor: i32, + pub is_internal: bool, +} + +/// Enumerate monitors sorted by our desired priority. +pub fn enumerate_monitors() -> Vec { + let Some(display) = gdk::Display::default() else { + tracing::warn!("⚠️ no GDK display – falling back to single-monitor 0,0"); + return vec![MonitorInfo { + geometry: gdk::Rectangle::new(0, 0, 1920, 1080), + scale_factor: 1, + is_internal: false, + }]; + }; + let model = display.monitors(); // gio::ListModel + + let mut list: Vec<_> = (0..model.n_items()) + .filter_map(|i| model.item(i)) + .filter_map(|obj| obj.downcast::().ok()) + .map(|m| { + // -------- internal vs external ---------------------------------- + let connector = m.connector().unwrap_or_default(); // e.g. "eDP-1" + let is_internal = connector.starts_with("eDP") + || connector.starts_with("LVDS") + || connector.starts_with("DSI") + || connector.to_ascii_lowercase().contains("internal"); + + // -------- geometry / scale -------------------------------------- + let geometry = m.geometry(); + let scale_factor = m.scale_factor(); + + debug!( + "🖥️ monitor: {:?}, connector={:?}, geom={:?}, scale={}", + m.model(), connector, geometry, scale_factor + ); + MonitorInfo { geometry, scale_factor, is_internal } + }) + .collect(); + + // external first, built-in last + list.sort_by_key(|m| m.is_internal); + debug!("🖥️ sorted monitors = {:?}", list); + list +} diff --git a/client/src/output/layout.rs b/client/src/output/layout.rs new file mode 100644 index 0000000..b55bf79 --- /dev/null +++ b/client/src/output/layout.rs @@ -0,0 +1,61 @@ +// client/src/output/layout.rs + +use super::display::MonitorInfo; +use tracing::debug; + +#[derive(Clone, Copy, Debug)] +pub struct Rect { pub x: i32, pub y: i32, pub w: i32, pub h: i32 } + +/// Compute rectangles for N video streams (all 16:9 here). +pub fn assign_rectangles( + monitors: &[MonitorInfo], + streams: &[(&str, i32, i32)], // (name, w, h) +) -> Vec { + let mut rects = vec![Rect { x:0, y:0, w:0, h:0 }; streams.len()]; + + match monitors.len() { + 0 => return rects, // impossible, but keep compiler happy + 1 => { + // One monitor: side-by-side layout + let m = &monitors[0].geometry; + let total_native_width: i32 = streams.iter().map(|(_,w,_)| *w).sum(); + let scale = f64::min( + m.width() as f64 / total_native_width as f64, + m.height() as f64 / streams[0].2 as f64, + ); + debug!("one-monitor scale = {}", scale); + + let mut x = m.x(); + for (idx, &(_, w, h)) in streams.iter().enumerate() { + let ww = (w as f64 * scale).round() as i32; + let hh = (h as f64 * scale).round() as i32; + rects[idx] = Rect { x, y: m.y(), w: ww, h: hh }; + x += ww; + } + } + _ => { + // ≥2 monitors: map 1-to-1 until we run out + for (idx, stream) in streams.iter().enumerate() { + if idx >= monitors.len() { break; } + + let m = &monitors[idx]; + let geom = m.geometry; + let (w, h) = (stream.1, stream.2); + + let scale = f64::min( + geom.width() as f64 / w as f64, + geom.height() as f64 / h as f64, + ); + debug!("monitor#{idx} scale = {scale}"); + + let ww = (w as f64 * scale).round() as i32; + let hh = (h as f64 * scale).round() as i32; + let xx = geom.x() + (geom.width() - ww) / 2; + let yy = geom.y() + (geom.height() - hh) / 2; + rects[idx] = Rect { x: xx, y: yy, w: ww, h: hh }; + } + } + } + debug!("📐 final rectangles = {:?}", rects); + rects +} diff --git a/client/src/output/mod.rs b/client/src/output/mod.rs index 887695f..fb9cc4b 100644 --- a/client/src/output/mod.rs +++ b/client/src/output/mod.rs @@ -1,4 +1,6 @@ // client/src/output/mod.rs pub mod audio; -pub mod video; \ No newline at end of file +pub mod video; +pub mod layout; +pub mod display; diff --git a/client/src/output/video.rs b/client/src/output/video.rs index 589da49..0d8f2d6 100644 --- a/client/src/output/video.rs +++ b/client/src/output/video.rs @@ -1,13 +1,20 @@ // client/src/output/video.rs +use std::process::Command; +use std::time::Duration; +use std::thread; use anyhow::Context; use gstreamer as gst; use gstreamer_app as gst_app; +use gstreamer_video::{prelude::*, VideoOverlay}; use gst::prelude::*; use lesavka_common::lesavka::VideoPacket; use tracing::{error, info, warn, debug}; +use gstreamer_video as gst_video; use gstreamer_video::glib::Type; +use crate::output::{display, layout}; + /* ---------- pipeline ---------------------------------------------------- * ┌────────────┐ H.264/AU ┌─────────┐ Decoded ┌─────────────┐ * │ AppSrc │────────────► decodebin ├──────────► glimagesink │ @@ -27,32 +34,20 @@ pub struct MonitorWindow { } impl MonitorWindow { - pub fn new(_id: u32) -> anyhow::Result { + pub fn new(id: u32) -> anyhow::Result { gst::init().context("initialising GStreamer")?; - // --- Build pipeline ------------------------------------------------ + // --- Build pipeline --------------------------------------------------- let pipeline: gst::Pipeline = gst::parse::launch(PIPELINE_DESC)? .downcast::() .expect("not a pipeline"); - // Optional: turn the sink full‑screen when LESAVKA_FULLSCREEN=1 - let fullscreen = std::env::var("LESAVKA_FULLSCREEN").is_ok(); - if let Some(sink) = pipeline.by_name("sink") { - // glimagesink: title / fullscreen / force‑aspect‑ratio - let title = format!("Lesavka‑eye‑{_id}"); - if sink.has_property("title", None::) { - sink.set_property("title", &title); - } - if sink.has_property("window-title", None::) { - sink.set_property("window-title", &title); - } - if sink.has_property("fullscreen", None::) && fullscreen { - let _ = sink.set_property("fullscreen", &true); - } - let _ = sink.set_property("force-aspect-ratio", &true); - } + /* -------- placement maths -------------------------------------- */ + let monitors = display::enumerate_monitors(); + let stream_defs = &[("eye-0", 1920, 1080), ("eye-1", 1920, 1080)]; + let rects = layout::assign_rectangles(&monitors, stream_defs); - /* ---------- AppSrc ------------------------------------------------- */ + // --- AppSrc------------------------------------------------------------ let src: gst_app::AppSrc = pipeline .by_name("src") .unwrap() @@ -64,11 +59,98 @@ impl MonitorWindow { .field("alignment", &"au") .build())); src.set_format(gst::Format::Time); - pipeline.set_state(gst::State::Playing)?; - if let Some(sink) = pipeline.by_name("sink") { - let title = format!("Lesavka‑eye‑{_id}"); - sink.set_property("force-aspect-ratio", &true); + + /* -------- move/resize overlay ---------------------------------- */ + if let Some(sink_elem) = pipeline.by_name("sink") { + if let Ok(overlay) = sink_elem.dynamic_cast::() { + if let Some(r) = rects.get(id as usize) { + // 1. Tell glimagesink how to crop the texture in its own window + overlay.set_render_rectangle(r.x, r.y, r.w, r.h); + debug!( + "🔲 eye-{id} → render_rectangle({}, {}, {}, {})", + r.x, r.y, r.w, r.h + ); + + // 2. **Compositor-level** placement (Wayland only) + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + use std::process::{Command, ExitStatus}; + use std::sync::Arc; + use std::thread; + use std::time::Duration; + + // A small helper struct so the two branches return the same type + struct Placer { + name: &'static str, + run: Arc std::io::Result + Send + Sync>, + } + + let placer = if Command::new("swaymsg").arg("-t").arg("get_tree") + .output().is_ok() + { + Placer { + name: "swaymsg", + run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()), + } + } else if Command::new("hyprctl").arg("version").output().is_ok() { + Placer { + name: "hyprctl", + run: Arc::new(|cmd| { + Command::new("hyprctl") + .args(["dispatch", "exec", cmd]) + .status() + }), + } + } else { + Placer { + name: "noop", + run: Arc::new(|_| { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "no swaymsg/hyprctl found", + )) + }), + } + }; + + if placer.name != "noop" { + // Criteria string that works for i3-/sway-compatible IPC + let criteria = format!( + r#"[title="^Lesavka-eye-{id}$"] \ + resize set {w} {h}; \ + move absolute position {x} {y}"#, + w = r.w, + h = r.h, + x = r.x, + y = r.y, + ); + + // Retry in a detached thread – avoids blocking GStreamer + let placename = placer.name; + let runner = placer.run.clone(); + thread::spawn(move || { + for attempt in 1..=10 { + thread::sleep(Duration::from_millis(300)); + match runner(&criteria) { + Ok(st) if st.success() => { + tracing::info!( + "✅ {placename}: placed eye-{id} (attempt {attempt})" + ); + break; + } + _ => tracing::debug!( + "⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})" + ), + } + } + }); + } + } + } + } } + + + pipeline.set_state(gst::State::Playing)?; Ok(Self { _pipeline: pipeline, src }) } diff --git a/server/src/video.rs b/server/src/video.rs index aad730b..bb74aeb 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -92,7 +92,7 @@ pub async fn eye_ball( } else { warn!(target:"lesavka_server::video", eye = %eye, - "🍪 cam_{eye} not found – skipping pad‑probe"); + "🍪 cam_{eye} not found – skipping pad-probe"); } let eye_clone = eye.to_owned();