From 8e4febe4658dd7d5582a1687c878f6321230f724 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 12 May 2026 01:04:31 -0300 Subject: [PATCH] media: require proven hardware video paths --- Cargo.lock | 6 +- Cargo.toml | 4 + client/Cargo.toml | 2 +- client/src/input/camera.rs | 9 +- client/src/input/camera/bus_and_encoder.rs | 4 + client/src/input/camera/capture_pipeline.rs | 175 ++++++++- client/src/input/camera/device_selection.rs | 1 + client/src/input/camera/encoder_selection.rs | 107 ++++-- client/src/launcher/preview/preview_core.rs | 5 +- .../src/launcher/preview/status_pipeline.rs | 35 +- client/src/output/video/monitor_window.rs | 74 +++- client/src/output/video/unified_monitor.rs | 2 +- client/src/video_support.rs | 106 ++++-- common/Cargo.toml | 2 +- docs/operational-env.md | 12 +- scripts/install/client.sh | 42 ++- scripts/install/server.sh | 40 +- scripts/manual/run_hardware_media_smoke.sh | 344 ++++++++++++++++++ server/Cargo.toml | 2 +- server/src/audio/ear_capture.rs | 21 ++ server/src/video_sinks/hdmi_sink.rs | 4 +- server/src/video_sinks/webcam_sink.rs | 6 +- server/src/video_support.rs | 144 ++++++-- .../client_video_support_include_contract.rs | 84 +++-- ...ream_video_mode_decoder_matrix_contract.rs | 9 +- .../video/video_support_contract.rs | 35 +- .../camera/client_camera_include_contract.rs | 31 +- .../client_output_video_include_contract.rs | 71 ++-- .../quality_ratchet_evidence_contract.rs | 4 + .../install/client_install_script_contract.rs | 15 +- .../install/server_install_script_contract.rs | 19 +- .../hardware_media_smoke_contract.rs | 81 +++++ ...stall_preserves_codec_settings_contract.rs | 2 +- 33 files changed, 1251 insertions(+), 247 deletions(-) create mode 100755 scripts/manual/run_hardware_media_smoke.sh create mode 100644 tests/manual/hardware_media/hardware_media_smoke_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 1090c37..bb51c12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 3c61d83..d0a4e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/client/Cargo.toml b/client/Cargo.toml index 264e63f..105345c 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.5" +version = "0.22.6" edition = "2024" [dependencies] diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 7a8b552..7fe1a25 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -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, preview_tap_running: Option>, 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")) ); } diff --git a/client/src/input/camera/bus_and_encoder.rs b/client/src/input/camera/bus_and_encoder.rs index 611ff79..b6a3a08 100644 --- a/client/src/input/camera/bus_and_encoder.rs +++ b/client/src/input/camera/bus_and_encoder.rs @@ -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(); + } } } diff --git a/client/src/input/camera/capture_pipeline.rs b/client/src/input/camera/capture_pipeline.rs index 4750ee1..6cebfcc 100644 --- a/client/src/input/camera/capture_pipeline.rs +++ b/client/src/input/camera/capture_pipeline.rs @@ -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 { + 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::().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::() + .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 { diff --git a/client/src/input/camera/device_selection.rs b/client/src/input/camera/device_selection.rs index 9d2dfea..90952fb 100644 --- a/client/src/input/camera/device_selection.rs +++ b/client/src/input/camera/device_selection.rs @@ -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, diff --git a/client/src/input/camera/encoder_selection.rs b/client/src/input/camera/encoder_selection.rs index 3a34189..98669f5 100644 --- a/client/src/input/camera/encoder_selection.rs +++ b/client/src/input/camera/encoder_selection.rs @@ -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( diff --git a/client/src/launcher/preview/preview_core.rs b/client/src/launcher/preview/preview_core.rs index 142cf15..9612bc6 100644 --- a/client/src/launcher/preview/preview_core.rs +++ b/client/src/launcher/preview/preview_core.rs @@ -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))] diff --git a/client/src/launcher/preview/status_pipeline.rs b/client/src/launcher/preview/status_pipeline.rs index 05b0896..2131821 100644 --- a/client/src/launcher/preview/status_pipeline.rs +++ b/client/src/launcher/preview/status_pipeline.rs @@ -227,34 +227,25 @@ fn build_preview_pipeline( #[cfg(not(coverage))] fn preview_decoder_candidates() -> Vec { 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 } diff --git a/client/src/output/video/monitor_window.rs b/client/src/output/video/monitor_window.rs index cfe0bef..e8e1f67 100644 --- a/client/src/output/video/monitor_window.rs +++ b/client/src/output/video/monitor_window.rs @@ -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 { 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()) diff --git a/client/src/output/video/unified_monitor.rs b/client/src/output/video/unified_monitor.rs index 76aef43..40330f0 100644 --- a/client/src/output/video/unified_monitor.rs +++ b/client/src/output/video/unified_monitor.rs @@ -63,7 +63,7 @@ impl UnifiedMonitorWindow { pub fn new() -> anyhow::Result { 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()) diff --git a/client/src/video_support.rs b/client/src/video_support.rs index c19710c..69bd08a 100644 --- a/client/src/video_support.rs +++ b/client/src/video_support.rs @@ -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=` 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 { 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 } diff --git a/common/Cargo.toml b/common/Cargo.toml index e8bd4db..772df5f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.5" +version = "0.22.6" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 8acfd73..b70441b 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -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` | diff --git a/scripts/install/client.sh b/scripts/install/client.sh index b316753..c50e7e9 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -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= or LESAVKA_H264_DECODER_PREFERENCE=software" + echo " β†ͺ override decoder route with LESAVKA_H264_DECODER=; 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" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index e1ba103..6633e7a 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -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 diff --git a/scripts/manual/run_hardware_media_smoke.sh b/scripts/manual/run_hardware_media_smoke.sh new file mode 100755 index 0000000..1367a3e --- /dev/null +++ b/scripts/manual/run_hardware_media_smoke.sh @@ -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 "$@" diff --git a/server/Cargo.toml b/server/Cargo.toml index 3b4e4cb..f76a0b9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.5" +version = "0.22.6" edition = "2024" autobins = false diff --git a/server/src/audio/ear_capture.rs b/server/src/audio/ear_capture.rs index 70c08a3..ed979e8 100644 --- a/server/src/audio/ear_capture.rs +++ b/server/src/audio/ear_capture.rs @@ -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(|| "".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 */ /*───────────────────────────────────────────────────────────────────────────*/ diff --git a/server/src/video_sinks/hdmi_sink.rs b/server/src/video_sinks/hdmi_sink.rs index ac06a94..b6cbd5a 100644 --- a/server/src/video_sinks/hdmi_sink.rs +++ b/server/src/video_sinks/hdmi_sink.rs @@ -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}"))?; diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index a7e8a7a..df74184 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -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}"))?; diff --git a/server/src/video_support.rs b/server/src/video_support.rs index d9b4e2d..6e8351a 100644 --- a/server/src/video_support.rs +++ b/server/src/video_support.rs @@ -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 = 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 { + 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. diff --git a/tests/compatibility/client/video_support/client_video_support_include_contract.rs b/tests/compatibility/client/video_support/client_video_support_include_contract.rs index 53852e8..baeed2b 100644 --- a/tests/compatibility/client/video_support/client_video_support_include_contract.rs +++ b/tests/compatibility/client/video_support/client_video_support_include_contract.rs @@ -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" + ); }); }); } diff --git a/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs b/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs index c3b2a18..8bf530f 100644 --- a/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs +++ b/tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs @@ -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), diff --git a/tests/compatibility/video/video_support_contract.rs b/tests/compatibility/video/video_support_contract.rs index 32f48f2..9826175 100644 --- a/tests/compatibility/video/video_support_contract.rs +++ b/tests/compatibility/video/video_support_contract.rs @@ -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" )); }); }); diff --git a/tests/contract/client/input/camera/client_camera_include_contract.rs b/tests/contract/client/input/camera/client_camera_include_contract.rs index ad62bfe..3e97e99 100644 --- a/tests/contract/client/input/camera/client_camera_include_contract.rs +++ b/tests/contract/client/input/camera/client_camera_include_contract.rs @@ -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()); } } diff --git a/tests/contract/client/output/video/client_output_video_include_contract.rs b/tests/contract/client/output/video/client_output_video_include_contract.rs index c876e6e..a5a1e88 100644 --- a/tests/contract/client/output/video/client_output_video_include_contract.rs +++ b/tests/contract/client/output/video/client_output_video_include_contract.rs @@ -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() + ); + }); }); } diff --git a/tests/contract/testing/quality_ratchet_evidence_contract.rs b/tests/contract/testing/quality_ratchet_evidence_contract.rs index a290bf8..c34b7b1 100644 --- a/tests/contract/testing/quality_ratchet_evidence_contract.rs +++ b/tests/contract/testing/quality_ratchet_evidence_contract.rs @@ -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", diff --git a/tests/installer/scripts/install/client_install_script_contract.rs b/tests/installer/scripts/install/client_install_script_contract.rs index f54c5e7..833dd5b 100644 --- a/tests/installer/scripts/install/client_install_script_contract.rs +++ b/tests/installer/scripts/install/client_install_script_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), diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index 71a5940..a1e1994 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -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 diff --git a/tests/manual/hardware_media/hardware_media_smoke_contract.rs b/tests/manual/hardware_media/hardware_media_smoke_contract.rs new file mode 100644 index 0000000..5cc36dd --- /dev/null +++ b/tests/manual/hardware_media/hardware_media_smoke_contract.rs @@ -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}" + ); + } +} diff --git a/tests/regression/install/install_preserves_codec_settings_contract.rs b/tests/regression/install/install_preserves_codec_settings_contract.rs index d2a801e..031bda0 100644 --- a/tests/regression/install/install_preserves_codec_settings_contract.rs +++ b/tests/regression/install/install_preserves_codec_settings_contract.rs @@ -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),