diff --git a/i18n/en/camera.ftl b/i18n/en/camera.ftl index 29cfe2c..eb53858 100644 --- a/i18n/en/camera.ftl +++ b/i18n/en/camera.ftl @@ -28,6 +28,8 @@ settings-camera = Camera settings-video = Video settings-device = Device settings-format = Format +settings-backend = Backend +settings-backend-description = Camera backend to use. PipeWire is recommended for most systems. Libcamera is for mobile Linux devices. settings-microphone = Microphone settings-encoder = Encoder settings-quality = Quality @@ -40,6 +42,10 @@ settings-bug-reports = Bug reports settings-report-bug = Report bug settings-show-report = Show Report settings-resolution = Resolution + +# Backend options +backend-pipewire = PipeWire +backend-libcamera = libcamera settings-version = Version { $version } settings-version-flatpak = Version { $version } (Flatpak) diff --git a/src/app/camera_ops.rs b/src/app/camera_ops.rs index 3882af2..f31ab6d 100644 --- a/src/app/camera_ops.rs +++ b/src/app/camera_ops.rs @@ -21,7 +21,7 @@ impl AppModel { let camera_path = &camera.path; // Get formats for the new mode - let backend = crate::backends::camera::get_backend(); + let backend = crate::backends::camera::get_backend(self.config.backend); let device = crate::backends::camera::types::CameraDevice { name: camera.name.clone(), path: camera_path.clone(), @@ -230,8 +230,8 @@ impl AppModel { }; let camera_path = camera.path.clone(); - // Get formats for this camera using PipeWire backend - let backend = crate::backends::camera::get_backend(); + // Get formats for this camera using the configured backend + let backend = crate::backends::camera::get_backend(self.config.backend); let device = crate::backends::camera::types::CameraDevice { name: camera.name.clone(), path: camera_path.clone(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 9513678..2321cc9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -243,6 +243,26 @@ impl cosmic::Application for AppModel { .iter() .map(|p| p.display_name().to_string()) .collect(), + backend_dropdown_options: { + let mut options = Vec::new(); + if crate::backends::camera::pipewire::is_pipewire_available() { + options.push(fl!("backend-pipewire")); + } + if crate::backends::camera::libcamera::is_libcamera_available() { + options.push(fl!("backend-libcamera")); + } + options + }, + available_backends: { + let mut backends = Vec::new(); + if crate::backends::camera::pipewire::is_pipewire_available() { + backends.push(crate::backends::camera::CameraBackendType::PipeWire); + } + if crate::backends::camera::libcamera::is_libcamera_available() { + backends.push(crate::backends::camera::CameraBackendType::Libcamera); + } + backends + }, bitrate_info_visible: false, transition_state: crate::app::state::TransitionState::default(), // QR detection enabled by default @@ -269,7 +289,7 @@ impl cosmic::Application for AppModel { // Enumerate cameras (can be slow, especially with multiple devices) info!(backend = %backend_type, "Enumerating cameras asynchronously"); - let backend = crate::backends::camera::get_backend(); + let backend = crate::backends::camera::get_backend(backend_type); let cameras = backend.enumerate_cameras(); info!(count = cameras.len(), backend = %backend_type, "Found camera(s)"); diff --git a/src/app/settings/view.rs b/src/app/settings/view.rs index 5de6884..219f642 100644 --- a/src/app/settings/view.rs +++ b/src/app/settings/view.rs @@ -33,8 +33,15 @@ impl AppModel { .position(|p| *p == self.config.bitrate_preset) .unwrap_or(1); // Default to Medium (index 1) - // Camera section - let camera_section = widget::settings::section() + // Current backend index for dropdown (find in available backends list) + let current_backend_index = self + .available_backends + .iter() + .position(|b| *b == self.config.backend) + .unwrap_or(0); + + // Camera section - conditionally add backend dropdown only if multiple backends available + let mut camera_section = widget::settings::section() .title(fl!("settings-camera")) .add( widget::settings::item::builder(fl!("settings-device")).control(widget::dropdown( @@ -51,6 +58,19 @@ impl AppModel { )), ); + // Only show backend dropdown if multiple backends are available + if self.available_backends.len() > 1 { + camera_section = camera_section.add( + widget::settings::item::builder(fl!("settings-backend")) + .description(fl!("settings-backend-description")) + .control(widget::dropdown( + &self.backend_dropdown_options, + Some(current_backend_index), + Message::SelectBackend, + )), + ); + } + // Video section let video_section = widget::settings::section() .title(fl!("settings-video")) diff --git a/src/app/state.rs b/src/app/state.rs index 2c08c37..513dbeb 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -348,6 +348,10 @@ pub struct AppModel { pub codec_dropdown_options: Vec, /// Bitrate preset dropdown options pub bitrate_preset_dropdown_options: Vec, + /// Backend dropdown options + pub backend_dropdown_options: Vec, + /// Available backend types (corresponding to dropdown options) + pub available_backends: Vec, /// Whether the bitrate info matrix is visible pub bitrate_info_visible: bool, @@ -599,6 +603,8 @@ pub enum Message { SelectAudioDevice(usize), /// Select video encoder SelectVideoEncoder(usize), + /// Select camera backend + SelectBackend(usize), /// Toggle virtual camera feature enabled ToggleVirtualCameraEnabled, diff --git a/src/app/update.rs b/src/app/update.rs index bd822a3..82ed320 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -117,6 +117,7 @@ impl AppModel { Message::UpdateConfig(config) => self.handle_update_config(config), Message::SelectAudioDevice(index) => self.handle_select_audio_device(index), Message::SelectVideoEncoder(index) => self.handle_select_video_encoder(index), + Message::SelectBackend(index) => self.handle_select_backend(index), // ===== System & Recovery ===== Message::CameraRecoveryStarted { @@ -2133,6 +2134,74 @@ impl AppModel { Task::none() } + fn handle_select_backend(&mut self, index: usize) -> Task> { + // Get the backend type from the available backends list + let new_backend = match self.available_backends.get(index) { + Some(backend) => *backend, + None => return Task::none(), + }; + + // Don't do anything if the backend is the same + if new_backend == self.config.backend { + return Task::none(); + } + + info!(old = %self.config.backend, new = %new_backend, "Switching camera backend"); + + // Update config + self.config.backend = new_backend; + if let Some(handler) = self.config_handler.as_ref() { + if let Err(err) = self.config.write_entry(handler) { + error!(?err, "Failed to save backend selection"); + } + } + + // Change the backend in the manager + if let Some(ref manager) = self.backend_manager { + if let Err(err) = manager.change_backend(new_backend) { + error!(?err, "Failed to change backend"); + return Task::none(); + } + } + + // Clear current state and re-enumerate cameras with the new backend + self.available_cameras.clear(); + self.available_formats.clear(); + self.active_format = None; + self.current_frame = None; + + // Re-enumerate cameras with the new backend + let backend = crate::backends::camera::get_backend(new_backend); + let cameras = backend.enumerate_cameras(); + info!(count = cameras.len(), backend = %new_backend, "Re-enumerated cameras with new backend"); + + if !cameras.is_empty() { + self.available_cameras = cameras; + self.current_camera_index = 0; + + // Update camera dropdown + self.camera_dropdown_options = self + .available_cameras + .iter() + .map(|c| c.name.clone()) + .collect(); + + // Get formats for the first camera + let device = &self.available_cameras[0]; + self.available_formats = backend.get_formats(device, false); + + // Select the first format if available + if !self.available_formats.is_empty() { + self.active_format = self.available_formats.first().cloned(); + } + + // Update all dropdowns + self.update_all_dropdowns(); + } + + Task::none() + } + // ========================================================================= // System & Recovery Handlers // ========================================================================= diff --git a/src/backends/camera/libcamera/enumeration.rs b/src/backends/camera/libcamera/enumeration.rs new file mode 100644 index 0000000..d59c560 --- /dev/null +++ b/src/backends/camera/libcamera/enumeration.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Libcamera camera enumeration and format detection +//! +//! This module provides camera discovery and format enumeration using libcamera. +//! Libcamera is the modern camera stack for Linux mobile devices. + +use super::super::types::{CameraDevice, CameraFormat}; +use crate::constants::formats; +use tracing::{debug, info, warn}; + +/// Check if libcamera is available on this system +/// +/// Returns true if the libcamerasrc GStreamer element is available +pub fn is_libcamera_available() -> bool { + if gstreamer::init().is_err() { + return false; + } + + gstreamer::ElementFactory::make("libcamerasrc") + .build() + .is_ok() +} + +/// Enumerate cameras using libcamera +/// Returns list of available cameras discovered through libcamera +pub fn enumerate_libcamera_cameras() -> Option> { + debug!("Attempting to enumerate cameras via libcamera"); + + // Check if libcamera is available + if gstreamer::init().is_err() { + warn!("GStreamer init failed"); + return None; + } + + // Check if libcamerasrc element exists + if gstreamer::ElementFactory::make("libcamerasrc") + .build() + .is_err() + { + debug!("libcamerasrc not available"); + return None; + } + + info!("libcamera available for camera enumeration"); + + // Libcamera camera enumeration strategy: + // 1. Try to discover cameras through cam CLI tool (if available) + // 2. Otherwise, provide generic "Default Camera" that lets libcamera auto-select + + let cameras = try_enumerate_with_cam_cli(); + + if let Some(ref cams) = cameras { + info!(count = cams.len(), "Found libcamera cameras"); + return Some(cams.clone()); + } + + // Fallback: Let libcamera use its default camera + info!("Using libcamera auto-selection (default camera)"); + Some(vec![CameraDevice { + name: "Default Camera (libcamera)".to_string(), + path: String::new(), // Empty path = libcamera auto-selects + metadata_path: None, + }]) +} + +/// Try to enumerate cameras using the cam CLI tool +fn try_enumerate_with_cam_cli() -> Option> { + debug!("Trying cam CLI for camera enumeration"); + + let output = std::process::Command::new("cam") + .args(["--list"]) + .output() + .ok()?; + + if !output.status.success() { + debug!("cam --list command failed"); + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut cameras = Vec::new(); + + // Parse cam --list output + // Example format: + // Available cameras: + // 1: 'ov5640 2-003c' (/base/soc/i2c@fdd40000/ov5640@3c) + // 2: 'gc2145 4-003c' (/base/soc/i2c@fe5d0000/gc2145@3c) + for line in stdout.lines() { + let trimmed = line.trim(); + + // Look for lines starting with a number followed by ':' + if let Some(colon_pos) = trimmed.find(':') { + let before_colon = trimmed[..colon_pos].trim(); + if before_colon.parse::().is_ok() { + // This is a camera line + let rest = &trimmed[colon_pos + 1..].trim(); + + // Extract camera name between single quotes + if let Some(name_start) = rest.find('\'') { + if let Some(name_end) = rest[name_start + 1..].find('\'') { + let name = &rest[name_start + 1..name_start + 1 + name_end]; + + // Extract camera ID between parentheses + let camera_id = if let Some(id_start) = rest.find('(') { + if let Some(id_end) = rest.rfind(')') { + rest[id_start + 1..id_end].to_string() + } else { + name.to_string() + } + } else { + name.to_string() + }; + + debug!(name = %name, camera_id = %camera_id, "Found libcamera camera"); + cameras.push(CameraDevice { + name: name.to_string(), + path: camera_id.clone(), + metadata_path: Some(camera_id), + }); + } + } + } + } + } + + if cameras.is_empty() { + debug!("No cameras found via cam CLI"); + None + } else { + info!(count = cameras.len(), "Enumerated cameras via cam CLI"); + Some(cameras) + } +} + +/// Get supported formats for a libcamera camera +/// Queries actual supported formats from libcamera +pub fn get_libcamera_formats(device_path: &str, metadata_path: Option<&str>) -> Vec { + debug!(device_path, metadata_path = ?metadata_path, "Getting libcamera formats"); + + // Try to enumerate formats from the camera + if let Some(camera_id) = metadata_path.or(if device_path.is_empty() { + None + } else { + Some(device_path) + }) { + if let Some(formats) = try_enumerate_formats_from_camera(camera_id) { + info!(count = formats.len(), camera_id = %camera_id, "Enumerated formats via cam CLI"); + return formats; + } else { + warn!(camera_id = %camera_id, "Failed to enumerate formats from camera, using fallback"); + } + } else { + warn!( + device_path, + "No camera ID provided for format enumeration, using fallback" + ); + } + + // Fallback: return common mobile camera formats + get_fallback_formats() +} + +/// Fallback formats for mobile cameras when enumeration fails +fn get_fallback_formats() -> Vec { + let mut formats = Vec::new(); + + // Common mobile camera resolutions (lower than desktop due to ISP constraints) + let resolutions = [ + (1920, 1080), // 1080p + (1280, 720), // 720p + (640, 480), // VGA + ]; + + // Mobile cameras typically output NV12 from the ISP + for &(width, height) in &resolutions { + for &fps in formats::COMMON_FRAMERATES { + // Only include reasonable framerates for mobile + if fps <= 30 { + formats.push(CameraFormat { + width, + height, + framerate: Some(fps), + hardware_accelerated: true, + pixel_format: "NV12".to_string(), + }); + } + } + } + formats +} + +/// Try to enumerate formats from a libcamera camera using cam CLI +fn try_enumerate_formats_from_camera(camera_id: &str) -> Option> { + debug!(camera_id, "Enumerating formats via cam CLI"); + + // cam -c --list-properties shows camera info + // cam -c --capture=1 shows supported formats during capture + // For now, we'll use common mobile formats and let GStreamer negotiate + + // Try to get some info via cam -c -I + let output = std::process::Command::new("cam") + .args(["-c", camera_id, "-I"]) + .output() + .ok()?; + + if !output.status.success() { + debug!("cam -c {} -I failed", camera_id); + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut formats = Vec::new(); + let mut current_width: Option = None; + let mut current_height: Option = None; + + // Parse the output for resolution information + // Format varies, but look for lines containing resolution info + for line in stdout.lines() { + let trimmed = line.trim(); + + // Look for resolution patterns like "1920x1080" or "Size: 1920x1080" + if let Some(res_match) = find_resolution_in_line(trimmed) { + current_width = Some(res_match.0); + current_height = Some(res_match.1); + } + } + + // If we found a resolution, create formats for common framerates + if let (Some(w), Some(h)) = (current_width, current_height) { + for &fps in &[30u32, 15, 10] { + formats.push(CameraFormat { + width: w, + height: h, + framerate: Some(fps), + hardware_accelerated: true, + pixel_format: "NV12".to_string(), + }); + } + } + + // If no formats found from parsing, return None to use fallback + if formats.is_empty() { + None + } else { + Some(formats) + } +} + +/// Find resolution pattern (WxH) in a line +fn find_resolution_in_line(line: &str) -> Option<(u32, u32)> { + // Look for patterns like "1920x1080" or "1920 x 1080" + for word in line.split_whitespace() { + if let Some((w_str, h_str)) = word.split_once('x') { + if let (Ok(w), Ok(h)) = (w_str.parse::(), h_str.parse::()) { + if w >= 320 && h >= 240 { + return Some((w, h)); + } + } + } + } + None +} diff --git a/src/backends/camera/libcamera/mod.rs b/src/backends/camera/libcamera/mod.rs new file mode 100644 index 0000000..07afd76 --- /dev/null +++ b/src/backends/camera/libcamera/mod.rs @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Libcamera camera backend +//! +//! This backend uses libcamera for camera enumeration, format detection, and capture. +//! It's designed for mobile Linux devices (PinePhone, Librem 5, etc.) that use +//! the libcamera camera stack. + +mod enumeration; +mod pipeline; + +pub use enumeration::{enumerate_libcamera_cameras, get_libcamera_formats, is_libcamera_available}; +pub use pipeline::LibcameraPipeline; + +use super::CameraBackend; +use super::types::*; +use std::path::PathBuf; +use tracing::{debug, info}; + +/// Libcamera backend implementation +pub struct LibcameraBackend { + /// Currently active camera device (if initialized) + current_device: Option, + /// Currently active format (if initialized) + current_format: Option, + /// Active GStreamer pipeline for preview + pipeline: Option, + /// Frame sender for preview stream + frame_sender: Option, + /// Frame receiver for preview stream (given to UI) + frame_receiver: Option, +} + +impl LibcameraBackend { + /// Create a new libcamera backend + pub fn new() -> Self { + Self { + current_device: None, + current_format: None, + pipeline: None, + frame_sender: None, + frame_receiver: None, + } + } + + /// Internal method to create preview pipeline + fn create_pipeline(&mut self) -> BackendResult<()> { + let device = self + .current_device + .as_ref() + .ok_or_else(|| BackendError::Other("No device set".to_string()))?; + let format = self + .current_format + .as_ref() + .ok_or_else(|| BackendError::Other("No format set".to_string()))?; + + // Create frame channel + let (sender, receiver) = cosmic::iced::futures::channel::mpsc::channel(100); + + // Create pipeline + let pipeline = pipeline::LibcameraPipeline::new(device, format, sender.clone())?; + + pipeline.start()?; + + self.pipeline = Some(pipeline); + self.frame_sender = Some(sender); + self.frame_receiver = Some(receiver); + + Ok(()) + } +} + +impl CameraBackend for LibcameraBackend { + fn enumerate_cameras(&self) -> Vec { + info!("Using libcamera backend for camera enumeration"); + + if let Some(cameras) = enumerate_libcamera_cameras() { + info!(count = cameras.len(), "Libcamera cameras enumerated"); + cameras + } else { + info!("Libcamera enumeration returned None"); + Vec::new() + } + } + + fn get_formats(&self, device: &CameraDevice, _video_mode: bool) -> Vec { + info!(device_path = %device.path, "Getting formats via libcamera backend"); + get_libcamera_formats(&device.path, device.metadata_path.as_deref()) + } + + fn initialize(&mut self, device: &CameraDevice, format: &CameraFormat) -> BackendResult<()> { + info!( + device = %device.name, + format = %format, + "Initializing libcamera backend" + ); + + // Shutdown any existing pipeline + if self.is_initialized() { + self.shutdown()?; + } + + // Store device and format + self.current_device = Some(device.clone()); + self.current_format = Some(format.clone()); + + // Create pipeline + self.create_pipeline()?; + + info!("Libcamera backend initialized successfully"); + Ok(()) + } + + fn shutdown(&mut self) -> BackendResult<()> { + info!("Shutting down libcamera backend"); + + // Stop pipeline + if let Some(pipeline) = self.pipeline.take() { + pipeline.stop()?; + } + + // Clear state + self.frame_sender = None; + self.frame_receiver = None; + self.current_device = None; + self.current_format = None; + + info!("Libcamera backend shut down"); + Ok(()) + } + + fn is_initialized(&self) -> bool { + self.pipeline.is_some() && self.current_device.is_some() + } + + fn recover(&mut self) -> BackendResult<()> { + info!("Attempting to recover libcamera backend"); + + // Get current config + let device = self + .current_device + .clone() + .ok_or_else(|| BackendError::Other("No device to recover".to_string()))?; + let format = self + .current_format + .clone() + .ok_or_else(|| BackendError::Other("No format to recover".to_string()))?; + + // Shutdown and reinitialize + let _ = self.shutdown(); // Ignore errors during recovery shutdown + self.initialize(&device, &format) + } + + fn switch_camera(&mut self, device: &CameraDevice) -> BackendResult<()> { + info!(device = %device.name, "Switching to new camera"); + + // Get available formats for new device + let formats = self.get_formats(device, false); + if formats.is_empty() { + return Err(BackendError::FormatNotSupported( + "No formats available for device".to_string(), + )); + } + + // Select highest resolution format + let format = formats + .iter() + .max_by_key(|f| f.width * f.height) + .cloned() + .ok_or_else(|| BackendError::Other("Failed to select format".to_string()))?; + + // Reinitialize with new device + self.initialize(device, &format) + } + + fn apply_format(&mut self, format: &CameraFormat) -> BackendResult<()> { + info!(format = %format, "Applying new format"); + + let device = self + .current_device + .clone() + .ok_or_else(|| BackendError::Other("No active device".to_string()))?; + + // Reinitialize with new format + self.initialize(&device, format) + } + + fn capture_photo(&self) -> BackendResult { + debug!("Capturing photo via libcamera backend"); + + let pipeline = self + .pipeline + .as_ref() + .ok_or_else(|| BackendError::Other("Pipeline not initialized".to_string()))?; + + pipeline.capture_frame() + } + + fn start_recording(&mut self, output_path: PathBuf) -> BackendResult<()> { + info!(path = %output_path.display(), "Starting recording"); + + let pipeline = self + .pipeline + .as_mut() + .ok_or_else(|| BackendError::Other("Pipeline not initialized".to_string()))?; + + pipeline.start_recording(output_path) + } + + fn stop_recording(&mut self) -> BackendResult { + info!("Stopping recording"); + + let pipeline = self + .pipeline + .as_mut() + .ok_or_else(|| BackendError::Other("Pipeline not initialized".to_string()))?; + + pipeline.stop_recording() + } + + fn is_recording(&self) -> bool { + self.pipeline + .as_ref() + .map(|p| p.is_recording()) + .unwrap_or(false) + } + + fn get_preview_receiver(&self) -> Option { + // Note: This returns a clone of the receiver + // The actual implementation will need to handle this differently + // For now, we'll return None and handle preview frames via subscription + None + } + + fn backend_type(&self) -> CameraBackendType { + CameraBackendType::Libcamera + } + + fn is_available(&self) -> bool { + is_libcamera_available() + } + + fn current_device(&self) -> Option<&CameraDevice> { + self.current_device.as_ref() + } + + fn current_format(&self) -> Option<&CameraFormat> { + self.current_format.as_ref() + } +} diff --git a/src/backends/camera/libcamera/pipeline.rs b/src/backends/camera/libcamera/pipeline.rs new file mode 100644 index 0000000..fa56544 --- /dev/null +++ b/src/backends/camera/libcamera/pipeline.rs @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Libcamera GStreamer pipeline for camera capture +//! +//! This module handles the creation of GStreamer pipelines using libcamerasrc, +//! specifically designed for mobile Linux devices with libcamera support. + +use super::super::types::*; +use crate::constants::{pipeline, timing}; +use gstreamer::prelude::*; +use gstreamer_app::AppSink; +use gstreamer_video::VideoInfo; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; +use tracing::{debug, error, info, warn}; + +static FRAME_COUNTER: AtomicU64 = AtomicU64::new(0); +static DECODE_TIME_US: AtomicU64 = AtomicU64::new(0); +static SEND_TIME_US: AtomicU64 = AtomicU64::new(0); + +/// Libcamera camera pipeline +/// +/// GStreamer pipeline implementation using libcamerasrc for camera capture. +/// Designed for mobile Linux devices (PinePhone, Librem 5, etc.) +pub struct LibcameraPipeline { + pipeline: gstreamer::Pipeline, + _appsink: AppSink, + recording: bool, +} + +impl LibcameraPipeline { + /// Create a new libcamera pipeline + pub fn new( + device: &CameraDevice, + format: &CameraFormat, + frame_sender: FrameSender, + ) -> BackendResult { + info!( + device = %device.name, + format = %format, + "Creating libcamera pipeline" + ); + + // Initialize GStreamer + debug!("Initializing GStreamer"); + gstreamer::init().map_err(|e| BackendError::InitializationFailed(e.to_string()))?; + debug!("GStreamer initialized successfully"); + + // Build the pipeline string + let pipeline_str = build_libcamera_pipeline_string(device, format); + info!(pipeline = %pipeline_str, "Creating libcamera pipeline"); + + // Parse and create the pipeline + let pipeline = gstreamer::parse::launch(&pipeline_str) + .map_err(|e| { + BackendError::InitializationFailed(format!("Failed to parse pipeline: {}", e)) + })? + .dynamic_cast::() + .map_err(|_| { + BackendError::InitializationFailed("Failed to cast to pipeline".to_string()) + })?; + + // Get the appsink element + debug!("Getting appsink element"); + let appsink = pipeline + .by_name("sink") + .ok_or_else(|| BackendError::InitializationFailed("Failed to get appsink".to_string()))? + .dynamic_cast::() + .map_err(|_| { + BackendError::InitializationFailed("Failed to cast appsink".to_string()) + })?; + debug!("Got appsink element"); + + // Configure appsink for maximum performance + debug!("Configuring appsink"); + appsink.set_property("emit-signals", true); + appsink.set_property("sync", false); // Disable sync for lowest latency + + // Mobile devices typically have lower framerates, use less buffering + let buffer_count = if format.framerate.unwrap_or(0) > 30 { + 3 + } else { + pipeline::MAX_BUFFERS + }; + appsink.set_property("max-buffers", buffer_count); + appsink.set_property("drop", true); + appsink.set_property("enable-last-sample", false); + + debug!( + buffer_count, + framerate = format.framerate, + "Appsink configured for libcamera" + ); + + // Set up callback for new samples with performance tracking + debug!("Setting up frame callback"); + appsink.set_callbacks( + gstreamer_app::AppSinkCallbacks::builder() + .new_sample(move |appsink| { + let frame_start = Instant::now(); + let frame_num = FRAME_COUNTER.fetch_add(1, Ordering::Relaxed); + + // Pull and decode sample + let sample = match appsink.pull_sample() { + Ok(s) => s, + Err(e) => { + if frame_num % 30 == 0 { + error!(frame = frame_num, error = ?e, "Failed to pull sample"); + } + return Err(gstreamer::FlowError::Eos); + } + }; + + let buffer = sample.buffer().ok_or_else(|| { + if frame_num % 30 == 0 { + error!(frame = frame_num, "No buffer in sample"); + } + gstreamer::FlowError::Error + })?; + + // Check buffer flags for incomplete/corrupted frames + let buffer_flags = buffer.flags(); + if buffer_flags.contains(gstreamer::BufferFlags::CORRUPTED) { + if frame_num % 30 == 0 { + warn!(frame = frame_num, "Buffer marked as corrupted, skipping frame"); + } + return Err(gstreamer::FlowError::Error); + } + + let caps = sample.caps().ok_or_else(|| { + if frame_num % 30 == 0 { + error!(frame = frame_num, "No caps in sample"); + } + gstreamer::FlowError::Error + })?; + + let video_info = VideoInfo::from_caps(caps).map_err(|e| { + if frame_num % 30 == 0 { + error!(frame = frame_num, error = ?e, "Failed to get video info"); + } + gstreamer::FlowError::Error + })?; + + let map = buffer.map_readable().map_err(|e| { + if frame_num % 30 == 0 { + error!(frame = frame_num, error = ?e, "Failed to map buffer"); + } + gstreamer::FlowError::Error + })?; + + let decode_time = frame_start.elapsed(); + DECODE_TIME_US.store(decode_time.as_micros() as u64, Ordering::Relaxed); + + // Extract stride information for RGBA format + let stride = video_info.stride()[0] as u32; + + // Log stride info every 60 frames for debugging + if frame_num % 60 == 0 { + info!( + frame = frame_num, + width = video_info.width(), + height = video_info.height(), + stride, + "Frame stride information (libcamera)" + ); + } + + // Use Arc::from to avoid intermediate Vec allocation + let frame = CameraFrame { + width: video_info.width(), + height: video_info.height(), + data: Arc::from(map.as_slice()), + format: PixelFormat::RGBA, + stride, + captured_at: frame_start, + }; + + // Send frame to the app (non-blocking using try_send) + let send_start = Instant::now(); + let mut sender = frame_sender.clone(); + match sender.try_send(frame) { + Ok(_) => { + let send_time = send_start.elapsed(); + SEND_TIME_US.store(send_time.as_micros() as u64, Ordering::Relaxed); + + // Performance stats every N frames + if frame_num % timing::FRAME_LOG_INTERVAL == 0 { + let total_time = frame_start.elapsed(); + debug!( + frame = frame_num, + decode_us = decode_time.as_micros(), + send_us = send_time.as_micros(), + total_us = total_time.as_micros(), + width = video_info.width(), + height = video_info.height(), + size_kb = map.as_slice().len() / 1024, + "Frame performance (libcamera)" + ); + } + } + Err(e) => { + if frame_num % 30 == 0 { + debug!(frame = frame_num, error = ?e, "Frame dropped (channel full)"); + } + } + } + + Ok(gstreamer::FlowSuccess::Ok) + }) + .build(), + ); + debug!("Frame callback set up with performance tracking"); + + // Start the pipeline + debug!("Setting pipeline to PLAYING state"); + pipeline.set_state(gstreamer::State::Playing).map_err(|e| { + BackendError::InitializationFailed(format!("Failed to start pipeline: {}", e)) + })?; + + // Wait for state change to complete + let (result, state, pending) = pipeline.state(gstreamer::ClockTime::from_seconds( + timing::START_TIMEOUT_SECS, + )); + debug!(result = ?result, state = ?state, pending = ?pending, "Pipeline state"); + if state != gstreamer::State::Playing { + warn!("Pipeline is not in PLAYING state"); + } + + info!("Libcamera camera initialization complete"); + + Ok(Self { + pipeline, + _appsink: appsink, + recording: false, + }) + } + + /// Start the pipeline (already started in new()) + pub fn start(&self) -> BackendResult<()> { + info!("Libcamera pipeline already started"); + Ok(()) + } + + /// Stop the pipeline + pub fn stop(self) -> BackendResult<()> { + info!("Stopping libcamera pipeline"); + + // Clear appsink callbacks to release all references + debug!("Clearing appsink callbacks"); + self._appsink + .set_callbacks(gstreamer_app::AppSinkCallbacks::builder().build()); + + // Set pipeline to NULL state to release camera + self.pipeline + .set_state(gstreamer::State::Null) + .map_err(|e| BackendError::Other(format!("Failed to stop pipeline: {}", e)))?; + + // Wait for state change to complete + let (result, state, _) = self.pipeline.state(gstreamer::ClockTime::from_seconds( + timing::STOP_TIMEOUT_SECS, + )); + match result { + Ok(_) => { + info!(state = ?state, "Libcamera pipeline stopped successfully"); + } + Err(e) => { + debug!(error = ?e, state = ?state, "Pipeline state change had issues"); + } + } + + info!("Libcamera will release camera when ready"); + + Ok(()) + } + + /// Capture a single frame + pub fn capture_frame(&self) -> BackendResult { + Err(BackendError::Other( + "Photo capture not yet implemented for libcamera backend".to_string(), + )) + } + + /// Start recording video + pub fn start_recording(&mut self, _output_path: PathBuf) -> BackendResult<()> { + if self.recording { + return Err(BackendError::RecordingInProgress); + } + + Err(BackendError::Other( + "Video recording not yet implemented for libcamera backend".to_string(), + )) + } + + /// Stop recording video + pub fn stop_recording(&mut self) -> BackendResult { + if !self.recording { + return Err(BackendError::NoRecordingInProgress); + } + + Err(BackendError::Other( + "Video recording not yet implemented for libcamera backend".to_string(), + )) + } + + /// Check if currently recording + pub fn is_recording(&self) -> bool { + self.recording + } +} + +impl Drop for LibcameraPipeline { + fn drop(&mut self) { + info!("Dropping libcamera pipeline - explicitly stopping"); + // Clear callbacks first + self._appsink + .set_callbacks(gstreamer_app::AppSinkCallbacks::builder().build()); + // Explicitly set pipeline to Null to release device immediately + let _ = self.pipeline.set_state(gstreamer::State::Null); + info!("Libcamera pipeline stopped"); + } +} + +/// Build the GStreamer pipeline string for libcamera +fn build_libcamera_pipeline_string(device: &CameraDevice, format: &CameraFormat) -> String { + // Build camera-name property if device path is specified + let camera_prop = if device.path.is_empty() { + String::new() + } else { + format!("camera-name=\"{}\" ", device.path) + }; + + // Build caps filter for resolution and framerate + let caps_filter = if let Some(fps) = format.framerate { + format!( + "width={},height={},framerate={}/1", + format.width, format.height, fps + ) + } else { + format!("width={},height={}", format.width, format.height) + }; + + // Libcamera typically outputs raw formats (NV12, YUY2, etc.) + // Use videoconvert to convert to RGBA for display + match format.pixel_format.as_str() { + "MJPG" | "MJPEG" => { + // Some libcamera devices might support MJPEG + format!( + "libcamerasrc {}! image/jpeg,{} ! \ + queue max-size-buffers=2 leaky=downstream ! \ + jpegdec ! \ + videoconvert n-threads={} ! \ + video/x-raw,format={} ! \ + appsink name=sink", + camera_prop, + caps_filter, + pipeline::videoconvert_threads(), + pipeline::OUTPUT_FORMAT + ) + } + _ => { + // Raw formats (NV12, YUY2, etc.) - direct conversion + // This is the common case for mobile cameras with ISP + format!( + "libcamerasrc {}! video/x-raw,{} ! \ + queue max-size-buffers=2 leaky=downstream ! \ + videoconvert n-threads={} ! \ + video/x-raw,format={} ! \ + appsink name=sink", + camera_prop, + caps_filter, + pipeline::videoconvert_threads(), + pipeline::OUTPUT_FORMAT + ) + } + } +} diff --git a/src/backends/camera/manager.rs b/src/backends/camera/manager.rs index 0efb739..7c1d7f3 100644 --- a/src/backends/camera/manager.rs +++ b/src/backends/camera/manager.rs @@ -37,7 +37,7 @@ impl CameraBackendManager { pub fn new(backend_type: CameraBackendType) -> Self { info!(backend = %backend_type, "Creating camera backend manager"); - let backend = get_backend(); + let backend = get_backend(backend_type); let state = ManagerState { backend, @@ -161,7 +161,7 @@ impl CameraBackendManager { let _ = state.backend.shutdown(); // Ignore errors during shutdown // Create new backend - let new_backend = get_backend(); + let new_backend = get_backend(new_backend_type); state.backend = new_backend; state.backend_type = new_backend_type; diff --git a/src/backends/camera/mod.rs b/src/backends/camera/mod.rs index d469a86..64e900d 100644 --- a/src/backends/camera/mod.rs +++ b/src/backends/camera/mod.rs @@ -24,11 +24,14 @@ //! └──────────┬──────────┘ //! │ //! ▼ -//! ┌────────┐ -//! │PipeWire│ ← Concrete implementation -//! └────────┘ +//! ┌────────┴────────┐ +//! │ │ +//! ┌────────┐ ┌──────────┐ +//! │PipeWire│ │libcamera │ ← Concrete implementations +//! └────────┘ └──────────┘ //! ``` +pub mod libcamera; pub mod manager; pub mod pipewire; pub mod types; @@ -184,12 +187,23 @@ pub trait CameraBackend: Send + Sync { fn current_format(&self) -> Option<&CameraFormat>; } -/// Get a concrete backend instance (PipeWire only) -pub fn get_backend() -> Box { - Box::new(pipewire::PipeWireBackend::new()) +/// Get a concrete backend instance for the specified type +pub fn get_backend(backend_type: CameraBackendType) -> Box { + match backend_type { + CameraBackendType::PipeWire => Box::new(pipewire::PipeWireBackend::new()), + CameraBackendType::Libcamera => Box::new(libcamera::LibcameraBackend::new()), + } } -/// Get the default backend (PipeWire) +/// Get the default backend +/// +/// Uses libcamera priority: try libcamera first if available, fall back to PipeWire. +/// This ensures mobile users get proper camera support automatically while desktop +/// users fall back to PipeWire when libcamerasrc is not installed. pub fn get_default_backend() -> CameraBackendType { - CameraBackendType::PipeWire + if libcamera::is_libcamera_available() { + CameraBackendType::Libcamera + } else { + CameraBackendType::PipeWire + } } diff --git a/src/backends/camera/types.rs b/src/backends/camera/types.rs index 0a7c83e..df094bc 100644 --- a/src/backends/camera/types.rs +++ b/src/backends/camera/types.rs @@ -8,11 +8,13 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::time::Instant; -/// Camera backend type (PipeWire only) +/// Camera backend type #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CameraBackendType { - /// PipeWire backend (modern Linux standard) + /// PipeWire backend (modern Linux desktop standard) PipeWire, + /// Libcamera backend (for mobile Linux devices) + Libcamera, } impl Default for CameraBackendType { @@ -23,7 +25,10 @@ impl Default for CameraBackendType { impl std::fmt::Display for CameraBackendType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "PipeWire") + match self { + Self::PipeWire => write!(f, "PipeWire"), + Self::Libcamera => write!(f, "libcamera"), + } } } diff --git a/src/media/decoders/mod.rs b/src/media/decoders/mod.rs index eb43679..c41615b 100644 --- a/src/media/decoders/mod.rs +++ b/src/media/decoders/mod.rs @@ -16,12 +16,16 @@ pub use pipeline::try_create_pipeline; pub enum PipelineBackend { /// PipeWire backend (allows simultaneous preview + recording) PipeWire, + /// Libcamera backend (for mobile Linux devices) + Libcamera, } -// Conversion from CameraBackendType (for backward compatibility) +// Conversion from CameraBackendType impl From for PipelineBackend { - fn from(_backend: crate::backends::camera::CameraBackendType) -> Self { - // Only PipeWire is supported - PipelineBackend::PipeWire + fn from(backend: crate::backends::camera::CameraBackendType) -> Self { + match backend { + crate::backends::camera::CameraBackendType::PipeWire => PipelineBackend::PipeWire, + crate::backends::camera::CameraBackendType::Libcamera => PipelineBackend::Libcamera, + } } } diff --git a/src/media/decoders/pipeline.rs b/src/media/decoders/pipeline.rs index 39a1083..12e379d 100644 --- a/src/media/decoders/pipeline.rs +++ b/src/media/decoders/pipeline.rs @@ -10,17 +10,17 @@ use crate::constants::{pipeline, timing}; use gstreamer::prelude::*; use tracing::{error, info, warn}; -/// Try to create a GStreamer pipeline for camera capture using PipeWire +/// Try to create a GStreamer pipeline for camera capture /// -/// This function creates pipelines using pipewiresrc, handling format negotiation -/// and decoder selection automatically. PipeWire-only application. +/// This function creates pipelines using the appropriate source element +/// (pipewiresrc or libcamerasrc) based on the backend type. /// /// # Arguments -/// * `device_path` - Device path (e.g., "pipewire-serial-12345" or "/dev/video0" via PipeWire) +/// * `device_path` - Device path (e.g., "pipewire-serial-12345" or camera ID for libcamera) /// * `caps_filter` - GStreamer caps filter string (e.g., "width=1920,height=1080") /// * `_decoder` - Decoder element name (unused, kept for API compatibility) /// * `pixel_format` - Pixel format FourCC (e.g., "MJPG", "H264", "YUYV") -/// * `_backend` - Backend type (unused, always PipeWire) +/// * `backend` - Backend type (PipeWire or Libcamera) /// /// # Returns /// * `Ok(Pipeline)` - Successfully created and started pipeline @@ -30,9 +30,16 @@ pub fn try_create_pipeline( caps_filter: &str, _decoder: &str, pixel_format: Option<&str>, - _backend: PipelineBackend, + backend: PipelineBackend, ) -> Result> { - try_create_pipewire_pipeline(device_path, caps_filter, pixel_format) + match backend { + PipelineBackend::PipeWire => { + try_create_pipewire_pipeline(device_path, caps_filter, pixel_format) + } + PipelineBackend::Libcamera => { + try_create_libcamera_pipeline(device_path, caps_filter, pixel_format) + } + } } /// Try to create a PipeWire pipeline @@ -312,3 +319,83 @@ fn check_bus_for_errors(pipeline: &gstreamer::Pipeline) { } } } + +/// Try to create a libcamera pipeline +fn try_create_libcamera_pipeline( + device_path: Option<&str>, + caps_filter: &str, + pixel_format: Option<&str>, +) -> Result> { + // Check if libcamera source is available + gstreamer::ElementFactory::make("libcamerasrc") + .build() + .map_err(|e| format!("libcamerasrc not available: {}", e))?; + + info!("libcamera available - creating camera pipeline"); + + // Build camera-name property if device path is specified + let camera_prop = if let Some(path) = device_path { + if path.is_empty() { + String::new() + } else { + format!("camera-name=\"{}\" ", path) + } + } else { + String::new() + }; + + // Build libcamera pipeline based on pixel format + let libcamera_pipeline = + build_libcamera_pipeline_string(&camera_prop, caps_filter, pixel_format); + + info!(pipeline = %libcamera_pipeline, "Creating libcamera pipeline"); + try_launch_pipeline_with_bus_errors(&libcamera_pipeline) +} + +/// Build libcamera pipeline string based on pixel format +fn build_libcamera_pipeline_string( + camera_prop: &str, + caps_filter: &str, + pixel_format: Option<&str>, +) -> String { + if !caps_filter.is_empty() { + match pixel_format { + Some("MJPG") | Some("MJPEG") => { + // MJPEG format (less common on mobile, but possible) + format!( + "libcamerasrc {}! image/jpeg,{} ! \ + queue max-size-buffers=2 leaky=downstream ! \ + jpegdec ! \ + videoconvert n-threads={} ! \ + video/x-raw,format={} ! \ + appsink name=sink", + camera_prop, + caps_filter, + pipeline::videoconvert_threads(), + pipeline::OUTPUT_FORMAT + ) + } + _ => { + // Raw formats (NV12, YUY2, etc.) - the common case for libcamera + format!( + "libcamerasrc {}! video/x-raw,{} ! \ + queue max-size-buffers=2 leaky=downstream ! \ + videoconvert n-threads={} ! \ + video/x-raw,format={} ! \ + appsink name=sink", + camera_prop, + caps_filter, + pipeline::videoconvert_threads(), + pipeline::OUTPUT_FORMAT + ) + } + } + } else { + // No specific format - let libcamera auto-negotiate + format!( + "libcamerasrc {}! videoconvert ! video/x-raw,format={} ! appsink name=sink", + camera_prop, + pipeline::OUTPUT_FORMAT + ) + } +}