media: harden hevc upstream smoothness
This commit is contained in:
parent
e7a6d8f288
commit
aeae7e7e07
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.21.11"
|
version = "0.21.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.21.11"
|
version = "0.21.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.21.11"
|
version = "0.21.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.21.11"
|
version = "0.21.12"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -6,6 +6,8 @@ include!("uplink_media/camera_loop.rs");
|
|||||||
|
|
||||||
include!("uplink_media/media_source_requirements.rs");
|
include!("uplink_media/media_source_requirements.rs");
|
||||||
|
|
||||||
|
include!("uplink_media/video_keyframes.rs");
|
||||||
|
|
||||||
include!("uplink_media/bundled_media_queue.rs");
|
include!("uplink_media/bundled_media_queue.rs");
|
||||||
|
|
||||||
include!("uplink_media/uplink_queue_metadata.rs");
|
include!("uplink_media/uplink_queue_metadata.rs");
|
||||||
|
|||||||
@ -148,6 +148,43 @@ use super::*;
|
|||||||
assert!(!disables_upstream_epoch_auto_heal("yes"));
|
assert!(!disables_upstream_epoch_auto_heal("yes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Keeps HEVC recovery explicit because freshness drops can otherwise resume from predictive frames.
|
||||||
|
fn hevc_keyframe_detection_recognizes_irap_access_units() {
|
||||||
|
assert!(contains_hevc_irap(&[0, 0, 0, 1, 0x26, 0x01, 0xaa]));
|
||||||
|
assert!(contains_hevc_irap(&[0, 0, 1, 0x28, 0x01, 0xaa]));
|
||||||
|
assert!(!contains_hevc_irap(&[0, 0, 0, 1, 0x02, 0x01, 0xaa]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Keeps queue-drop recovery explicit because stale HEVC bundles should not corrupt the decoder.
|
||||||
|
fn hevc_recovery_holds_delta_bundle_until_next_keyframe() {
|
||||||
|
let delta_bundle = UpstreamMediaBundle {
|
||||||
|
video: Some(VideoPacket {
|
||||||
|
data: vec![0, 0, 0, 1, 0x02, 0x01, 0xaa],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let keyframe_bundle = UpstreamMediaBundle {
|
||||||
|
video: Some(VideoPacket {
|
||||||
|
data: vec![0, 0, 0, 1, 0x26, 0x01, 0xaa],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(should_hold_hevc_bundle_for_keyframe_recovery(
|
||||||
|
true,
|
||||||
|
&delta_bundle
|
||||||
|
));
|
||||||
|
assert!(!should_hold_hevc_bundle_for_keyframe_recovery(
|
||||||
|
true,
|
||||||
|
&keyframe_bundle
|
||||||
|
));
|
||||||
|
assert!(bundle_has_hevc_recovery_keyframe(&keyframe_bundle));
|
||||||
|
}
|
||||||
|
|
||||||
/// Verifies the live uplink queue emits one physically bundled HEVC frame and PCM span.
|
/// Verifies the live uplink queue emits one physically bundled HEVC frame and PCM span.
|
||||||
///
|
///
|
||||||
/// Inputs: pre-stamped HEVC video plus two nearby audio packets, exactly as
|
/// Inputs: pre-stamped HEVC video plus two nearby audio packets, exactly as
|
||||||
|
|||||||
90
client/src/app/uplink_media/video_keyframes.rs
Normal file
90
client/src/app/uplink_media/video_keyframes.rs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Detect whether an Annex-B HEVC access unit contains an intra recovery point.
|
||||||
|
///
|
||||||
|
/// Inputs: one encoded HEVC packet in byte-stream form. Output: `true` when the
|
||||||
|
/// packet carries an IRAP NAL unit. Why: the freshness-first uplink may drop
|
||||||
|
/// predictive frames, and the server decoder should only resume from a frame
|
||||||
|
/// that can rebuild a clean picture.
|
||||||
|
fn contains_hevc_irap(data: &[u8]) -> bool {
|
||||||
|
let mut offset = 0;
|
||||||
|
while let Some((start, prefix_len)) = find_annex_b_start_code(data, offset) {
|
||||||
|
let nal_index = start + prefix_len;
|
||||||
|
if nal_index + 1 < data.len() {
|
||||||
|
let nal_type = (data[nal_index] >> 1) & 0x3f;
|
||||||
|
if (16..=23).contains(&nal_type) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = nal_index.saturating_add(2);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Locate the next Annex-B start code in an encoded video packet.
|
||||||
|
///
|
||||||
|
/// Inputs: encoded bytes plus the search offset. Output: the start index and
|
||||||
|
/// prefix length. Why: both three-byte and four-byte start codes appear in
|
||||||
|
/// HEVC streams, and keyframe recovery must handle both without allocating.
|
||||||
|
fn find_annex_b_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> {
|
||||||
|
let mut index = from;
|
||||||
|
while index + 3 < data.len() {
|
||||||
|
if data[index] == 0 && data[index + 1] == 0 {
|
||||||
|
if data[index + 2] == 1 {
|
||||||
|
return Some((index, 3));
|
||||||
|
}
|
||||||
|
if index + 4 < data.len() && data[index + 2] == 0 && data[index + 3] == 1 {
|
||||||
|
return Some((index, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Decide whether a bundled HEVC packet is safe after a freshness drop.
|
||||||
|
///
|
||||||
|
/// Inputs: the recovery state and the next outbound bundle. Output: `true`
|
||||||
|
/// when the bundle should be held back. Why: sending a non-keyframe immediately
|
||||||
|
/// after dropping older HEVC packets can make the server decode from missing
|
||||||
|
/// references, which shows up as tearing or grey corrupted frames.
|
||||||
|
fn should_hold_hevc_bundle_for_keyframe_recovery(
|
||||||
|
waiting_for_keyframe: bool,
|
||||||
|
bundle: &UpstreamMediaBundle,
|
||||||
|
) -> bool {
|
||||||
|
waiting_for_keyframe
|
||||||
|
&& bundle
|
||||||
|
.video
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|video| !contains_hevc_irap(&video.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Report whether a bundle carries the HEVC recovery frame we were waiting for.
|
||||||
|
///
|
||||||
|
/// Inputs: an outbound media bundle. Output: true when its video packet can
|
||||||
|
/// restart HEVC prediction. Why: the freshness policy should clear recovery
|
||||||
|
/// mode as soon as the next clean picture reaches the server.
|
||||||
|
fn bundle_has_hevc_recovery_keyframe(bundle: &UpstreamMediaBundle) -> bool {
|
||||||
|
bundle
|
||||||
|
.video
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|video| contains_hevc_irap(&video.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Resolve whether the active upstream camera codec needs HEVC recovery.
|
||||||
|
///
|
||||||
|
/// Inputs: the negotiated camera config plus optional env fallback. Output:
|
||||||
|
/// `true` only for HEVC/H.265. Why: MJPEG frames are independent, so the extra
|
||||||
|
/// keyframe wait should only apply to predictive video transport.
|
||||||
|
fn upstream_camera_uses_hevc(camera_cfg: Option<crate::input::camera::CameraConfig>) -> bool {
|
||||||
|
if camera_cfg.is_some_and(|cfg| matches!(cfg.codec, crate::input::camera::CameraCodec::Hevc)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::env::var("LESAVKA_CAM_CODEC")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
.is_some_and(|value| matches!(value.as_str(), "hevc" | "h265" | "h.265"))
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ impl LesavkaClientApp {
|
|||||||
) {
|
) {
|
||||||
let mut delay = Duration::from_secs(1);
|
let mut delay = Duration::from_secs(1);
|
||||||
let mut startup_epoch_heal_delay = upstream_epoch_auto_heal_delay();
|
let mut startup_epoch_heal_delay = upstream_epoch_auto_heal_delay();
|
||||||
|
let recover_hevc_after_drops = upstream_camera_uses_hevc(camera_cfg);
|
||||||
static FAIL_CNT: AtomicUsize = AtomicUsize::new(0);
|
static FAIL_CNT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@ -123,6 +124,7 @@ impl LesavkaClientApp {
|
|||||||
let microphone_telemetry_stream = microphone_telemetry.clone();
|
let microphone_telemetry_stream = microphone_telemetry.clone();
|
||||||
let drop_log_stream = Arc::clone(&drop_log);
|
let drop_log_stream = Arc::clone(&drop_log);
|
||||||
let outbound = async_stream::stream! {
|
let outbound = async_stream::stream! {
|
||||||
|
let mut waiting_for_hevc_keyframe = false;
|
||||||
loop {
|
loop {
|
||||||
let next = queue_stream.pop_fresh().await;
|
let next = queue_stream.pop_fresh().await;
|
||||||
if next.dropped_stale > 0 {
|
if next.dropped_stale > 0 {
|
||||||
@ -135,8 +137,31 @@ impl LesavkaClientApp {
|
|||||||
next.queue_depth,
|
next.queue_depth,
|
||||||
duration_ms(next.delivery_age),
|
duration_ms(next.delivery_age),
|
||||||
);
|
);
|
||||||
|
if recover_hevc_after_drops {
|
||||||
|
waiting_for_hevc_keyframe = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(mut bundle) = next.packet {
|
if let Some(mut bundle) = next.packet {
|
||||||
|
if recover_hevc_after_drops
|
||||||
|
&& should_hold_hevc_bundle_for_keyframe_recovery(
|
||||||
|
waiting_for_hevc_keyframe,
|
||||||
|
&bundle,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
camera_telemetry_stream.record_stale_drop(1);
|
||||||
|
microphone_telemetry_stream.record_stale_drop(1);
|
||||||
|
log_uplink_drop(
|
||||||
|
&drop_log_stream,
|
||||||
|
UplinkDropReason::Stale,
|
||||||
|
1,
|
||||||
|
next.queue_depth,
|
||||||
|
duration_ms(next.delivery_age),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if recover_hevc_after_drops && bundle_has_hevc_recovery_keyframe(&bundle) {
|
||||||
|
waiting_for_hevc_keyframe = false;
|
||||||
|
}
|
||||||
let queue_depth = queue_depth_u32(next.queue_depth);
|
let queue_depth = queue_depth_u32(next.queue_depth);
|
||||||
let delivery_age_ms = duration_ms(next.delivery_age);
|
let delivery_age_ms = duration_ms(next.delivery_age);
|
||||||
if bundle.video.is_some() {
|
if bundle.video.is_some() {
|
||||||
@ -175,6 +200,7 @@ impl LesavkaClientApp {
|
|||||||
let active_camera_source = active_camera_source.clone();
|
let active_camera_source = active_camera_source.clone();
|
||||||
let active_camera_profile = active_camera_profile.clone();
|
let active_camera_profile = active_camera_profile.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
let mut waiting_for_hevc_keyframe = false;
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
let state = media_controls.refresh();
|
let state = media_controls.refresh();
|
||||||
let desired_source =
|
let desired_source =
|
||||||
@ -190,10 +216,27 @@ impl LesavkaClientApp {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(mut pkt) = camera.pull() {
|
if let Some(mut pkt) = camera.pull() {
|
||||||
|
if recover_hevc_after_drops
|
||||||
|
&& waiting_for_hevc_keyframe
|
||||||
|
&& !contains_hevc_irap(&pkt.data)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let _ = stamp_video_timing_metadata_at_enqueue(&mut pkt);
|
let _ = stamp_video_timing_metadata_at_enqueue(&mut pkt);
|
||||||
|
let is_recovery_keyframe = recover_hevc_after_drops
|
||||||
|
&& contains_hevc_irap(&pkt.data);
|
||||||
match event_tx.try_send(BundledCaptureEvent::Video(pkt)) {
|
match event_tx.try_send(BundledCaptureEvent::Video(pkt)) {
|
||||||
Ok(()) => {}
|
Ok(()) => {
|
||||||
Err(std::sync::mpsc::TrySendError::Full(_)) => continue,
|
if is_recovery_keyframe {
|
||||||
|
waiting_for_hevc_keyframe = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||||||
|
if recover_hevc_after_drops {
|
||||||
|
waiting_for_hevc_keyframe = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => break,
|
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,7 +66,8 @@ include!("camera/bus_and_encoder.rs");
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
CameraCapture, CameraCodec, CameraConfig, resolved_capture_profile, resolved_output_profile,
|
CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile,
|
||||||
|
resolved_output_profile,
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
@ -194,6 +195,31 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// HEVC should recover quickly after freshness drops without changing H.264 knobs.
|
||||||
|
fn hevc_keyframe_interval_defaults_short_and_honors_overrides() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>),
|
||||||
|
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(hevc_keyframe_interval(30), 3);
|
||||||
|
assert_eq!(hevc_keyframe_interval(2), 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")),
|
||||||
|
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("1")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(hevc_keyframe_interval(30), 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
#[test]
|
#[test]
|
||||||
/// Coverage builds use a deterministic HEVC encoder choice.
|
/// Coverage builds use a deterministic HEVC encoder choice.
|
||||||
|
|||||||
@ -62,7 +62,11 @@ impl CameraCapture {
|
|||||||
let capture_profile = capture_profile_override.unwrap_or_else(|| resolved_capture_profile(cfg));
|
let capture_profile = capture_profile_override.unwrap_or_else(|| resolved_capture_profile(cfg));
|
||||||
let (capture_width, capture_height, capture_fps) = capture_profile;
|
let (capture_width, capture_height, capture_fps) = capture_profile;
|
||||||
let (width, height, fps) = resolved_output_profile(cfg, capture_profile);
|
let (width, height, fps) = resolved_output_profile(cfg, capture_profile);
|
||||||
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
|
let keyframe_interval = if output_hevc {
|
||||||
|
hevc_keyframe_interval(fps)
|
||||||
|
} else {
|
||||||
|
env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps)
|
||||||
|
};
|
||||||
let source_profile = camera_source_profile(allow_mjpg_source);
|
let source_profile = camera_source_profile(allow_mjpg_source);
|
||||||
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
|
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
|
||||||
let passthrough_mjpg_source =
|
let passthrough_mjpg_source =
|
||||||
@ -346,6 +350,18 @@ fn env_flag_enabled(name: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Choose the live HEVC keyframe cadence.
|
||||||
|
///
|
||||||
|
/// Inputs: target FPS plus optional `LESAVKA_CAM_KEYFRAME_INTERVAL` and
|
||||||
|
/// `LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL` overrides. Output: GOP length in
|
||||||
|
/// frames. Why: the uplink intentionally drops stale HEVC bundles for
|
||||||
|
/// freshness, so short GOPs keep decoder recovery below a human-visible blink.
|
||||||
|
fn hevc_keyframe_interval(fps: u32) -> u32 {
|
||||||
|
let fps = fps.max(1);
|
||||||
|
let generic_default = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(3));
|
||||||
|
env_u32("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", generic_default).clamp(1, fps)
|
||||||
|
}
|
||||||
|
|
||||||
/// Keeps `log_camera_first_packet` explicit because it sits on camera selection, where negotiated profiles must match the server output contract.
|
/// Keeps `log_camera_first_packet` explicit because it sits on camera selection, where negotiated profiles must match the server output contract.
|
||||||
/// Inputs are the typed parameters; output is the return value or side effect.
|
/// Inputs are the typed parameters; output is the return value or side effect.
|
||||||
fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) {
|
fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) {
|
||||||
|
|||||||
@ -264,7 +264,7 @@ fn pick_hevc_encoder(fps: u32) -> Result<String> {
|
|||||||
/// real webcam uplink; a one-second GOP made coded flashes less representative
|
/// real webcam uplink; a one-second GOP made coded flashes less representative
|
||||||
/// than Lesavka's default live-call HEVC pipeline.
|
/// than Lesavka's default live-call HEVC pipeline.
|
||||||
fn low_latency_hevc_keyframe_interval(fps: u32) -> u32 {
|
fn low_latency_hevc_keyframe_interval(fps: u32) -> u32 {
|
||||||
fps.clamp(1, 5)
|
fps.clamp(1, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the visual signature for a video timestamp.
|
/// Select the visual signature for a video timestamp.
|
||||||
@ -406,9 +406,9 @@ mod tests {
|
|||||||
fn low_latency_hevc_keyframe_interval_matches_live_camera_default() {
|
fn low_latency_hevc_keyframe_interval_matches_live_camera_default() {
|
||||||
assert_eq!(super::low_latency_hevc_keyframe_interval(0), 1);
|
assert_eq!(super::low_latency_hevc_keyframe_interval(0), 1);
|
||||||
assert_eq!(super::low_latency_hevc_keyframe_interval(1), 1);
|
assert_eq!(super::low_latency_hevc_keyframe_interval(1), 1);
|
||||||
assert_eq!(super::low_latency_hevc_keyframe_interval(5), 5);
|
assert_eq!(super::low_latency_hevc_keyframe_interval(5), 3);
|
||||||
assert_eq!(super::low_latency_hevc_keyframe_interval(20), 5);
|
assert_eq!(super::low_latency_hevc_keyframe_interval(20), 3);
|
||||||
assert_eq!(super::low_latency_hevc_keyframe_interval(30), 5);
|
assert_eq!(super::low_latency_hevc_keyframe_interval(30), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies encoded packet timestamps come from the encoder output sample.
|
/// Verifies encoded packet timestamps come from the encoder output sample.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.21.11"
|
version = "0.21.12"
|
||||||
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.21.11"
|
version = "0.21.12"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,7 @@ const V4L2_BUF_TYPE_VIDEO_OUTPUT: u32 = 2;
|
|||||||
const V4L2_MEMORY_MMAP: u32 = 1;
|
const V4L2_MEMORY_MMAP: u32 = 1;
|
||||||
const V4L2_FIELD_NONE: u32 = 1;
|
const V4L2_FIELD_NONE: u32 = 1;
|
||||||
const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG");
|
const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG");
|
||||||
const MAX_MJPEG_FRAME_BYTES: usize = 1024 * 1024;
|
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||||
const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
|
const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
|
||||||
const DEFAULT_UVC_BUFFER_COUNT: u32 = 2;
|
const DEFAULT_UVC_BUFFER_COUNT: u32 = 2;
|
||||||
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
|
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
|
||||||
@ -400,10 +400,11 @@ impl UvcVideoStream {
|
|||||||
let Some(buffer) = self.buffers.get(index as usize) else {
|
let Some(buffer) = self.buffers.get(index as usize) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let bytes = self.latest_frame.len().min(buffer.len);
|
let frame = self.frame_for_buffer(buffer.len);
|
||||||
|
let bytes = frame.len();
|
||||||
if bytes > 0 {
|
if bytes > 0 {
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(self.latest_frame.as_ptr(), buffer.ptr, bytes);
|
std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
unsafe {
|
unsafe {
|
||||||
@ -428,8 +429,9 @@ impl UvcVideoStream {
|
|||||||
if stale && looks_like_mjpeg_frame(&self.latest_frame) {
|
if stale && looks_like_mjpeg_frame(&self.latest_frame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let max_frame_bytes = self.frame_payload_limit();
|
||||||
if let Ok(frame) = std::fs::read(&self.frame_path)
|
if let Ok(frame) = std::fs::read(&self.frame_path)
|
||||||
&& frame.len() <= MAX_MJPEG_FRAME_BYTES
|
&& frame.len() <= max_frame_bytes
|
||||||
&& looks_like_mjpeg_frame(&frame)
|
&& looks_like_mjpeg_frame(&frame)
|
||||||
{
|
{
|
||||||
self.latest_frame = frame;
|
self.latest_frame = frame;
|
||||||
@ -437,6 +439,22 @@ impl UvcVideoStream {
|
|||||||
self.latest_frame = EMPTY_MJPEG_FRAME.to_vec();
|
self.latest_frame = EMPTY_MJPEG_FRAME.to_vec();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn frame_payload_limit(&self) -> usize {
|
||||||
|
self.buffers
|
||||||
|
.iter()
|
||||||
|
.map(|buffer| buffer.len)
|
||||||
|
.min()
|
||||||
|
.unwrap_or(MAX_MJPEG_FRAME_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_for_buffer(&self, buffer_len: usize) -> &[u8] {
|
||||||
|
if self.latest_frame.len() <= buffer_len && looks_like_mjpeg_frame(&self.latest_frame) {
|
||||||
|
&self.latest_frame
|
||||||
|
} else {
|
||||||
|
EMPTY_MJPEG_FRAME
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for UvcVideoStream {
|
impl Drop for UvcVideoStream {
|
||||||
|
|||||||
@ -400,6 +400,29 @@ mod uvc_binary_extra {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_frame_refresh_rejects_frames_that_would_be_truncated() {
|
||||||
|
let frame = NamedTempFile::new().expect("tmp frame");
|
||||||
|
let mut stream = UvcVideoStream::new(-1);
|
||||||
|
stream.frame_path = frame.path().to_path_buf();
|
||||||
|
stream.latest_frame = vec![0xff, 0xd8, 0x11, 0xff, 0xd9];
|
||||||
|
stream.buffers.push(MmapBuffer {
|
||||||
|
ptr: std::ptr::null_mut(),
|
||||||
|
len: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
frame.path(),
|
||||||
|
[0xff, 0xd8, 1, 2, 3, 4, 5, 6, 7, 8, 0xff, 0xd9],
|
||||||
|
)
|
||||||
|
.expect("write oversize frame");
|
||||||
|
stream.refresh_latest_frame();
|
||||||
|
|
||||||
|
assert_eq!(stream.latest_frame, vec![0xff, 0xd8, 0x11, 0xff, 0xd9]);
|
||||||
|
assert_eq!(stream.frame_payload_limit(), 8);
|
||||||
|
assert_eq!(stream.frame_for_buffer(4), EMPTY_MJPEG_FRAME);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compute_payload_cap_clamps_limit_pct_bounds() {
|
fn compute_payload_cap_clamps_limit_pct_bounds() {
|
||||||
with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", None::<&str>, || {
|
with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", None::<&str>, || {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user