AV fix - more or less

This commit is contained in:
Brad Stein 2025-06-29 22:39:17 -05:00
parent e62f588cf5
commit 6241715c57
10 changed files with 239 additions and 38 deletions

View File

@ -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"] }

View File

@ -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 30s auto-exit in dev mode */
/*───────── optional 300s 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::<VideoPacket>();
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");

View File

@ -49,7 +49,7 @@ impl InputAggregator {
continue;
}
// ─── open the event node readwrite *without* unsafe ──────────
// ─── open the event node read-write *without* unsafe ──────────
let mut dev = match Device::open(&path) {
Ok(d) => d,
Err(e) => {

View File

@ -1,4 +1,4 @@
// client/src/layout.rs Waylandonly 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 fullscreen, right hidden
FullRight, // right eye fullscreen, left hidden
FullLeft, // left eye full-screen, right hidden
FullRight, // right eye full-screen, left hidden
}
/// Move/resize a window titled “Lesavkaeye{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!("Lesavkaeye{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 **currentlyfocused** 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); // offscreen
place_window(1, x + w * 2, y + h * 2, 1, 1); // off-screen
}
Layout::FullRight => {
place_window(1, x, y, w, h);

View File

@ -14,12 +14,12 @@ impl AudioOut {
pub fn new() -> anyhow::Result<Self> {
gst::init()?;
// Autoaudiosink 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`; downcast 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::<gst::Pipeline>()

View File

@ -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<MonitorInfo> {
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::<gdk::Monitor>().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
}

View File

@ -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<Rect> {
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
}

View File

@ -1,4 +1,6 @@
// client/src/output/mod.rs
pub mod audio;
pub mod video;
pub mod video;
pub mod layout;
pub mod display;

View File

@ -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<Self> {
pub fn new(id: u32) -> anyhow::Result<Self> {
gst::init().context("initialising GStreamer")?;
// --- Build pipeline ------------------------------------------------
// --- Build pipeline ---------------------------------------------------
let pipeline: gst::Pipeline = gst::parse::launch(PIPELINE_DESC)?
.downcast::<gst::Pipeline>()
.expect("not a pipeline");
// Optional: turn the sink fullscreen when LESAVKA_FULLSCREEN=1
let fullscreen = std::env::var("LESAVKA_FULLSCREEN").is_ok();
if let Some(sink) = pipeline.by_name("sink") {
// glimagesink: title / fullscreen / forceaspectratio
let title = format!("Lesavkaeye{_id}");
if sink.has_property("title", None::<Type>) {
sink.set_property("title", &title);
}
if sink.has_property("window-title", None::<Type>) {
sink.set_property("window-title", &title);
}
if sink.has_property("fullscreen", None::<Type>) && 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!("Lesavkaeye{_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::<VideoOverlay>() {
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<dyn Fn(&str) -> std::io::Result<ExitStatus> + 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 })
}

View File

@ -92,7 +92,7 @@ pub async fn eye_ball(
} else {
warn!(target:"lesavka_server::video",
eye = %eye,
"🍪 cam_{eye} not found skipping padprobe");
"🍪 cam_{eye} not found skipping pad-probe");
}
let eye_clone = eye.to_owned();