lesavka: pivot eye streams to native source modes
This commit is contained in:
parent
377cda1309
commit
25da3137aa
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.11"
|
||||
version = "0.11.12"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -425,7 +425,7 @@ impl LesavkaClientApp {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
};
|
||||
match cli.capture_video(Request::new(req)).await {
|
||||
Ok(mut stream) => {
|
||||
@ -469,7 +469,7 @@ impl LesavkaClientApp {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
};
|
||||
match cli.capture_audio(Request::new(req)).await {
|
||||
Ok(mut stream) => {
|
||||
|
||||
128
client/src/bin/lesavka-relayctl.rs
Normal file
128
client/src/bin/lesavka-relayctl.rs
Normal 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(())
|
||||
}
|
||||
@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::state::{InputRouting, LauncherState, ViewMode};
|
||||
use super::state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PerformanceSample {
|
||||
@ -28,6 +28,7 @@ pub struct PerformanceSample {
|
||||
pub left_decoder_label: String,
|
||||
pub left_stream_caps_label: String,
|
||||
pub left_decoded_caps_label: String,
|
||||
pub left_rendered_caps_label: String,
|
||||
pub right_receive_fps: f32,
|
||||
pub right_present_fps: f32,
|
||||
pub right_server_fps: f32,
|
||||
@ -43,6 +44,7 @@ pub struct PerformanceSample {
|
||||
pub right_decoder_label: String,
|
||||
pub right_stream_caps_label: String,
|
||||
pub right_decoded_caps_label: String,
|
||||
pub right_rendered_caps_label: String,
|
||||
pub dropped_frames: u64,
|
||||
pub queue_depth: u32,
|
||||
}
|
||||
@ -100,6 +102,7 @@ pub struct SnapshotReport {
|
||||
pub preview_source: String,
|
||||
pub client_display_limit: String,
|
||||
pub left_surface: String,
|
||||
pub left_feed_source: String,
|
||||
pub left_capture_profile: String,
|
||||
pub left_capture_transport: String,
|
||||
pub left_breakout_profile: String,
|
||||
@ -115,7 +118,9 @@ pub struct SnapshotReport {
|
||||
pub left_server_encoder_label: String,
|
||||
pub left_stream_caps_label: String,
|
||||
pub left_decoded_caps_label: String,
|
||||
pub left_rendered_caps_label: String,
|
||||
pub right_surface: String,
|
||||
pub right_feed_source: String,
|
||||
pub right_capture_profile: String,
|
||||
pub right_capture_transport: String,
|
||||
pub right_breakout_profile: String,
|
||||
@ -131,6 +136,7 @@ pub struct SnapshotReport {
|
||||
pub right_server_encoder_label: String,
|
||||
pub right_stream_caps_label: String,
|
||||
pub right_decoded_caps_label: String,
|
||||
pub right_rendered_caps_label: String,
|
||||
pub selected_camera: Option<String>,
|
||||
pub selected_microphone: Option<String>,
|
||||
pub selected_speaker: Option<String>,
|
||||
@ -150,6 +156,12 @@ impl SnapshotReport {
|
||||
let left_breakout = state.breakout_size_choice(0);
|
||||
let right_breakout = state.breakout_size_choice(1);
|
||||
let latest = log.latest();
|
||||
let left_stream_caps = latest
|
||||
.map(|sample| sample.left_stream_caps_label.clone())
|
||||
.unwrap_or_default();
|
||||
let right_stream_caps = latest
|
||||
.map(|sample| sample.right_stream_caps_label.clone())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
client_version: crate::VERSION.to_string(),
|
||||
server_version: state.server_version.clone(),
|
||||
@ -178,14 +190,12 @@ impl SnapshotReport {
|
||||
state.breakout_display.width, state.breakout_display.height
|
||||
),
|
||||
left_surface: state.display_surface(0).label().to_string(),
|
||||
left_capture_profile: format!(
|
||||
"{} | {}x{} | {} fps | {} kbit",
|
||||
left_capture.preset.label(),
|
||||
left_capture.width,
|
||||
left_capture.height,
|
||||
left_capture.fps,
|
||||
left_capture.max_bitrate_kbit
|
||||
),
|
||||
left_feed_source: match state.feed_source_preset(0) {
|
||||
super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(),
|
||||
super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(),
|
||||
super::state::FeedSourcePreset::Off => "Off".to_string(),
|
||||
},
|
||||
left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps),
|
||||
left_capture_transport: left_capture.preset.transport_label().to_string(),
|
||||
left_breakout_profile: format!(
|
||||
"{} | {}x{}",
|
||||
@ -249,15 +259,22 @@ impl SnapshotReport {
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "pending".to_string()),
|
||||
left_rendered_caps_label: latest
|
||||
.map(|sample| {
|
||||
if sample.left_rendered_caps_label.is_empty() {
|
||||
"pending".to_string()
|
||||
} else {
|
||||
sample.left_rendered_caps_label.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "pending".to_string()),
|
||||
right_surface: state.display_surface(1).label().to_string(),
|
||||
right_capture_profile: format!(
|
||||
"{} | {}x{} | {} fps | {} kbit",
|
||||
right_capture.preset.label(),
|
||||
right_capture.width,
|
||||
right_capture.height,
|
||||
right_capture.fps,
|
||||
right_capture.max_bitrate_kbit
|
||||
),
|
||||
right_feed_source: match state.feed_source_preset(1) {
|
||||
super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(),
|
||||
super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(),
|
||||
super::state::FeedSourcePreset::Off => "Off".to_string(),
|
||||
},
|
||||
right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps),
|
||||
right_capture_transport: right_capture.preset.transport_label().to_string(),
|
||||
right_breakout_profile: format!(
|
||||
"{} | {}x{}",
|
||||
@ -321,6 +338,15 @@ impl SnapshotReport {
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "pending".to_string()),
|
||||
right_rendered_caps_label: latest
|
||||
.map(|sample| {
|
||||
if sample.right_rendered_caps_label.is_empty() {
|
||||
"pending".to_string()
|
||||
} else {
|
||||
sample.right_rendered_caps_label.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "pending".to_string()),
|
||||
selected_camera: state.devices.camera.clone(),
|
||||
selected_microphone: state.devices.microphone.clone(),
|
||||
selected_speaker: state.devices.speaker.clone(),
|
||||
@ -364,6 +390,7 @@ impl SnapshotReport {
|
||||
let _ = writeln!(text);
|
||||
let _ = writeln!(text, "left eye");
|
||||
let _ = writeln!(text, " surface: {}", self.left_surface);
|
||||
let _ = writeln!(text, " source: {}", self.left_feed_source);
|
||||
let _ = writeln!(text, " capture: {}", self.left_capture_profile);
|
||||
let _ = writeln!(text, " transport: {}", self.left_capture_transport);
|
||||
let _ = writeln!(text, " breakout: {}", self.left_breakout_profile);
|
||||
@ -379,6 +406,7 @@ impl SnapshotReport {
|
||||
);
|
||||
let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label);
|
||||
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
|
||||
let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
|
||||
@ -390,6 +418,7 @@ impl SnapshotReport {
|
||||
);
|
||||
let _ = writeln!(text, "right eye");
|
||||
let _ = writeln!(text, " surface: {}", self.right_surface);
|
||||
let _ = writeln!(text, " source: {}", self.right_feed_source);
|
||||
let _ = writeln!(text, " capture: {}", self.right_capture_profile);
|
||||
let _ = writeln!(text, " transport: {}", self.right_capture_transport);
|
||||
let _ = writeln!(text, " breakout: {}", self.right_breakout_profile);
|
||||
@ -405,6 +434,7 @@ impl SnapshotReport {
|
||||
);
|
||||
let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label);
|
||||
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
|
||||
let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
|
||||
@ -510,6 +540,50 @@ pub fn quality_probe_command() -> &'static str {
|
||||
"scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh"
|
||||
}
|
||||
|
||||
fn capture_profile_label(capture: &CaptureSizeChoice, stream_caps_label: &str) -> String {
|
||||
if let Some((width, height, fps)) = parse_stream_caps_profile(stream_caps_label) {
|
||||
return format!(
|
||||
"{} | observed {}x{} @ {} fps | bitrate est ~{} kbit",
|
||||
capture.preset.label(),
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
capture.max_bitrate_kbit
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"{} | {}x{} | {} fps | bitrate est ~{} kbit",
|
||||
capture.preset.label(),
|
||||
capture.width,
|
||||
capture.height,
|
||||
capture.fps,
|
||||
capture.max_bitrate_kbit
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_stream_caps_profile(caps: &str) -> Option<(u32, u32, u32)> {
|
||||
let width = parse_caps_u32(caps, "width=(int)")?;
|
||||
let height = parse_caps_u32(caps, "height=(int)")?;
|
||||
let fps = parse_caps_fraction_numerator(caps, "framerate=(fraction)")?;
|
||||
Some((width, height, fps))
|
||||
}
|
||||
|
||||
fn parse_caps_u32(caps: &str, needle: &str) -> Option<u32> {
|
||||
let start = caps.find(needle)? + needle.len();
|
||||
let tail = &caps[start..];
|
||||
let end = tail.find([',', ';']).unwrap_or(tail.len());
|
||||
tail[..end].trim().parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option<u32> {
|
||||
let start = caps.find(needle)? + needle.len();
|
||||
let tail = &caps[start..];
|
||||
let end = tail.find([',', ';']).unwrap_or(tail.len());
|
||||
let value = tail[..end].trim();
|
||||
let numerator = value.split('/').next()?;
|
||||
numerator.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<String> {
|
||||
let mut items = Vec::new();
|
||||
if !state.server_available {
|
||||
@ -533,7 +607,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|
||||
}
|
||||
if sample.video_loss_pct >= 2.0 || sample.dropped_frames > 0 {
|
||||
items.push(
|
||||
"Video packets are arriving with gaps or server-side drops. Try 900p or 720p capture first, then watch whether dropped frames and video-loss fall."
|
||||
"Video packets are arriving with gaps or server-side drops. Stay on device H.264 pass-through for now and reduce concurrent load before trying more invasive changes."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@ -595,32 +669,18 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|
||||
}
|
||||
if sample.server_process_cpu_pct >= 85.0 {
|
||||
items.push(
|
||||
"Server process CPU is high. That makes re-encode stalls more likely, so compare Source against lighter re-encode profiles before assuming the WAN is the bottleneck."
|
||||
"Server process CPU is high. On current hardware that is a strong reason to stay on device H.264 pass-through and avoid any server-side eye transcoding."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let heavy_capture = state.capture_sizes.iter().any(|preset| {
|
||||
matches!(
|
||||
preset,
|
||||
super::state::CaptureSizePreset::Source
|
||||
| super::state::CaptureSizePreset::P1080
|
||||
| super::state::CaptureSizePreset::P1440
|
||||
)
|
||||
});
|
||||
if heavy_capture {
|
||||
items.push(
|
||||
"If motion artifacting spikes, try a 900p or 720p capture profile before shrinking the breakout window; that usually lowers WAN pressure faster."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let source_passthrough = state
|
||||
.capture_sizes
|
||||
.feed_sources
|
||||
.iter()
|
||||
.any(|preset| matches!(preset, super::state::CaptureSizePreset::Source));
|
||||
.any(|preset| !matches!(preset, FeedSourcePreset::Off));
|
||||
if source_passthrough {
|
||||
items.push(
|
||||
"Source capture uses the HDMI device's own H.264 stream. If motion damage lingers for seconds, switch that eye to 1080p, 900p, or 720p so the server re-encodes with a tighter keyframe cadence."
|
||||
"Device H.264 pass-through is active. If we need it cheaper, use the eye device's real 1080p/720p/576p/480p/VGA source modes before considering codec-aware thinning."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@ -631,7 +691,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|
||||
|| (sample.right_server_fps - sample.right_receive_fps) > 6.0)
|
||||
{
|
||||
items.push(
|
||||
"Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss, so compare Source against 1080p/900p and watch which side stays steadier."
|
||||
"Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@ -662,7 +722,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|
||||
|| sample.right_server_encoder_label.contains("x264"))
|
||||
{
|
||||
items.push(
|
||||
"The server is leaning on `x264enc` while process CPU is already elevated. That makes `Source` or lighter re-encode settings more attractive than pushing the bitrate ceiling upward."
|
||||
"At least one eye is still leaning on `x264enc`. That is now unexpected on the source-first path, so treat it as a bug or stale install rather than a normal operating mode."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@ -681,7 +741,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::launcher::state::{DeviceSelection, LauncherState};
|
||||
use crate::launcher::state::{CaptureSizePreset, DeviceSelection, LauncherState};
|
||||
|
||||
fn sample(n: u64) -> PerformanceSample {
|
||||
PerformanceSample {
|
||||
@ -705,9 +765,13 @@ mod tests {
|
||||
left_server_queue_peak: n as u32 + 1,
|
||||
left_server_encoder_label: "x264enc".to_string(),
|
||||
left_decoder_label: "decodebin".to_string(),
|
||||
left_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(),
|
||||
left_stream_caps_label:
|
||||
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1"
|
||||
.to_string(),
|
||||
left_decoded_caps_label:
|
||||
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
|
||||
left_rendered_caps_label:
|
||||
"video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(),
|
||||
right_receive_fps: 30.0,
|
||||
right_present_fps: 28.0,
|
||||
right_server_fps: 30.0,
|
||||
@ -721,9 +785,13 @@ mod tests {
|
||||
right_server_queue_peak: n as u32 + 1,
|
||||
right_server_encoder_label: "source-pass-through".to_string(),
|
||||
right_decoder_label: "decodebin".to_string(),
|
||||
right_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(),
|
||||
right_stream_caps_label:
|
||||
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1"
|
||||
.to_string(),
|
||||
right_decoded_caps_label:
|
||||
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
|
||||
right_rendered_caps_label:
|
||||
"video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(),
|
||||
dropped_frames: n,
|
||||
queue_depth: n as u32,
|
||||
}
|
||||
@ -781,11 +849,17 @@ mod tests {
|
||||
assert_eq!(report.notes, vec!["first note".to_string()]);
|
||||
assert!(report.status.contains("mode=remote"));
|
||||
assert!(report.client_version.starts_with("0."));
|
||||
assert!(report.left_capture_profile.contains("fps"));
|
||||
assert_eq!(report.left_capture_transport, "server re-encode");
|
||||
assert_eq!(report.left_feed_source, "Left Eye");
|
||||
assert!(
|
||||
report
|
||||
.left_capture_profile
|
||||
.contains("observed 1920x1080 @ 60 fps")
|
||||
);
|
||||
assert_eq!(report.left_capture_transport, "device H.264 pass-through");
|
||||
assert_eq!(report.left_decoder_label, "decodebin");
|
||||
assert!(report.left_stream_caps_label.contains("video/x-h264"));
|
||||
assert!(report.left_decoded_caps_label.contains("video/x-raw"));
|
||||
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -812,10 +886,12 @@ mod tests {
|
||||
assert!(text.contains("Lesavka Diagnostics"));
|
||||
assert!(text.contains("client: v"));
|
||||
assert!(text.contains("left eye"));
|
||||
assert!(text.contains("source:"));
|
||||
assert!(text.contains("transport:"));
|
||||
assert!(text.contains("live: decoder="));
|
||||
assert!(text.contains("stream caps:"));
|
||||
assert!(text.contains("decoded caps:"));
|
||||
assert!(text.contains("rendered caps:"));
|
||||
assert!(text.contains("recommendations"));
|
||||
}
|
||||
|
||||
@ -825,4 +901,39 @@ mod tests {
|
||||
assert!(cmd.contains("hygiene_gate.sh"));
|
||||
assert!(cmd.contains("quality_gate.sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_capture_profile_prefers_observed_stream_caps_when_available() {
|
||||
let capture = CaptureSizeChoice {
|
||||
preset: CaptureSizePreset::P1080,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
max_bitrate_kbit: 18_000,
|
||||
};
|
||||
let label = capture_profile_label(
|
||||
&capture,
|
||||
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1",
|
||||
);
|
||||
assert_eq!(
|
||||
label,
|
||||
"1080p | observed 1920x1080 @ 60 fps | bitrate est ~18000 kbit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_profile_falls_back_when_stream_caps_are_incomplete() {
|
||||
let capture = CaptureSizeChoice {
|
||||
preset: CaptureSizePreset::P1080,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
max_bitrate_kbit: 18_000,
|
||||
};
|
||||
let label = capture_profile_label(&capture, "video/x-h264, width=(int)1920");
|
||||
assert_eq!(
|
||||
label,
|
||||
"1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ use gstreamer_app as gst_app;
|
||||
use gtk::prelude::WidgetExt;
|
||||
#[cfg(not(coverage))]
|
||||
use gtk::{gdk, glib};
|
||||
#[cfg(not(coverage))]
|
||||
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
|
||||
#[cfg(not(coverage))]
|
||||
use std::collections::VecDeque;
|
||||
@ -32,18 +31,22 @@ const PREVIEW_WIDTH: i32 = 960;
|
||||
#[cfg(not(coverage))]
|
||||
const PREVIEW_HEIGHT: i32 = 540;
|
||||
#[cfg(not(coverage))]
|
||||
const INLINE_PREVIEW_REQUEST_WIDTH: i32 = 960;
|
||||
const INLINE_PREVIEW_REQUEST_WIDTH: i32 = DEFAULT_EYE_SOURCE_WIDTH;
|
||||
#[cfg(not(coverage))]
|
||||
const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = 540;
|
||||
const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = DEFAULT_EYE_SOURCE_HEIGHT;
|
||||
#[cfg(not(coverage))]
|
||||
const INLINE_PREVIEW_REQUEST_FPS: u32 = 24;
|
||||
const INLINE_PREVIEW_REQUEST_FPS: u32 = DEFAULT_EYE_SOURCE_FPS;
|
||||
#[cfg(not(coverage))]
|
||||
const INLINE_PREVIEW_MAX_KBIT: u32 = 4_000;
|
||||
const INLINE_PREVIEW_MAX_KBIT: u32 = DEFAULT_EYE_SOURCE_MAX_KBIT;
|
||||
#[cfg(not(coverage))]
|
||||
const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920;
|
||||
#[cfg(not(coverage))]
|
||||
const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080;
|
||||
#[cfg(not(coverage))]
|
||||
const DEFAULT_EYE_SOURCE_FPS: u32 = 60;
|
||||
#[cfg(not(coverage))]
|
||||
const DEFAULT_EYE_SOURCE_MAX_KBIT: u32 = 18_000;
|
||||
#[cfg(not(coverage))]
|
||||
const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview.";
|
||||
#[cfg(not(coverage))]
|
||||
const TELEMETRY_WINDOW: Duration = Duration::from_secs(5);
|
||||
@ -92,18 +95,19 @@ pub struct PreviewMetricsSnapshot {
|
||||
pub decoder_label: String,
|
||||
pub stream_caps_label: String,
|
||||
pub decoded_caps_label: String,
|
||||
pub rendered_caps_label: String,
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PreviewProfile {
|
||||
source_monitor_id: u32,
|
||||
display_width: i32,
|
||||
display_height: i32,
|
||||
requested_width: i32,
|
||||
requested_height: i32,
|
||||
requested_fps: u32,
|
||||
max_bitrate_kbit: u32,
|
||||
prefer_reencode: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -111,6 +115,7 @@ impl PreviewSurface {
|
||||
fn profile(self) -> PreviewProfile {
|
||||
match self {
|
||||
Self::Inline => PreviewProfile {
|
||||
source_monitor_id: 0,
|
||||
display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH),
|
||||
display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT),
|
||||
requested_width: preview_dimension(
|
||||
@ -129,9 +134,9 @@ impl PreviewSurface {
|
||||
"LESAVKA_PREVIEW_MAX_KBIT",
|
||||
INLINE_PREVIEW_MAX_KBIT,
|
||||
),
|
||||
prefer_reencode: true,
|
||||
},
|
||||
Self::Window => PreviewProfile {
|
||||
source_monitor_id: 0,
|
||||
display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280),
|
||||
display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720),
|
||||
requested_width: preview_dimension(
|
||||
@ -142,9 +147,14 @@ impl PreviewSurface {
|
||||
"LESAVKA_BREAKOUT_REQUEST_HEIGHT",
|
||||
DEFAULT_EYE_SOURCE_HEIGHT,
|
||||
),
|
||||
requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30),
|
||||
max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000),
|
||||
prefer_reencode: true,
|
||||
requested_fps: preview_bitrate(
|
||||
"LESAVKA_BREAKOUT_REQUEST_FPS",
|
||||
DEFAULT_EYE_SOURCE_FPS,
|
||||
),
|
||||
max_bitrate_kbit: preview_bitrate(
|
||||
"LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT",
|
||||
DEFAULT_EYE_SOURCE_MAX_KBIT,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -277,34 +287,32 @@ impl LauncherPreview {
|
||||
pub fn set_capture_profile(
|
||||
&self,
|
||||
monitor_id: usize,
|
||||
source_monitor_id: usize,
|
||||
requested_width: i32,
|
||||
requested_height: i32,
|
||||
requested_fps: u32,
|
||||
max_bitrate_kbit: u32,
|
||||
prefer_reencode: bool,
|
||||
) {
|
||||
let (
|
||||
inline_requested_width,
|
||||
inline_requested_height,
|
||||
inline_requested_fps,
|
||||
inline_max_bitrate_kbit,
|
||||
inline_prefer_reencode,
|
||||
) = adapt_inline_preview_request(
|
||||
) = sanitize_preview_request(
|
||||
requested_width,
|
||||
requested_height,
|
||||
requested_fps,
|
||||
max_bitrate_kbit,
|
||||
prefer_reencode,
|
||||
);
|
||||
self.rebuild_feed(
|
||||
&self.inline_feeds,
|
||||
monitor_id,
|
||||
Some((
|
||||
source_monitor_id,
|
||||
inline_requested_width,
|
||||
inline_requested_height,
|
||||
inline_requested_fps,
|
||||
inline_max_bitrate_kbit,
|
||||
inline_prefer_reencode,
|
||||
)),
|
||||
None,
|
||||
);
|
||||
@ -312,11 +320,11 @@ impl LauncherPreview {
|
||||
&self.window_feeds,
|
||||
monitor_id,
|
||||
Some((
|
||||
source_monitor_id,
|
||||
requested_width,
|
||||
requested_height,
|
||||
requested_fps,
|
||||
max_bitrate_kbit,
|
||||
prefer_reencode,
|
||||
)),
|
||||
None,
|
||||
);
|
||||
@ -331,23 +339,36 @@ impl LauncherPreview {
|
||||
&self,
|
||||
monitor_id: usize,
|
||||
surface: PreviewSurface,
|
||||
) -> Option<(i32, i32, i32, i32, u32, u32, bool)> {
|
||||
) -> Option<(u32, i32, i32, i32, i32, u32, u32)> {
|
||||
let feed = match surface {
|
||||
PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(),
|
||||
PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(),
|
||||
}?;
|
||||
let profile = feed.profile();
|
||||
Some((
|
||||
profile.source_monitor_id,
|
||||
profile.display_width,
|
||||
profile.display_height,
|
||||
profile.requested_width,
|
||||
profile.requested_height,
|
||||
profile.requested_fps,
|
||||
profile.max_bitrate_kbit,
|
||||
profile.prefer_reencode,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn feed_disabled_for_test(
|
||||
&self,
|
||||
monitor_id: usize,
|
||||
surface: PreviewSurface,
|
||||
) -> Option<bool> {
|
||||
let feed = match surface {
|
||||
PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(),
|
||||
PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(),
|
||||
}?;
|
||||
Some(feed.is_disabled())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) {
|
||||
let feed = match surface {
|
||||
@ -372,7 +393,7 @@ impl LauncherPreview {
|
||||
&self,
|
||||
feeds: &Arc<Mutex<[PreviewFeed; 2]>>,
|
||||
monitor_id: usize,
|
||||
requested: Option<(i32, i32, u32, u32, bool)>,
|
||||
requested: Option<(usize, i32, i32, u32, u32)>,
|
||||
display: Option<(i32, i32)>,
|
||||
) {
|
||||
let Ok(mut feeds) = feeds.lock() else {
|
||||
@ -382,43 +403,95 @@ impl LauncherPreview {
|
||||
return;
|
||||
};
|
||||
let was_active = existing.is_active();
|
||||
let keep_disabled = existing.is_disabled();
|
||||
let mut profile = existing.profile();
|
||||
if let Some((
|
||||
source_monitor_id,
|
||||
requested_width,
|
||||
requested_height,
|
||||
requested_fps,
|
||||
max_bitrate_kbit,
|
||||
prefer_reencode,
|
||||
)) = requested
|
||||
{
|
||||
profile.source_monitor_id = source_monitor_id as u32;
|
||||
profile.requested_width = requested_width.max(2);
|
||||
profile.requested_height = requested_height.max(2);
|
||||
profile.requested_fps = requested_fps.max(1);
|
||||
profile.max_bitrate_kbit = max_bitrate_kbit.max(800);
|
||||
profile.prefer_reencode = prefer_reencode;
|
||||
}
|
||||
if let Some((display_width, display_height)) = display {
|
||||
profile.display_width = display_width.max(2);
|
||||
profile.display_height = display_height.max(2);
|
||||
}
|
||||
match PreviewFeed::spawn(
|
||||
Arc::clone(&self.server_addr),
|
||||
monitor_id as u32,
|
||||
profile,
|
||||
Arc::clone(&self.log_sink),
|
||||
) {
|
||||
Ok(feed) => {
|
||||
if was_active {
|
||||
feed.set_active(true);
|
||||
let next_feed = if keep_disabled {
|
||||
Some(PreviewFeed::spawn_disabled(profile))
|
||||
} else {
|
||||
match PreviewFeed::spawn(
|
||||
Arc::clone(&self.server_addr),
|
||||
monitor_id as u32,
|
||||
profile,
|
||||
Arc::clone(&self.log_sink),
|
||||
) {
|
||||
Ok(feed) => Some(feed),
|
||||
Err(err) => {
|
||||
warn!(monitor_id, ?err, "could not rebuild preview feed");
|
||||
None
|
||||
}
|
||||
existing.shutdown();
|
||||
feeds[monitor_id] = feed;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(monitor_id, ?err, "could not rebuild preview feed");
|
||||
};
|
||||
if let Some(feed) = next_feed {
|
||||
if was_active {
|
||||
feed.set_active(true);
|
||||
}
|
||||
existing.shutdown();
|
||||
feeds[monitor_id] = feed;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) {
|
||||
self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled);
|
||||
self.set_feed_enabled(&self.window_feeds, monitor_id, enabled);
|
||||
}
|
||||
|
||||
fn set_feed_enabled(
|
||||
&self,
|
||||
feeds: &Arc<Mutex<[PreviewFeed; 2]>>,
|
||||
monitor_id: usize,
|
||||
enabled: bool,
|
||||
) {
|
||||
let Ok(mut feeds) = feeds.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(existing) = feeds.get(monitor_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
if existing.is_disabled() == !enabled {
|
||||
return;
|
||||
}
|
||||
let was_active = existing.is_active();
|
||||
let profile = existing.profile();
|
||||
let replacement = if enabled {
|
||||
match PreviewFeed::spawn(
|
||||
Arc::clone(&self.server_addr),
|
||||
monitor_id as u32,
|
||||
profile,
|
||||
Arc::clone(&self.log_sink),
|
||||
) {
|
||||
Ok(feed) => feed,
|
||||
Err(err) => {
|
||||
warn!(monitor_id, ?err, "could not enable preview feed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PreviewFeed::spawn_disabled(profile)
|
||||
};
|
||||
if was_active {
|
||||
replacement.set_active(true);
|
||||
}
|
||||
existing.shutdown();
|
||||
feeds[monitor_id] = replacement;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -463,6 +536,7 @@ struct PreviewFeed {
|
||||
active_bindings: Arc<AtomicUsize>,
|
||||
running: Arc<AtomicBool>,
|
||||
profile: PreviewProfile,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -542,6 +616,7 @@ struct PreviewTelemetry {
|
||||
decoder_label: String,
|
||||
stream_caps_label: String,
|
||||
decoded_caps_label: String,
|
||||
rendered_caps_label: String,
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -655,6 +730,12 @@ impl PreviewTelemetry {
|
||||
}
|
||||
}
|
||||
|
||||
fn note_rendered_caps(&mut self, caps_label: &str) {
|
||||
if !caps_label.is_empty() {
|
||||
self.rendered_caps_label = caps_label.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&mut self) -> PreviewMetricsSnapshot {
|
||||
self.snapshot_at(Instant::now())
|
||||
}
|
||||
@ -700,6 +781,7 @@ impl PreviewTelemetry {
|
||||
decoder_label: self.decoder_label.clone(),
|
||||
stream_caps_label: self.stream_caps_label.clone(),
|
||||
decoded_caps_label: self.decoded_caps_label.clone(),
|
||||
rendered_caps_label: self.rendered_caps_label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -750,27 +832,54 @@ impl PreviewFeed {
|
||||
active_bindings,
|
||||
running,
|
||||
profile,
|
||||
disabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_disabled(profile: PreviewProfile) -> Self {
|
||||
let shared = Arc::new(Mutex::new(SharedPreviewState::new()));
|
||||
if let Ok(mut slot) = shared.lock() {
|
||||
slot.set_status("Feed disabled.", true);
|
||||
}
|
||||
Self {
|
||||
shared,
|
||||
session_active: Arc::new(AtomicBool::new(false)),
|
||||
active_bindings: Arc::new(AtomicUsize::new(0)),
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
profile,
|
||||
disabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(&self) -> PreviewProfile {
|
||||
self.profile
|
||||
}
|
||||
|
||||
fn is_disabled(&self) -> bool {
|
||||
self.disabled
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.session_active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn set_active(&self, active: bool) {
|
||||
self.session_active.store(active, Ordering::Relaxed);
|
||||
if !active {
|
||||
if !active && !self.disabled {
|
||||
self.replace_status(PREVIEW_IDLE_STATUS, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
self.replace_status(PREVIEW_IDLE_STATUS, true);
|
||||
self.replace_status(
|
||||
if self.disabled {
|
||||
"Feed disabled."
|
||||
} else {
|
||||
PREVIEW_IDLE_STATUS
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn replace_status(&self, status: impl Into<String>, clear_picture: bool) {
|
||||
@ -907,6 +1016,14 @@ fn run_preview_feed(
|
||||
if let Some(decoder) = decoder.as_ref() {
|
||||
record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded);
|
||||
}
|
||||
if let Some(caps) = sample.caps() {
|
||||
let caps_label = preview_caps_summary(&caps);
|
||||
if !caps_label.is_empty()
|
||||
&& let Ok(mut slot) = shared.lock()
|
||||
{
|
||||
slot.telemetry.note_rendered_caps(&caps_label);
|
||||
}
|
||||
}
|
||||
if let Some(frame) = sample_to_frame(&sample) {
|
||||
if let Ok(mut slot) = shared.lock() {
|
||||
slot.push_frame(frame);
|
||||
@ -1022,7 +1139,7 @@ fn run_preview_feed(
|
||||
requested_width: profile.requested_width.max(0) as u32,
|
||||
requested_height: profile.requested_height.max(0) as u32,
|
||||
requested_fps: profile.requested_fps,
|
||||
prefer_reencode: profile.prefer_reencode,
|
||||
source_id: Some(profile.source_monitor_id),
|
||||
};
|
||||
match cli.capture_video(Request::new(req)).await {
|
||||
Ok(mut stream) => {
|
||||
@ -1289,16 +1406,15 @@ fn looks_like_preview_problem(status: &str) -> bool {
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn build_preview_pipeline(
|
||||
profile: PreviewProfile,
|
||||
_profile: PreviewProfile,
|
||||
) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> {
|
||||
let decoder_name = pick_h264_decoder();
|
||||
let desc = format!(
|
||||
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
|
||||
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
||||
h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! videoscale ! \
|
||||
video/x-raw,format=RGBA,width={},height={},pixel-aspect-ratio=1/1 ! \
|
||||
h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! \
|
||||
video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \
|
||||
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
|
||||
profile.display_width, profile.display_height
|
||||
);
|
||||
let pipeline = gst::parse::launch(&desc)?
|
||||
.downcast::<gst::Pipeline>()
|
||||
@ -1325,8 +1441,7 @@ fn build_preview_pipeline(
|
||||
appsink.set_caps(Some(
|
||||
&gst::Caps::builder("video/x-raw")
|
||||
.field("format", &"RGBA")
|
||||
.field("width", &profile.display_width)
|
||||
.field("height", &profile.display_height)
|
||||
.field("pixel-aspect-ratio", &gst::Fraction::new(1, 1))
|
||||
.build(),
|
||||
));
|
||||
|
||||
@ -1375,10 +1490,8 @@ fn record_preview_caps(
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn preview_caps_summary(caps: &gst::Caps) -> String {
|
||||
caps.structure(0)
|
||||
.map(|structure| structure.to_string())
|
||||
.unwrap_or_else(|| caps.to_string())
|
||||
fn preview_caps_summary(caps: &impl std::fmt::Display) -> String {
|
||||
caps.to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -1444,39 +1557,17 @@ fn preview_dimension(var: &str, default: i32) -> i32 {
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn adapt_inline_preview_request(
|
||||
fn sanitize_preview_request(
|
||||
requested_width: i32,
|
||||
requested_height: i32,
|
||||
requested_fps: u32,
|
||||
max_bitrate_kbit: u32,
|
||||
prefer_reencode: bool,
|
||||
) -> (i32, i32, u32, u32, bool) {
|
||||
let inline_width = requested_width.max(2);
|
||||
let inline_height = requested_height.max(2);
|
||||
if !prefer_reencode {
|
||||
return (
|
||||
inline_width,
|
||||
inline_height,
|
||||
requested_fps.max(1),
|
||||
max_bitrate_kbit.max(800),
|
||||
false,
|
||||
);
|
||||
}
|
||||
let inline_fps = requested_fps.max(1).min(preview_bitrate(
|
||||
"LESAVKA_PREVIEW_REQUEST_FPS",
|
||||
INLINE_PREVIEW_REQUEST_FPS,
|
||||
));
|
||||
let inline_bitrate = max_bitrate_kbit.max(800).min(preview_bitrate(
|
||||
"LESAVKA_PREVIEW_MAX_KBIT",
|
||||
INLINE_PREVIEW_MAX_KBIT,
|
||||
));
|
||||
let adapted = inline_fps != requested_fps.max(1) || inline_bitrate != max_bitrate_kbit.max(800);
|
||||
) -> (i32, i32, u32, u32) {
|
||||
(
|
||||
inline_width,
|
||||
inline_height,
|
||||
inline_fps,
|
||||
inline_bitrate,
|
||||
prefer_reencode || adapted,
|
||||
requested_width.max(2),
|
||||
requested_height.max(2),
|
||||
requested_fps.max(1),
|
||||
max_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
|
||||
@ -1539,7 +1630,7 @@ mod tests {
|
||||
DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT,
|
||||
INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH,
|
||||
LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry,
|
||||
adapt_inline_preview_request,
|
||||
sanitize_preview_request,
|
||||
};
|
||||
use crate::launcher::state::{CaptureSizePreset, LauncherState};
|
||||
use futures::stream;
|
||||
@ -1691,20 +1782,20 @@ mod tests {
|
||||
assert_eq!(profile.display_height, 720);
|
||||
assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH);
|
||||
assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT);
|
||||
assert_eq!(profile.requested_fps, 30);
|
||||
assert_eq!(profile.max_bitrate_kbit, 12_000);
|
||||
assert_eq!(profile.requested_fps, 60);
|
||||
assert_eq!(profile.max_bitrate_kbit, 18_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_preview_request_caps_large_full_quality_streams() {
|
||||
let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, true);
|
||||
assert_eq!(adapted, (1920, 1080, 24, 4_000, true));
|
||||
fn preview_request_sanitizer_keeps_requested_source_geometry() {
|
||||
let adapted = sanitize_preview_request(1920, 1080, 60, 18_000);
|
||||
assert_eq!(adapted, (1920, 1080, 60, 18_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_preview_request_keeps_source_stream_honest() {
|
||||
let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, false);
|
||||
assert_eq!(adapted, (1920, 1080, 30, 12_000, false));
|
||||
fn preview_request_sanitizer_clamps_invalid_values() {
|
||||
let adapted = sanitize_preview_request(0, 0, 0, 0);
|
||||
assert_eq!(adapted, (2, 2, 1, 800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1762,7 +1853,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn inline_preview_requests_selected_reencode_profile_on_wire() {
|
||||
fn inline_preview_requests_selected_source_profile_on_wire() {
|
||||
let relay = ProbeRelay::default();
|
||||
let requests = relay.requests.clone();
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
@ -1783,23 +1874,24 @@ mod tests {
|
||||
let state = LauncherState::default();
|
||||
let capture = state.capture_size_choice(1);
|
||||
preview.set_capture_profile(
|
||||
1,
|
||||
1,
|
||||
capture.width,
|
||||
capture.height,
|
||||
capture.fps,
|
||||
capture.max_bitrate_kbit,
|
||||
capture.preset != CaptureSizePreset::Source,
|
||||
);
|
||||
preview.activate_surface_for_test(1, PreviewSurface::Inline);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(request) = requests.lock().unwrap().last().cloned() {
|
||||
assert_eq!(request.id, 1);
|
||||
assert_eq!(request.source_id, Some(1));
|
||||
assert_eq!(request.requested_width, 1920);
|
||||
assert_eq!(request.requested_height, 1080);
|
||||
assert_eq!(request.requested_fps, 24);
|
||||
assert_eq!(request.max_bitrate, 4_000);
|
||||
assert!(request.prefer_reencode);
|
||||
assert_eq!(request.requested_fps, 60);
|
||||
assert_eq!(request.max_bitrate, 18_000);
|
||||
preview.shutdown_all();
|
||||
return;
|
||||
}
|
||||
@ -1831,26 +1923,27 @@ mod tests {
|
||||
|
||||
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
|
||||
let mut state = LauncherState::default();
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::Source);
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
|
||||
let capture = state.capture_size_choice(1);
|
||||
preview.set_capture_profile(
|
||||
1,
|
||||
1,
|
||||
capture.width,
|
||||
capture.height,
|
||||
capture.fps,
|
||||
capture.max_bitrate_kbit,
|
||||
capture.preset != CaptureSizePreset::Source,
|
||||
);
|
||||
preview.activate_surface_for_test(1, PreviewSurface::Inline);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(request) = requests.lock().unwrap().last().cloned() {
|
||||
assert_eq!(request.id, 1);
|
||||
assert_eq!(request.source_id, Some(1));
|
||||
assert_eq!(request.requested_width, 1920);
|
||||
assert_eq!(request.requested_height, 1080);
|
||||
assert_eq!(request.requested_fps, 30);
|
||||
assert_eq!(request.max_bitrate, 12_000);
|
||||
assert!(!request.prefer_reencode);
|
||||
assert_eq!(request.requested_fps, 60);
|
||||
assert_eq!(request.max_bitrate, 18_000);
|
||||
preview.shutdown_all();
|
||||
return;
|
||||
}
|
||||
@ -1860,4 +1953,148 @@ mod tests {
|
||||
preview.shutdown_all();
|
||||
panic!("preview did not issue a source capture request within timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn inline_preview_requests_native_720p_source_mode_on_wire() {
|
||||
let relay = ProbeRelay::default();
|
||||
let requests = relay.requests.clone();
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let addr = rt.block_on(async move {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
drop(listener);
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(RelayServer::new(relay))
|
||||
.serve(addr)
|
||||
.await;
|
||||
});
|
||||
addr
|
||||
});
|
||||
|
||||
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
|
||||
let mut state = LauncherState::default();
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::P720);
|
||||
let capture = state.capture_size_choice(1);
|
||||
preview.set_capture_profile(
|
||||
1,
|
||||
1,
|
||||
capture.width,
|
||||
capture.height,
|
||||
capture.fps,
|
||||
capture.max_bitrate_kbit,
|
||||
);
|
||||
preview.activate_surface_for_test(1, PreviewSurface::Inline);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(request) = requests.lock().unwrap().last().cloned() {
|
||||
assert_eq!(request.id, 1);
|
||||
assert_eq!(request.source_id, Some(1));
|
||||
assert_eq!(request.requested_width, 1280);
|
||||
assert_eq!(request.requested_height, 720);
|
||||
assert_eq!(request.requested_fps, 60);
|
||||
assert_eq!(request.max_bitrate, 12_000);
|
||||
preview.shutdown_all();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
preview.shutdown_all();
|
||||
panic!("preview did not issue a 720p source capture request within timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn inline_preview_requests_native_480p_source_mode_on_wire() {
|
||||
let relay = ProbeRelay::default();
|
||||
let requests = relay.requests.clone();
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let addr = rt.block_on(async move {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
drop(listener);
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(RelayServer::new(relay))
|
||||
.serve(addr)
|
||||
.await;
|
||||
});
|
||||
addr
|
||||
});
|
||||
|
||||
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
|
||||
let mut state = LauncherState::default();
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::P480);
|
||||
let capture = state.capture_size_choice(1);
|
||||
preview.set_capture_profile(
|
||||
1,
|
||||
1,
|
||||
capture.width,
|
||||
capture.height,
|
||||
capture.fps,
|
||||
capture.max_bitrate_kbit,
|
||||
);
|
||||
preview.activate_surface_for_test(1, PreviewSurface::Inline);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(request) = requests.lock().unwrap().last().cloned() {
|
||||
assert_eq!(request.id, 1);
|
||||
assert_eq!(request.source_id, Some(1));
|
||||
assert_eq!(request.requested_width, 720);
|
||||
assert_eq!(request.requested_height, 480);
|
||||
assert_eq!(request.requested_fps, 60);
|
||||
assert_eq!(request.max_bitrate, 2_500);
|
||||
preview.shutdown_all();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
preview.shutdown_all();
|
||||
panic!("preview did not issue a 480p source capture request within timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn preview_can_request_other_eye_as_a_distinct_stream() {
|
||||
let relay = ProbeRelay::default();
|
||||
let requests = relay.requests.clone();
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
let addr = rt.block_on(async move {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
drop(listener);
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(RelayServer::new(relay))
|
||||
.serve(addr)
|
||||
.await;
|
||||
});
|
||||
addr
|
||||
});
|
||||
|
||||
let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview");
|
||||
preview.set_capture_profile(0, 1, 1920, 1080, 30, 12_000);
|
||||
preview.activate_surface_for_test(0, PreviewSurface::Inline);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(request) = requests.lock().unwrap().last().cloned() {
|
||||
assert_eq!(request.id, 0);
|
||||
assert_eq!(request.source_id, Some(1));
|
||||
assert_eq!(request.requested_width, 1920);
|
||||
assert_eq!(request.requested_height, 1080);
|
||||
preview.shutdown_all();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
preview.shutdown_all();
|
||||
panic!("preview did not issue a mirrored capture request within timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::devices::DeviceCatalog;
|
||||
use lesavka_common::eye_source::{EyeSourceMode, default_eye_source_mode, native_eye_source_modes};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InputRouting {
|
||||
@ -49,6 +48,43 @@ impl DisplaySurface {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FeedSourcePreset {
|
||||
ThisEye,
|
||||
OtherEye,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl FeedSourcePreset {
|
||||
pub fn as_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::ThisEye => "self",
|
||||
Self::OtherEye => "other",
|
||||
Self::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"self" => Some(Self::ThisEye),
|
||||
"other" => Some(Self::OtherEye),
|
||||
"off" => Some(Self::Off),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self, monitor_id: usize) -> &'static str {
|
||||
match (monitor_id, self) {
|
||||
(_, Self::Off) => "Off",
|
||||
(0, Self::ThisEye) => "Left Eye",
|
||||
(0, Self::OtherEye) => "Right Eye",
|
||||
(1, Self::ThisEye) => "Right Eye",
|
||||
(1, Self::OtherEye) => "Left Eye",
|
||||
_ => "This Eye",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BreakoutSizePreset {
|
||||
P360,
|
||||
@ -105,57 +141,69 @@ impl BreakoutSizePreset {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CaptureSizePreset {
|
||||
P360,
|
||||
P540,
|
||||
#[serde(alias = "P360")]
|
||||
Vga,
|
||||
#[serde(alias = "P540")]
|
||||
P480,
|
||||
P576,
|
||||
P720,
|
||||
P900,
|
||||
#[serde(alias = "P900", alias = "P1440", alias = "Source")]
|
||||
P1080,
|
||||
P1440,
|
||||
Source,
|
||||
}
|
||||
|
||||
impl CaptureSizePreset {
|
||||
pub fn as_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::P360 => "360p",
|
||||
Self::P540 => "540p",
|
||||
Self::Vga => "vga",
|
||||
Self::P480 => "480p",
|
||||
Self::P576 => "576p",
|
||||
Self::P720 => "720p",
|
||||
Self::P900 => "900p",
|
||||
Self::P1080 => "1080p",
|
||||
Self::P1440 => "1440p",
|
||||
Self::Source => "source",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"360p" => Some(Self::P360),
|
||||
"540p" => Some(Self::P540),
|
||||
"vga" | "360p" => Some(Self::Vga),
|
||||
"480p" | "540p" => Some(Self::P480),
|
||||
"576p" => Some(Self::P576),
|
||||
"720p" => Some(Self::P720),
|
||||
"900p" => Some(Self::P900),
|
||||
"1080p" => Some(Self::P1080),
|
||||
"1440p" => Some(Self::P1440),
|
||||
"source" => Some(Self::Source),
|
||||
"900p" | "1080p" | "1440p" | "source" => Some(Self::P1080),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::P360 => "360p",
|
||||
Self::P540 => "540p",
|
||||
Self::Vga => "VGA",
|
||||
Self::P480 => "480p",
|
||||
Self::P576 => "576p",
|
||||
Self::P720 => "720p",
|
||||
Self::P900 => "900p",
|
||||
Self::P1080 => "1080p",
|
||||
Self::P1440 => "1440p",
|
||||
Self::Source => "Source",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transport_label(self) -> &'static str {
|
||||
"device H.264 pass-through"
|
||||
}
|
||||
|
||||
pub fn source_mode(self) -> EyeSourceMode {
|
||||
match self {
|
||||
Self::Source => "source pass-through",
|
||||
_ => "server re-encode",
|
||||
Self::Vga => native_eye_source_modes()[4],
|
||||
Self::P480 => native_eye_source_modes()[3],
|
||||
Self::P576 => native_eye_source_modes()[2],
|
||||
Self::P720 => native_eye_source_modes()[1],
|
||||
Self::P1080 => native_eye_source_modes()[0],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_source_mode(mode: EyeSourceMode) -> Self {
|
||||
match (mode.width, mode.height, mode.fps) {
|
||||
(640, 480, 60) => Self::Vga,
|
||||
(720, 480, 60) => Self::P480,
|
||||
(720, 576, 50) => Self::P576,
|
||||
(1280, 720, 60) => Self::P720,
|
||||
_ => Self::P1080,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -169,10 +217,11 @@ pub struct PreviewSourceSize {
|
||||
|
||||
impl Default for PreviewSourceSize {
|
||||
fn default() -> Self {
|
||||
let mode = default_eye_source_mode();
|
||||
Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
fps: mode.fps,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -193,6 +242,12 @@ pub struct CaptureSizeChoice {
|
||||
pub max_bitrate_kbit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FeedSourceChoice {
|
||||
pub preset: FeedSourcePreset,
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CaptureFpsChoice {
|
||||
pub fps: u32,
|
||||
@ -242,6 +297,7 @@ pub struct LauncherState {
|
||||
pub routing: InputRouting,
|
||||
pub view_mode: ViewMode,
|
||||
pub displays: [DisplaySurface; 2],
|
||||
pub feed_sources: [FeedSourcePreset; 2],
|
||||
pub preview_source: PreviewSourceSize,
|
||||
pub breakout_limit: PreviewSourceSize,
|
||||
pub breakout_display: PreviewSourceSize,
|
||||
@ -266,12 +322,13 @@ impl Default for LauncherState {
|
||||
routing: InputRouting::Remote,
|
||||
view_mode: ViewMode::Unified,
|
||||
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
|
||||
feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye],
|
||||
preview_source: PreviewSourceSize::default(),
|
||||
breakout_limit: PreviewSourceSize::default(),
|
||||
breakout_display: PreviewSourceSize::default(),
|
||||
capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080],
|
||||
capture_fps: [30, 30],
|
||||
capture_bitrates_kbit: [12_000, 12_000],
|
||||
capture_fps: [60, 60],
|
||||
capture_bitrates_kbit: [18_000, 18_000],
|
||||
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
||||
devices: DeviceSelection::default(),
|
||||
swap_key: "pause".to_string(),
|
||||
@ -323,6 +380,44 @@ impl LauncherState {
|
||||
.unwrap_or(DisplaySurface::Preview)
|
||||
}
|
||||
|
||||
pub fn feed_source_preset(&self, monitor_id: usize) -> FeedSourcePreset {
|
||||
self.feed_sources
|
||||
.get(monitor_id)
|
||||
.copied()
|
||||
.unwrap_or(FeedSourcePreset::ThisEye)
|
||||
}
|
||||
|
||||
pub fn set_feed_source_preset(&mut self, monitor_id: usize, preset: FeedSourcePreset) {
|
||||
if let Some(slot) = self.feed_sources.get_mut(monitor_id) {
|
||||
*slot = preset;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn feed_source_options(&self, monitor_id: usize) -> Vec<FeedSourceChoice> {
|
||||
vec![
|
||||
FeedSourceChoice {
|
||||
preset: FeedSourcePreset::ThisEye,
|
||||
label: FeedSourcePreset::ThisEye.label(monitor_id),
|
||||
},
|
||||
FeedSourceChoice {
|
||||
preset: FeedSourcePreset::OtherEye,
|
||||
label: FeedSourcePreset::OtherEye.label(monitor_id),
|
||||
},
|
||||
FeedSourceChoice {
|
||||
preset: FeedSourcePreset::Off,
|
||||
label: FeedSourcePreset::Off.label(monitor_id),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn resolved_feed_monitor_id(&self, monitor_id: usize) -> Option<usize> {
|
||||
match self.feed_source_preset(monitor_id) {
|
||||
FeedSourcePreset::ThisEye => Some(monitor_id.min(1)),
|
||||
FeedSourcePreset::OtherEye => Some(1_usize.saturating_sub(monitor_id.min(1))),
|
||||
FeedSourcePreset::Off => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) {
|
||||
if let Some(slot) = self.displays.get_mut(monitor_id) {
|
||||
*slot = surface;
|
||||
@ -391,13 +486,21 @@ impl LauncherState {
|
||||
}
|
||||
|
||||
pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset {
|
||||
self.capture_sizes
|
||||
.get(monitor_id)
|
||||
.copied()
|
||||
.unwrap_or(CaptureSizePreset::Source)
|
||||
normalize_capture_size_preset(
|
||||
self.capture_sizes
|
||||
.get(monitor_id)
|
||||
.copied()
|
||||
.unwrap_or(CaptureSizePreset::P1080),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn display_capture_size_preset(&self, monitor_id: usize) -> Option<CaptureSizePreset> {
|
||||
self.resolved_feed_monitor_id(monitor_id)
|
||||
.map(|source_id| self.capture_size_preset(source_id))
|
||||
}
|
||||
|
||||
pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) {
|
||||
let preset = normalize_capture_size_preset(preset);
|
||||
if let Some(slot) = self.capture_sizes.get_mut(monitor_id) {
|
||||
*slot = preset;
|
||||
}
|
||||
@ -410,10 +513,15 @@ impl LauncherState {
|
||||
self.capture_fps
|
||||
.get(monitor_id)
|
||||
.copied()
|
||||
.unwrap_or(30)
|
||||
.unwrap_or(default_eye_source_mode().fps)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
pub fn display_capture_fps(&self, monitor_id: usize) -> Option<u32> {
|
||||
self.resolved_feed_monitor_id(monitor_id)
|
||||
.map(|source_id| self.capture_fps(source_id))
|
||||
}
|
||||
|
||||
pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) {
|
||||
if let Some(slot) = self.capture_fps.get_mut(monitor_id) {
|
||||
*slot = fps.max(1);
|
||||
@ -424,10 +532,19 @@ impl LauncherState {
|
||||
self.capture_bitrates_kbit
|
||||
.get(monitor_id)
|
||||
.copied()
|
||||
.unwrap_or(12_000)
|
||||
.unwrap_or(estimate_source_bitrate_kbit(
|
||||
default_eye_source_mode().width as i32,
|
||||
default_eye_source_mode().height as i32,
|
||||
default_eye_source_mode().fps,
|
||||
))
|
||||
.max(800)
|
||||
}
|
||||
|
||||
pub fn display_capture_bitrate_kbit(&self, monitor_id: usize) -> Option<u32> {
|
||||
self.resolved_feed_monitor_id(monitor_id)
|
||||
.map(|source_id| self.capture_bitrate_kbit(source_id))
|
||||
}
|
||||
|
||||
pub fn set_capture_bitrate_kbit(&mut self, monitor_id: usize, max_bitrate_kbit: u32) {
|
||||
if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) {
|
||||
*slot = max_bitrate_kbit.max(800);
|
||||
@ -443,6 +560,11 @@ impl LauncherState {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn display_capture_size_choice(&self, monitor_id: usize) -> Option<CaptureSizeChoice> {
|
||||
self.resolved_feed_monitor_id(monitor_id)
|
||||
.map(|source_id| self.capture_size_choice(source_id))
|
||||
}
|
||||
|
||||
pub fn capture_size_options(&self) -> Vec<CaptureSizeChoice> {
|
||||
capture_size_options(self.preview_source)
|
||||
}
|
||||
@ -571,7 +693,7 @@ impl LauncherState {
|
||||
|
||||
pub fn status_line(&self) -> String {
|
||||
format!(
|
||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}",
|
||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}",
|
||||
self.server_available,
|
||||
match self.routing {
|
||||
InputRouting::Local => "local",
|
||||
@ -591,6 +713,8 @@ impl LauncherState {
|
||||
self.preview_source.height,
|
||||
self.displays[0].label(),
|
||||
self.displays[1].label(),
|
||||
self.feed_source_preset(0).as_id(),
|
||||
self.feed_source_preset(1).as_id(),
|
||||
self.devices.camera.as_deref().unwrap_or("auto"),
|
||||
self.devices.microphone.as_deref().unwrap_or("auto"),
|
||||
self.devices.speaker.as_deref().unwrap_or("auto"),
|
||||
@ -679,76 +803,20 @@ fn breakout_size_options(
|
||||
}
|
||||
|
||||
fn capture_size_choice(
|
||||
source: PreviewSourceSize,
|
||||
_source: PreviewSourceSize,
|
||||
preset: CaptureSizePreset,
|
||||
selected_fps: u32,
|
||||
selected_bitrate_kbit: u32,
|
||||
) -> CaptureSizeChoice {
|
||||
let source_width = source.width.max(1) as i32;
|
||||
let source_height = source.height.max(1) as i32;
|
||||
let source_fps = source.fps.max(1);
|
||||
let (width, height, fps, max_bitrate_kbit) = match preset {
|
||||
CaptureSizePreset::P360 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360);
|
||||
(
|
||||
width,
|
||||
height,
|
||||
source_fps.min(selected_fps.max(1)),
|
||||
selected_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
CaptureSizePreset::P540 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 960, 540);
|
||||
(
|
||||
width,
|
||||
height,
|
||||
source_fps.min(selected_fps.max(1)),
|
||||
selected_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
CaptureSizePreset::P720 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720);
|
||||
(
|
||||
width,
|
||||
height,
|
||||
source_fps.min(selected_fps.max(1)),
|
||||
selected_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
CaptureSizePreset::P900 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900);
|
||||
(
|
||||
width,
|
||||
height,
|
||||
source_fps.min(selected_fps.max(1)),
|
||||
selected_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
CaptureSizePreset::P1080 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080);
|
||||
(
|
||||
width,
|
||||
height,
|
||||
source_fps.min(selected_fps.max(1)),
|
||||
selected_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
CaptureSizePreset::P1440 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440);
|
||||
(
|
||||
width,
|
||||
height,
|
||||
source_fps.min(selected_fps.max(1)),
|
||||
selected_bitrate_kbit.max(800),
|
||||
)
|
||||
}
|
||||
CaptureSizePreset::Source => (
|
||||
source_width,
|
||||
source_height,
|
||||
source_fps,
|
||||
estimate_source_bitrate_kbit(source_width, source_height, source_fps),
|
||||
),
|
||||
};
|
||||
let preset = normalize_capture_size_preset(preset);
|
||||
let mode = preset.source_mode();
|
||||
let _ = (selected_fps, selected_bitrate_kbit);
|
||||
let (width, height, fps, max_bitrate_kbit) = (
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
mode.fps,
|
||||
estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps),
|
||||
);
|
||||
CaptureSizeChoice {
|
||||
preset,
|
||||
width,
|
||||
@ -769,106 +837,46 @@ fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 {
|
||||
}
|
||||
|
||||
fn capture_size_options(source: PreviewSourceSize) -> Vec<CaptureSizeChoice> {
|
||||
let mut options = Vec::new();
|
||||
for preset in [
|
||||
CaptureSizePreset::Source,
|
||||
CaptureSizePreset::P360,
|
||||
CaptureSizePreset::P540,
|
||||
CaptureSizePreset::P720,
|
||||
CaptureSizePreset::P900,
|
||||
CaptureSizePreset::P1080,
|
||||
CaptureSizePreset::P1440,
|
||||
] {
|
||||
let defaults = default_profile_for_preset(source, preset);
|
||||
let choice = capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit);
|
||||
if options.iter().any(|existing: &CaptureSizeChoice| {
|
||||
let existing_transport = existing.preset.transport_label();
|
||||
let current_transport = choice.preset.transport_label();
|
||||
existing_transport == current_transport
|
||||
&& existing.width == choice.width
|
||||
&& existing.height == choice.height
|
||||
&& existing.fps == choice.fps
|
||||
&& existing.max_bitrate_kbit == choice.max_bitrate_kbit
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
options.push(choice);
|
||||
}
|
||||
options
|
||||
native_eye_source_modes()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|mode| mode.width <= source.width && mode.height <= source.height)
|
||||
.map(CaptureSizePreset::from_source_mode)
|
||||
.map(|preset| {
|
||||
let defaults = default_profile_for_preset(source, preset);
|
||||
capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn capture_fps_options(source: PreviewSourceSize) -> Vec<CaptureFpsChoice> {
|
||||
let mut values = BTreeSet::new();
|
||||
for fps in [15, 20, 24, 30, 48, 60, source.fps.max(1)] {
|
||||
if fps <= source.fps.max(1) || fps == source.fps.max(1) {
|
||||
values.insert(fps.max(1));
|
||||
}
|
||||
}
|
||||
values
|
||||
.into_iter()
|
||||
.map(|fps| CaptureFpsChoice { fps })
|
||||
.collect()
|
||||
vec![CaptureFpsChoice {
|
||||
fps: source.fps.max(1),
|
||||
}]
|
||||
}
|
||||
|
||||
fn capture_bitrate_options(source: PreviewSourceSize) -> Vec<CaptureBitrateChoice> {
|
||||
let mut values = BTreeSet::new();
|
||||
for bitrate in [
|
||||
2_500,
|
||||
4_000,
|
||||
6_000,
|
||||
8_500,
|
||||
12_000,
|
||||
18_000,
|
||||
24_000,
|
||||
estimate_source_bitrate_kbit(source.width as i32, source.height as i32, source.fps),
|
||||
] {
|
||||
values.insert(bitrate.max(800));
|
||||
}
|
||||
values
|
||||
.into_iter()
|
||||
.map(|max_bitrate_kbit| CaptureBitrateChoice { max_bitrate_kbit })
|
||||
.collect()
|
||||
vec![CaptureBitrateChoice {
|
||||
max_bitrate_kbit: estimate_source_bitrate_kbit(
|
||||
source.width as i32,
|
||||
source.height as i32,
|
||||
source.fps,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
fn default_profile_for_preset(
|
||||
source: PreviewSourceSize,
|
||||
_source: PreviewSourceSize,
|
||||
preset: CaptureSizePreset,
|
||||
) -> CaptureSizeChoice {
|
||||
let source_width = source.width.max(1) as i32;
|
||||
let source_height = source.height.max(1) as i32;
|
||||
let source_fps = source.fps.max(1);
|
||||
let (width, height, fps, max_bitrate_kbit) = match preset {
|
||||
CaptureSizePreset::P360 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360);
|
||||
(width, height, source_fps.min(15), 2_500)
|
||||
}
|
||||
CaptureSizePreset::P540 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 960, 540);
|
||||
(width, height, source_fps.min(20), 4_000)
|
||||
}
|
||||
CaptureSizePreset::P720 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720);
|
||||
(width, height, source_fps.min(24), 6_000)
|
||||
}
|
||||
CaptureSizePreset::P900 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900);
|
||||
(width, height, source_fps.min(30), 8_500)
|
||||
}
|
||||
CaptureSizePreset::P1080 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080);
|
||||
(width, height, source_fps.min(30), 12_000)
|
||||
}
|
||||
CaptureSizePreset::P1440 => {
|
||||
let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440);
|
||||
(width, height, source_fps.min(30), 18_000)
|
||||
}
|
||||
CaptureSizePreset::Source => (
|
||||
source_width,
|
||||
source_height,
|
||||
source_fps,
|
||||
estimate_source_bitrate_kbit(source_width, source_height, source_fps),
|
||||
),
|
||||
};
|
||||
let preset = normalize_capture_size_preset(preset);
|
||||
let mode = preset.source_mode();
|
||||
let (width, height, fps, max_bitrate_kbit) = (
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
mode.fps,
|
||||
estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps),
|
||||
);
|
||||
CaptureSizeChoice {
|
||||
preset,
|
||||
width,
|
||||
@ -878,6 +886,10 @@ fn default_profile_for_preset(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset {
|
||||
preset
|
||||
}
|
||||
|
||||
fn fit_standard_dimensions(
|
||||
limit_width: i32,
|
||||
limit_height: i32,
|
||||
@ -973,6 +985,29 @@ mod tests {
|
||||
assert_eq!(state.display_surface(1), DisplaySurface::Window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_sources_can_mirror_or_disable_a_pane() {
|
||||
let mut state = LauncherState::new();
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
|
||||
|
||||
assert_eq!(state.resolved_feed_monitor_id(0), Some(0));
|
||||
assert_eq!(state.resolved_feed_monitor_id(1), Some(1));
|
||||
|
||||
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
|
||||
assert_eq!(state.resolved_feed_monitor_id(0), Some(1));
|
||||
assert_eq!(
|
||||
state.display_capture_size_choice(0),
|
||||
Some(state.capture_size_choice(1))
|
||||
);
|
||||
|
||||
state.set_feed_source_preset(1, FeedSourcePreset::OtherEye);
|
||||
assert_eq!(state.resolved_feed_monitor_id(1), Some(0));
|
||||
|
||||
state.set_feed_source_preset(0, FeedSourcePreset::Off);
|
||||
assert_eq!(state.resolved_feed_monitor_id(0), None);
|
||||
assert!(state.display_capture_size_choice(0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_auto_or_blank_clears_explicit_device() {
|
||||
let mut state = LauncherState::new();
|
||||
@ -1076,21 +1111,21 @@ mod tests {
|
||||
#[test]
|
||||
fn breakout_size_choices_track_the_negotiated_source_size() {
|
||||
let mut state = LauncherState::new();
|
||||
state.set_preview_source_profile(1920, 1080, 30);
|
||||
state.set_preview_source_profile(1920, 1080, 60);
|
||||
state.set_breakout_limit_size(2560, 1440);
|
||||
|
||||
let source = state.capture_size_choice(0);
|
||||
assert_eq!(source.width, 1920);
|
||||
assert_eq!(source.height, 1080);
|
||||
assert_eq!(source.fps, 30);
|
||||
assert_eq!(source.max_bitrate_kbit, 12_000);
|
||||
assert_eq!(source.fps, 60);
|
||||
assert_eq!(source.max_bitrate_kbit, 18_000);
|
||||
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P540);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P480);
|
||||
let compact_capture = state.capture_size_choice(0);
|
||||
assert_eq!(compact_capture.width, 960);
|
||||
assert_eq!(compact_capture.height, 540);
|
||||
assert_eq!(compact_capture.fps, 20);
|
||||
assert_eq!(compact_capture.max_bitrate_kbit, 4_000);
|
||||
assert_eq!(compact_capture.width, 720);
|
||||
assert_eq!(compact_capture.height, 480);
|
||||
assert_eq!(compact_capture.fps, 60);
|
||||
assert_eq!(compact_capture.max_bitrate_kbit, 2_500);
|
||||
|
||||
let display = state.breakout_size_choice(0);
|
||||
assert_eq!(display.width, 1920);
|
||||
@ -1107,17 +1142,12 @@ mod tests {
|
||||
assert_eq!(compact.height, 540);
|
||||
|
||||
let capture_options = state.capture_size_options();
|
||||
assert!(capture_options.len() >= 5);
|
||||
assert!(capture_options.iter().any(|choice| {
|
||||
choice.preset == CaptureSizePreset::Source
|
||||
&& choice.width == 1920
|
||||
&& choice.height == 1080
|
||||
}));
|
||||
assert!(capture_options.iter().any(|choice| {
|
||||
choice.preset == CaptureSizePreset::P1080
|
||||
&& choice.width == 1920
|
||||
&& choice.height == 1080
|
||||
}));
|
||||
assert_eq!(capture_options.len(), 5);
|
||||
assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080);
|
||||
assert_eq!(capture_options[0].width, 1920);
|
||||
assert_eq!(capture_options[0].height, 1080);
|
||||
assert_eq!(capture_options[0].fps, 60);
|
||||
assert_eq!(capture_options[0].max_bitrate_kbit, 18_000);
|
||||
|
||||
let breakout_options = state.breakout_size_options();
|
||||
assert!(breakout_options.len() >= 5);
|
||||
@ -1181,61 +1211,73 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_capture_profile_uses_source_fps_and_scaled_profiles_cap_it() {
|
||||
fn capture_size_presets_map_to_real_device_modes() {
|
||||
let mut state = LauncherState::new();
|
||||
state.set_preview_source_profile(1920, 1080, 60);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::Source);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P1080);
|
||||
let source = state.capture_size_choice(0);
|
||||
assert_eq!(source.width, 1920);
|
||||
assert_eq!(source.height, 1080);
|
||||
assert_eq!(source.fps, 60);
|
||||
assert!(source.max_bitrate_kbit >= 12_000);
|
||||
assert!(source.max_bitrate_kbit >= 18_000);
|
||||
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P720);
|
||||
let hd = state.capture_size_choice(0);
|
||||
assert_eq!(hd.fps, 24);
|
||||
assert_eq!(hd.preset, CaptureSizePreset::P720);
|
||||
assert_eq!(hd.width, 1280);
|
||||
assert_eq!(hd.height, 720);
|
||||
assert_eq!(hd.fps, 60);
|
||||
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P540);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P576);
|
||||
let compact = state.capture_size_choice(0);
|
||||
assert_eq!(compact.fps, 20);
|
||||
assert_eq!(compact.preset, CaptureSizePreset::P576);
|
||||
assert_eq!(compact.width, 720);
|
||||
assert_eq!(compact.height, 576);
|
||||
assert_eq!(compact.fps, 50);
|
||||
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P360);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::Vga);
|
||||
let small = state.capture_size_choice(0);
|
||||
assert_eq!(small.fps, 15);
|
||||
assert_eq!(small.preset, CaptureSizePreset::Vga);
|
||||
assert_eq!(small.width, 640);
|
||||
assert_eq!(small.height, 480);
|
||||
assert_eq!(small.fps, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_capture_controls_apply_custom_fps_and_bitrate() {
|
||||
fn source_capture_knobs_follow_the_selected_native_mode() {
|
||||
let mut state = LauncherState::new();
|
||||
state.set_preview_source_profile(1920, 1080, 30);
|
||||
state.set_preview_source_profile(1920, 1080, 60);
|
||||
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
|
||||
let defaults = state.capture_size_choice(1);
|
||||
assert_eq!(defaults.width, 1920);
|
||||
assert_eq!(defaults.height, 1080);
|
||||
assert_eq!(defaults.fps, 30);
|
||||
assert_eq!(defaults.max_bitrate_kbit, 12_000);
|
||||
assert_eq!(defaults.fps, 60);
|
||||
assert_eq!(defaults.max_bitrate_kbit, 18_000);
|
||||
|
||||
state.set_capture_fps(1, 24);
|
||||
state.set_capture_bitrate_kbit(1, 8_500);
|
||||
let tuned = state.capture_size_choice(1);
|
||||
assert_eq!(tuned.preset, CaptureSizePreset::P1080);
|
||||
assert_eq!(tuned.width, 1920);
|
||||
assert_eq!(tuned.height, 1080);
|
||||
assert_eq!(tuned.fps, 24);
|
||||
assert_eq!(tuned.max_bitrate_kbit, 8_500);
|
||||
assert_eq!(tuned.fps, 60);
|
||||
assert_eq!(tuned.max_bitrate_kbit, 18_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_capture_ignores_manual_fps_and_bitrate_knobs() {
|
||||
let mut state = LauncherState::new();
|
||||
state.set_preview_source_profile(1920, 1080, 25);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::Source);
|
||||
state.set_preview_source_profile(1920, 1080, 60);
|
||||
state.set_capture_size_preset(0, CaptureSizePreset::P720);
|
||||
state.set_capture_fps(0, 60);
|
||||
state.set_capture_bitrate_kbit(0, 24_000);
|
||||
|
||||
let source = state.capture_size_choice(0);
|
||||
assert_eq!(source.preset, CaptureSizePreset::Source);
|
||||
assert_eq!(source.fps, 25);
|
||||
assert_eq!(source.preset, CaptureSizePreset::P720);
|
||||
assert_eq!(source.width, 1280);
|
||||
assert_eq!(source.height, 720);
|
||||
assert_eq!(source.fps, 60);
|
||||
assert_eq!(source.max_bitrate_kbit, 12_000);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,8 @@ use {
|
||||
super::launcher_focus_signal_path,
|
||||
super::power::{fetch_capture_power, set_capture_power_mode},
|
||||
super::state::{
|
||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, InputRouting,
|
||||
LauncherState,
|
||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
||||
FeedSourcePreset, InputRouting, LauncherState,
|
||||
},
|
||||
super::ui_components::build_launcher_view,
|
||||
super::ui_runtime::{
|
||||
@ -207,25 +207,43 @@ fn refresh_eye_feed_controls(
|
||||
state: &LauncherState,
|
||||
) {
|
||||
for monitor_id in 0..2 {
|
||||
super::ui_components::sync_capture_resolution_combo(
|
||||
&widgets.display_panes[monitor_id].capture_resolution_combo,
|
||||
state.capture_size_options(),
|
||||
state.capture_size_preset(monitor_id),
|
||||
);
|
||||
super::ui_components::sync_capture_fps_combo(
|
||||
&widgets.display_panes[monitor_id].capture_fps_combo,
|
||||
state.capture_fps_options(),
|
||||
state.capture_fps(monitor_id),
|
||||
state.capture_size_preset(monitor_id) == CaptureSizePreset::Source,
|
||||
state.capture_size_choice(monitor_id).fps,
|
||||
);
|
||||
super::ui_components::sync_capture_bitrate_combo(
|
||||
&widgets.display_panes[monitor_id].capture_bitrate_combo,
|
||||
state.capture_bitrate_options(),
|
||||
state.capture_bitrate_kbit(monitor_id),
|
||||
state.capture_size_preset(monitor_id) == CaptureSizePreset::Source,
|
||||
state.capture_size_choice(monitor_id).max_bitrate_kbit,
|
||||
super::ui_components::sync_feed_source_combo(
|
||||
&widgets.display_panes[monitor_id].feed_source_combo,
|
||||
state.feed_source_options(monitor_id),
|
||||
state.feed_source_preset(monitor_id),
|
||||
);
|
||||
if state.feed_source_preset(monitor_id) != FeedSourcePreset::Off {
|
||||
let choice = state.capture_size_choice(monitor_id);
|
||||
super::ui_components::sync_capture_resolution_combo(
|
||||
&widgets.display_panes[monitor_id].capture_resolution_combo,
|
||||
state.capture_size_options(),
|
||||
state.capture_size_preset(monitor_id),
|
||||
);
|
||||
super::ui_components::sync_capture_fps_combo(
|
||||
&widgets.display_panes[monitor_id].capture_fps_combo,
|
||||
state.capture_fps_options(),
|
||||
state.capture_fps(monitor_id),
|
||||
true,
|
||||
choice.fps,
|
||||
);
|
||||
super::ui_components::sync_capture_bitrate_combo(
|
||||
&widgets.display_panes[monitor_id].capture_bitrate_combo,
|
||||
state.capture_bitrate_options(),
|
||||
state.capture_bitrate_kbit(monitor_id),
|
||||
true,
|
||||
choice.max_bitrate_kbit,
|
||||
);
|
||||
} else {
|
||||
super::ui_components::sync_capture_resolution_disabled(
|
||||
&widgets.display_panes[monitor_id].capture_resolution_combo,
|
||||
);
|
||||
super::ui_components::sync_capture_fps_disabled(
|
||||
&widgets.display_panes[monitor_id].capture_fps_combo,
|
||||
);
|
||||
super::ui_components::sync_capture_bitrate_disabled(
|
||||
&widgets.display_panes[monitor_id].capture_bitrate_combo,
|
||||
);
|
||||
}
|
||||
super::ui_components::sync_breakout_size_combo(
|
||||
&widgets.display_panes[monitor_id].breakout_combo,
|
||||
state.breakout_size_options(),
|
||||
@ -254,25 +272,31 @@ fn record_diagnostics_sample(
|
||||
) {
|
||||
let left_metrics = preview
|
||||
.and_then(|preview| {
|
||||
preview.snapshot_metrics(
|
||||
0,
|
||||
match state.display_surface(0) {
|
||||
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
|
||||
DisplaySurface::Window => super::preview::PreviewSurface::Window,
|
||||
},
|
||||
(state.feed_source_preset(0) != FeedSourcePreset::Off).then_some(
|
||||
preview.snapshot_metrics(
|
||||
0,
|
||||
match state.display_surface(0) {
|
||||
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
|
||||
DisplaySurface::Window => super::preview::PreviewSurface::Window,
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let right_metrics = preview
|
||||
.and_then(|preview| {
|
||||
preview.snapshot_metrics(
|
||||
1,
|
||||
match state.display_surface(1) {
|
||||
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
|
||||
DisplaySurface::Window => super::preview::PreviewSurface::Window,
|
||||
},
|
||||
(state.feed_source_preset(1) != FeedSourcePreset::Off).then_some(
|
||||
preview.snapshot_metrics(
|
||||
1,
|
||||
match state.display_surface(1) {
|
||||
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
|
||||
DisplaySurface::Window => super::preview::PreviewSurface::Window,
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
widgets
|
||||
@ -305,6 +329,7 @@ fn record_diagnostics_sample(
|
||||
left_decoder_label: left_metrics.decoder_label.clone(),
|
||||
left_stream_caps_label: left_metrics.stream_caps_label.clone(),
|
||||
left_decoded_caps_label: left_metrics.decoded_caps_label.clone(),
|
||||
left_rendered_caps_label: left_metrics.rendered_caps_label.clone(),
|
||||
right_receive_fps: right_metrics.receive_fps,
|
||||
right_present_fps: right_metrics.present_fps,
|
||||
right_server_fps: right_metrics.server_fps,
|
||||
@ -320,6 +345,7 @@ fn record_diagnostics_sample(
|
||||
right_decoder_label: right_metrics.decoder_label.clone(),
|
||||
right_stream_caps_label: right_metrics.stream_caps_label.clone(),
|
||||
right_decoded_caps_label: right_metrics.decoded_caps_label.clone(),
|
||||
right_rendered_caps_label: right_metrics.rendered_caps_label.clone(),
|
||||
dropped_frames: left_metrics
|
||||
.dropped_frames
|
||||
.saturating_add(right_metrics.dropped_frames),
|
||||
@ -419,6 +445,7 @@ fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) {
|
||||
fn rebind_inline_preview(
|
||||
preview: &super::preview::LauncherPreview,
|
||||
widgets: &super::ui_components::LauncherWidgets,
|
||||
state: &LauncherState,
|
||||
monitor_id: usize,
|
||||
) {
|
||||
if let Some(binding) = widgets.display_panes[monitor_id]
|
||||
@ -428,6 +455,15 @@ fn rebind_inline_preview(
|
||||
{
|
||||
binding.close();
|
||||
}
|
||||
if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
||||
widgets.display_panes[monitor_id]
|
||||
.picture
|
||||
.set_paintable(Option::<>k::gdk::Paintable>::None);
|
||||
widgets.display_panes[monitor_id]
|
||||
.stream_status
|
||||
.set_text("Feed disabled.");
|
||||
return;
|
||||
}
|
||||
let binding = preview.install_on_picture(
|
||||
monitor_id,
|
||||
super::preview::PreviewSurface::Inline,
|
||||
@ -443,6 +479,7 @@ fn rebind_inline_preview(
|
||||
fn rebind_popout_preview(
|
||||
preview: &super::preview::LauncherPreview,
|
||||
popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>,
|
||||
state: &LauncherState,
|
||||
monitor_id: usize,
|
||||
) {
|
||||
let mut popouts = popouts.borrow_mut();
|
||||
@ -450,6 +487,13 @@ fn rebind_popout_preview(
|
||||
return;
|
||||
};
|
||||
handle.binding.close();
|
||||
if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
||||
handle
|
||||
.picture
|
||||
.set_paintable(Option::<>k::gdk::Paintable>::None);
|
||||
handle.status_label.set_text("Feed disabled.");
|
||||
return;
|
||||
}
|
||||
if let Some(binding) = preview.install_on_picture(
|
||||
monitor_id,
|
||||
super::preview::PreviewSurface::Window,
|
||||
@ -463,15 +507,20 @@ fn rebind_popout_preview(
|
||||
#[cfg(not(coverage))]
|
||||
fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) {
|
||||
for monitor_id in 0..2 {
|
||||
let enabled = state.feed_source_preset(monitor_id) != FeedSourcePreset::Off;
|
||||
preview.set_monitor_enabled(monitor_id, enabled);
|
||||
let capture = state.capture_size_choice(monitor_id);
|
||||
let source_monitor_id = state
|
||||
.resolved_feed_monitor_id(monitor_id)
|
||||
.unwrap_or(monitor_id);
|
||||
let breakout = state.breakout_size_choice(monitor_id);
|
||||
preview.set_capture_profile(
|
||||
monitor_id,
|
||||
source_monitor_id,
|
||||
capture.width,
|
||||
capture.height,
|
||||
capture.fps,
|
||||
capture.max_bitrate_kbit,
|
||||
capture.preset != CaptureSizePreset::Source,
|
||||
);
|
||||
preview.set_breakout_profile(monitor_id, breakout.width, breakout.height);
|
||||
}
|
||||
@ -486,8 +535,8 @@ fn sync_preview_profiles(
|
||||
) {
|
||||
apply_preview_profiles(preview, state);
|
||||
for monitor_id in 0..2 {
|
||||
rebind_inline_preview(preview, widgets, monitor_id);
|
||||
rebind_popout_preview(preview, popouts, monitor_id);
|
||||
rebind_inline_preview(preview, widgets, state, monitor_id);
|
||||
rebind_popout_preview(preview, popouts, state, monitor_id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -796,6 +845,34 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
for monitor_id in 0..2 {
|
||||
let state = Rc::clone(&state);
|
||||
let widgets = widgets.clone();
|
||||
let popouts = Rc::clone(&popouts);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let preview = preview.clone();
|
||||
let feed_source_combo = widgets.display_panes[monitor_id].feed_source_combo.clone();
|
||||
feed_source_combo.connect_changed(move |combo| {
|
||||
let Some(active_id) = combo.active_id() else {
|
||||
return;
|
||||
};
|
||||
let Some(preset) = FeedSourcePreset::from_id(active_id.as_str()) else {
|
||||
return;
|
||||
};
|
||||
if state.borrow().feed_source_preset(monitor_id) == preset {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut state = state.borrow_mut();
|
||||
state.set_feed_source_preset(monitor_id, preset);
|
||||
}
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
||||
}
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
});
|
||||
}
|
||||
|
||||
for monitor_id in 0..2 {
|
||||
let state = Rc::clone(&state);
|
||||
let widgets = widgets.clone();
|
||||
@ -811,6 +888,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else {
|
||||
return;
|
||||
};
|
||||
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
||||
return;
|
||||
}
|
||||
if state.borrow().capture_size_preset(monitor_id) == preset {
|
||||
return;
|
||||
}
|
||||
@ -820,16 +900,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
}
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
let choice = state.borrow().capture_size_choice(monitor_id);
|
||||
let source_monitor_id = state
|
||||
.borrow()
|
||||
.resolved_feed_monitor_id(monitor_id)
|
||||
.unwrap_or(monitor_id);
|
||||
preview.set_capture_profile(
|
||||
monitor_id,
|
||||
source_monitor_id,
|
||||
choice.width,
|
||||
choice.height,
|
||||
choice.fps,
|
||||
choice.max_bitrate_kbit,
|
||||
choice.preset != CaptureSizePreset::Source,
|
||||
);
|
||||
rebind_inline_preview(preview, &widgets, monitor_id);
|
||||
rebind_popout_preview(preview, &popouts, monitor_id);
|
||||
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
||||
}
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
});
|
||||
@ -852,6 +935,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let Ok(fps) = active_id.as_str().parse::<u32>() else {
|
||||
return;
|
||||
};
|
||||
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
||||
return;
|
||||
}
|
||||
if state.borrow().capture_fps(monitor_id) == fps {
|
||||
return;
|
||||
}
|
||||
@ -861,16 +947,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
}
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
let choice = state.borrow().capture_size_choice(monitor_id);
|
||||
let source_monitor_id = state
|
||||
.borrow()
|
||||
.resolved_feed_monitor_id(monitor_id)
|
||||
.unwrap_or(monitor_id);
|
||||
preview.set_capture_profile(
|
||||
monitor_id,
|
||||
source_monitor_id,
|
||||
choice.width,
|
||||
choice.height,
|
||||
choice.fps,
|
||||
choice.max_bitrate_kbit,
|
||||
choice.preset != CaptureSizePreset::Source,
|
||||
);
|
||||
rebind_inline_preview(preview, &widgets, monitor_id);
|
||||
rebind_popout_preview(preview, &popouts, monitor_id);
|
||||
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
||||
}
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
});
|
||||
@ -894,6 +983,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let Ok(max_bitrate_kbit) = active_id.as_str().parse::<u32>() else {
|
||||
return;
|
||||
};
|
||||
if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
||||
return;
|
||||
}
|
||||
if state.borrow().capture_bitrate_kbit(monitor_id) == max_bitrate_kbit {
|
||||
return;
|
||||
}
|
||||
@ -903,16 +995,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
}
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
let choice = state.borrow().capture_size_choice(monitor_id);
|
||||
let source_monitor_id = state
|
||||
.borrow()
|
||||
.resolved_feed_monitor_id(monitor_id)
|
||||
.unwrap_or(monitor_id);
|
||||
preview.set_capture_profile(
|
||||
monitor_id,
|
||||
source_monitor_id,
|
||||
choice.width,
|
||||
choice.height,
|
||||
choice.fps,
|
||||
choice.max_bitrate_kbit,
|
||||
choice.preset != CaptureSizePreset::Source,
|
||||
);
|
||||
rebind_inline_preview(preview, &widgets, monitor_id);
|
||||
rebind_popout_preview(preview, &popouts, monitor_id);
|
||||
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
||||
}
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
});
|
||||
@ -952,7 +1047,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
};
|
||||
if popout_open {
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
rebind_popout_preview(preview, &popouts, monitor_id);
|
||||
rebind_popout_preview(preview, &popouts, &state.borrow(), monitor_id);
|
||||
}
|
||||
if let Some(handle) = popouts
|
||||
.borrow()
|
||||
@ -1833,7 +1928,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
if let (Some(width), Some(height)) =
|
||||
(caps.eye_width, caps.eye_height)
|
||||
{
|
||||
let fps = caps.eye_fps.unwrap_or(30);
|
||||
let fps = caps
|
||||
.eye_fps
|
||||
.unwrap_or(crate::launcher::state::PreviewSourceSize::default().fps);
|
||||
{
|
||||
let mut state = state.borrow_mut();
|
||||
state.set_preview_source_profile(width, height, fps);
|
||||
@ -1911,7 +2008,7 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
|
||||
mod tests {
|
||||
use super::apply_preview_profiles;
|
||||
use crate::launcher::preview::{LauncherPreview, PreviewSurface};
|
||||
use crate::launcher::state::{CaptureSizePreset, LauncherState};
|
||||
use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
|
||||
|
||||
#[test]
|
||||
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {
|
||||
@ -1919,25 +2016,27 @@ mod tests {
|
||||
let state = LauncherState::default();
|
||||
|
||||
let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
|
||||
assert_eq!(bootstrap.2, 960);
|
||||
assert_eq!(bootstrap.3, 540);
|
||||
assert_eq!(bootstrap.4, 24);
|
||||
assert_eq!(bootstrap.0, 0);
|
||||
assert_eq!(bootstrap.3, 1920);
|
||||
assert_eq!(bootstrap.4, 1080);
|
||||
assert_eq!(bootstrap.5, 60);
|
||||
assert_eq!(bootstrap.6, 18_000);
|
||||
|
||||
apply_preview_profiles(&preview, &state);
|
||||
|
||||
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
|
||||
assert_eq!(inline.2, 1920);
|
||||
assert_eq!(inline.3, 1080);
|
||||
assert_eq!(inline.4, 24);
|
||||
assert_eq!(inline.5, 4_000);
|
||||
assert!(inline.6);
|
||||
assert_eq!(inline.0, 1);
|
||||
assert_eq!(inline.3, 1920);
|
||||
assert_eq!(inline.4, 1080);
|
||||
assert_eq!(inline.5, 60);
|
||||
assert_eq!(inline.6, 18_000);
|
||||
|
||||
let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap();
|
||||
assert_eq!(window.2, 1920);
|
||||
assert_eq!(window.3, 1080);
|
||||
assert_eq!(window.4, 30);
|
||||
assert_eq!(window.5, 12_000);
|
||||
assert!(window.6);
|
||||
assert_eq!(window.0, 1);
|
||||
assert_eq!(window.3, 1920);
|
||||
assert_eq!(window.4, 1080);
|
||||
assert_eq!(window.5, 60);
|
||||
assert_eq!(window.6, 18_000);
|
||||
|
||||
preview.shutdown_all();
|
||||
}
|
||||
@ -1946,16 +2045,56 @@ mod tests {
|
||||
fn source_preview_profile_stays_honest_after_apply() {
|
||||
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
||||
let mut state = LauncherState::default();
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::Source);
|
||||
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
|
||||
|
||||
apply_preview_profiles(&preview, &state);
|
||||
|
||||
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
|
||||
assert_eq!(inline.2, 1920);
|
||||
assert_eq!(inline.3, 1080);
|
||||
assert_eq!(inline.4, 30);
|
||||
assert_eq!(inline.5, 12_000);
|
||||
assert!(!inline.6);
|
||||
assert_eq!(inline.0, 1);
|
||||
assert_eq!(inline.3, 1920);
|
||||
assert_eq!(inline.4, 1080);
|
||||
assert_eq!(inline.5, 60);
|
||||
assert_eq!(inline.6, 18_000);
|
||||
|
||||
preview.shutdown_all();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_preview_profile_keeps_its_own_feed_id_but_uses_the_other_source() {
|
||||
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
||||
let mut state = LauncherState::default();
|
||||
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
|
||||
|
||||
apply_preview_profiles(&preview, &state);
|
||||
|
||||
let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap();
|
||||
let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap();
|
||||
assert_eq!(inline.0, 1);
|
||||
assert_eq!(window.0, 1);
|
||||
|
||||
preview.shutdown_all();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_preview_profile_disables_both_surfaces_instead_of_leaving_idle_feeds_running() {
|
||||
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
||||
let mut state = LauncherState::default();
|
||||
state.set_feed_source_preset(0, FeedSourcePreset::Off);
|
||||
|
||||
apply_preview_profiles(&preview, &state);
|
||||
|
||||
assert_eq!(
|
||||
preview.feed_disabled_for_test(0, PreviewSurface::Inline),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
preview.feed_disabled_for_test(0, PreviewSurface::Window),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
preview.feed_disabled_for_test(1, PreviewSurface::Inline),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
preview.shutdown_all();
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ use super::{
|
||||
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
||||
state::{
|
||||
BreakoutSizeChoice, BreakoutSizePreset, CaptureBitrateChoice, CaptureFpsChoice,
|
||||
CaptureSizeChoice, CaptureSizePreset, LauncherState,
|
||||
CaptureSizeChoice, CaptureSizePreset, FeedSourceChoice, FeedSourcePreset, LauncherState,
|
||||
},
|
||||
};
|
||||
|
||||
@ -31,6 +31,7 @@ pub struct DisplayPaneWidgets {
|
||||
pub picture: gtk::Picture,
|
||||
pub stream_status: gtk::Label,
|
||||
pub placeholder: gtk::Label,
|
||||
pub feed_source_combo: gtk::ComboBoxText,
|
||||
pub capture_resolution_combo: gtk::ComboBoxText,
|
||||
pub capture_fps_combo: gtk::ComboBoxText,
|
||||
pub capture_bitrate_combo: gtk::ComboBoxText,
|
||||
@ -529,60 +530,94 @@ pub fn build_launcher_view(
|
||||
let left_pane = left_pane;
|
||||
let right_pane = right_pane;
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
*left_pane.preview_binding.borrow_mut() = preview.install_on_picture(
|
||||
0,
|
||||
PreviewSurface::Inline,
|
||||
&left_pane.picture,
|
||||
&left_pane.stream_status,
|
||||
);
|
||||
*right_pane.preview_binding.borrow_mut() = preview.install_on_picture(
|
||||
1,
|
||||
PreviewSurface::Inline,
|
||||
&right_pane.picture,
|
||||
&right_pane.stream_status,
|
||||
);
|
||||
*left_pane.preview_binding.borrow_mut() =
|
||||
if state.feed_source_preset(0) == FeedSourcePreset::Off {
|
||||
None
|
||||
} else {
|
||||
preview.install_on_picture(
|
||||
0,
|
||||
PreviewSurface::Inline,
|
||||
&left_pane.picture,
|
||||
&left_pane.stream_status,
|
||||
)
|
||||
};
|
||||
*right_pane.preview_binding.borrow_mut() =
|
||||
if state.feed_source_preset(1) == FeedSourcePreset::Off {
|
||||
None
|
||||
} else {
|
||||
preview.install_on_picture(
|
||||
1,
|
||||
PreviewSurface::Inline,
|
||||
&right_pane.picture,
|
||||
&right_pane.stream_status,
|
||||
)
|
||||
};
|
||||
} else {
|
||||
left_pane.stream_status.set_text("Preview unavailable");
|
||||
right_pane.stream_status.set_text("Preview unavailable");
|
||||
}
|
||||
sync_capture_resolution_combo(
|
||||
&left_pane.capture_resolution_combo,
|
||||
state.capture_size_options(),
|
||||
state.capture_size_preset(0),
|
||||
sync_feed_source_combo(
|
||||
&left_pane.feed_source_combo,
|
||||
state.feed_source_options(0),
|
||||
state.feed_source_preset(0),
|
||||
);
|
||||
sync_capture_fps_combo(
|
||||
&left_pane.capture_fps_combo,
|
||||
state.capture_fps_options(),
|
||||
state.capture_fps(0),
|
||||
state.capture_size_preset(0) == CaptureSizePreset::Source,
|
||||
state.capture_size_choice(0).fps,
|
||||
);
|
||||
sync_capture_bitrate_combo(
|
||||
&left_pane.capture_bitrate_combo,
|
||||
state.capture_bitrate_options(),
|
||||
state.capture_bitrate_kbit(0),
|
||||
state.capture_size_preset(0) == CaptureSizePreset::Source,
|
||||
state.capture_size_choice(0).max_bitrate_kbit,
|
||||
);
|
||||
sync_capture_resolution_combo(
|
||||
&right_pane.capture_resolution_combo,
|
||||
state.capture_size_options(),
|
||||
state.capture_size_preset(1),
|
||||
);
|
||||
sync_capture_fps_combo(
|
||||
&right_pane.capture_fps_combo,
|
||||
state.capture_fps_options(),
|
||||
state.capture_fps(1),
|
||||
state.capture_size_preset(1) == CaptureSizePreset::Source,
|
||||
state.capture_size_choice(1).fps,
|
||||
);
|
||||
sync_capture_bitrate_combo(
|
||||
&right_pane.capture_bitrate_combo,
|
||||
state.capture_bitrate_options(),
|
||||
state.capture_bitrate_kbit(1),
|
||||
state.capture_size_preset(1) == CaptureSizePreset::Source,
|
||||
state.capture_size_choice(1).max_bitrate_kbit,
|
||||
sync_feed_source_combo(
|
||||
&right_pane.feed_source_combo,
|
||||
state.feed_source_options(1),
|
||||
state.feed_source_preset(1),
|
||||
);
|
||||
if state.feed_source_preset(0) != FeedSourcePreset::Off {
|
||||
let choice = state.capture_size_choice(0);
|
||||
sync_capture_resolution_combo(
|
||||
&left_pane.capture_resolution_combo,
|
||||
state.capture_size_options(),
|
||||
state.capture_size_preset(0),
|
||||
);
|
||||
sync_capture_fps_combo(
|
||||
&left_pane.capture_fps_combo,
|
||||
state.capture_fps_options(),
|
||||
state.capture_fps(0),
|
||||
true,
|
||||
choice.fps,
|
||||
);
|
||||
sync_capture_bitrate_combo(
|
||||
&left_pane.capture_bitrate_combo,
|
||||
state.capture_bitrate_options(),
|
||||
state.capture_bitrate_kbit(0),
|
||||
true,
|
||||
choice.max_bitrate_kbit,
|
||||
);
|
||||
} else {
|
||||
sync_capture_resolution_disabled(&left_pane.capture_resolution_combo);
|
||||
sync_capture_fps_disabled(&left_pane.capture_fps_combo);
|
||||
sync_capture_bitrate_disabled(&left_pane.capture_bitrate_combo);
|
||||
}
|
||||
if state.feed_source_preset(1) != FeedSourcePreset::Off {
|
||||
let choice = state.capture_size_choice(1);
|
||||
sync_capture_resolution_combo(
|
||||
&right_pane.capture_resolution_combo,
|
||||
state.capture_size_options(),
|
||||
state.capture_size_preset(1),
|
||||
);
|
||||
sync_capture_fps_combo(
|
||||
&right_pane.capture_fps_combo,
|
||||
state.capture_fps_options(),
|
||||
state.capture_fps(1),
|
||||
true,
|
||||
choice.fps,
|
||||
);
|
||||
sync_capture_bitrate_combo(
|
||||
&right_pane.capture_bitrate_combo,
|
||||
state.capture_bitrate_options(),
|
||||
state.capture_bitrate_kbit(1),
|
||||
true,
|
||||
choice.max_bitrate_kbit,
|
||||
);
|
||||
} else {
|
||||
sync_capture_resolution_disabled(&right_pane.capture_resolution_combo);
|
||||
sync_capture_fps_disabled(&right_pane.capture_fps_combo);
|
||||
sync_capture_bitrate_disabled(&right_pane.capture_bitrate_combo);
|
||||
}
|
||||
sync_breakout_size_combo(
|
||||
&left_pane.breakout_combo,
|
||||
state.breakout_size_options(),
|
||||
@ -853,30 +888,45 @@ fn stabilize_chip(chip: >k::Box, width: i32) {
|
||||
chip.set_size_request(width, -1);
|
||||
}
|
||||
|
||||
pub fn sync_feed_source_combo(
|
||||
combo: >k::ComboBoxText,
|
||||
options: Vec<FeedSourceChoice>,
|
||||
selected: FeedSourcePreset,
|
||||
) {
|
||||
combo.remove_all();
|
||||
for option in options {
|
||||
combo.append(Some(option.preset.as_id()), option.label);
|
||||
}
|
||||
combo.set_active_id(Some(selected.as_id()));
|
||||
combo.set_sensitive(true);
|
||||
}
|
||||
|
||||
pub fn sync_capture_resolution_combo(
|
||||
combo: >k::ComboBoxText,
|
||||
options: Vec<CaptureSizeChoice>,
|
||||
selected: CaptureSizePreset,
|
||||
) {
|
||||
combo.remove_all();
|
||||
let option_count = options.len();
|
||||
for option in options {
|
||||
let label = match option.preset {
|
||||
CaptureSizePreset::Source => format!(
|
||||
"{} • {}x{} (Pass-through)",
|
||||
option.preset.label(),
|
||||
option.width,
|
||||
option.height,
|
||||
),
|
||||
_ => format!(
|
||||
"{} • {}x{} (Re-encode)",
|
||||
option.preset.label(),
|
||||
option.width,
|
||||
option.height,
|
||||
),
|
||||
};
|
||||
let label = format!(
|
||||
"{} • {}x{} @ {} fps (Device H.264)",
|
||||
option.preset.label(),
|
||||
option.width,
|
||||
option.height,
|
||||
option.fps,
|
||||
);
|
||||
combo.append(Some(option.preset.as_id()), &label);
|
||||
}
|
||||
combo.set_active_id(Some(selected.as_id()));
|
||||
combo.set_sensitive(option_count > 1);
|
||||
}
|
||||
|
||||
pub fn sync_capture_resolution_disabled(combo: >k::ComboBoxText) {
|
||||
combo.remove_all();
|
||||
combo.append(Some("off"), "Feed disabled");
|
||||
combo.set_active_id(Some("off"));
|
||||
combo.set_sensitive(false);
|
||||
}
|
||||
|
||||
pub fn sync_capture_fps_combo(
|
||||
@ -888,7 +938,7 @@ pub fn sync_capture_fps_combo(
|
||||
) {
|
||||
combo.remove_all();
|
||||
if locked_to_source {
|
||||
combo.append(Some("source"), &format!("{source_fps} fps (Source)"));
|
||||
combo.append(Some("source"), &format!("{source_fps} fps (Device mode)"));
|
||||
combo.set_active_id(Some("source"));
|
||||
combo.set_sensitive(false);
|
||||
return;
|
||||
@ -903,6 +953,13 @@ pub fn sync_capture_fps_combo(
|
||||
combo.set_sensitive(true);
|
||||
}
|
||||
|
||||
pub fn sync_capture_fps_disabled(combo: >k::ComboBoxText) {
|
||||
combo.remove_all();
|
||||
combo.append(Some("off"), "Feed disabled");
|
||||
combo.set_active_id(Some("off"));
|
||||
combo.set_sensitive(false);
|
||||
}
|
||||
|
||||
pub fn sync_capture_bitrate_combo(
|
||||
combo: >k::ComboBoxText,
|
||||
options: Vec<CaptureBitrateChoice>,
|
||||
@ -914,7 +971,7 @@ pub fn sync_capture_bitrate_combo(
|
||||
if locked_to_source {
|
||||
combo.append(
|
||||
Some("source"),
|
||||
&format!("{source_bitrate_kbit} kbit (Source estimate)"),
|
||||
&format!("~{source_bitrate_kbit} kbit (Estimated, device-managed)"),
|
||||
);
|
||||
combo.set_active_id(Some("source"));
|
||||
combo.set_sensitive(false);
|
||||
@ -930,6 +987,13 @@ pub fn sync_capture_bitrate_combo(
|
||||
combo.set_sensitive(true);
|
||||
}
|
||||
|
||||
pub fn sync_capture_bitrate_disabled(combo: >k::ComboBoxText) {
|
||||
combo.remove_all();
|
||||
combo.append(Some("off"), "Feed disabled");
|
||||
combo.set_active_id(Some("off"));
|
||||
combo.set_sensitive(false);
|
||||
}
|
||||
|
||||
pub fn sync_breakout_size_combo(
|
||||
combo: >k::ComboBoxText,
|
||||
options: Vec<BreakoutSizeChoice>,
|
||||
@ -1105,19 +1169,24 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
stream_status.set_single_line_mode(true);
|
||||
stream_status.set_max_width_chars(24);
|
||||
stream_status.set_tooltip_text(Some("Connect relay to preview."));
|
||||
let feed_source_combo = gtk::ComboBoxText::new();
|
||||
feed_source_combo.set_tooltip_text(Some(
|
||||
"Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.",
|
||||
));
|
||||
feed_source_combo.set_size_request(150, -1);
|
||||
let capture_resolution_combo = gtk::ComboBoxText::new();
|
||||
capture_resolution_combo.set_tooltip_text(Some(
|
||||
"Choose the server-side capture resolution for this eye feed. Source keeps the HDMI device's own H.264 stream; the standard sizes force a server re-encode.",
|
||||
"Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.",
|
||||
));
|
||||
capture_resolution_combo.set_size_request(240, -1);
|
||||
let capture_fps_combo = gtk::ComboBoxText::new();
|
||||
capture_fps_combo.set_tooltip_text(Some(
|
||||
"Choose the target server-side fps for re-encoded eye feeds. Source pass-through uses the HDMI device's own cadence.",
|
||||
"Source pass-through uses the HDMI device's own cadence. This control will wake back up if we add a proven source-side fps option later.",
|
||||
));
|
||||
capture_fps_combo.set_size_request(120, -1);
|
||||
let capture_bitrate_combo = gtk::ComboBoxText::new();
|
||||
capture_bitrate_combo.set_tooltip_text(Some(
|
||||
"Choose the target server-side bitrate for re-encoded eye feeds. Source pass-through uses an estimated hardware bitrate tier.",
|
||||
"Source pass-through uses the eye device's own H.264 bitrate behavior. This control stays disabled until the hardware exposes a real source-side bitrate mode we can trust.",
|
||||
));
|
||||
capture_bitrate_combo.set_size_request(170, -1);
|
||||
let breakout_combo = gtk::ComboBoxText::new();
|
||||
@ -1129,6 +1198,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
stabilize_button(&action_button, 104);
|
||||
action_button.set_halign(gtk::Align::End);
|
||||
let capture_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
capture_row.append(&feed_source_combo);
|
||||
capture_row.append(&capture_resolution_combo);
|
||||
capture_row.append(&capture_fps_combo);
|
||||
capture_row.append(&capture_bitrate_combo);
|
||||
@ -1146,6 +1216,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
picture,
|
||||
stream_status,
|
||||
placeholder,
|
||||
feed_source_combo,
|
||||
capture_resolution_combo,
|
||||
capture_fps_combo,
|
||||
capture_bitrate_combo,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.11"
|
||||
version = "0.11.12"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ message MonitorRequest {
|
||||
uint32 requested_width = 3;
|
||||
uint32 requested_height = 4;
|
||||
uint32 requested_fps = 5;
|
||||
bool prefer_reencode = 6;
|
||||
optional uint32 source_id = 6;
|
||||
}
|
||||
message VideoPacket {
|
||||
uint32 id = 1;
|
||||
|
||||
@ -17,6 +17,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn banner_includes_version() {
|
||||
assert_eq!(banner("0.11.11"), "lesavka-common CLI (v0.11.11)");
|
||||
assert_eq!(banner("0.11.12"), "lesavka-common CLI (v0.11.12)");
|
||||
}
|
||||
}
|
||||
|
||||
99
common/src/eye_source.rs
Normal file
99
common/src/eye_source.rs
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
// common/src/lib.rs
|
||||
|
||||
pub mod cli;
|
||||
pub mod eye_source;
|
||||
pub mod hid;
|
||||
pub mod paste;
|
||||
pub mod process_metrics;
|
||||
|
||||
72
scripts/manual/probe-eye-capabilities.sh
Executable file
72
scripts/manual/probe-eye-capabilities.sh
Executable 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"
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.11"
|
||||
version = "0.11.12"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -98,10 +98,11 @@ impl Handler {
|
||||
req: MonitorRequest,
|
||||
) -> Result<Response<VideoStream>, Status> {
|
||||
let id = req.id;
|
||||
let dev = match id {
|
||||
let source_id = req.source_id.unwrap_or(id);
|
||||
let dev = match source_id {
|
||||
0 => "/dev/lesavka_l_eye",
|
||||
1 => "/dev/lesavka_r_eye",
|
||||
_ => return Err(Status::invalid_argument("monitor id must be 0 or 1")),
|
||||
_ => return Err(Status::invalid_argument("source id must be 0 or 1")),
|
||||
};
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -110,11 +111,11 @@ impl Handler {
|
||||
info!(
|
||||
rpc_id,
|
||||
id,
|
||||
source_id,
|
||||
max_bitrate = req.max_bitrate,
|
||||
requested_width = req.requested_width,
|
||||
requested_height = req.requested_height,
|
||||
requested_fps = req.requested_fps,
|
||||
prefer_reencode = req.prefer_reencode,
|
||||
"🎥 capture_video opened"
|
||||
);
|
||||
debug!(rpc_id, "🎥 streaming {dev}");
|
||||
@ -128,7 +129,6 @@ impl Handler {
|
||||
req.requested_width,
|
||||
req.requested_height,
|
||||
req.requested_fps,
|
||||
req.prefer_reencode,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
|
||||
@ -6,6 +6,7 @@ use gst::MessageView::*;
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use lesavka_common::eye_source::{default_eye_source_mode, eye_source_mode_for_request};
|
||||
use lesavka_common::lesavka::VideoPacket;
|
||||
use lesavka_common::process_metrics::ProcessCpuSampler;
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
@ -193,10 +194,8 @@ fn eye_device_wait_poll() -> Duration {
|
||||
}
|
||||
|
||||
pub fn eye_source_profile() -> (u32, u32, u32) {
|
||||
let width = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_WIDTH", 1920).max(320));
|
||||
let height = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_HEIGHT", 1080).max(180));
|
||||
let fps = env_u32("LESAVKA_EYE_SOURCE_FPS", 30).max(1);
|
||||
(width, height, fps)
|
||||
let mode = default_eye_source_mode();
|
||||
(mode.width, mode.height, mode.fps)
|
||||
}
|
||||
|
||||
fn round_down_even_u32(value: u32) -> u32 {
|
||||
@ -227,53 +226,24 @@ fn reset_stream_telemetry_window(
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct EyeCaptureRequest {
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
requested_width: u32,
|
||||
requested_height: u32,
|
||||
requested_fps: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
max_bitrate_kbit: u32,
|
||||
reencode: bool,
|
||||
}
|
||||
|
||||
fn normalize_eye_capture_request(
|
||||
requested_width: u32,
|
||||
requested_height: u32,
|
||||
requested_fps: u32,
|
||||
_requested_fps: u32,
|
||||
max_bitrate_kbit: u32,
|
||||
prefer_reencode: bool,
|
||||
) -> EyeCaptureRequest {
|
||||
let (source_width, source_height, source_fps) = eye_source_profile();
|
||||
let requested_width = if requested_width == 0 {
|
||||
source_width
|
||||
} else {
|
||||
round_down_even_u32(requested_width.min(source_width).max(320))
|
||||
};
|
||||
let requested_height = if requested_height == 0 {
|
||||
source_height
|
||||
} else {
|
||||
round_down_even_u32(requested_height.min(source_height).max(180))
|
||||
};
|
||||
let requested_fps = if requested_fps == 0 {
|
||||
source_fps.max(1)
|
||||
} else {
|
||||
requested_fps.max(1).min(source_fps.max(1))
|
||||
};
|
||||
let max_bitrate_kbit = max_bitrate_kbit.max(800);
|
||||
let downscale = requested_width < source_width || requested_height < source_height;
|
||||
let baseline_source_bitrate_kbit = 12_000;
|
||||
let reencode = prefer_reencode
|
||||
|| downscale
|
||||
|| requested_fps != source_fps.max(1)
|
||||
|| max_bitrate_kbit != baseline_source_bitrate_kbit;
|
||||
let source_mode = eye_source_mode_for_request(requested_width, requested_height);
|
||||
EyeCaptureRequest {
|
||||
source_width,
|
||||
source_height,
|
||||
requested_width,
|
||||
requested_height,
|
||||
requested_fps,
|
||||
width: round_down_even_u32(source_mode.width.max(320)),
|
||||
height: round_down_even_u32(source_mode.height.max(180)),
|
||||
fps: source_mode.fps.max(1),
|
||||
max_bitrate_kbit,
|
||||
reencode,
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,7 +307,7 @@ async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> {
|
||||
/// frames before they build up in gRPC queues and destabilize downstream playback.
|
||||
#[cfg(coverage)]
|
||||
pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
|
||||
eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0, false).await
|
||||
eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0).await
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -348,7 +318,6 @@ pub async fn eye_ball_with_request(
|
||||
_requested_width: u32,
|
||||
_requested_height: u32,
|
||||
_requested_fps: u32,
|
||||
_prefer_reencode: bool,
|
||||
) -> anyhow::Result<VideoStream> {
|
||||
let _ = EYE_ID[id as usize];
|
||||
if dev.contains('"') {
|
||||
@ -384,7 +353,7 @@ pub async fn eye_ball_with_request(
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
|
||||
eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0, false).await
|
||||
eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0).await
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -395,7 +364,6 @@ pub async fn eye_ball_with_request(
|
||||
requested_width: u32,
|
||||
requested_height: u32,
|
||||
requested_fps: u32,
|
||||
prefer_reencode: bool,
|
||||
) -> anyhow::Result<VideoStream> {
|
||||
let eye = EYE_ID[id as usize];
|
||||
gst::init().context("gst init")?;
|
||||
@ -405,10 +373,9 @@ pub async fn eye_ball_with_request(
|
||||
requested_height,
|
||||
requested_fps,
|
||||
max_bitrate_kbit,
|
||||
prefer_reencode,
|
||||
);
|
||||
let target_fps = if requested_fps > 0 {
|
||||
request.requested_fps
|
||||
request.fps
|
||||
} else {
|
||||
env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1)
|
||||
};
|
||||
@ -420,11 +387,12 @@ pub async fn eye_ball_with_request(
|
||||
target: "lesavka_server::video",
|
||||
eye = %eye,
|
||||
max_bitrate_kbit,
|
||||
source_width = request.source_width,
|
||||
source_height = request.source_height,
|
||||
requested_width = request.requested_width,
|
||||
requested_height = request.requested_height,
|
||||
requested_fps = request.requested_fps,
|
||||
source_width = request.width,
|
||||
source_height = request.height,
|
||||
source_fps = request.fps,
|
||||
requested_width = request.width,
|
||||
requested_height = request.height,
|
||||
requested_fps = request.fps,
|
||||
target_fps,
|
||||
min_fps,
|
||||
adaptive,
|
||||
@ -447,17 +415,12 @@ pub async fn eye_ball_with_request(
|
||||
|
||||
let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 4).max(1);
|
||||
let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 4).max(1);
|
||||
let keyframe_interval = env_u32(
|
||||
"LESAVKA_EYE_KEYFRAME_INTERVAL",
|
||||
request.requested_fps.max(1).min(5),
|
||||
)
|
||||
.clamp(1, request.requested_fps.max(1));
|
||||
let keyframe_interval = env_u32("LESAVKA_EYE_KEYFRAME_INTERVAL", request.fps.max(1).min(5))
|
||||
.clamp(1, request.fps.max(1));
|
||||
let use_test_src =
|
||||
dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
|
||||
let server_encoder_label = if use_test_src {
|
||||
"x264enc(testsrc)".to_string()
|
||||
} else if request.reencode {
|
||||
"x264enc".to_string()
|
||||
} else {
|
||||
"source-pass-through".to_string()
|
||||
};
|
||||
@ -476,36 +439,17 @@ pub async fn eye_ball_with_request(
|
||||
h264parse disable-passthrough=true config-interval=-1 ! \
|
||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
|
||||
request.requested_width,
|
||||
request.requested_height,
|
||||
request.requested_fps,
|
||||
request.requested_width,
|
||||
request.requested_height,
|
||||
request.requested_fps,
|
||||
)
|
||||
} else if request.reencode {
|
||||
format!(
|
||||
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
|
||||
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
||||
h264parse disable-passthrough=true config-interval=-1 ! avdec_h264 ! videoconvert ! \
|
||||
videoscale add-borders=false ! videorate ! video/x-raw,format=I420,width={},height={},framerate={}/1,pixel-aspect-ratio=1/1 ! \
|
||||
x264enc tune=zerolatency speed-preset=faster bitrate={} key-int-max={} option-string=sar=1/1 ! \
|
||||
h264parse disable-passthrough=true config-interval=-1 ! \
|
||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
|
||||
request.requested_width,
|
||||
request.requested_height,
|
||||
request.requested_fps,
|
||||
request.max_bitrate_kbit,
|
||||
keyframe_interval,
|
||||
request.width, request.height, request.fps, request.width, request.height, request.fps,
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
|
||||
video/x-h264,width={},height={},framerate={}/1 ! \
|
||||
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
||||
h264parse disable-passthrough=true config-interval=-1 ! \
|
||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true"
|
||||
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
|
||||
request.width, request.height, request.fps,
|
||||
)
|
||||
};
|
||||
|
||||
@ -718,31 +662,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn source_profile_stays_pass_through_without_explicit_reencode_request() {
|
||||
let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, false);
|
||||
let request = normalize_eye_capture_request(1920, 1080, 30, 12_000);
|
||||
|
||||
assert_eq!(request.requested_width, 1920);
|
||||
assert_eq!(request.requested_height, 1080);
|
||||
assert_eq!(request.requested_fps, 30);
|
||||
assert!(!request.reencode);
|
||||
assert_eq!(request.width, 1920);
|
||||
assert_eq!(request.height, 1080);
|
||||
assert_eq!(request.fps, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_reencode_preference_forces_same_size_reencode() {
|
||||
let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, true);
|
||||
fn source_mode_selection_prefers_native_modes_without_reencode() {
|
||||
let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500);
|
||||
let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000);
|
||||
let smaller_mode_request = normalize_eye_capture_request(1600, 900, 30, 12_000);
|
||||
|
||||
assert_eq!(request.requested_width, 1920);
|
||||
assert_eq!(request.requested_height, 1080);
|
||||
assert_eq!(request.requested_fps, 30);
|
||||
assert!(request.reencode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_or_fps_change_forces_reencode_even_at_source_size() {
|
||||
let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500, false);
|
||||
let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000, false);
|
||||
|
||||
assert!(bitrate_request.reencode);
|
||||
assert!(fps_request.reencode);
|
||||
assert_eq!(bitrate_request.fps, 60);
|
||||
assert_eq!(fps_request.fps, 60);
|
||||
assert_eq!(smaller_mode_request.width, 1280);
|
||||
assert_eq!(smaller_mode_request.height, 720);
|
||||
assert_eq!(smaller_mode_request.fps, 60);
|
||||
}
|
||||
|
||||
fn marker_frame(width: i32, height: i32) -> Vec<u8> {
|
||||
@ -824,9 +761,9 @@ mod tests {
|
||||
let mut buffer = gst::Buffer::from_mut_slice(marker_frame(width, height));
|
||||
if let Some(buf) = buffer.get_mut() {
|
||||
buf.set_pts(Some(gst::ClockTime::ZERO));
|
||||
buf.set_duration(Some(
|
||||
gst::ClockTime::from_nseconds(1_000_000_000_u64 / input_fps.max(1) as u64),
|
||||
));
|
||||
buf.set_duration(Some(gst::ClockTime::from_nseconds(
|
||||
1_000_000_000_u64 / input_fps.max(1) as u64,
|
||||
)));
|
||||
}
|
||||
appsrc
|
||||
.push_buffer(buffer)
|
||||
@ -838,7 +775,9 @@ mod tests {
|
||||
let sample = appsink
|
||||
.try_pull_sample(gst::ClockTime::from_seconds(5))
|
||||
.ok_or_else(|| anyhow::anyhow!("timed out pulling reencoded frame"))?;
|
||||
let caps = sample.caps().ok_or_else(|| anyhow::anyhow!("missing sample caps"))?;
|
||||
let caps = sample
|
||||
.caps()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing sample caps"))?;
|
||||
let structure = caps
|
||||
.structure(0)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing caps structure"))?;
|
||||
|
||||
@ -152,7 +152,7 @@ mod server_main_binary {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
}))
|
||||
.await
|
||||
});
|
||||
@ -206,7 +206,7 @@ mod server_main_binary {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
|
||||
@ -81,7 +81,7 @@ mod server_main_rpc {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
}))
|
||||
.await
|
||||
});
|
||||
@ -105,7 +105,7 @@ mod server_main_rpc {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
}))
|
||||
.await
|
||||
});
|
||||
@ -135,7 +135,7 @@ mod server_main_rpc {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
@ -209,7 +209,7 @@ mod server_main_rpc {
|
||||
requested_width: 0,
|
||||
requested_height: 0,
|
||||
requested_fps: 0,
|
||||
prefer_reencode: false,
|
||||
source_id: None,
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user