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] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.11.11" version = "0.11.12"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -425,7 +425,7 @@ impl LesavkaClientApp {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false, source_id: None,
}; };
match cli.capture_video(Request::new(req)).await { match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => { Ok(mut stream) => {
@ -469,7 +469,7 @@ impl LesavkaClientApp {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false, source_id: None,
}; };
match cli.capture_audio(Request::new(req)).await { match cli.capture_audio(Request::new(req)).await {
Ok(mut stream) => { 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::collections::VecDeque;
use std::fmt::Write as _; 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerformanceSample { pub struct PerformanceSample {
@ -28,6 +28,7 @@ pub struct PerformanceSample {
pub left_decoder_label: String, pub left_decoder_label: String,
pub left_stream_caps_label: String, pub left_stream_caps_label: String,
pub left_decoded_caps_label: String, pub left_decoded_caps_label: String,
pub left_rendered_caps_label: String,
pub right_receive_fps: f32, pub right_receive_fps: f32,
pub right_present_fps: f32, pub right_present_fps: f32,
pub right_server_fps: f32, pub right_server_fps: f32,
@ -43,6 +44,7 @@ pub struct PerformanceSample {
pub right_decoder_label: String, pub right_decoder_label: String,
pub right_stream_caps_label: String, pub right_stream_caps_label: String,
pub right_decoded_caps_label: String, pub right_decoded_caps_label: String,
pub right_rendered_caps_label: String,
pub dropped_frames: u64, pub dropped_frames: u64,
pub queue_depth: u32, pub queue_depth: u32,
} }
@ -100,6 +102,7 @@ pub struct SnapshotReport {
pub preview_source: String, pub preview_source: String,
pub client_display_limit: String, pub client_display_limit: String,
pub left_surface: String, pub left_surface: String,
pub left_feed_source: String,
pub left_capture_profile: String, pub left_capture_profile: String,
pub left_capture_transport: String, pub left_capture_transport: String,
pub left_breakout_profile: String, pub left_breakout_profile: String,
@ -115,7 +118,9 @@ pub struct SnapshotReport {
pub left_server_encoder_label: String, pub left_server_encoder_label: String,
pub left_stream_caps_label: String, pub left_stream_caps_label: String,
pub left_decoded_caps_label: String, pub left_decoded_caps_label: String,
pub left_rendered_caps_label: String,
pub right_surface: String, pub right_surface: String,
pub right_feed_source: String,
pub right_capture_profile: String, pub right_capture_profile: String,
pub right_capture_transport: String, pub right_capture_transport: String,
pub right_breakout_profile: String, pub right_breakout_profile: String,
@ -131,6 +136,7 @@ pub struct SnapshotReport {
pub right_server_encoder_label: String, pub right_server_encoder_label: String,
pub right_stream_caps_label: String, pub right_stream_caps_label: String,
pub right_decoded_caps_label: String, pub right_decoded_caps_label: String,
pub right_rendered_caps_label: String,
pub selected_camera: Option<String>, pub selected_camera: Option<String>,
pub selected_microphone: Option<String>, pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>, pub selected_speaker: Option<String>,
@ -150,6 +156,12 @@ impl SnapshotReport {
let left_breakout = state.breakout_size_choice(0); let left_breakout = state.breakout_size_choice(0);
let right_breakout = state.breakout_size_choice(1); let right_breakout = state.breakout_size_choice(1);
let latest = log.latest(); 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 { Self {
client_version: crate::VERSION.to_string(), client_version: crate::VERSION.to_string(),
server_version: state.server_version.clone(), server_version: state.server_version.clone(),
@ -178,14 +190,12 @@ impl SnapshotReport {
state.breakout_display.width, state.breakout_display.height state.breakout_display.width, state.breakout_display.height
), ),
left_surface: state.display_surface(0).label().to_string(), left_surface: state.display_surface(0).label().to_string(),
left_capture_profile: format!( left_feed_source: match state.feed_source_preset(0) {
"{} | {}x{} | {} fps | {} kbit", super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(),
left_capture.preset.label(), super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(),
left_capture.width, super::state::FeedSourcePreset::Off => "Off".to_string(),
left_capture.height, },
left_capture.fps, left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps),
left_capture.max_bitrate_kbit
),
left_capture_transport: left_capture.preset.transport_label().to_string(), left_capture_transport: left_capture.preset.transport_label().to_string(),
left_breakout_profile: format!( left_breakout_profile: format!(
"{} | {}x{}", "{} | {}x{}",
@ -249,15 +259,22 @@ impl SnapshotReport {
} }
}) })
.unwrap_or_else(|| "pending".to_string()), .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_surface: state.display_surface(1).label().to_string(),
right_capture_profile: format!( right_feed_source: match state.feed_source_preset(1) {
"{} | {}x{} | {} fps | {} kbit", super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(),
right_capture.preset.label(), super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(),
right_capture.width, super::state::FeedSourcePreset::Off => "Off".to_string(),
right_capture.height, },
right_capture.fps, right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps),
right_capture.max_bitrate_kbit
),
right_capture_transport: right_capture.preset.transport_label().to_string(), right_capture_transport: right_capture.preset.transport_label().to_string(),
right_breakout_profile: format!( right_breakout_profile: format!(
"{} | {}x{}", "{} | {}x{}",
@ -321,6 +338,15 @@ impl SnapshotReport {
} }
}) })
.unwrap_or_else(|| "pending".to_string()), .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_camera: state.devices.camera.clone(),
selected_microphone: state.devices.microphone.clone(), selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(), selected_speaker: state.devices.speaker.clone(),
@ -364,6 +390,7 @@ impl SnapshotReport {
let _ = writeln!(text); let _ = writeln!(text);
let _ = writeln!(text, "left eye"); let _ = writeln!(text, "left eye");
let _ = writeln!(text, " surface: {}", self.left_surface); 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, " capture: {}", self.left_capture_profile);
let _ = writeln!(text, " transport: {}", self.left_capture_transport); let _ = writeln!(text, " transport: {}", self.left_capture_transport);
let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); 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, " stream caps: {}", self.left_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label); let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label);
let _ = writeln!( let _ = writeln!(
text, text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
@ -390,6 +418,7 @@ impl SnapshotReport {
); );
let _ = writeln!(text, "right eye"); let _ = writeln!(text, "right eye");
let _ = writeln!(text, " surface: {}", self.right_surface); 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, " capture: {}", self.right_capture_profile);
let _ = writeln!(text, " transport: {}", self.right_capture_transport); let _ = writeln!(text, " transport: {}", self.right_capture_transport);
let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); 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, " stream caps: {}", self.right_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label); let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label);
let _ = writeln!( let _ = writeln!(
text, text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", " 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" "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> { fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<String> {
let mut items = Vec::new(); let mut items = Vec::new();
if !state.server_available { 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 { if sample.video_loss_pct >= 2.0 || sample.dropped_frames > 0 {
items.push( 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(), .to_string(),
); );
} }
@ -595,32 +669,18 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
} }
if sample.server_process_cpu_pct >= 85.0 { if sample.server_process_cpu_pct >= 85.0 {
items.push( 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(), .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 let source_passthrough = state
.capture_sizes .feed_sources
.iter() .iter()
.any(|preset| matches!(preset, super::state::CaptureSizePreset::Source)); .any(|preset| !matches!(preset, FeedSourcePreset::Off));
if source_passthrough { if source_passthrough {
items.push( 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(), .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) || (sample.right_server_fps - sample.right_receive_fps) > 6.0)
{ {
items.push( 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(), .to_string(),
); );
} }
@ -662,7 +722,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|| sample.right_server_encoder_label.contains("x264")) || sample.right_server_encoder_label.contains("x264"))
{ {
items.push( 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(), .to_string(),
); );
} }
@ -681,7 +741,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::launcher::state::{DeviceSelection, LauncherState}; use crate::launcher::state::{CaptureSizePreset, DeviceSelection, LauncherState};
fn sample(n: u64) -> PerformanceSample { fn sample(n: u64) -> PerformanceSample {
PerformanceSample { PerformanceSample {
@ -705,9 +765,13 @@ mod tests {
left_server_queue_peak: n as u32 + 1, left_server_queue_peak: n as u32 + 1,
left_server_encoder_label: "x264enc".to_string(), left_server_encoder_label: "x264enc".to_string(),
left_decoder_label: "decodebin".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: left_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), "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_receive_fps: 30.0,
right_present_fps: 28.0, right_present_fps: 28.0,
right_server_fps: 30.0, right_server_fps: 30.0,
@ -721,9 +785,13 @@ mod tests {
right_server_queue_peak: n as u32 + 1, right_server_queue_peak: n as u32 + 1,
right_server_encoder_label: "source-pass-through".to_string(), right_server_encoder_label: "source-pass-through".to_string(),
right_decoder_label: "decodebin".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: right_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), "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, dropped_frames: n,
queue_depth: n as u32, queue_depth: n as u32,
} }
@ -781,11 +849,17 @@ mod tests {
assert_eq!(report.notes, vec!["first note".to_string()]); assert_eq!(report.notes, vec!["first note".to_string()]);
assert!(report.status.contains("mode=remote")); assert!(report.status.contains("mode=remote"));
assert!(report.client_version.starts_with("0.")); assert!(report.client_version.starts_with("0."));
assert!(report.left_capture_profile.contains("fps")); assert_eq!(report.left_feed_source, "Left Eye");
assert_eq!(report.left_capture_transport, "server re-encode"); 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_eq!(report.left_decoder_label, "decodebin");
assert!(report.left_stream_caps_label.contains("video/x-h264")); assert!(report.left_stream_caps_label.contains("video/x-h264"));
assert!(report.left_decoded_caps_label.contains("video/x-raw")); assert!(report.left_decoded_caps_label.contains("video/x-raw"));
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
} }
#[test] #[test]
@ -812,10 +886,12 @@ mod tests {
assert!(text.contains("Lesavka Diagnostics")); assert!(text.contains("Lesavka Diagnostics"));
assert!(text.contains("client: v")); assert!(text.contains("client: v"));
assert!(text.contains("left eye")); assert!(text.contains("left eye"));
assert!(text.contains("source:"));
assert!(text.contains("transport:")); assert!(text.contains("transport:"));
assert!(text.contains("live: decoder=")); assert!(text.contains("live: decoder="));
assert!(text.contains("stream caps:")); assert!(text.contains("stream caps:"));
assert!(text.contains("decoded caps:")); assert!(text.contains("decoded caps:"));
assert!(text.contains("rendered caps:"));
assert!(text.contains("recommendations")); assert!(text.contains("recommendations"));
} }
@ -825,4 +901,39 @@ mod tests {
assert!(cmd.contains("hygiene_gate.sh")); assert!(cmd.contains("hygiene_gate.sh"));
assert!(cmd.contains("quality_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; use gtk::prelude::WidgetExt;
#[cfg(not(coverage))] #[cfg(not(coverage))]
use gtk::{gdk, glib}; use gtk::{gdk, glib};
#[cfg(not(coverage))]
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}; use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
#[cfg(not(coverage))] #[cfg(not(coverage))]
use std::collections::VecDeque; use std::collections::VecDeque;
@ -32,18 +31,22 @@ const PREVIEW_WIDTH: i32 = 960;
#[cfg(not(coverage))] #[cfg(not(coverage))]
const PREVIEW_HEIGHT: i32 = 540; const PREVIEW_HEIGHT: i32 = 540;
#[cfg(not(coverage))] #[cfg(not(coverage))]
const INLINE_PREVIEW_REQUEST_WIDTH: i32 = 960; const INLINE_PREVIEW_REQUEST_WIDTH: i32 = DEFAULT_EYE_SOURCE_WIDTH;
#[cfg(not(coverage))] #[cfg(not(coverage))]
const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = 540; const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = DEFAULT_EYE_SOURCE_HEIGHT;
#[cfg(not(coverage))] #[cfg(not(coverage))]
const INLINE_PREVIEW_REQUEST_FPS: u32 = 24; const INLINE_PREVIEW_REQUEST_FPS: u32 = DEFAULT_EYE_SOURCE_FPS;
#[cfg(not(coverage))] #[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))] #[cfg(not(coverage))]
const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920; const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920;
#[cfg(not(coverage))] #[cfg(not(coverage))]
const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080; const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080;
#[cfg(not(coverage))] #[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."; const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview.";
#[cfg(not(coverage))] #[cfg(not(coverage))]
const TELEMETRY_WINDOW: Duration = Duration::from_secs(5); const TELEMETRY_WINDOW: Duration = Duration::from_secs(5);
@ -92,18 +95,19 @@ pub struct PreviewMetricsSnapshot {
pub decoder_label: String, pub decoder_label: String,
pub stream_caps_label: String, pub stream_caps_label: String,
pub decoded_caps_label: String, pub decoded_caps_label: String,
pub rendered_caps_label: String,
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct PreviewProfile { struct PreviewProfile {
source_monitor_id: u32,
display_width: i32, display_width: i32,
display_height: i32, display_height: i32,
requested_width: i32, requested_width: i32,
requested_height: i32, requested_height: i32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
prefer_reencode: bool,
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -111,6 +115,7 @@ impl PreviewSurface {
fn profile(self) -> PreviewProfile { fn profile(self) -> PreviewProfile {
match self { match self {
Self::Inline => PreviewProfile { Self::Inline => PreviewProfile {
source_monitor_id: 0,
display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH), display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH),
display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT), display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT),
requested_width: preview_dimension( requested_width: preview_dimension(
@ -129,9 +134,9 @@ impl PreviewSurface {
"LESAVKA_PREVIEW_MAX_KBIT", "LESAVKA_PREVIEW_MAX_KBIT",
INLINE_PREVIEW_MAX_KBIT, INLINE_PREVIEW_MAX_KBIT,
), ),
prefer_reencode: true,
}, },
Self::Window => PreviewProfile { Self::Window => PreviewProfile {
source_monitor_id: 0,
display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280), display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280),
display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720), display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720),
requested_width: preview_dimension( requested_width: preview_dimension(
@ -142,9 +147,14 @@ impl PreviewSurface {
"LESAVKA_BREAKOUT_REQUEST_HEIGHT", "LESAVKA_BREAKOUT_REQUEST_HEIGHT",
DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_HEIGHT,
), ),
requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30), requested_fps: preview_bitrate(
max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000), "LESAVKA_BREAKOUT_REQUEST_FPS",
prefer_reencode: true, 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( pub fn set_capture_profile(
&self, &self,
monitor_id: usize, monitor_id: usize,
source_monitor_id: usize,
requested_width: i32, requested_width: i32,
requested_height: i32, requested_height: i32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
prefer_reencode: bool,
) { ) {
let ( let (
inline_requested_width, inline_requested_width,
inline_requested_height, inline_requested_height,
inline_requested_fps, inline_requested_fps,
inline_max_bitrate_kbit, inline_max_bitrate_kbit,
inline_prefer_reencode, ) = sanitize_preview_request(
) = adapt_inline_preview_request(
requested_width, requested_width,
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit, max_bitrate_kbit,
prefer_reencode,
); );
self.rebuild_feed( self.rebuild_feed(
&self.inline_feeds, &self.inline_feeds,
monitor_id, monitor_id,
Some(( Some((
source_monitor_id,
inline_requested_width, inline_requested_width,
inline_requested_height, inline_requested_height,
inline_requested_fps, inline_requested_fps,
inline_max_bitrate_kbit, inline_max_bitrate_kbit,
inline_prefer_reencode,
)), )),
None, None,
); );
@ -312,11 +320,11 @@ impl LauncherPreview {
&self.window_feeds, &self.window_feeds,
monitor_id, monitor_id,
Some(( Some((
source_monitor_id,
requested_width, requested_width,
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit, max_bitrate_kbit,
prefer_reencode,
)), )),
None, None,
); );
@ -331,23 +339,36 @@ impl LauncherPreview {
&self, &self,
monitor_id: usize, monitor_id: usize,
surface: PreviewSurface, surface: PreviewSurface,
) -> Option<(i32, i32, i32, i32, u32, u32, bool)> { ) -> Option<(u32, i32, i32, i32, i32, u32, u32)> {
let feed = match surface { let feed = match surface {
PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(),
PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(),
}?; }?;
let profile = feed.profile(); let profile = feed.profile();
Some(( Some((
profile.source_monitor_id,
profile.display_width, profile.display_width,
profile.display_height, profile.display_height,
profile.requested_width, profile.requested_width,
profile.requested_height, profile.requested_height,
profile.requested_fps, profile.requested_fps,
profile.max_bitrate_kbit, 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)] #[cfg(test)]
pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) { pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) {
let feed = match surface { let feed = match surface {
@ -372,7 +393,7 @@ impl LauncherPreview {
&self, &self,
feeds: &Arc<Mutex<[PreviewFeed; 2]>>, feeds: &Arc<Mutex<[PreviewFeed; 2]>>,
monitor_id: usize, monitor_id: usize,
requested: Option<(i32, i32, u32, u32, bool)>, requested: Option<(usize, i32, i32, u32, u32)>,
display: Option<(i32, i32)>, display: Option<(i32, i32)>,
) { ) {
let Ok(mut feeds) = feeds.lock() else { let Ok(mut feeds) = feeds.lock() else {
@ -382,43 +403,95 @@ impl LauncherPreview {
return; return;
}; };
let was_active = existing.is_active(); let was_active = existing.is_active();
let keep_disabled = existing.is_disabled();
let mut profile = existing.profile(); let mut profile = existing.profile();
if let Some(( if let Some((
source_monitor_id,
requested_width, requested_width,
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit, max_bitrate_kbit,
prefer_reencode,
)) = requested )) = requested
{ {
profile.source_monitor_id = source_monitor_id as u32;
profile.requested_width = requested_width.max(2); profile.requested_width = requested_width.max(2);
profile.requested_height = requested_height.max(2); profile.requested_height = requested_height.max(2);
profile.requested_fps = requested_fps.max(1); profile.requested_fps = requested_fps.max(1);
profile.max_bitrate_kbit = max_bitrate_kbit.max(800); profile.max_bitrate_kbit = max_bitrate_kbit.max(800);
profile.prefer_reencode = prefer_reencode;
} }
if let Some((display_width, display_height)) = display { if let Some((display_width, display_height)) = display {
profile.display_width = display_width.max(2); profile.display_width = display_width.max(2);
profile.display_height = display_height.max(2); profile.display_height = display_height.max(2);
} }
match PreviewFeed::spawn( let next_feed = if keep_disabled {
Arc::clone(&self.server_addr), Some(PreviewFeed::spawn_disabled(profile))
monitor_id as u32, } else {
profile, match PreviewFeed::spawn(
Arc::clone(&self.log_sink), Arc::clone(&self.server_addr),
) { monitor_id as u32,
Ok(feed) => { profile,
if was_active { Arc::clone(&self.log_sink),
feed.set_active(true); ) {
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))] #[cfg(not(coverage))]
@ -463,6 +536,7 @@ struct PreviewFeed {
active_bindings: Arc<AtomicUsize>, active_bindings: Arc<AtomicUsize>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
profile: PreviewProfile, profile: PreviewProfile,
disabled: bool,
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -542,6 +616,7 @@ struct PreviewTelemetry {
decoder_label: String, decoder_label: String,
stream_caps_label: String, stream_caps_label: String,
decoded_caps_label: String, decoded_caps_label: String,
rendered_caps_label: String,
} }
#[cfg(not(coverage))] #[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 { fn snapshot(&mut self) -> PreviewMetricsSnapshot {
self.snapshot_at(Instant::now()) self.snapshot_at(Instant::now())
} }
@ -700,6 +781,7 @@ impl PreviewTelemetry {
decoder_label: self.decoder_label.clone(), decoder_label: self.decoder_label.clone(),
stream_caps_label: self.stream_caps_label.clone(), stream_caps_label: self.stream_caps_label.clone(),
decoded_caps_label: self.decoded_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, active_bindings,
running, running,
profile, 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 { fn profile(&self) -> PreviewProfile {
self.profile self.profile
} }
fn is_disabled(&self) -> bool {
self.disabled
}
fn is_active(&self) -> bool { fn is_active(&self) -> bool {
self.session_active.load(Ordering::Relaxed) self.session_active.load(Ordering::Relaxed)
} }
fn set_active(&self, active: bool) { fn set_active(&self, active: bool) {
self.session_active.store(active, Ordering::Relaxed); self.session_active.store(active, Ordering::Relaxed);
if !active { if !active && !self.disabled {
self.replace_status(PREVIEW_IDLE_STATUS, true); self.replace_status(PREVIEW_IDLE_STATUS, true);
} }
} }
fn shutdown(&self) { fn shutdown(&self) {
self.running.store(false, Ordering::Relaxed); 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) { 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() { if let Some(decoder) = decoder.as_ref() {
record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded); 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 Some(frame) = sample_to_frame(&sample) {
if let Ok(mut slot) = shared.lock() { if let Ok(mut slot) = shared.lock() {
slot.push_frame(frame); slot.push_frame(frame);
@ -1022,7 +1139,7 @@ fn run_preview_feed(
requested_width: profile.requested_width.max(0) as u32, requested_width: profile.requested_width.max(0) as u32,
requested_height: profile.requested_height.max(0) as u32, requested_height: profile.requested_height.max(0) as u32,
requested_fps: profile.requested_fps, 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 { match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => { Ok(mut stream) => {
@ -1289,16 +1406,15 @@ fn looks_like_preview_problem(status: &str) -> bool {
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn build_preview_pipeline( fn build_preview_pipeline(
profile: PreviewProfile, _profile: PreviewProfile,
) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> { ) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> {
let decoder_name = pick_h264_decoder(); let decoder_name = pick_h264_decoder();
let desc = format!( let desc = format!(
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ "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 ! \ 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 ! \ h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! \
video/x-raw,format=RGBA,width={},height={},pixel-aspect-ratio=1/1 ! \ video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true", 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)? let pipeline = gst::parse::launch(&desc)?
.downcast::<gst::Pipeline>() .downcast::<gst::Pipeline>()
@ -1325,8 +1441,7 @@ fn build_preview_pipeline(
appsink.set_caps(Some( appsink.set_caps(Some(
&gst::Caps::builder("video/x-raw") &gst::Caps::builder("video/x-raw")
.field("format", &"RGBA") .field("format", &"RGBA")
.field("width", &profile.display_width) .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1))
.field("height", &profile.display_height)
.build(), .build(),
)); ));
@ -1375,10 +1490,8 @@ fn record_preview_caps(
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn preview_caps_summary(caps: &gst::Caps) -> String { fn preview_caps_summary(caps: &impl std::fmt::Display) -> String {
caps.structure(0) caps.to_string()
.map(|structure| structure.to_string())
.unwrap_or_else(|| caps.to_string())
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -1444,39 +1557,17 @@ fn preview_dimension(var: &str, default: i32) -> i32 {
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn adapt_inline_preview_request( fn sanitize_preview_request(
requested_width: i32, requested_width: i32,
requested_height: i32, requested_height: i32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
prefer_reencode: bool, ) -> (i32, i32, u32, u32) {
) -> (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);
( (
inline_width, requested_width.max(2),
inline_height, requested_height.max(2),
inline_fps, requested_fps.max(1),
inline_bitrate, max_bitrate_kbit.max(800),
prefer_reencode || adapted,
) )
} }
@ -1539,7 +1630,7 @@ mod tests {
DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT, DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT,
INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH, INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH,
LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry,
adapt_inline_preview_request, sanitize_preview_request,
}; };
use crate::launcher::state::{CaptureSizePreset, LauncherState}; use crate::launcher::state::{CaptureSizePreset, LauncherState};
use futures::stream; use futures::stream;
@ -1691,20 +1782,20 @@ mod tests {
assert_eq!(profile.display_height, 720); assert_eq!(profile.display_height, 720);
assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH); assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH);
assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT); assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT);
assert_eq!(profile.requested_fps, 30); assert_eq!(profile.requested_fps, 60);
assert_eq!(profile.max_bitrate_kbit, 12_000); assert_eq!(profile.max_bitrate_kbit, 18_000);
} }
#[test] #[test]
fn inline_preview_request_caps_large_full_quality_streams() { fn preview_request_sanitizer_keeps_requested_source_geometry() {
let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, true); let adapted = sanitize_preview_request(1920, 1080, 60, 18_000);
assert_eq!(adapted, (1920, 1080, 24, 4_000, true)); assert_eq!(adapted, (1920, 1080, 60, 18_000));
} }
#[test] #[test]
fn inline_preview_request_keeps_source_stream_honest() { fn preview_request_sanitizer_clamps_invalid_values() {
let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, false); let adapted = sanitize_preview_request(0, 0, 0, 0);
assert_eq!(adapted, (1920, 1080, 30, 12_000, false)); assert_eq!(adapted, (2, 2, 1, 800));
} }
#[test] #[test]
@ -1762,7 +1853,7 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn inline_preview_requests_selected_reencode_profile_on_wire() { fn inline_preview_requests_selected_source_profile_on_wire() {
let relay = ProbeRelay::default(); let relay = ProbeRelay::default();
let requests = relay.requests.clone(); let requests = relay.requests.clone();
let rt = tokio::runtime::Runtime::new().expect("runtime"); let rt = tokio::runtime::Runtime::new().expect("runtime");
@ -1783,23 +1874,24 @@ mod tests {
let state = LauncherState::default(); let state = LauncherState::default();
let capture = state.capture_size_choice(1); let capture = state.capture_size_choice(1);
preview.set_capture_profile( preview.set_capture_profile(
1,
1, 1,
capture.width, capture.width,
capture.height, capture.height,
capture.fps, capture.fps,
capture.max_bitrate_kbit, capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
); );
preview.activate_surface_for_test(1, PreviewSurface::Inline); preview.activate_surface_for_test(1, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5); let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline { while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() { 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_width, 1920);
assert_eq!(request.requested_height, 1080); assert_eq!(request.requested_height, 1080);
assert_eq!(request.requested_fps, 24); assert_eq!(request.requested_fps, 60);
assert_eq!(request.max_bitrate, 4_000); assert_eq!(request.max_bitrate, 18_000);
assert!(request.prefer_reencode);
preview.shutdown_all(); preview.shutdown_all();
return; return;
} }
@ -1831,26 +1923,27 @@ mod tests {
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
let mut state = LauncherState::default(); 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); let capture = state.capture_size_choice(1);
preview.set_capture_profile( preview.set_capture_profile(
1,
1, 1,
capture.width, capture.width,
capture.height, capture.height,
capture.fps, capture.fps,
capture.max_bitrate_kbit, capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
); );
preview.activate_surface_for_test(1, PreviewSurface::Inline); preview.activate_surface_for_test(1, PreviewSurface::Inline);
let deadline = Instant::now() + Duration::from_secs(5); let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline { while Instant::now() < deadline {
if let Some(request) = requests.lock().unwrap().last().cloned() { 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_width, 1920);
assert_eq!(request.requested_height, 1080); assert_eq!(request.requested_height, 1080);
assert_eq!(request.requested_fps, 30); assert_eq!(request.requested_fps, 60);
assert_eq!(request.max_bitrate, 12_000); assert_eq!(request.max_bitrate, 18_000);
assert!(!request.prefer_reencode);
preview.shutdown_all(); preview.shutdown_all();
return; return;
} }
@ -1860,4 +1953,148 @@ mod tests {
preview.shutdown_all(); preview.shutdown_all();
panic!("preview did not issue a source capture request within timeout"); 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 serde::{Deserialize, Serialize};
use super::devices::DeviceCatalog; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputRouting { 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BreakoutSizePreset { pub enum BreakoutSizePreset {
P360, P360,
@ -105,57 +141,69 @@ impl BreakoutSizePreset {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CaptureSizePreset { pub enum CaptureSizePreset {
P360, #[serde(alias = "P360")]
P540, Vga,
#[serde(alias = "P540")]
P480,
P576,
P720, P720,
P900, #[serde(alias = "P900", alias = "P1440", alias = "Source")]
P1080, P1080,
P1440,
Source,
} }
impl CaptureSizePreset { impl CaptureSizePreset {
pub fn as_id(self) -> &'static str { pub fn as_id(self) -> &'static str {
match self { match self {
Self::P360 => "360p", Self::Vga => "vga",
Self::P540 => "540p", Self::P480 => "480p",
Self::P576 => "576p",
Self::P720 => "720p", Self::P720 => "720p",
Self::P900 => "900p",
Self::P1080 => "1080p", Self::P1080 => "1080p",
Self::P1440 => "1440p",
Self::Source => "source",
} }
} }
pub fn from_id(raw: &str) -> Option<Self> { pub fn from_id(raw: &str) -> Option<Self> {
match raw { match raw {
"360p" => Some(Self::P360), "vga" | "360p" => Some(Self::Vga),
"540p" => Some(Self::P540), "480p" | "540p" => Some(Self::P480),
"576p" => Some(Self::P576),
"720p" => Some(Self::P720), "720p" => Some(Self::P720),
"900p" => Some(Self::P900), "900p" | "1080p" | "1440p" | "source" => Some(Self::P1080),
"1080p" => Some(Self::P1080),
"1440p" => Some(Self::P1440),
"source" => Some(Self::Source),
_ => None, _ => None,
} }
} }
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
Self::P360 => "360p", Self::Vga => "VGA",
Self::P540 => "540p", Self::P480 => "480p",
Self::P576 => "576p",
Self::P720 => "720p", Self::P720 => "720p",
Self::P900 => "900p",
Self::P1080 => "1080p", Self::P1080 => "1080p",
Self::P1440 => "1440p",
Self::Source => "Source",
} }
} }
pub fn transport_label(self) -> &'static str { pub fn transport_label(self) -> &'static str {
"device H.264 pass-through"
}
pub fn source_mode(self) -> EyeSourceMode {
match self { match self {
Self::Source => "source pass-through", Self::Vga => native_eye_source_modes()[4],
_ => "server re-encode", 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 { impl Default for PreviewSourceSize {
fn default() -> Self { fn default() -> Self {
let mode = default_eye_source_mode();
Self { Self {
width: 1920, width: mode.width,
height: 1080, height: mode.height,
fps: 30, fps: mode.fps,
} }
} }
} }
@ -193,6 +242,12 @@ pub struct CaptureSizeChoice {
pub max_bitrate_kbit: u32, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CaptureFpsChoice { pub struct CaptureFpsChoice {
pub fps: u32, pub fps: u32,
@ -242,6 +297,7 @@ pub struct LauncherState {
pub routing: InputRouting, pub routing: InputRouting,
pub view_mode: ViewMode, pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2], pub displays: [DisplaySurface; 2],
pub feed_sources: [FeedSourcePreset; 2],
pub preview_source: PreviewSourceSize, pub preview_source: PreviewSourceSize,
pub breakout_limit: PreviewSourceSize, pub breakout_limit: PreviewSourceSize,
pub breakout_display: PreviewSourceSize, pub breakout_display: PreviewSourceSize,
@ -266,12 +322,13 @@ impl Default for LauncherState {
routing: InputRouting::Remote, routing: InputRouting::Remote,
view_mode: ViewMode::Unified, view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview], displays: [DisplaySurface::Preview, DisplaySurface::Preview],
feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye],
preview_source: PreviewSourceSize::default(), preview_source: PreviewSourceSize::default(),
breakout_limit: PreviewSourceSize::default(), breakout_limit: PreviewSourceSize::default(),
breakout_display: PreviewSourceSize::default(), breakout_display: PreviewSourceSize::default(),
capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080], capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080],
capture_fps: [30, 30], capture_fps: [60, 60],
capture_bitrates_kbit: [12_000, 12_000], capture_bitrates_kbit: [18_000, 18_000],
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
devices: DeviceSelection::default(), devices: DeviceSelection::default(),
swap_key: "pause".to_string(), swap_key: "pause".to_string(),
@ -323,6 +380,44 @@ impl LauncherState {
.unwrap_or(DisplaySurface::Preview) .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) { pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) {
if let Some(slot) = self.displays.get_mut(monitor_id) { if let Some(slot) = self.displays.get_mut(monitor_id) {
*slot = surface; *slot = surface;
@ -391,13 +486,21 @@ impl LauncherState {
} }
pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset { pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset {
self.capture_sizes normalize_capture_size_preset(
.get(monitor_id) self.capture_sizes
.copied() .get(monitor_id)
.unwrap_or(CaptureSizePreset::Source) .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) { 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) { if let Some(slot) = self.capture_sizes.get_mut(monitor_id) {
*slot = preset; *slot = preset;
} }
@ -410,10 +513,15 @@ impl LauncherState {
self.capture_fps self.capture_fps
.get(monitor_id) .get(monitor_id)
.copied() .copied()
.unwrap_or(30) .unwrap_or(default_eye_source_mode().fps)
.max(1) .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) { pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) {
if let Some(slot) = self.capture_fps.get_mut(monitor_id) { if let Some(slot) = self.capture_fps.get_mut(monitor_id) {
*slot = fps.max(1); *slot = fps.max(1);
@ -424,10 +532,19 @@ impl LauncherState {
self.capture_bitrates_kbit self.capture_bitrates_kbit
.get(monitor_id) .get(monitor_id)
.copied() .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) .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) { 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) { if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) {
*slot = max_bitrate_kbit.max(800); *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> { pub fn capture_size_options(&self) -> Vec<CaptureSizeChoice> {
capture_size_options(self.preview_source) capture_size_options(self.preview_source)
} }
@ -571,7 +693,7 @@ impl LauncherState {
pub fn status_line(&self) -> String { pub fn status_line(&self) -> String {
format!( 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, self.server_available,
match self.routing { match self.routing {
InputRouting::Local => "local", InputRouting::Local => "local",
@ -591,6 +713,8 @@ impl LauncherState {
self.preview_source.height, self.preview_source.height,
self.displays[0].label(), self.displays[0].label(),
self.displays[1].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.camera.as_deref().unwrap_or("auto"),
self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"),
self.devices.speaker.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( fn capture_size_choice(
source: PreviewSourceSize, _source: PreviewSourceSize,
preset: CaptureSizePreset, preset: CaptureSizePreset,
selected_fps: u32, selected_fps: u32,
selected_bitrate_kbit: u32, selected_bitrate_kbit: u32,
) -> CaptureSizeChoice { ) -> CaptureSizeChoice {
let source_width = source.width.max(1) as i32; let preset = normalize_capture_size_preset(preset);
let source_height = source.height.max(1) as i32; let mode = preset.source_mode();
let source_fps = source.fps.max(1); let _ = (selected_fps, selected_bitrate_kbit);
let (width, height, fps, max_bitrate_kbit) = match preset { let (width, height, fps, max_bitrate_kbit) = (
CaptureSizePreset::P360 => { mode.width as i32,
let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360); mode.height as i32,
( mode.fps,
width, estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps),
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),
),
};
CaptureSizeChoice { CaptureSizeChoice {
preset, preset,
width, 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> { fn capture_size_options(source: PreviewSourceSize) -> Vec<CaptureSizeChoice> {
let mut options = Vec::new(); native_eye_source_modes()
for preset in [ .iter()
CaptureSizePreset::Source, .copied()
CaptureSizePreset::P360, .filter(|mode| mode.width <= source.width && mode.height <= source.height)
CaptureSizePreset::P540, .map(CaptureSizePreset::from_source_mode)
CaptureSizePreset::P720, .map(|preset| {
CaptureSizePreset::P900, let defaults = default_profile_for_preset(source, preset);
CaptureSizePreset::P1080, capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit)
CaptureSizePreset::P1440, })
] { .collect()
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
} }
fn capture_fps_options(source: PreviewSourceSize) -> Vec<CaptureFpsChoice> { fn capture_fps_options(source: PreviewSourceSize) -> Vec<CaptureFpsChoice> {
let mut values = BTreeSet::new(); vec![CaptureFpsChoice {
for fps in [15, 20, 24, 30, 48, 60, source.fps.max(1)] { fps: 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()
} }
fn capture_bitrate_options(source: PreviewSourceSize) -> Vec<CaptureBitrateChoice> { fn capture_bitrate_options(source: PreviewSourceSize) -> Vec<CaptureBitrateChoice> {
let mut values = BTreeSet::new(); vec![CaptureBitrateChoice {
for bitrate in [ max_bitrate_kbit: estimate_source_bitrate_kbit(
2_500, source.width as i32,
4_000, source.height as i32,
6_000, source.fps,
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()
} }
fn default_profile_for_preset( fn default_profile_for_preset(
source: PreviewSourceSize, _source: PreviewSourceSize,
preset: CaptureSizePreset, preset: CaptureSizePreset,
) -> CaptureSizeChoice { ) -> CaptureSizeChoice {
let source_width = source.width.max(1) as i32; let preset = normalize_capture_size_preset(preset);
let source_height = source.height.max(1) as i32; let mode = preset.source_mode();
let source_fps = source.fps.max(1); let (width, height, fps, max_bitrate_kbit) = (
let (width, height, fps, max_bitrate_kbit) = match preset { mode.width as i32,
CaptureSizePreset::P360 => { mode.height as i32,
let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360); mode.fps,
(width, height, source_fps.min(15), 2_500) estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps),
} );
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),
),
};
CaptureSizeChoice { CaptureSizeChoice {
preset, preset,
width, width,
@ -878,6 +886,10 @@ fn default_profile_for_preset(
} }
} }
fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset {
preset
}
fn fit_standard_dimensions( fn fit_standard_dimensions(
limit_width: i32, limit_width: i32,
limit_height: i32, limit_height: i32,
@ -973,6 +985,29 @@ mod tests {
assert_eq!(state.display_surface(1), DisplaySurface::Window); 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] #[test]
fn selecting_auto_or_blank_clears_explicit_device() { fn selecting_auto_or_blank_clears_explicit_device() {
let mut state = LauncherState::new(); let mut state = LauncherState::new();
@ -1076,21 +1111,21 @@ mod tests {
#[test] #[test]
fn breakout_size_choices_track_the_negotiated_source_size() { fn breakout_size_choices_track_the_negotiated_source_size() {
let mut state = LauncherState::new(); 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); state.set_breakout_limit_size(2560, 1440);
let source = state.capture_size_choice(0); let source = state.capture_size_choice(0);
assert_eq!(source.width, 1920); assert_eq!(source.width, 1920);
assert_eq!(source.height, 1080); assert_eq!(source.height, 1080);
assert_eq!(source.fps, 30); assert_eq!(source.fps, 60);
assert_eq!(source.max_bitrate_kbit, 12_000); 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); let compact_capture = state.capture_size_choice(0);
assert_eq!(compact_capture.width, 960); assert_eq!(compact_capture.width, 720);
assert_eq!(compact_capture.height, 540); assert_eq!(compact_capture.height, 480);
assert_eq!(compact_capture.fps, 20); assert_eq!(compact_capture.fps, 60);
assert_eq!(compact_capture.max_bitrate_kbit, 4_000); assert_eq!(compact_capture.max_bitrate_kbit, 2_500);
let display = state.breakout_size_choice(0); let display = state.breakout_size_choice(0);
assert_eq!(display.width, 1920); assert_eq!(display.width, 1920);
@ -1107,17 +1142,12 @@ mod tests {
assert_eq!(compact.height, 540); assert_eq!(compact.height, 540);
let capture_options = state.capture_size_options(); let capture_options = state.capture_size_options();
assert!(capture_options.len() >= 5); assert_eq!(capture_options.len(), 5);
assert!(capture_options.iter().any(|choice| { assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080);
choice.preset == CaptureSizePreset::Source assert_eq!(capture_options[0].width, 1920);
&& choice.width == 1920 assert_eq!(capture_options[0].height, 1080);
&& choice.height == 1080 assert_eq!(capture_options[0].fps, 60);
})); assert_eq!(capture_options[0].max_bitrate_kbit, 18_000);
assert!(capture_options.iter().any(|choice| {
choice.preset == CaptureSizePreset::P1080
&& choice.width == 1920
&& choice.height == 1080
}));
let breakout_options = state.breakout_size_options(); let breakout_options = state.breakout_size_options();
assert!(breakout_options.len() >= 5); assert!(breakout_options.len() >= 5);
@ -1181,61 +1211,73 @@ mod tests {
} }
#[test] #[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(); let mut state = LauncherState::new();
state.set_preview_source_profile(1920, 1080, 60); 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); let source = state.capture_size_choice(0);
assert_eq!(source.width, 1920); assert_eq!(source.width, 1920);
assert_eq!(source.height, 1080); assert_eq!(source.height, 1080);
assert_eq!(source.fps, 60); 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); state.set_capture_size_preset(0, CaptureSizePreset::P720);
let hd = state.capture_size_choice(0); 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); 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); 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] #[test]
fn split_capture_controls_apply_custom_fps_and_bitrate() { fn source_capture_knobs_follow_the_selected_native_mode() {
let mut state = LauncherState::new(); 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); state.set_capture_size_preset(1, CaptureSizePreset::P1080);
let defaults = state.capture_size_choice(1); let defaults = state.capture_size_choice(1);
assert_eq!(defaults.width, 1920); assert_eq!(defaults.width, 1920);
assert_eq!(defaults.height, 1080); assert_eq!(defaults.height, 1080);
assert_eq!(defaults.fps, 30); assert_eq!(defaults.fps, 60);
assert_eq!(defaults.max_bitrate_kbit, 12_000); assert_eq!(defaults.max_bitrate_kbit, 18_000);
state.set_capture_fps(1, 24); state.set_capture_fps(1, 24);
state.set_capture_bitrate_kbit(1, 8_500); state.set_capture_bitrate_kbit(1, 8_500);
let tuned = state.capture_size_choice(1); let tuned = state.capture_size_choice(1);
assert_eq!(tuned.preset, CaptureSizePreset::P1080);
assert_eq!(tuned.width, 1920); assert_eq!(tuned.width, 1920);
assert_eq!(tuned.height, 1080); assert_eq!(tuned.height, 1080);
assert_eq!(tuned.fps, 24); assert_eq!(tuned.fps, 60);
assert_eq!(tuned.max_bitrate_kbit, 8_500); assert_eq!(tuned.max_bitrate_kbit, 18_000);
} }
#[test] #[test]
fn source_capture_ignores_manual_fps_and_bitrate_knobs() { fn source_capture_ignores_manual_fps_and_bitrate_knobs() {
let mut state = LauncherState::new(); let mut state = LauncherState::new();
state.set_preview_source_profile(1920, 1080, 25); state.set_preview_source_profile(1920, 1080, 60);
state.set_capture_size_preset(0, CaptureSizePreset::Source); state.set_capture_size_preset(0, CaptureSizePreset::P720);
state.set_capture_fps(0, 60); state.set_capture_fps(0, 60);
state.set_capture_bitrate_kbit(0, 24_000); state.set_capture_bitrate_kbit(0, 24_000);
let source = state.capture_size_choice(0); let source = state.capture_size_choice(0);
assert_eq!(source.preset, CaptureSizePreset::Source); assert_eq!(source.preset, CaptureSizePreset::P720);
assert_eq!(source.fps, 25); assert_eq!(source.width, 1280);
assert_eq!(source.height, 720);
assert_eq!(source.fps, 60);
assert_eq!(source.max_bitrate_kbit, 12_000); assert_eq!(source.max_bitrate_kbit, 12_000);
} }
} }

View File

@ -10,8 +10,8 @@ use {
super::launcher_focus_signal_path, super::launcher_focus_signal_path,
super::power::{fetch_capture_power, set_capture_power_mode}, super::power::{fetch_capture_power, set_capture_power_mode},
super::state::{ super::state::{
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, InputRouting, BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
LauncherState, FeedSourcePreset, InputRouting, LauncherState,
}, },
super::ui_components::build_launcher_view, super::ui_components::build_launcher_view,
super::ui_runtime::{ super::ui_runtime::{
@ -207,25 +207,43 @@ fn refresh_eye_feed_controls(
state: &LauncherState, state: &LauncherState,
) { ) {
for monitor_id in 0..2 { for monitor_id in 0..2 {
super::ui_components::sync_capture_resolution_combo( super::ui_components::sync_feed_source_combo(
&widgets.display_panes[monitor_id].capture_resolution_combo, &widgets.display_panes[monitor_id].feed_source_combo,
state.capture_size_options(), state.feed_source_options(monitor_id),
state.capture_size_preset(monitor_id), state.feed_source_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,
); );
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( super::ui_components::sync_breakout_size_combo(
&widgets.display_panes[monitor_id].breakout_combo, &widgets.display_panes[monitor_id].breakout_combo,
state.breakout_size_options(), state.breakout_size_options(),
@ -254,25 +272,31 @@ fn record_diagnostics_sample(
) { ) {
let left_metrics = preview let left_metrics = preview
.and_then(|preview| { .and_then(|preview| {
preview.snapshot_metrics( (state.feed_source_preset(0) != FeedSourcePreset::Off).then_some(
0, preview.snapshot_metrics(
match state.display_surface(0) { 0,
DisplaySurface::Preview => super::preview::PreviewSurface::Inline, match state.display_surface(0) {
DisplaySurface::Window => super::preview::PreviewSurface::Window, DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
}, DisplaySurface::Window => super::preview::PreviewSurface::Window,
},
),
) )
}) })
.flatten()
.unwrap_or_default(); .unwrap_or_default();
let right_metrics = preview let right_metrics = preview
.and_then(|preview| { .and_then(|preview| {
preview.snapshot_metrics( (state.feed_source_preset(1) != FeedSourcePreset::Off).then_some(
1, preview.snapshot_metrics(
match state.display_surface(1) { 1,
DisplaySurface::Preview => super::preview::PreviewSurface::Inline, match state.display_surface(1) {
DisplaySurface::Window => super::preview::PreviewSurface::Window, DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
}, DisplaySurface::Window => super::preview::PreviewSurface::Window,
},
),
) )
}) })
.flatten()
.unwrap_or_default(); .unwrap_or_default();
widgets widgets
@ -305,6 +329,7 @@ fn record_diagnostics_sample(
left_decoder_label: left_metrics.decoder_label.clone(), left_decoder_label: left_metrics.decoder_label.clone(),
left_stream_caps_label: left_metrics.stream_caps_label.clone(), left_stream_caps_label: left_metrics.stream_caps_label.clone(),
left_decoded_caps_label: left_metrics.decoded_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_receive_fps: right_metrics.receive_fps,
right_present_fps: right_metrics.present_fps, right_present_fps: right_metrics.present_fps,
right_server_fps: right_metrics.server_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_decoder_label: right_metrics.decoder_label.clone(),
right_stream_caps_label: right_metrics.stream_caps_label.clone(), right_stream_caps_label: right_metrics.stream_caps_label.clone(),
right_decoded_caps_label: right_metrics.decoded_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: left_metrics
.dropped_frames .dropped_frames
.saturating_add(right_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( fn rebind_inline_preview(
preview: &super::preview::LauncherPreview, preview: &super::preview::LauncherPreview,
widgets: &super::ui_components::LauncherWidgets, widgets: &super::ui_components::LauncherWidgets,
state: &LauncherState,
monitor_id: usize, monitor_id: usize,
) { ) {
if let Some(binding) = widgets.display_panes[monitor_id] if let Some(binding) = widgets.display_panes[monitor_id]
@ -428,6 +455,15 @@ fn rebind_inline_preview(
{ {
binding.close(); 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( let binding = preview.install_on_picture(
monitor_id, monitor_id,
super::preview::PreviewSurface::Inline, super::preview::PreviewSurface::Inline,
@ -443,6 +479,7 @@ fn rebind_inline_preview(
fn rebind_popout_preview( fn rebind_popout_preview(
preview: &super::preview::LauncherPreview, preview: &super::preview::LauncherPreview,
popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>, popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>,
state: &LauncherState,
monitor_id: usize, monitor_id: usize,
) { ) {
let mut popouts = popouts.borrow_mut(); let mut popouts = popouts.borrow_mut();
@ -450,6 +487,13 @@ fn rebind_popout_preview(
return; return;
}; };
handle.binding.close(); 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( if let Some(binding) = preview.install_on_picture(
monitor_id, monitor_id,
super::preview::PreviewSurface::Window, super::preview::PreviewSurface::Window,
@ -463,15 +507,20 @@ fn rebind_popout_preview(
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) { fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) {
for monitor_id in 0..2 { 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 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); let breakout = state.breakout_size_choice(monitor_id);
preview.set_capture_profile( preview.set_capture_profile(
monitor_id, monitor_id,
source_monitor_id,
capture.width, capture.width,
capture.height, capture.height,
capture.fps, capture.fps,
capture.max_bitrate_kbit, capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
); );
preview.set_breakout_profile(monitor_id, breakout.width, breakout.height); preview.set_breakout_profile(monitor_id, breakout.width, breakout.height);
} }
@ -486,8 +535,8 @@ fn sync_preview_profiles(
) { ) {
apply_preview_profiles(preview, state); apply_preview_profiles(preview, state);
for monitor_id in 0..2 { for monitor_id in 0..2 {
rebind_inline_preview(preview, widgets, monitor_id); rebind_inline_preview(preview, widgets, state, monitor_id);
rebind_popout_preview(preview, popouts, 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 { for monitor_id in 0..2 {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let widgets = widgets.clone(); 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 { let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else {
return; return;
}; };
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
return;
}
if state.borrow().capture_size_preset(monitor_id) == preset { if state.borrow().capture_size_preset(monitor_id) == preset {
return; return;
} }
@ -820,16 +900,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
} }
if let Some(preview) = preview.as_ref() { if let Some(preview) = preview.as_ref() {
let choice = state.borrow().capture_size_choice(monitor_id); 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( preview.set_capture_profile(
monitor_id, monitor_id,
source_monitor_id,
choice.width, choice.width,
choice.height, choice.height,
choice.fps, choice.fps,
choice.max_bitrate_kbit, choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
); );
rebind_inline_preview(preview, &widgets, monitor_id); sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
rebind_popout_preview(preview, &popouts, monitor_id);
} }
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); 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 { let Ok(fps) = active_id.as_str().parse::<u32>() else {
return; return;
}; };
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
return;
}
if state.borrow().capture_fps(monitor_id) == fps { if state.borrow().capture_fps(monitor_id) == fps {
return; return;
} }
@ -861,16 +947,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
} }
if let Some(preview) = preview.as_ref() { if let Some(preview) = preview.as_ref() {
let choice = state.borrow().capture_size_choice(monitor_id); 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( preview.set_capture_profile(
monitor_id, monitor_id,
source_monitor_id,
choice.width, choice.width,
choice.height, choice.height,
choice.fps, choice.fps,
choice.max_bitrate_kbit, choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
); );
rebind_inline_preview(preview, &widgets, monitor_id); sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
rebind_popout_preview(preview, &popouts, monitor_id);
} }
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); 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 { let Ok(max_bitrate_kbit) = active_id.as_str().parse::<u32>() else {
return; return;
}; };
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
return;
}
if state.borrow().capture_bitrate_kbit(monitor_id) == max_bitrate_kbit { if state.borrow().capture_bitrate_kbit(monitor_id) == max_bitrate_kbit {
return; return;
} }
@ -903,16 +995,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
} }
if let Some(preview) = preview.as_ref() { if let Some(preview) = preview.as_ref() {
let choice = state.borrow().capture_size_choice(monitor_id); 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( preview.set_capture_profile(
monitor_id, monitor_id,
source_monitor_id,
choice.width, choice.width,
choice.height, choice.height,
choice.fps, choice.fps,
choice.max_bitrate_kbit, choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
); );
rebind_inline_preview(preview, &widgets, monitor_id); sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
rebind_popout_preview(preview, &popouts, monitor_id);
} }
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); 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 popout_open {
if let Some(preview) = preview.as_ref() { 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 if let Some(handle) = popouts
.borrow() .borrow()
@ -1833,7 +1928,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
if let (Some(width), Some(height)) = if let (Some(width), Some(height)) =
(caps.eye_width, caps.eye_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(); let mut state = state.borrow_mut();
state.set_preview_source_profile(width, height, fps); state.set_preview_source_profile(width, height, fps);
@ -1911,7 +2008,7 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
mod tests { mod tests {
use super::apply_preview_profiles; use super::apply_preview_profiles;
use crate::launcher::preview::{LauncherPreview, PreviewSurface}; use crate::launcher::preview::{LauncherPreview, PreviewSurface};
use crate::launcher::state::{CaptureSizePreset, LauncherState}; use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
#[test] #[test]
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() { fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {
@ -1919,25 +2016,27 @@ mod tests {
let state = LauncherState::default(); let state = LauncherState::default();
let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
assert_eq!(bootstrap.2, 960); assert_eq!(bootstrap.0, 0);
assert_eq!(bootstrap.3, 540); assert_eq!(bootstrap.3, 1920);
assert_eq!(bootstrap.4, 24); assert_eq!(bootstrap.4, 1080);
assert_eq!(bootstrap.5, 60);
assert_eq!(bootstrap.6, 18_000);
apply_preview_profiles(&preview, &state); apply_preview_profiles(&preview, &state);
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
assert_eq!(inline.2, 1920); assert_eq!(inline.0, 1);
assert_eq!(inline.3, 1080); assert_eq!(inline.3, 1920);
assert_eq!(inline.4, 24); assert_eq!(inline.4, 1080);
assert_eq!(inline.5, 4_000); assert_eq!(inline.5, 60);
assert!(inline.6); assert_eq!(inline.6, 18_000);
let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap(); let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap();
assert_eq!(window.2, 1920); assert_eq!(window.0, 1);
assert_eq!(window.3, 1080); assert_eq!(window.3, 1920);
assert_eq!(window.4, 30); assert_eq!(window.4, 1080);
assert_eq!(window.5, 12_000); assert_eq!(window.5, 60);
assert!(window.6); assert_eq!(window.6, 18_000);
preview.shutdown_all(); preview.shutdown_all();
} }
@ -1946,16 +2045,56 @@ mod tests {
fn source_preview_profile_stays_honest_after_apply() { fn source_preview_profile_stays_honest_after_apply() {
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
let mut state = LauncherState::default(); 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); apply_preview_profiles(&preview, &state);
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
assert_eq!(inline.2, 1920); assert_eq!(inline.0, 1);
assert_eq!(inline.3, 1080); assert_eq!(inline.3, 1920);
assert_eq!(inline.4, 30); assert_eq!(inline.4, 1080);
assert_eq!(inline.5, 12_000); assert_eq!(inline.5, 60);
assert!(!inline.6); 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(); preview.shutdown_all();
} }

View File

@ -9,7 +9,7 @@ use super::{
preview::{LauncherPreview, PreviewBinding, PreviewSurface}, preview::{LauncherPreview, PreviewBinding, PreviewSurface},
state::{ state::{
BreakoutSizeChoice, BreakoutSizePreset, CaptureBitrateChoice, CaptureFpsChoice, BreakoutSizeChoice, BreakoutSizePreset, CaptureBitrateChoice, CaptureFpsChoice,
CaptureSizeChoice, CaptureSizePreset, LauncherState, CaptureSizeChoice, CaptureSizePreset, FeedSourceChoice, FeedSourcePreset, LauncherState,
}, },
}; };
@ -31,6 +31,7 @@ pub struct DisplayPaneWidgets {
pub picture: gtk::Picture, pub picture: gtk::Picture,
pub stream_status: gtk::Label, pub stream_status: gtk::Label,
pub placeholder: gtk::Label, pub placeholder: gtk::Label,
pub feed_source_combo: gtk::ComboBoxText,
pub capture_resolution_combo: gtk::ComboBoxText, pub capture_resolution_combo: gtk::ComboBoxText,
pub capture_fps_combo: gtk::ComboBoxText, pub capture_fps_combo: gtk::ComboBoxText,
pub capture_bitrate_combo: gtk::ComboBoxText, pub capture_bitrate_combo: gtk::ComboBoxText,
@ -529,60 +530,94 @@ pub fn build_launcher_view(
let left_pane = left_pane; let left_pane = left_pane;
let right_pane = right_pane; let right_pane = right_pane;
if let Some(preview) = preview.as_ref() { if let Some(preview) = preview.as_ref() {
*left_pane.preview_binding.borrow_mut() = preview.install_on_picture( *left_pane.preview_binding.borrow_mut() =
0, if state.feed_source_preset(0) == FeedSourcePreset::Off {
PreviewSurface::Inline, None
&left_pane.picture, } else {
&left_pane.stream_status, preview.install_on_picture(
); 0,
*right_pane.preview_binding.borrow_mut() = preview.install_on_picture( PreviewSurface::Inline,
1, &left_pane.picture,
PreviewSurface::Inline, &left_pane.stream_status,
&right_pane.picture, )
&right_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 { } else {
left_pane.stream_status.set_text("Preview unavailable"); left_pane.stream_status.set_text("Preview unavailable");
right_pane.stream_status.set_text("Preview unavailable"); right_pane.stream_status.set_text("Preview unavailable");
} }
sync_capture_resolution_combo( sync_feed_source_combo(
&left_pane.capture_resolution_combo, &left_pane.feed_source_combo,
state.capture_size_options(), state.feed_source_options(0),
state.capture_size_preset(0), state.feed_source_preset(0),
); );
sync_capture_fps_combo( sync_feed_source_combo(
&left_pane.capture_fps_combo, &right_pane.feed_source_combo,
state.capture_fps_options(), state.feed_source_options(1),
state.capture_fps(0), state.feed_source_preset(1),
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,
); );
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( sync_breakout_size_combo(
&left_pane.breakout_combo, &left_pane.breakout_combo,
state.breakout_size_options(), state.breakout_size_options(),
@ -853,30 +888,45 @@ fn stabilize_chip(chip: &gtk::Box, width: i32) {
chip.set_size_request(width, -1); 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( pub fn sync_capture_resolution_combo(
combo: &gtk::ComboBoxText, combo: &gtk::ComboBoxText,
options: Vec<CaptureSizeChoice>, options: Vec<CaptureSizeChoice>,
selected: CaptureSizePreset, selected: CaptureSizePreset,
) { ) {
combo.remove_all(); combo.remove_all();
let option_count = options.len();
for option in options { for option in options {
let label = match option.preset { let label = format!(
CaptureSizePreset::Source => format!( "{} • {}x{} @ {} fps (Device H.264)",
"{} • {}x{} (Pass-through)", option.preset.label(),
option.preset.label(), option.width,
option.width, option.height,
option.height, option.fps,
), );
_ => format!(
"{} • {}x{} (Re-encode)",
option.preset.label(),
option.width,
option.height,
),
};
combo.append(Some(option.preset.as_id()), &label); combo.append(Some(option.preset.as_id()), &label);
} }
combo.set_active_id(Some(selected.as_id())); 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( pub fn sync_capture_fps_combo(
@ -888,7 +938,7 @@ pub fn sync_capture_fps_combo(
) { ) {
combo.remove_all(); combo.remove_all();
if locked_to_source { 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_active_id(Some("source"));
combo.set_sensitive(false); combo.set_sensitive(false);
return; return;
@ -903,6 +953,13 @@ pub fn sync_capture_fps_combo(
combo.set_sensitive(true); 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( pub fn sync_capture_bitrate_combo(
combo: &gtk::ComboBoxText, combo: &gtk::ComboBoxText,
options: Vec<CaptureBitrateChoice>, options: Vec<CaptureBitrateChoice>,
@ -914,7 +971,7 @@ pub fn sync_capture_bitrate_combo(
if locked_to_source { if locked_to_source {
combo.append( combo.append(
Some("source"), 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_active_id(Some("source"));
combo.set_sensitive(false); combo.set_sensitive(false);
@ -930,6 +987,13 @@ pub fn sync_capture_bitrate_combo(
combo.set_sensitive(true); 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( pub fn sync_breakout_size_combo(
combo: &gtk::ComboBoxText, combo: &gtk::ComboBoxText,
options: Vec<BreakoutSizeChoice>, 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_single_line_mode(true);
stream_status.set_max_width_chars(24); stream_status.set_max_width_chars(24);
stream_status.set_tooltip_text(Some("Connect relay to preview.")); 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(); let capture_resolution_combo = gtk::ComboBoxText::new();
capture_resolution_combo.set_tooltip_text(Some( 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); capture_resolution_combo.set_size_request(240, -1);
let capture_fps_combo = gtk::ComboBoxText::new(); let capture_fps_combo = gtk::ComboBoxText::new();
capture_fps_combo.set_tooltip_text(Some( 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); capture_fps_combo.set_size_request(120, -1);
let capture_bitrate_combo = gtk::ComboBoxText::new(); let capture_bitrate_combo = gtk::ComboBoxText::new();
capture_bitrate_combo.set_tooltip_text(Some( 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); capture_bitrate_combo.set_size_request(170, -1);
let breakout_combo = gtk::ComboBoxText::new(); 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); stabilize_button(&action_button, 104);
action_button.set_halign(gtk::Align::End); action_button.set_halign(gtk::Align::End);
let capture_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); 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_resolution_combo);
capture_row.append(&capture_fps_combo); capture_row.append(&capture_fps_combo);
capture_row.append(&capture_bitrate_combo); capture_row.append(&capture_bitrate_combo);
@ -1146,6 +1216,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
picture, picture,
stream_status, stream_status,
placeholder, placeholder,
feed_source_combo,
capture_resolution_combo, capture_resolution_combo,
capture_fps_combo, capture_fps_combo,
capture_bitrate_combo, capture_bitrate_combo,

View File

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

View File

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

View File

@ -17,6 +17,6 @@ mod tests {
#[test] #[test]
fn banner_includes_version() { 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 // common/src/lib.rs
pub mod cli; pub mod cli;
pub mod eye_source;
pub mod hid; pub mod hid;
pub mod paste; pub mod paste;
pub mod process_metrics; 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] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.11.11" version = "0.11.12"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

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

View File

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

View File

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

View File

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