475 lines
16 KiB
Rust
475 lines
16 KiB
Rust
#![forbid(unsafe_code)]
|
|
|
|
use anyhow::{Result, bail};
|
|
use gstreamer as gst;
|
|
use std::sync::OnceLock;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static DEV_MODE: OnceLock<bool> = OnceLock::new();
|
|
pub const SOFTWARE_VIDEO_FALLBACK_ENV: &str = "LESAVKA_ALLOW_SOFTWARE_VIDEO";
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// Return whether software video decode/encode fallback is explicitly allowed.
|
|
///
|
|
/// Inputs: `LESAVKA_ALLOW_SOFTWARE_VIDEO`.
|
|
/// Outputs: `true` only for intentional opt-in values.
|
|
/// Why: production Lesavka should fail loudly when hardware video acceleration
|
|
/// is missing instead of silently wedging a host with CPU decode.
|
|
#[must_use]
|
|
pub fn software_video_fallback_allowed() -> bool {
|
|
std::env::var(SOFTWARE_VIDEO_FALLBACK_ENV)
|
|
.ok()
|
|
.is_some_and(|value| {
|
|
let trimmed = value.trim();
|
|
!(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"))
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_hardware_h264_decoder(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"v4l2h264dec" | "v4l2slh264dec" | "omxh264dec" | "vulkanh264dec"
|
|
)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_hardware_hevc_decoder(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"v4l2slh265dec" | "v4l2h265dec" | "vulkanh265dec" | "nvh265dec" | "nvh265sldec"
|
|
)
|
|
}
|
|
|
|
fn buildable_decoder(name: &str) -> bool {
|
|
gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok()
|
|
}
|
|
|
|
fn env_override_decoder(env_name: &str) -> Option<String> {
|
|
std::env::var(env_name)
|
|
.ok()
|
|
.map(|name| name.trim().to_string())
|
|
.filter(|name| !name.is_empty())
|
|
}
|
|
|
|
/// 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. Software decode is allowed only by explicit lab opt-in.
|
|
#[must_use]
|
|
pub fn pick_h264_decoder() -> &'static str {
|
|
require_h264_decoder().unwrap_or("missing-hardware-h264dec")
|
|
}
|
|
|
|
pub fn require_h264_decoder() -> Result<&'static str> {
|
|
if let Some(name) = env_override_decoder("LESAVKA_H264_DECODER") {
|
|
if !buildable_decoder(&name) {
|
|
bail!("requested H.264 decoder '{name}' is not buildable");
|
|
}
|
|
let leaked = Box::leak(name.into_boxed_str());
|
|
if is_hardware_h264_decoder(leaked) || software_video_fallback_allowed() {
|
|
return Ok(leaked);
|
|
}
|
|
bail!(
|
|
"requested H.264 decoder '{leaked}' is not a hardware decoder; set {SOFTWARE_VIDEO_FALLBACK_ENV}=1 only for lab fallback"
|
|
);
|
|
}
|
|
|
|
for name in [
|
|
"v4l2h264dec",
|
|
"v4l2slh264dec",
|
|
"omxh264dec",
|
|
"vulkanh264dec",
|
|
] {
|
|
if buildable_decoder(name) {
|
|
return Ok(name);
|
|
}
|
|
}
|
|
|
|
if software_video_fallback_allowed() {
|
|
for name in ["avdec_h264", "openh264dec"] {
|
|
if buildable_decoder(name) {
|
|
return Ok(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
bail!("hardware H.264 decoder required, but no buildable V4L2/OMX/Vulkan decoder was found")
|
|
}
|
|
|
|
/// Pick the HEVC decoder that should be used for client-origin H.265 media.
|
|
///
|
|
/// Inputs: optional `LESAVKA_HEVC_DECODER` / `LESAVKA_HEVC_ALLOW_HARDWARE`
|
|
/// environment overrides plus the local GStreamer registry.
|
|
/// Outputs: a decoder element name.
|
|
/// Why: production media must stay on hardware acceleration; software HEVC
|
|
/// fallback is intentionally opt-in for lab/debug runs only.
|
|
#[must_use]
|
|
pub fn pick_hevc_decoder() -> &'static str {
|
|
require_hevc_decoder().unwrap_or("missing-hardware-hevcdec")
|
|
}
|
|
|
|
pub fn require_hevc_decoder() -> Result<&'static str> {
|
|
if let Some(name) = env_override_decoder("LESAVKA_HEVC_DECODER") {
|
|
if !buildable_decoder(&name) {
|
|
bail!("requested HEVC decoder '{name}' is not buildable");
|
|
}
|
|
let leaked = Box::leak(name.into_boxed_str());
|
|
if is_hardware_hevc_decoder(leaked) || software_video_fallback_allowed() {
|
|
return Ok(leaked);
|
|
}
|
|
bail!(
|
|
"requested HEVC decoder '{leaked}' is not a hardware decoder; set {SOFTWARE_VIDEO_FALLBACK_ENV}=1 only for lab fallback"
|
|
);
|
|
}
|
|
|
|
for name in ["v4l2slh265dec", "v4l2h265dec", "vulkanh265dec"] {
|
|
if buildable_decoder(name) {
|
|
return Ok(name);
|
|
}
|
|
}
|
|
|
|
if software_video_fallback_allowed() {
|
|
for name in ["avdec_h265", "libde265dec"] {
|
|
if buildable_decoder(name) {
|
|
return Ok(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
bail!("hardware HEVC decoder required, but no buildable V4L2/Vulkan decoder was found")
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Detect whether an HEVC access unit contains an intra recovery point.
|
|
///
|
|
/// Inputs: one Annex-B encoded HEVC access unit. Output: `true` when the access
|
|
/// unit carries an IRAP NAL. Why: freshness-first upstream media can drop HEVC
|
|
/// packets before server decode; after that gap, the decoder must resume from a
|
|
/// clean picture instead of a predictive frame with missing references.
|
|
#[must_use]
|
|
pub fn contains_hevc_irap(hevc: &[u8]) -> bool {
|
|
let mut index = 0;
|
|
while index + 4 < hevc.len() {
|
|
if hevc[index] == 0 && hevc[index + 1] == 0 {
|
|
let offset = if hevc[index + 2] == 1 {
|
|
3
|
|
} else if hevc[index + 2] == 0 && hevc[index + 3] == 1 {
|
|
4
|
|
} else {
|
|
index += 1;
|
|
continue;
|
|
};
|
|
let nal_index = index + offset;
|
|
if nal_index + 1 < hevc.len() {
|
|
let nal_type = (hevc[nal_index] >> 1) & 0x3f;
|
|
if (16..=23).contains(&nal_type) {
|
|
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)
|
|
}
|
|
|
|
/// Reserve a monotonic local timestamp while preferring a caller-provided value.
|
|
///
|
|
/// Inputs: the shared counter, a preferred local timestamp, and the minimum
|
|
/// step to enforce between consecutive values.
|
|
/// Outputs: a timestamp that never goes backwards for the current sink.
|
|
/// Why: upstream media can now arrive with a shared session timeline, so sink
|
|
/// playback should honor that timing when possible while still guarding against
|
|
/// repeated values that would destabilize live playback.
|
|
#[must_use]
|
|
pub fn reserve_local_pts(counter: &AtomicU64, preferred_pts_us: u64, frame_step_us: u64) -> u64 {
|
|
let next_allowed_pts_us = counter.load(Ordering::Relaxed);
|
|
let chosen_pts_us = preferred_pts_us.max(next_allowed_pts_us);
|
|
counter.store(
|
|
chosen_pts_us.saturating_add(frame_step_us.max(1)),
|
|
Ordering::Relaxed,
|
|
);
|
|
chosen_pts_us
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
SOFTWARE_VIDEO_FALLBACK_ENV, adjust_effective_fps, contains_hevc_irap, contains_idr,
|
|
default_eye_fps, env_u32, env_usize, is_hardware_h264_decoder, is_hardware_hevc_decoder,
|
|
next_local_pts, reserve_local_pts, should_send_frame, software_video_fallback_allowed,
|
|
};
|
|
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, 1, 0x65, 0x88]));
|
|
assert!(!contains_idr(&[0, 0, 0, 1, 0x41, 0x99]));
|
|
assert!(!contains_idr(&[0, 0, 2, 0x65, 0x88]));
|
|
}
|
|
|
|
#[test]
|
|
fn contains_hevc_irap_finds_annex_b_recovery_frames() {
|
|
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]
|
|
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));
|
|
assert!(!should_send_frame(40_000, 40_001, 0));
|
|
assert!(should_send_frame(40_000, 1_040_000, 0));
|
|
}
|
|
|
|
#[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]
|
|
fn reserve_local_pts_prefers_preferred_value_but_stays_monotonic() {
|
|
let counter = AtomicU64::new(0);
|
|
assert_eq!(reserve_local_pts(&counter, 0, 40_000), 0);
|
|
assert_eq!(reserve_local_pts(&counter, 10_000, 40_000), 40_000);
|
|
assert_eq!(reserve_local_pts(&counter, 120_000, 40_000), 120_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]
|
|
#[serial]
|
|
fn software_video_fallback_requires_clear_operator_opt_in() {
|
|
for disabled in [
|
|
None,
|
|
Some(""),
|
|
Some("0"),
|
|
Some("false"),
|
|
Some("no"),
|
|
Some("off"),
|
|
] {
|
|
with_var(SOFTWARE_VIDEO_FALLBACK_ENV, disabled, || {
|
|
assert!(!software_video_fallback_allowed());
|
|
});
|
|
}
|
|
|
|
for enabled in ["1", "true", "yes", "on", "lab"] {
|
|
with_var(SOFTWARE_VIDEO_FALLBACK_ENV, Some(enabled), || {
|
|
assert!(software_video_fallback_allowed());
|
|
});
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hardware_decoder_classification_keeps_software_fallbacks_out() {
|
|
for name in [
|
|
"v4l2h264dec",
|
|
"v4l2slh264dec",
|
|
"omxh264dec",
|
|
"vulkanh264dec",
|
|
] {
|
|
assert!(is_hardware_h264_decoder(name));
|
|
}
|
|
assert!(!is_hardware_h264_decoder("avdec_h264"));
|
|
|
|
for name in [
|
|
"v4l2slh265dec",
|
|
"v4l2h265dec",
|
|
"vulkanh265dec",
|
|
"nvh265dec",
|
|
"nvh265sldec",
|
|
] {
|
|
assert!(is_hardware_hevc_decoder(name));
|
|
}
|
|
assert!(!is_hardware_hevc_decoder("libde265dec"));
|
|
}
|
|
|
|
#[test]
|
|
fn adjust_effective_fps_keeps_current_rate_when_no_samples() {
|
|
assert_eq!(adjust_effective_fps(18, 12, 25, 0, 0), 18);
|
|
}
|
|
}
|