ci(lesavka): repair hygiene gate regressions
This commit is contained in:
parent
3d32ee0922
commit
92b9aeecbd
@ -187,6 +187,10 @@ path = "tests/component/client/uplink/client_uplink_component_contract.rs"
|
|||||||
name = "client_app_include_contract"
|
name = "client_app_include_contract"
|
||||||
path = "tests/contract/client/app/client_app_include_contract.rs"
|
path = "tests/contract/client/app/client_app_include_contract.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "client_app_support_contract"
|
||||||
|
path = "tests/contract/client/app/client_app_support_contract.rs"
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "client_app_process_contract"
|
name = "client_app_process_contract"
|
||||||
path = "tests/contract/client/app/client_app_process_contract.rs"
|
path = "tests/contract/client/app/client_app_process_contract.rs"
|
||||||
|
|||||||
@ -2,8 +2,7 @@ impl LesavkaClientApp {
|
|||||||
/*──────────────── bundled webcam + mic stream ─────────────────*/
|
/*──────────────── bundled webcam + mic stream ─────────────────*/
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
/// Keeps `webcam_media_loop` explicit because it sits on the live uplink path, where stale media must be dropped instead of queued into latency.
|
/// Run the live bundled uplink loop, dropping stale media instead of queueing latency.
|
||||||
/// Inputs are the typed parameters; output is the return value or side effect.
|
|
||||||
async fn webcam_media_loop(
|
async fn webcam_media_loop(
|
||||||
ep: Channel,
|
ep: Channel,
|
||||||
initial_camera_source: Option<String>,
|
initial_camera_source: Option<String>,
|
||||||
@ -27,7 +26,8 @@ impl LesavkaClientApp {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let active_camera_source = state.camera_source.resolve(initial_camera_source.as_deref());
|
let active_camera_source =
|
||||||
|
state.camera_source.resolve(initial_camera_source.as_deref());
|
||||||
let active_camera_profile =
|
let active_camera_profile =
|
||||||
state.camera_profile.resolve(initial_camera_profile.as_deref());
|
state.camera_profile.resolve(initial_camera_profile.as_deref());
|
||||||
let active_camera_cfg = camera_config_with_live_codec(camera_cfg, &state.camera_codec);
|
let active_camera_cfg = camera_config_with_live_codec(camera_cfg, &state.camera_codec);
|
||||||
@ -279,9 +279,9 @@ impl LesavkaClientApp {
|
|||||||
let desired_source = state
|
let desired_source = state
|
||||||
.microphone_source
|
.microphone_source
|
||||||
.resolve(initial_microphone_source.as_deref());
|
.resolve(initial_microphone_source.as_deref());
|
||||||
let desired_audio_codec = state
|
let desired_audio_codec = state.audio_codec.resolve(
|
||||||
.audio_codec
|
lesavka_common::audio_transport::UpstreamAudioCodec::Opus,
|
||||||
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
|
);
|
||||||
let desired_noise_suppression =
|
let desired_noise_suppression =
|
||||||
state.noise_suppression.resolve(false);
|
state.noise_suppression.resolve(false);
|
||||||
if state.camera != active_camera_requested
|
if state.camera != active_camera_requested
|
||||||
|
|||||||
@ -74,174 +74,3 @@ fn env_flag_enabled(name: &str) -> bool {
|
|||||||
.map(|value| value.trim().to_ascii_lowercase())
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on"))
|
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr,
|
|
||||||
sanitize_video_queue,
|
|
||||||
};
|
|
||||||
use crate::handshake::PeerCaps;
|
|
||||||
use crate::input::camera::CameraCodec;
|
|
||||||
use serial_test::serial;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_server_addr_prefers_cli_then_env_then_default() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")),
|
|
||||||
"http://cli:1"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_server_addr(
|
|
||||||
&[
|
|
||||||
String::from("--no-launcher"),
|
|
||||||
String::from("--server"),
|
|
||||||
String::from("http://cli-flag:3"),
|
|
||||||
],
|
|
||||||
Some("http://env:2"),
|
|
||||||
),
|
|
||||||
"http://cli-flag:3"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")),
|
|
||||||
"http://env:2"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_server_addr(&[], Some("http://env:2")),
|
|
||||||
"http://env:2"
|
|
||||||
);
|
|
||||||
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn camera_config_from_caps_requires_complete_profile() {
|
|
||||||
temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || {
|
|
||||||
let mut caps = PeerCaps {
|
|
||||||
camera: true,
|
|
||||||
microphone: false,
|
|
||||||
bundled_webcam_media: false,
|
|
||||||
server_version: None,
|
|
||||||
camera_output: Some(String::from("uvc")),
|
|
||||||
camera_codec: Some(String::from("mjpeg")),
|
|
||||||
camera_width: Some(1280),
|
|
||||||
camera_height: Some(720),
|
|
||||||
camera_fps: Some(25),
|
|
||||||
eye_width: None,
|
|
||||||
eye_height: None,
|
|
||||||
eye_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = camera_config_from_caps(&caps).expect("complete caps should map");
|
|
||||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
|
||||||
assert_eq!(config.width, 1280);
|
|
||||||
|
|
||||||
caps.camera_codec = Some(String::from("h265"));
|
|
||||||
let config = camera_config_from_caps(&caps).expect("h265 alias should map");
|
|
||||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
|
||||||
|
|
||||||
caps.camera_codec = Some(String::from("vp9"));
|
|
||||||
assert!(camera_config_from_caps(&caps).is_none());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() {
|
|
||||||
let caps = PeerCaps {
|
|
||||||
camera: true,
|
|
||||||
microphone: false,
|
|
||||||
bundled_webcam_media: false,
|
|
||||||
server_version: None,
|
|
||||||
camera_output: Some(String::from("uvc")),
|
|
||||||
camera_codec: Some(String::from("hevc")),
|
|
||||||
camera_width: Some(1280),
|
|
||||||
camera_height: Some(720),
|
|
||||||
camera_fps: Some(25),
|
|
||||||
eye_width: None,
|
|
||||||
eye_height: None,
|
|
||||||
eye_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
|
|
||||||
let config = camera_config_from_caps(&caps).expect("caps codec should map");
|
|
||||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn camera_config_from_caps_allows_explicit_forced_codec_override() {
|
|
||||||
let caps = PeerCaps {
|
|
||||||
camera: true,
|
|
||||||
microphone: false,
|
|
||||||
bundled_webcam_media: false,
|
|
||||||
server_version: None,
|
|
||||||
camera_output: Some(String::from("uvc")),
|
|
||||||
camera_codec: Some(String::from("hevc")),
|
|
||||||
camera_width: Some(1280),
|
|
||||||
camera_height: Some(720),
|
|
||||||
camera_fps: Some(25),
|
|
||||||
eye_width: None,
|
|
||||||
eye_height: None,
|
|
||||||
eye_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_CODEC", Some("mjpeg")),
|
|
||||||
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = camera_config_from_caps(&caps).expect("forced override should map");
|
|
||||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn camera_config_force_flag_ignores_unknown_override() {
|
|
||||||
let caps = PeerCaps {
|
|
||||||
camera: true,
|
|
||||||
microphone: false,
|
|
||||||
bundled_webcam_media: false,
|
|
||||||
server_version: None,
|
|
||||||
camera_output: Some(String::from("uvc")),
|
|
||||||
camera_codec: Some(String::from("hevc")),
|
|
||||||
camera_width: Some(1280),
|
|
||||||
camera_height: Some(720),
|
|
||||||
camera_fps: Some(25),
|
|
||||||
eye_width: None,
|
|
||||||
eye_height: None,
|
|
||||||
eye_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_CODEC", Some("vp9")),
|
|
||||||
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config =
|
|
||||||
camera_config_from_caps(&caps).expect("bad forced override should fall back");
|
|
||||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitize_video_queue_enforces_floor() {
|
|
||||||
assert_eq!(sanitize_video_queue(None), 8);
|
|
||||||
assert_eq!(sanitize_video_queue(Some(1)), 4);
|
|
||||||
assert_eq!(sanitize_video_queue(Some(32)), 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_delay_doubles_until_capped() {
|
|
||||||
assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2));
|
|
||||||
assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30));
|
|
||||||
assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,14 +12,7 @@ impl LauncherState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_server_version(&mut self, version: Option<String>) {
|
pub fn set_server_version(&mut self, version: Option<String>) {
|
||||||
self.server_version = version.and_then(|value| {
|
self.server_version = normalize_optional_text(version);
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_server_media_caps(
|
pub fn set_server_media_caps(
|
||||||
@ -31,22 +24,8 @@ impl LauncherState {
|
|||||||
) {
|
) {
|
||||||
self.server_camera = camera;
|
self.server_camera = camera;
|
||||||
self.server_microphone = microphone;
|
self.server_microphone = microphone;
|
||||||
self.server_camera_output = camera_output.and_then(|value| {
|
self.server_camera_output = normalize_optional_text(camera_output);
|
||||||
let trimmed = value.trim();
|
self.server_camera_codec = normalize_optional_text(camera_codec);
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.server_camera_codec = camera_codec.and_then(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
|
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
|
||||||
@ -481,3 +460,10 @@ impl LauncherState {
|
|||||||
self.upstream_sync = upstream_sync;
|
self.upstream_sync = upstream_sync;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|value| {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -340,6 +340,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
|||||||
| `LESAVKA_UVC_EXTERNAL` | server hardware/device override |
|
| `LESAVKA_UVC_EXTERNAL` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_FALLBACK` | server hardware/device override |
|
| `LESAVKA_UVC_FALLBACK` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_FPS` | server hardware/device override |
|
| `LESAVKA_UVC_FPS` | server hardware/device override |
|
||||||
|
| `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` | UVC helper boundary-audit runtime control path; defaults to `/tmp/lesavka-uvc-frame-audit.control`, where a path value enables exact-frame audit capture and `off`/`0` disables it without restarting the server |
|
||||||
| `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
|
| `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
|
||||||
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros |
|
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros |
|
||||||
| `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH` | UVC helper boundary-audit JSONL path override; defaults to `spool-audit.jsonl` inside `LESAVKA_UVC_FRAME_AUDIT_DIR` |
|
| `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH` | UVC helper boundary-audit JSONL path override; defaults to `spool-audit.jsonl` inside `LESAVKA_UVC_FRAME_AUDIT_DIR` |
|
||||||
|
|||||||
@ -68,12 +68,12 @@
|
|||||||
"client/src/app/uplink_media/webcam_media_loop.rs": {
|
"client/src/app/uplink_media/webcam_media_loop.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 476
|
"loc": 492
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 0,
|
||||||
"loc": 174
|
"loc": 76
|
||||||
},
|
},
|
||||||
"client/src/bin/lesavka-relayctl.rs": {
|
"client/src/bin/lesavka-relayctl.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -362,8 +362,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/state/launcher_state_impl.rs": {
|
"client/src/launcher/state/launcher_state_impl.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 17,
|
"doc_debt": 16,
|
||||||
"loc": 475
|
"loc": 469
|
||||||
},
|
},
|
||||||
"client/src/launcher/state/launcher_status_line.rs": {
|
"client/src/launcher/state/launcher_status_line.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -1328,32 +1328,32 @@
|
|||||||
"server/src/video_sinks/mjpeg_spool.rs": {
|
"server/src/video_sinks/mjpeg_spool.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 443
|
"loc": 468
|
||||||
},
|
},
|
||||||
"server/src/video_sinks/mjpeg_spool/audit.rs": {
|
"server/src/video_sinks/mjpeg_spool/audit.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 287
|
"loc": 338
|
||||||
},
|
},
|
||||||
"server/src/video_sinks/mjpeg_spool/tests.rs": {
|
"server/src/video_sinks/mjpeg_spool/tests.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 439
|
"loc": 489
|
||||||
},
|
},
|
||||||
"server/src/video_sinks/webcam_sink.rs": {
|
"server/src/video_sinks/webcam_sink.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 7,
|
"doc_debt": 7,
|
||||||
"loc": 479
|
"loc": 480
|
||||||
},
|
},
|
||||||
"server/src/video_sinks/webcam_sink/constructor.rs": {
|
"server/src/video_sinks/webcam_sink/constructor.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 382
|
"loc": 384
|
||||||
},
|
},
|
||||||
"server/src/video_sinks/webcam_sink/frame_handoff.rs": {
|
"server/src/video_sinks/webcam_sink/frame_handoff.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 357
|
"loc": 364
|
||||||
},
|
},
|
||||||
"server/src/video_sinks/webcam_sink/tests.rs": {
|
"server/src/video_sinks/webcam_sink/tests.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
|
|||||||
@ -22,6 +22,7 @@ HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC = 8000
|
|||||||
DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85
|
DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85
|
||||||
DEFAULT_UVC_MAX_PACKET = 1024
|
DEFAULT_UVC_MAX_PACKET = 1024
|
||||||
DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control"
|
DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control"
|
||||||
|
DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control"
|
||||||
MARKER_BITS = 32
|
MARKER_BITS = 32
|
||||||
MARKER_COLUMNS = 16
|
MARKER_COLUMNS = 16
|
||||||
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
|
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
|
||||||
@ -235,6 +236,32 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--max-reference-artifacts", type=int, default=12)
|
parser.add_argument("--max-reference-artifacts", type=int, default=12)
|
||||||
parser.add_argument("--reference-every", type=int, default=900)
|
parser.add_argument("--reference-every", type=int, default=900)
|
||||||
parser.add_argument("--progress-every", type=int, default=150)
|
parser.add_argument("--progress-every", type=int, default=150)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-uvc-audit",
|
||||||
|
action="store_true",
|
||||||
|
help="enable exact server-side UVC-bound MJPEG audit evidence for this run",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-uvc-audit-host",
|
||||||
|
default="",
|
||||||
|
help="SSH host running the Lesavka server; defaults to --inject-host when set",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-uvc-audit-control-path",
|
||||||
|
default=DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH,
|
||||||
|
help="runtime control file read by the server to enable UVC-bound frame auditing",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-uvc-audit-dir",
|
||||||
|
default="",
|
||||||
|
help="remote audit directory; default uses a unique /tmp path on the server host",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-uvc-audit-sample-frames",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="number of audited MJPEG frames to copy/decode for boundary classification",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--stream-analyze",
|
"--stream-analyze",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@ -417,6 +444,263 @@ def restore_remote_live_upstream(host: str, state: dict[str, Any]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_server_uvc_audit_host(args: argparse.Namespace) -> str:
|
||||||
|
if args.server_uvc_audit_host:
|
||||||
|
return args.server_uvc_audit_host
|
||||||
|
if args.inject_host:
|
||||||
|
return args.inject_host
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def setup_server_uvc_audit(args: argparse.Namespace, artifact_stamp: str) -> tuple[str, str] | None:
|
||||||
|
if not args.server_uvc_audit:
|
||||||
|
return None
|
||||||
|
host = resolve_server_uvc_audit_host(args)
|
||||||
|
if not host:
|
||||||
|
raise SystemExit("--server-uvc-audit requires --server-uvc-audit-host when --local-inject is used")
|
||||||
|
remote_dir = args.server_uvc_audit_dir or f"/tmp/lesavka-synthetic-rct-uvc-audit-{artifact_stamp}"
|
||||||
|
command = (
|
||||||
|
f"rm -rf {shlex.quote(remote_dir)} && "
|
||||||
|
f"mkdir -p {shlex.quote(remote_dir)} && "
|
||||||
|
f"printf '%s\\n' {shlex.quote(remote_dir)} > {shlex.quote(args.server_uvc_audit_control_path)}"
|
||||||
|
)
|
||||||
|
subprocess.run(["ssh", host, command], check=True)
|
||||||
|
print(
|
||||||
|
f"enabled server UVC-bound frame audit on {host}: {remote_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return host, remote_dir
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_server_uvc_audit(args: argparse.Namespace, state: tuple[str, str] | None) -> None:
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
host, _remote_dir = state
|
||||||
|
command = f"rm -f {shlex.quote(args.server_uvc_audit_control_path)}"
|
||||||
|
subprocess.run(["ssh", host, command], check=False)
|
||||||
|
print(
|
||||||
|
f"disabled server UVC-bound frame audit on {host}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]:
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
if not path.exists():
|
||||||
|
return records
|
||||||
|
for line in path.read_text(errors="replace").splitlines():
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
records.append(value)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def sample_records(records: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
||||||
|
if limit <= 0 or len(records) <= limit:
|
||||||
|
return records
|
||||||
|
if limit == 1:
|
||||||
|
return [records[-1]]
|
||||||
|
indexes = {
|
||||||
|
round(idx * (len(records) - 1) / (limit - 1))
|
||||||
|
for idx in range(limit)
|
||||||
|
}
|
||||||
|
return [records[idx] for idx in sorted(indexes)]
|
||||||
|
|
||||||
|
|
||||||
|
def copy_server_uvc_audit(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
state: tuple[str, str] | None,
|
||||||
|
local_dir: pathlib.Path,
|
||||||
|
) -> pathlib.Path | None:
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
host, remote_dir = state
|
||||||
|
local_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
remote_log = f"{remote_dir.rstrip('/')}/spool-audit.jsonl"
|
||||||
|
local_log = local_dir / "spool-audit.jsonl"
|
||||||
|
subprocess.run(["scp", f"{host}:{remote_log}", str(local_log)], check=False)
|
||||||
|
records = read_jsonl(local_log)
|
||||||
|
for record in sample_records(records, args.server_uvc_audit_sample_frames):
|
||||||
|
frame_file = str(record.get("file") or "")
|
||||||
|
if not frame_file or "/" in frame_file:
|
||||||
|
continue
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"scp",
|
||||||
|
f"{host}:{remote_dir.rstrip('/')}/{frame_file}",
|
||||||
|
str(local_dir / frame_file),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return local_dir
|
||||||
|
|
||||||
|
|
||||||
|
def decode_mjpeg_to_gray(path: pathlib.Path, width: int, height: int) -> bytes | None:
|
||||||
|
if width <= 0 or height <= 0 or not path.exists():
|
||||||
|
return None
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
str(path),
|
||||||
|
"-an",
|
||||||
|
"-pix_fmt",
|
||||||
|
"gray",
|
||||||
|
"-f",
|
||||||
|
"rawvideo",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
expected = width * height
|
||||||
|
if proc.returncode != 0 or len(proc.stdout) < expected:
|
||||||
|
return None
|
||||||
|
return proc.stdout[:expected]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_server_uvc_audit(
|
||||||
|
local_dir: pathlib.Path | None,
|
||||||
|
mode_width: int,
|
||||||
|
mode_height: int,
|
||||||
|
mode_fps: int,
|
||||||
|
capture_data: dict[str, Any] | None,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if local_dir is None:
|
||||||
|
return None
|
||||||
|
log_path = local_dir / "spool-audit.jsonl"
|
||||||
|
records = read_jsonl(log_path)
|
||||||
|
frame_size_counts: collections.Counter[str] = collections.Counter()
|
||||||
|
uvc_mode_counts: collections.Counter[str] = collections.Counter()
|
||||||
|
complete_count = 0
|
||||||
|
rejected_count = 0
|
||||||
|
decoded_sample_count = 0
|
||||||
|
marker_sample_count = 0
|
||||||
|
visual_sample_count = 0
|
||||||
|
sample_reason_counts: collections.Counter[str] = collections.Counter()
|
||||||
|
previous_seq: int | None = None
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
width = record.get("frame_width")
|
||||||
|
height = record.get("frame_height")
|
||||||
|
frame_size_counts[f"{width}x{height}"] += 1
|
||||||
|
uvc_width = record.get("uvc_width")
|
||||||
|
uvc_height = record.get("uvc_height")
|
||||||
|
uvc_fps = record.get("uvc_fps")
|
||||||
|
uvc_mode_counts[f"{uvc_width}x{uvc_height}@{uvc_fps}"] += 1
|
||||||
|
complete_count += int(bool(record.get("jpeg_complete")))
|
||||||
|
rejected_count += int(bool(record.get("rejected")))
|
||||||
|
|
||||||
|
for record in sample_records(records, args.server_uvc_audit_sample_frames):
|
||||||
|
frame_file = str(record.get("file") or "")
|
||||||
|
width = int(record.get("frame_width") or 0)
|
||||||
|
height = int(record.get("frame_height") or 0)
|
||||||
|
if not frame_file or width <= 0 or height <= 0:
|
||||||
|
continue
|
||||||
|
raw = decode_mjpeg_to_gray(local_dir / frame_file, width, height)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
decoded_sample_count += 1
|
||||||
|
result = analyze_frame(raw, width, height, args, previous_seq)
|
||||||
|
comparison_seq = result.get("comparison_sequence")
|
||||||
|
if comparison_seq is not None:
|
||||||
|
previous_seq = int(comparison_seq)
|
||||||
|
if result.get("decoded_sequence") is not None:
|
||||||
|
marker_sample_count += 1
|
||||||
|
if result.get("visual_suspicious"):
|
||||||
|
visual_sample_count += 1
|
||||||
|
sample_reason_counts.update(result.get("visual_reasons") or [])
|
||||||
|
|
||||||
|
matching_records = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if int(record.get("frame_width") or 0) == mode_width
|
||||||
|
and int(record.get("frame_height") or 0) == mode_height
|
||||||
|
]
|
||||||
|
matching_uvc_records = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if int(record.get("uvc_width") or 0) == mode_width
|
||||||
|
and int(record.get("uvc_height") or 0) == mode_height
|
||||||
|
and int(record.get("uvc_fps") or 0) == mode_fps
|
||||||
|
]
|
||||||
|
capture_visual_frames = int((capture_data or {}).get("visual_suspicious_frames") or 0)
|
||||||
|
capture_frames = int((capture_data or {}).get("frames") or 0)
|
||||||
|
status = "inconclusive"
|
||||||
|
diagnosis: list[str] = []
|
||||||
|
if not records:
|
||||||
|
status = "server_boundary_missing"
|
||||||
|
diagnosis.append(
|
||||||
|
"server UVC-bound audit recorded no frames; the software output path did not prove it produced fresh webcam frames during the probe"
|
||||||
|
)
|
||||||
|
elif rejected_count and rejected_count == len(records):
|
||||||
|
status = "server_boundary_rejected"
|
||||||
|
diagnosis.append(
|
||||||
|
"every audited UVC-bound frame was rejected before handoff; corruption or profile trouble is before the browser-facing UVC path"
|
||||||
|
)
|
||||||
|
elif complete_count < len(records):
|
||||||
|
status = "server_boundary_incomplete_jpeg"
|
||||||
|
diagnosis.append(
|
||||||
|
"the server UVC-bound audit contains incomplete JPEG payloads, so corruption exists before or at the server handoff"
|
||||||
|
)
|
||||||
|
elif not matching_records:
|
||||||
|
status = "server_boundary_frame_mode_mismatch"
|
||||||
|
diagnosis.append(
|
||||||
|
f"server UVC-bound frames did not match requested {mode_width}x{mode_height}; observed frame sizes {dict(frame_size_counts)}"
|
||||||
|
)
|
||||||
|
elif not matching_uvc_records:
|
||||||
|
status = "server_boundary_uvc_mode_mismatch"
|
||||||
|
diagnosis.append(
|
||||||
|
f"server UVC-bound records did not advertise requested {mode_width}x{mode_height}@{mode_fps}; observed UVC modes {dict(uvc_mode_counts)}"
|
||||||
|
)
|
||||||
|
elif visual_sample_count:
|
||||||
|
status = "server_boundary_visual_corruption"
|
||||||
|
diagnosis.append(
|
||||||
|
"decoded server UVC-bound audit samples were already visually suspicious before reaching the host/browser"
|
||||||
|
)
|
||||||
|
elif capture_visual_frames:
|
||||||
|
status = "downstream_uvc_or_browser_corruption"
|
||||||
|
diagnosis.append(
|
||||||
|
"server UVC-bound samples were clean and mode-matched, but receiver capture showed visual corruption; the software UVC gadget/browser leg is implicated"
|
||||||
|
)
|
||||||
|
elif capture_frames:
|
||||||
|
status = "no_visual_corruption_observed"
|
||||||
|
diagnosis.append(
|
||||||
|
"server UVC-bound samples and receiver capture had no visual corruption in this run"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"schema": "lesavka.server-uvc-boundary-summary.v1",
|
||||||
|
"status": status,
|
||||||
|
"diagnosis": diagnosis,
|
||||||
|
"record_count": len(records),
|
||||||
|
"complete_count": complete_count,
|
||||||
|
"rejected_count": rejected_count,
|
||||||
|
"frame_size_counts": dict(frame_size_counts),
|
||||||
|
"uvc_mode_counts": dict(uvc_mode_counts),
|
||||||
|
"matching_frame_records": len(matching_records),
|
||||||
|
"matching_uvc_mode_records": len(matching_uvc_records),
|
||||||
|
"decoded_sample_count": decoded_sample_count,
|
||||||
|
"marker_sample_count": marker_sample_count,
|
||||||
|
"visual_sample_count": visual_sample_count,
|
||||||
|
"sample_visual_reason_counts": dict(sample_reason_counts),
|
||||||
|
"artifact_dir": str(local_dir),
|
||||||
|
"log_path": str(log_path),
|
||||||
|
}
|
||||||
|
(local_dir / "boundary-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||||
if (not args.inject_host and not args.local_inject) or not args.rct_host:
|
if (not args.inject_host and not args.local_inject) or not args.rct_host:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@ -425,11 +709,16 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
if not shutil.which("ssh") or not shutil.which("scp"):
|
if not shutil.which("ssh") or not shutil.which("scp"):
|
||||||
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
||||||
width, height, fps = mode_dimensions(args)
|
width, height, fps = mode_dimensions(args)
|
||||||
|
run_stamp = timestamp()
|
||||||
inject_max_frame_bytes = args.inject_max_frame_bytes or default_inject_max_frame_bytes(fps)
|
inject_max_frame_bytes = args.inject_max_frame_bytes or default_inject_max_frame_bytes(fps)
|
||||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
|
artifact_dir = (
|
||||||
|
pathlib.Path(args.artifact_dir)
|
||||||
|
if args.artifact_dir
|
||||||
|
else pathlib.Path("artifacts/synthetic-rct") / f"{args.mode.replace('@', '-').replace('x', 'x')}-{run_stamp}"
|
||||||
|
)
|
||||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||||
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
|
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{run_stamp}"
|
||||||
remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{timestamp()}"
|
remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{run_stamp}"
|
||||||
remote_script = f"/tmp/lesavka-synthetic-rct-probe-{os.getpid()}.py"
|
remote_script = f"/tmp/lesavka-synthetic-rct-probe-{os.getpid()}.py"
|
||||||
script_text = pathlib.Path(__file__).read_text()
|
script_text = pathlib.Path(__file__).read_text()
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@ -537,6 +826,10 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
"rct_host": args.rct_host,
|
"rct_host": args.rct_host,
|
||||||
"pause_local_live_upstream": args.pause_local_live_upstream,
|
"pause_local_live_upstream": args.pause_local_live_upstream,
|
||||||
"media_control_path": args.media_control_path,
|
"media_control_path": args.media_control_path,
|
||||||
|
"server_uvc_audit": args.server_uvc_audit,
|
||||||
|
"server_uvc_audit_host": resolve_server_uvc_audit_host(args),
|
||||||
|
"server_uvc_audit_control_path": args.server_uvc_audit_control_path,
|
||||||
|
"server_uvc_audit_sample_frames": args.server_uvc_audit_sample_frames,
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
@ -598,7 +891,9 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
diagnosis: list[str] = []
|
diagnosis: list[str] = []
|
||||||
paused_control: tuple[pathlib.Path, bytes | None] | None = None
|
paused_control: tuple[pathlib.Path, bytes | None] | None = None
|
||||||
paused_remote_control: tuple[str, dict[str, Any]] | None = None
|
paused_remote_control: tuple[str, dict[str, Any]] | None = None
|
||||||
|
server_audit_state: tuple[str, str] | None = None
|
||||||
try:
|
try:
|
||||||
|
server_audit_state = setup_server_uvc_audit(args, run_stamp)
|
||||||
if args.pause_local_live_upstream:
|
if args.pause_local_live_upstream:
|
||||||
if args.local_inject:
|
if args.local_inject:
|
||||||
paused_control = pause_local_live_upstream(args)
|
paused_control = pause_local_live_upstream(args)
|
||||||
@ -624,12 +919,14 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
capture = start_capture()
|
capture = start_capture()
|
||||||
capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject)
|
capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject)
|
||||||
finally:
|
finally:
|
||||||
|
cleanup_server_uvc_audit(args, server_audit_state)
|
||||||
if paused_remote_control is not None:
|
if paused_remote_control is not None:
|
||||||
restore_remote_live_upstream(*paused_remote_control)
|
restore_remote_live_upstream(*paused_remote_control)
|
||||||
if paused_control is not None:
|
if paused_control is not None:
|
||||||
restore_local_live_upstream(*paused_control)
|
restore_local_live_upstream(*paused_control)
|
||||||
local_capture = artifact_dir / "capture"
|
local_capture = artifact_dir / "capture"
|
||||||
local_inject = artifact_dir / "inject"
|
local_inject = artifact_dir / "inject"
|
||||||
|
local_server_audit = artifact_dir / "server-uvc-audit"
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
||||||
if args.local_inject:
|
if args.local_inject:
|
||||||
@ -639,7 +936,9 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
shutil.copytree(remote_inject_dir, local_inject)
|
shutil.copytree(remote_inject_dir, local_inject)
|
||||||
else:
|
else:
|
||||||
subprocess.run(["scp", "-r", f"{args.inject_host}:{remote_inject_dir}", str(local_inject)], check=False)
|
subprocess.run(["scp", "-r", f"{args.inject_host}:{remote_inject_dir}", str(local_inject)], check=False)
|
||||||
|
copied_server_audit = copy_server_uvc_audit(args, server_audit_state, local_server_audit)
|
||||||
capture_summary = local_capture / "summary.json"
|
capture_summary = local_capture / "summary.json"
|
||||||
|
capture_data: dict[str, Any] | None = None
|
||||||
if capture_summary.exists():
|
if capture_summary.exists():
|
||||||
try:
|
try:
|
||||||
capture_data = json.loads(capture_summary.read_text())
|
capture_data = json.loads(capture_summary.read_text())
|
||||||
@ -695,6 +994,17 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
server_boundary_summary = summarize_server_uvc_audit(
|
||||||
|
copied_server_audit,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
capture_data,
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
if server_boundary_summary:
|
||||||
|
for item in server_boundary_summary.get("diagnosis") or []:
|
||||||
|
diagnosis.append(str(item))
|
||||||
summary = {
|
summary = {
|
||||||
"schema": "lesavka.synthetic-rct-probe.orchestrator.v1",
|
"schema": "lesavka.synthetic-rct-probe.orchestrator.v1",
|
||||||
"mode": args.mode,
|
"mode": args.mode,
|
||||||
@ -704,6 +1014,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
"artifact_dir": str(artifact_dir),
|
"artifact_dir": str(artifact_dir),
|
||||||
"capture_artifacts": str(local_capture),
|
"capture_artifacts": str(local_capture),
|
||||||
"inject_artifacts": str(local_inject),
|
"inject_artifacts": str(local_inject),
|
||||||
|
"server_uvc_boundary": server_boundary_summary,
|
||||||
|
"server_uvc_audit_artifacts": str(local_server_audit) if copied_server_audit else None,
|
||||||
}
|
}
|
||||||
(artifact_dir / "run-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
(artifact_dir / "run-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||||
|
|||||||
@ -23,6 +23,9 @@ pub(super) struct MjpegSpoolTiming {
|
|||||||
pub profile: &'static str,
|
pub profile: &'static str,
|
||||||
pub source_pts_us: Option<u64>,
|
pub source_pts_us: Option<u64>,
|
||||||
pub decoded_pts_us: Option<u64>,
|
pub decoded_pts_us: Option<u64>,
|
||||||
|
pub uvc_width: Option<u16>,
|
||||||
|
pub uvc_height: Option<u16>,
|
||||||
|
pub uvc_fps: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MjpegSpoolTiming {
|
impl MjpegSpoolTiming {
|
||||||
@ -36,6 +39,9 @@ impl MjpegSpoolTiming {
|
|||||||
profile: "mjpeg-passthrough",
|
profile: "mjpeg-passthrough",
|
||||||
source_pts_us: Some(source_pts_us),
|
source_pts_us: Some(source_pts_us),
|
||||||
decoded_pts_us: None,
|
decoded_pts_us: None,
|
||||||
|
uvc_width: None,
|
||||||
|
uvc_height: None,
|
||||||
|
uvc_fps: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +56,9 @@ impl MjpegSpoolTiming {
|
|||||||
profile: "mjpeg-normalized",
|
profile: "mjpeg-normalized",
|
||||||
source_pts_us: Some(source_pts_us),
|
source_pts_us: Some(source_pts_us),
|
||||||
decoded_pts_us: None,
|
decoded_pts_us: None,
|
||||||
|
uvc_width: None,
|
||||||
|
uvc_height: None,
|
||||||
|
uvc_fps: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +73,9 @@ impl MjpegSpoolTiming {
|
|||||||
profile: "hevc-decoded-mjpeg",
|
profile: "hevc-decoded-mjpeg",
|
||||||
source_pts_us: Some(source_pts_us),
|
source_pts_us: Some(source_pts_us),
|
||||||
decoded_pts_us,
|
decoded_pts_us,
|
||||||
|
uvc_width: None,
|
||||||
|
uvc_height: None,
|
||||||
|
uvc_fps: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,8 +93,18 @@ impl MjpegSpoolTiming {
|
|||||||
profile: "hevc-decoded-mjpeg-rejected",
|
profile: "hevc-decoded-mjpeg-rejected",
|
||||||
source_pts_us: Some(source_pts_us),
|
source_pts_us: Some(source_pts_us),
|
||||||
decoded_pts_us,
|
decoded_pts_us,
|
||||||
|
uvc_width: None,
|
||||||
|
uvc_height: None,
|
||||||
|
uvc_fps: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn with_uvc_mode(mut self, width: u16, height: u16, fps: u32) -> Self {
|
||||||
|
self.uvc_width = Some(width);
|
||||||
|
self.uvc_height = Some(height);
|
||||||
|
self.uvc_fps = Some(fps);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether the UVC helper file-spool path should own MJPEG emission.
|
/// Decide whether the UVC helper file-spool path should own MJPEG emission.
|
||||||
@ -345,12 +367,15 @@ pub(super) fn format_mjpeg_spool_metadata(
|
|||||||
timing: MjpegSpoolTiming,
|
timing: MjpegSpoolTiming,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{}}}\n",
|
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{}}}\n",
|
||||||
sequence,
|
sequence,
|
||||||
timing.profile,
|
timing.profile,
|
||||||
bytes,
|
bytes,
|
||||||
json_number_or_null(timing.source_pts_us),
|
json_number_or_null(timing.source_pts_us),
|
||||||
json_number_or_null(timing.decoded_pts_us),
|
json_number_or_null(timing.decoded_pts_us),
|
||||||
|
json_number_or_null(timing.uvc_width.map(u64::from)),
|
||||||
|
json_number_or_null(timing.uvc_height.map(u64::from)),
|
||||||
|
json_number_or_null(timing.uvc_fps.map(u64::from)),
|
||||||
unix_now_ns()
|
unix_now_ns()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use super::super::hevc_mjpeg_guard;
|
||||||
use super::MjpegSpoolTiming;
|
use super::MjpegSpoolTiming;
|
||||||
|
|
||||||
static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||||
@ -11,6 +12,7 @@ static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0);
|
|||||||
const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1;
|
const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1;
|
||||||
const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800;
|
const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800;
|
||||||
const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true;
|
const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true;
|
||||||
|
const DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH: &str = "/tmp/lesavka-uvc-frame-audit.control";
|
||||||
const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325;
|
const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325;
|
||||||
const FNV1A64_PRIME: u64 = 0x100000001b3;
|
const FNV1A64_PRIME: u64 = 0x100000001b3;
|
||||||
|
|
||||||
@ -71,6 +73,30 @@ pub(super) fn mjpeg_spool_audit_dir() -> Option<PathBuf> {
|
|||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
|
.or_else(mjpeg_spool_runtime_audit_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the runtime control-file audit directory.
|
||||||
|
///
|
||||||
|
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` or the default control path.
|
||||||
|
/// Output: an audit directory, unless the file is absent or contains an off
|
||||||
|
/// value. Why: lab probes need to enable exact UVC-bound captures around one
|
||||||
|
/// run without restarting or making all installed calls write MJPEG dumps.
|
||||||
|
fn mjpeg_spool_runtime_audit_dir() -> Option<PathBuf> {
|
||||||
|
let control_path = std::env::var("LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH));
|
||||||
|
let value = fs::read_to_string(control_path).ok()?;
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty()
|
||||||
|
|| trimmed.eq_ignore_ascii_case("0")
|
||||||
|
|| trimmed.eq_ignore_ascii_case("false")
|
||||||
|
|| trimmed.eq_ignore_ascii_case("no")
|
||||||
|
|| trimmed.eq_ignore_ascii_case("off")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(trimmed))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the UVC-bound audit sampling interval.
|
/// Resolve the UVC-bound audit sampling interval.
|
||||||
@ -189,14 +215,28 @@ fn format_audit_record(
|
|||||||
let profile = timing.map(|value| value.profile).unwrap_or("unknown");
|
let profile = timing.map(|value| value.profile).unwrap_or("unknown");
|
||||||
let source_pts_us = timing.and_then(|value| value.source_pts_us);
|
let source_pts_us = timing.and_then(|value| value.source_pts_us);
|
||||||
let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us);
|
let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us);
|
||||||
|
let uvc_width = timing.and_then(|value| value.uvc_width.map(u64::from));
|
||||||
|
let uvc_height = timing.and_then(|value| value.uvc_height.map(u64::from));
|
||||||
|
let uvc_fps = timing.and_then(|value| value.uvc_fps.map(u64::from));
|
||||||
|
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data);
|
||||||
format!(
|
format!(
|
||||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
||||||
sequence,
|
sequence,
|
||||||
slot,
|
slot,
|
||||||
json_escape(profile),
|
json_escape(profile),
|
||||||
data.len(),
|
data.len(),
|
||||||
|
inspection.complete,
|
||||||
|
json_number_or_null(inspection.width.map(u64::from)),
|
||||||
|
json_number_or_null(inspection.height.map(u64::from)),
|
||||||
|
inspection.entropy_bytes,
|
||||||
|
inspection.entropy_distinct_bytes,
|
||||||
|
inspection.entropy_dominant_pct,
|
||||||
|
inspection.entropy_max_run,
|
||||||
json_number_or_null(source_pts_us),
|
json_number_or_null(source_pts_us),
|
||||||
json_number_or_null(decoded_pts_us),
|
json_number_or_null(decoded_pts_us),
|
||||||
|
json_number_or_null(uvc_width),
|
||||||
|
json_number_or_null(uvc_height),
|
||||||
|
json_number_or_null(uvc_fps),
|
||||||
unix_now_ns(),
|
unix_now_ns(),
|
||||||
fnv1a64_hex(data),
|
fnv1a64_hex(data),
|
||||||
json_escape(frame_file)
|
json_escape(frame_file)
|
||||||
@ -218,15 +258,26 @@ fn format_rejected_audit_record(
|
|||||||
) -> String {
|
) -> String {
|
||||||
let source_pts_us = timing.source_pts_us;
|
let source_pts_us = timing.source_pts_us;
|
||||||
let decoded_pts_us = timing.decoded_pts_us;
|
let decoded_pts_us = timing.decoded_pts_us;
|
||||||
|
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data);
|
||||||
format!(
|
format!(
|
||||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
||||||
sequence,
|
sequence,
|
||||||
slot,
|
slot,
|
||||||
json_escape(timing.profile),
|
json_escape(timing.profile),
|
||||||
json_escape(reason),
|
json_escape(reason),
|
||||||
data.len(),
|
data.len(),
|
||||||
|
inspection.complete,
|
||||||
|
json_number_or_null(inspection.width.map(u64::from)),
|
||||||
|
json_number_or_null(inspection.height.map(u64::from)),
|
||||||
|
inspection.entropy_bytes,
|
||||||
|
inspection.entropy_distinct_bytes,
|
||||||
|
inspection.entropy_dominant_pct,
|
||||||
|
inspection.entropy_max_run,
|
||||||
json_number_or_null(source_pts_us),
|
json_number_or_null(source_pts_us),
|
||||||
json_number_or_null(decoded_pts_us),
|
json_number_or_null(decoded_pts_us),
|
||||||
|
json_number_or_null(timing.uvc_width.map(u64::from)),
|
||||||
|
json_number_or_null(timing.uvc_height.map(u64::from)),
|
||||||
|
json_number_or_null(timing.uvc_fps.map(u64::from)),
|
||||||
unix_now_ns(),
|
unix_now_ns(),
|
||||||
fnv1a64_hex(data),
|
fnv1a64_hex(data),
|
||||||
json_escape(frame_file)
|
json_escape(frame_file)
|
||||||
|
|||||||
@ -184,6 +184,48 @@ fn mjpeg_spool_audit_knobs_are_opt_in_and_bounded() {
|
|||||||
assert!(super::audit::should_sample_spool_audit_frame(4, 3));
|
assert!(super::audit::should_sample_spool_audit_frame(4, 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies runtime audit control can enable boundary evidence without a
|
||||||
|
/// server restart.
|
||||||
|
///
|
||||||
|
/// Input: a temporary control file containing an audit directory. Output:
|
||||||
|
/// audit dir resolution follows the control file when the env dir is unset.
|
||||||
|
/// Why: hardware/lab probes need to turn on exact UVC-bound frame evidence
|
||||||
|
/// around one run without making every installed call write MJPEG dumps.
|
||||||
|
#[test]
|
||||||
|
fn mjpeg_spool_audit_can_be_enabled_by_runtime_control_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let control = dir.path().join("uvc-audit.control");
|
||||||
|
let audit = dir.path().join("audit");
|
||||||
|
std::fs::write(&control, format!("{}\n", audit.display())).expect("write control");
|
||||||
|
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>),
|
||||||
|
(
|
||||||
|
"LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH",
|
||||||
|
Some(control.to_str().expect("utf8 path")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(super::audit::mjpeg_spool_audit_dir(), Some(audit));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&control, "off\n").expect("disable control");
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>),
|
||||||
|
(
|
||||||
|
"LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH",
|
||||||
|
Some(control.to_str().expect("utf8 path")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(super::audit::mjpeg_spool_audit_dir(), None);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Verifies metadata records carry enough timing evidence for RCT analysis.
|
/// Verifies metadata records carry enough timing evidence for RCT analysis.
|
||||||
///
|
///
|
||||||
/// Input: HEVC-decoded spool timing. Output: JSON fields for source and
|
/// Input: HEVC-decoded spool timing. Output: JSON fields for source and
|
||||||
@ -350,7 +392,10 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
|
|||||||
super::spool_mjpeg_frame_with_timing(
|
super::spool_mjpeg_frame_with_timing(
|
||||||
&frame,
|
&frame,
|
||||||
b"audit-jpeg",
|
b"audit-jpeg",
|
||||||
Some(super::MjpegSpoolTiming::mjpeg_passthrough(222)),
|
Some(
|
||||||
|
super::MjpegSpoolTiming::mjpeg_passthrough(222)
|
||||||
|
.with_uvc_mode(1280, 720, 30),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.expect("spool audited frame");
|
.expect("spool audited frame");
|
||||||
super::spool_mjpeg_frame_with_timing(
|
super::spool_mjpeg_frame_with_timing(
|
||||||
@ -377,6 +422,11 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
|
|||||||
assert!(index.contains("\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\""));
|
assert!(index.contains("\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\""));
|
||||||
assert!(index.contains("\"profile\":\"mjpeg-passthrough\""));
|
assert!(index.contains("\"profile\":\"mjpeg-passthrough\""));
|
||||||
assert!(index.contains("\"source_pts_us\":222"));
|
assert!(index.contains("\"source_pts_us\":222"));
|
||||||
|
assert!(index.contains("\"jpeg_complete\":false"));
|
||||||
|
assert!(index.contains("\"frame_width\":null"));
|
||||||
|
assert!(index.contains("\"uvc_width\":1280"));
|
||||||
|
assert!(index.contains("\"uvc_height\":720"));
|
||||||
|
assert!(index.contains("\"uvc_fps\":30"));
|
||||||
assert!(index.contains("\"file\":\"frame-"));
|
assert!(index.contains("\"file\":\"frame-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,7 @@ pub struct WebcamSink {
|
|||||||
direct_mjpeg_max_bytes: usize,
|
direct_mjpeg_max_bytes: usize,
|
||||||
uvc_width: u16,
|
uvc_width: u16,
|
||||||
uvc_height: u16,
|
uvc_height: u16,
|
||||||
|
uvc_fps: u32,
|
||||||
direct_mjpeg_profile_mismatch_seen: AtomicBool,
|
direct_mjpeg_profile_mismatch_seen: AtomicBool,
|
||||||
unexpected_mjpeg_in_hevc_seen: AtomicBool,
|
unexpected_mjpeg_in_hevc_seen: AtomicBool,
|
||||||
last_decoded_mjpeg_bytes: AtomicU64,
|
last_decoded_mjpeg_bytes: AtomicU64,
|
||||||
|
|||||||
@ -61,6 +61,7 @@ impl WebcamSink {
|
|||||||
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
|
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
|
||||||
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
|
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
|
||||||
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
|
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
|
||||||
|
uvc_fps: cfg.fps.max(1),
|
||||||
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
|
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
|
||||||
unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false),
|
unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false),
|
||||||
last_decoded_mjpeg_bytes: AtomicU64::new(0),
|
last_decoded_mjpeg_bytes: AtomicU64::new(0),
|
||||||
@ -368,6 +369,7 @@ impl WebcamSink {
|
|||||||
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
|
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
|
||||||
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
|
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
|
||||||
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
|
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
|
||||||
|
uvc_fps: cfg.fps.max(1),
|
||||||
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
|
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
|
||||||
unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false),
|
unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false),
|
||||||
last_decoded_mjpeg_bytes: AtomicU64::new(0),
|
last_decoded_mjpeg_bytes: AtomicU64::new(0),
|
||||||
|
|||||||
@ -107,14 +107,16 @@ impl WebcamSink {
|
|||||||
{
|
{
|
||||||
self.decoded_mjpeg_miss_count.store(0, Ordering::Relaxed);
|
self.decoded_mjpeg_miss_count.store(0, Ordering::Relaxed);
|
||||||
let decoded_pts_us = buffer.pts().map(|pts| pts.nseconds() / 1_000);
|
let decoded_pts_us = buffer.pts().map(|pts| pts.nseconds() / 1_000);
|
||||||
let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us);
|
let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us)
|
||||||
|
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||||
let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed);
|
let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed);
|
||||||
let decoded_bytes = map.as_slice().len();
|
let decoded_bytes = map.as_slice().len();
|
||||||
if let Some(reason) =
|
if let Some(reason) =
|
||||||
hevc_mjpeg_guard::decoded_mjpeg_reject_reason(previous_bytes, map.as_slice())
|
hevc_mjpeg_guard::decoded_mjpeg_reject_reason(previous_bytes, map.as_slice())
|
||||||
{
|
{
|
||||||
let rejected_timing =
|
let rejected_timing =
|
||||||
MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us);
|
MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us)
|
||||||
|
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||||
let reason_text = format!("{reason:?}");
|
let reason_text = format!("{reason:?}");
|
||||||
super::mjpeg_spool::audit_rejected_mjpeg_frame(
|
super::mjpeg_spool::audit_rejected_mjpeg_frame(
|
||||||
map.as_slice(),
|
map.as_slice(),
|
||||||
@ -218,7 +220,8 @@ impl WebcamSink {
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn spool_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
|
fn spool_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
|
||||||
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts);
|
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts)
|
||||||
|
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||||
if let Err(err) = spool_mjpeg_frame_with_timing(path, &pkt.data, Some(timing)) {
|
if let Err(err) = spool_mjpeg_frame_with_timing(path, &pkt.data, Some(timing)) {
|
||||||
warn!(target:"lesavka_server::video", %err, "failed to spool MJPEG frame for UVC helper");
|
warn!(target:"lesavka_server::video", %err, "failed to spool MJPEG frame for UVC helper");
|
||||||
} else {
|
} else {
|
||||||
@ -347,7 +350,8 @@ impl WebcamSink {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts);
|
let timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts)
|
||||||
|
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||||
if let Err(err) = spool_mjpeg_frame_with_timing(path, normalized, Some(timing)) {
|
if let Err(err) = spool_mjpeg_frame_with_timing(path, normalized, Some(timing)) {
|
||||||
warn!(target:"lesavka_server::video", %err, "failed to spool normalized direct MJPEG frame for UVC helper");
|
warn!(target:"lesavka_server::video", %err, "failed to spool normalized direct MJPEG frame for UVC helper");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
173
tests/contract/client/app/client_app_support_contract.rs
Normal file
173
tests/contract/client/app/client_app_support_contract.rs
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// Integration coverage for client app support helpers.
|
||||||
|
//
|
||||||
|
// Scope: exercise server-address, camera-capability, and retry-delay helpers
|
||||||
|
// against deterministic local types.
|
||||||
|
// Targets: `client/src/app_support.rs`.
|
||||||
|
// Why: these startup decisions shape live camera codec negotiation and should
|
||||||
|
// stay covered without adding source-file LOC to the hygiene ratchet.
|
||||||
|
|
||||||
|
mod handshake {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PeerCaps {
|
||||||
|
pub camera: bool,
|
||||||
|
pub microphone: bool,
|
||||||
|
pub bundled_webcam_media: bool,
|
||||||
|
pub server_version: Option<String>,
|
||||||
|
pub camera_output: Option<String>,
|
||||||
|
pub camera_codec: Option<String>,
|
||||||
|
pub camera_width: Option<u32>,
|
||||||
|
pub camera_height: Option<u32>,
|
||||||
|
pub camera_fps: Option<u32>,
|
||||||
|
pub eye_width: Option<u32>,
|
||||||
|
pub eye_height: Option<u32>,
|
||||||
|
pub eye_fps: Option<u32>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod input {
|
||||||
|
pub mod camera {
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CameraCodec {
|
||||||
|
Mjpeg,
|
||||||
|
H264,
|
||||||
|
Hevc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CameraConfig {
|
||||||
|
pub codec: CameraCodec,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[path = "../../../../client/src/app_support.rs"]
|
||||||
|
mod app_support;
|
||||||
|
|
||||||
|
use app_support::{
|
||||||
|
DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr,
|
||||||
|
sanitize_video_queue,
|
||||||
|
};
|
||||||
|
use handshake::PeerCaps;
|
||||||
|
use input::camera::CameraCodec;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn peer_caps(codec: &str) -> PeerCaps {
|
||||||
|
PeerCaps {
|
||||||
|
camera: true,
|
||||||
|
microphone: false,
|
||||||
|
bundled_webcam_media: false,
|
||||||
|
server_version: None,
|
||||||
|
camera_output: Some(String::from("uvc")),
|
||||||
|
camera_codec: Some(codec.to_string()),
|
||||||
|
camera_width: Some(1280),
|
||||||
|
camera_height: Some(720),
|
||||||
|
camera_fps: Some(25),
|
||||||
|
eye_width: None,
|
||||||
|
eye_height: None,
|
||||||
|
eye_fps: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_server_addr_prefers_cli_then_env_then_default() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")),
|
||||||
|
"http://cli:1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_server_addr(
|
||||||
|
&[
|
||||||
|
String::from("--no-launcher"),
|
||||||
|
String::from("--server"),
|
||||||
|
String::from("http://cli-flag:3"),
|
||||||
|
],
|
||||||
|
Some("http://env:2"),
|
||||||
|
),
|
||||||
|
"http://cli-flag:3"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")),
|
||||||
|
"http://env:2"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_server_addr(&[], Some("http://env:2")),
|
||||||
|
"http://env:2"
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn camera_config_from_caps_requires_complete_profile() {
|
||||||
|
temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || {
|
||||||
|
let mut caps = peer_caps("mjpeg");
|
||||||
|
let config = camera_config_from_caps(&caps).expect("complete caps should map");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||||
|
assert_eq!(config.width, 1280);
|
||||||
|
|
||||||
|
caps.camera_codec = Some(String::from("h265"));
|
||||||
|
let config = camera_config_from_caps(&caps).expect("h265 alias should map");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||||
|
|
||||||
|
caps.camera_codec = Some(String::from("vp9"));
|
||||||
|
assert!(camera_config_from_caps(&caps).is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() {
|
||||||
|
temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
|
||||||
|
let config = camera_config_from_caps(&peer_caps("hevc")).expect("caps codec should map");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn camera_config_from_caps_allows_explicit_forced_codec_override() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_CODEC", Some("mjpeg")),
|
||||||
|
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let config = camera_config_from_caps(&peer_caps("hevc")).expect("forced override");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn camera_config_force_flag_ignores_unknown_override() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_CODEC", Some("vp9")),
|
||||||
|
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let config = camera_config_from_caps(&peer_caps("hevc")).expect("fallback codec");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_video_queue_enforces_floor() {
|
||||||
|
assert_eq!(sanitize_video_queue(None), 8);
|
||||||
|
assert_eq!(sanitize_video_queue(Some(1)), 4);
|
||||||
|
assert_eq!(sanitize_video_queue(Some(32)), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_delay_doubles_until_capped() {
|
||||||
|
assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2));
|
||||||
|
assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30));
|
||||||
|
assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30));
|
||||||
|
}
|
||||||
@ -10,42 +10,62 @@ use std::fs;
|
|||||||
|
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
#[allow(dead_code, clippy::items_after_test_module)]
|
mod video_support {
|
||||||
mod spool {
|
pub fn env_u32(name: &str, default: u32) -> u32 {
|
||||||
include!(concat!(
|
std::env::var(name)
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
.ok()
|
||||||
"/server/src/video_sinks/mjpeg_spool.rs"
|
.and_then(|value| value.trim().parse().ok())
|
||||||
));
|
.unwrap_or(default)
|
||||||
|
|
||||||
pub fn write_hevc(
|
|
||||||
path: &std::path::Path,
|
|
||||||
data: &[u8],
|
|
||||||
source_pts_us: u64,
|
|
||||||
decoded_pts_us: Option<u64>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
spool_mjpeg_frame_with_timing(
|
|
||||||
path,
|
|
||||||
data,
|
|
||||||
Some(MjpegSpoolTiming::hevc_decoded_mjpeg(
|
|
||||||
source_pts_us,
|
|
||||||
decoded_pts_us,
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_mjpeg(
|
|
||||||
path: &std::path::Path,
|
|
||||||
data: &[u8],
|
|
||||||
source_pts_us: u64,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
spool_mjpeg_frame_with_timing(
|
|
||||||
path,
|
|
||||||
data,
|
|
||||||
Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod video_sinks {
|
||||||
|
#[allow(clippy::items_after_test_module)]
|
||||||
|
pub mod hevc_mjpeg_guard {
|
||||||
|
include!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/server/src/video_sinks/hevc_mjpeg_guard.rs"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code, clippy::items_after_test_module)]
|
||||||
|
pub mod spool {
|
||||||
|
include!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/server/src/video_sinks/mjpeg_spool.rs"
|
||||||
|
));
|
||||||
|
|
||||||
|
pub fn write_hevc(
|
||||||
|
path: &std::path::Path,
|
||||||
|
data: &[u8],
|
||||||
|
source_pts_us: u64,
|
||||||
|
decoded_pts_us: Option<u64>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
spool_mjpeg_frame_with_timing(
|
||||||
|
path,
|
||||||
|
data,
|
||||||
|
Some(MjpegSpoolTiming::hevc_decoded_mjpeg(
|
||||||
|
source_pts_us,
|
||||||
|
decoded_pts_us,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_mjpeg(
|
||||||
|
path: &std::path::Path,
|
||||||
|
data: &[u8],
|
||||||
|
source_pts_us: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
spool_mjpeg_frame_with_timing(
|
||||||
|
path,
|
||||||
|
data,
|
||||||
|
Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use video_sinks::spool;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn hevc_decoded_mjpeg_spool_writes_frame_and_timing_metadata() {
|
fn hevc_decoded_mjpeg_spool_writes_frame_and_timing_metadata() {
|
||||||
|
|||||||
@ -52,6 +52,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
|||||||
"--media-control-path",
|
"--media-control-path",
|
||||||
"--jpeg-quality",
|
"--jpeg-quality",
|
||||||
"--inject-max-frame-bytes",
|
"--inject-max-frame-bytes",
|
||||||
|
"--server-uvc-audit",
|
||||||
|
"--server-uvc-audit-host",
|
||||||
|
"--server-uvc-audit-control-path",
|
||||||
|
"--server-uvc-audit-dir",
|
||||||
|
"--server-uvc-audit-sample-frames",
|
||||||
"--stream-analyze",
|
"--stream-analyze",
|
||||||
"--sequence-window",
|
"--sequence-window",
|
||||||
"--mix-mae-threshold",
|
"--mix-mae-threshold",
|
||||||
@ -94,6 +99,20 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
|||||||
"lower_dominant_sequence",
|
"lower_dominant_sequence",
|
||||||
"max_mixed_band_count",
|
"max_mixed_band_count",
|
||||||
"max_sequence_boundary_count",
|
"max_sequence_boundary_count",
|
||||||
|
"lesavka.server-uvc-boundary-summary.v1",
|
||||||
|
"server_uvc_boundary",
|
||||||
|
"server_uvc_audit_artifacts",
|
||||||
|
"server_boundary_missing",
|
||||||
|
"server_boundary_frame_mode_mismatch",
|
||||||
|
"server_boundary_uvc_mode_mismatch",
|
||||||
|
"server_boundary_visual_corruption",
|
||||||
|
"downstream_uvc_or_browser_corruption",
|
||||||
|
"no_visual_corruption_observed",
|
||||||
|
"spool-audit.jsonl",
|
||||||
|
"matching_frame_records",
|
||||||
|
"matching_uvc_mode_records",
|
||||||
|
"decoded_sample_count",
|
||||||
|
"marker_sample_count",
|
||||||
"diagnosis",
|
"diagnosis",
|
||||||
"encoded_oversize_frames",
|
"encoded_oversize_frames",
|
||||||
"sent_frames",
|
"sent_frames",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user