// Webcam capture pipeline, quality selection, and launcher preview tap support. use anyhow::Context; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; use lesavka_common::lesavka::VideoPacket; use std::{ io::{Read, Write}, os::fd::IntoRawFd, path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, thread, time::Duration, }; const CAMERA_PREVIEW_TAP_ENV: &str = "LESAVKA_UPLINK_CAMERA_PREVIEW"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CameraSourceProfile { Raw, Mjpeg, AutoDecode, } fn env_u32(name: &str, default: u32) -> u32 { std::env::var(name) .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(default) } #[derive(Clone, Copy, Debug)] pub enum CameraCodec { H264, Hevc, Mjpeg, } #[derive(Clone, Copy, Debug)] pub struct CameraConfig { pub codec: CameraCodec, pub width: u32, pub height: u32, pub fps: u32, } 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, } include!("camera/capture_pipeline.rs"); include!("camera/device_selection.rs"); include!("camera/encoder_selection.rs"); include!("camera/source_description.rs"); include!("camera/preview_tap.rs"); include!("camera/bus_and_encoder.rs"); #[cfg(test)] mod tests { use super::{ CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile, resolved_output_profile, }; use serial_test::serial; #[test] #[serial] /// Keeps the selected local webcam mode independent from the UVC gadget mode. fn local_capture_profile_keeps_launcher_quality_env_by_default() { let cfg = CameraConfig { codec: CameraCodec::Mjpeg, width: 640, height: 480, fps: 20, }; temp_env::with_vars( [ ("LESAVKA_CAM_WIDTH", Some("1280")), ("LESAVKA_CAM_HEIGHT", Some("720")), ("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), ("LESAVKA_CAM_EMIT_UI_PROFILE", None), ], || assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)), ); } #[test] #[serial] /// UVC output must match the gadget profile that browsers negotiate. fn negotiated_output_profile_matches_server_uvc_contract_by_default() { let cfg = CameraConfig { codec: CameraCodec::Mjpeg, width: 640, height: 480, fps: 20, }; temp_env::with_vars( [ ("LESAVKA_CAM_WIDTH", Some("1280")), ("LESAVKA_CAM_HEIGHT", Some("720")), ("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), ("LESAVKA_CAM_EMIT_UI_PROFILE", None), ], || { let capture_profile = resolved_capture_profile(Some(cfg)); assert_eq!(capture_profile, (1280, 720, 30)); assert_eq!( resolved_output_profile(Some(cfg), capture_profile), (640, 480, 20) ); }, ); } #[test] #[serial] /// Keeps UI-profile emission explicit until the server can reconfigure UVC. fn explicit_ui_profile_emission_keeps_lab_mode_available() { let cfg = CameraConfig { codec: CameraCodec::Mjpeg, width: 640, height: 480, fps: 20, }; temp_env::with_vars( [ ("LESAVKA_CAM_WIDTH", Some("1280")), ("LESAVKA_CAM_HEIGHT", Some("720")), ("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), ("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")), ], || { let capture_profile = resolved_capture_profile(Some(cfg)); assert_eq!(capture_profile, (1280, 720, 30)); assert_eq!( resolved_output_profile(Some(cfg), capture_profile), (1280, 720, 30) ); }, ); } #[test] #[serial] /// The safety lock wins if both experimental flags are set. fn explicit_server_profile_lock_wins_over_ui_emission() { let cfg = CameraConfig { codec: CameraCodec::Mjpeg, width: 640, height: 480, fps: 20, }; temp_env::with_vars( [ ("LESAVKA_CAM_WIDTH", Some("1280")), ("LESAVKA_CAM_HEIGHT", Some("720")), ("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")), ("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")), ], || { let capture_profile = resolved_capture_profile(Some(cfg)); assert_eq!(capture_profile, (1280, 720, 30)); assert_eq!( resolved_output_profile(Some(cfg), capture_profile), (640, 480, 20) ); }, ); } #[test] #[serial] /// 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); assert!(options.starts_with("x265enc ")); assert!(options.contains("tune=zerolatency")); assert!(options.contains("speed-preset=ultrafast")); assert!(options.contains("bitrate=2400")); assert!(options.contains("key-int-max=30")); }); } #[test] #[serial] /// Vulkan H.264 hardware encode should stay live-call shaped when available. fn vulkan_h264_encoder_options_keep_cbr_and_keyframes() { temp_env::with_var("LESAVKA_CAM_H264_KBIT", Some("6000"), || { let options = CameraCapture::encoder_options("vulkanh264enc", Some("idr-period"), 30); assert_eq!( options, "vulkanh264enc bitrate=6000 rate-control=cbr idr-period=30" ); }); } #[test] #[serial] /// HEVC should recover quickly after freshness drops without changing H.264 knobs. fn hevc_keyframe_interval_defaults_short_and_honors_overrides() { temp_env::with_vars( [ ("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>), ("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>), ], || { assert_eq!(hevc_keyframe_interval(30), 1); assert_eq!(hevc_keyframe_interval(2), 1); }, ); temp_env::with_vars( [ ("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")), ("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("2")), ], || { assert_eq!(hevc_keyframe_interval(30), 2); }, ); } #[cfg(coverage)] #[test] /// Coverage builds use a deterministic HEVC encoder choice. fn coverage_hevc_encoder_choice_is_stable() { assert_eq!( CameraCapture::choose_hevc_encoder().unwrap(), ("x265enc", Some("key-int-max")) ); } }