sync(server-rc): stabilize mode matrix calibration
This commit is contained in:
parent
d33c5ebbaf
commit
1854018e2a
@ -123,12 +123,12 @@ path.
|
|||||||
- [x] Add dense server-generated smoothness evidence on the normal UVC/UAC
|
- [x] Add dense server-generated smoothness evidence on the normal UVC/UAC
|
||||||
path: per-frame video continuity watermark, quiet audio pilot, cadence
|
path: per-frame video continuity watermark, quiet audio pilot, cadence
|
||||||
jitter, duplicate/missing frame estimates, and low-RMS audio gap counts.
|
jitter, duplicate/missing frame estimates, and low-RMS audio gap counts.
|
||||||
- [ ] Keep UI/profile controls authoritative for UVC output profiles beyond
|
- [ ] Keep UI/profile controls authoritative for webcam-backed UVC output
|
||||||
`640x480@20`; validate `1280x720@30` and `1920x1080@20/30` after sync is
|
profiles; validate `1280x720@20/30` and `1920x1080@20/30` after sync is
|
||||||
locked.
|
locked.
|
||||||
- [x] Add a server-to-RC mode-matrix harness so the same sync/freshness/
|
- [x] Add a server-to-RC mode-matrix harness so the same sync/freshness/
|
||||||
smoothness contract can be run against `640x480@20`, `1280x720@30`,
|
smoothness contract can be run against the core Logitech-backed modes:
|
||||||
`1920x1080@20`, and `1920x1080@30`.
|
`1280x720@20`, `1280x720@30`, `1920x1080@20`, and `1920x1080@30`.
|
||||||
- [ ] Run the mode matrix on Theia/Tethys and record per-mode static delay
|
- [ ] Run the mode matrix on Theia/Tethys and record per-mode static delay
|
||||||
center points before changing the normal advertised profiles.
|
center points before changing the normal advertised profiles.
|
||||||
- [ ] Keep the UI +/-5ms calibration nudges available as small post-baseline
|
- [ ] Keep the UI +/-5ms calibration nudges available as small post-baseline
|
||||||
|
|||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.19.14"
|
version = "0.19.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.19.14"
|
version = "0.19.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.19.14"
|
version = "0.19.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.19.14"
|
version = "0.19.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -68,7 +68,7 @@ where
|
|||||||
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
|
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
|
||||||
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
println!(
|
println!(
|
||||||
"Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>] [--event-width-codes 1,2,1,3]"
|
"Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]"
|
||||||
);
|
);
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
@ -111,6 +111,48 @@ where
|
|||||||
options.event_width_codes = parse_event_width_codes(raw_codes)?;
|
options.event_width_codes = parse_event_width_codes(raw_codes)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if arg == "--analysis-window-s" {
|
||||||
|
let Some(raw_window) = iter.next() else {
|
||||||
|
bail!("--analysis-window-s requires START:END seconds");
|
||||||
|
};
|
||||||
|
parse_analysis_window(&raw_window, &mut options)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(raw_window) = arg.strip_prefix("--analysis-window-s=") {
|
||||||
|
if raw_window.is_empty() {
|
||||||
|
bail!("--analysis-window-s requires START:END seconds");
|
||||||
|
}
|
||||||
|
parse_analysis_window(raw_window, &mut options)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if arg == "--analysis-start-s" {
|
||||||
|
let Some(raw_start) = iter.next() else {
|
||||||
|
bail!("--analysis-start-s requires seconds");
|
||||||
|
};
|
||||||
|
options.analysis_start_s = Some(parse_analysis_seconds(&raw_start, "analysis start")?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(raw_start) = arg.strip_prefix("--analysis-start-s=") {
|
||||||
|
if raw_start.is_empty() {
|
||||||
|
bail!("--analysis-start-s requires seconds");
|
||||||
|
}
|
||||||
|
options.analysis_start_s = Some(parse_analysis_seconds(raw_start, "analysis start")?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if arg == "--analysis-end-s" {
|
||||||
|
let Some(raw_end) = iter.next() else {
|
||||||
|
bail!("--analysis-end-s requires seconds");
|
||||||
|
};
|
||||||
|
options.analysis_end_s = Some(parse_analysis_seconds(&raw_end, "analysis end")?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(raw_end) = arg.strip_prefix("--analysis-end-s=") {
|
||||||
|
if raw_end.is_empty() {
|
||||||
|
bail!("--analysis-end-s requires seconds");
|
||||||
|
}
|
||||||
|
options.analysis_end_s = Some(parse_analysis_seconds(raw_end, "analysis end")?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if capture_path.is_some() {
|
if capture_path.is_some() {
|
||||||
bail!("unexpected extra argument `{arg}`");
|
bail!("unexpected extra argument `{arg}`");
|
||||||
}
|
}
|
||||||
@ -150,6 +192,36 @@ fn parse_event_width_codes(raw: &str) -> Result<Vec<u32>> {
|
|||||||
Ok(codes)
|
Ok(codes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(not(coverage), test))]
|
||||||
|
fn parse_analysis_window(raw: &str, options: &mut SyncAnalysisOptions) -> Result<()> {
|
||||||
|
let Some((start, end)) = raw.split_once(':') else {
|
||||||
|
bail!("--analysis-window-s requires START:END seconds");
|
||||||
|
};
|
||||||
|
options.analysis_start_s = if start.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parse_analysis_seconds(start, "analysis start")?)
|
||||||
|
};
|
||||||
|
options.analysis_end_s = if end.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parse_analysis_seconds(end, "analysis end")?)
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(not(coverage), test))]
|
||||||
|
fn parse_analysis_seconds(raw: &str, label: &str) -> Result<f64> {
|
||||||
|
let value = raw
|
||||||
|
.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.with_context(|| format!("parsing {label} `{raw}`"))?;
|
||||||
|
if !value.is_finite() || value < 0.0 {
|
||||||
|
bail!("{label} must be a finite non-negative timestamp");
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn format_human_report(
|
fn format_human_report(
|
||||||
capture_path: &std::path::Path,
|
capture_path: &std::path::Path,
|
||||||
@ -358,11 +430,20 @@ mod tests {
|
|||||||
assert_eq!(args.options.event_width_codes, vec![1, 2, 1, 3]);
|
assert_eq!(args.options.event_width_codes, vec![1, 2, 1, 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_accepts_analysis_window() {
|
||||||
|
let args = parse_args(["capture.mkv", "--analysis-window-s", "8.25:26.5"]).expect("args");
|
||||||
|
assert_eq!(args.options.analysis_start_s, Some(8.25));
|
||||||
|
assert_eq!(args.options.analysis_end_s, Some(26.5));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_args_rejects_extra_positional_arguments() {
|
fn parse_args_rejects_extra_positional_arguments() {
|
||||||
assert!(parse_args(["one.mkv", "two.mkv"]).is_err());
|
assert!(parse_args(["one.mkv", "two.mkv"]).is_err());
|
||||||
assert!(parse_args(["one.mkv", "--event-width-codes", ""]).is_err());
|
assert!(parse_args(["one.mkv", "--event-width-codes", ""]).is_err());
|
||||||
assert!(parse_args(["one.mkv", "--event-width-codes", "0"]).is_err());
|
assert!(parse_args(["one.mkv", "--event-width-codes", "0"]).is_err());
|
||||||
|
assert!(parse_args(["one.mkv", "--analysis-window-s", "wat:10"]).is_err());
|
||||||
|
assert!(parse_args(["one.mkv", "--analysis-start-s", "-1"]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -196,7 +196,9 @@ fn discover_camera_modes_for(camera: &str) -> Vec<CameraMode> {
|
|||||||
pub fn lesavka_supported_camera_modes() -> Vec<CameraMode> {
|
pub fn lesavka_supported_camera_modes() -> Vec<CameraMode> {
|
||||||
vec![
|
vec![
|
||||||
CameraMode::new(1920, 1080, 30),
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1920, 1080, 20),
|
||||||
CameraMode::new(1280, 720, 30),
|
CameraMode::new(1280, 720, 30),
|
||||||
|
CameraMode::new(1280, 720, 20),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +226,7 @@ pub fn parse_supported_camera_modes(stdout: &str) -> Vec<CameraMode> {
|
|||||||
discovered.iter().any(|actual| {
|
discovered.iter().any(|actual| {
|
||||||
actual.width == supported.width
|
actual.width == supported.width
|
||||||
&& actual.height == supported.height
|
&& actual.height == supported.height
|
||||||
&& actual.fps >= supported.fps
|
&& actual.fps == supported.fps
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@ -83,9 +83,12 @@ ioctl: VIDIOC_ENUM_FMT
|
|||||||
[0]: 'MJPG' (Motion-JPEG, compressed)
|
[0]: 'MJPG' (Motion-JPEG, compressed)
|
||||||
Size: Discrete 1920x1080
|
Size: Discrete 1920x1080
|
||||||
Interval: Discrete 0.033s (30.000 fps)
|
Interval: Discrete 0.033s (30.000 fps)
|
||||||
|
Interval: Discrete 0.050s (20.000 fps)
|
||||||
Interval: Discrete 0.067s (15.000 fps)
|
Interval: Discrete 0.067s (15.000 fps)
|
||||||
Size: Discrete 1280x720
|
Size: Discrete 1280x720
|
||||||
Interval: Discrete 0.017s (60.000 fps)
|
Interval: Discrete 0.017s (60.000 fps)
|
||||||
|
Interval: Discrete 0.033s (30.000 fps)
|
||||||
|
Interval: Discrete 0.050s (20.000 fps)
|
||||||
Size: Discrete 640x480
|
Size: Discrete 640x480
|
||||||
Interval: Discrete 0.033s (30.000 fps)
|
Interval: Discrete 0.033s (30.000 fps)
|
||||||
[1]: 'YUYV' (YUYV 4:2:2)
|
[1]: 'YUYV' (YUYV 4:2:2)
|
||||||
@ -97,7 +100,41 @@ ioctl: VIDIOC_ENUM_FMT
|
|||||||
parse_supported_camera_modes(stdout),
|
parse_supported_camera_modes(stdout),
|
||||||
vec![
|
vec![
|
||||||
CameraMode::new(1920, 1080, 30),
|
CameraMode::new(1920, 1080, 30),
|
||||||
CameraMode::new(1280, 720, 30)
|
CameraMode::new(1920, 1080, 20),
|
||||||
|
CameraMode::new(1280, 720, 30),
|
||||||
|
CameraMode::new(1280, 720, 20)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_mode_parser_requires_exact_fps_for_supported_qualities() {
|
||||||
|
let stdout = r#"
|
||||||
|
ioctl: VIDIOC_ENUM_FMT
|
||||||
|
Type: Video Capture
|
||||||
|
|
||||||
|
[0]: 'MJPG' (Motion-JPEG, compressed)
|
||||||
|
Size: Discrete 1280x720
|
||||||
|
Interval: Discrete 0.033s (30.000 fps)
|
||||||
|
Size: Discrete 1920x1080
|
||||||
|
Interval: Discrete 0.017s (60.000 fps)
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_supported_camera_modes(stdout),
|
||||||
|
vec![CameraMode::new(1280, 720, 30)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_camera_modes_match_the_core_logitech_profiles() {
|
||||||
|
assert_eq!(
|
||||||
|
lesavka_supported_camera_modes(),
|
||||||
|
vec![
|
||||||
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1920, 1080, 20),
|
||||||
|
CameraMode::new(1280, 720, 30),
|
||||||
|
CameraMode::new(1280, 720, 20)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -275,7 +275,9 @@ fn camera_quality_tracks_selected_camera_supported_modes() {
|
|||||||
"cam-a".to_string(),
|
"cam-a".to_string(),
|
||||||
vec![
|
vec![
|
||||||
CameraMode::new(1920, 1080, 30),
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1920, 1080, 20),
|
||||||
CameraMode::new(1280, 720, 30),
|
CameraMode::new(1280, 720, 30),
|
||||||
|
CameraMode::new(1280, 720, 20),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]),
|
("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]),
|
||||||
@ -292,7 +294,9 @@ fn camera_quality_tracks_selected_camera_supported_modes() {
|
|||||||
state.camera_quality_options(&catalog),
|
state.camera_quality_options(&catalog),
|
||||||
vec![
|
vec![
|
||||||
CameraMode::new(1920, 1080, 30),
|
CameraMode::new(1920, 1080, 30),
|
||||||
CameraMode::new(1280, 720, 30)
|
CameraMode::new(1920, 1080, 20),
|
||||||
|
CameraMode::new(1280, 720, 30),
|
||||||
|
CameraMode::new(1280, 720, 20)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -780,7 +780,9 @@ fn realistic_device_catalog() -> DeviceCatalog {
|
|||||||
"usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(),
|
"usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(),
|
||||||
vec![
|
vec![
|
||||||
CameraMode::new(1920, 1080, 30),
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1920, 1080, 20),
|
||||||
CameraMode::new(1280, 720, 30),
|
CameraMode::new(1280, 720, 30),
|
||||||
|
CameraMode::new(1280, 720, 20),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@ -13,7 +13,7 @@ use media_extract::{
|
|||||||
extract_audio_samples, extract_video_brightness, extract_video_colors, extract_video_timestamps,
|
extract_audio_samples, extract_video_brightness, extract_video_colors, extract_video_timestamps,
|
||||||
};
|
};
|
||||||
use onset_detection::{
|
use onset_detection::{
|
||||||
DEFAULT_AUDIO_SAMPLE_RATE_HZ, correlate_coded_segments, correlate_segments,
|
DEFAULT_AUDIO_SAMPLE_RATE_HZ, PulseSegment, correlate_coded_segments, correlate_segments,
|
||||||
detect_audio_segments, detect_coded_audio_segments, detect_color_coded_video_segments,
|
detect_audio_segments, detect_coded_audio_segments, detect_color_coded_video_segments,
|
||||||
detect_video_segments,
|
detect_video_segments,
|
||||||
};
|
};
|
||||||
@ -75,6 +75,9 @@ pub fn analyze_capture(
|
|||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let video_segments = filter_segments_to_analysis_window(video_segments, options, "video")?;
|
||||||
|
let audio_segments = filter_segments_to_analysis_window(audio_segments, options, "audio")?;
|
||||||
|
|
||||||
if !coded_video_events {
|
if !coded_video_events {
|
||||||
correlate_segments(
|
correlate_segments(
|
||||||
&video_segments,
|
&video_segments,
|
||||||
@ -96,6 +99,45 @@ pub fn analyze_capture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn filter_segments_to_analysis_window(
|
||||||
|
segments: Vec<PulseSegment>,
|
||||||
|
options: &SyncAnalysisOptions,
|
||||||
|
stream_name: &str,
|
||||||
|
) -> Result<Vec<PulseSegment>> {
|
||||||
|
let start_s = options.analysis_start_s;
|
||||||
|
let end_s = options.analysis_end_s;
|
||||||
|
if start_s.is_none() && end_s.is_none() {
|
||||||
|
return Ok(segments);
|
||||||
|
}
|
||||||
|
if let Some(start_s) = start_s
|
||||||
|
&& (!start_s.is_finite() || start_s < 0.0)
|
||||||
|
{
|
||||||
|
bail!("{stream_name} analysis start must be a finite non-negative timestamp");
|
||||||
|
}
|
||||||
|
if let Some(end_s) = end_s
|
||||||
|
&& (!end_s.is_finite() || end_s < 0.0)
|
||||||
|
{
|
||||||
|
bail!("{stream_name} analysis end must be a finite non-negative timestamp");
|
||||||
|
}
|
||||||
|
if let (Some(start_s), Some(end_s)) = (start_s, end_s)
|
||||||
|
&& end_s <= start_s
|
||||||
|
{
|
||||||
|
bail!("{stream_name} analysis window end must be after its start");
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = segments
|
||||||
|
.into_iter()
|
||||||
|
.filter(|segment| {
|
||||||
|
start_s.is_none_or(|start_s| segment.start_s >= start_s)
|
||||||
|
&& end_s.is_none_or(|end_s| segment.start_s <= end_s)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
bail!("{stream_name} analysis window removed all detected sync pulses");
|
||||||
|
}
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
fn reconcile_video_timestamps(timestamps: Vec<f64>, frame_count: usize) -> Result<Vec<f64>> {
|
fn reconcile_video_timestamps(timestamps: Vec<f64>, frame_count: usize) -> Result<Vec<f64>> {
|
||||||
if frame_count == 0 {
|
if frame_count == 0 {
|
||||||
bail!("capture did not contain any decoded video brightness frames");
|
bail!("capture did not contain any decoded video brightness frames");
|
||||||
|
|||||||
@ -304,6 +304,8 @@ pub struct SyncAnalysisOptions {
|
|||||||
pub pulse_width_s: f64,
|
pub pulse_width_s: f64,
|
||||||
pub marker_tick_period: u32,
|
pub marker_tick_period: u32,
|
||||||
pub event_width_codes: Vec<u32>,
|
pub event_width_codes: Vec<u32>,
|
||||||
|
pub analysis_start_s: Option<f64>,
|
||||||
|
pub analysis_end_s: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SyncAnalysisOptions {
|
impl Default for SyncAnalysisOptions {
|
||||||
@ -315,6 +317,8 @@ impl Default for SyncAnalysisOptions {
|
|||||||
pulse_width_s: DEFAULT_PULSE_WIDTH_S,
|
pulse_width_s: DEFAULT_PULSE_WIDTH_S,
|
||||||
marker_tick_period: DEFAULT_MARKER_TICK_PERIOD,
|
marker_tick_period: DEFAULT_MARKER_TICK_PERIOD,
|
||||||
event_width_codes: Vec::new(),
|
event_width_codes: Vec::new(),
|
||||||
|
analysis_start_s: None,
|
||||||
|
analysis_end_s: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.19.14"
|
version = "0.19.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -1011,6 +1011,10 @@ fi
|
|||||||
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
||||||
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}"
|
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}"
|
||||||
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
|
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
|
||||||
|
printf 'LESAVKA_UVC_WIDTH=%s\n' "${LESAVKA_UVC_WIDTH:-1280}"
|
||||||
|
printf 'LESAVKA_UVC_HEIGHT=%s\n' "${LESAVKA_UVC_HEIGHT:-720}"
|
||||||
|
printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-30}"
|
||||||
|
printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-333333}"
|
||||||
printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}"
|
printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}"
|
||||||
printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}"
|
printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}"
|
||||||
printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}"
|
printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}"
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
# the UVC modes the UI advertises. This is still a hardware-in-the-loop probe:
|
# the UVC modes the UI advertises. This is still a hardware-in-the-loop probe:
|
||||||
# it captures the real Tethys UVC/UAC endpoints and summarizes sync,
|
# it captures the real Tethys UVC/UAC endpoints and summarizes sync,
|
||||||
# freshness, and smoothness for each mode.
|
# freshness, and smoothness for each mode.
|
||||||
|
#
|
||||||
|
# Reconfigure mode is intentionally a fast runtime path: it updates the remote
|
||||||
|
# Lesavka env files and cycles the UVC gadget, but it does not rebuild or
|
||||||
|
# reinstall the server binary for each mode.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@ -25,13 +29,34 @@ LESAVKA_TLS_DOMAIN=${LESAVKA_TLS_DOMAIN:-lesavka-server}
|
|||||||
LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto}
|
LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto}
|
||||||
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
||||||
|
|
||||||
LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-640x480@20,1280x720@30,1920x1080@20,1920x1080@30}
|
LESAVKA_SERVER_RC_CORE_WEBCAM_MODES=${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30}
|
||||||
|
LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES}}
|
||||||
|
LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US:-${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}}
|
||||||
LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-170000}
|
LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-170000}
|
||||||
LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-640x480@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}
|
LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US:-1280x720@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1280x720@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}}
|
||||||
|
LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-1280x720@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}
|
||||||
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES=${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES:-1280x720,1920x1080}
|
||||||
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS=${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS:-20,30}
|
||||||
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX:-Logitech|BRIO|C9[0-9]+|HD UVC WebCam|USB2[.]0 HD|Integrated Camera|Webcam|Camera}
|
||||||
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX:-Lesavka|UGREEN|MACROSILICON|Composite|Capture}
|
||||||
|
LESAVKA_SERVER_RC_MODE_SOURCE=${LESAVKA_SERVER_RC_MODE_SOURCE:-configured}
|
||||||
LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}
|
LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}
|
||||||
LESAVKA_SERVER_RC_RECONFIGURE_REF=${LESAVKA_SERVER_RC_RECONFIGURE_REF:-master}
|
LESAVKA_SERVER_RC_RECONFIGURE_REF=${LESAVKA_SERVER_RC_RECONFIGURE_REF:-master}
|
||||||
LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}
|
LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}
|
||||||
LESAVKA_SERVER_RC_RECONFIGURE_COMMAND=${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND:-}
|
LESAVKA_SERVER_RC_RECONFIGURE_COMMAND=${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND:-}
|
||||||
|
LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime}
|
||||||
|
LESAVKA_SERVER_RC_RECONFIGURE_CODEC=${LESAVKA_SERVER_RC_RECONFIGURE_CODEC:-mjpeg}
|
||||||
|
LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1}
|
||||||
|
LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD=${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD:-1}
|
||||||
|
LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS=${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS:-4}
|
||||||
|
LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE=${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE:-0}
|
||||||
|
LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1}
|
||||||
|
LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD:-}
|
||||||
|
LESAVKA_SERVER_RC_WAIT_TETHYS_READY=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY:-1}
|
||||||
|
LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS:-60}
|
||||||
|
LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS:-6}
|
||||||
|
LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS:-3}
|
||||||
|
LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}
|
||||||
LESAVKA_SERVER_RC_CONTINUE_ON_FAIL=${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL:-1}
|
LESAVKA_SERVER_RC_CONTINUE_ON_FAIL=${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL:-1}
|
||||||
|
|
||||||
LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}
|
LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}
|
||||||
@ -80,10 +105,12 @@ parse_mode() {
|
|||||||
printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
lookup_video_delay_us() {
|
lookup_mode_delay_us() {
|
||||||
local mode=$1
|
local mode=$1
|
||||||
|
local delay_map=$2
|
||||||
|
local default_value=$3
|
||||||
local entry key value
|
local entry key value
|
||||||
IFS=',' read -r -a delay_entries <<<"${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
IFS=',' read -r -a delay_entries <<<"${delay_map}"
|
||||||
for entry in "${delay_entries[@]}"; do
|
for entry in "${delay_entries[@]}"; do
|
||||||
key=${entry%%=*}
|
key=${entry%%=*}
|
||||||
value=${entry#*=}
|
value=${entry#*=}
|
||||||
@ -92,7 +119,212 @@ lookup_video_delay_us() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
printf '%s\n' "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}"
|
printf '%s\n' "${default_value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup_audio_delay_us() {
|
||||||
|
lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}"
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup_video_delay_us() {
|
||||||
|
lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}"
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_local_webcam_modes() {
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
printf 'LESAVKA_SERVER_RC_MODES=auto requires python3 for local webcam mode discovery.\n' >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
if ! command -v v4l2-ctl >/dev/null 2>&1; then
|
||||||
|
printf 'LESAVKA_SERVER_RC_MODES=auto requires v4l2-ctl for local webcam mode discovery.\n' >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
python3 - <<'PY' \
|
||||||
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES}" \
|
||||||
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS}" \
|
||||||
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX}" \
|
||||||
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX}"
|
||||||
|
import glob
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sizes_raw, fps_raw, include_raw, exclude_raw = sys.argv[1:5]
|
||||||
|
|
||||||
|
def parse_sizes(raw):
|
||||||
|
sizes = set()
|
||||||
|
for entry in raw.split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
match = re.fullmatch(r"(\d+)x(\d+)", entry)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit(f"invalid discovery size {entry!r}; expected WIDTHxHEIGHT")
|
||||||
|
sizes.add((int(match.group(1)), int(match.group(2))))
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
def parse_fps(raw):
|
||||||
|
values = set()
|
||||||
|
for entry in raw.split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
if not entry.isdigit():
|
||||||
|
raise SystemExit(f"invalid discovery fps {entry!r}; expected integer FPS")
|
||||||
|
values.add(int(entry))
|
||||||
|
return values
|
||||||
|
|
||||||
|
allowed_sizes = parse_sizes(sizes_raw)
|
||||||
|
allowed_fps = parse_fps(fps_raw)
|
||||||
|
include = re.compile(include_raw, re.IGNORECASE) if include_raw else None
|
||||||
|
exclude = re.compile(exclude_raw, re.IGNORECASE) if exclude_raw else None
|
||||||
|
all_modes = set()
|
||||||
|
camera_modes = []
|
||||||
|
|
||||||
|
for dev in sorted(glob.glob("/dev/video*")):
|
||||||
|
try:
|
||||||
|
info = subprocess.run(
|
||||||
|
["v4l2-ctl", "-d", dev, "--info"],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).stdout
|
||||||
|
formats = subprocess.run(
|
||||||
|
["v4l2-ctl", "-d", dev, "--list-formats-ext"],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).stdout
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
card = dev
|
||||||
|
for line in info.splitlines():
|
||||||
|
if "Card type" in line:
|
||||||
|
card = line.split(":", 1)[1].strip()
|
||||||
|
break
|
||||||
|
label = f"{card} {dev}"
|
||||||
|
if exclude and exclude.search(label):
|
||||||
|
continue
|
||||||
|
if include and not include.search(label):
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_size = None
|
||||||
|
modes = set()
|
||||||
|
for line in formats.splitlines():
|
||||||
|
size_match = re.search(r"Size:\s+Discrete\s+(\d+)x(\d+)", line)
|
||||||
|
if size_match:
|
||||||
|
current_size = (int(size_match.group(1)), int(size_match.group(2)))
|
||||||
|
continue
|
||||||
|
fps_match = re.search(r"Interval:\s+Discrete\s+[^()]+\((\d+(?:\.\d+)?) fps\)", line)
|
||||||
|
if not fps_match or current_size not in allowed_sizes:
|
||||||
|
continue
|
||||||
|
fps_float = float(fps_match.group(1))
|
||||||
|
fps = int(round(fps_float))
|
||||||
|
if abs(fps_float - fps) > 0.05 or fps not in allowed_fps:
|
||||||
|
continue
|
||||||
|
modes.add((current_size[0], current_size[1], fps))
|
||||||
|
|
||||||
|
if modes:
|
||||||
|
all_modes.update(modes)
|
||||||
|
camera_modes.append((label, modes))
|
||||||
|
|
||||||
|
if not all_modes:
|
||||||
|
print(
|
||||||
|
"no matching local webcam modes found; "
|
||||||
|
f"sizes={sizes_raw} fps={fps_raw} include={include_raw!r} exclude={exclude_raw!r}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(64)
|
||||||
|
|
||||||
|
for label, modes in camera_modes:
|
||||||
|
rendered = ",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(modes))
|
||||||
|
print(f" -> local webcam {label}: {rendered}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(all_modes)), end="")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_remote_sudo_password() {
|
||||||
|
LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=""
|
||||||
|
}
|
||||||
|
trap clear_remote_sudo_password EXIT
|
||||||
|
|
||||||
|
ensure_remote_sudo_password() {
|
||||||
|
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
||||||
|
[[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0
|
||||||
|
[[ -n "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}" ]] && return 0
|
||||||
|
|
||||||
|
if [[ "${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY}" == "0" ]]; then
|
||||||
|
printf 'remote sudo password is required for automatic reconfigure; set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD or leave LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=1.\n' >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
printf 'remote sudo password is required, but stdin is not a terminal.\n' >&2
|
||||||
|
printf 'Re-run from a terminal or set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD in the environment.\n' >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Theia sudo password for %s: ' "${LESAVKA_SERVER_HOST}" >&2
|
||||||
|
IFS= read -r -s LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD
|
||||||
|
printf '\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
run_remote_root_script() {
|
||||||
|
local description=$1
|
||||||
|
shift
|
||||||
|
local script_file remote_wrapper ssh_cmd status
|
||||||
|
script_file="$(mktemp)"
|
||||||
|
cat >"${script_file}"
|
||||||
|
|
||||||
|
remote_wrapper='set -euo pipefail
|
||||||
|
read -r __lesavka_sudo_password
|
||||||
|
__lesavka_script=$(mktemp)
|
||||||
|
cleanup() { rm -f "$__lesavka_script"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
cat >"$__lesavka_script"
|
||||||
|
printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" -v
|
||||||
|
printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" bash "$__lesavka_script" "$@"
|
||||||
|
'
|
||||||
|
printf -v ssh_cmd 'bash -c %q _' "${remote_wrapper}"
|
||||||
|
for arg in "$@"; do
|
||||||
|
printf -v ssh_cmd '%s %q' "${ssh_cmd}" "${arg}"
|
||||||
|
done
|
||||||
|
|
||||||
|
set +e
|
||||||
|
{
|
||||||
|
printf '%s\n' "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}"
|
||||||
|
cat "${script_file}"
|
||||||
|
} | ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${ssh_cmd}"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
rm -f "${script_file}"
|
||||||
|
if [[ "${status}" -ne 0 ]]; then
|
||||||
|
printf '%s failed on %s with exit %s\n' "${description}" "${LESAVKA_SERVER_HOST}" "${status}" >&2
|
||||||
|
fi
|
||||||
|
return "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
prime_remote_sudo() {
|
||||||
|
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
||||||
|
[[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0
|
||||||
|
ensure_remote_sudo_password
|
||||||
|
echo "==> priming remote sudo on ${LESAVKA_SERVER_HOST}"
|
||||||
|
run_remote_root_script "remote sudo prime" <<'REMOTE_SUDO_PRIME'
|
||||||
|
set -euo pipefail
|
||||||
|
true
|
||||||
|
REMOTE_SUDO_PRIME
|
||||||
|
}
|
||||||
|
|
||||||
|
prebuild_probe_tools() {
|
||||||
|
[[ "${LESAVKA_SERVER_RC_PROBE_PREBUILD}" != "0" ]] || return 0
|
||||||
|
echo "==> prebuilding relay control/analyzer once for the mode matrix"
|
||||||
|
(
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
cargo build -p lesavka_client --bin lesavka-sync-analyze --bin lesavka-relayctl
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
reconfigure_server_mode() {
|
reconfigure_server_mode() {
|
||||||
@ -112,71 +344,281 @@ reconfigure_server_mode() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local prepare_output repo interval remote_cmd
|
if [[ "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" != "runtime" ]]; then
|
||||||
prepare_output="$(
|
printf 'unsupported LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=%s\n' "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" >&2
|
||||||
ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" bash -s -- \
|
printf 'Use runtime for fast mode changes, or LESAVKA_SERVER_RC_RECONFIGURE_COMMAND for a custom install/reconfigure command.\n' >&2
|
||||||
"${LESAVKA_SERVER_REPO}" \
|
return 64
|
||||||
"${LESAVKA_SERVER_RC_RECONFIGURE_REF}" \
|
|
||||||
"${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE}" <<'REMOTE_PREPARE'
|
|
||||||
set -euo pipefail
|
|
||||||
repo=$1
|
|
||||||
ref=$2
|
|
||||||
update_checkout=$3
|
|
||||||
if [[ "${repo}" == "auto" ]]; then
|
|
||||||
for candidate in \
|
|
||||||
"${HOME}/Development/lesavka" \
|
|
||||||
/home/theia/Development/lesavka \
|
|
||||||
/home/brad/Development/lesavka \
|
|
||||||
"${HOME}/lesavka" \
|
|
||||||
/opt/lesavka \
|
|
||||||
/srv/lesavka \
|
|
||||||
/tmp/lesavka-server-rc-matrix-source
|
|
||||||
do
|
|
||||||
if [[ -d "${candidate}/.git" && -f "${candidate}/scripts/install/server.sh" ]]; then
|
|
||||||
repo="${candidate}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [[ "${repo}" == "auto" || ! -f "${repo}/scripts/install/server.sh" ]]; then
|
|
||||||
printf 'could not find a Lesavka checkout on this server host.\n' >&2
|
|
||||||
printf 'Set LESAVKA_SERVER_REPO=/path/to/lesavka, or create one of the standard checkout paths.\n' >&2
|
|
||||||
exit 65
|
|
||||||
fi
|
|
||||||
printf ' ↪ using server repo: %s\n' "${repo}"
|
|
||||||
cd "${repo}"
|
|
||||||
if [[ "${update_checkout}" != "0" ]]; then
|
|
||||||
if [[ -n "$(git status --porcelain)" ]]; then
|
|
||||||
printf 'server checkout has local changes; refusing to update it for the mode matrix.\n' >&2
|
|
||||||
printf 'Run with LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=0, or clean/update the checkout intentionally.\n' >&2
|
|
||||||
exit 66
|
|
||||||
fi
|
|
||||||
git fetch --all --prune
|
|
||||||
git checkout "${ref}"
|
|
||||||
git pull --ff-only
|
|
||||||
else
|
|
||||||
printf ' ↪ leaving server checkout untouched; set LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=1 to update it first\n'
|
|
||||||
fi
|
|
||||||
printf '__LESAVKA_SERVER_REPO__=%s\n' "${repo}"
|
|
||||||
REMOTE_PREPARE
|
|
||||||
)"
|
|
||||||
printf '%s\n' "${prepare_output}" | sed '/^__LESAVKA_SERVER_REPO__=/d'
|
|
||||||
repo="$(awk -F= '/^__LESAVKA_SERVER_REPO__=/{print $2; exit}' <<<"${prepare_output}")"
|
|
||||||
if [[ -z "${repo}" ]]; then
|
|
||||||
printf 'could not determine the prepared server repo path on %s\n' "${LESAVKA_SERVER_HOST}" >&2
|
|
||||||
return 65
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local interval
|
||||||
interval=$((10000000 / fps))
|
interval=$((10000000 / fps))
|
||||||
printf -v remote_cmd \
|
run_remote_root_script "runtime UVC reconfigure for ${mode}" \
|
||||||
'cd %q && sudo env LESAVKA_REF=%q LESAVKA_INSTALL_UVC_CODEC=mjpeg LESAVKA_UVC_WIDTH=%q LESAVKA_UVC_HEIGHT=%q LESAVKA_UVC_FPS=%q LESAVKA_UVC_INTERVAL=%q ./scripts/install/server.sh' \
|
"${mode}" \
|
||||||
"${repo}" \
|
|
||||||
"${LESAVKA_SERVER_RC_RECONFIGURE_REF}" \
|
|
||||||
"${width}" \
|
"${width}" \
|
||||||
"${height}" \
|
"${height}" \
|
||||||
"${fps}" \
|
"${fps}" \
|
||||||
"${interval}"
|
"${interval}" \
|
||||||
ssh -tt ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${remote_cmd}"
|
"${LESAVKA_SERVER_RC_RECONFIGURE_CODEC}" \
|
||||||
|
"${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" \
|
||||||
|
"${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD}" \
|
||||||
|
"${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS}" \
|
||||||
|
"${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE}" <<'REMOTE_RECONFIGURE'
|
||||||
|
set -euo pipefail
|
||||||
|
mode=$1
|
||||||
|
width=$2
|
||||||
|
height=$3
|
||||||
|
fps=$4
|
||||||
|
interval=$5
|
||||||
|
codec=$6
|
||||||
|
allow_gadget_reset=$7
|
||||||
|
force_gadget_rebuild=$8
|
||||||
|
settle_seconds=$9
|
||||||
|
verbose=${10}
|
||||||
|
|
||||||
|
set_env_value() {
|
||||||
|
local file=$1
|
||||||
|
local key=$2
|
||||||
|
local value=$3
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
if [[ -f "${file}" ]]; then
|
||||||
|
awk -v key="${key}" -v value="${value}" '
|
||||||
|
BEGIN { wrote = 0 }
|
||||||
|
$0 ~ "^" key "=" {
|
||||||
|
print key "=" value
|
||||||
|
wrote = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
END {
|
||||||
|
if (!wrote) {
|
||||||
|
print key "=" value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "${file}" >"${tmp}"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "${key}" "${value}" >"${tmp}"
|
||||||
|
fi
|
||||||
|
install -m 0644 "${tmp}" "${file}"
|
||||||
|
rm -f "${tmp}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -x /usr/local/bin/lesavka-core.sh ]]; then
|
||||||
|
printf 'missing /usr/local/bin/lesavka-core.sh; run the server installer once before using fast runtime reconfigure.\n' >&2
|
||||||
|
exit 65
|
||||||
|
fi
|
||||||
|
if [[ ! -x /usr/local/bin/lesavka-server || ! -x /usr/local/bin/lesavka-uvc ]]; then
|
||||||
|
printf 'missing installed Lesavka binaries; run the server installer once before using fast runtime reconfigure.\n' >&2
|
||||||
|
exit 65
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0755 /etc/lesavka
|
||||||
|
touch /etc/lesavka/server.env /etc/lesavka/uvc.env
|
||||||
|
|
||||||
|
set_env_value /etc/lesavka/server.env LESAVKA_CAM_OUTPUT uvc
|
||||||
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_CODEC "${codec}"
|
||||||
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_WIDTH "${width}"
|
||||||
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_HEIGHT "${height}"
|
||||||
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_FPS "${fps}"
|
||||||
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_INTERVAL "${interval}"
|
||||||
|
|
||||||
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_CODEC "${codec}"
|
||||||
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_WIDTH "${width}"
|
||||||
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_HEIGHT "${height}"
|
||||||
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_FPS "${fps}"
|
||||||
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_INTERVAL "${interval}"
|
||||||
|
|
||||||
|
printf ' ↪ fast runtime env updated: CAM_OUTPUT=uvc UVC_MODE=%s codec=%s\n' "${mode}" "${codec}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl stop lesavka-server >/dev/null 2>&1 || true
|
||||||
|
systemctl stop lesavka-uvc >/dev/null 2>&1 || true
|
||||||
|
systemctl reset-failed lesavka-core lesavka-uvc lesavka-server >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [[ "${allow_gadget_reset}" != "0" && "${force_gadget_rebuild}" != "0" ]]; then
|
||||||
|
printf ' ↪ cycling UVC gadget descriptors for %s\n' "${mode}"
|
||||||
|
mode_log_id=$(printf '%s' "${mode}" | tr -c '[:alnum:]_.-' '_')
|
||||||
|
core_log="/tmp/lesavka-core-reconfigure-${mode_log_id}.log"
|
||||||
|
core_cmd=(
|
||||||
|
env
|
||||||
|
LESAVKA_ALLOW_GADGET_RESET=1 \
|
||||||
|
LESAVKA_FORCE_GADGET_REBUILD=1 \
|
||||||
|
LESAVKA_ATTACH_WRITE_UDC=1 \
|
||||||
|
LESAVKA_DETACH_CLEAR_UDC=1 \
|
||||||
|
LESAVKA_UVC_FALLBACK=0 \
|
||||||
|
LESAVKA_UVC_CODEC="${codec}" \
|
||||||
|
LESAVKA_UVC_WIDTH="${width}" \
|
||||||
|
LESAVKA_UVC_HEIGHT="${height}" \
|
||||||
|
LESAVKA_UVC_FPS="${fps}" \
|
||||||
|
LESAVKA_UVC_INTERVAL="${interval}" \
|
||||||
|
/usr/local/bin/lesavka-core.sh
|
||||||
|
)
|
||||||
|
if [[ "${verbose}" != "0" ]]; then
|
||||||
|
"${core_cmd[@]}"
|
||||||
|
else
|
||||||
|
if "${core_cmd[@]}" >"${core_log}" 2>&1; then
|
||||||
|
printf ' ↪ lesavka-core reconfigure log: %s\n' "${core_log}"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
printf 'lesavka-core reconfigure failed; tail of %s follows\n' "${core_log}" >&2
|
||||||
|
tail -n 80 "${core_log}" >&2 || true
|
||||||
|
exit "${rc}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [[ "${allow_gadget_reset}" != "0" ]]; then
|
||||||
|
printf ' ↪ gadget reset allowed, but force rebuild disabled; refreshing services only\n'
|
||||||
|
else
|
||||||
|
printf ' ↪ preserving attached UVC gadget; descriptors may remain on the previous mode\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl start lesavka-uvc
|
||||||
|
systemctl restart lesavka-server
|
||||||
|
sleep "${settle_seconds}"
|
||||||
|
systemctl is-active lesavka-core lesavka-uvc lesavka-server >/dev/null
|
||||||
|
printf ' ↪ services active after %s reconfigure\n' "${mode}"
|
||||||
|
REMOTE_RECONFIGURE
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_tethys_media_ready() {
|
||||||
|
local mode=$1
|
||||||
|
local width=$2
|
||||||
|
local height=$3
|
||||||
|
local fps=$4
|
||||||
|
[[ "${LESAVKA_SERVER_RC_WAIT_TETHYS_READY}" != "0" ]] || return 0
|
||||||
|
|
||||||
|
echo "==> waiting for Tethys media endpoints for ${mode}"
|
||||||
|
ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
|
||||||
|
"${mode}" \
|
||||||
|
"${width}" \
|
||||||
|
"${height}" \
|
||||||
|
"${fps}" \
|
||||||
|
"${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}" \
|
||||||
|
"${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}" \
|
||||||
|
"${REMOTE_CAPTURE_STACK}" \
|
||||||
|
"${REMOTE_AUDIO_SOURCE}" <<'REMOTE_TETHYS_READY'
|
||||||
|
set -euo pipefail
|
||||||
|
mode=$1
|
||||||
|
width=$2
|
||||||
|
height=$3
|
||||||
|
fps=$4
|
||||||
|
timeout_seconds=$5
|
||||||
|
settle_seconds=$6
|
||||||
|
capture_stack=$7
|
||||||
|
audio_source=$8
|
||||||
|
|
||||||
|
sleep "${settle_seconds}"
|
||||||
|
|
||||||
|
find_lesavka_video_device() {
|
||||||
|
if [[ -d /dev/v4l/by-id ]]; then
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -e "${path}" ]] || continue
|
||||||
|
printf '%s\n' "${path}"
|
||||||
|
return 0
|
||||||
|
done < <(find /dev/v4l/by-id -maxdepth 1 -type l -name '*Lesavka*video-index0' 2>/dev/null | sort)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||||||
|
v4l2-ctl --list-devices 2>/dev/null \
|
||||||
|
| awk '
|
||||||
|
BEGIN { want=0 }
|
||||||
|
/Lesavka Composite|Lesavka.*UVC/ { want=1; next }
|
||||||
|
/^[^ \t]/ { want=0 }
|
||||||
|
want && /^[ \t]+\/dev\/video[0-9]+/ {
|
||||||
|
gsub(/^[ \t]+/, "", $0)
|
||||||
|
print
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
video_ready() {
|
||||||
|
local dev=$1
|
||||||
|
[[ -n "${dev}" ]] || return 1
|
||||||
|
[[ -e "${dev}" ]] || return 1
|
||||||
|
if ! command -v v4l2-ctl >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local listing
|
||||||
|
listing="$(v4l2-ctl -d "${dev}" --list-formats-ext 2>/dev/null || true)"
|
||||||
|
grep -q "Size: Discrete ${width}x${height}" <<<"${listing}" || return 1
|
||||||
|
grep -Eq "(^|[^0-9])${fps}(\\.0+)? fps" <<<"${listing}" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pulse_ready() {
|
||||||
|
if ! command -v pactl >/dev/null 2>&1; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "${audio_source}" == pulse:* ]]; then
|
||||||
|
local requested=${audio_source#pulse:}
|
||||||
|
pactl list short sources 2>/dev/null | awk -v requested="${requested}" '$2 == requested { found=1 } END { exit found ? 0 : 1 }'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
pactl list short sources 2>/dev/null | grep -Eq 'alsa_input\..*Lesavka_Composite|Lesavka_Composite'
|
||||||
|
}
|
||||||
|
|
||||||
|
alsa_ready() {
|
||||||
|
if [[ "${audio_source}" == alsa:* ]]; then
|
||||||
|
[[ -e "${audio_source#alsa:}" ]] || return 1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
command -v arecord >/dev/null 2>&1 || return 1
|
||||||
|
arecord -l 2>/dev/null | grep -Eq 'Lesavka|UAC2_Gadget|UAC2Gadget|Composite'
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_ready() {
|
||||||
|
case "${capture_stack}" in
|
||||||
|
pulse)
|
||||||
|
pulse_ready
|
||||||
|
;;
|
||||||
|
alsa)
|
||||||
|
alsa_ready
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
pulse_ready || alsa_ready
|
||||||
|
;;
|
||||||
|
pwpipe)
|
||||||
|
command -v pw-dump >/dev/null 2>&1 && pw-dump 2>/dev/null | grep -Eq 'Lesavka_Composite|Lesavka Composite'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pulse_ready || alsa_ready
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline=$((SECONDS + timeout_seconds))
|
||||||
|
last_video='none'
|
||||||
|
last_audio='none'
|
||||||
|
while (( SECONDS <= deadline )); do
|
||||||
|
video_dev="$(find_lesavka_video_device || true)"
|
||||||
|
last_video="${video_dev:-none}"
|
||||||
|
if [[ -n "${video_dev}" ]] && video_ready "${video_dev}"; then
|
||||||
|
if audio_ready; then
|
||||||
|
printf ' ↪ Tethys media ready: video=%s mode=%s audio_stack=%s\n' "${video_dev}" "${mode}" "${capture_stack}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
last_audio='not-ready'
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'timed out waiting for Tethys Lesavka media endpoints for %s after %ss\n' "${mode}" "${timeout_seconds}" >&2
|
||||||
|
printf 'last video candidate: %s\n' "${last_video}" >&2
|
||||||
|
printf 'last audio candidate: %s\n' "${last_audio}" >&2
|
||||||
|
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||||||
|
v4l2-ctl --list-devices >&2 || true
|
||||||
|
if [[ "${last_video}" != "none" ]]; then
|
||||||
|
v4l2-ctl -d "${last_video}" --list-formats-ext >&2 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if command -v pactl >/dev/null 2>&1; then
|
||||||
|
pactl list short sources | grep -iE 'lesavka|uac|composite' >&2 || true
|
||||||
|
fi
|
||||||
|
if command -v arecord >/dev/null 2>&1; then
|
||||||
|
arecord -l >&2 || true
|
||||||
|
fi
|
||||||
|
exit 70
|
||||||
|
REMOTE_TETHYS_READY
|
||||||
}
|
}
|
||||||
|
|
||||||
write_mode_result() {
|
write_mode_result() {
|
||||||
@ -185,10 +627,11 @@ write_mode_result() {
|
|||||||
local height=$3
|
local height=$3
|
||||||
local fps=$4
|
local fps=$4
|
||||||
local video_delay_us=$5
|
local video_delay_us=$5
|
||||||
local run_status=$6
|
local audio_delay_us=$6
|
||||||
local run_log=$7
|
local run_status=$7
|
||||||
local artifact_dir=$8
|
local run_log=$8
|
||||||
local output_json=$9
|
local artifact_dir=$9
|
||||||
|
local output_json=${10}
|
||||||
|
|
||||||
python3 - <<'PY' \
|
python3 - <<'PY' \
|
||||||
"${mode}" \
|
"${mode}" \
|
||||||
@ -196,6 +639,7 @@ write_mode_result() {
|
|||||||
"${height}" \
|
"${height}" \
|
||||||
"${fps}" \
|
"${fps}" \
|
||||||
"${video_delay_us}" \
|
"${video_delay_us}" \
|
||||||
|
"${audio_delay_us}" \
|
||||||
"${run_status}" \
|
"${run_status}" \
|
||||||
"${run_log}" \
|
"${run_log}" \
|
||||||
"${artifact_dir}" \
|
"${artifact_dir}" \
|
||||||
@ -221,6 +665,7 @@ import sys
|
|||||||
height_raw,
|
height_raw,
|
||||||
fps_raw,
|
fps_raw,
|
||||||
video_delay_raw,
|
video_delay_raw,
|
||||||
|
audio_delay_raw,
|
||||||
run_status_raw,
|
run_status_raw,
|
||||||
run_log,
|
run_log,
|
||||||
artifact_dir_raw,
|
artifact_dir_raw,
|
||||||
@ -276,6 +721,7 @@ def nested(mapping, *keys, default=None):
|
|||||||
artifact_dir = pathlib.Path(artifact_dir_raw) if artifact_dir_raw else pathlib.Path()
|
artifact_dir = pathlib.Path(artifact_dir_raw) if artifact_dir_raw else pathlib.Path()
|
||||||
report = load_json(artifact_dir / "report.json")
|
report = load_json(artifact_dir / "report.json")
|
||||||
correlation = load_json(artifact_dir / "output-delay-correlation.json")
|
correlation = load_json(artifact_dir / "output-delay-correlation.json")
|
||||||
|
calibration = load_json(artifact_dir / "output-delay-calibration.json")
|
||||||
freshness = correlation.get("freshness") or {}
|
freshness = correlation.get("freshness") or {}
|
||||||
smoothness = correlation.get("smoothness") or {}
|
smoothness = correlation.get("smoothness") or {}
|
||||||
video = smoothness.get("video") or {}
|
video = smoothness.get("video") or {}
|
||||||
@ -328,13 +774,14 @@ artifact = {
|
|||||||
"width": as_int(width_raw),
|
"width": as_int(width_raw),
|
||||||
"height": as_int(height_raw),
|
"height": as_int(height_raw),
|
||||||
"fps": as_int(fps_raw),
|
"fps": as_int(fps_raw),
|
||||||
"audio_delay_us": 0,
|
"audio_delay_us": as_int(audio_delay_raw),
|
||||||
"video_delay_us": as_int(video_delay_raw),
|
"video_delay_us": as_int(video_delay_raw),
|
||||||
"run_status": run_status,
|
"run_status": run_status,
|
||||||
"run_log": run_log,
|
"run_log": run_log,
|
||||||
"artifact_dir": str(artifact_dir),
|
"artifact_dir": str(artifact_dir),
|
||||||
"report_json": str(artifact_dir / "report.json"),
|
"report_json": str(artifact_dir / "report.json"),
|
||||||
"correlation_json": str(artifact_dir / "output-delay-correlation.json"),
|
"correlation_json": str(artifact_dir / "output-delay-correlation.json"),
|
||||||
|
"calibration_json": str(artifact_dir / "output-delay-calibration.json"),
|
||||||
"passed": not reasons,
|
"passed": not reasons,
|
||||||
"failure_reasons": reasons,
|
"failure_reasons": reasons,
|
||||||
"sync": {
|
"sync": {
|
||||||
@ -371,6 +818,21 @@ artifact = {
|
|||||||
"audio_low_rms_windows": low_rms,
|
"audio_low_rms_windows": low_rms,
|
||||||
"audio_median_rms": as_float(nested(audio_rms, "rms_stats", "median_rms"), 0.0),
|
"audio_median_rms": as_float(nested(audio_rms, "rms_stats", "median_rms"), 0.0),
|
||||||
},
|
},
|
||||||
|
"output_delay_calibration": {
|
||||||
|
"ready": calibration.get("ready") is True,
|
||||||
|
"decision": calibration.get("decision", "unknown"),
|
||||||
|
"target": calibration.get("target", ""),
|
||||||
|
"paired_event_count": as_int(calibration.get("paired_event_count"), 0),
|
||||||
|
"measured_device_skew_ms": as_float(calibration.get("measured_device_skew_ms"), 0.0),
|
||||||
|
"p95_abs_skew_ms": as_float(calibration.get("p95_abs_skew_ms"), 0.0),
|
||||||
|
"max_abs_skew_ms": as_float(calibration.get("max_abs_skew_ms"), 0.0),
|
||||||
|
"drift_ms": as_float(calibration.get("drift_ms"), 0.0),
|
||||||
|
"audio_offset_adjust_us": as_int(calibration.get("audio_offset_adjust_us"), 0),
|
||||||
|
"video_offset_adjust_us": as_int(calibration.get("video_offset_adjust_us"), 0),
|
||||||
|
"audio_target_offset_us": as_int(calibration.get("audio_target_offset_us"), 0),
|
||||||
|
"video_target_offset_us": as_int(calibration.get("video_target_offset_us"), 0),
|
||||||
|
"note": calibration.get("note", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
pathlib.Path(output_json).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n")
|
pathlib.Path(output_json).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n")
|
||||||
PY
|
PY
|
||||||
@ -407,6 +869,7 @@ fieldnames = [
|
|||||||
"mode",
|
"mode",
|
||||||
"passed",
|
"passed",
|
||||||
"video_delay_us",
|
"video_delay_us",
|
||||||
|
"audio_delay_us",
|
||||||
"sync_status",
|
"sync_status",
|
||||||
"p95_abs_skew_ms",
|
"p95_abs_skew_ms",
|
||||||
"median_skew_ms",
|
"median_skew_ms",
|
||||||
@ -419,6 +882,12 @@ fieldnames = [
|
|||||||
"video_undecodable",
|
"video_undecodable",
|
||||||
"audio_hiccups",
|
"audio_hiccups",
|
||||||
"audio_low_rms",
|
"audio_low_rms",
|
||||||
|
"calibration_ready",
|
||||||
|
"calibration_decision",
|
||||||
|
"calibration_target",
|
||||||
|
"calibration_measured_skew_ms",
|
||||||
|
"calibration_video_target_offset_us",
|
||||||
|
"calibration_audio_target_offset_us",
|
||||||
"artifact_dir",
|
"artifact_dir",
|
||||||
]
|
]
|
||||||
with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
@ -429,6 +898,7 @@ with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
|||||||
"mode": result.get("mode"),
|
"mode": result.get("mode"),
|
||||||
"passed": result.get("passed"),
|
"passed": result.get("passed"),
|
||||||
"video_delay_us": result.get("video_delay_us"),
|
"video_delay_us": result.get("video_delay_us"),
|
||||||
|
"audio_delay_us": result.get("audio_delay_us"),
|
||||||
"sync_status": (result.get("sync") or {}).get("status"),
|
"sync_status": (result.get("sync") or {}).get("status"),
|
||||||
"p95_abs_skew_ms": (result.get("sync") or {}).get("p95_abs_skew_ms"),
|
"p95_abs_skew_ms": (result.get("sync") or {}).get("p95_abs_skew_ms"),
|
||||||
"median_skew_ms": (result.get("sync") or {}).get("median_skew_ms"),
|
"median_skew_ms": (result.get("sync") or {}).get("median_skew_ms"),
|
||||||
@ -441,6 +911,12 @@ with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
|||||||
"video_undecodable": (result.get("smoothness") or {}).get("video_undecodable_frames"),
|
"video_undecodable": (result.get("smoothness") or {}).get("video_undecodable_frames"),
|
||||||
"audio_hiccups": (result.get("smoothness") or {}).get("audio_hiccups"),
|
"audio_hiccups": (result.get("smoothness") or {}).get("audio_hiccups"),
|
||||||
"audio_low_rms": (result.get("smoothness") or {}).get("audio_low_rms_windows"),
|
"audio_low_rms": (result.get("smoothness") or {}).get("audio_low_rms_windows"),
|
||||||
|
"calibration_ready": (result.get("output_delay_calibration") or {}).get("ready"),
|
||||||
|
"calibration_decision": (result.get("output_delay_calibration") or {}).get("decision"),
|
||||||
|
"calibration_target": (result.get("output_delay_calibration") or {}).get("target"),
|
||||||
|
"calibration_measured_skew_ms": (result.get("output_delay_calibration") or {}).get("measured_device_skew_ms"),
|
||||||
|
"calibration_video_target_offset_us": (result.get("output_delay_calibration") or {}).get("video_target_offset_us"),
|
||||||
|
"calibration_audio_target_offset_us": (result.get("output_delay_calibration") or {}).get("audio_target_offset_us"),
|
||||||
"artifact_dir": result.get("artifact_dir"),
|
"artifact_dir": result.get("artifact_dir"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -453,13 +929,22 @@ for result in results:
|
|||||||
sync = result.get("sync") or {}
|
sync = result.get("sync") or {}
|
||||||
freshness = result.get("freshness") or {}
|
freshness = result.get("freshness") or {}
|
||||||
smooth = result.get("smoothness") or {}
|
smooth = result.get("smoothness") or {}
|
||||||
|
calibration = result.get("output_delay_calibration") or {}
|
||||||
marker = "PASS" if result.get("passed") else "FAIL"
|
marker = "PASS" if result.get("passed") else "FAIL"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- {marker} {result.get('mode')}: "
|
f"- {marker} {result.get('mode')}: "
|
||||||
|
f"delays video={result.get('video_delay_us', 0)}us audio={result.get('audio_delay_us', 0)}us; "
|
||||||
f"sync {sync.get('status')} p95={sync.get('p95_abs_skew_ms', 0.0):.1f}ms median={sync.get('median_skew_ms', 0.0):+.1f}ms; "
|
f"sync {sync.get('status')} p95={sync.get('p95_abs_skew_ms', 0.0):.1f}ms median={sync.get('median_skew_ms', 0.0):+.1f}ms; "
|
||||||
f"freshness {freshness.get('status')} budget={freshness.get('worst_event_age_with_uncertainty_ms') or 0.0:.1f}ms; "
|
f"freshness {freshness.get('status')} budget={freshness.get('worst_event_age_with_uncertainty_ms') or 0.0:.1f}ms; "
|
||||||
f"smooth video hiccups={smooth.get('video_hiccups', 0)} missing={smooth.get('video_estimated_missing_frames', 0)} undecodable={smooth.get('video_undecodable_frames', 0)} audio hiccups={smooth.get('audio_hiccups', 0)}"
|
f"smooth video hiccups={smooth.get('video_hiccups', 0)} missing={smooth.get('video_estimated_missing_frames', 0)} undecodable={smooth.get('video_undecodable_frames', 0)} audio hiccups={smooth.get('audio_hiccups', 0)}"
|
||||||
)
|
)
|
||||||
|
lines.append(
|
||||||
|
" calibration: "
|
||||||
|
f"{calibration.get('decision', 'unknown')} ready={calibration.get('ready', False)} "
|
||||||
|
f"target={calibration.get('target', '')} measured={calibration.get('measured_device_skew_ms', 0.0):+.1f}ms "
|
||||||
|
f"video_target={calibration.get('video_target_offset_us', 0)}us "
|
||||||
|
f"audio_target={calibration.get('audio_target_offset_us', 0)}us"
|
||||||
|
)
|
||||||
for reason in result.get("failure_reasons") or []:
|
for reason in result.get("failure_reasons") or []:
|
||||||
lines.append(f" reason: {reason}")
|
lines.append(f" reason: {reason}")
|
||||||
summary_txt.write_text("\n".join(lines) + "\n")
|
summary_txt.write_text("\n".join(lines) + "\n")
|
||||||
@ -467,26 +952,41 @@ print("\n".join(lines))
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if [[ "${LESAVKA_SERVER_RC_MODES}" == "auto" ]]; then
|
||||||
|
echo "==> discovering local webcam-backed Lesavka modes"
|
||||||
|
LESAVKA_SERVER_RC_MODES="$(discover_local_webcam_modes)"
|
||||||
|
LESAVKA_SERVER_RC_MODE_SOURCE=local-v4l2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> server-to-RC mode matrix"
|
echo "==> server-to-RC mode matrix"
|
||||||
echo " ↪ modes=${LESAVKA_SERVER_RC_MODES}"
|
echo " ↪ modes=${LESAVKA_SERVER_RC_MODES}"
|
||||||
echo " ↪ delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
echo " ↪ mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}"
|
||||||
|
echo " ↪ video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
||||||
|
echo " ↪ audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}"
|
||||||
echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}"
|
echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}"
|
||||||
|
echo " ↪ reconfigure=${LESAVKA_SERVER_RC_RECONFIGURE} strategy=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY} allow_gadget_reset=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}"
|
||||||
|
echo " ↪ tethys_ready=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY} settle=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}s timeout=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}s preroll_discard=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}s"
|
||||||
echo " ↪ artifact_dir=${MATRIX_REPORT_DIR}"
|
echo " ↪ artifact_dir=${MATRIX_REPORT_DIR}"
|
||||||
|
|
||||||
|
prime_remote_sudo
|
||||||
|
prebuild_probe_tools
|
||||||
|
|
||||||
IFS=',' read -r -a modes <<<"${LESAVKA_SERVER_RC_MODES}"
|
IFS=',' read -r -a modes <<<"${LESAVKA_SERVER_RC_MODES}"
|
||||||
for mode in "${modes[@]}"; do
|
for mode in "${modes[@]}"; do
|
||||||
mode="${mode//[[:space:]]/}"
|
mode="${mode//[[:space:]]/}"
|
||||||
[[ -n "${mode}" ]] || continue
|
[[ -n "${mode}" ]] || continue
|
||||||
read -r width height fps < <(parse_mode "${mode}")
|
read -r width height fps < <(parse_mode "${mode}")
|
||||||
video_delay_us="$(lookup_video_delay_us "${mode}")"
|
video_delay_us="$(lookup_video_delay_us "${mode}")"
|
||||||
|
audio_delay_us="$(lookup_audio_delay_us "${mode}")"
|
||||||
id="$(mode_id "${mode}")"
|
id="$(mode_id "${mode}")"
|
||||||
mode_dir="${MATRIX_REPORT_DIR}/${id}"
|
mode_dir="${MATRIX_REPORT_DIR}/${id}"
|
||||||
mode_log="${mode_dir}/mode-run.log"
|
mode_log="${mode_dir}/mode-run.log"
|
||||||
mode_result="${mode_dir}/mode-result.json"
|
mode_result="${mode_dir}/mode-result.json"
|
||||||
mkdir -p "${mode_dir}"
|
mkdir -p "${mode_dir}"
|
||||||
|
|
||||||
echo "==> mode ${mode}: video_delay_us=${video_delay_us}"
|
echo "==> mode ${mode}: video_delay_us=${video_delay_us} audio_delay_us=${audio_delay_us}"
|
||||||
reconfigure_server_mode "${mode}" "${width}" "${height}" "${fps}"
|
reconfigure_server_mode "${mode}" "${width}" "${height}" "${fps}"
|
||||||
|
wait_tethys_media_ready "${mode}" "${width}" "${height}" "${fps}"
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
TETHYS_HOST="${TETHYS_HOST}" \
|
TETHYS_HOST="${TETHYS_HOST}" \
|
||||||
@ -500,13 +1000,15 @@ for mode in "${modes[@]}"; do
|
|||||||
REMOTE_PULSE_VIDEO_MODE="${REMOTE_PULSE_VIDEO_MODE}" \
|
REMOTE_PULSE_VIDEO_MODE="${REMOTE_PULSE_VIDEO_MODE}" \
|
||||||
REMOTE_CAPTURE_STACK="${REMOTE_CAPTURE_STACK}" \
|
REMOTE_CAPTURE_STACK="${REMOTE_CAPTURE_STACK}" \
|
||||||
REMOTE_AUDIO_SOURCE="${REMOTE_AUDIO_SOURCE}" \
|
REMOTE_AUDIO_SOURCE="${REMOTE_AUDIO_SOURCE}" \
|
||||||
|
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS="${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}" \
|
||||||
|
PROBE_PREBUILD=0 \
|
||||||
VIDEO_SIZE="${width}x${height}" \
|
VIDEO_SIZE="${width}x${height}" \
|
||||||
VIDEO_FPS="${fps}" \
|
VIDEO_FPS="${fps}" \
|
||||||
VIDEO_FORMAT=mjpeg \
|
VIDEO_FORMAT=mjpeg \
|
||||||
REMOTE_EXPECT_UVC_WIDTH="${width}" \
|
REMOTE_EXPECT_UVC_WIDTH="${width}" \
|
||||||
REMOTE_EXPECT_UVC_HEIGHT="${height}" \
|
REMOTE_EXPECT_UVC_HEIGHT="${height}" \
|
||||||
REMOTE_EXPECT_UVC_FPS="${fps}" \
|
REMOTE_EXPECT_UVC_FPS="${fps}" \
|
||||||
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US="${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US}" \
|
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US="${audio_delay_us}" \
|
||||||
LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US="${video_delay_us}" \
|
LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US="${video_delay_us}" \
|
||||||
LESAVKA_OUTPUT_DELAY_APPLY=0 \
|
LESAVKA_OUTPUT_DELAY_APPLY=0 \
|
||||||
LESAVKA_OUTPUT_DELAY_SAVE=0 \
|
LESAVKA_OUTPUT_DELAY_SAVE=0 \
|
||||||
@ -526,7 +1028,7 @@ for mode in "${modes[@]}"; do
|
|||||||
if [[ -z "${artifact_dir}" ]]; then
|
if [[ -z "${artifact_dir}" ]]; then
|
||||||
artifact_dir="$(find "${mode_dir}" -mindepth 1 -maxdepth 1 -type d -name 'lesavka-output-delay-probe-*' | sort | tail -n1 || true)"
|
artifact_dir="$(find "${mode_dir}" -mindepth 1 -maxdepth 1 -type d -name 'lesavka-output-delay-probe-*' | sort | tail -n1 || true)"
|
||||||
fi
|
fi
|
||||||
write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}"
|
write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${audio_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}"
|
||||||
|
|
||||||
if [[ "${run_status}" -ne 0 && "${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL}" == "0" ]]; then
|
if [[ "${run_status}" -ne 0 && "${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL}" == "0" ]]; then
|
||||||
break
|
break
|
||||||
|
|||||||
@ -41,8 +41,11 @@ REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
|||||||
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-copy}
|
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-copy}
|
||||||
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
||||||
REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto}
|
REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto}
|
||||||
|
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}
|
||||||
ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-0}
|
ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-0}
|
||||||
ANALYSIS_SCALE_WIDTH=${ANALYSIS_SCALE_WIDTH:-1280}
|
ANALYSIS_SCALE_WIDTH=${ANALYSIS_SCALE_WIDTH:-1280}
|
||||||
|
ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1}
|
||||||
|
ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS=${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS:-1.0}
|
||||||
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
||||||
PROBE_PREBUILD=${PROBE_PREBUILD:-1}
|
PROBE_PREBUILD=${PROBE_PREBUILD:-1}
|
||||||
ANALYZE_BIN=${ANALYZE_BIN:-"${REPO_ROOT}/target/debug/lesavka-sync-analyze"}
|
ANALYZE_BIN=${ANALYZE_BIN:-"${REPO_ROOT}/target/debug/lesavka-sync-analyze"}
|
||||||
@ -553,35 +556,40 @@ server_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/server.env)
|
|||||||
runtime_uvc_width=$(read_env_value "LESAVKA_UVC_WIDTH" /etc/lesavka/uvc.env)
|
runtime_uvc_width=$(read_env_value "LESAVKA_UVC_WIDTH" /etc/lesavka/uvc.env)
|
||||||
runtime_uvc_height=$(read_env_value "LESAVKA_UVC_HEIGHT" /etc/lesavka/uvc.env)
|
runtime_uvc_height=$(read_env_value "LESAVKA_UVC_HEIGHT" /etc/lesavka/uvc.env)
|
||||||
runtime_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/uvc.env)
|
runtime_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/uvc.env)
|
||||||
|
effective_uvc_codec=${server_uvc_codec:-$runtime_uvc_codec}
|
||||||
|
effective_uvc_width=${server_uvc_width:-$runtime_uvc_width}
|
||||||
|
effective_uvc_height=${server_uvc_height:-$runtime_uvc_height}
|
||||||
|
effective_uvc_fps=${server_uvc_fps:-$runtime_uvc_fps}
|
||||||
|
|
||||||
printf ' ↪ server.env CAM_OUTPUT=%s\n' "${cam_output:-<unset>}"
|
printf ' ↪ server.env CAM_OUTPUT=%s\n' "${cam_output:-<unset>}"
|
||||||
printf ' ↪ server.env UVC_CODEC=%s\n' "${server_uvc_codec:-<unset>}"
|
printf ' ↪ server.env UVC_CODEC=%s\n' "${server_uvc_codec:-<unset>}"
|
||||||
printf ' ↪ uvc.env UVC_CODEC=%s\n' "${runtime_uvc_codec:-<unset>}"
|
printf ' ↪ uvc.env UVC_CODEC=%s\n' "${runtime_uvc_codec:-<unset>}"
|
||||||
printf ' ↪ server.env UVC_MODE=%sx%s@%s\n' "${server_uvc_width:-<unset>}" "${server_uvc_height:-<unset>}" "${server_uvc_fps:-<unset>}"
|
printf ' ↪ server.env UVC_MODE=%sx%s@%s\n' "${server_uvc_width:-<unset>}" "${server_uvc_height:-<unset>}" "${server_uvc_fps:-<unset>}"
|
||||||
printf ' ↪ uvc.env UVC_MODE=%sx%s@%s\n' "${runtime_uvc_width:-<unset>}" "${runtime_uvc_height:-<unset>}" "${runtime_uvc_fps:-<unset>}"
|
printf ' ↪ uvc.env UVC_MODE=%sx%s@%s\n' "${runtime_uvc_width:-<unset>}" "${runtime_uvc_height:-<unset>}" "${runtime_uvc_fps:-<unset>}"
|
||||||
|
printf ' ↪ effective UVC_MODE=%sx%s@%s\n' "${effective_uvc_width:-<unset>}" "${effective_uvc_height:-<unset>}" "${effective_uvc_fps:-<unset>}"
|
||||||
|
|
||||||
if [[ -n "${expect_cam_output}" && "${cam_output}" != "${expect_cam_output}" ]]; then
|
if [[ -n "${expect_cam_output}" && "${cam_output}" != "${expect_cam_output}" ]]; then
|
||||||
printf 'expected CAM_OUTPUT=%s but found %s\n' "${expect_cam_output}" "${cam_output:-<unset>}" >&2
|
printf 'expected CAM_OUTPUT=%s but found %s\n' "${expect_cam_output}" "${cam_output:-<unset>}" >&2
|
||||||
exit 64
|
exit 64
|
||||||
fi
|
fi
|
||||||
if [[ -n "${expect_uvc_codec}" && "${server_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
if [[ -n "${expect_uvc_codec}" && "${effective_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
||||||
printf 'expected server.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${server_uvc_codec:-<unset>}" >&2
|
printf 'expected effective UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${effective_uvc_codec:-<unset>}" >&2
|
||||||
exit 65
|
exit 65
|
||||||
fi
|
fi
|
||||||
if [[ -n "${expect_uvc_codec}" && "${runtime_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
if [[ -n "${expect_uvc_codec}" && "${runtime_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
||||||
printf 'expected uvc.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${runtime_uvc_codec:-<unset>}" >&2
|
printf 'expected uvc.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${runtime_uvc_codec:-<unset>}" >&2
|
||||||
exit 66
|
exit 66
|
||||||
fi
|
fi
|
||||||
if [[ -n "${expect_uvc_width}" && "${server_uvc_width}" != "${expect_uvc_width}" ]]; then
|
if [[ -n "${expect_uvc_width}" && "${effective_uvc_width}" != "${expect_uvc_width}" ]]; then
|
||||||
printf 'expected server.env UVC_WIDTH=%s but found %s\n' "${expect_uvc_width}" "${server_uvc_width:-<unset>}" >&2
|
printf 'expected effective UVC_WIDTH=%s but found %s\n' "${expect_uvc_width}" "${effective_uvc_width:-<unset>}" >&2
|
||||||
exit 67
|
exit 67
|
||||||
fi
|
fi
|
||||||
if [[ -n "${expect_uvc_height}" && "${server_uvc_height}" != "${expect_uvc_height}" ]]; then
|
if [[ -n "${expect_uvc_height}" && "${effective_uvc_height}" != "${expect_uvc_height}" ]]; then
|
||||||
printf 'expected server.env UVC_HEIGHT=%s but found %s\n' "${expect_uvc_height}" "${server_uvc_height:-<unset>}" >&2
|
printf 'expected effective UVC_HEIGHT=%s but found %s\n' "${expect_uvc_height}" "${effective_uvc_height:-<unset>}" >&2
|
||||||
exit 68
|
exit 68
|
||||||
fi
|
fi
|
||||||
if [[ -n "${expect_uvc_fps}" && "${server_uvc_fps}" != "${expect_uvc_fps}" ]]; then
|
if [[ -n "${expect_uvc_fps}" && "${effective_uvc_fps}" != "${expect_uvc_fps}" ]]; then
|
||||||
printf 'expected server.env UVC_FPS=%s but found %s\n' "${expect_uvc_fps}" "${server_uvc_fps:-<unset>}" >&2
|
printf 'expected effective UVC_FPS=%s but found %s\n' "${expect_uvc_fps}" "${effective_uvc_fps:-<unset>}" >&2
|
||||||
exit 69
|
exit 69
|
||||||
fi
|
fi
|
||||||
if [[ -n "${expect_uvc_width}" && "${runtime_uvc_width}" != "${expect_uvc_width}" ]]; then
|
if [[ -n "${expect_uvc_width}" && "${runtime_uvc_width}" != "${expect_uvc_width}" ]]; then
|
||||||
@ -851,6 +859,81 @@ timeline_path.write_text(json.dumps(timeline, indent=2, sort_keys=True) + "\n")
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compute_analysis_window_arg() {
|
||||||
|
[[ "${ANALYSIS_TIMELINE_WINDOW}" != "0" ]] || return 0
|
||||||
|
[[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0
|
||||||
|
[[ -f "${LOCAL_CAPTURE_LOG}" ]] || return 0
|
||||||
|
[[ -f "${LOCAL_CLOCK_ALIGNMENT_JSON}" ]] || return 0
|
||||||
|
|
||||||
|
python3 - <<'PY' \
|
||||||
|
"${LOCAL_SERVER_TIMELINE_JSON}" \
|
||||||
|
"${LOCAL_CAPTURE_LOG}" \
|
||||||
|
"${LOCAL_CLOCK_ALIGNMENT_JSON}" \
|
||||||
|
"${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS}" || true
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
timeline_path, capture_log_path, clock_path, padding_raw = sys.argv[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def as_int(value):
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def as_float(value, default):
|
||||||
|
try:
|
||||||
|
parsed = float(str(value).strip())
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
return parsed if math.isfinite(parsed) else default
|
||||||
|
|
||||||
|
|
||||||
|
def capture_start_ns(path):
|
||||||
|
try:
|
||||||
|
lines = pathlib.Path(path).read_text(errors="replace").splitlines()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("capture_start_unix_ns="):
|
||||||
|
return as_int(line.split("=", 1)[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeline = json.loads(pathlib.Path(timeline_path).read_text())
|
||||||
|
clock = json.loads(pathlib.Path(clock_path).read_text())
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
capture_start = capture_start_ns(capture_log_path)
|
||||||
|
offset = as_int(clock.get("theia_to_tethys_offset_ns")) if clock.get("available") else None
|
||||||
|
if capture_start is None or offset is None:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
times = []
|
||||||
|
for event in timeline.get("events", []):
|
||||||
|
for key in ("audio_push_unix_ns", "video_feed_unix_ns"):
|
||||||
|
event_ns = as_int(event.get(key))
|
||||||
|
if event_ns is not None:
|
||||||
|
times.append((event_ns + offset - capture_start) / 1_000_000_000.0)
|
||||||
|
|
||||||
|
if not times:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
padding = max(0.0, as_float(padding_raw, 1.0))
|
||||||
|
start = max(0.0, min(times) - padding)
|
||||||
|
end = max(times) + padding
|
||||||
|
if end <= start:
|
||||||
|
raise SystemExit(0)
|
||||||
|
print(f"--analysis-window-s {start:.3f}:{end:.3f}")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
write_output_delay_correlation() {
|
write_output_delay_correlation() {
|
||||||
[[ -f "${LOCAL_ANALYSIS_JSON}" ]] || return 0
|
[[ -f "${LOCAL_ANALYSIS_JSON}" ]] || return 0
|
||||||
[[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0
|
[[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0
|
||||||
@ -998,6 +1081,7 @@ def stats(rows, key):
|
|||||||
"first_ms": None,
|
"first_ms": None,
|
||||||
"last_ms": None,
|
"last_ms": None,
|
||||||
"mean_ms": None,
|
"mean_ms": None,
|
||||||
|
"min_ms": None,
|
||||||
"median_ms": None,
|
"median_ms": None,
|
||||||
"p95_ms": None,
|
"p95_ms": None,
|
||||||
"max_ms": None,
|
"max_ms": None,
|
||||||
@ -1008,6 +1092,7 @@ def stats(rows, key):
|
|||||||
"first_ms": values[0],
|
"first_ms": values[0],
|
||||||
"last_ms": values[-1],
|
"last_ms": values[-1],
|
||||||
"mean_ms": sum(values) / len(values),
|
"mean_ms": sum(values) / len(values),
|
||||||
|
"min_ms": min(values),
|
||||||
"median_ms": percentile(values, 50.0),
|
"median_ms": percentile(values, 50.0),
|
||||||
"p95_ms": percentile(values, 95.0),
|
"p95_ms": percentile(values, 95.0),
|
||||||
"max_ms": max(values),
|
"max_ms": max(values),
|
||||||
@ -1018,7 +1103,7 @@ def shift_stats(base, delta_ms):
|
|||||||
shifted = dict(base)
|
shifted = dict(base)
|
||||||
if not shifted.get("available"):
|
if not shifted.get("available"):
|
||||||
return shifted
|
return shifted
|
||||||
for key in ["first_ms", "last_ms", "mean_ms", "median_ms", "p95_ms", "max_ms"]:
|
for key in ["first_ms", "last_ms", "mean_ms", "min_ms", "median_ms", "p95_ms", "max_ms"]:
|
||||||
value = shifted.get(key)
|
value = shifted.get(key)
|
||||||
shifted[key] = value + delta_ms if value is not None else None
|
shifted[key] = value + delta_ms if value is not None else None
|
||||||
shifted["intentional_delay_ms"] = delta_ms
|
shifted["intentional_delay_ms"] = delta_ms
|
||||||
@ -1480,6 +1565,8 @@ audio_freshness_stats = stats(joined, "audio_freshness_ms")
|
|||||||
video_event_age_stats = shift_stats(video_freshness_stats, video_delay_ms)
|
video_event_age_stats = shift_stats(video_freshness_stats, video_delay_ms)
|
||||||
audio_event_age_stats = shift_stats(audio_freshness_stats, audio_delay_ms)
|
audio_event_age_stats = shift_stats(audio_freshness_stats, audio_delay_ms)
|
||||||
server_observed_correlation = correlation(joined, "server_feed_delta_ms", "observed_skew_ms")
|
server_observed_correlation = correlation(joined, "server_feed_delta_ms", "observed_skew_ms")
|
||||||
|
sync_verdict = report.get("verdict") or {}
|
||||||
|
sync_passed = sync_verdict.get("passed") is True
|
||||||
|
|
||||||
observed_drift = observed_model.get("drift_ms", 0.0)
|
observed_drift = observed_model.get("drift_ms", 0.0)
|
||||||
server_drift = server_model.get("drift_ms", 0.0)
|
server_drift = server_model.get("drift_ms", 0.0)
|
||||||
@ -1532,15 +1619,37 @@ freshness_worst_event_with_uncertainty_ms = (
|
|||||||
if freshness_worst_event_p95_ms is not None
|
if freshness_worst_event_p95_ms is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
event_age_min_values = [
|
||||||
|
value
|
||||||
|
for value in [
|
||||||
|
video_event_age_stats.get("min_ms"),
|
||||||
|
audio_event_age_stats.get("min_ms"),
|
||||||
|
]
|
||||||
|
if value is not None and math.isfinite(value)
|
||||||
|
]
|
||||||
|
freshness_min_event_age_ms = min(event_age_min_values) if event_age_min_values else None
|
||||||
if not event_age_p95_values:
|
if not event_age_p95_values:
|
||||||
freshness_status = "unknown"
|
freshness_status = "unknown"
|
||||||
freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available"
|
freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available"
|
||||||
|
elif not sync_passed:
|
||||||
|
freshness_status = "unknown"
|
||||||
|
freshness_reason = (
|
||||||
|
"sync did not pass, so freshness from paired signatures is not trustworthy: "
|
||||||
|
f"{sync_verdict.get('status', 'unknown')} - {sync_verdict.get('reason', '')}"
|
||||||
|
)
|
||||||
elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms:
|
elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms:
|
||||||
freshness_status = "unknown"
|
freshness_status = "unknown"
|
||||||
freshness_reason = (
|
freshness_reason = (
|
||||||
f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds "
|
f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds "
|
||||||
f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit"
|
f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit"
|
||||||
)
|
)
|
||||||
|
elif freshness_min_event_age_ms is not None and freshness_min_event_age_ms < -clock_uncertainty_ms:
|
||||||
|
freshness_status = "invalid"
|
||||||
|
freshness_reason = (
|
||||||
|
f"one media stream has impossible negative RC event age beyond clock uncertainty: "
|
||||||
|
f"minimum event age {freshness_min_event_age_ms:.1f} ms, uncertainty "
|
||||||
|
f"{clock_uncertainty_ms:.1f} ms"
|
||||||
|
)
|
||||||
elif freshness_worst_event_p95_ms < -clock_uncertainty_ms:
|
elif freshness_worst_event_p95_ms < -clock_uncertainty_ms:
|
||||||
freshness_status = "invalid"
|
freshness_status = "invalid"
|
||||||
freshness_reason = (
|
freshness_reason = (
|
||||||
@ -1595,6 +1704,8 @@ artifact = {
|
|||||||
"worst_p95_path_freshness_ms": freshness_worst_p95_ms,
|
"worst_p95_path_freshness_ms": freshness_worst_p95_ms,
|
||||||
"worst_event_age_p95_ms": freshness_worst_event_p95_ms,
|
"worst_event_age_p95_ms": freshness_worst_event_p95_ms,
|
||||||
"worst_event_age_with_uncertainty_ms": freshness_worst_event_with_uncertainty_ms,
|
"worst_event_age_with_uncertainty_ms": freshness_worst_event_with_uncertainty_ms,
|
||||||
|
"minimum_event_age_ms": freshness_min_event_age_ms,
|
||||||
|
"sync_passed": sync_passed,
|
||||||
"worst_p95_freshness_ms": freshness_worst_event_p95_ms,
|
"worst_p95_freshness_ms": freshness_worst_event_p95_ms,
|
||||||
"worst_freshness_drift_ms": freshness_worst_drift_ms,
|
"worst_freshness_drift_ms": freshness_worst_drift_ms,
|
||||||
"video_freshness_stats": video_freshness_stats,
|
"video_freshness_stats": video_freshness_stats,
|
||||||
@ -1867,6 +1978,7 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
|
|||||||
"${REMOTE_PULSE_VIDEO_MODE}" \
|
"${REMOTE_PULSE_VIDEO_MODE}" \
|
||||||
"${REMOTE_AUDIO_SOURCE}" \
|
"${REMOTE_AUDIO_SOURCE}" \
|
||||||
"${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" \
|
"${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" \
|
||||||
|
"${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \
|
||||||
> >(tee "${LOCAL_CAPTURE_LOG}") \
|
> >(tee "${LOCAL_CAPTURE_LOG}") \
|
||||||
2> >(tee -a "${LOCAL_CAPTURE_LOG}" >&2) <<'REMOTE_CAPTURE_SCRIPT' &
|
2> >(tee -a "${LOCAL_CAPTURE_LOG}" >&2) <<'REMOTE_CAPTURE_SCRIPT' &
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -1881,6 +1993,7 @@ remote_pulse_capture_tool=$8
|
|||||||
remote_pulse_video_mode=$9
|
remote_pulse_video_mode=$9
|
||||||
remote_audio_source=${10}
|
remote_audio_source=${10}
|
||||||
remote_audio_quiesce_user_audio=${11}
|
remote_audio_quiesce_user_audio=${11}
|
||||||
|
remote_capture_preroll_discard_seconds=${12}
|
||||||
|
|
||||||
rm -f "${remote_capture}"
|
rm -f "${remote_capture}"
|
||||||
|
|
||||||
@ -2213,7 +2326,6 @@ resolved_video_size="$(resolve_video_size "${video_size}")"
|
|||||||
resolved_video_fps="$(resolve_video_fps "${video_fps}")"
|
resolved_video_fps="$(resolve_video_fps "${video_fps}")"
|
||||||
printf 'using video device: %s\n' "${resolved_video_device}" >&2
|
printf 'using video device: %s\n' "${resolved_video_device}" >&2
|
||||||
printf 'using video mode: %s @ %s fps (%s)\n' "${resolved_video_size}" "${resolved_video_fps}" "${video_format:-driver-default}" >&2
|
printf 'using video mode: %s @ %s fps (%s)\n' "${resolved_video_size}" "${resolved_video_fps}" "${video_format:-driver-default}" >&2
|
||||||
printf '%s\n' "__LESAVKA_CAPTURE_READY__"
|
|
||||||
video_args=(-f video4linux2 -framerate "${resolved_video_fps}" -video_size "${resolved_video_size}")
|
video_args=(-f video4linux2 -framerate "${resolved_video_fps}" -video_size "${resolved_video_size}")
|
||||||
if [[ -n "${video_format}" ]]; then
|
if [[ -n "${video_format}" ]]; then
|
||||||
video_args+=(-input_format "${video_format}")
|
video_args+=(-input_format "${video_format}")
|
||||||
@ -2223,7 +2335,7 @@ gst_decode_chain="$(gst_video_decode_chain)"
|
|||||||
|
|
||||||
run_ffmpeg_capture() {
|
run_ffmpeg_capture() {
|
||||||
local rc=0
|
local rc=0
|
||||||
timeout --signal=INT "$((capture_seconds + 5))" "$@" || rc=$?
|
timeout --kill-after=5 --signal=INT "$((capture_seconds + 5))" "$@" </dev/null || rc=$?
|
||||||
case "${rc}" in
|
case "${rc}" in
|
||||||
0|124|130)
|
0|124|130)
|
||||||
return 0
|
return 0
|
||||||
@ -2259,6 +2371,53 @@ if [[ "${capture_mode}" == "alsa" && "${quiesce_for_alsa}" == "1" ]]; then
|
|||||||
trap restore_user_audio EXIT
|
trap restore_user_audio EXIT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
discard_preroll_capture() {
|
||||||
|
local seconds=$1
|
||||||
|
[[ "${seconds}" =~ ^[0-9]+$ ]] || return 0
|
||||||
|
[[ "${seconds}" -gt 0 ]] || return 0
|
||||||
|
local discard="/tmp/lesavka-output-delay-preroll-discard-$RANDOM.mkv"
|
||||||
|
printf 'discarding %ss of post-enumeration capture before probe\n' "${seconds}" >&2
|
||||||
|
case "${capture_mode}" in
|
||||||
|
pulse)
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
timeout --kill-after=3 --signal=INT "$((seconds + 5))" ffmpeg -nostdin -hide_banner -loglevel error -y \
|
||||||
|
-thread_queue_size 1024 \
|
||||||
|
"${video_args[@]}" \
|
||||||
|
-i "${resolved_video_device}" \
|
||||||
|
-thread_queue_size 1024 \
|
||||||
|
-f pulse \
|
||||||
|
-i "${pulse_source}" \
|
||||||
|
-t "${seconds}" \
|
||||||
|
-c:v copy \
|
||||||
|
-c:a pcm_s16le \
|
||||||
|
"${discard}" </dev/null >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
alsa)
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
timeout --kill-after=3 --signal=INT "$((seconds + 5))" ffmpeg -nostdin -hide_banner -loglevel error -y \
|
||||||
|
-thread_queue_size 1024 \
|
||||||
|
"${video_args[@]}" \
|
||||||
|
-i "${resolved_video_device}" \
|
||||||
|
-thread_queue_size 1024 \
|
||||||
|
-f alsa -ac 2 -ar 48000 \
|
||||||
|
-i "${alsa_audio_dev}" \
|
||||||
|
-t "${seconds}" \
|
||||||
|
-c:v copy \
|
||||||
|
-c:a pcm_s16le \
|
||||||
|
"${discard}" </dev/null >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
sleep "${seconds}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "${discard}"
|
||||||
|
}
|
||||||
|
|
||||||
|
discard_preroll_capture "${remote_capture_preroll_discard_seconds}"
|
||||||
|
|
||||||
|
printf '%s\n' "__LESAVKA_CAPTURE_READY__"
|
||||||
printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2
|
printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2
|
||||||
|
|
||||||
if [[ "${capture_mode}" == "pwpipe" ]]; then
|
if [[ "${capture_mode}" == "pwpipe" ]]; then
|
||||||
@ -2293,6 +2452,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
-thread_queue_size 1024 \
|
-thread_queue_size 1024 \
|
||||||
-f pulse \
|
-f pulse \
|
||||||
-i "${pulse_source}" \
|
-i "${pulse_source}" \
|
||||||
|
-f lavfi \
|
||||||
|
-i anullsrc=channel_layout=stereo:sample_rate=48000 \
|
||||||
|
-filter_complex "[1:a][2:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]" \
|
||||||
|
-map 0:v:0 \
|
||||||
|
-map "[aout]" \
|
||||||
-t "${capture_seconds}" \
|
-t "${capture_seconds}" \
|
||||||
-c:v copy \
|
-c:v copy \
|
||||||
-c:a pcm_s16le \
|
-c:a pcm_s16le \
|
||||||
@ -2306,6 +2470,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
-thread_queue_size 1024 \
|
-thread_queue_size 1024 \
|
||||||
-f pulse \
|
-f pulse \
|
||||||
-i "${pulse_source}" \
|
-i "${pulse_source}" \
|
||||||
|
-f lavfi \
|
||||||
|
-i anullsrc=channel_layout=stereo:sample_rate=48000 \
|
||||||
|
-filter_complex "[1:a][2:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]" \
|
||||||
|
-map 0:v:0 \
|
||||||
|
-map "[aout]" \
|
||||||
-t "${capture_seconds}" \
|
-t "${capture_seconds}" \
|
||||||
-vf "fps=${resolved_video_fps}" \
|
-vf "fps=${resolved_video_fps}" \
|
||||||
-c:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \
|
-c:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \
|
||||||
@ -2325,18 +2494,24 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2
|
printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2
|
||||||
exit 64
|
exit 64
|
||||||
fi
|
fi
|
||||||
timeout --signal=INT "$((capture_seconds + 3))" \
|
timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
||||||
gst-launch-1.0 -q -e \
|
gst-launch-1.0 -q -e \
|
||||||
matroskamux name=mux ! filesink location="${remote_capture}" \
|
matroskamux name=mux ! filesink location="${remote_capture}" \
|
||||||
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
||||||
${gst_source_caps} ! \
|
${gst_source_caps} ! \
|
||||||
queue ! mux. \
|
queue ! mux. \
|
||||||
|
audiotestsrc wave=silence is-live=true samplesperbuffer=480 ! \
|
||||||
|
audio/x-raw,rate=48000,channels=2 ! \
|
||||||
|
queue ! mix. \
|
||||||
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
||||||
audio/x-raw,rate=48000,channels=2 ! \
|
audio/x-raw,rate=48000,channels=2 ! \
|
||||||
audioconvert ! audioresample ! queue ! mux. || true
|
audioconvert ! audioresample ! queue ! mix. \
|
||||||
|
audiomixer name=mix ! \
|
||||||
|
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
||||||
|
queue ! mux. || true
|
||||||
;;
|
;;
|
||||||
cfr)
|
cfr)
|
||||||
timeout --signal=INT "$((capture_seconds + 3))" \
|
timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
||||||
gst-launch-1.0 -q -e \
|
gst-launch-1.0 -q -e \
|
||||||
matroskamux name=mux ! filesink location="${remote_capture}" \
|
matroskamux name=mux ! filesink location="${remote_capture}" \
|
||||||
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
||||||
@ -2346,9 +2521,15 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
x264enc tune=zerolatency speed-preset=ultrafast key-int-max=1 bitrate=5000 ! \
|
x264enc tune=zerolatency speed-preset=ultrafast key-int-max=1 bitrate=5000 ! \
|
||||||
h264parse ! \
|
h264parse ! \
|
||||||
queue ! mux. \
|
queue ! mux. \
|
||||||
|
audiotestsrc wave=silence is-live=true samplesperbuffer=480 ! \
|
||||||
|
audio/x-raw,rate=48000,channels=2 ! \
|
||||||
|
queue ! mix. \
|
||||||
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
||||||
audio/x-raw,rate=48000,channels=2 ! \
|
audio/x-raw,rate=48000,channels=2 ! \
|
||||||
audioconvert ! audioresample ! queue ! mux. || true
|
audioconvert ! audioresample ! queue ! mix. \
|
||||||
|
audiomixer name=mix ! \
|
||||||
|
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
||||||
|
queue ! mux. || true
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
printf 'unsupported REMOTE_PULSE_VIDEO_MODE=%s\n' "${remote_pulse_video_mode}" >&2
|
printf 'unsupported REMOTE_PULSE_VIDEO_MODE=%s\n' "${remote_pulse_video_mode}" >&2
|
||||||
@ -2475,9 +2656,13 @@ REMOTE_NORMALIZE_SCRIPT
|
|||||||
echo "==> copying sync analyzer to ${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}"
|
echo "==> copying sync analyzer to ${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}"
|
||||||
scp ${SSH_OPTS} "${ANALYZE_BIN}" "${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}"
|
scp ${SSH_OPTS} "${ANALYZE_BIN}" "${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}"
|
||||||
fi
|
fi
|
||||||
|
analysis_window_arg="$(compute_analysis_window_arg)"
|
||||||
|
if [[ -n "${analysis_window_arg}" ]]; then
|
||||||
|
echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }"
|
||||||
|
fi
|
||||||
echo "==> analyzing capture on ${TETHYS_HOST}"
|
echo "==> analyzing capture on ${TETHYS_HOST}"
|
||||||
ssh ${SSH_OPTS} "${TETHYS_HOST}" \
|
ssh ${SSH_OPTS} "${TETHYS_HOST}" \
|
||||||
"chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}'" \
|
"chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}' ${analysis_window_arg}" \
|
||||||
> "${LOCAL_ANALYSIS_JSON}"
|
> "${LOCAL_ANALYSIS_JSON}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -2570,9 +2755,14 @@ else
|
|||||||
exit 93
|
exit 93
|
||||||
fi
|
fi
|
||||||
echo "==> analyzing capture"
|
echo "==> analyzing capture"
|
||||||
|
analysis_window_arg="$(compute_analysis_window_arg)"
|
||||||
|
if [[ -n "${analysis_window_arg}" ]]; then
|
||||||
|
echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }"
|
||||||
|
fi
|
||||||
(
|
(
|
||||||
cd "${REPO_ROOT}"
|
cd "${REPO_ROOT}"
|
||||||
"${ANALYZE_BIN}" "${LOCAL_CAPTURE}" --report-dir "${LOCAL_REPORT_DIR}" --event-width-codes "${PROBE_EVENT_WIDTH_CODES}"
|
# shellcheck disable=SC2086
|
||||||
|
"${ANALYZE_BIN}" "${LOCAL_CAPTURE}" --report-dir "${LOCAL_REPORT_DIR}" --event-width-codes "${PROBE_EVENT_WIDTH_CODES}" ${analysis_window_arg}
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.19.14"
|
version = "0.19.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -56,12 +56,19 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS:-1000}",
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS:-1000}",
|
||||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}",
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}",
|
||||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}",
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}",
|
||||||
|
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}",
|
||||||
|
"discarding %ss of post-enumeration capture before probe",
|
||||||
|
"ffmpeg -nostdin -hide_banner",
|
||||||
|
"\"$@\" </dev/null",
|
||||||
|
"timeout --kill-after=5 --signal=INT",
|
||||||
|
"timeout --kill-after=3 --signal=INT",
|
||||||
"REMOTE_EXPECT_UVC_WIDTH=${REMOTE_EXPECT_UVC_WIDTH:-}",
|
"REMOTE_EXPECT_UVC_WIDTH=${REMOTE_EXPECT_UVC_WIDTH:-}",
|
||||||
"REMOTE_EXPECT_UVC_HEIGHT=${REMOTE_EXPECT_UVC_HEIGHT:-}",
|
"REMOTE_EXPECT_UVC_HEIGHT=${REMOTE_EXPECT_UVC_HEIGHT:-}",
|
||||||
"REMOTE_EXPECT_UVC_FPS=${REMOTE_EXPECT_UVC_FPS:-}",
|
"REMOTE_EXPECT_UVC_FPS=${REMOTE_EXPECT_UVC_FPS:-}",
|
||||||
"server.env UVC_MODE=",
|
"server.env UVC_MODE=",
|
||||||
"uvc.env UVC_MODE=",
|
"uvc.env UVC_MODE=",
|
||||||
"expected server.env UVC_WIDTH",
|
"effective UVC_MODE=",
|
||||||
|
"expected effective UVC_WIDTH",
|
||||||
"expected uvc.env UVC_FPS",
|
"expected uvc.env UVC_FPS",
|
||||||
"server-to-capture clock alignment unavailable; falling back to client-mediated SSH samples",
|
"server-to-capture clock alignment unavailable; falling back to client-mediated SSH samples",
|
||||||
"LESAVKA_CLOCK_ALIGNMENT_SAMPLES=${LESAVKA_CLOCK_ALIGNMENT_SAMPLES:-5}",
|
"LESAVKA_CLOCK_ALIGNMENT_SAMPLES=${LESAVKA_CLOCK_ALIGNMENT_SAMPLES:-5}",
|
||||||
@ -103,6 +110,10 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"intentional_video_delay_ms",
|
"intentional_video_delay_ms",
|
||||||
"video_event_age_stats",
|
"video_event_age_stats",
|
||||||
"worst_event_age_with_uncertainty_ms",
|
"worst_event_age_with_uncertainty_ms",
|
||||||
|
"minimum_event_age_ms",
|
||||||
|
"sync_passed",
|
||||||
|
"sync did not pass, so freshness from paired signatures is not trustworthy",
|
||||||
|
"impossible negative RC event age",
|
||||||
"video_delay_function_candidate",
|
"video_delay_function_candidate",
|
||||||
"source\": \"direct-uvc-uac-output-probe\"",
|
"source\": \"direct-uvc-uac-output-probe\"",
|
||||||
"scope\": \"server-output-static-baseline\"",
|
"scope\": \"server-output-static-baseline\"",
|
||||||
@ -133,6 +144,9 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"calibration-save-default",
|
"calibration-save-default",
|
||||||
"LEAD_IN_SECONDS=${LEAD_IN_SECONDS:-0}",
|
"LEAD_IN_SECONDS=${LEAD_IN_SECONDS:-0}",
|
||||||
"PROBE_TIMEOUT_SECONDS=${PROBE_TIMEOUT_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + 20))}",
|
"PROBE_TIMEOUT_SECONDS=${PROBE_TIMEOUT_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + 20))}",
|
||||||
|
"ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1}",
|
||||||
|
"compute_analysis_window_arg",
|
||||||
|
"analyzer timeline window:",
|
||||||
"output-delay-probe",
|
"output-delay-probe",
|
||||||
"server-generated UVC/UAC output-delay probe",
|
"server-generated UVC/UAC output-delay probe",
|
||||||
"server output-delay probe timed out after ${PROBE_TIMEOUT_SECONDS}s",
|
"server output-delay probe timed out after ${PROBE_TIMEOUT_SECONDS}s",
|
||||||
@ -147,6 +161,11 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"resolve_alsa_audio_device",
|
"resolve_alsa_audio_device",
|
||||||
"PipeWire Lesavka source not found; falling back to ALSA device",
|
"PipeWire Lesavka source not found; falling back to ALSA device",
|
||||||
"Lesavka audio source not found in PipeWire or ALSA; capture host does not currently expose the gadget microphone.",
|
"Lesavka audio source not found in PipeWire or ALSA; capture host does not currently expose the gadget microphone.",
|
||||||
|
"discarding %ss of post-enumeration capture before probe",
|
||||||
|
"audiotestsrc wave=silence is-live=true samplesperbuffer=480",
|
||||||
|
"audiomixer name=mix",
|
||||||
|
"anullsrc=channel_layout=stereo:sample_rate=48000",
|
||||||
|
"amix=inputs=2:duration=longest",
|
||||||
"artifact_dir: ${LOCAL_REPORT_DIR}",
|
"artifact_dir: ${LOCAL_REPORT_DIR}",
|
||||||
"events_csv: ${LOCAL_EVENTS_CSV}",
|
"events_csv: ${LOCAL_EVENTS_CSV}",
|
||||||
"server_timeline_json: ${LOCAL_SERVER_TIMELINE_JSON}",
|
"server_timeline_json: ${LOCAL_SERVER_TIMELINE_JSON}",
|
||||||
@ -186,18 +205,47 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
||||||
for expected in [
|
for expected in [
|
||||||
"LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-640x480@20,1280x720@30,1920x1080@20,1920x1080@30}",
|
"LESAVKA_SERVER_RC_CORE_WEBCAM_MODES=${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30}",
|
||||||
|
"LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES}}",
|
||||||
"LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto}",
|
"LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto}",
|
||||||
|
"LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US:-${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}}",
|
||||||
"LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-170000}",
|
"LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-170000}",
|
||||||
"LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-640x480@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}",
|
"LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US:-1280x720@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1280x720@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}}",
|
||||||
|
"LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-1280x720@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}",
|
||||||
|
"LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES=${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES:-1280x720,1920x1080}",
|
||||||
|
"LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS=${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS:-20,30}",
|
||||||
|
"LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX:-Logitech|BRIO|C9[0-9]+|HD UVC WebCam|USB2[.]0 HD|Integrated Camera|Webcam|Camera}",
|
||||||
|
"LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX:-Lesavka|UGREEN|MACROSILICON|Composite|Capture}",
|
||||||
|
"LESAVKA_SERVER_RC_MODE_SOURCE=${LESAVKA_SERVER_RC_MODE_SOURCE:-configured}",
|
||||||
"LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}",
|
"LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}",
|
||||||
"LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}",
|
"LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}",
|
||||||
"could not find a Lesavka checkout on this server host",
|
"LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime}",
|
||||||
"Set LESAVKA_SERVER_REPO=/path/to/lesavka",
|
"LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1}",
|
||||||
"/home/theia/Development/lesavka",
|
"LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE=${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE:-0}",
|
||||||
"using server repo:",
|
"LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1}",
|
||||||
"server checkout has local changes; refusing to update it for the mode matrix",
|
"LESAVKA_SERVER_RC_WAIT_TETHYS_READY=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY:-1}",
|
||||||
"leaving server checkout untouched",
|
"LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS:-60}",
|
||||||
|
"LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS:-6}",
|
||||||
|
"LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS:-3}",
|
||||||
|
"LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}",
|
||||||
|
"Theia sudo password for %s",
|
||||||
|
"==> priming remote sudo on ${LESAVKA_SERVER_HOST}",
|
||||||
|
"==> prebuilding relay control/analyzer once for the mode matrix",
|
||||||
|
"LESAVKA_SERVER_RC_MODES=auto",
|
||||||
|
"discover_local_webcam_modes",
|
||||||
|
"lookup_audio_delay_us",
|
||||||
|
"local webcam",
|
||||||
|
"mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}",
|
||||||
|
"video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}",
|
||||||
|
"audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}",
|
||||||
|
"fast runtime env updated: CAM_OUTPUT=uvc",
|
||||||
|
"cycling UVC gadget descriptors",
|
||||||
|
"lesavka-core reconfigure log:",
|
||||||
|
"missing /usr/local/bin/lesavka-core.sh",
|
||||||
|
"wait_tethys_media_ready",
|
||||||
|
"==> waiting for Tethys media endpoints for ${mode}",
|
||||||
|
"Tethys media ready: video=%s mode=%s audio_stack=%s",
|
||||||
|
"timed out waiting for Tethys Lesavka media endpoints",
|
||||||
"LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}",
|
"LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}",
|
||||||
"LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS=${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS:-0}",
|
"LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS=${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS:-0}",
|
||||||
"LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS=${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS:-0}",
|
"LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS=${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS:-0}",
|
||||||
@ -207,13 +255,21 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
|||||||
"mode-matrix-summary.txt",
|
"mode-matrix-summary.txt",
|
||||||
"schema\": \"lesavka.server-rc-mode-result.v1\"",
|
"schema\": \"lesavka.server-rc-mode-result.v1\"",
|
||||||
"schema\": \"lesavka.server-rc-mode-matrix-summary.v1\"",
|
"schema\": \"lesavka.server-rc-mode-matrix-summary.v1\"",
|
||||||
|
"output_delay_calibration",
|
||||||
|
"calibration_ready",
|
||||||
|
"calibration_video_target_offset_us",
|
||||||
|
"calibration_audio_target_offset_us",
|
||||||
|
"calibration:",
|
||||||
"REMOTE_PULSE_CAPTURE_TOOL=\"${REMOTE_PULSE_CAPTURE_TOOL}\"",
|
"REMOTE_PULSE_CAPTURE_TOOL=\"${REMOTE_PULSE_CAPTURE_TOOL}\"",
|
||||||
"REMOTE_PULSE_VIDEO_MODE=\"${REMOTE_PULSE_VIDEO_MODE}\"",
|
"REMOTE_PULSE_VIDEO_MODE=\"${REMOTE_PULSE_VIDEO_MODE}\"",
|
||||||
|
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=\"${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}\"",
|
||||||
|
"PROBE_PREBUILD=0",
|
||||||
"VIDEO_SIZE=\"${width}x${height}\"",
|
"VIDEO_SIZE=\"${width}x${height}\"",
|
||||||
"VIDEO_FPS=\"${fps}\"",
|
"VIDEO_FPS=\"${fps}\"",
|
||||||
"REMOTE_EXPECT_UVC_WIDTH=\"${width}\"",
|
"REMOTE_EXPECT_UVC_WIDTH=\"${width}\"",
|
||||||
"REMOTE_EXPECT_UVC_HEIGHT=\"${height}\"",
|
"REMOTE_EXPECT_UVC_HEIGHT=\"${height}\"",
|
||||||
"REMOTE_EXPECT_UVC_FPS=\"${fps}\"",
|
"REMOTE_EXPECT_UVC_FPS=\"${fps}\"",
|
||||||
|
"LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=\"${audio_delay_us}\"",
|
||||||
"LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=\"${video_delay_us}\"",
|
"LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=\"${video_delay_us}\"",
|
||||||
"LESAVKA_OUTPUT_DELAY_APPLY=0",
|
"LESAVKA_OUTPUT_DELAY_APPLY=0",
|
||||||
"LESAVKA_OUTPUT_DELAY_SAVE=0",
|
"LESAVKA_OUTPUT_DELAY_SAVE=0",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user