fix: honor live UVC gadget profile
This commit is contained in:
parent
f1e3faa404
commit
1673bb8b1b
10
AGENTS.md
10
AGENTS.md
@ -1,6 +1,6 @@
|
|||||||
# Lesavka Agent Notes
|
# Lesavka Agent Notes
|
||||||
|
|
||||||
## 0.18.1 Bundled Webcam A/V Migration Checklist
|
## 0.18.2 Bundled Webcam A/V Migration Checklist
|
||||||
|
|
||||||
Context: manual Google Meet and mirrored-probe testing showed the split webcam
|
Context: manual Google Meet and mirrored-probe testing showed the split webcam
|
||||||
and microphone uplink design is too fragile under real browser/device pressure.
|
and microphone uplink design is too fragile under real browser/device pressure.
|
||||||
@ -28,6 +28,9 @@ explicit no-camera path.
|
|||||||
- [x] Startup video is allowed to prime UVC if a first mixed bundle has bad
|
- [x] Startup video is allowed to prime UVC if a first mixed bundle has bad
|
||||||
audio/video timing; the mismatched audio is dropped, preserving sync while
|
audio/video timing; the mismatched audio is dropped, preserving sync while
|
||||||
avoiding browser `Camera is starting` starvation.
|
avoiding browser `Camera is starting` starvation.
|
||||||
|
- [x] An already-attached UVC gadget descriptor is the physical browser contract:
|
||||||
|
if it still advertises an older profile, server handshake/capture sizing
|
||||||
|
follows that live descriptor until a controlled gadget rebuild is allowed.
|
||||||
|
|
||||||
### Wire Protocol
|
### Wire Protocol
|
||||||
- [x] Add `UpstreamMediaBundle` containing one optional video frame plus zero or
|
- [x] Add `UpstreamMediaBundle` containing one optional video frame plus zero or
|
||||||
@ -66,6 +69,10 @@ explicit no-camera path.
|
|||||||
- [x] Activate the camera relay before opening the microphone sink so UVC can
|
- [x] Activate the camera relay before opening the microphone sink so UVC can
|
||||||
become ready even if UAC setup is slow.
|
become ready even if UAC setup is slow.
|
||||||
- [x] Log the first bundled video frame handed to the camera sink.
|
- [x] Log the first bundled video frame handed to the camera sink.
|
||||||
|
- [x] Honor the live configfs UVC descriptor when it differs from configured
|
||||||
|
defaults, preventing browsers from receiving frames outside negotiated caps.
|
||||||
|
- [x] Make the UVC control helper answer probe/commit requests from the same
|
||||||
|
live descriptor so Firefox/Chrome negotiation matches server frame output.
|
||||||
- [x] Continue reporting client timing and sink handoff diagnostics from bundled packets.
|
- [x] Continue reporting client timing and sink handoff diagnostics from bundled packets.
|
||||||
- [ ] Add bundled-mode counters for first bundle, first audio push, first video feed,
|
- [ ] Add bundled-mode counters for first bundle, first audio push, first video feed,
|
||||||
dropped stale bundles, and bundle queue age.
|
dropped stale bundles, and bundle queue age.
|
||||||
@ -75,6 +82,7 @@ explicit no-camera path.
|
|||||||
### Validation
|
### Validation
|
||||||
- [x] `cargo check -p lesavka_common -p lesavka_client -p lesavka_server --bins`
|
- [x] `cargo check -p lesavka_common -p lesavka_client -p lesavka_server --bins`
|
||||||
- [x] Focused handshake and launcher tests.
|
- [x] Focused handshake and launcher tests.
|
||||||
|
- [x] Focused UVC profile test for stale configured profile vs live attached descriptor.
|
||||||
- [ ] Focused server upstream-media tests including bundled stream acceptance.
|
- [ ] Focused server upstream-media tests including bundled stream acceptance.
|
||||||
- [ ] Install on both ends and verify diagnostics show bundled webcam media.
|
- [ ] Install on both ends and verify diagnostics show bundled webcam media.
|
||||||
- [ ] Manual Google Meet test: camera starts, video is not black/unsupported,
|
- [ ] Manual Google Meet test: camera starts, video is not black/unsupported,
|
||||||
|
|||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -247,6 +247,9 @@ fn print_versions(server_addr: &str, caps: &HandshakeSet) {
|
|||||||
println!("server_revision={server_revision}");
|
println!("server_revision={server_revision}");
|
||||||
println!("server_camera_output={}", caps.camera_output);
|
println!("server_camera_output={}", caps.camera_output);
|
||||||
println!("server_camera_codec={}", caps.camera_codec);
|
println!("server_camera_codec={}", caps.camera_codec);
|
||||||
|
println!("server_camera_width={}", caps.camera_width);
|
||||||
|
println!("server_camera_height={}", caps.camera_height);
|
||||||
|
println!("server_camera_fps={}", caps.camera_fps);
|
||||||
println!("server_bundled_webcam_media={}", caps.bundled_webcam_media);
|
println!("server_bundled_webcam_media={}", caps.bundled_webcam_media);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -668,10 +668,44 @@ fn parse_args() -> Result<(String, UvcConfig)> {
|
|||||||
|
|
||||||
impl UvcConfig {
|
impl UvcConfig {
|
||||||
fn from_env() -> Self {
|
fn from_env() -> Self {
|
||||||
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
|
let mut width = env_u32("LESAVKA_UVC_WIDTH", 1280);
|
||||||
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
|
let mut height = env_u32("LESAVKA_UVC_HEIGHT", 720);
|
||||||
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
|
let mut fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
|
||||||
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
|
let requested_interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
|
||||||
|
let mut interval = if requested_interval == 0 {
|
||||||
|
10_000_000 / fps
|
||||||
|
} else {
|
||||||
|
requested_interval
|
||||||
|
};
|
||||||
|
if let Some(snapshot) = read_configfs_snapshot() {
|
||||||
|
let live_interval = if snapshot.default_interval == 0 {
|
||||||
|
snapshot.frame_interval
|
||||||
|
} else {
|
||||||
|
snapshot.default_interval
|
||||||
|
};
|
||||||
|
if live_interval > 0 {
|
||||||
|
let live_fps = (10_000_000 / live_interval).max(1);
|
||||||
|
if (width, height, fps, interval)
|
||||||
|
!= (snapshot.width, snapshot.height, live_fps, live_interval)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[lesavka-uvc] live descriptor differs from configured profile; honoring attached gadget {}x{}@{}fps interval={} until rebuild (configured {}x{}@{}fps interval={})",
|
||||||
|
snapshot.width,
|
||||||
|
snapshot.height,
|
||||||
|
live_fps,
|
||||||
|
live_interval,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
width = snapshot.width;
|
||||||
|
height = snapshot.height;
|
||||||
|
fps = live_fps;
|
||||||
|
interval = live_interval;
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
|
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
|
||||||
let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2);
|
let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2);
|
||||||
let bulk = env::var("LESAVKA_UVC_BULK").is_ok();
|
let bulk = env::var("LESAVKA_UVC_BULK").is_ok();
|
||||||
@ -725,12 +759,6 @@ impl UvcConfig {
|
|||||||
max_packet = max_packet.min(1024);
|
max_packet = max_packet.min(1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
let interval = if interval == 0 {
|
|
||||||
10_000_000 / fps
|
|
||||||
} else {
|
|
||||||
interval
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|||||||
@ -2,8 +2,24 @@ use super::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, LA
|
|||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const DEFAULT_UVC_CONFIGFS_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const UVC_CONFIGFS_BASE_ENV: &str = "LESAVKA_UVC_CONFIGFS_BASE";
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct LiveUvcProfile {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: u32,
|
||||||
|
interval_100ns: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
pub(super) fn select_camera_config() -> CameraConfig {
|
pub(super) fn select_camera_config() -> CameraConfig {
|
||||||
let output_override = std::env::var("LESAVKA_CAM_OUTPUT")
|
let output_override = std::env::var("LESAVKA_CAM_OUTPUT")
|
||||||
@ -172,13 +188,13 @@ fn select_uvc_config() -> CameraConfig {
|
|||||||
uvc_env = parse_env_file(&text);
|
uvc_env = parse_env_file(&text);
|
||||||
}
|
}
|
||||||
|
|
||||||
let width = read_u32_from_env("LESAVKA_UVC_WIDTH")
|
let mut width = read_u32_from_env("LESAVKA_UVC_WIDTH")
|
||||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_WIDTH"))
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_WIDTH"))
|
||||||
.unwrap_or(1280);
|
.unwrap_or(1280);
|
||||||
let height = read_u32_from_env("LESAVKA_UVC_HEIGHT")
|
let mut height = read_u32_from_env("LESAVKA_UVC_HEIGHT")
|
||||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_HEIGHT"))
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_HEIGHT"))
|
||||||
.unwrap_or(720);
|
.unwrap_or(720);
|
||||||
let fps = read_u32_from_env("LESAVKA_UVC_FPS")
|
let mut fps = read_u32_from_env("LESAVKA_UVC_FPS")
|
||||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_FPS"))
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_FPS"))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
read_u32_from_env("LESAVKA_UVC_INTERVAL")
|
read_u32_from_env("LESAVKA_UVC_INTERVAL")
|
||||||
@ -194,6 +210,24 @@ fn select_uvc_config() -> CameraConfig {
|
|||||||
.unwrap_or(30);
|
.unwrap_or(30);
|
||||||
let codec = select_uvc_codec(Some(&uvc_env));
|
let codec = select_uvc_codec(Some(&uvc_env));
|
||||||
|
|
||||||
|
if let Some(live) = read_live_uvc_configfs_profile() {
|
||||||
|
if (width, height, fps) != (live.width, live.height, live.fps) {
|
||||||
|
warn!(
|
||||||
|
configured_width = width,
|
||||||
|
configured_height = height,
|
||||||
|
configured_fps = fps,
|
||||||
|
live_width = live.width,
|
||||||
|
live_height = live.height,
|
||||||
|
live_fps = live.fps,
|
||||||
|
live_interval_100ns = live.interval_100ns,
|
||||||
|
"📷 live UVC descriptor differs from configured profile; honoring attached gadget profile until rebuild"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
width = live.width;
|
||||||
|
height = live.height;
|
||||||
|
fps = live.fps.max(1);
|
||||||
|
}
|
||||||
|
|
||||||
CameraConfig {
|
CameraConfig {
|
||||||
output: CameraOutput::Uvc,
|
output: CameraOutput::Uvc,
|
||||||
codec,
|
codec,
|
||||||
@ -204,6 +238,59 @@ fn select_uvc_config() -> CameraConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn read_live_uvc_configfs_profile() -> Option<LiveUvcProfile> {
|
||||||
|
let base = std::env::var(UVC_CONFIGFS_BASE_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_CONFIGFS_BASE));
|
||||||
|
let frame_dir = live_uvc_frame_dir(&base)?;
|
||||||
|
let width = read_u32_file(frame_dir.join("wWidth"))?;
|
||||||
|
let height = read_u32_file(frame_dir.join("wHeight"))?;
|
||||||
|
let interval_100ns = read_u32_file(frame_dir.join("dwDefaultFrameInterval"))
|
||||||
|
.or_else(|| read_first_u32_line(frame_dir.join("dwFrameInterval")))?;
|
||||||
|
if width == 0 || height == 0 || interval_100ns == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(LiveUvcProfile {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps: (10_000_000 / interval_100ns).max(1),
|
||||||
|
interval_100ns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn live_uvc_frame_dir(base: &Path) -> Option<PathBuf> {
|
||||||
|
let preferred = base.join("streaming/mjpeg/m/720p");
|
||||||
|
if preferred.join("wWidth").is_file() && preferred.join("wHeight").is_file() {
|
||||||
|
return Some(preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mjpeg_dir = base.join("streaming/mjpeg/m");
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for entry in fs::read_dir(mjpeg_dir).ok()?.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.join("wWidth").is_file() && path.join("wHeight").is_file() {
|
||||||
|
candidates.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort();
|
||||||
|
candidates.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn read_u32_file(path: impl AsRef<Path>) -> Option<u32> {
|
||||||
|
fs::read_to_string(path).ok()?.trim().parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn read_first_u32_line(path: impl AsRef<Path>) -> Option<u32> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.ok()?
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.trim().parse::<u32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
fn has_hw_h264_decode() -> bool {
|
fn has_hw_h264_decode() -> bool {
|
||||||
std::env::var("LESAVKA_HW_H264").is_ok()
|
std::env::var("LESAVKA_HW_H264").is_ok()
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use super::{
|
|||||||
update_camera_config,
|
update_camera_config,
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
use std::fs;
|
||||||
use temp_env::with_var;
|
use temp_env::with_var;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -32,6 +33,36 @@ fn camera_config_env_override_prefers_uvc_values() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[serial]
|
||||||
|
fn uvc_camera_profile_honors_live_attached_descriptor() {
|
||||||
|
let temp = tempfile::tempdir().expect("temp configfs dir");
|
||||||
|
let frame_dir = temp.path().join("streaming/mjpeg/m/720p");
|
||||||
|
fs::create_dir_all(&frame_dir).expect("mock UVC frame descriptor");
|
||||||
|
fs::write(frame_dir.join("wWidth"), "640\n").expect("width");
|
||||||
|
fs::write(frame_dir.join("wHeight"), "480\n").expect("height");
|
||||||
|
fs::write(frame_dir.join("dwDefaultFrameInterval"), "500000\n").expect("interval");
|
||||||
|
|
||||||
|
let configfs_base = temp.path().to_string_lossy().into_owned();
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_OUTPUT", Some("uvc")),
|
||||||
|
("LESAVKA_UVC_WIDTH", Some("1280")),
|
||||||
|
("LESAVKA_UVC_HEIGHT", Some("720")),
|
||||||
|
("LESAVKA_UVC_FPS", Some("30")),
|
||||||
|
("LESAVKA_UVC_INTERVAL", None),
|
||||||
|
("LESAVKA_UVC_CONFIGFS_BASE", Some(configfs_base.as_str())),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let cfg = update_camera_config();
|
||||||
|
assert_eq!(cfg.output, CameraOutput::Uvc);
|
||||||
|
assert_eq!(cfg.codec, CameraCodec::Mjpeg);
|
||||||
|
assert_eq!((cfg.width, cfg.height, cfg.fps), (640, 480, 20));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn camera_config_env_override_honors_uvc_codec() {
|
fn camera_config_env_override_honors_uvc_codec() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user