media: require proven hardware video paths
This commit is contained in:
parent
c5a383d508
commit
8e4febe465
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.5"
|
||||
version = "0.22.6"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.5"
|
||||
version = "0.22.6"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
344
scripts/manual/run_hardware_media_smoke.sh
Executable file
344
scripts/manual/run_hardware_media_smoke.sh
Executable 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 "$@"
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.5"
|
||||
version = "0.22.6"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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 */
|
||||
/*───────────────────────────────────────────────────────────────────────────*/
|
||||
|
||||
@ -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}"))?;
|
||||
|
||||
@ -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}"))?;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
81
tests/manual/hardware_media/hardware_media_smoke_contract.rs
Normal file
81
tests/manual/hardware_media/hardware_media_smoke_contract.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user