lesavka/server/src/video_support.rs

237 lines
7.9 KiB
Rust

#![forbid(unsafe_code)]
use gstreamer as gst;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
static DEV_MODE: OnceLock<bool> = OnceLock::new();
/// Read an unsigned integer environment variable with a default.
///
/// Inputs: the env var name plus the fallback value.
/// Outputs: the parsed value when present and valid, or the fallback.
/// Why: video tuning knobs are operator-controlled and should never panic the
/// server when a typo slips into the environment.
#[must_use]
pub fn env_u32(name: &str, default: u32) -> u32 {
std::env::var(name)
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(default)
}
/// Read a `usize` environment variable with a default.
///
/// Inputs: the env var name plus the fallback value.
/// Outputs: the parsed value when present and valid, or the fallback.
/// Why: queue and channel capacities use `usize`, but should otherwise follow
/// the same forgiving behavior as the numeric video tuning env vars.
#[must_use]
pub fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(default)
}
/// Check whether development-mode video dumps are enabled.
///
/// Inputs: none.
/// Outputs: `true` once the process observes `LESAVKA_DEV_MODE`.
/// Why: the value is cached because the callback hot path checks it on every
/// frame when deciding whether to dump debug samples.
#[must_use]
pub fn dev_mode_enabled() -> bool {
*DEV_MODE.get_or_init(|| std::env::var("LESAVKA_DEV_MODE").is_ok())
}
/// Pick the first available H.264 decoder in our preference order.
///
/// Inputs: none.
/// Outputs: the GStreamer element name that should be instantiated.
/// Why: different targets expose different hardware decoders, so we probe in a
/// stable order before falling back to software decoding.
#[must_use]
pub fn pick_h264_decoder() -> &'static str {
if gst::ElementFactory::find("v4l2h264dec").is_some() {
"v4l2h264dec"
} else if gst::ElementFactory::find("v4l2slh264dec").is_some() {
"v4l2slh264dec"
} else if gst::ElementFactory::find("omxh264dec").is_some() {
"omxh264dec"
} else {
"avdec_h264"
}
}
/// Choose the default eye-stream FPS for the requested bitrate tier.
///
/// Inputs: the negotiated maximum bitrate in kbit/s.
/// Outputs: the target FPS before env overrides are applied.
/// Why: low bitrates need a lower frame rate to preserve visual quality, while
/// higher bitrates can sustain the full target cadence.
#[must_use]
pub fn default_eye_fps(max_bitrate_kbit: u32) -> u32 {
match max_bitrate_kbit {
0 => 25,
1..=2_500 => 15,
2_501..=4_000 => 20,
_ => 25,
}
}
/// Detect whether an H.264 access unit contains an IDR NAL.
///
/// Inputs: one Annex-B encoded H.264 access unit.
/// Outputs: `true` when the access unit carries an IDR frame.
/// Why: after dropping frames we wait for the next keyframe so downstream
/// decoders do not resume from a broken prediction chain.
#[must_use]
pub fn contains_idr(h264: &[u8]) -> bool {
let mut index = 0;
while index + 4 < h264.len() {
if h264[index] == 0 && h264[index + 1] == 0 {
let offset = if h264[index + 2] == 1 {
3
} else if h264[index + 2] == 0 && h264[index + 3] == 1 {
4
} else {
index += 1;
continue;
};
let nal_index = index + offset;
if nal_index < h264.len() && (h264[nal_index] & 0x1F) == 5 {
return true;
}
}
index += 1;
}
false
}
/// Compute the next adaptive eye-stream FPS after one reporting window.
///
/// Inputs: the current FPS plus the target/min bounds and the sent/dropped
/// frame counts collected during the last window.
/// Outputs: the adjusted FPS for the next window.
/// Why: the callback path keeps only counters; this pure policy function makes
/// the adaptation behavior unit-testable and easier to tune.
#[must_use]
pub fn adjust_effective_fps(
current_fps: u32,
min_fps: u32,
target_fps: u32,
dropped: u64,
sent: u64,
) -> u32 {
let total = dropped + sent;
if total == 0 {
return current_fps.max(1);
}
let drop_ratio = dropped as f64 / total as f64;
if drop_ratio > 0.10 && current_fps > min_fps {
current_fps.saturating_sub(3).max(min_fps)
} else if dropped == 0 && drop_ratio < 0.02 && current_fps < target_fps {
(current_fps + 1).min(target_fps)
} else {
current_fps.max(1)
}
}
/// Decide whether a frame should be emitted at the current pacing budget.
///
/// Inputs: the previous sent timestamp, the candidate frame timestamp, and the
/// current target FPS.
/// Outputs: `true` when enough time has elapsed to send another frame.
/// Why: rate limiting on timestamps keeps the gRPC stream bounded without
/// requiring the callback to inspect wall-clock time.
#[must_use]
pub fn should_send_frame(last_pts_us: u64, current_pts_us: u64, fps: u32) -> bool {
let frame_interval_us = 1_000_000u64 / u64::from(fps.max(1));
if frame_interval_us == 0 || last_pts_us == 0 {
return true;
}
current_pts_us.saturating_sub(last_pts_us) >= frame_interval_us
}
/// Advance the local monotonic PTS used by sink appsrc instances.
///
/// Inputs: the shared counter and the per-frame duration in microseconds.
/// Outputs: the next strictly monotonic local timestamp.
/// Why: WAN-delivered packet PTS values can arrive out of order, so sink-side
/// playback uses a synthetic monotonic timeline instead.
#[must_use]
pub fn next_local_pts(counter: &AtomicU64, frame_step_us: u64) -> u64 {
counter.fetch_add(frame_step_us, Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::{
adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize, next_local_pts,
should_send_frame,
};
use serial_test::serial;
use std::sync::atomic::AtomicU64;
use temp_env::with_var;
#[test]
fn default_eye_fps_tracks_bitrate_tiers() {
assert_eq!(default_eye_fps(0), 25);
assert_eq!(default_eye_fps(2_000), 15);
assert_eq!(default_eye_fps(3_000), 20);
assert_eq!(default_eye_fps(8_000), 25);
}
#[test]
fn contains_idr_finds_annex_b_keyframes() {
let sample = [0, 0, 0, 1, 0x65, 0x88, 0x99];
assert!(contains_idr(&sample));
assert!(!contains_idr(&[0, 0, 0, 1, 0x41, 0x99]));
}
#[test]
fn adjust_effective_fps_reacts_to_drop_windows() {
assert_eq!(adjust_effective_fps(20, 12, 25, 5, 10), 17);
assert_eq!(adjust_effective_fps(20, 12, 25, 0, 20), 21);
assert_eq!(adjust_effective_fps(12, 12, 25, 10, 10), 12);
}
#[test]
fn should_send_frame_enforces_interval() {
assert!(should_send_frame(0, 10, 25));
assert!(!should_send_frame(40_000, 50_000, 25));
assert!(should_send_frame(40_000, 90_000, 25));
}
#[test]
fn next_local_pts_monotonically_advances() {
let counter = AtomicU64::new(0);
assert_eq!(next_local_pts(&counter, 40_000), 0);
assert_eq!(next_local_pts(&counter, 40_000), 40_000);
}
#[test]
#[serial]
fn env_helpers_parse_values_and_fallbacks() {
with_var("LESAVKA_TEST_U32", Some("42"), || {
assert_eq!(env_u32("LESAVKA_TEST_U32", 7), 42);
});
with_var("LESAVKA_TEST_U32", Some("oops"), || {
assert_eq!(env_u32("LESAVKA_TEST_U32", 7), 7);
});
with_var("LESAVKA_TEST_USIZE", Some("128"), || {
assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 128);
});
with_var("LESAVKA_TEST_USIZE", None::<&str>, || {
assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 64);
});
}
#[test]
fn adjust_effective_fps_keeps_current_rate_when_no_samples() {
assert_eq!(adjust_effective_fps(18, 12, 25, 0, 0), 18);
}
}