lesavka: pivot eye streams to native source modes

This commit is contained in:
Brad Stein 2026-04-19 03:28:23 -03:00
parent 377cda1309
commit 25da3137aa
19 changed files with 1462 additions and 623 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.11.11"
version = "0.11.12"
edition = "2024"
[dependencies]

View File

@ -425,7 +425,7 @@ impl LesavkaClientApp {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
};
match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => {
@ -469,7 +469,7 @@ impl LesavkaClientApp {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
};
match cli.capture_audio(Request::new(req)).await {
Ok(mut stream) => {

View File

@ -0,0 +1,128 @@
use anyhow::{Context, Result, bail};
use lesavka_common::lesavka::{
CapturePowerCommand, Empty, SetCapturePowerRequest, relay_client::RelayClient,
};
use tonic::{Request, transport::Channel};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandKind {
Status,
Auto,
On,
Off,
}
impl CommandKind {
fn parse(value: &str) -> Option<Self> {
match value {
"status" | "get" => Some(Self::Status),
"auto" => Some(Self::Auto),
"on" | "force-on" => Some(Self::On),
"off" | "force-off" => Some(Self::Off),
_ => None,
}
}
}
struct Config {
server: String,
command: CommandKind,
}
fn usage() -> &'static str {
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off>"
}
fn parse_args() -> Result<Config> {
let mut args = std::env::args().skip(1);
let mut server = "http://127.0.0.1:50051".to_string();
let mut command = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--server" => {
server = args
.next()
.context("missing value after --server")?
.trim()
.to_string();
}
"--help" | "-h" => {
println!("{}", usage());
std::process::exit(0);
}
_ if command.is_none() => {
command = CommandKind::parse(arg.as_str());
if command.is_none() {
bail!("unknown command `{arg}`\n{}", usage());
}
}
_ => bail!("unexpected argument `{arg}`\n{}", usage()),
}
}
Ok(Config {
server,
command: command.unwrap_or(CommandKind::Status),
})
}
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
let channel = Channel::from_shared(server_addr.to_string())
.context("invalid relay server address")?
.tcp_nodelay(true)
.connect()
.await
.with_context(|| format!("connecting to relay at {server_addr}"))?;
Ok(RelayClient::new(channel))
}
fn print_state(state: lesavka_common::lesavka::CapturePowerState) {
println!("available={}", state.available);
println!("enabled={}", state.enabled);
println!("mode={}", state.mode);
println!("active_leases={}", state.active_leases);
println!("unit={}", state.unit);
println!("detail={}", state.detail);
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let config = parse_args()?;
let mut client = connect(config.server.as_str()).await?;
let reply = match config.command {
CommandKind::Status => client
.get_capture_power(Request::new(Empty {}))
.await
.context("querying capture power state")?
.into_inner(),
CommandKind::Auto => client
.set_capture_power(Request::new(SetCapturePowerRequest {
enabled: false,
command: CapturePowerCommand::Auto as i32,
}))
.await
.context("setting capture power to auto")?
.into_inner(),
CommandKind::On => client
.set_capture_power(Request::new(SetCapturePowerRequest {
enabled: true,
command: CapturePowerCommand::ForceOn as i32,
}))
.await
.context("forcing capture power on")?
.into_inner(),
CommandKind::Off => client
.set_capture_power(Request::new(SetCapturePowerRequest {
enabled: false,
command: CapturePowerCommand::ForceOff as i32,
}))
.await
.context("forcing capture power off")?
.into_inner(),
};
print_state(reply);
Ok(())
}

View File

@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fmt::Write as _;
use super::state::{InputRouting, LauncherState, ViewMode};
use super::state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerformanceSample {
@ -28,6 +28,7 @@ pub struct PerformanceSample {
pub left_decoder_label: String,
pub left_stream_caps_label: String,
pub left_decoded_caps_label: String,
pub left_rendered_caps_label: String,
pub right_receive_fps: f32,
pub right_present_fps: f32,
pub right_server_fps: f32,
@ -43,6 +44,7 @@ pub struct PerformanceSample {
pub right_decoder_label: String,
pub right_stream_caps_label: String,
pub right_decoded_caps_label: String,
pub right_rendered_caps_label: String,
pub dropped_frames: u64,
pub queue_depth: u32,
}
@ -100,6 +102,7 @@ pub struct SnapshotReport {
pub preview_source: String,
pub client_display_limit: String,
pub left_surface: String,
pub left_feed_source: String,
pub left_capture_profile: String,
pub left_capture_transport: String,
pub left_breakout_profile: String,
@ -115,7 +118,9 @@ pub struct SnapshotReport {
pub left_server_encoder_label: String,
pub left_stream_caps_label: String,
pub left_decoded_caps_label: String,
pub left_rendered_caps_label: String,
pub right_surface: String,
pub right_feed_source: String,
pub right_capture_profile: String,
pub right_capture_transport: String,
pub right_breakout_profile: String,
@ -131,6 +136,7 @@ pub struct SnapshotReport {
pub right_server_encoder_label: String,
pub right_stream_caps_label: String,
pub right_decoded_caps_label: String,
pub right_rendered_caps_label: String,
pub selected_camera: Option<String>,
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
@ -150,6 +156,12 @@ impl SnapshotReport {
let left_breakout = state.breakout_size_choice(0);
let right_breakout = state.breakout_size_choice(1);
let latest = log.latest();
let left_stream_caps = latest
.map(|sample| sample.left_stream_caps_label.clone())
.unwrap_or_default();
let right_stream_caps = latest
.map(|sample| sample.right_stream_caps_label.clone())
.unwrap_or_default();
Self {
client_version: crate::VERSION.to_string(),
server_version: state.server_version.clone(),
@ -178,14 +190,12 @@ impl SnapshotReport {
state.breakout_display.width, state.breakout_display.height
),
left_surface: state.display_surface(0).label().to_string(),
left_capture_profile: format!(
"{} | {}x{} | {} fps | {} kbit",
left_capture.preset.label(),
left_capture.width,
left_capture.height,
left_capture.fps,
left_capture.max_bitrate_kbit
),
left_feed_source: match state.feed_source_preset(0) {
super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(),
super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(),
super::state::FeedSourcePreset::Off => "Off".to_string(),
},
left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps),
left_capture_transport: left_capture.preset.transport_label().to_string(),
left_breakout_profile: format!(
"{} | {}x{}",
@ -249,15 +259,22 @@ impl SnapshotReport {
}
})
.unwrap_or_else(|| "pending".to_string()),
left_rendered_caps_label: latest
.map(|sample| {
if sample.left_rendered_caps_label.is_empty() {
"pending".to_string()
} else {
sample.left_rendered_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_surface: state.display_surface(1).label().to_string(),
right_capture_profile: format!(
"{} | {}x{} | {} fps | {} kbit",
right_capture.preset.label(),
right_capture.width,
right_capture.height,
right_capture.fps,
right_capture.max_bitrate_kbit
),
right_feed_source: match state.feed_source_preset(1) {
super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(),
super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(),
super::state::FeedSourcePreset::Off => "Off".to_string(),
},
right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps),
right_capture_transport: right_capture.preset.transport_label().to_string(),
right_breakout_profile: format!(
"{} | {}x{}",
@ -321,6 +338,15 @@ impl SnapshotReport {
}
})
.unwrap_or_else(|| "pending".to_string()),
right_rendered_caps_label: latest
.map(|sample| {
if sample.right_rendered_caps_label.is_empty() {
"pending".to_string()
} else {
sample.right_rendered_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
selected_camera: state.devices.camera.clone(),
selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(),
@ -364,6 +390,7 @@ impl SnapshotReport {
let _ = writeln!(text);
let _ = writeln!(text, "left eye");
let _ = writeln!(text, " surface: {}", self.left_surface);
let _ = writeln!(text, " source: {}", self.left_feed_source);
let _ = writeln!(text, " capture: {}", self.left_capture_profile);
let _ = writeln!(text, " transport: {}", self.left_capture_transport);
let _ = writeln!(text, " breakout: {}", self.left_breakout_profile);
@ -379,6 +406,7 @@ impl SnapshotReport {
);
let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label);
let _ = writeln!(
text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
@ -390,6 +418,7 @@ impl SnapshotReport {
);
let _ = writeln!(text, "right eye");
let _ = writeln!(text, " surface: {}", self.right_surface);
let _ = writeln!(text, " source: {}", self.right_feed_source);
let _ = writeln!(text, " capture: {}", self.right_capture_profile);
let _ = writeln!(text, " transport: {}", self.right_capture_transport);
let _ = writeln!(text, " breakout: {}", self.right_breakout_profile);
@ -405,6 +434,7 @@ impl SnapshotReport {
);
let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label);
let _ = writeln!(
text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
@ -510,6 +540,50 @@ pub fn quality_probe_command() -> &'static str {
"scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh"
}
fn capture_profile_label(capture: &CaptureSizeChoice, stream_caps_label: &str) -> String {
if let Some((width, height, fps)) = parse_stream_caps_profile(stream_caps_label) {
return format!(
"{} | observed {}x{} @ {} fps | bitrate est ~{} kbit",
capture.preset.label(),
width,
height,
fps,
capture.max_bitrate_kbit
);
}
format!(
"{} | {}x{} | {} fps | bitrate est ~{} kbit",
capture.preset.label(),
capture.width,
capture.height,
capture.fps,
capture.max_bitrate_kbit
)
}
fn parse_stream_caps_profile(caps: &str) -> Option<(u32, u32, u32)> {
let width = parse_caps_u32(caps, "width=(int)")?;
let height = parse_caps_u32(caps, "height=(int)")?;
let fps = parse_caps_fraction_numerator(caps, "framerate=(fraction)")?;
Some((width, height, fps))
}
fn parse_caps_u32(caps: &str, needle: &str) -> Option<u32> {
let start = caps.find(needle)? + needle.len();
let tail = &caps[start..];
let end = tail.find([',', ';']).unwrap_or(tail.len());
tail[..end].trim().parse::<u32>().ok()
}
fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option<u32> {
let start = caps.find(needle)? + needle.len();
let tail = &caps[start..];
let end = tail.find([',', ';']).unwrap_or(tail.len());
let value = tail[..end].trim();
let numerator = value.split('/').next()?;
numerator.parse::<u32>().ok()
}
fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<String> {
let mut items = Vec::new();
if !state.server_available {
@ -533,7 +607,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
}
if sample.video_loss_pct >= 2.0 || sample.dropped_frames > 0 {
items.push(
"Video packets are arriving with gaps or server-side drops. Try 900p or 720p capture first, then watch whether dropped frames and video-loss fall."
"Video packets are arriving with gaps or server-side drops. Stay on device H.264 pass-through for now and reduce concurrent load before trying more invasive changes."
.to_string(),
);
}
@ -595,32 +669,18 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
}
if sample.server_process_cpu_pct >= 85.0 {
items.push(
"Server process CPU is high. That makes re-encode stalls more likely, so compare Source against lighter re-encode profiles before assuming the WAN is the bottleneck."
"Server process CPU is high. On current hardware that is a strong reason to stay on device H.264 pass-through and avoid any server-side eye transcoding."
.to_string(),
);
}
}
let heavy_capture = state.capture_sizes.iter().any(|preset| {
matches!(
preset,
super::state::CaptureSizePreset::Source
| super::state::CaptureSizePreset::P1080
| super::state::CaptureSizePreset::P1440
)
});
if heavy_capture {
items.push(
"If motion artifacting spikes, try a 900p or 720p capture profile before shrinking the breakout window; that usually lowers WAN pressure faster."
.to_string(),
);
}
let source_passthrough = state
.capture_sizes
.feed_sources
.iter()
.any(|preset| matches!(preset, super::state::CaptureSizePreset::Source));
.any(|preset| !matches!(preset, FeedSourcePreset::Off));
if source_passthrough {
items.push(
"Source capture uses the HDMI device's own H.264 stream. If motion damage lingers for seconds, switch that eye to 1080p, 900p, or 720p so the server re-encodes with a tighter keyframe cadence."
"Device H.264 pass-through is active. If we need it cheaper, use the eye device's real 1080p/720p/576p/480p/VGA source modes before considering codec-aware thinning."
.to_string(),
);
}
@ -631,7 +691,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|| (sample.right_server_fps - sample.right_receive_fps) > 6.0)
{
items.push(
"Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss, so compare Source against 1080p/900p and watch which side stays steadier."
"Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss."
.to_string(),
);
}
@ -662,7 +722,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|| sample.right_server_encoder_label.contains("x264"))
{
items.push(
"The server is leaning on `x264enc` while process CPU is already elevated. That makes `Source` or lighter re-encode settings more attractive than pushing the bitrate ceiling upward."
"At least one eye is still leaning on `x264enc`. That is now unexpected on the source-first path, so treat it as a bug or stale install rather than a normal operating mode."
.to_string(),
);
}
@ -681,7 +741,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
#[cfg(test)]
mod tests {
use super::*;
use crate::launcher::state::{DeviceSelection, LauncherState};
use crate::launcher::state::{CaptureSizePreset, DeviceSelection, LauncherState};
fn sample(n: u64) -> PerformanceSample {
PerformanceSample {
@ -705,9 +765,13 @@ mod tests {
left_server_queue_peak: n as u32 + 1,
left_server_encoder_label: "x264enc".to_string(),
left_decoder_label: "decodebin".to_string(),
left_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(),
left_stream_caps_label:
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1"
.to_string(),
left_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
left_rendered_caps_label:
"video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(),
right_receive_fps: 30.0,
right_present_fps: 28.0,
right_server_fps: 30.0,
@ -721,9 +785,13 @@ mod tests {
right_server_queue_peak: n as u32 + 1,
right_server_encoder_label: "source-pass-through".to_string(),
right_decoder_label: "decodebin".to_string(),
right_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(),
right_stream_caps_label:
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1"
.to_string(),
right_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
right_rendered_caps_label:
"video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(),
dropped_frames: n,
queue_depth: n as u32,
}
@ -781,11 +849,17 @@ mod tests {
assert_eq!(report.notes, vec!["first note".to_string()]);
assert!(report.status.contains("mode=remote"));
assert!(report.client_version.starts_with("0."));
assert!(report.left_capture_profile.contains("fps"));
assert_eq!(report.left_capture_transport, "server re-encode");
assert_eq!(report.left_feed_source, "Left Eye");
assert!(
report
.left_capture_profile
.contains("observed 1920x1080 @ 60 fps")
);
assert_eq!(report.left_capture_transport, "device H.264 pass-through");
assert_eq!(report.left_decoder_label, "decodebin");
assert!(report.left_stream_caps_label.contains("video/x-h264"));
assert!(report.left_decoded_caps_label.contains("video/x-raw"));
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
}
#[test]
@ -812,10 +886,12 @@ mod tests {
assert!(text.contains("Lesavka Diagnostics"));
assert!(text.contains("client: v"));
assert!(text.contains("left eye"));
assert!(text.contains("source:"));
assert!(text.contains("transport:"));
assert!(text.contains("live: decoder="));
assert!(text.contains("stream caps:"));
assert!(text.contains("decoded caps:"));
assert!(text.contains("rendered caps:"));
assert!(text.contains("recommendations"));
}
@ -825,4 +901,39 @@ mod tests {
assert!(cmd.contains("hygiene_gate.sh"));
assert!(cmd.contains("quality_gate.sh"));
}
#[test]
fn source_capture_profile_prefers_observed_stream_caps_when_available() {
let capture = CaptureSizeChoice {
preset: CaptureSizePreset::P1080,
width: 1920,
height: 1080,
fps: 60,
max_bitrate_kbit: 18_000,
};
let label = capture_profile_label(
&capture,
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1",
);
assert_eq!(
label,
"1080p | observed 1920x1080 @ 60 fps | bitrate est ~18000 kbit"
);
}
#[test]
fn capture_profile_falls_back_when_stream_caps_are_incomplete() {
let capture = CaptureSizeChoice {
preset: CaptureSizePreset::P1080,
width: 1920,
height: 1080,
fps: 60,
max_bitrate_kbit: 18_000,
};
let label = capture_profile_label(&capture, "video/x-h264, width=(int)1920");
assert_eq!(
label,
"1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit"
);
}
}

View File

@ -12,7 +12,6 @@ use gstreamer_app as gst_app;
use gtk::prelude::WidgetExt;
#[cfg(not(coverage))]
use gtk::{gdk, glib};
#[cfg(not(coverage))]
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
#[cfg(not(coverage))]
use std::collections::VecDeque;
@ -32,18 +31,22 @@ const PREVIEW_WIDTH: i32 = 960;
#[cfg(not(coverage))]
const PREVIEW_HEIGHT: i32 = 540;
#[cfg(not(coverage))]
const INLINE_PREVIEW_REQUEST_WIDTH: i32 = 960;
const INLINE_PREVIEW_REQUEST_WIDTH: i32 = DEFAULT_EYE_SOURCE_WIDTH;
#[cfg(not(coverage))]
const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = 540;
const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = DEFAULT_EYE_SOURCE_HEIGHT;
#[cfg(not(coverage))]
const INLINE_PREVIEW_REQUEST_FPS: u32 = 24;
const INLINE_PREVIEW_REQUEST_FPS: u32 = DEFAULT_EYE_SOURCE_FPS;
#[cfg(not(coverage))]
const INLINE_PREVIEW_MAX_KBIT: u32 = 4_000;
const INLINE_PREVIEW_MAX_KBIT: u32 = DEFAULT_EYE_SOURCE_MAX_KBIT;
#[cfg(not(coverage))]
const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920;
#[cfg(not(coverage))]
const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080;
#[cfg(not(coverage))]
const DEFAULT_EYE_SOURCE_FPS: u32 = 60;
#[cfg(not(coverage))]
const DEFAULT_EYE_SOURCE_MAX_KBIT: u32 = 18_000;
#[cfg(not(coverage))]
const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview.";
#[cfg(not(coverage))]
const TELEMETRY_WINDOW: Duration = Duration::from_secs(5);
@ -92,18 +95,19 @@ pub struct PreviewMetricsSnapshot {
pub decoder_label: String,
pub stream_caps_label: String,
pub decoded_caps_label: String,
pub rendered_caps_label: String,
}
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug)]
struct PreviewProfile {
source_monitor_id: u32,
display_width: i32,
display_height: i32,
requested_width: i32,
requested_height: i32,
requested_fps: u32,
max_bitrate_kbit: u32,
prefer_reencode: bool,
}
#[cfg(not(coverage))]
@ -111,6 +115,7 @@ impl PreviewSurface {
fn profile(self) -> PreviewProfile {
match self {
Self::Inline => PreviewProfile {
source_monitor_id: 0,
display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH),
display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT),
requested_width: preview_dimension(
@ -129,9 +134,9 @@ impl PreviewSurface {
"LESAVKA_PREVIEW_MAX_KBIT",
INLINE_PREVIEW_MAX_KBIT,
),
prefer_reencode: true,
},
Self::Window => PreviewProfile {
source_monitor_id: 0,
display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280),
display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720),
requested_width: preview_dimension(
@ -142,9 +147,14 @@ impl PreviewSurface {
"LESAVKA_BREAKOUT_REQUEST_HEIGHT",
DEFAULT_EYE_SOURCE_HEIGHT,
),
requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30),
max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000),
prefer_reencode: true,
requested_fps: preview_bitrate(
"LESAVKA_BREAKOUT_REQUEST_FPS",
DEFAULT_EYE_SOURCE_FPS,
),
max_bitrate_kbit: preview_bitrate(
"LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT",
DEFAULT_EYE_SOURCE_MAX_KBIT,
),
},
}
}
@ -277,34 +287,32 @@ impl LauncherPreview {
pub fn set_capture_profile(
&self,
monitor_id: usize,
source_monitor_id: usize,
requested_width: i32,
requested_height: i32,
requested_fps: u32,
max_bitrate_kbit: u32,
prefer_reencode: bool,
) {
let (
inline_requested_width,
inline_requested_height,
inline_requested_fps,
inline_max_bitrate_kbit,
inline_prefer_reencode,
) = adapt_inline_preview_request(
) = sanitize_preview_request(
requested_width,
requested_height,
requested_fps,
max_bitrate_kbit,
prefer_reencode,
);
self.rebuild_feed(
&self.inline_feeds,
monitor_id,
Some((
source_monitor_id,
inline_requested_width,
inline_requested_height,
inline_requested_fps,
inline_max_bitrate_kbit,
inline_prefer_reencode,
)),
None,
);
@ -312,11 +320,11 @@ impl LauncherPreview {
&self.window_feeds,
monitor_id,
Some((
source_monitor_id,
requested_width,
requested_height,
requested_fps,
max_bitrate_kbit,
prefer_reencode,
)),
None,
);
@ -331,23 +339,36 @@ impl LauncherPreview {
&self,
monitor_id: usize,
surface: PreviewSurface,
) -> Option<(i32, i32, i32, i32, u32, u32, bool)> {
) -> Option<(u32, i32, i32, i32, i32, u32, u32)> {
let feed = match surface {
PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(),
PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(),
}?;
let profile = feed.profile();
Some((
profile.source_monitor_id,
profile.display_width,
profile.display_height,
profile.requested_width,
profile.requested_height,
profile.requested_fps,
profile.max_bitrate_kbit,
profile.prefer_reencode,
))
}
#[cfg(test)]
pub(crate) fn feed_disabled_for_test(
&self,
monitor_id: usize,
surface: PreviewSurface,
) -> Option<bool> {
let feed = match surface {
PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(),
PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(),
}?;
Some(feed.is_disabled())
}
#[cfg(test)]
pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) {
let feed = match surface {
@ -372,7 +393,7 @@ impl LauncherPreview {
&self,
feeds: &Arc<Mutex<[PreviewFeed; 2]>>,
monitor_id: usize,
requested: Option<(i32, i32, u32, u32, bool)>,
requested: Option<(usize, i32, i32, u32, u32)>,
display: Option<(i32, i32)>,
) {
let Ok(mut feeds) = feeds.lock() else {
@ -382,43 +403,95 @@ impl LauncherPreview {
return;
};
let was_active = existing.is_active();
let keep_disabled = existing.is_disabled();
let mut profile = existing.profile();
if let Some((
source_monitor_id,
requested_width,
requested_height,
requested_fps,
max_bitrate_kbit,
prefer_reencode,
)) = requested
{
profile.source_monitor_id = source_monitor_id as u32;
profile.requested_width = requested_width.max(2);
profile.requested_height = requested_height.max(2);
profile.requested_fps = requested_fps.max(1);
profile.max_bitrate_kbit = max_bitrate_kbit.max(800);
profile.prefer_reencode = prefer_reencode;
}
if let Some((display_width, display_height)) = display {
profile.display_width = display_width.max(2);
profile.display_height = display_height.max(2);
}
match PreviewFeed::spawn(
Arc::clone(&self.server_addr),
monitor_id as u32,
profile,
Arc::clone(&self.log_sink),
) {
Ok(feed) => {
if was_active {
feed.set_active(true);
let next_feed = if keep_disabled {
Some(PreviewFeed::spawn_disabled(profile))
} else {
match PreviewFeed::spawn(
Arc::clone(&self.server_addr),
monitor_id as u32,
profile,
Arc::clone(&self.log_sink),
) {
Ok(feed) => Some(feed),
Err(err) => {
warn!(monitor_id, ?err, "could not rebuild preview feed");
None
}
existing.shutdown();
feeds[monitor_id] = feed;
}
Err(err) => {
warn!(monitor_id, ?err, "could not rebuild preview feed");
};
if let Some(feed) = next_feed {
if was_active {
feed.set_active(true);
}
existing.shutdown();
feeds[monitor_id] = feed;
}
}
pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) {
self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled);
self.set_feed_enabled(&self.window_feeds, monitor_id, enabled);
}
fn set_feed_enabled(
&self,
feeds: &Arc<Mutex<[PreviewFeed; 2]>>,
monitor_id: usize,
enabled: bool,
) {
let Ok(mut feeds) = feeds.lock() else {
return;
};
let Some(existing) = feeds.get(monitor_id).cloned() else {
return;
};
if existing.is_disabled() == !enabled {
return;
}
let was_active = existing.is_active();
let profile = existing.profile();
let replacement = if enabled {
match PreviewFeed::spawn(
Arc::clone(&self.server_addr),
monitor_id as u32,
profile,
Arc::clone(&self.log_sink),
) {
Ok(feed) => feed,
Err(err) => {
warn!(monitor_id, ?err, "could not enable preview feed");
return;
}
}
} else {
PreviewFeed::spawn_disabled(profile)
};
if was_active {
replacement.set_active(true);
}
existing.shutdown();
feeds[monitor_id] = replacement;
}
}
#[cfg(not(coverage))]
@ -463,6 +536,7 @@ struct PreviewFeed {
active_bindings: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
profile: PreviewProfile,
disabled: bool,
}
#[cfg(not(coverage))]
@ -542,6 +616,7 @@ struct PreviewTelemetry {
decoder_label: String,
stream_caps_label: String,
decoded_caps_label: String,
rendered_caps_label: String,
}
#[cfg(not(coverage))]
@ -655,6 +730,12 @@ impl PreviewTelemetry {
}
}
fn note_rendered_caps(&mut self, caps_label: &str) {
if !caps_label.is_empty() {
self.rendered_caps_label = caps_label.to_string();
}
}
fn snapshot(&mut self) -> PreviewMetricsSnapshot {
self.snapshot_at(Instant::now())
}
@ -700,6 +781,7 @@ impl PreviewTelemetry {
decoder_label: self.decoder_label.clone(),
stream_caps_label: self.stream_caps_label.clone(),
decoded_caps_label: self.decoded_caps_label.clone(),
rendered_caps_label: self.rendered_caps_label.clone(),
}
}
@ -750,27 +832,54 @@ impl PreviewFeed {
active_bindings,
running,
profile,
disabled: false,
})
}
fn spawn_disabled(profile: PreviewProfile) -> Self {
let shared = Arc::new(Mutex::new(SharedPreviewState::new()));
if let Ok(mut slot) = shared.lock() {
slot.set_status("Feed disabled.", true);
}
Self {
shared,
session_active: Arc::new(AtomicBool::new(false)),
active_bindings: Arc::new(AtomicUsize::new(0)),
running: Arc::new(AtomicBool::new(false)),
profile,
disabled: true,
}
}
fn profile(&self) -> PreviewProfile {
self.profile
}
fn is_disabled(&self) -> bool {
self.disabled
}
fn is_active(&self) -> bool {
self.session_active.load(Ordering::Relaxed)
}
fn set_active(&self, active: bool) {
self.session_active.store(active, Ordering::Relaxed);
if !active {
if !active && !self.disabled {
self.replace_status(PREVIEW_IDLE_STATUS, true);
}
}
fn shutdown(&self) {
self.running.store(false, Ordering::Relaxed);
self.replace_status(PREVIEW_IDLE_STATUS, true);
self.replace_status(
if self.disabled {
"Feed disabled."
} else {
PREVIEW_IDLE_STATUS
},
true,
);
}
fn replace_status(&self, status: impl Into<String>, clear_picture: bool) {
@ -907,6 +1016,14 @@ fn run_preview_feed(
if let Some(decoder) = decoder.as_ref() {
record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded);
}
if let Some(caps) = sample.caps() {
let caps_label = preview_caps_summary(&caps);
if !caps_label.is_empty()
&& let Ok(mut slot) = shared.lock()
{
slot.telemetry.note_rendered_caps(&caps_label);
}
}
if let Some(frame) = sample_to_frame(&sample) {
if let Ok(mut slot) = shared.lock() {
slot.push_frame(frame);
@ -1022,7 +1139,7 @@ fn run_preview_feed(
requested_width: profile.requested_width.max(0) as u32,
requested_height: profile.requested_height.max(0) as u32,
requested_fps: profile.requested_fps,
prefer_reencode: profile.prefer_reencode,
source_id: Some(profile.source_monitor_id),
};
match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => {
@ -1289,16 +1406,15 @@ fn looks_like_preview_problem(status: &str) -> bool {
#[cfg(not(coverage))]
fn build_preview_pipeline(
profile: PreviewProfile,
_profile: PreviewProfile,
) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> {
let decoder_name = pick_h264_decoder();
let desc = format!(
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! videoscale ! \
video/x-raw,format=RGBA,width={},height={},pixel-aspect-ratio=1/1 ! \
h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! \
video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
profile.display_width, profile.display_height
);
let pipeline = gst::parse::launch(&desc)?
.downcast::<gst::Pipeline>()
@ -1325,8 +1441,7 @@ fn build_preview_pipeline(
appsink.set_caps(Some(
&gst::Caps::builder("video/x-raw")
.field("format", &"RGBA")
.field("width", &profile.display_width)
.field("height", &profile.display_height)
.field("pixel-aspect-ratio", &gst::Fraction::new(1, 1))
.build(),
));
@ -1375,10 +1490,8 @@ fn record_preview_caps(
}
#[cfg(not(coverage))]
fn preview_caps_summary(caps: &gst::Caps) -> String {
caps.structure(0)
.map(|structure| structure.to_string())
.unwrap_or_else(|| caps.to_string())
fn preview_caps_summary(caps: &impl std::fmt::Display) -> String {
caps.to_string()
}
#[cfg(not(coverage))]
@ -1444,39 +1557,17 @@ fn preview_dimension(var: &str, default: i32) -> i32 {
}
#[cfg(not(coverage))]
fn adapt_inline_preview_request(
fn sanitize_preview_request(
requested_width: i32,
requested_height: i32,
requested_fps: u32,
max_bitrate_kbit: u32,
prefer_reencode: bool,
) -> (i32, i32, u32, u32, bool) {
let inline_width = requested_width.max(2);
let inline_height = requested_height.max(2);
if !prefer_reencode {
return (
inline_width,
inline_height,
requested_fps.max(1),
max_bitrate_kbit.max(800),
false,
);
}
let inline_fps = requested_fps.max(1).min(preview_bitrate(
"LESAVKA_PREVIEW_REQUEST_FPS",
INLINE_PREVIEW_REQUEST_FPS,
));
let inline_bitrate = max_bitrate_kbit.max(800).min(preview_bitrate(
"LESAVKA_PREVIEW_MAX_KBIT",
INLINE_PREVIEW_MAX_KBIT,
));
let adapted = inline_fps != requested_fps.max(1) || inline_bitrate != max_bitrate_kbit.max(800);
) -> (i32, i32, u32, u32) {
(
inline_width,
inline_height,
inline_fps,
inline_bitrate,
prefer_reencode || adapted,
requested_width.max(2),
requested_height.max(2),
requested_fps.max(1),
max_bitrate_kbit.max(800),
)
}
@ -1539,7 +1630,7 @@ mod tests {
DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT,
INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH,
LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry,
adapt_inline_preview_request,
sanitize_preview_request,
};
use crate::launcher::state::{CaptureSizePreset, LauncherState};
use futures::stream;
@ -1691,20 +1782,20 @@ mod tests {
assert_eq!(profile.display_height, 720);
assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH);
assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT);
assert_eq!(profile.requested_fps, 30);
assert_eq!(profile.max_bitrate_kbit, 12_000);
assert_eq!(profile.requested_fps, 60);
assert_eq!(profile.max_bitrate_kbit, 18_000);
}
#[test]
fn inline_preview_request_caps_large_full_quality_streams() {
let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, true);
assert_eq!(adapted, (1920, 1080, 24, 4_000, true));
fn preview_request_sanitizer_keeps_requested_source_geometry() {
let adapted = sanitize_preview_request(1920, 1080, 60, 18_000);
assert_eq!(adapted, (1920, 1080, 60, 18_000));
}
#[test]
fn inline_preview_request_keeps_source_stream_honest() {
let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, false);
assert_eq!(adapted, (1920, 1080, 30, 12_000, false));
fn preview_request_sanitizer_clamps_invalid_values() {
let adapted = sanitize_preview_request(0, 0, 0, 0);
assert_eq!(adapted, (2, 2, 1, 800));
}
#[test]
@ -1762,7 +1853,7 @@ mod tests {
#[test]
#[serial]
fn inline_preview_requests_selected_reencode_profile_on_wire() {
fn inline_preview_requests_selected_source_profile_on_wire() {
let relay = ProbeRelay::default();
let requests = relay.requests.clone();
let rt = tokio::runtime::Runtime::new().expect("runtime");
@ -1783,23 +1874,24 @@ mod tests {
let state = LauncherState::default();
let capture = state.capture_size_choice(1);
preview.set_capture_profile(
1,
1,
capture.width,
capture.height,
capture.fps,
capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
);
preview.activate_surface_for_test(1, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() {
assert_eq!(request.id, 1);
assert_eq!(request.source_id, Some(1));
assert_eq!(request.requested_width, 1920);
assert_eq!(request.requested_height, 1080);
assert_eq!(request.requested_fps, 24);
assert_eq!(request.max_bitrate, 4_000);
assert!(request.prefer_reencode);
assert_eq!(request.requested_fps, 60);
assert_eq!(request.max_bitrate, 18_000);
preview.shutdown_all();
return;
}
@ -1831,26 +1923,27 @@ mod tests {
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
let mut state = LauncherState::default();
state.set_capture_size_preset(1, CaptureSizePreset::Source);
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
let capture = state.capture_size_choice(1);
preview.set_capture_profile(
1,
1,
capture.width,
capture.height,
capture.fps,
capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
);
preview.activate_surface_for_test(1, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() {
assert_eq!(request.id, 1);
assert_eq!(request.source_id, Some(1));
assert_eq!(request.requested_width, 1920);
assert_eq!(request.requested_height, 1080);
assert_eq!(request.requested_fps, 30);
assert_eq!(request.max_bitrate, 12_000);
assert!(!request.prefer_reencode);
assert_eq!(request.requested_fps, 60);
assert_eq!(request.max_bitrate, 18_000);
preview.shutdown_all();
return;
}
@ -1860,4 +1953,148 @@ mod tests {
preview.shutdown_all();
panic!("preview did not issue a source capture request within timeout");
}
#[test]
#[serial]
fn inline_preview_requests_native_720p_source_mode_on_wire() {
let relay = ProbeRelay::default();
let requests = relay.requests.clone();
let rt = tokio::runtime::Runtime::new().expect("runtime");
let addr = rt.block_on(async move {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(RelayServer::new(relay))
.serve(addr)
.await;
});
addr
});
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
let mut state = LauncherState::default();
state.set_capture_size_preset(1, CaptureSizePreset::P720);
let capture = state.capture_size_choice(1);
preview.set_capture_profile(
1,
1,
capture.width,
capture.height,
capture.fps,
capture.max_bitrate_kbit,
);
preview.activate_surface_for_test(1, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() {
assert_eq!(request.id, 1);
assert_eq!(request.source_id, Some(1));
assert_eq!(request.requested_width, 1280);
assert_eq!(request.requested_height, 720);
assert_eq!(request.requested_fps, 60);
assert_eq!(request.max_bitrate, 12_000);
preview.shutdown_all();
return;
}
std::thread::sleep(Duration::from_millis(50));
}
preview.shutdown_all();
panic!("preview did not issue a 720p source capture request within timeout");
}
#[test]
#[serial]
fn inline_preview_requests_native_480p_source_mode_on_wire() {
let relay = ProbeRelay::default();
let requests = relay.requests.clone();
let rt = tokio::runtime::Runtime::new().expect("runtime");
let addr = rt.block_on(async move {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(RelayServer::new(relay))
.serve(addr)
.await;
});
addr
});
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
let mut state = LauncherState::default();
state.set_capture_size_preset(1, CaptureSizePreset::P480);
let capture = state.capture_size_choice(1);
preview.set_capture_profile(
1,
1,
capture.width,
capture.height,
capture.fps,
capture.max_bitrate_kbit,
);
preview.activate_surface_for_test(1, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() {
assert_eq!(request.id, 1);
assert_eq!(request.source_id, Some(1));
assert_eq!(request.requested_width, 720);
assert_eq!(request.requested_height, 480);
assert_eq!(request.requested_fps, 60);
assert_eq!(request.max_bitrate, 2_500);
preview.shutdown_all();
return;
}
std::thread::sleep(Duration::from_millis(50));
}
preview.shutdown_all();
panic!("preview did not issue a 480p source capture request within timeout");
}
#[test]
#[serial]
fn preview_can_request_other_eye_as_a_distinct_stream() {
let relay = ProbeRelay::default();
let requests = relay.requests.clone();
let rt = tokio::runtime::Runtime::new().expect("runtime");
let addr = rt.block_on(async move {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(RelayServer::new(relay))
.serve(addr)
.await;
});
addr
});
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
preview.set_capture_profile(0, 1, 1920, 1080, 30, 12_000);
preview.activate_surface_for_test(0, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() {
assert_eq!(request.id, 0);
assert_eq!(request.source_id, Some(1));
assert_eq!(request.requested_width, 1920);
assert_eq!(request.requested_height, 1080);
preview.shutdown_all();
return;
}
std::thread::sleep(Duration::from_millis(50));
}
preview.shutdown_all();
panic!("preview did not issue a mirrored capture request within timeout");
}
}

View File

@ -1,8 +1,7 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::devices::DeviceCatalog;
use lesavka_common::eye_source::{EyeSourceMode, default_eye_source_mode, native_eye_source_modes};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputRouting {
@ -49,6 +48,43 @@ impl DisplaySurface {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FeedSourcePreset {
ThisEye,
OtherEye,
Off,
}
impl FeedSourcePreset {
pub fn as_id(self) -> &'static str {
match self {
Self::ThisEye => "self",
Self::OtherEye => "other",
Self::Off => "off",
}
}
pub fn from_id(raw: &str) -> Option<Self> {
match raw {
"self" => Some(Self::ThisEye),
"other" => Some(Self::OtherEye),
"off" => Some(Self::Off),
_ => None,
}
}
pub fn label(self, monitor_id: usize) -> &'static str {
match (monitor_id, self) {
(_, Self::Off) => "Off",
(0, Self::ThisEye) => "Left Eye",
(0, Self::OtherEye) => "Right Eye",
(1, Self::ThisEye) => "Right Eye",
(1, Self::OtherEye) => "Left Eye",
_ => "This Eye",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BreakoutSizePreset {
P360,
@ -105,57 +141,69 @@ impl BreakoutSizePreset {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CaptureSizePreset {
P360,
P540,
#[serde(alias = "P360")]
Vga,
#[serde(alias = "P540")]
P480,
P576,
P720,
P900,
#[serde(alias = "P900", alias = "P1440", alias = "Source")]
P1080,
P1440,
Source,
}
impl CaptureSizePreset {
pub fn as_id(self) -> &'static str {
match self {
Self::P360 => "360p",
Self::P540 => "540p",
Self::Vga => "vga",
Self::P480 => "480p",
Self::P576 => "576p",
Self::P720 => "720p",
Self::P900 => "900p",
Self::P1080 => "1080p",
Self::P1440 => "1440p",
Self::Source => "source",
}
}
pub fn from_id(raw: &str) -> Option<Self> {
match raw {
"360p" => Some(Self::P360),
"540p" => Some(Self::P540),
"vga" | "360p" => Some(Self::Vga),
"480p" | "540p" => Some(Self::P480),
"576p" => Some(Self::P576),
"720p" => Some(Self::P720),
"900p" => Some(Self::P900),
"1080p" => Some(Self::P1080),
"1440p" => Some(Self::P1440),
"source" => Some(Self::Source),
"900p" | "1080p" | "1440p" | "source" => Some(Self::P1080),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::P360 => "360p",
Self::P540 => "540p",
Self::Vga => "VGA",
Self::P480 => "480p",
Self::P576 => "576p",
Self::P720 => "720p",
Self::P900 => "900p",
Self::P1080 => "1080p",
Self::P1440 => "1440p",
Self::Source => "Source",
}
}
pub fn transport_label(self) -> &'static str {
"device H.264 pass-through"
}
pub fn source_mode(self) -> EyeSourceMode {
match self {
Self::Source => "source pass-through",
_ => "server re-encode",
Self::Vga => native_eye_source_modes()[4],
Self::P480 => native_eye_source_modes()[3],
Self::P576 => native_eye_source_modes()[2],
Self::P720 => native_eye_source_modes()[1],
Self::P1080 => native_eye_source_modes()[0],
}
}
pub fn from_source_mode(mode: EyeSourceMode) -> Self {
match (mode.width, mode.height, mode.fps) {
(640, 480, 60) => Self::Vga,
(720, 480, 60) => Self::P480,
(720, 576, 50) => Self::P576,
(1280, 720, 60) => Self::P720,
_ => Self::P1080,
}
}
}
@ -169,10 +217,11 @@ pub struct PreviewSourceSize {
impl Default for PreviewSourceSize {
fn default() -> Self {
let mode = default_eye_source_mode();
Self {
width: 1920,
height: 1080,
fps: 30,
width: mode.width,
height: mode.height,
fps: mode.fps,
}
}
}
@ -193,6 +242,12 @@ pub struct CaptureSizeChoice {
pub max_bitrate_kbit: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeedSourceChoice {
pub preset: FeedSourcePreset,
pub label: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CaptureFpsChoice {
pub fps: u32,
@ -242,6 +297,7 @@ pub struct LauncherState {
pub routing: InputRouting,
pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2],
pub feed_sources: [FeedSourcePreset; 2],
pub preview_source: PreviewSourceSize,
pub breakout_limit: PreviewSourceSize,
pub breakout_display: PreviewSourceSize,
@ -266,12 +322,13 @@ impl Default for LauncherState {
routing: InputRouting::Remote,
view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye],
preview_source: PreviewSourceSize::default(),
breakout_limit: PreviewSourceSize::default(),
breakout_display: PreviewSourceSize::default(),
capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080],
capture_fps: [30, 30],
capture_bitrates_kbit: [12_000, 12_000],
capture_fps: [60, 60],
capture_bitrates_kbit: [18_000, 18_000],
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
devices: DeviceSelection::default(),
swap_key: "pause".to_string(),
@ -323,6 +380,44 @@ impl LauncherState {
.unwrap_or(DisplaySurface::Preview)
}
pub fn feed_source_preset(&self, monitor_id: usize) -> FeedSourcePreset {
self.feed_sources
.get(monitor_id)
.copied()
.unwrap_or(FeedSourcePreset::ThisEye)
}
pub fn set_feed_source_preset(&mut self, monitor_id: usize, preset: FeedSourcePreset) {
if let Some(slot) = self.feed_sources.get_mut(monitor_id) {
*slot = preset;
}
}
pub fn feed_source_options(&self, monitor_id: usize) -> Vec<FeedSourceChoice> {
vec![
FeedSourceChoice {
preset: FeedSourcePreset::ThisEye,
label: FeedSourcePreset::ThisEye.label(monitor_id),
},
FeedSourceChoice {
preset: FeedSourcePreset::OtherEye,
label: FeedSourcePreset::OtherEye.label(monitor_id),
},
FeedSourceChoice {
preset: FeedSourcePreset::Off,
label: FeedSourcePreset::Off.label(monitor_id),
},
]
}
pub fn resolved_feed_monitor_id(&self, monitor_id: usize) -> Option<usize> {
match self.feed_source_preset(monitor_id) {
FeedSourcePreset::ThisEye => Some(monitor_id.min(1)),
FeedSourcePreset::OtherEye => Some(1_usize.saturating_sub(monitor_id.min(1))),
FeedSourcePreset::Off => None,
}
}
pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) {
if let Some(slot) = self.displays.get_mut(monitor_id) {
*slot = surface;
@ -391,13 +486,21 @@ impl LauncherState {
}
pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset {
self.capture_sizes
.get(monitor_id)
.copied()
.unwrap_or(CaptureSizePreset::Source)
normalize_capture_size_preset(
self.capture_sizes
.get(monitor_id)
.copied()
.unwrap_or(CaptureSizePreset::P1080),
)
}
pub fn display_capture_size_preset(&self, monitor_id: usize) -> Option<CaptureSizePreset> {
self.resolved_feed_monitor_id(monitor_id)
.map(|source_id| self.capture_size_preset(source_id))
}
pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) {
let preset = normalize_capture_size_preset(preset);
if let Some(slot) = self.capture_sizes.get_mut(monitor_id) {
*slot = preset;
}
@ -410,10 +513,15 @@ impl LauncherState {
self.capture_fps
.get(monitor_id)
.copied()
.unwrap_or(30)
.unwrap_or(default_eye_source_mode().fps)
.max(1)
}
pub fn display_capture_fps(&self, monitor_id: usize) -> Option<u32> {
self.resolved_feed_monitor_id(monitor_id)
.map(|source_id| self.capture_fps(source_id))
}
pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) {
if let Some(slot) = self.capture_fps.get_mut(monitor_id) {
*slot = fps.max(1);
@ -424,10 +532,19 @@ impl LauncherState {
self.capture_bitrates_kbit
.get(monitor_id)
.copied()
.unwrap_or(12_000)
.unwrap_or(estimate_source_bitrate_kbit(
default_eye_source_mode().width as i32,
default_eye_source_mode().height as i32,
default_eye_source_mode().fps,
))
.max(800)
}
pub fn display_capture_bitrate_kbit(&self, monitor_id: usize) -> Option<u32> {
self.resolved_feed_monitor_id(monitor_id)
.map(|source_id| self.capture_bitrate_kbit(source_id))
}
pub fn set_capture_bitrate_kbit(&mut self, monitor_id: usize, max_bitrate_kbit: u32) {
if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) {
*slot = max_bitrate_kbit.max(800);
@ -443,6 +560,11 @@ impl LauncherState {
)
}
pub fn display_capture_size_choice(&self, monitor_id: usize) -> Option<CaptureSizeChoice> {
self.resolved_feed_monitor_id(monitor_id)
.map(|source_id| self.capture_size_choice(source_id))
}
pub fn capture_size_options(&self) -> Vec<CaptureSizeChoice> {
capture_size_options(self.preview_source)
}
@ -571,7 +693,7 @@ impl LauncherState {
pub fn status_line(&self) -> String {
format!(
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}",
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}",
self.server_available,
match self.routing {
InputRouting::Local => "local",
@ -591,6 +713,8 @@ impl LauncherState {
self.preview_source.height,
self.displays[0].label(),
self.displays[1].label(),
self.feed_source_preset(0).as_id(),
self.feed_source_preset(1).as_id(),
self.devices.camera.as_deref().unwrap_or("auto"),
self.devices.microphone.as_deref().unwrap_or("auto"),
self.devices.speaker.as_deref().unwrap_or("auto"),
@ -679,76 +803,20 @@ fn breakout_size_options(
}
fn capture_size_choice(
source: PreviewSourceSize,
_source: PreviewSourceSize,
preset: CaptureSizePreset,
selected_fps: u32,
selected_bitrate_kbit: u32,
) -> CaptureSizeChoice {
let source_width = source.width.max(1) as i32;
let source_height = source.height.max(1) as i32;
let source_fps = source.fps.max(1);
let (width, height, fps, max_bitrate_kbit) = match preset {
CaptureSizePreset::P360 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360);
(
width,
height,
source_fps.min(selected_fps.max(1)),
selected_bitrate_kbit.max(800),
)
}
CaptureSizePreset::P540 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 960, 540);
(
width,
height,
source_fps.min(selected_fps.max(1)),
selected_bitrate_kbit.max(800),
)
}
CaptureSizePreset::P720 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720);
(
width,
height,
source_fps.min(selected_fps.max(1)),
selected_bitrate_kbit.max(800),
)
}
CaptureSizePreset::P900 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900);
(
width,
height,
source_fps.min(selected_fps.max(1)),
selected_bitrate_kbit.max(800),
)
}
CaptureSizePreset::P1080 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080);
(
width,
height,
source_fps.min(selected_fps.max(1)),
selected_bitrate_kbit.max(800),
)
}
CaptureSizePreset::P1440 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440);
(
width,
height,
source_fps.min(selected_fps.max(1)),
selected_bitrate_kbit.max(800),
)
}
CaptureSizePreset::Source => (
source_width,
source_height,
source_fps,
estimate_source_bitrate_kbit(source_width, source_height, source_fps),
),
};
let preset = normalize_capture_size_preset(preset);
let mode = preset.source_mode();
let _ = (selected_fps, selected_bitrate_kbit);
let (width, height, fps, max_bitrate_kbit) = (
mode.width as i32,
mode.height as i32,
mode.fps,
estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps),
);
CaptureSizeChoice {
preset,
width,
@ -769,106 +837,46 @@ fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 {
}
fn capture_size_options(source: PreviewSourceSize) -> Vec<CaptureSizeChoice> {
let mut options = Vec::new();
for preset in [
CaptureSizePreset::Source,
CaptureSizePreset::P360,
CaptureSizePreset::P540,
CaptureSizePreset::P720,
CaptureSizePreset::P900,
CaptureSizePreset::P1080,
CaptureSizePreset::P1440,
] {
let defaults = default_profile_for_preset(source, preset);
let choice = capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit);
if options.iter().any(|existing: &CaptureSizeChoice| {
let existing_transport = existing.preset.transport_label();
let current_transport = choice.preset.transport_label();
existing_transport == current_transport
&& existing.width == choice.width
&& existing.height == choice.height
&& existing.fps == choice.fps
&& existing.max_bitrate_kbit == choice.max_bitrate_kbit
}) {
continue;
}
options.push(choice);
}
options
native_eye_source_modes()
.iter()
.copied()
.filter(|mode| mode.width <= source.width && mode.height <= source.height)
.map(CaptureSizePreset::from_source_mode)
.map(|preset| {
let defaults = default_profile_for_preset(source, preset);
capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit)
})
.collect()
}
fn capture_fps_options(source: PreviewSourceSize) -> Vec<CaptureFpsChoice> {
let mut values = BTreeSet::new();
for fps in [15, 20, 24, 30, 48, 60, source.fps.max(1)] {
if fps <= source.fps.max(1) || fps == source.fps.max(1) {
values.insert(fps.max(1));
}
}
values
.into_iter()
.map(|fps| CaptureFpsChoice { fps })
.collect()
vec![CaptureFpsChoice {
fps: source.fps.max(1),
}]
}
fn capture_bitrate_options(source: PreviewSourceSize) -> Vec<CaptureBitrateChoice> {
let mut values = BTreeSet::new();
for bitrate in [
2_500,
4_000,
6_000,
8_500,
12_000,
18_000,
24_000,
estimate_source_bitrate_kbit(source.width as i32, source.height as i32, source.fps),
] {
values.insert(bitrate.max(800));
}
values
.into_iter()
.map(|max_bitrate_kbit| CaptureBitrateChoice { max_bitrate_kbit })
.collect()
vec![CaptureBitrateChoice {
max_bitrate_kbit: estimate_source_bitrate_kbit(
source.width as i32,
source.height as i32,
source.fps,
),
}]
}
fn default_profile_for_preset(
source: PreviewSourceSize,
_source: PreviewSourceSize,
preset: CaptureSizePreset,
) -> CaptureSizeChoice {
let source_width = source.width.max(1) as i32;
let source_height = source.height.max(1) as i32;
let source_fps = source.fps.max(1);
let (width, height, fps, max_bitrate_kbit) = match preset {
CaptureSizePreset::P360 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360);
(width, height, source_fps.min(15), 2_500)
}
CaptureSizePreset::P540 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 960, 540);
(width, height, source_fps.min(20), 4_000)
}
CaptureSizePreset::P720 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720);
(width, height, source_fps.min(24), 6_000)
}
CaptureSizePreset::P900 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900);
(width, height, source_fps.min(30), 8_500)
}
CaptureSizePreset::P1080 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080);
(width, height, source_fps.min(30), 12_000)
}
CaptureSizePreset::P1440 => {
let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440);
(width, height, source_fps.min(30), 18_000)
}
CaptureSizePreset::Source => (
source_width,
source_height,
source_fps,
estimate_source_bitrate_kbit(source_width, source_height, source_fps),
),
};
let preset = normalize_capture_size_preset(preset);
let mode = preset.source_mode();
let (width, height, fps, max_bitrate_kbit) = (
mode.width as i32,
mode.height as i32,
mode.fps,
estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps),
);
CaptureSizeChoice {
preset,
width,
@ -878,6 +886,10 @@ fn default_profile_for_preset(
}
}
fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset {
preset
}
fn fit_standard_dimensions(
limit_width: i32,
limit_height: i32,
@ -973,6 +985,29 @@ mod tests {
assert_eq!(state.display_surface(1), DisplaySurface::Window);
}
#[test]
fn feed_sources_can_mirror_or_disable_a_pane() {
let mut state = LauncherState::new();
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
assert_eq!(state.resolved_feed_monitor_id(0), Some(0));
assert_eq!(state.resolved_feed_monitor_id(1), Some(1));
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
assert_eq!(state.resolved_feed_monitor_id(0), Some(1));
assert_eq!(
state.display_capture_size_choice(0),
Some(state.capture_size_choice(1))
);
state.set_feed_source_preset(1, FeedSourcePreset::OtherEye);
assert_eq!(state.resolved_feed_monitor_id(1), Some(0));
state.set_feed_source_preset(0, FeedSourcePreset::Off);
assert_eq!(state.resolved_feed_monitor_id(0), None);
assert!(state.display_capture_size_choice(0).is_none());
}
#[test]
fn selecting_auto_or_blank_clears_explicit_device() {
let mut state = LauncherState::new();
@ -1076,21 +1111,21 @@ mod tests {
#[test]
fn breakout_size_choices_track_the_negotiated_source_size() {
let mut state = LauncherState::new();
state.set_preview_source_profile(1920, 1080, 30);
state.set_preview_source_profile(1920, 1080, 60);
state.set_breakout_limit_size(2560, 1440);
let source = state.capture_size_choice(0);
assert_eq!(source.width, 1920);
assert_eq!(source.height, 1080);
assert_eq!(source.fps, 30);
assert_eq!(source.max_bitrate_kbit, 12_000);
assert_eq!(source.fps, 60);
assert_eq!(source.max_bitrate_kbit, 18_000);
state.set_capture_size_preset(0, CaptureSizePreset::P540);
state.set_capture_size_preset(0, CaptureSizePreset::P480);
let compact_capture = state.capture_size_choice(0);
assert_eq!(compact_capture.width, 960);
assert_eq!(compact_capture.height, 540);
assert_eq!(compact_capture.fps, 20);
assert_eq!(compact_capture.max_bitrate_kbit, 4_000);
assert_eq!(compact_capture.width, 720);
assert_eq!(compact_capture.height, 480);
assert_eq!(compact_capture.fps, 60);
assert_eq!(compact_capture.max_bitrate_kbit, 2_500);
let display = state.breakout_size_choice(0);
assert_eq!(display.width, 1920);
@ -1107,17 +1142,12 @@ mod tests {
assert_eq!(compact.height, 540);
let capture_options = state.capture_size_options();
assert!(capture_options.len() >= 5);
assert!(capture_options.iter().any(|choice| {
choice.preset == CaptureSizePreset::Source
&& choice.width == 1920
&& choice.height == 1080
}));
assert!(capture_options.iter().any(|choice| {
choice.preset == CaptureSizePreset::P1080
&& choice.width == 1920
&& choice.height == 1080
}));
assert_eq!(capture_options.len(), 5);
assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080);
assert_eq!(capture_options[0].width, 1920);
assert_eq!(capture_options[0].height, 1080);
assert_eq!(capture_options[0].fps, 60);
assert_eq!(capture_options[0].max_bitrate_kbit, 18_000);
let breakout_options = state.breakout_size_options();
assert!(breakout_options.len() >= 5);
@ -1181,61 +1211,73 @@ mod tests {
}
#[test]
fn source_capture_profile_uses_source_fps_and_scaled_profiles_cap_it() {
fn capture_size_presets_map_to_real_device_modes() {
let mut state = LauncherState::new();
state.set_preview_source_profile(1920, 1080, 60);
state.set_capture_size_preset(0, CaptureSizePreset::Source);
state.set_capture_size_preset(0, CaptureSizePreset::P1080);
let source = state.capture_size_choice(0);
assert_eq!(source.width, 1920);
assert_eq!(source.height, 1080);
assert_eq!(source.fps, 60);
assert!(source.max_bitrate_kbit >= 12_000);
assert!(source.max_bitrate_kbit >= 18_000);
state.set_capture_size_preset(0, CaptureSizePreset::P720);
let hd = state.capture_size_choice(0);
assert_eq!(hd.fps, 24);
assert_eq!(hd.preset, CaptureSizePreset::P720);
assert_eq!(hd.width, 1280);
assert_eq!(hd.height, 720);
assert_eq!(hd.fps, 60);
state.set_capture_size_preset(0, CaptureSizePreset::P540);
state.set_capture_size_preset(0, CaptureSizePreset::P576);
let compact = state.capture_size_choice(0);
assert_eq!(compact.fps, 20);
assert_eq!(compact.preset, CaptureSizePreset::P576);
assert_eq!(compact.width, 720);
assert_eq!(compact.height, 576);
assert_eq!(compact.fps, 50);
state.set_capture_size_preset(0, CaptureSizePreset::P360);
state.set_capture_size_preset(0, CaptureSizePreset::Vga);
let small = state.capture_size_choice(0);
assert_eq!(small.fps, 15);
assert_eq!(small.preset, CaptureSizePreset::Vga);
assert_eq!(small.width, 640);
assert_eq!(small.height, 480);
assert_eq!(small.fps, 60);
}
#[test]
fn split_capture_controls_apply_custom_fps_and_bitrate() {
fn source_capture_knobs_follow_the_selected_native_mode() {
let mut state = LauncherState::new();
state.set_preview_source_profile(1920, 1080, 30);
state.set_preview_source_profile(1920, 1080, 60);
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
let defaults = state.capture_size_choice(1);
assert_eq!(defaults.width, 1920);
assert_eq!(defaults.height, 1080);
assert_eq!(defaults.fps, 30);
assert_eq!(defaults.max_bitrate_kbit, 12_000);
assert_eq!(defaults.fps, 60);
assert_eq!(defaults.max_bitrate_kbit, 18_000);
state.set_capture_fps(1, 24);
state.set_capture_bitrate_kbit(1, 8_500);
let tuned = state.capture_size_choice(1);
assert_eq!(tuned.preset, CaptureSizePreset::P1080);
assert_eq!(tuned.width, 1920);
assert_eq!(tuned.height, 1080);
assert_eq!(tuned.fps, 24);
assert_eq!(tuned.max_bitrate_kbit, 8_500);
assert_eq!(tuned.fps, 60);
assert_eq!(tuned.max_bitrate_kbit, 18_000);
}
#[test]
fn source_capture_ignores_manual_fps_and_bitrate_knobs() {
let mut state = LauncherState::new();
state.set_preview_source_profile(1920, 1080, 25);
state.set_capture_size_preset(0, CaptureSizePreset::Source);
state.set_preview_source_profile(1920, 1080, 60);
state.set_capture_size_preset(0, CaptureSizePreset::P720);
state.set_capture_fps(0, 60);
state.set_capture_bitrate_kbit(0, 24_000);
let source = state.capture_size_choice(0);
assert_eq!(source.preset, CaptureSizePreset::Source);
assert_eq!(source.fps, 25);
assert_eq!(source.preset, CaptureSizePreset::P720);
assert_eq!(source.width, 1280);
assert_eq!(source.height, 720);
assert_eq!(source.fps, 60);
assert_eq!(source.max_bitrate_kbit, 12_000);
}
}

View File

@ -10,8 +10,8 @@ use {
super::launcher_focus_signal_path,
super::power::{fetch_capture_power, set_capture_power_mode},
super::state::{
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, InputRouting,
LauncherState,
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
FeedSourcePreset, InputRouting, LauncherState,
},
super::ui_components::build_launcher_view,
super::ui_runtime::{
@ -207,25 +207,43 @@ fn refresh_eye_feed_controls(
state: &LauncherState,
) {
for monitor_id in 0..2 {
super::ui_components::sync_capture_resolution_combo(
&widgets.display_panes[monitor_id].capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(monitor_id),
);
super::ui_components::sync_capture_fps_combo(
&widgets.display_panes[monitor_id].capture_fps_combo,
state.capture_fps_options(),
state.capture_fps(monitor_id),
state.capture_size_preset(monitor_id) == CaptureSizePreset::Source,
state.capture_size_choice(monitor_id).fps,
);
super::ui_components::sync_capture_bitrate_combo(
&widgets.display_panes[monitor_id].capture_bitrate_combo,
state.capture_bitrate_options(),
state.capture_bitrate_kbit(monitor_id),
state.capture_size_preset(monitor_id) == CaptureSizePreset::Source,
state.capture_size_choice(monitor_id).max_bitrate_kbit,
super::ui_components::sync_feed_source_combo(
&widgets.display_panes[monitor_id].feed_source_combo,
state.feed_source_options(monitor_id),
state.feed_source_preset(monitor_id),
);
if state.feed_source_preset(monitor_id) != FeedSourcePreset::Off {
let choice = state.capture_size_choice(monitor_id);
super::ui_components::sync_capture_resolution_combo(
&widgets.display_panes[monitor_id].capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(monitor_id),
);
super::ui_components::sync_capture_fps_combo(
&widgets.display_panes[monitor_id].capture_fps_combo,
state.capture_fps_options(),
state.capture_fps(monitor_id),
true,
choice.fps,
);
super::ui_components::sync_capture_bitrate_combo(
&widgets.display_panes[monitor_id].capture_bitrate_combo,
state.capture_bitrate_options(),
state.capture_bitrate_kbit(monitor_id),
true,
choice.max_bitrate_kbit,
);
} else {
super::ui_components::sync_capture_resolution_disabled(
&widgets.display_panes[monitor_id].capture_resolution_combo,
);
super::ui_components::sync_capture_fps_disabled(
&widgets.display_panes[monitor_id].capture_fps_combo,
);
super::ui_components::sync_capture_bitrate_disabled(
&widgets.display_panes[monitor_id].capture_bitrate_combo,
);
}
super::ui_components::sync_breakout_size_combo(
&widgets.display_panes[monitor_id].breakout_combo,
state.breakout_size_options(),
@ -254,25 +272,31 @@ fn record_diagnostics_sample(
) {
let left_metrics = preview
.and_then(|preview| {
preview.snapshot_metrics(
0,
match state.display_surface(0) {
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
DisplaySurface::Window => super::preview::PreviewSurface::Window,
},
(state.feed_source_preset(0) != FeedSourcePreset::Off).then_some(
preview.snapshot_metrics(
0,
match state.display_surface(0) {
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
DisplaySurface::Window => super::preview::PreviewSurface::Window,
},
),
)
})
.flatten()
.unwrap_or_default();
let right_metrics = preview
.and_then(|preview| {
preview.snapshot_metrics(
1,
match state.display_surface(1) {
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
DisplaySurface::Window => super::preview::PreviewSurface::Window,
},
(state.feed_source_preset(1) != FeedSourcePreset::Off).then_some(
preview.snapshot_metrics(
1,
match state.display_surface(1) {
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
DisplaySurface::Window => super::preview::PreviewSurface::Window,
},
),
)
})
.flatten()
.unwrap_or_default();
widgets
@ -305,6 +329,7 @@ fn record_diagnostics_sample(
left_decoder_label: left_metrics.decoder_label.clone(),
left_stream_caps_label: left_metrics.stream_caps_label.clone(),
left_decoded_caps_label: left_metrics.decoded_caps_label.clone(),
left_rendered_caps_label: left_metrics.rendered_caps_label.clone(),
right_receive_fps: right_metrics.receive_fps,
right_present_fps: right_metrics.present_fps,
right_server_fps: right_metrics.server_fps,
@ -320,6 +345,7 @@ fn record_diagnostics_sample(
right_decoder_label: right_metrics.decoder_label.clone(),
right_stream_caps_label: right_metrics.stream_caps_label.clone(),
right_decoded_caps_label: right_metrics.decoded_caps_label.clone(),
right_rendered_caps_label: right_metrics.rendered_caps_label.clone(),
dropped_frames: left_metrics
.dropped_frames
.saturating_add(right_metrics.dropped_frames),
@ -419,6 +445,7 @@ fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) {
fn rebind_inline_preview(
preview: &super::preview::LauncherPreview,
widgets: &super::ui_components::LauncherWidgets,
state: &LauncherState,
monitor_id: usize,
) {
if let Some(binding) = widgets.display_panes[monitor_id]
@ -428,6 +455,15 @@ fn rebind_inline_preview(
{
binding.close();
}
if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off {
widgets.display_panes[monitor_id]
.picture
.set_paintable(Option::<&gtk::gdk::Paintable>::None);
widgets.display_panes[monitor_id]
.stream_status
.set_text("Feed disabled.");
return;
}
let binding = preview.install_on_picture(
monitor_id,
super::preview::PreviewSurface::Inline,
@ -443,6 +479,7 @@ fn rebind_inline_preview(
fn rebind_popout_preview(
preview: &super::preview::LauncherPreview,
popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>,
state: &LauncherState,
monitor_id: usize,
) {
let mut popouts = popouts.borrow_mut();
@ -450,6 +487,13 @@ fn rebind_popout_preview(
return;
};
handle.binding.close();
if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off {
handle
.picture
.set_paintable(Option::<&gtk::gdk::Paintable>::None);
handle.status_label.set_text("Feed disabled.");
return;
}
if let Some(binding) = preview.install_on_picture(
monitor_id,
super::preview::PreviewSurface::Window,
@ -463,15 +507,20 @@ fn rebind_popout_preview(
#[cfg(not(coverage))]
fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) {
for monitor_id in 0..2 {
let enabled = state.feed_source_preset(monitor_id) != FeedSourcePreset::Off;
preview.set_monitor_enabled(monitor_id, enabled);
let capture = state.capture_size_choice(monitor_id);
let source_monitor_id = state
.resolved_feed_monitor_id(monitor_id)
.unwrap_or(monitor_id);
let breakout = state.breakout_size_choice(monitor_id);
preview.set_capture_profile(
monitor_id,
source_monitor_id,
capture.width,
capture.height,
capture.fps,
capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
);
preview.set_breakout_profile(monitor_id, breakout.width, breakout.height);
}
@ -486,8 +535,8 @@ fn sync_preview_profiles(
) {
apply_preview_profiles(preview, state);
for monitor_id in 0..2 {
rebind_inline_preview(preview, widgets, monitor_id);
rebind_popout_preview(preview, popouts, monitor_id);
rebind_inline_preview(preview, widgets, state, monitor_id);
rebind_popout_preview(preview, popouts, state, monitor_id);
}
}
@ -796,6 +845,34 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
for monitor_id in 0..2 {
let state = Rc::clone(&state);
let widgets = widgets.clone();
let popouts = Rc::clone(&popouts);
let child_proc = Rc::clone(&child_proc);
let preview = preview.clone();
let feed_source_combo = widgets.display_panes[monitor_id].feed_source_combo.clone();
feed_source_combo.connect_changed(move |combo| {
let Some(active_id) = combo.active_id() else {
return;
};
let Some(preset) = FeedSourcePreset::from_id(active_id.as_str()) else {
return;
};
if state.borrow().feed_source_preset(monitor_id) == preset {
return;
}
{
let mut state = state.borrow_mut();
state.set_feed_source_preset(monitor_id, preset);
}
if let Some(preview) = preview.as_ref() {
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
});
}
for monitor_id in 0..2 {
let state = Rc::clone(&state);
let widgets = widgets.clone();
@ -811,6 +888,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else {
return;
};
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
return;
}
if state.borrow().capture_size_preset(monitor_id) == preset {
return;
}
@ -820,16 +900,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
if let Some(preview) = preview.as_ref() {
let choice = state.borrow().capture_size_choice(monitor_id);
let source_monitor_id = state
.borrow()
.resolved_feed_monitor_id(monitor_id)
.unwrap_or(monitor_id);
preview.set_capture_profile(
monitor_id,
source_monitor_id,
choice.width,
choice.height,
choice.fps,
choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
);
rebind_inline_preview(preview, &widgets, monitor_id);
rebind_popout_preview(preview, &popouts, monitor_id);
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
});
@ -852,6 +935,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let Ok(fps) = active_id.as_str().parse::<u32>() else {
return;
};
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
return;
}
if state.borrow().capture_fps(monitor_id) == fps {
return;
}
@ -861,16 +947,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
if let Some(preview) = preview.as_ref() {
let choice = state.borrow().capture_size_choice(monitor_id);
let source_monitor_id = state
.borrow()
.resolved_feed_monitor_id(monitor_id)
.unwrap_or(monitor_id);
preview.set_capture_profile(
monitor_id,
source_monitor_id,
choice.width,
choice.height,
choice.fps,
choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
);
rebind_inline_preview(preview, &widgets, monitor_id);
rebind_popout_preview(preview, &popouts, monitor_id);
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
});
@ -894,6 +983,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let Ok(max_bitrate_kbit) = active_id.as_str().parse::<u32>() else {
return;
};
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
return;
}
if state.borrow().capture_bitrate_kbit(monitor_id) == max_bitrate_kbit {
return;
}
@ -903,16 +995,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
if let Some(preview) = preview.as_ref() {
let choice = state.borrow().capture_size_choice(monitor_id);
let source_monitor_id = state
.borrow()
.resolved_feed_monitor_id(monitor_id)
.unwrap_or(monitor_id);
preview.set_capture_profile(
monitor_id,
source_monitor_id,
choice.width,
choice.height,
choice.fps,
choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
);
rebind_inline_preview(preview, &widgets, monitor_id);
rebind_popout_preview(preview, &popouts, monitor_id);
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
});
@ -952,7 +1047,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
};
if popout_open {
if let Some(preview) = preview.as_ref() {
rebind_popout_preview(preview, &popouts, monitor_id);
rebind_popout_preview(preview, &popouts, &state.borrow(), monitor_id);
}
if let Some(handle) = popouts
.borrow()
@ -1833,7 +1928,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
if let (Some(width), Some(height)) =
(caps.eye_width, caps.eye_height)
{
let fps = caps.eye_fps.unwrap_or(30);
let fps = caps
.eye_fps
.unwrap_or(crate::launcher::state::PreviewSourceSize::default().fps);
{
let mut state = state.borrow_mut();
state.set_preview_source_profile(width, height, fps);
@ -1911,7 +2008,7 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
mod tests {
use super::apply_preview_profiles;
use crate::launcher::preview::{LauncherPreview, PreviewSurface};
use crate::launcher::state::{CaptureSizePreset, LauncherState};
use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
#[test]
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {
@ -1919,25 +2016,27 @@ mod tests {
let state = LauncherState::default();
let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
assert_eq!(bootstrap.2, 960);
assert_eq!(bootstrap.3, 540);
assert_eq!(bootstrap.4, 24);
assert_eq!(bootstrap.0, 0);
assert_eq!(bootstrap.3, 1920);
assert_eq!(bootstrap.4, 1080);
assert_eq!(bootstrap.5, 60);
assert_eq!(bootstrap.6, 18_000);
apply_preview_profiles(&preview, &state);
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
assert_eq!(inline.2, 1920);
assert_eq!(inline.3, 1080);
assert_eq!(inline.4, 24);
assert_eq!(inline.5, 4_000);
assert!(inline.6);
assert_eq!(inline.0, 1);
assert_eq!(inline.3, 1920);
assert_eq!(inline.4, 1080);
assert_eq!(inline.5, 60);
assert_eq!(inline.6, 18_000);
let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap();
assert_eq!(window.2, 1920);
assert_eq!(window.3, 1080);
assert_eq!(window.4, 30);
assert_eq!(window.5, 12_000);
assert!(window.6);
assert_eq!(window.0, 1);
assert_eq!(window.3, 1920);
assert_eq!(window.4, 1080);
assert_eq!(window.5, 60);
assert_eq!(window.6, 18_000);
preview.shutdown_all();
}
@ -1946,16 +2045,56 @@ mod tests {
fn source_preview_profile_stays_honest_after_apply() {
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
let mut state = LauncherState::default();
state.set_capture_size_preset(1, CaptureSizePreset::Source);
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
apply_preview_profiles(&preview, &state);
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
assert_eq!(inline.2, 1920);
assert_eq!(inline.3, 1080);
assert_eq!(inline.4, 30);
assert_eq!(inline.5, 12_000);
assert!(!inline.6);
assert_eq!(inline.0, 1);
assert_eq!(inline.3, 1920);
assert_eq!(inline.4, 1080);
assert_eq!(inline.5, 60);
assert_eq!(inline.6, 18_000);
preview.shutdown_all();
}
#[test]
fn mirrored_preview_profile_keeps_its_own_feed_id_but_uses_the_other_source() {
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
let mut state = LauncherState::default();
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
apply_preview_profiles(&preview, &state);
let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap();
let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap();
assert_eq!(inline.0, 1);
assert_eq!(window.0, 1);
preview.shutdown_all();
}
#[test]
fn off_preview_profile_disables_both_surfaces_instead_of_leaving_idle_feeds_running() {
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
let mut state = LauncherState::default();
state.set_feed_source_preset(0, FeedSourcePreset::Off);
apply_preview_profiles(&preview, &state);
assert_eq!(
preview.feed_disabled_for_test(0, PreviewSurface::Inline),
Some(true)
);
assert_eq!(
preview.feed_disabled_for_test(0, PreviewSurface::Window),
Some(true)
);
assert_eq!(
preview.feed_disabled_for_test(1, PreviewSurface::Inline),
Some(false)
);
preview.shutdown_all();
}

View File

@ -9,7 +9,7 @@ use super::{
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
state::{
BreakoutSizeChoice, BreakoutSizePreset, CaptureBitrateChoice, CaptureFpsChoice,
CaptureSizeChoice, CaptureSizePreset, LauncherState,
CaptureSizeChoice, CaptureSizePreset, FeedSourceChoice, FeedSourcePreset, LauncherState,
},
};
@ -31,6 +31,7 @@ pub struct DisplayPaneWidgets {
pub picture: gtk::Picture,
pub stream_status: gtk::Label,
pub placeholder: gtk::Label,
pub feed_source_combo: gtk::ComboBoxText,
pub capture_resolution_combo: gtk::ComboBoxText,
pub capture_fps_combo: gtk::ComboBoxText,
pub capture_bitrate_combo: gtk::ComboBoxText,
@ -529,60 +530,94 @@ pub fn build_launcher_view(
let left_pane = left_pane;
let right_pane = right_pane;
if let Some(preview) = preview.as_ref() {
*left_pane.preview_binding.borrow_mut() = preview.install_on_picture(
0,
PreviewSurface::Inline,
&left_pane.picture,
&left_pane.stream_status,
);
*right_pane.preview_binding.borrow_mut() = preview.install_on_picture(
1,
PreviewSurface::Inline,
&right_pane.picture,
&right_pane.stream_status,
);
*left_pane.preview_binding.borrow_mut() =
if state.feed_source_preset(0) == FeedSourcePreset::Off {
None
} else {
preview.install_on_picture(
0,
PreviewSurface::Inline,
&left_pane.picture,
&left_pane.stream_status,
)
};
*right_pane.preview_binding.borrow_mut() =
if state.feed_source_preset(1) == FeedSourcePreset::Off {
None
} else {
preview.install_on_picture(
1,
PreviewSurface::Inline,
&right_pane.picture,
&right_pane.stream_status,
)
};
} else {
left_pane.stream_status.set_text("Preview unavailable");
right_pane.stream_status.set_text("Preview unavailable");
}
sync_capture_resolution_combo(
&left_pane.capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(0),
sync_feed_source_combo(
&left_pane.feed_source_combo,
state.feed_source_options(0),
state.feed_source_preset(0),
);
sync_capture_fps_combo(
&left_pane.capture_fps_combo,
state.capture_fps_options(),
state.capture_fps(0),
state.capture_size_preset(0) == CaptureSizePreset::Source,
state.capture_size_choice(0).fps,
);
sync_capture_bitrate_combo(
&left_pane.capture_bitrate_combo,
state.capture_bitrate_options(),
state.capture_bitrate_kbit(0),
state.capture_size_preset(0) == CaptureSizePreset::Source,
state.capture_size_choice(0).max_bitrate_kbit,
);
sync_capture_resolution_combo(
&right_pane.capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(1),
);
sync_capture_fps_combo(
&right_pane.capture_fps_combo,
state.capture_fps_options(),
state.capture_fps(1),
state.capture_size_preset(1) == CaptureSizePreset::Source,
state.capture_size_choice(1).fps,
);
sync_capture_bitrate_combo(
&right_pane.capture_bitrate_combo,
state.capture_bitrate_options(),
state.capture_bitrate_kbit(1),
state.capture_size_preset(1) == CaptureSizePreset::Source,
state.capture_size_choice(1).max_bitrate_kbit,
sync_feed_source_combo(
&right_pane.feed_source_combo,
state.feed_source_options(1),
state.feed_source_preset(1),
);
if state.feed_source_preset(0) != FeedSourcePreset::Off {
let choice = state.capture_size_choice(0);
sync_capture_resolution_combo(
&left_pane.capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(0),
);
sync_capture_fps_combo(
&left_pane.capture_fps_combo,
state.capture_fps_options(),
state.capture_fps(0),
true,
choice.fps,
);
sync_capture_bitrate_combo(
&left_pane.capture_bitrate_combo,
state.capture_bitrate_options(),
state.capture_bitrate_kbit(0),
true,
choice.max_bitrate_kbit,
);
} else {
sync_capture_resolution_disabled(&left_pane.capture_resolution_combo);
sync_capture_fps_disabled(&left_pane.capture_fps_combo);
sync_capture_bitrate_disabled(&left_pane.capture_bitrate_combo);
}
if state.feed_source_preset(1) != FeedSourcePreset::Off {
let choice = state.capture_size_choice(1);
sync_capture_resolution_combo(
&right_pane.capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(1),
);
sync_capture_fps_combo(
&right_pane.capture_fps_combo,
state.capture_fps_options(),
state.capture_fps(1),
true,
choice.fps,
);
sync_capture_bitrate_combo(
&right_pane.capture_bitrate_combo,
state.capture_bitrate_options(),
state.capture_bitrate_kbit(1),
true,
choice.max_bitrate_kbit,
);
} else {
sync_capture_resolution_disabled(&right_pane.capture_resolution_combo);
sync_capture_fps_disabled(&right_pane.capture_fps_combo);
sync_capture_bitrate_disabled(&right_pane.capture_bitrate_combo);
}
sync_breakout_size_combo(
&left_pane.breakout_combo,
state.breakout_size_options(),
@ -853,30 +888,45 @@ fn stabilize_chip(chip: &gtk::Box, width: i32) {
chip.set_size_request(width, -1);
}
pub fn sync_feed_source_combo(
combo: &gtk::ComboBoxText,
options: Vec<FeedSourceChoice>,
selected: FeedSourcePreset,
) {
combo.remove_all();
for option in options {
combo.append(Some(option.preset.as_id()), option.label);
}
combo.set_active_id(Some(selected.as_id()));
combo.set_sensitive(true);
}
pub fn sync_capture_resolution_combo(
combo: &gtk::ComboBoxText,
options: Vec<CaptureSizeChoice>,
selected: CaptureSizePreset,
) {
combo.remove_all();
let option_count = options.len();
for option in options {
let label = match option.preset {
CaptureSizePreset::Source => format!(
"{} • {}x{} (Pass-through)",
option.preset.label(),
option.width,
option.height,
),
_ => format!(
"{} • {}x{} (Re-encode)",
option.preset.label(),
option.width,
option.height,
),
};
let label = format!(
"{} • {}x{} @ {} fps (Device H.264)",
option.preset.label(),
option.width,
option.height,
option.fps,
);
combo.append(Some(option.preset.as_id()), &label);
}
combo.set_active_id(Some(selected.as_id()));
combo.set_sensitive(option_count > 1);
}
pub fn sync_capture_resolution_disabled(combo: &gtk::ComboBoxText) {
combo.remove_all();
combo.append(Some("off"), "Feed disabled");
combo.set_active_id(Some("off"));
combo.set_sensitive(false);
}
pub fn sync_capture_fps_combo(
@ -888,7 +938,7 @@ pub fn sync_capture_fps_combo(
) {
combo.remove_all();
if locked_to_source {
combo.append(Some("source"), &format!("{source_fps} fps (Source)"));
combo.append(Some("source"), &format!("{source_fps} fps (Device mode)"));
combo.set_active_id(Some("source"));
combo.set_sensitive(false);
return;
@ -903,6 +953,13 @@ pub fn sync_capture_fps_combo(
combo.set_sensitive(true);
}
pub fn sync_capture_fps_disabled(combo: &gtk::ComboBoxText) {
combo.remove_all();
combo.append(Some("off"), "Feed disabled");
combo.set_active_id(Some("off"));
combo.set_sensitive(false);
}
pub fn sync_capture_bitrate_combo(
combo: &gtk::ComboBoxText,
options: Vec<CaptureBitrateChoice>,
@ -914,7 +971,7 @@ pub fn sync_capture_bitrate_combo(
if locked_to_source {
combo.append(
Some("source"),
&format!("{source_bitrate_kbit} kbit (Source estimate)"),
&format!("~{source_bitrate_kbit} kbit (Estimated, device-managed)"),
);
combo.set_active_id(Some("source"));
combo.set_sensitive(false);
@ -930,6 +987,13 @@ pub fn sync_capture_bitrate_combo(
combo.set_sensitive(true);
}
pub fn sync_capture_bitrate_disabled(combo: &gtk::ComboBoxText) {
combo.remove_all();
combo.append(Some("off"), "Feed disabled");
combo.set_active_id(Some("off"));
combo.set_sensitive(false);
}
pub fn sync_breakout_size_combo(
combo: &gtk::ComboBoxText,
options: Vec<BreakoutSizeChoice>,
@ -1105,19 +1169,24 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stream_status.set_single_line_mode(true);
stream_status.set_max_width_chars(24);
stream_status.set_tooltip_text(Some("Connect relay to preview."));
let feed_source_combo = gtk::ComboBoxText::new();
feed_source_combo.set_tooltip_text(Some(
"Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.",
));
feed_source_combo.set_size_request(150, -1);
let capture_resolution_combo = gtk::ComboBoxText::new();
capture_resolution_combo.set_tooltip_text(Some(
"Choose the server-side capture resolution for this eye feed. Source keeps the HDMI device's own H.264 stream; the standard sizes force a server re-encode.",
"Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.",
));
capture_resolution_combo.set_size_request(240, -1);
let capture_fps_combo = gtk::ComboBoxText::new();
capture_fps_combo.set_tooltip_text(Some(
"Choose the target server-side fps for re-encoded eye feeds. Source pass-through uses the HDMI device's own cadence.",
"Source pass-through uses the HDMI device's own cadence. This control will wake back up if we add a proven source-side fps option later.",
));
capture_fps_combo.set_size_request(120, -1);
let capture_bitrate_combo = gtk::ComboBoxText::new();
capture_bitrate_combo.set_tooltip_text(Some(
"Choose the target server-side bitrate for re-encoded eye feeds. Source pass-through uses an estimated hardware bitrate tier.",
"Source pass-through uses the eye device's own H.264 bitrate behavior. This control stays disabled until the hardware exposes a real source-side bitrate mode we can trust.",
));
capture_bitrate_combo.set_size_request(170, -1);
let breakout_combo = gtk::ComboBoxText::new();
@ -1129,6 +1198,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stabilize_button(&action_button, 104);
action_button.set_halign(gtk::Align::End);
let capture_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
capture_row.append(&feed_source_combo);
capture_row.append(&capture_resolution_combo);
capture_row.append(&capture_fps_combo);
capture_row.append(&capture_bitrate_combo);
@ -1146,6 +1216,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
picture,
stream_status,
placeholder,
feed_source_combo,
capture_resolution_combo,
capture_fps_combo,
capture_bitrate_combo,

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.11.11"
version = "0.11.12"
edition = "2024"
build = "build.rs"

View File

@ -11,7 +11,7 @@ message MonitorRequest {
uint32 requested_width = 3;
uint32 requested_height = 4;
uint32 requested_fps = 5;
bool prefer_reencode = 6;
optional uint32 source_id = 6;
}
message VideoPacket {
uint32 id = 1;

View File

@ -17,6 +17,6 @@ mod tests {
#[test]
fn banner_includes_version() {
assert_eq!(banner("0.11.11"), "lesavka-common CLI (v0.11.11)");
assert_eq!(banner("0.11.12"), "lesavka-common CLI (v0.11.12)");
}
}

99
common/src/eye_source.rs Normal file
View File

@ -0,0 +1,99 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EyeSourceMode {
pub width: u32,
pub height: u32,
pub fps: u32,
}
const GC311_H264_SOURCE_MODES: [EyeSourceMode; 5] = [
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
},
EyeSourceMode {
width: 1280,
height: 720,
fps: 60,
},
EyeSourceMode {
width: 720,
height: 576,
fps: 50,
},
EyeSourceMode {
width: 720,
height: 480,
fps: 60,
},
EyeSourceMode {
width: 640,
height: 480,
fps: 60,
},
];
pub fn native_eye_source_modes() -> &'static [EyeSourceMode] {
&GC311_H264_SOURCE_MODES
}
pub fn default_eye_source_mode() -> EyeSourceMode {
GC311_H264_SOURCE_MODES[0]
}
pub fn eye_source_mode_for_request(requested_width: u32, requested_height: u32) -> EyeSourceMode {
if requested_width == 0 || requested_height == 0 {
return default_eye_source_mode();
}
native_eye_source_modes()
.iter()
.copied()
.find(|mode| mode.width <= requested_width && mode.height <= requested_height)
.unwrap_or_else(|| *native_eye_source_modes().last().expect("source mode list"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_to_1080p60() {
assert_eq!(
default_eye_source_mode(),
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
}
);
}
#[test]
fn picks_the_highest_supported_mode_that_fits_the_request() {
assert_eq!(
eye_source_mode_for_request(1920, 1080),
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
}
);
assert_eq!(
eye_source_mode_for_request(1600, 900),
EyeSourceMode {
width: 1280,
height: 720,
fps: 60,
}
);
assert_eq!(
eye_source_mode_for_request(960, 540),
EyeSourceMode {
width: 720,
height: 480,
fps: 60,
}
);
}
}

View File

@ -3,6 +3,7 @@
// common/src/lib.rs
pub mod cli;
pub mod eye_source;
pub mod hid;
pub mod paste;
pub mod process_metrics;

View File

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
DEVICE="${1:-/dev/lesavka_r_eye}"
OUTDIR="${2:-/tmp/lesavka-eye-capabilities}"
WAIT_SECONDS="${LESAVKA_EYE_CAP_WAIT_SECONDS:-3600}"
POLL_SECONDS="${LESAVKA_EYE_CAP_POLL_SECONDS:-1}"
mkdir -p "$OUTDIR"
deadline=$((SECONDS + WAIT_SECONDS))
while [[ ! -e "$DEVICE" ]]; do
if (( SECONDS >= deadline )); then
echo "eye capability probe timed out waiting for $DEVICE" >&2
exit 124
fi
sleep "$POLL_SECONDS"
done
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
device_name="$(basename "$DEVICE")"
resolved_device="$(readlink -f "$DEVICE" 2>/dev/null || printf "%s" "$DEVICE")"
prefix="$OUTDIR/${timestamp}-${device_name}"
sysfs_base="/sys/class/video4linux/$(basename "$resolved_device" 2>/dev/null || basename "$DEVICE")"
echo "capturing eye capabilities for $DEVICE (${resolved_device}) into ${prefix}-*.txt"
run_with_optional_sudo() {
if "$@"; then
return 0
fi
local status=$?
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
echo
echo "# direct access failed with status ${status}; retrying under sudo -n"
sudo -n "$@"
return $?
fi
echo
echo "# command exited with status ${status}"
if [[ -e "$resolved_device" && ! -r "$resolved_device" ]]; then
echo "# ${resolved_device} is not readable by user $(id -un)"
echo "# groups: $(id -Gn)"
fi
echo "# passwordless sudo is unavailable; root or video-group access is required for full V4L2 inspection"
return $status
}
run_probe() {
local label="$1"
shift
local outfile="${prefix}-${label}.txt"
{
printf "# %s\n" "$label"
printf "# device: %s\n" "$DEVICE"
printf "# resolved_device: %s\n" "$resolved_device"
printf "# timestamp_utc: %s\n\n" "$timestamp"
"$@"
} >"$outfile" 2>&1 || true
}
run_probe path-info bash -lc "printf 'device=%s\nresolved=%s\n' '$DEVICE' '$resolved_device'; printf 'ls -l: '; ls -l '$resolved_device' 2>/dev/null || true; printf 'user=%s\ngroups=%s\n' \"\$(id -un)\" \"\$(id -Gn)\""
run_probe sysfs-summary bash -lc "printf 'sysfs_base=%s\n' '$sysfs_base'; if [[ -d '$sysfs_base' ]]; then for path in name device/modalias device/uevent; do file='$sysfs_base/'\"\$path\"; if [[ -f \"\$file\" ]]; then printf -- '\n## %s\n' \"\$path\"; sed -n '1,200p' \"\$file\"; fi; done; else echo 'missing sysfs path'; fi"
run_probe v4l2-device run_with_optional_sudo v4l2-ctl --device "$DEVICE" --info
run_probe v4l2-all run_with_optional_sudo v4l2-ctl --device "$DEVICE" --all
run_probe v4l2-formats run_with_optional_sudo v4l2-ctl --device "$DEVICE" --list-formats-ext
run_probe v4l2-ctrls run_with_optional_sudo v4l2-ctl --device "$DEVICE" --list-ctrls-menus
run_probe udev udevadm info --query=all --name "$DEVICE"
echo "eye capability artifacts written under $OUTDIR"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.11.11"
version = "0.11.12"
edition = "2024"
autobins = false

View File

@ -98,10 +98,11 @@ impl Handler {
req: MonitorRequest,
) -> Result<Response<VideoStream>, Status> {
let id = req.id;
let dev = match id {
let source_id = req.source_id.unwrap_or(id);
let dev = match source_id {
0 => "/dev/lesavka_l_eye",
1 => "/dev/lesavka_r_eye",
_ => return Err(Status::invalid_argument("monitor id must be 0 or 1")),
_ => return Err(Status::invalid_argument("source id must be 0 or 1")),
};
#[cfg(not(coverage))]
@ -110,11 +111,11 @@ impl Handler {
info!(
rpc_id,
id,
source_id,
max_bitrate = req.max_bitrate,
requested_width = req.requested_width,
requested_height = req.requested_height,
requested_fps = req.requested_fps,
prefer_reencode = req.prefer_reencode,
"🎥 capture_video opened"
);
debug!(rpc_id, "🎥 streaming {dev}");
@ -128,7 +129,6 @@ impl Handler {
req.requested_width,
req.requested_height,
req.requested_fps,
req.prefer_reencode,
)
.await
.map_err(|e| Status::internal(format!("{e:#}")))?;

View File

@ -6,6 +6,7 @@ use gst::MessageView::*;
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::eye_source::{default_eye_source_mode, eye_source_mode_for_request};
use lesavka_common::lesavka::VideoPacket;
use lesavka_common::process_metrics::ProcessCpuSampler;
use std::os::unix::fs::FileTypeExt;
@ -193,10 +194,8 @@ fn eye_device_wait_poll() -> Duration {
}
pub fn eye_source_profile() -> (u32, u32, u32) {
let width = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_WIDTH", 1920).max(320));
let height = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_HEIGHT", 1080).max(180));
let fps = env_u32("LESAVKA_EYE_SOURCE_FPS", 30).max(1);
(width, height, fps)
let mode = default_eye_source_mode();
(mode.width, mode.height, mode.fps)
}
fn round_down_even_u32(value: u32) -> u32 {
@ -227,53 +226,24 @@ fn reset_stream_telemetry_window(
#[derive(Clone, Copy, Debug)]
struct EyeCaptureRequest {
source_width: u32,
source_height: u32,
requested_width: u32,
requested_height: u32,
requested_fps: u32,
width: u32,
height: u32,
fps: u32,
max_bitrate_kbit: u32,
reencode: bool,
}
fn normalize_eye_capture_request(
requested_width: u32,
requested_height: u32,
requested_fps: u32,
_requested_fps: u32,
max_bitrate_kbit: u32,
prefer_reencode: bool,
) -> EyeCaptureRequest {
let (source_width, source_height, source_fps) = eye_source_profile();
let requested_width = if requested_width == 0 {
source_width
} else {
round_down_even_u32(requested_width.min(source_width).max(320))
};
let requested_height = if requested_height == 0 {
source_height
} else {
round_down_even_u32(requested_height.min(source_height).max(180))
};
let requested_fps = if requested_fps == 0 {
source_fps.max(1)
} else {
requested_fps.max(1).min(source_fps.max(1))
};
let max_bitrate_kbit = max_bitrate_kbit.max(800);
let downscale = requested_width < source_width || requested_height < source_height;
let baseline_source_bitrate_kbit = 12_000;
let reencode = prefer_reencode
|| downscale
|| requested_fps != source_fps.max(1)
|| max_bitrate_kbit != baseline_source_bitrate_kbit;
let source_mode = eye_source_mode_for_request(requested_width, requested_height);
EyeCaptureRequest {
source_width,
source_height,
requested_width,
requested_height,
requested_fps,
width: round_down_even_u32(source_mode.width.max(320)),
height: round_down_even_u32(source_mode.height.max(180)),
fps: source_mode.fps.max(1),
max_bitrate_kbit,
reencode,
}
}
@ -337,7 +307,7 @@ async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> {
/// frames before they build up in gRPC queues and destabilize downstream playback.
#[cfg(coverage)]
pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0, false).await
eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0).await
}
#[cfg(coverage)]
@ -348,7 +318,6 @@ pub async fn eye_ball_with_request(
_requested_width: u32,
_requested_height: u32,
_requested_fps: u32,
_prefer_reencode: bool,
) -> anyhow::Result<VideoStream> {
let _ = EYE_ID[id as usize];
if dev.contains('"') {
@ -384,7 +353,7 @@ pub async fn eye_ball_with_request(
#[cfg(not(coverage))]
pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0, false).await
eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0).await
}
#[cfg(not(coverage))]
@ -395,7 +364,6 @@ pub async fn eye_ball_with_request(
requested_width: u32,
requested_height: u32,
requested_fps: u32,
prefer_reencode: bool,
) -> anyhow::Result<VideoStream> {
let eye = EYE_ID[id as usize];
gst::init().context("gst init")?;
@ -405,10 +373,9 @@ pub async fn eye_ball_with_request(
requested_height,
requested_fps,
max_bitrate_kbit,
prefer_reencode,
);
let target_fps = if requested_fps > 0 {
request.requested_fps
request.fps
} else {
env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1)
};
@ -420,11 +387,12 @@ pub async fn eye_ball_with_request(
target: "lesavka_server::video",
eye = %eye,
max_bitrate_kbit,
source_width = request.source_width,
source_height = request.source_height,
requested_width = request.requested_width,
requested_height = request.requested_height,
requested_fps = request.requested_fps,
source_width = request.width,
source_height = request.height,
source_fps = request.fps,
requested_width = request.width,
requested_height = request.height,
requested_fps = request.fps,
target_fps,
min_fps,
adaptive,
@ -447,17 +415,12 @@ pub async fn eye_ball_with_request(
let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 4).max(1);
let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 4).max(1);
let keyframe_interval = env_u32(
"LESAVKA_EYE_KEYFRAME_INTERVAL",
request.requested_fps.max(1).min(5),
)
.clamp(1, request.requested_fps.max(1));
let keyframe_interval = env_u32("LESAVKA_EYE_KEYFRAME_INTERVAL", request.fps.max(1).min(5))
.clamp(1, request.fps.max(1));
let use_test_src =
dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
let server_encoder_label = if use_test_src {
"x264enc(testsrc)".to_string()
} else if request.reencode {
"x264enc".to_string()
} else {
"source-pass-through".to_string()
};
@ -476,36 +439,17 @@ pub async fn eye_ball_with_request(
h264parse disable-passthrough=true config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
request.requested_width,
request.requested_height,
request.requested_fps,
request.requested_width,
request.requested_height,
request.requested_fps,
)
} else if request.reencode {
format!(
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse disable-passthrough=true config-interval=-1 ! avdec_h264 ! videoconvert ! \
videoscale add-borders=false ! videorate ! video/x-raw,format=I420,width={},height={},framerate={}/1,pixel-aspect-ratio=1/1 ! \
x264enc tune=zerolatency speed-preset=faster bitrate={} key-int-max={} option-string=sar=1/1 ! \
h264parse disable-passthrough=true config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
request.requested_width,
request.requested_height,
request.requested_fps,
request.max_bitrate_kbit,
keyframe_interval,
request.width, request.height, request.fps, request.width, request.height, request.fps,
)
} else {
format!(
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
video/x-h264,width={},height={},framerate={}/1 ! \
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse disable-passthrough=true config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true"
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
request.width, request.height, request.fps,
)
};
@ -718,31 +662,24 @@ mod tests {
#[test]
fn source_profile_stays_pass_through_without_explicit_reencode_request() {
let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, false);
let request = normalize_eye_capture_request(1920, 1080, 30, 12_000);
assert_eq!(request.requested_width, 1920);
assert_eq!(request.requested_height, 1080);
assert_eq!(request.requested_fps, 30);
assert!(!request.reencode);
assert_eq!(request.width, 1920);
assert_eq!(request.height, 1080);
assert_eq!(request.fps, 60);
}
#[test]
fn explicit_reencode_preference_forces_same_size_reencode() {
let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, true);
fn source_mode_selection_prefers_native_modes_without_reencode() {
let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500);
let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000);
let smaller_mode_request = normalize_eye_capture_request(1600, 900, 30, 12_000);
assert_eq!(request.requested_width, 1920);
assert_eq!(request.requested_height, 1080);
assert_eq!(request.requested_fps, 30);
assert!(request.reencode);
}
#[test]
fn bitrate_or_fps_change_forces_reencode_even_at_source_size() {
let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500, false);
let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000, false);
assert!(bitrate_request.reencode);
assert!(fps_request.reencode);
assert_eq!(bitrate_request.fps, 60);
assert_eq!(fps_request.fps, 60);
assert_eq!(smaller_mode_request.width, 1280);
assert_eq!(smaller_mode_request.height, 720);
assert_eq!(smaller_mode_request.fps, 60);
}
fn marker_frame(width: i32, height: i32) -> Vec<u8> {
@ -824,9 +761,9 @@ mod tests {
let mut buffer = gst::Buffer::from_mut_slice(marker_frame(width, height));
if let Some(buf) = buffer.get_mut() {
buf.set_pts(Some(gst::ClockTime::ZERO));
buf.set_duration(Some(
gst::ClockTime::from_nseconds(1_000_000_000_u64 / input_fps.max(1) as u64),
));
buf.set_duration(Some(gst::ClockTime::from_nseconds(
1_000_000_000_u64 / input_fps.max(1) as u64,
)));
}
appsrc
.push_buffer(buffer)
@ -838,7 +775,9 @@ mod tests {
let sample = appsink
.try_pull_sample(gst::ClockTime::from_seconds(5))
.ok_or_else(|| anyhow::anyhow!("timed out pulling reencoded frame"))?;
let caps = sample.caps().ok_or_else(|| anyhow::anyhow!("missing sample caps"))?;
let caps = sample
.caps()
.ok_or_else(|| anyhow::anyhow!("missing sample caps"))?;
let structure = caps
.structure(0)
.ok_or_else(|| anyhow::anyhow!("missing caps structure"))?;

View File

@ -152,7 +152,7 @@ mod server_main_binary {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
}))
.await
});
@ -206,7 +206,7 @@ mod server_main_binary {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
};
let rt = tokio::runtime::Runtime::new().expect("runtime");

View File

@ -81,7 +81,7 @@ mod server_main_rpc {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
}))
.await
});
@ -105,7 +105,7 @@ mod server_main_rpc {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
}))
.await
});
@ -135,7 +135,7 @@ mod server_main_rpc {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
}))
.await
})
@ -209,7 +209,7 @@ mod server_main_rpc {
requested_width: 0,
requested_height: 0,
requested_fps: 0,
prefer_reencode: false,
source_id: None,
};
let rt = tokio::runtime::Runtime::new().expect("runtime");