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