// client/src/input/camera.rs #![forbid(unsafe_code)] use anyhow::Context; use gstreamer as gst; use gstreamer_app as gst_app; use gst::prelude::*; use lesavka_common::lesavka::VideoPacket; pub struct CameraCapture { #[allow(dead_code)] // kept alive to hold PLAYING state pipeline: gst::Pipeline, sink: gst_app::AppSink, } impl CameraCapture { pub fn new(device_fragment: Option<&str>) -> anyhow::Result { gst::init().ok(); // Pick device (prefers V4L2 nodes with capture capability) let dev = match device_fragment { Some(path) if path.starts_with("/dev/") => path.to_string(), Some(fragment) => Self::find_device(fragment) .unwrap_or_else(|| "/dev/video0".into()), None => "/dev/video0".into(), }; // let (enc, raw_caps) = Self::pick_encoder(); // (NVIDIA β†’ VA-API β†’ software x264). let (enc, kf_prop, kf_val) = Self::choose_encoder(); tracing::info!("πŸ“Έ using encoder element: {enc}"); let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); let (src_caps, preenc) = match enc { // ─────────────────────────────────────────────────────────────────── // Jetson (has nvvidconv) Desktop (falls back to videoconvert) // ─────────────────────────────────────────────────────────────────── "nvh264enc" if have_nvvidconv => ("video/x-raw(memory:NVMM),format=NV12,width=1280,height=720", "nvvidconv !"), "nvh264enc" /* else */ => ("video/x-raw,format=NV12,width=1280,height=720", "videoconvert !"), "vaapih264enc" => ("video/x-raw,format=NV12,width=1280,height=720", "videoconvert !"), _ => ("video/x-raw,width=1280,height=720", "videoconvert !"), }; // let desc = format!( // "v4l2src device={dev} do-timestamp=true ! {raw_caps},width=1280,height=720 ! \ // videoconvert ! {enc} key-int-max=30 ! \ // h264parse config-interval=-1 ! \ // appsink name=asink emit-signals=true max-buffers=60 drop=true" // ); // tracing::debug!(%desc, "πŸ“Έ pipeline-desc"); // Build a pipeline that works for any of the three encoders. // * nvh264enc needs NVMM memory caps; // * vaapih264enc wants system-memory caps; // * x264enc needs the usual raw caps. let desc = format!( "v4l2src device={dev} do-timestamp=true ! {src_caps} ! \ {preenc} {enc} {kf_prop}={kf_val} ! \ h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ); tracing::info!(%enc, ?desc, "πŸ“Έ using encoder element"); let pipeline: gst::Pipeline = gst::parse::launch(&desc) .context("gst parse_launch(cam)")? .downcast::() .expect("not a pipeline"); tracing::debug!("πŸ“Έ pipeline built OK – setting PLAYING…"); let sink: gst_app::AppSink = pipeline .by_name("asink") .expect("appsink element not found") .downcast::() .expect("appsink down‑cast"); pipeline.set_state(gst::State::Playing)?; tracing::info!("πŸ“Έ webcam pipeline ▢️ device={dev}"); Ok(Self { pipeline, sink }) } pub fn pull(&self) -> Option { let sample = self.sink.pull_sample().ok()?; let buf = sample.buffer()?; let map = buf.map_readable().ok()?; let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; Some(VideoPacket { id: 2, pts, data: map.as_slice().to_vec() }) } /// Fuzzy‑match devices under `/dev/v4l/by-id`, preferring capture nodes fn find_device(substr: &str) -> Option { let wanted = substr.to_ascii_lowercase(); let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id") .ok()? .flatten() .filter_map(|e| { let p = e.path(); let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); if name.contains(&wanted) { Some(p) } else { None } }) .collect(); // deterministic order matches.sort(); for p in matches { if let Ok(target) = std::fs::read_link(&p) { let dev = format!("/dev/{}", target.file_name()?.to_string_lossy()); if Self::is_capture(&dev) { return Some(dev); } } } None } fn is_capture(dev: &str) -> bool { const V4L2_CAP_VIDEO_CAPTURE: u32 = 0x0000_0001; const V4L2_CAP_VIDEO_CAPTURE_MPLANE: u32 = 0x0000_1000; v4l::Device::with_path(dev) .ok() .and_then(|d| d.query_caps().ok()) .map(|caps| { let bits = caps.capabilities.bits(); (bits & V4L2_CAP_VIDEO_CAPTURE != 0) || (bits & V4L2_CAP_VIDEO_CAPTURE_MPLANE != 0) }) .unwrap_or(false) } /// Cheap stub used when the web‑cam is disabled pub fn new_stub() -> Self { let pipeline = gst::Pipeline::new(); let sink: gst_app::AppSink = gst::ElementFactory::make("appsink") .build() .expect("appsink") .downcast::() .unwrap(); Self { pipeline, sink } } #[allow(dead_code)] // helper kept for future heuristics fn pick_encoder() -> (&'static str, &'static str) { let encoders = &[ ("nvh264enc", "video/x-raw(memory:NVMM),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") } fn choose_encoder() -> (&'static str, &'static str, &'static str) { match () { _ if gst::ElementFactory::find("nvh264enc").is_some() => ("nvh264enc", "gop-size", "30"), _ if gst::ElementFactory::find("vaapih264enc").is_some() => ("vaapih264enc","keyframe-period","30"), _ if gst::ElementFactory::find("v4l2h264enc").is_some() => ("v4l2h264enc","idrcount", "30"), _ => ("x264enc", "key-int-max", "30"), } } }