impl CameraCapture { pub fn new(device_fragment: Option<&str>, cfg: Option) -> anyhow::Result { Self::new_with_capture_profile(device_fragment, cfg, None) } /// Keeps `new_with_capture_profile` 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 new_with_capture_profile( device_fragment: Option<&str>, cfg: Option, capture_profile_override: Option<(u32, u32, u32)>, ) -> anyhow::Result { gst::init().ok(); // Select source: V4L2 device or test pattern let (src_desc, dev_label, allow_mjpg_source) = match device_fragment { Some(fragment) if fragment.eq_ignore_ascii_case("test") || fragment.eq_ignore_ascii_case("videotestsrc") => { let pattern = std::env::var("LESAVKA_CAM_TEST_PATTERN").unwrap_or_else(|_| "smpte".into()); ( format!("videotestsrc is-live=true pattern={pattern}"), format!("videotestsrc:{pattern}"), false, ) } Some(path) if path.starts_with("/dev/") => ( format!("v4l2src device={path} do-timestamp=true"), path.to_string(), true, ), Some(fragment) => { let dev = Self::find_device(fragment) .with_context(|| format!("requested camera '{fragment}' was not found"))?; (format!("v4l2src device={dev} do-timestamp=true"), dev, true) } None => { let dev = "/dev/video0".to_string(); (format!("v4l2src device={dev} do-timestamp=true"), dev, true) } }; let output_codec = cfg.map_or_else( || { match std::env::var("LESAVKA_CAM_CODEC") .ok() .map(|value| value.trim().to_ascii_lowercase()) .as_deref() { Some("mjpeg" | "mjpg" | "jpeg") => CameraCodec::Mjpeg, Some("hevc" | "h265" | "h.265") => CameraCodec::Hevc, _ => CameraCodec::H264, } }, |cfg| cfg.codec, ); let output_mjpeg = matches!(output_codec, CameraCodec::Mjpeg); let output_hevc = matches!(output_codec, CameraCodec::Hevc); let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); let capture_profile = capture_profile_override.unwrap_or_else(|| resolved_capture_profile(cfg)); let (capture_width, capture_height, capture_fps) = capture_profile; let (width, height, fps) = resolved_output_profile(cfg, capture_profile); let keyframe_interval = if output_hevc { hevc_keyframe_interval(fps) } else { 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()? } else { Self::choose_encoder()? } } else if output_hevc { Self::choose_hevc_encoder()? } else { Self::choose_encoder()? }; match source_profile { CameraSourceProfile::Mjpeg if !output_mjpeg => { tracing::info!("πŸ“Έ using MJPG source with transcoded output"); } CameraSourceProfile::AutoDecode => { tracing::info!("πŸ“Έ using auto-decoded webcam source (raw/MJPEG accepted)"); } _ => {} } let enc_opts = Self::encoder_options(enc, kf_prop, keyframe_interval); if output_mjpeg { tracing::info!("πŸ“Έ outputting MJPEG frames for UVC (quality={jpeg_quality})"); } else if output_hevc { tracing::info!("πŸ“Έ using HEVC encoder element: {enc}"); } else { tracing::info!("πŸ“Έ using encoder element: {enc}"); } #[cfg(not(coverage))] let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); let preenc = match enc { // ─────────────────────────────────────────────────────────────────── // Jetson (has nvvidconv) Desktop (falls back to videoconvert) // ─────────────────────────────────────────────────────────────────── #[cfg(not(coverage))] "nvh264enc" | "nvh265enc" if have_nvvidconv => format!( "nvvidconv ! video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1 !" ), #[cfg(not(coverage))] "nvh264enc" | "nvh265enc" /* else */ => format!( "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !" ), #[cfg(not(coverage))] "x265enc" => format!( "videoconvert ! video/x-raw,format=I420,width={width},height={height},framerate={fps}/1 !" ), #[cfg(not(coverage))] "vulkanh264enc" => format!( "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 ! \ vulkanupload ! video/x-raw(memory:VulkanImage),format=NV12,width={width},height={height},framerate={fps}/1 !" ), #[cfg(not(coverage))] "vaapih264enc" | "vah265enc" | "vaapih265enc" | "v4l2h265enc" => format!( "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !" ), _ => format!( "videoconvert ! video/x-raw,width={width},height={height},framerate={fps}/1 !" ), }; // 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. // * NVIDIA encoders prefer NV12, using NVMM when Jetson's converter is present. // * Vulkan/VAAPI/V4L2 hardware encoders also get explicit NV12 caps. // * x264enc/x265enc keep their software-friendly raw caps. let preview_tap_path = camera_preview_tap_path(); let preview_tap_branch = camera_preview_tap_branch(width, height, fps); let source_raw_caps = format!( "video/x-raw,width={capture_width},height={capture_height},framerate={capture_fps}/1" ); let raw_source_chain = camera_raw_source_chain( &src_desc, &source_raw_caps, capture_width, capture_height, capture_fps, source_profile, ); let normalized_raw_chain = format!( "{raw_source_chain} ! {}", camera_output_raw_chain(width, height, fps) ); let encoded_parse_chain = if output_hevc { "h265parse config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au" } else { "h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au" }; let desc = if preview_tap_path.is_some() { if output_mjpeg { if passthrough_mjpg_source { format!( "{src_desc} ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \ tee name=t \ t. ! queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true \ t. ! queue max-size-buffers=2 leaky=downstream ! jpegdec ! \ {preview_tap_branch}" ) } else { format!( "{normalized_raw_chain} ! \ tee name=t \ t. ! queue max-size-buffers=30 leaky=downstream ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true \ t. ! queue max-size-buffers=2 leaky=downstream ! \ {preview_tap_branch}" ) } } else { format!( "{normalized_raw_chain} ! \ tee name=t \ t. ! queue max-size-buffers=30 leaky=downstream ! \ {preenc} {enc_opts} ! \ {encoded_parse_chain} ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true \ t. ! queue max-size-buffers=2 leaky=downstream ! \ {preview_tap_branch}" ) } } else if output_mjpeg { if passthrough_mjpg_source { format!( "{src_desc} ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ) } else { format!( "{normalized_raw_chain} ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ) } } else { format!( "{normalized_raw_chain} ! \ {preenc} {enc_opts} ! \ {encoded_parse_chain} ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ) }; tracing::info!( %enc, capture_width, capture_height, capture_fps, output_width = width, output_height = height, output_fps = fps, ?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"); spawn_camera_bus_logger(&pipeline, dev_label.clone()); if let Err(err) = pipeline.set_state(gst::State::Playing) { let _ = pipeline.set_state(gst::State::Null); return Err(err.into()); } tracing::info!("πŸ“Έ webcam pipeline ▢️ device={dev_label}"); let preview_tap_running = if let Some(path) = preview_tap_path { let preview_sink = pipeline .by_name("preview_sink") .context("missing camera preview tap appsink")? .downcast::() .expect("camera preview tap appsink"); Some(spawn_camera_preview_tap(preview_sink, path)) } else { None }; 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 { let sample = self.sink.pull_sample().ok()?; let buf = sample.buffer()?; let map = buf.map_readable().ok()?; let source_pts_us = buf.pts().map(|ts| ts.nseconds() / 1_000); let packet_duration_us = buf .duration() .map(|ts| (ts.nseconds() / 1_000).max(1)) .unwrap_or(self.frame_duration_us); let timing = self.pts_rebaser.rebase_with_packet_duration( source_pts_us, packet_duration_us, crate::live_capture_clock::upstream_source_lag_cap(), ); if timing.lag_clamped { log_camera_stale_source_drop(timing, map.as_slice().len()); return None; } let pts = timing.packet_pts_us; static CAMERA_PACKET_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let packet_index = CAMERA_PACKET_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); log_camera_first_packet(packet_index, map.as_slice().len(), pts); log_camera_timing_sample(packet_index, timing, map.as_slice().len()); Some(VideoPacket { id: 2, pts, data: map.as_slice().to_vec(), ..Default::default() }) } } /// Resolve the profile requested from the local webcam. /// /// The server UVC contract is applied after capture. Keeping these separate /// prevents a browser-facing 640x480/20 gadget mode from forcing a local webcam /// to expose that exact mode when the selected camera quality is 720p/30. fn resolved_capture_profile(cfg: Option) -> (u32, u32, u32) { ( env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)), env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)), env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1), ) } /// Resolve the profile emitted toward the remote UVC gadget. fn resolved_output_profile( cfg: Option, capture_profile: (u32, u32, u32), ) -> (u32, u32, u32) { match cfg { Some(cfg) if env_flag_enabled("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE") || !env_flag_enabled("LESAVKA_CAM_EMIT_UI_PROFILE") => { (cfg.width, cfg.height, cfg.fps.max(1)) } _ => capture_profile, } } fn env_flag_enabled(name: &str) -> bool { std::env::var(name).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")) }) } /// Choose the live HEVC keyframe cadence. /// /// Inputs: target FPS plus optional `LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL` /// override. Output: GOP length in frames. Why: the upstream relay is /// freshness-first and may intentionally discard video; defaulting HEVC to /// all-intra is less compression-efficient, but it turns packet loss into a /// freeze/stutter instead of browser-visible block corruption. fn hevc_keyframe_interval(fps: u32) -> u32 { let fps = fps.max(1); env_u32("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", 1).clamp(1, fps) } /// Keeps `log_camera_first_packet` 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. fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) { if packet_index == 0 { tracing::info!(bytes, pts_us, "πŸ“Έ upstream webcam frames flowing"); } } fn should_log_camera_timing_sample(packet_index: u64) -> bool { crate::live_capture_clock::upstream_timing_trace_enabled() && (packet_index < 10 || packet_index.is_multiple_of(300)) } /// Keeps `log_camera_timing_sample` 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. fn log_camera_timing_sample( packet_index: u64, timing: crate::live_capture_clock::RebasedSourcePts, bytes: usize, ) { if should_log_camera_timing_sample(packet_index) { tracing::info!( packet_index, source_pts_us = timing.source_pts_us.unwrap_or_default(), source_base_us = timing.source_base_us.unwrap_or_default(), capture_base_us = timing.capture_base_us.unwrap_or_default(), capture_now_us = timing.capture_now_us, packet_pts_us = timing.packet_pts_us, pull_path_delay_us = timing.capture_now_us as i128 - timing.packet_pts_us as i128, used_source_pts = timing.used_source_pts, lag_clamped = timing.lag_clamped, lead_clamped = timing.lead_clamped, bytes, "πŸ“Έ upstream webcam timing sample" ); } } /// Keeps `log_camera_stale_source_drop` 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. fn log_camera_stale_source_drop(timing: crate::live_capture_clock::RebasedSourcePts, bytes: usize) { static CAMERA_STALE_SOURCE_DROPS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let drop_index = CAMERA_STALE_SOURCE_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if drop_index < 10 || drop_index.is_multiple_of(300) { tracing::warn!( drop_index, bytes, source_pts_us = timing.source_pts_us.unwrap_or_default(), capture_now_us = timing.capture_now_us, packet_pts_us = timing.packet_pts_us, "πŸ“Έ dropping stale webcam source buffer before bundled uplink" ); } }