media: require proven hardware video paths

This commit is contained in:
Brad Stein 2026-05-12 01:04:31 -03:00
parent c5a383d508
commit 8e4febe465
33 changed files with 1251 additions and 247 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.22.5"
version = "0.22.6"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.5"
version = "0.22.6"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.5"
version = "0.22.6"
dependencies = [
"anyhow",
"base64",

View File

@ -480,6 +480,10 @@ path = "tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs"
name = "probe_artifact_contract"
path = "tests/manual/artifacts/probe_artifact_contract.rs"
[[test]]
name = "hardware_media_smoke_contract"
path = "tests/manual/hardware_media/hardware_media_smoke_contract.rs"
[[test]]
name = "client_uplink_performance_contract"
path = "tests/performance/client/uplink/client_uplink_performance_contract.rs"

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.5"
version = "0.22.6"
edition = "2024"
[dependencies]

View File

@ -6,7 +6,9 @@ use gstreamer_app as gst_app;
use lesavka_common::lesavka::VideoPacket;
use std::{
io::Write,
os::fd::IntoRawFd,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
@ -50,6 +52,7 @@ pub struct CameraCapture {
#[allow(dead_code)] // kept alive to hold PLAYING state
pipeline: gst::Pipeline,
sink: gst_app::AppSink,
ffmpeg_child: Option<Child>,
preview_tap_running: Option<Arc<AtomicBool>>,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser,
frame_duration_us: u64,
@ -182,8 +185,8 @@ mod tests {
#[test]
#[serial]
/// HEVC software fallback options must stay shaped for live transport.
fn hevc_encoder_options_keep_low_latency_and_keyframes() {
/// HEVC lab fallback options must stay shaped for live transport.
fn hevc_lab_fallback_options_keep_low_latency_and_keyframes() {
temp_env::with_var("LESAVKA_CAM_HEVC_KBIT", Some("2400"), || {
let options = CameraCapture::encoder_options("x265enc", Some("key-int-max"), 30);
@ -239,7 +242,7 @@ mod tests {
/// Coverage builds use a deterministic HEVC encoder choice.
fn coverage_hevc_encoder_choice_is_stable() {
assert_eq!(
CameraCapture::choose_hevc_encoder(),
CameraCapture::choose_hevc_encoder().unwrap(),
("x265enc", Some("key-int-max"))
);
}

View File

@ -65,5 +65,9 @@ impl Drop for CameraCapture {
running.store(false, Ordering::Release);
}
let _ = self.pipeline.set_state(gst::State::Null);
if let Some(child) = &mut self.ffmpeg_child {
let _ = child.kill();
let _ = child.wait();
}
}
}

View File

@ -68,19 +68,34 @@ impl CameraCapture {
env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps)
};
let source_profile = camera_source_profile(allow_mjpg_source);
#[cfg(not(coverage))]
if output_hevc && Self::should_use_ffmpeg_hevc_nvenc() {
return Self::new_ffmpeg_hevc_nvenc(
&dev_label,
source_profile,
capture_width,
capture_height,
capture_fps,
width,
height,
fps,
keyframe_interval,
camera_preview_tap_path().is_some(),
);
}
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
let passthrough_mjpg_source =
use_mjpg_source && capture_profile == (width, height, fps);
let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg {
if output_hevc {
Self::choose_hevc_encoder()
Self::choose_hevc_encoder()?
} else {
Self::choose_encoder()
Self::choose_encoder()?
}
} else if output_hevc {
Self::choose_hevc_encoder()
Self::choose_hevc_encoder()?
} else {
Self::choose_encoder()
Self::choose_encoder()?
};
match source_profile {
CameraSourceProfile::Mjpeg if !output_mjpeg => {
@ -275,12 +290,164 @@ impl CameraCapture {
Ok(Self {
pipeline,
sink,
ffmpeg_child: None,
preview_tap_running,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(),
frame_duration_us: (1_000_000u64 / u64::from(fps.max(1))).max(1),
})
}
#[cfg(not(coverage))]
fn new_ffmpeg_hevc_nvenc(
dev_label: &str,
source_profile: CameraSourceProfile,
capture_width: u32,
capture_height: u32,
capture_fps: u32,
width: u32,
height: u32,
fps: u32,
keyframe_interval: u32,
preview_tap_enabled: bool,
) -> anyhow::Result<Self> {
if preview_tap_enabled {
tracing::warn!(
"📸 HEVC NVENC route is active; launcher preview tap is temporarily disabled for this hardware encode path"
);
}
let bitrate_kbit = env_u32("LESAVKA_CAM_HEVC_KBIT", 3000).max(250);
let fps = fps.max(1);
let capture_fps = capture_fps.max(1);
let keyframe_interval = keyframe_interval.max(1);
let mut command = Command::new("ffmpeg");
command
.arg("-hide_banner")
.arg("-loglevel")
.arg(std::env::var("LESAVKA_FFMPEG_LOGLEVEL").unwrap_or_else(|_| "warning".into()))
.arg("-nostdin")
.arg("-fflags")
.arg("nobuffer")
.arg("-flags")
.arg("low_delay")
.arg("-use_wallclock_as_timestamps")
.arg("1");
if dev_label.starts_with("/dev/") {
command
.arg("-f")
.arg("v4l2")
.arg("-framerate")
.arg(capture_fps.to_string())
.arg("-video_size")
.arg(format!("{capture_width}x{capture_height}"));
if source_profile == CameraSourceProfile::Mjpeg {
command.arg("-input_format").arg("mjpeg");
}
command.arg("-i").arg(dev_label);
} else if dev_label.starts_with("videotestsrc:") {
command
.arg("-f")
.arg("lavfi")
.arg("-i")
.arg(format!(
"testsrc2=size={capture_width}x{capture_height}:rate={capture_fps}"
));
} else {
anyhow::bail!("FFmpeg HEVC NVENC route does not understand camera source '{dev_label}'");
}
let video_filter =
format!("scale={width}:{height}:flags=fast_bilinear,fps={fps},format=nv12");
let bitrate = format!("{bitrate_kbit}k");
command
.arg("-an")
.arg("-sn")
.arg("-dn")
.arg("-vf")
.arg(video_filter)
.arg("-c:v")
.arg("hevc_nvenc")
.arg("-preset")
.arg("p1")
.arg("-tune")
.arg("ll")
.arg("-rc")
.arg("cbr")
.arg("-b:v")
.arg(&bitrate)
.arg("-maxrate")
.arg(&bitrate)
.arg("-bufsize")
.arg(&bitrate)
.arg("-g")
.arg(keyframe_interval.to_string())
.arg("-bf")
.arg("0")
.arg("-forced-idr")
.arg("1")
.arg("-f")
.arg("hevc")
.arg("pipe:1")
.stdout(Stdio::piped())
.stderr(Stdio::null());
tracing::info!(
device = dev_label,
capture_width,
capture_height,
capture_fps,
output_width = width,
output_height = height,
output_fps = fps,
bitrate_kbit,
keyframe_interval,
"📸 using FFmpeg hevc_nvenc hardware encoder"
);
let mut child = command.spawn().context("starting FFmpeg hevc_nvenc camera encoder")?;
let stdout = child
.stdout
.take()
.context("FFmpeg hevc_nvenc stdout was not piped")?;
let fd = stdout.into_raw_fd();
let desc = format!(
"fdsrc fd={fd} blocksize=1048576 ! \
h265parse config-interval=-1 ! \
video/x-h265,stream-format=byte-stream,alignment=au ! \
queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true"
);
let pipeline: gst::Pipeline = match gst::parse::launch(&desc) {
Ok(element) => element.downcast::<gst::Pipeline>().expect("not a pipeline"),
Err(err) => {
let _ = child.kill();
return Err(err).context("gst parse_launch(ffmpeg hevc nvenc)");
}
};
let sink: gst_app::AppSink = pipeline
.by_name("asink")
.context("appsink element not found for FFmpeg HEVC route")?
.downcast::<gst_app::AppSink>()
.expect("appsink down-cast");
spawn_camera_bus_logger(&pipeline, format!("{dev_label} via ffmpeg hevc_nvenc"));
if let Err(err) = pipeline.set_state(gst::State::Playing) {
let _ = pipeline.set_state(gst::State::Null);
let _ = child.kill();
return Err(err).context("starting FFmpeg HEVC GStreamer handoff pipeline");
}
Ok(Self {
pipeline,
sink,
ffmpeg_child: Some(child),
preview_tap_running: None,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(),
frame_duration_us: (1_000_000u64 / u64::from(fps.max(1))).max(1),
})
}
/// Keeps `pull` 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.
pub fn pull(&self) -> Option<VideoPacket> {

View File

@ -93,6 +93,7 @@ impl CameraCapture {
Self {
pipeline,
sink,
ffmpeg_child: None,
preview_tap_running: None,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(),
frame_duration_us: 1,

View File

@ -7,15 +7,16 @@ impl CameraCapture {
("vulkanh264enc", "video/x-raw(memory:VulkanImage),format=NV12"),
("vaapih264enc", "video/x-raw,format=NV12"),
("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc.
("x264enc", "video/x-raw"), // software
];
for (name, caps) in encoders {
if gst::ElementFactory::find(name).is_some() {
return (name, caps);
}
}
// last resort software
("x264enc", "video/x-raw")
if Self::software_video_fallback_allowed() {
return ("x264enc", "video/x-raw");
}
("missing-hardware-h264enc", "video/x-raw")
}
#[cfg(coverage)]
@ -24,40 +25,45 @@ impl CameraCapture {
}
#[cfg(not(coverage))]
fn choose_encoder() -> (&'static str, Option<&'static str>) {
fn choose_encoder() -> anyhow::Result<(&'static str, Option<&'static str>)> {
if buildable_encoder("nvh264enc") {
return (
return Ok((
"nvh264enc",
supported_encoder_property(
"nvh264enc",
&["iframeinterval", "idrinterval", "gop-size"],
),
);
));
}
if buildable_encoder("vulkanh264enc") {
return (
return Ok((
"vulkanh264enc",
supported_encoder_property("vulkanh264enc", &["idr-period"]),
);
));
}
if buildable_encoder("vaapih264enc") {
return (
return Ok((
"vaapih264enc",
supported_encoder_property("vaapih264enc", &["keyframe-period"]),
);
));
}
if buildable_encoder("v4l2h264enc") {
return (
return Ok((
"v4l2h264enc",
supported_encoder_property("v4l2h264enc", &["idrcount"]),
);
));
}
("x264enc", Some("key-int-max"))
if Self::software_video_fallback_allowed() {
return Ok(("x264enc", Some("key-int-max")));
}
anyhow::bail!(
"hardware H.264 encoder required, but no buildable NVIDIA/Vulkan/VAAPI/V4L2 encoder was found"
)
}
#[cfg(coverage)]
fn choose_encoder() -> (&'static str, Option<&'static str>) {
match std::env::var("LESAVKA_CAM_TEST_ENCODER")
fn choose_encoder() -> anyhow::Result<(&'static str, Option<&'static str>)> {
Ok(match std::env::var("LESAVKA_CAM_TEST_ENCODER")
.ok()
.as_deref()
.map(str::trim)
@ -67,7 +73,7 @@ impl CameraCapture {
Some("vaapih264enc") => ("vaapih264enc", Some("keyframe-period")),
Some("v4l2h264enc") => ("v4l2h264enc", Some("idrcount")),
_ => ("x264enc", Some("key-int-max")),
}
})
}
#[cfg(not(coverage))]
@ -78,7 +84,7 @@ impl CameraCapture {
/// property used to keep keyframes frequent. Why: transport freshness
/// improves only if HEVC is encoded in a live-call shape instead of a
/// throughput-oriented offline encode shape.
fn choose_hevc_encoder() -> (&'static str, Option<&'static str>) {
fn choose_hevc_encoder() -> anyhow::Result<(&'static str, Option<&'static str>)> {
for (name, keyframe_props) in [
("nvh265enc", &["iframeinterval", "idrinterval", "gop-size"][..]),
("vah265enc", &["keyframe-period"][..]),
@ -86,10 +92,15 @@ impl CameraCapture {
("v4l2h265enc", &["idrcount"][..]),
] {
if buildable_encoder(name) {
return (name, supported_encoder_property(name, keyframe_props));
return Ok((name, supported_encoder_property(name, keyframe_props)));
}
}
("x265enc", Some("key-int-max"))
if Self::software_video_fallback_allowed() {
return Ok(("x265enc", Some("key-int-max")));
}
anyhow::bail!(
"hardware HEVC encoder required, but no GStreamer HEVC hardware encoder was found and FFmpeg hevc_nvenc was not selected"
)
}
#[cfg(coverage)]
@ -98,8 +109,62 @@ impl CameraCapture {
/// Inputs: none. Output: the software encoder contract used by tests. Why:
/// coverage builds should exercise deterministic string construction
/// without depending on workstation-specific hardware encoders.
fn choose_hevc_encoder() -> (&'static str, Option<&'static str>) {
("x265enc", Some("key-int-max"))
fn choose_hevc_encoder() -> anyhow::Result<(&'static str, Option<&'static str>)> {
Ok(("x265enc", Some("key-int-max")))
}
#[cfg(not(coverage))]
fn software_video_fallback_allowed() -> bool {
std::env::var("LESAVKA_ALLOW_SOFTWARE_VIDEO")
.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"))
})
}
#[cfg(coverage)]
fn software_video_fallback_allowed() -> bool {
true
}
#[cfg(not(coverage))]
fn gstreamer_hevc_hardware_encoder_available() -> bool {
["nvh265enc", "vah265enc", "vaapih265enc", "v4l2h265enc"]
.iter()
.any(|name| buildable_encoder(name))
}
#[cfg(not(coverage))]
fn ffmpeg_hevc_nvenc_available() -> bool {
Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-h", "encoder=hevc_nvenc"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[cfg(not(coverage))]
fn should_use_ffmpeg_hevc_nvenc() -> bool {
let requested = std::env::var("LESAVKA_CAM_HEVC_ENCODER")
.or_else(|_| std::env::var("LESAVKA_LOCAL_HEVC_ENCODER"))
.ok()
.map(|value| value.trim().to_ascii_lowercase());
match requested.as_deref() {
Some("ffmpeg_hevc_nvenc" | "hevc_nvenc" | "nvenc") => {
Self::ffmpeg_hevc_nvenc_available()
}
Some("gstreamer" | "gst") => false,
_ => {
!Self::gstreamer_hevc_hardware_encoder_available()
&& Self::ffmpeg_hevc_nvenc_available()
}
}
}
fn encoder_options(

View File

@ -1,5 +1,8 @@
#[cfg(not(coverage))]
use crate::video_support::{h264_decoder_launch_fragment, pick_h264_decoder};
use crate::video_support::{
h264_decoder_launch_fragment, h264_decoder_preference_order, require_h264_decoder,
software_video_fallback_allowed,
};
#[cfg(not(coverage))]
use anyhow::{Context, Result};
#[cfg(not(coverage))]

View File

@ -227,34 +227,25 @@ fn build_preview_pipeline(
#[cfg(not(coverage))]
fn preview_decoder_candidates() -> Vec<String> {
let mut candidates = Vec::new();
let preferred = pick_h264_decoder();
if !preferred.trim().is_empty() {
candidates.push(preferred);
let preferred = require_h264_decoder().ok();
if let Some(name) = preferred.as_ref() {
candidates.push(name.clone());
}
for name in [
"avdec_h264",
"openh264dec",
"vulkanh264dec",
"vah264dec",
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
"nvh264dec",
"nvh264sldec",
"decodebin",
] {
if name == "decodebin" || gst::ElementFactory::find(name).is_some() {
for name in h264_decoder_preference_order() {
if gst::ElementFactory::find(name).is_some() {
candidates.push(name.to_string());
}
}
if software_video_fallback_allowed() {
candidates.push("decodebin".to_string());
}
candidates.sort();
candidates.dedup();
if let Some(pos) = candidates
.iter()
.position(|name| name == &pick_h264_decoder())
{
let preferred = candidates.remove(pos);
candidates.insert(0, preferred);
if let Some(preferred) = preferred {
if let Some(pos) = candidates.iter().position(|name| name == &preferred) {
let decoder = candidates.remove(pos);
candidates.insert(0, decoder);
}
}
candidates
}

View File

@ -10,38 +10,81 @@ use lesavka_common::lesavka::VideoPacket;
use std::process::Command;
use tracing::{debug, error, info, warn};
const SOFTWARE_VIDEO_FALLBACK_ENV: &str = "LESAVKA_ALLOW_SOFTWARE_VIDEO";
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"))
})
}
fn is_hardware_h264_decoder(name: &str) -> bool {
matches!(
name,
"nvh264dec"
| "nvh264sldec"
| "vulkanh264dec"
| "vah264dec"
| "vaapih264dec"
| "v4l2h264dec"
| "v4l2slh264dec"
)
}
/// Pick the first H.264 decoder that can be built on this client.
///
/// Inputs: optional `LESAVKA_H264_DECODER` override and
/// `LESAVKA_H264_DECODER_PREFERENCE=hardware|software`. Output: a decoder
/// element name. Why: breakout windows should benefit from NVIDIA proprietary
/// decode when it is present, while keeping Vulkan/VAAPI/V4L2 and CPU routes usable
/// for open-source-driver machines and debugging.
fn pick_h264_decoder() -> String {
/// decode when it is present, and should fail instead of silently using CPU
/// decode when the hardware path is broken.
fn pick_h264_decoder() -> anyhow::Result<String> {
if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") {
let name = raw.trim();
if name.eq_ignore_ascii_case("decodebin") {
return "decodebin".to_string();
}
if !name.is_empty() && buildable_decoder(name) {
return name.to_string();
if !name.is_empty() {
if name.eq_ignore_ascii_case("decodebin") {
if software_video_fallback_allowed() {
return Ok("decodebin".to_string());
}
anyhow::bail!(
"requested H.264 decoder '{name}' is not hardware-specific; set {SOFTWARE_VIDEO_FALLBACK_ENV}=1 only for lab fallback"
);
}
if !buildable_decoder(name) {
anyhow::bail!("requested H.264 decoder '{name}' is not buildable");
}
if is_hardware_h264_decoder(name) || software_video_fallback_allowed() {
return Ok(name.to_string());
}
anyhow::bail!(
"requested H.264 decoder '{name}' is not a hardware decoder; set {SOFTWARE_VIDEO_FALLBACK_ENV}=1 only for lab fallback"
);
}
}
for name in h264_decoder_preference_order() {
if buildable_decoder(name) {
return name.to_string();
return Ok(name.to_string());
}
}
"decodebin".to_string()
anyhow::bail!(
"hardware H.264 decoder required, but no buildable NVIDIA/Vulkan/VAAPI/V4L2 decoder was found"
)
}
/// Return automatic decoder candidates in the same order breakout windows use.
///
/// Inputs: `LESAVKA_H264_DECODER_PREFERENCE`, if set. Output: ordered decoder
/// element names. Why: include-based tests need to protect the same hardware
/// and software route order as the launcher preview path.
/// route order as the launcher preview path.
fn h264_decoder_preference_order() -> Vec<&'static str> {
const HARDWARE: &[&str] = &[
"nvh264dec",
@ -54,7 +97,8 @@ fn h264_decoder_preference_order() -> Vec<&'static str> {
];
const SOFTWARE: &[&str] = &["avdec_h264", "openh264dec"];
let prefer_software = std::env::var("LESAVKA_H264_DECODER_PREFERENCE")
let prefer_software = software_video_fallback_allowed()
&& std::env::var("LESAVKA_H264_DECODER_PREFERENCE")
.ok()
.map(|value| {
matches!(
@ -70,7 +114,9 @@ fn h264_decoder_preference_order() -> Vec<&'static str> {
candidates.extend_from_slice(HARDWARE);
} else {
candidates.extend_from_slice(HARDWARE);
candidates.extend_from_slice(SOFTWARE);
if software_video_fallback_allowed() {
candidates.extend_from_slice(SOFTWARE);
}
}
candidates
}
@ -229,7 +275,7 @@ impl MonitorWindow {
gst::init().context("initialising GStreamer")?;
// --- Build pipeline ---------------------------------------------------
let decoder_name = pick_h264_decoder();
let decoder_name = pick_h264_decoder()?;
let sink = if std::env::var("GDK_BACKEND")
.map(|v| v.contains("x11"))
.unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some())

View File

@ -63,7 +63,7 @@ impl UnifiedMonitorWindow {
pub fn new() -> anyhow::Result<Self> {
gst::init().context("initialising GStreamer")?;
let decoder_name = pick_h264_decoder();
let decoder_name = pick_h264_decoder()?;
let sink = if std::env::var("GDK_BACKEND")
.map(|v| v.contains("x11"))
.unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some())

View File

@ -2,42 +2,101 @@
use gstreamer as gst;
pub const SOFTWARE_VIDEO_FALLBACK_ENV: &str = "LESAVKA_ALLOW_SOFTWARE_VIDEO";
/// Return whether software video fallback is explicitly allowed.
///
/// Inputs: `LESAVKA_ALLOW_SOFTWARE_VIDEO`.
/// Outputs: `true` only for intentional opt-in values.
/// Why: production Lesavka should fail loudly when GPU decode is unavailable,
/// instead of silently shifting downstream video onto the CPU.
#[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,
"nvh264dec"
| "nvh264sldec"
| "vulkanh264dec"
| "vah264dec"
| "vaapih264dec"
| "v4l2h264dec"
| "v4l2slh264dec"
)
}
/// Pick the client-side H.264 decoder in a predictable preference order.
///
/// Inputs: none, though operators may override the choice with
/// `LESAVKA_H264_DECODER=<element>` or bias automatic fallback order with
/// `LESAVKA_H264_DECODER_PREFERENCE=hardware|software`.
/// Outputs: the chosen decoder element name, or `decodebin` as a last-resort
/// fallback when no explicit decoder is present.
/// error when no hardware decoder is present.
/// Why: Lesavka should use GPU decode on NVIDIA/Vulkan/VAAPI/V4L2-capable clients
/// when possible, while keeping an explicit CPU route for open-source driver
/// comparisons and driver debugging.
/// and should not hide hardware failures behind CPU decode.
#[must_use]
#[allow(dead_code)] // retained for include-based tests and diagnostics.
pub fn pick_h264_decoder() -> String {
require_h264_decoder().unwrap_or_else(|_| "missing-hardware-h264dec".to_string())
}
/// Require a buildable H.264 decoder that satisfies the production policy.
///
/// Inputs: optional decoder override plus local GStreamer registry.
/// Outputs: a selected decoder or a human-readable error.
/// Why: callers that create live downstream video must fail before constructing
/// a CPU-bound pipeline when hardware decode is unavailable.
pub fn require_h264_decoder() -> Result<String, String> {
if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") {
let name = raw.trim();
if name.eq_ignore_ascii_case("decodebin") {
return "decodebin".to_string();
}
if !name.is_empty() && buildable_decoder(name) {
return name.to_string();
if !name.is_empty() {
if name.eq_ignore_ascii_case("decodebin") {
if software_video_fallback_allowed() {
return Ok("decodebin".to_string());
}
return Err(format!(
"requested H.264 decoder '{name}' is not hardware-specific; set {SOFTWARE_VIDEO_FALLBACK_ENV}=1 only for lab fallback"
));
}
if !buildable_decoder(name) {
return Err(format!("requested H.264 decoder '{name}' is not buildable"));
}
if is_hardware_h264_decoder(name) || software_video_fallback_allowed() {
return Ok(name.to_string());
}
return Err(format!(
"requested H.264 decoder '{name}' is not a hardware decoder; set {SOFTWARE_VIDEO_FALLBACK_ENV}=1 only for lab fallback"
));
}
}
for name in h264_decoder_preference_order() {
if buildable_decoder(name) {
return name.to_string();
return Ok(name.to_string());
}
}
"decodebin".to_string()
Err("hardware H.264 decoder required, but no buildable NVIDIA/Vulkan/VAAPI/V4L2 decoder was found".to_string())
}
/// Return automatic H.264 decoder candidates in selection order.
///
/// Inputs: `LESAVKA_H264_DECODER_PREFERENCE`, if set. Output: ordered decoder
/// element names. Why: tests and diagnostics need to prove both proprietary
/// NVIDIA, Vulkan, and VAAPI/V4L2 routes stay available before CPU fallback.
/// element names. Why: tests and diagnostics need to prove proprietary
/// NVIDIA, Vulkan, and VAAPI/V4L2 routes stay ahead of explicit lab fallback.
#[must_use]
pub fn h264_decoder_preference_order() -> Vec<&'static str> {
const HARDWARE: &[&str] = &[
@ -51,15 +110,16 @@ pub fn h264_decoder_preference_order() -> Vec<&'static str> {
];
const SOFTWARE: &[&str] = &["avdec_h264", "openh264dec"];
let prefer_software = std::env::var("LESAVKA_H264_DECODER_PREFERENCE")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"software" | "sw" | "cpu"
)
})
.unwrap_or(false);
let prefer_software = software_video_fallback_allowed()
&& std::env::var("LESAVKA_H264_DECODER_PREFERENCE")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"software" | "sw" | "cpu"
)
})
.unwrap_or(false);
let mut candidates = Vec::with_capacity(HARDWARE.len() + SOFTWARE.len());
if prefer_software {
@ -67,7 +127,9 @@ pub fn h264_decoder_preference_order() -> Vec<&'static str> {
candidates.extend_from_slice(HARDWARE);
} else {
candidates.extend_from_slice(HARDWARE);
candidates.extend_from_slice(SOFTWARE);
if software_video_fallback_allowed() {
candidates.extend_from_slice(SOFTWARE);
}
}
candidates
}

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.22.5"
version = "0.22.6"
edition = "2024"
build = "build.rs"

View File

@ -128,8 +128,9 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_GADGET_FORCE_CYCLE` | server hardware/device override |
| `LESAVKA_GADGET_SYSFS_ROOT` | server hardware/device override |
| `LESAVKA_GIT_SHA` | runtime/install/session override |
| `LESAVKA_H264_DECODER` | eye preview/video transport override; names an explicit GStreamer decoder such as `nvh264dec`, `v4l2h264dec`, `avdec_h264`, or `decodebin` |
| `LESAVKA_H264_DECODER_PREFERENCE` | eye preview/video transport override; `hardware`/unset prefers NVIDIA, VAAPI, and V4L2 decode before CPU fallback, while `software`/`cpu` keeps software first for driver comparison |
| `LESAVKA_H264_DECODER` | eye preview/video transport override; names an explicit hardware GStreamer decoder such as `nvh264dec`, `vulkanh264dec`, or `v4l2h264dec`; software names are rejected unless `LESAVKA_ALLOW_SOFTWARE_VIDEO=1` |
| `LESAVKA_H264_DECODER_PREFERENCE` | eye preview/video transport override; `hardware`/unset uses NVIDIA, Vulkan, VAAPI, and V4L2 decode only; `software`/`cpu` is honored only with `LESAVKA_ALLOW_SOFTWARE_VIDEO=1` for lab driver comparisons |
| `LESAVKA_ALLOW_SOFTWARE_VIDEO` | video acceleration safety override; when truthy, permits software decode/encode fallbacks for lab/debug runs only |
| `LESAVKA_HDMI_CONNECTOR` | server hardware/device override |
| `LESAVKA_HDMI_DRIVER` | server hardware/device override |
| `LESAVKA_HDMI_FBDEV` | server hardware/device override |
@ -385,8 +386,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_CORE_ONESHOT` | server gadget helper mode; when `1`, performs one descriptor rebuild/reconfigure pass and exits |
| `LESAVKA_EYE_FIRST_FRAME_TIMEOUT_MS` | runtime/install/session override; document near use before promoting to broader operator config |
| `LESAVKA_EYE_STALL_WARN_MS` | downstream eye-video diagnostic threshold; logs when an already-started eye stream stops producing samples, defaults to `5000`; `0` disables the midstream warning |
| `LESAVKA_HEVC_ALLOW_HARDWARE` | server HEVC decoder policy; when truthy, permits hardware decoder factories before the safe software fallback |
| `LESAVKA_HEVC_DECODER` | server HEVC decoder override; selects an explicit GStreamer decoder element for HEVC ingress experiments |
| `LESAVKA_HEVC_DECODER` | server HEVC decoder override; names an explicit hardware decoder such as `v4l2slh265dec` or `v4l2h265dec`; software decoders require `LESAVKA_ALLOW_SOFTWARE_VIDEO=1` |
| `LESAVKA_HEVC_POST_REBOOT_FINAL_MODES` | manual HEVC post-reboot sequence final sanity mode list; defaults to all four supported upstream profiles |
| `LESAVKA_HEVC_POST_REBOOT_OUTPUT_DIR` | manual HEVC post-reboot sequence artifact directory for local preflights, remote re-entry, and matrix logs |
| `LESAVKA_HEVC_POST_REBOOT_PENDING_MODES` | manual HEVC post-reboot sequence static-calibration mode list; defaults to the lower-risk 720p HEVC modes; set explicitly before retrying quarantined `1920x1080@20` |
@ -413,7 +413,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_HEVC_REENTRY_SYNC` | manual HEVC re-entry helper toggle; when `1`, rsyncs the local workspace to Theia before optional build/deploy |
| `LESAVKA_HEVC_REENTRY_WAIT_INTERVAL_SECONDS` | manual HEVC re-entry helper retry interval while waiting for SSH after a lab host outage, defaults to `15` |
| `LESAVKA_HEVC_REENTRY_WAIT_SECONDS` | manual HEVC re-entry helper reachability wait budget; when greater than `0`, polls SSH before status/build/deploy/reconfigure instead of failing immediately |
| `LESAVKA_INSTALL_CAM_CODEC` | server installer camera ingress codec default; persists `LESAVKA_CAM_CODEC` for installed services, defaults to `hevc` |
| `LESAVKA_INSTALL_CAM_CODEC` | server installer camera ingress codec default; persists `LESAVKA_CAM_CODEC` for installed services, defaults to `hevc`; HEVC installs run a real 1280x720 hardware decode smoke and fail before service changes when the decoder is exposed but unusable |
| `LESAVKA_INSTALL_SOURCE` | install script source selector; use `ref` to fetch the requested git ref instead of building the existing local checkout |
| `LESAVKA_INSTALL_UVC_FRAME_META` | server installer diagnostic toggle; persists `LESAVKA_UVC_FRAME_META`, defaults to `0` so spool metadata is opt-in |
| `LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH` | server installer diagnostic path; persists `LESAVKA_UVC_FRAME_META_LOG_PATH`, defaults to `/tmp/lesavka-uvc-frame-meta.jsonl` for optional client-to-RCT spool-boundary fetches |
@ -423,7 +423,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_LEGACY_SPLIT_UPLINK` | runtime/install/session override; document near use before promoting to broader operator config |
| `LESAVKA_LOCAL_HEVC_BUNDLE_AUDIT_JSON` | local HEVC bundle audit output path; receives the generated JSON manifest for outgoing synthetic HEVC+audio bundles |
| `LESAVKA_LOCAL_HEVC_BUNDLE_AUDIT_OUTPUT_DIR` | local HEVC bundle audit artifact directory, defaults to a timestamped `/tmp/lesavka-local-hevc-bundle-audit-*` path |
| `LESAVKA_LOCAL_HEVC_ENCODER` | local HEVC encoder preflight override; defaults to `auto` and otherwise names a GStreamer encoder element such as `x265enc` |
| `LESAVKA_LOCAL_HEVC_ENCODER` | local HEVC encoder preflight override; defaults to `auto`; `ffmpeg_hevc_nvenc`/`hevc_nvenc` forces the NVIDIA FFmpeg hardware route, while software encoders require `LESAVKA_ALLOW_SOFTWARE_VIDEO=1` |
| `LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_JSON` | local HEVC encoder preflight summary path; receives throughput and Annex-B validation for each tested mode |
| `LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_KBIT` | local HEVC encoder preflight bitrate in kbit/s, defaults to `3000` |
| `LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_MIN_REALTIME_FACTOR` | local HEVC encoder preflight pass threshold; encoded media seconds divided by wall time must meet this value, defaults to `1.05` |

View File

@ -150,6 +150,7 @@ report_client_media_acceleration() {
log "1e. Inspecting client media acceleration routes"
local hevc_encoder=""
local ffmpeg_hevc_encoder=""
local h264_encoder=""
local h264_decoder=""
local opus_encoder=""
@ -162,14 +163,16 @@ report_client_media_acceleration() {
nvh265enc \
vah265enc \
vaapih265enc \
v4l2h265enc \
x265enc || true)
v4l2h265enc || true)
if command -v ffmpeg >/dev/null 2>&1 \
&& ffmpeg -hide_banner -loglevel error -h encoder=hevc_nvenc >/dev/null 2>&1; then
ffmpeg_hevc_encoder="hevc_nvenc"
fi
h264_encoder=$(first_available_gst_element \
nvh264enc \
vulkanh264enc \
vaapih264enc \
v4l2h264enc \
x264enc || true)
v4l2h264enc || true)
h264_decoder=$(first_available_gst_element \
nvh264dec \
nvh264sldec \
@ -177,9 +180,7 @@ report_client_media_acceleration() {
vah264dec \
vaapih264dec \
v4l2h264dec \
v4l2slh264dec \
avdec_h264 \
openh264dec || true)
v4l2slh264dec || true)
opus_encoder=$(first_available_gst_element opusenc || true)
opus_decoder=$(first_available_gst_element opusdec || true)
webrtc_dsp=$(first_available_gst_element webrtcdsp || true)
@ -202,10 +203,14 @@ report_client_media_acceleration() {
if command -v nvidia-smi >/dev/null 2>&1; then
echo " ↪ nvidia-smi is available; proprietary NVIDIA driver tooling is present"
if [[ -z $hevc_encoder ]] || [[ $hevc_encoder == x265enc ]]; then
if [[ -z $hevc_encoder ]]; then
if gst-inspect-1.0 nvcodec 2>&1 | grep -q 'Unable to initialize CUDA library'; then
echo "⚠️ NVIDIA nvcodec is installed but CUDA initialization failed; NVENC HEVC is unavailable to GStreamer."
echo " Vulkan H.264 hardware encode/decode can still be used when the relay profile is H.264."
if [[ -n $ffmpeg_hevc_encoder ]]; then
echo " FFmpeg hevc_nvenc is available and will be used for HEVC upstream hardware encode."
else
echo " Install a working NVENC/VAAPI/V4L2 HEVC route before enabling HEVC upstream."
fi
fi
fi
else
@ -223,20 +228,20 @@ report_client_media_acceleration() {
echo " ↪ Vulkan/VAAPI/V4L2 GStreamer route: not exposed"
fi
if [[ -n $hevc_encoder ]]; then
echo " ↪ upstream HEVC encoder candidate: $hevc_encoder"
if [[ -n $hevc_encoder || -n $ffmpeg_hevc_encoder ]]; then
echo " ↪ upstream HEVC hardware encoder candidate: ${hevc_encoder:-ffmpeg:$ffmpeg_hevc_encoder}"
else
echo "⚠️ no HEVC encoder was detected; upstream HEVC will need NVIDIA/VAAPI/V4L2 or x265enc"
echo "⚠️ no hardware HEVC encoder was detected; upstream HEVC will fail instead of using x265enc"
fi
if [[ -n $h264_encoder ]]; then
echo " ↪ upstream H.264 encoder candidate: $h264_encoder"
echo " ↪ upstream H.264 hardware encoder candidate: $h264_encoder"
else
echo "⚠️ no H.264 encoder was detected; hardware H.264 uplink will need NVIDIA/Vulkan/VAAPI/V4L2 or x264enc"
echo "⚠️ no hardware H.264 encoder was detected; H.264 uplink will fail instead of using x264enc"
fi
if [[ -n $h264_decoder ]]; then
echo " ↪ downstream H.264 decoder candidate: $h264_decoder"
echo " ↪ downstream H.264 hardware decoder candidate: $h264_decoder"
else
echo "⚠️ no H.264 decoder was detected; downstream eye preview may fall back to decodebin"
echo "⚠️ no hardware H.264 decoder was detected; downstream eye video will fail instead of using decodebin"
fi
if [[ -n $opus_encoder && -n $opus_decoder ]]; then
echo "✅ Opus upstream audio transport route: encoder=$opus_encoder decoder=$opus_decoder"
@ -248,7 +253,7 @@ report_client_media_acceleration() {
else
echo " ↪ microphone noise suppression route: unavailable; raw microphone path still works"
fi
echo " ↪ override decoder route with LESAVKA_H264_DECODER=<element> or LESAVKA_H264_DECODER_PREFERENCE=software"
echo " ↪ override decoder route with LESAVKA_H264_DECODER=<hardware-element>; software routes require LESAVKA_ALLOW_SOFTWARE_VIDEO=1"
}
require_kernel_module() {
@ -432,7 +437,7 @@ pacman_install \
"${PIPEWIRE_PACKAGES[@]}" wireplumber \
alsa-utils gst-plugin-pipewire \
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils openssl
ffmpeg wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils openssl
ensure_yay() {
if command -v yay >/dev/null 2>&1; then
@ -464,6 +469,7 @@ sudo usermod -aG input "$ORIG_USER"
log "1d. Verifying runtime tools"
require_command pactl "libpulse"
require_command gst-inspect-1.0 "gstreamer"
require_command ffmpeg "ffmpeg"
require_command arecord "alsa-utils"
require_command speaker-test "alsa-utils"
require_command wmctrl "wmctrl"

View File

@ -168,7 +168,7 @@ ensure_hevc_decode_support() {
echo " ↪ rpi_hevc_dec kernel module loaded"
echo 'rpi_hevc_dec' | sudo tee /etc/modules-load.d/lesavka-hevc.conf >/dev/null
else
echo " ↪ rpi_hevc_dec kernel module unavailable; HEVC decode will use CPU fallback when needed"
echo " ↪ rpi_hevc_dec kernel module unavailable; HEVC decode will require another hardware decoder"
fi
if getent group video >/dev/null 2>&1 && [ -n "${ORIG_USER:-}" ] && [ "${ORIG_USER}" != "root" ]; then
@ -179,16 +179,44 @@ ensure_hevc_decode_support() {
rm -f "${USER_HOME}/.cache/gstreamer-1.0"/registry.* 2>/dev/null || true
sudo rm -f /root/.cache/gstreamer-1.0/registry.* 2>/dev/null || true
local hevc_decoder=""
if gst-inspect-1.0 v4l2slh265dec >/dev/null 2>&1; then
hevc_decoder=v4l2slh265dec
echo "✅ hardware HEVC decoder exposed: v4l2slh265dec"
echo " Lesavka will still smoke-test the decoder before using it; CPU fallback remains available."
elif gst-inspect-1.0 v4l2h265dec >/dev/null 2>&1; then
hevc_decoder=v4l2h265dec
echo "✅ hardware HEVC decoder exposed: v4l2h265dec"
echo " Lesavka will still smoke-test the decoder before using it; CPU fallback remains available."
elif gst-inspect-1.0 avdec_h265 >/dev/null 2>&1; then
echo "⚠️ hardware HEVC decoder not exposed; Lesavka can fall back to avdec_h265"
else
echo "⚠️ no HEVC decoder exposed to GStreamer; install gst-libav or a v4l2 HEVC decoder before enabling HEVC transport"
echo "❌ no hardware HEVC decoder exposed to GStreamer; Lesavka will not fall back to avdec_h265 in production." >&2
if [[ "$INSTALL_UVC_CODEC" == "hevc" || "$INSTALL_CAM_CODEC" == "hevc" ]]; then
echo " Install/repair v4l2slh265dec or set a non-HEVC UVC codec before running the server installer." >&2
exit 1
fi
fi
if [[ -n "$hevc_decoder" ]]; then
local hevc_smoke_log
hevc_smoke_log=$(mktemp "${TMPDIR}/lesavka-hevc-decode-smoke.XXXXXX.log")
local decoder_chain="$hevc_decoder"
if [[ "$hevc_decoder" == "v4l2slh265dec" ]]; then
decoder_chain="$hevc_decoder discard-corrupted-frames=true automatic-request-sync-points=true"
fi
if gst-inspect-1.0 x265enc >/dev/null 2>&1 && timeout 20 bash -o pipefail -c \
"gst-launch-1.0 -q videotestsrc num-buffers=30 ! video/x-raw,format=I420,width=1280,height=720,framerate=30/1 ! x265enc speed-preset=ultrafast tune=zerolatency key-int-max=30 ! h265parse disable-passthrough=true config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au ! ${decoder_chain} ! videoconvert ! fakesink sync=false" \
>"$hevc_smoke_log" 2>&1; then
echo "✅ hardware HEVC decoder passed a real 1280x720 decode smoke: $hevc_decoder"
else
echo "❌ hardware HEVC decoder is exposed but failed a real 1280x720 decode smoke: $hevc_decoder" >&2
echo " smoke log: $hevc_smoke_log" >&2
sed -n '1,120p' "$hevc_smoke_log" >&2 || true
if [[ "$INSTALL_UVC_CODEC" == "hevc" || "$INSTALL_CAM_CODEC" == "hevc" ]]; then
echo " Refusing HEVC install because production video decode must be hardware-accelerated and proven." >&2
echo " Use LESAVKA_INSTALL_CAM_CODEC=mjpeg and LESAVKA_INSTALL_UVC_CODEC=mjpeg for a non-HEVC install while the decoder stack is repaired." >&2
exit 1
fi
echo "⚠️ continuing because this install is not selecting HEVC camera/UVC mode." >&2
fi
fi
if gst-inspect-1.0 opusdec >/dev/null 2>&1; then

View File

@ -0,0 +1,344 @@
#!/usr/bin/env bash
# scripts/manual/run_hardware_media_smoke.sh
# Manual: local/remote hardware media smoke evidence; not part of CI.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
STAMP="$(date +%Y%m%d-%H%M%S)"
ARTIFACT_DIR="${LESAVKA_HARDWARE_SMOKE_DIR:-/tmp/lesavka-hardware-media-smoke-${STAMP}}"
RESULTS_TSV="${ARTIFACT_DIR}/results.tsv"
SUMMARY_JSON="${ARTIFACT_DIR}/summary.json"
SUMMARY_TXT="${ARTIFACT_DIR}/summary.txt"
UPSTREAM_HEVC_FILE="${ARTIFACT_DIR}/upstream-hevc-nvenc.hevc"
UPSTREAM_HEVC_FRAME="${ARTIFACT_DIR}/upstream-hevc-cuda-frame.png"
DOWNSTREAM_H264_FILE="${ARTIFACT_DIR}/downstream-h264-nvenc.h264"
DOWNSTREAM_H264_FRAME="${ARTIFACT_DIR}/downstream-h264-cuda-frame.png"
AUDIO_WAV="${ARTIFACT_DIR}/audio-aac-roundtrip.wav"
AUDIO_RMS_JSON="${ARTIFACT_DIR}/audio-aac-roundtrip-rms.json"
SMOKE_WIDTH="${LESAVKA_HARDWARE_SMOKE_WIDTH:-1280}"
SMOKE_HEIGHT="${LESAVKA_HARDWARE_SMOKE_HEIGHT:-720}"
SMOKE_FPS="${LESAVKA_HARDWARE_SMOKE_FPS:-30}"
SMOKE_FRAMES="${LESAVKA_HARDWARE_SMOKE_FRAMES:-90}"
SMOKE_BITRATE_KBPS="${LESAVKA_HARDWARE_SMOKE_BITRATE_KBPS:-5000}"
mkdir -p "${ARTIFACT_DIR}"
: >"${RESULTS_TSV}"
OVERALL=0
sanitize() {
printf '%s' "$*" | tr '\t\r\n' ' '
}
record_result() {
local name="$1"
local status="$2"
local detail="$3"
local artifact="${4:-}"
printf '%s\t%s\t%s\t%s\n' \
"${name}" \
"${status}" \
"$(sanitize "${detail}")" \
"${artifact}" >>"${RESULTS_TSV}"
}
have_command() {
command -v "$1" >/dev/null 2>&1
}
gst_has() {
gst-inspect-1.0 "$1" >/dev/null 2>&1
}
ffmpeg_has_encoder() {
local codec="$1"
ffmpeg -hide_banner -encoders 2>/dev/null \
| awk -v codec="${codec}" '$2 == codec { found = 1 } END { exit found ? 0 : 1 }'
}
ffmpeg_has_decoder() {
local codec="$1"
ffmpeg -hide_banner -decoders 2>/dev/null \
| awk -v codec="${codec}" '$2 == codec { found = 1 } END { exit found ? 0 : 1 }'
}
run_logged() {
local name="$1"
local detail="$2"
local rc=0
shift 2
local log="${ARTIFACT_DIR}/${name}.log"
echo "==> ${name}"
if "$@" >"${log}" 2>&1; then
record_result "${name}" "pass" "${detail}" "${log}"
echo " pass"
return 0
else
rc=$?
fi
record_result "${name}" "fail" "${detail}; exit=${rc}" "${log}"
echo " fail (exit ${rc})"
sed -n '1,120p' "${log}" | sed 's/^/ | /'
OVERALL=1
return 0
}
run_shell() {
local name="$1"
local detail="$2"
local command_text="$3"
run_logged "${name}" "${detail}" bash -o pipefail -c "${command_text}"
}
finish_summary() {
python3 - "${RESULTS_TSV}" "${SUMMARY_JSON}" "${SUMMARY_TXT}" "${ARTIFACT_DIR}" \
"${UPSTREAM_HEVC_FILE}" "${UPSTREAM_HEVC_FRAME}" \
"${DOWNSTREAM_H264_FILE}" "${DOWNSTREAM_H264_FRAME}" \
"${AUDIO_WAV}" "${AUDIO_RMS_JSON}" <<'PY'
import json
import pathlib
import sys
results_tsv = pathlib.Path(sys.argv[1])
summary_json = pathlib.Path(sys.argv[2])
summary_txt = pathlib.Path(sys.argv[3])
artifact_dir = pathlib.Path(sys.argv[4])
artifact_paths = {
"upstream_hevc_stream": sys.argv[5],
"upstream_hevc_cuda_frame": sys.argv[6],
"downstream_h264_stream": sys.argv[7],
"downstream_h264_cuda_frame": sys.argv[8],
"audio_aac_roundtrip_wav": sys.argv[9],
"audio_aac_roundtrip_rms": sys.argv[10],
}
results = []
for line in results_tsv.read_text(encoding="utf-8").splitlines():
name, status, detail, artifact = (line.split("\t") + ["", "", "", ""])[:4]
results.append(
{
"name": name,
"status": status,
"detail": detail,
"artifact": artifact,
}
)
failed = [row for row in results if row["status"] == "fail"]
summary = {
"status": "fail" if failed else "pass",
"artifact_dir": str(artifact_dir),
"results": results,
"artifacts": artifact_paths,
}
summary_json.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
lines = [
"Lesavka hardware media smoke summary",
f"status: {summary['status']}",
f"artifact_dir: {artifact_dir}",
"",
]
for row in results:
artifact = f" ({row['artifact']})" if row["artifact"] else ""
lines.append(f"- {row['status']}: {row['name']} - {row['detail']}{artifact}")
lines.extend(["", "Inspectable artifacts:"])
for name, path in artifact_paths.items():
lines.append(f"- {name}: {path}")
summary_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "==> hardware media smoke summary"
sed -n '1,160p' "${SUMMARY_TXT}"
echo "summary_json: ${SUMMARY_JSON}"
echo "summary_txt: ${SUMMARY_TXT}"
}
require_prereqs() {
local missing=()
for cmd in ffmpeg gst-inspect-1.0 gst-launch-1.0 python3 awk grep; do
if ! have_command "${cmd}"; then
missing+=("${cmd}")
fi
done
if ((${#missing[@]})); then
record_result "prerequisites" "fail" "missing commands: ${missing[*]}" ""
OVERALL=1
return 1
fi
record_result "prerequisites" "pass" "ffmpeg, GStreamer, and Python are available" ""
return 0
}
select_gst_h264_decoder() {
local decoder
for decoder in nvh264sldec nvh264dec vulkanh264dec vaapih264dec vaapi264dec v4l2h264dec; do
if gst_has "${decoder}"; then
printf '%s\n' "${decoder}"
return 0
fi
done
return 1
}
gst_h264_decode_chain() {
case "$1" in
vulkanh264dec)
printf '%s\n' 'vulkanh264dec discard-corrupted-frames=true automatic-request-sync-points=true ! vulkandownload'
;;
nvh264sldec|nvh264dec|vaapih264dec|vaapi264dec|v4l2h264dec)
printf '%s\n' "$1"
;;
*)
return 1
;;
esac
}
select_gst_aac_encoder() {
local encoder
for encoder in fdkaacenc voaacenc avenc_aac; do
if gst_has "${encoder}"; then
printf '%s\n' "${encoder}"
return 0
fi
done
return 1
}
main() {
echo "==> Lesavka hardware media smoke"
echo " artifact_dir=${ARTIFACT_DIR}"
echo " video=${SMOKE_WIDTH}x${SMOKE_HEIGHT}@${SMOKE_FPS} frames=${SMOKE_FRAMES}"
echo " no sudo, no systemctl, no UVC gadget reset, and no display-manager reset are used"
if ! require_prereqs; then
finish_summary
exit 1
fi
if ! ffmpeg_has_encoder hevc_nvenc; then
record_result "client_upstream_hevc_nvenc_available" "fail" "ffmpeg hevc_nvenc encoder is missing" ""
OVERALL=1
else
record_result "client_upstream_hevc_nvenc_available" "pass" "ffmpeg hevc_nvenc encoder is available" ""
fi
if ! ffmpeg_has_encoder h264_nvenc; then
record_result "client_downstream_h264_nvenc_source_available" "fail" "ffmpeg h264_nvenc encoder is missing for downstream test source" ""
OVERALL=1
else
record_result "client_downstream_h264_nvenc_source_available" "pass" "ffmpeg h264_nvenc encoder is available for downstream test source" ""
fi
if ! ffmpeg_has_decoder hevc_cuvid; then
record_result "client_hevc_cuvid_visual_decode_available" "fail" "ffmpeg hevc_cuvid decoder is missing for visual evidence frame" ""
OVERALL=1
else
record_result "client_hevc_cuvid_visual_decode_available" "pass" "ffmpeg hevc_cuvid decoder is available for visual evidence frame" ""
fi
if ! ffmpeg_has_decoder h264_cuvid; then
record_result "client_h264_cuvid_visual_decode_available" "fail" "ffmpeg h264_cuvid decoder is missing for visual evidence frame" ""
OVERALL=1
else
record_result "client_h264_cuvid_visual_decode_available" "pass" "ffmpeg h264_cuvid decoder is available for visual evidence frame" ""
fi
local gst_h264_decoder=""
local gst_h264_chain=""
if gst_h264_decoder="$(select_gst_h264_decoder)"; then
gst_h264_chain="$(gst_h264_decode_chain "${gst_h264_decoder}")"
record_result "client_downstream_gstreamer_h264_hw_decoder_available" "pass" "selected ${gst_h264_decoder}" ""
else
record_result "client_downstream_gstreamer_h264_hw_decoder_available" "fail" "no GStreamer hardware H.264 decoder found" ""
OVERALL=1
fi
local aac_encoder=""
if aac_encoder="$(select_gst_aac_encoder)" && gst_has avdec_aac && gst_has wavenc; then
record_result "audio_roundtrip_elements_available" "pass" "selected ${aac_encoder} -> avdec_aac -> wavenc" ""
else
record_result "audio_roundtrip_elements_available" "fail" "AAC encoder/decoder/wavenc elements are missing" ""
OVERALL=1
fi
if [[ ${OVERALL} -eq 0 ]]; then
run_shell "client_upstream_hevc_nvenc_file" \
"GPU encodes a synthetic upstream HEVC stream with hevc_nvenc" \
"ffmpeg -hide_banner -loglevel warning -y -nostdin -f lavfi -i testsrc2=size=${SMOKE_WIDTH}x${SMOKE_HEIGHT}:rate=${SMOKE_FPS} -frames:v ${SMOKE_FRAMES} -an -sn -dn -vf format=nv12 -c:v hevc_nvenc -preset p1 -tune ll -rc cbr -b:v ${SMOKE_BITRATE_KBPS}k -maxrate ${SMOKE_BITRATE_KBPS}k -bufsize ${SMOKE_BITRATE_KBPS}k -g ${SMOKE_FPS} -bf 0 -forced-idr 1 -f hevc '${UPSTREAM_HEVC_FILE}'"
run_shell "client_upstream_hevc_gstreamer_parse" \
"GStreamer accepts the HEVC elementary stream shape used by the upstream bundle path" \
"gst-launch-1.0 -q filesrc location='${UPSTREAM_HEVC_FILE}' ! h265parse config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au ! fakesink sync=false"
run_shell "client_upstream_hevc_cuvid_frame" \
"CUDA decodes one visual proof frame from the upstream HEVC stream" \
"ffmpeg -hide_banner -loglevel warning -y -nostdin -c:v hevc_cuvid -i '${UPSTREAM_HEVC_FILE}' -frames:v 1 '${UPSTREAM_HEVC_FRAME}'"
run_shell "client_downstream_h264_nvenc_file" \
"GPU creates a downstream-like H.264 elementary stream for decoder verification" \
"ffmpeg -hide_banner -loglevel warning -y -nostdin -f lavfi -i testsrc2=size=${SMOKE_WIDTH}x${SMOKE_HEIGHT}:rate=${SMOKE_FPS} -frames:v ${SMOKE_FRAMES} -an -sn -dn -vf format=nv12 -c:v h264_nvenc -preset p1 -tune ll -rc cbr -b:v ${SMOKE_BITRATE_KBPS}k -maxrate ${SMOKE_BITRATE_KBPS}k -bufsize ${SMOKE_BITRATE_KBPS}k -g ${SMOKE_FPS} -bf 0 -forced-idr 1 -f h264 '${DOWNSTREAM_H264_FILE}'"
run_shell "client_downstream_h264_gstreamer_hw_decode" \
"GStreamer decodes H.264 with hardware decoder ${gst_h264_decoder}" \
"gst-launch-1.0 -q filesrc location='${DOWNSTREAM_H264_FILE}' ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! ${gst_h264_chain} ! videoconvert ! fakesink sync=false"
run_shell "client_downstream_h264_cuvid_frame" \
"CUDA decodes one visual proof frame from the downstream H.264 stream" \
"ffmpeg -hide_banner -loglevel warning -y -nostdin -c:v h264_cuvid -i '${DOWNSTREAM_H264_FILE}' -frames:v 1 '${DOWNSTREAM_H264_FRAME}'"
run_shell "audio_aac_roundtrip_wav" \
"GStreamer encodes and decodes a 1 kHz tone to a WAV artifact for audio-path sanity" \
"gst-launch-1.0 -q audiotestsrc wave=sine freq=1000 num-buffers=120 ! audio/x-raw,format=S16LE,channels=2,rate=48000 ! audioconvert ! audioresample ! ${aac_encoder} ! aacparse ! avdec_aac ! audioconvert ! audioresample ! audio/x-raw,format=S16LE,channels=2,rate=48000 ! wavenc ! filesink location='${AUDIO_WAV}'"
run_shell "audio_aac_roundtrip_rms" \
"Decoded WAV contains non-silent audio energy" \
"python3 - '${AUDIO_WAV}' '${AUDIO_RMS_JSON}' <<'PY'
import json
import math
import pathlib
import struct
import sys
import wave
wav_path = pathlib.Path(sys.argv[1])
out_path = pathlib.Path(sys.argv[2])
with wave.open(str(wav_path), 'rb') as wav:
frames = wav.readframes(wav.getnframes())
sample_count = len(frames) // 2
samples = struct.unpack('<' + 'h' * sample_count, frames)
rms = math.sqrt(sum(sample * sample for sample in samples) / max(sample_count, 1))
summary = {'rms': rms, 'sample_count': sample_count, 'wav': str(wav_path)}
out_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\\n', encoding='utf-8')
if rms < 500:
raise SystemExit(f'audio RMS too low: {rms:.2f}')
print(json.dumps(summary, sort_keys=True))
PY"
fi
if [[ ${LESAVKA_RUN_REMOTE_MEDIA_SMOKE:-0} == 1 ]]; then
local remote_host="${LESAVKA_SERVER_HOST:-titan-jh}"
local ssh_opts="${SSH_OPTS:--o BatchMode=yes -o ConnectTimeout=5}"
if ssh ${ssh_opts} "${remote_host}" "gst-inspect-1.0 v4l2slh265dec >/dev/null"; then
run_shell "server_hevc_hardware_decode" \
"Remote server decodes the same upstream HEVC stream with v4l2slh265dec; no service/gadget mutation" \
"cat '${UPSTREAM_HEVC_FILE}' | ssh ${ssh_opts} '${remote_host}' \"gst-launch-1.0 -q fdsrc blocksize=1048576 ! h265parse disable-passthrough=true config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au ! v4l2slh265dec discard-corrupted-frames=true automatic-request-sync-points=true ! videoconvert ! fakesink sync=false\""
else
record_result "server_hevc_hardware_decode" "fail" "remote ${remote_host} did not expose v4l2slh265dec over ssh" ""
OVERALL=1
fi
else
record_result "server_hevc_hardware_decode" "skipped" "set LESAVKA_RUN_REMOTE_MEDIA_SMOKE=1 to verify Theia's hardware HEVC decoder without touching services" ""
fi
finish_summary
exit "${OVERALL}"
}
main "$@"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.22.5"
version = "0.22.6"
edition = "2024"
autobins = false

View File

@ -103,6 +103,9 @@ fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message
.map(gst::prelude::GstObjectExt::path_string)
.unwrap_or_else(|| "<unknown>".into());
info!("🔊 {label} audio level src={source} {}", structure);
if label == "audio" && level_message_looks_like_digital_silence(structure) {
log_remote_speaker_silence_hint();
}
} else {
debug!("🔎 audio element message: {}", structure);
}
@ -114,6 +117,24 @@ fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message
});
}
#[cfg(not(coverage))]
fn level_message_looks_like_digital_silence(structure: &gst::StructureRef) -> bool {
let text = structure.to_string();
text.contains("-699.") && text.contains("-349.")
}
#[cfg(not(coverage))]
fn log_remote_speaker_silence_hint() {
static SILENCE_MESSAGES: AtomicU64 = AtomicU64::new(0);
let count = SILENCE_MESSAGES.fetch_add(1, Ordering::Relaxed) + 1;
if count <= 3 || count % 30 == 0 {
warn!(
count,
"🔇 downstream UAC speaker capture is digital silence; Lesavka audio path is open, but the RCT/host is not currently feeding audible audio into the USB speaker endpoint"
);
}
}
/*───────────────────────────────────────────────────────────────────────────*/
/* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */
/*───────────────────────────────────────────────────────────────────────────*/

View File

@ -133,7 +133,7 @@ impl HdmiSink {
.build();
src.set_caps(Some(&caps_h264));
let h264parse = gst::ElementFactory::make("h264parse").build()?;
let decoder_name = pick_h264_decoder();
let decoder_name = require_h264_decoder()?;
let decoder = gst::ElementFactory::make(decoder_name)
.build()
.with_context(|| format!("building decoder element {decoder_name}"))?;
@ -171,7 +171,7 @@ impl HdmiSink {
.property("disable-passthrough", true)
.property("config-interval", -1i32)
.build()?;
let decoder_name = pick_hevc_decoder();
let decoder_name = require_hevc_decoder()?;
let decoder = gst::ElementFactory::make(decoder_name)
.build()
.with_context(|| format!("building HEVC decoder element {decoder_name}"))?;

View File

@ -11,7 +11,7 @@ use tracing::warn;
use crate::camera::{CameraCodec, CameraConfig};
use crate::video_support::{
contains_idr, dev_mode_enabled, pick_h264_decoder, pick_hevc_decoder, reserve_local_pts,
contains_idr, dev_mode_enabled, require_h264_decoder, require_hevc_decoder, reserve_local_pts,
};
mod mjpeg_spool;
@ -212,7 +212,7 @@ impl WebcamSink {
.property("disable-passthrough", true)
.property("config-interval", -1i32)
.build()?;
let decoder_name = pick_hevc_decoder();
let decoder_name = require_hevc_decoder()?;
let decoder = gst::ElementFactory::make(decoder_name)
.build()
.with_context(|| format!("building HEVC decoder element {decoder_name}"))?;
@ -304,7 +304,7 @@ impl WebcamSink {
src.set_caps(Some(&caps_h264));
let h264parse = gst::ElementFactory::make("h264parse").build()?;
let decoder_name = pick_h264_decoder();
let decoder_name = require_h264_decoder()?;
let decoder = gst::ElementFactory::make(decoder_name)
.build()
.with_context(|| format!("building decoder element {decoder_name}"))?;

View File

@ -1,10 +1,12 @@
#![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.
///
@ -45,23 +47,98 @@ 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 before falling back to software decoding.
/// stable order. Software decode is allowed only by explicit lab opt-in.
#[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"
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.
@ -69,37 +146,42 @@ pub fn pick_h264_decoder() -> &'static str {
/// Inputs: optional `LESAVKA_HEVC_DECODER` / `LESAVKA_HEVC_ALLOW_HARDWARE`
/// environment overrides plus the local GStreamer registry.
/// Outputs: a decoder element name.
/// Why: Raspberry Pi 5 can expose a stateless HEVC decoder before its tiled
/// output is usable by our MJPEG egress chain, so production defaults to the
/// measured-safe software decoder unless hardware is explicitly allowed.
/// 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 {
if let Ok(name) = std::env::var("LESAVKA_HEVC_DECODER") {
let trimmed = name.trim();
if !trimmed.is_empty() && gst::ElementFactory::find(trimmed).is_some() {
return Box::leak(trimmed.to_string().into_boxed_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);
}
}
let allow_hardware = std::env::var("LESAVKA_HEVC_ALLOW_HARDWARE")
.ok()
.map(|value| {
let trimmed = value.trim();
!(trimmed.eq_ignore_ascii_case("0")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off"))
})
.unwrap_or(false);
if allow_hardware {
for name in ["v4l2slh265dec", "v4l2h265dec"] {
if gst::ElementFactory::find(name).is_some() {
return name;
if software_video_fallback_allowed() {
for name in ["avdec_h265", "libde265dec"] {
if buildable_decoder(name) {
return Ok(name);
}
}
}
"avdec_h265"
bail!("hardware HEVC decoder required, but no buildable V4L2/Vulkan decoder was found")
}
/// Choose the default eye-stream FPS for the requested bitrate tier.

View File

@ -13,17 +13,47 @@ use temp_env::with_var;
#[test]
#[serial]
fn decoder_override_accepts_decodebin_without_factory_lookup() {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
fn decoder_override_accepts_decodebin_only_with_explicit_lab_fallback() {
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
});
});
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", None::<&str>, || {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(
video_support::pick_h264_decoder(),
"missing-hardware-h264dec"
);
});
});
}
#[test]
#[serial]
fn decoder_override_accepts_buildable_element() {
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(video_support::pick_h264_decoder(), "fakesink");
fn decoder_override_accepts_nonhardware_element_only_with_explicit_lab_fallback() {
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(video_support::pick_h264_decoder(), "fakesink");
});
});
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", None::<&str>, || {
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(
video_support::pick_h264_decoder(),
"missing-hardware-h264dec"
);
});
});
}
#[test]
#[serial]
fn explicit_decodebin_lab_override_is_available_for_driver_debugging() {
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
});
});
}
@ -51,12 +81,8 @@ fn decoder_auto_order_supports_proprietary_and_open_source_routes() {
assert!(order.contains(&"v4l2h264dec"));
assert!(order.contains(&"v4l2slh264dec"));
assert!(
order
.iter()
.position(|name| *name == "v4l2h264dec")
.unwrap()
< order.iter().position(|name| *name == "avdec_h264").unwrap(),
"hardware routes should be attempted before CPU fallback by default"
!order.contains(&"avdec_h264") && !order.contains(&"openh264dec"),
"CPU decoders should not be automatic production candidates"
);
});
}
@ -77,17 +103,19 @@ fn vulkan_decoder_fragment_downloads_gpu_memory_before_cpu_sinks() {
#[test]
#[serial]
fn decoder_auto_order_can_prefer_software_for_driver_comparisons() {
with_var("LESAVKA_H264_DECODER_PREFERENCE", Some("software"), || {
let order = video_support::h264_decoder_preference_order();
assert_eq!(order.first(), Some(&"avdec_h264"));
assert!(
order
.iter()
.position(|name| *name == "openh264dec")
.unwrap()
< order.iter().position(|name| *name == "nvh264dec").unwrap(),
"software preference should keep CPU decoders before GPU routes"
);
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
with_var("LESAVKA_H264_DECODER_PREFERENCE", Some("software"), || {
let order = video_support::h264_decoder_preference_order();
assert_eq!(order.first(), Some(&"avdec_h264"));
assert!(
order
.iter()
.position(|name| *name == "openh264dec")
.unwrap()
< order.iter().position(|name| *name == "nvh264dec").unwrap(),
"software preference should require explicit lab fallback"
);
});
});
}
@ -97,12 +125,18 @@ fn decoder_auto_order_can_prefer_software_for_driver_comparisons() {
fn decoder_selection_falls_back_when_no_factory_can_build() {
with_var("TEST_DISABLE_H264_DECODER_FACTORY", Some("1"), || {
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
assert_eq!(
video_support::pick_h264_decoder(),
"missing-hardware-h264dec"
);
});
});
with_var("TEST_FAIL_GST_INIT", Some("1"), || {
with_var("LESAVKA_H264_DECODER", None::<&str>, || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
assert_eq!(
video_support::pick_h264_decoder(),
"missing-hardware-h264dec"
);
});
});
}

View File

@ -1,6 +1,6 @@
// Downstream capture/decoder compatibility contracts.
//
// Scope: lock down the supported RCT capture modes and H.264 decoder fallback
// Scope: lock down the supported RCT capture modes and H.264 decoder hardware
// matrix used for server-to-client downstream video.
// Targets: `common/src/eye_source.rs`, `client/src/output/video/monitor_window.rs`,
// and `server/src/video/eye_capture.rs`.
@ -36,10 +36,8 @@ fn downstream_native_modes_remain_1080p60_and_720p60() {
}
#[test]
fn client_decoder_matrix_prefers_known_h264_decoders_with_decodebin_fallback() {
fn client_decoder_matrix_requires_hardware_with_lab_fallback_explicit() {
for decoder in [
"avdec_h264",
"openh264dec",
"nvh264dec",
"nvh264sldec",
"vulkanh264dec",
@ -47,7 +45,8 @@ fn client_decoder_matrix_prefers_known_h264_decoders_with_decodebin_fallback() {
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
"\"decodebin\".to_string()",
"LESAVKA_ALLOW_SOFTWARE_VIDEO",
"hardware H.264 decoder required",
] {
assert!(
CLIENT_MONITOR.contains(decoder),

View File

@ -13,8 +13,8 @@ use temp_env::with_var;
use lesavka_server::video_support::{
adjust_effective_fps, contains_hevc_irap, contains_idr, default_eye_fps, dev_mode_enabled,
env_u32, env_usize, next_local_pts, pick_h264_decoder, pick_hevc_decoder, reserve_local_pts,
should_send_frame,
env_u32, env_usize, next_local_pts, pick_h264_decoder, pick_hevc_decoder, require_hevc_decoder,
reserve_local_pts, should_send_frame,
};
#[test]
@ -92,43 +92,54 @@ fn pick_h264_decoder_returns_a_known_decoder_name() {
gst::init().expect("initialize gstreamer");
assert!(matches!(
pick_h264_decoder(),
"v4l2h264dec" | "v4l2slh264dec" | "omxh264dec" | "avdec_h264"
"v4l2h264dec"
| "v4l2slh264dec"
| "omxh264dec"
| "vulkanh264dec"
| "missing-hardware-h264dec"
));
}
#[test]
#[serial]
fn pick_hevc_decoder_defaults_to_measured_safe_software_decode() {
fn pick_hevc_decoder_defaults_to_hardware_or_loud_missing_decoder() {
gst::init().expect("initialize gstreamer");
with_var("LESAVKA_HEVC_DECODER", None::<&str>, || {
with_var("LESAVKA_HEVC_ALLOW_HARDWARE", None::<&str>, || {
assert_eq!(pick_hevc_decoder(), "avdec_h265");
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", None::<&str>, || {
assert!(matches!(
pick_hevc_decoder(),
"v4l2slh265dec" | "v4l2h265dec" | "vulkanh265dec" | "missing-hardware-hevcdec"
));
});
});
}
#[test]
#[serial]
fn pick_hevc_decoder_honors_safe_overrides_and_hardware_gate() {
fn pick_hevc_decoder_rejects_software_override_unless_lab_fallback_is_explicit() {
gst::init().expect("initialize gstreamer");
with_var("LESAVKA_HEVC_DECODER", Some(" avdec_h265 "), || {
with_var("LESAVKA_HEVC_ALLOW_HARDWARE", Some("0"), || {
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", None::<&str>, || {
assert_eq!(pick_hevc_decoder(), "missing-hardware-hevcdec");
assert!(require_hevc_decoder().is_err());
});
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
assert_eq!(pick_hevc_decoder(), "avdec_h265");
});
});
with_var("LESAVKA_HEVC_DECODER", Some(" definitely_missing "), || {
with_var("LESAVKA_HEVC_ALLOW_HARDWARE", Some("false"), || {
assert_eq!(pick_hevc_decoder(), "avdec_h265");
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
assert_eq!(pick_hevc_decoder(), "missing-hardware-hevcdec");
});
});
with_var("LESAVKA_HEVC_DECODER", Some(" "), || {
with_var("LESAVKA_HEVC_ALLOW_HARDWARE", Some("1"), || {
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", None::<&str>, || {
assert!(matches!(
pick_hevc_decoder(),
"v4l2slh265dec" | "v4l2h265dec" | "avdec_h265"
"v4l2slh265dec" | "v4l2h265dec" | "vulkanh265dec" | "missing-hardware-hevcdec"
));
});
});

View File

@ -98,20 +98,31 @@ mod camera_include_contract {
assert!(
matches!(
enc,
"nvh264enc" | "vulkanh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
"nvh264enc"
| "vulkanh264enc"
| "vaapih264enc"
| "v4l2h264enc"
| "missing-hardware-h264enc"
),
"unexpected encoder: {enc}"
);
let (enc, key_prop) = CameraCapture::choose_encoder();
assert!(
matches!(
enc,
"nvh264enc" | "vulkanh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
match CameraCapture::choose_encoder() {
Ok((enc, key_prop)) => {
assert!(
matches!(
enc,
"nvh264enc" | "vulkanh264enc" | "vaapih264enc" | "v4l2h264enc"
),
"unexpected encoder: {enc}"
);
if let Some(key_prop) = key_prop {
assert!(!key_prop.is_empty());
}
}
Err(err) => assert!(
err.to_string().contains("hardware H.264 encoder required"),
"unexpected encoder error: {err}"
),
"unexpected encoder: {enc}"
);
if let Some(key_prop) = key_prop {
assert!(!key_prop.is_empty());
}
}

View File

@ -99,29 +99,53 @@ mod video_include_contract {
#[serial]
fn h264_decoder_selection_honors_env_and_fallbacks() {
gst::init().expect("initialize gstreamer");
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(pick_h264_decoder(), "decodebin");
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(pick_h264_decoder().unwrap(), "decodebin");
});
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(pick_h264_decoder().unwrap(), "fakesink");
});
});
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(pick_h264_decoder(), "fakesink");
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", None::<&str>, || {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert!(pick_h264_decoder().is_err());
});
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert!(pick_h264_decoder().is_err());
});
});
with_var(
"LESAVKA_H264_DECODER",
Some("definitely-not-a-decoder"),
|| {
assert_ne!(pick_h264_decoder(), "definitely-not-a-decoder");
assert!(pick_h264_decoder().is_err());
},
);
with_var("LESAVKA_H264_DECODER", Some(" "), || {
assert!(!pick_h264_decoder().trim().is_empty());
let result = pick_h264_decoder();
assert!(
result.is_ok()
|| result
.unwrap_err()
.to_string()
.contains("hardware H.264 decoder")
);
});
with_var("LESAVKA_H264_DECODER", None::<&str>, || {
assert!(!pick_h264_decoder().trim().is_empty());
let result = pick_h264_decoder();
assert!(
result.is_ok()
|| result
.unwrap_err()
.to_string()
.contains("hardware H.264 decoder")
);
});
#[cfg(coverage)]
with_var("LESAVKA_TEST_DISABLE_H264_DECODERS", Some("1"), || {
with_var("LESAVKA_H264_DECODER", None::<&str>, || {
assert_eq!(pick_h264_decoder(), "decodebin");
assert!(pick_h264_decoder().is_err());
});
});
assert!(buildable_decoder("fakesink"));
@ -130,7 +154,7 @@ mod video_include_contract {
#[test]
#[serial]
fn h264_decoder_selection_prefers_hardware_but_keeps_software_route() {
fn h264_decoder_selection_requires_hardware_unless_lab_fallback_is_explicit() {
with_var("LESAVKA_H264_DECODER_PREFERENCE", None::<&str>, || {
let order = h264_decoder_preference_order();
assert_eq!(order.first(), Some(&"nvh264dec"));
@ -141,24 +165,23 @@ mod video_include_contract {
assert!(order.contains(&"v4l2h264dec"));
assert!(order.contains(&"v4l2slh264dec"));
assert!(
order
.iter()
.position(|name| *name == "v4l2h264dec")
.unwrap()
< order.iter().position(|name| *name == "avdec_h264").unwrap()
!order.contains(&"avdec_h264") && !order.contains(&"openh264dec"),
"software decoders should be absent from production order"
);
});
with_var("LESAVKA_H264_DECODER_PREFERENCE", Some("cpu"), || {
let order = h264_decoder_preference_order();
assert_eq!(order.first(), Some(&"avdec_h264"));
assert!(
order
.iter()
.position(|name| *name == "openh264dec")
.unwrap()
< order.iter().position(|name| *name == "nvh264dec").unwrap()
);
with_var("LESAVKA_ALLOW_SOFTWARE_VIDEO", Some("1"), || {
with_var("LESAVKA_H264_DECODER_PREFERENCE", Some("cpu"), || {
let order = h264_decoder_preference_order();
assert_eq!(order.first(), Some(&"avdec_h264"));
assert!(
order
.iter()
.position(|name| *name == "openh264dec")
.unwrap()
< order.iter().position(|name| *name == "nvh264dec").unwrap()
);
});
});
}

View File

@ -95,6 +95,10 @@ fn final_rct_route_has_sync_freshness_smoothness_and_artifact_evidence() {
category: "manual",
path: "tests/manual/artifacts/probe_artifact_contract.rs",
},
EvidencePath {
category: "manual",
path: "tests/manual/hardware_media/hardware_media_smoke_contract.rs",
},
EvidencePath {
category: "performance",
path: "tests/performance/diagnostics/stage_timing_contract.rs",

View File

@ -87,7 +87,7 @@ fn client_install_reports_nvidia_and_open_source_media_routes() {
"first_available_gst_element",
"nvidia-smi is available",
"NVIDIA nvcodec is installed but CUDA initialization failed",
"relay profile is H.264",
"FFmpeg hevc_nvenc is available",
"proprietary NVIDIA GStreamer route",
"Vulkan/VAAPI/V4L2 GStreamer route",
"nvh265enc",
@ -100,22 +100,23 @@ fn client_install_reports_nvidia_and_open_source_media_routes() {
"vah265enc",
"vaapih265enc",
"v4l2h265enc",
"ffmpeg",
"hevc_nvenc",
"vah264dec",
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
"x265enc",
"x264enc",
"upstream H.264 encoder candidate",
"avdec_h264",
"openh264dec",
"upstream H.264 hardware encoder candidate",
"downstream H.264 hardware decoder candidate",
"will fail instead of using x265enc",
"will fail instead of using x264enc",
"will fail instead of using decodebin",
"opusenc",
"opusdec",
"Opus upstream audio transport route",
"microphone noise suppression route",
"webrtcdsp",
"fall back to PCM",
"LESAVKA_H264_DECODER_PREFERENCE=software",
] {
assert!(
CLIENT_INSTALL.contains(expected),

View File

@ -165,11 +165,24 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
);
assert!(
SERVER_INSTALL.contains("rpi_hevc_dec"),
"install script should try the Raspberry Pi HEVC decoder before relying on CPU fallback"
"install script should try the Raspberry Pi HEVC decoder before requiring another hardware decoder"
);
assert!(
SERVER_INSTALL.contains("avdec_h265"),
"install script should keep a software HEVC fallback available when hardware probing fails"
SERVER_INSTALL.contains("will not fall back to avdec_h265 in production"),
"install script should fail loud instead of silently using software HEVC decode"
);
assert!(
SERVER_INSTALL.contains("hardware HEVC decoder passed a real 1280x720 decode smoke")
&& SERVER_INSTALL.contains("videotestsrc num-buffers=30")
&& SERVER_INSTALL.contains("x265enc speed-preset=ultrafast tune=zerolatency")
&& SERVER_INSTALL.contains("h265parse disable-passthrough=true config-interval=-1")
&& SERVER_INSTALL
.contains("decoder_chain=\"$hevc_decoder discard-corrupted-frames=true automatic-request-sync-points=true\""),
"install script should prove HEVC hardware decode with a real frame smoke, not only gst-inspect"
);
assert!(
SERVER_INSTALL.contains("Refusing HEVC install because production video decode must be hardware-accelerated and proven"),
"install script should refuse HEVC installs when the hardware decoder is exposed but unusable"
);
assert!(
!SERVER_INSTALL

View File

@ -0,0 +1,81 @@
// Contract tests for the hardware media smoke harness.
//
// Scope: inspect `scripts/manual/run_hardware_media_smoke.sh`.
// Why: hardware acceleration regressions are expensive to debug in the lab, so
// the manual proof script must stay artifact-backed, hardware-first, and safe
// to run without mutating Theia's USB gadget state.
const HARDWARE_SMOKE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/manual/run_hardware_media_smoke.sh"
));
#[test]
fn hardware_media_smoke_stays_manual_and_artifact_backed() {
for marker in [
"Manual: local/remote hardware media smoke evidence; not part of CI.",
"summary_json: ${SUMMARY_JSON}",
"summary_txt: ${SUMMARY_TXT}",
"upstream-hevc-nvenc.hevc",
"upstream-hevc-cuda-frame.png",
"downstream-h264-nvenc.h264",
"downstream-h264-cuda-frame.png",
"audio-aac-roundtrip.wav",
"audio-aac-roundtrip-rms.json",
] {
assert!(
HARDWARE_SMOKE.contains(marker),
"hardware smoke harness should preserve artifact marker {marker}"
);
}
}
#[test]
fn hardware_media_smoke_uses_accelerated_video_paths() {
for marker in [
"hevc_nvenc",
"h264_nvenc",
"hevc_cuvid",
"h264_cuvid",
"vulkanh264dec",
"v4l2slh265dec",
"no sudo, no systemctl, no UVC gadget reset",
] {
assert!(
HARDWARE_SMOKE.contains(marker),
"hardware smoke harness should include hardware/safety marker {marker}"
);
}
for forbidden in [
"x264enc",
"x265enc",
"avdec_h264",
"avdec_h265",
"openh264dec",
"decodebin",
"LESAVKA_ALLOW_SOFTWARE_VIDEO=1",
"LESAVKA_FORCE_GADGET_REBUILD",
] {
assert!(
!HARDWARE_SMOKE.contains(forbidden),
"hardware smoke harness should not depend on {forbidden}"
);
}
}
#[test]
fn hardware_media_smoke_has_optional_non_mutating_theia_probe() {
for marker in [
"LESAVKA_RUN_REMOTE_MEDIA_SMOKE",
"LESAVKA_SERVER_HOST",
"no service/gadget mutation",
"set LESAVKA_RUN_REMOTE_MEDIA_SMOKE=1",
"gst-inspect-1.0 v4l2slh265dec",
] {
assert!(
HARDWARE_SMOKE.contains(marker),
"remote smoke probe should preserve marker {marker}"
);
}
}

View File

@ -71,7 +71,7 @@ fn hevc_prerequisites_are_rechecked_idempotently() {
"modprobe rpi_hevc_dec",
"/etc/modules-load.d/lesavka-hevc.conf",
"gst-inspect-1.0 v4l2slh265dec",
"gst-inspect-1.0 avdec_h265",
"will not fall back to avdec_h265 in production",
] {
assert!(
SERVER_INSTALL.contains(marker),