use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CameraConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub default_device: Option<String>,
#[serde(default = "default_output_dir")]
pub output_dir: PathBuf,
#[serde(default = "default_quality")]
pub photo_quality: u8,
#[serde(default = "default_resolution")]
pub video_resolution: String,
#[serde(default = "default_fps")]
pub video_fps: u32,
}
fn default_true() -> bool {
true
}
fn default_output_dir() -> PathBuf {
dirs::picture_dir().unwrap_or_else(|| PathBuf::from("."))
}
fn default_quality() -> u8 {
85
}
fn default_resolution() -> String {
"1920x1080".to_string()
}
fn default_fps() -> u32 {
30
}
impl Default for CameraConfig {
fn default() -> Self {
Self {
enabled: true,
default_device: None,
output_dir: default_output_dir(),
photo_quality: default_quality(),
video_resolution: default_resolution(),
video_fps: default_fps(),
}
}
}
impl CameraConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CameraDevice {
pub id: String,
pub name: String,
pub is_default: bool,
pub resolutions: Vec<String>,
pub device_type: CameraType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CameraType {
Builtin,
Usb,
Virtual,
Network,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapturedPhoto {
pub path: PathBuf,
pub width: u32,
pub height: u32,
pub size: u64,
pub mime_type: String,
pub timestamp: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoRecording {
pub path: PathBuf,
pub duration: f64,
pub width: u32,
pub height: u32,
pub fps: u32,
pub size: u64,
pub mime_type: String,
pub timestamp: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordingState {
Idle,
Recording,
Paused,
Processing,
}
pub struct CameraClient {
config: CameraConfig,
recording_state: RecordingState,
current_recording: Option<PathBuf>,
}
impl CameraClient {
pub fn new(config: &CameraConfig) -> Result<Self> {
std::fs::create_dir_all(&config.output_dir)
.map_err(|e| DrivenError::Io(e))?;
Ok(Self {
config: config.clone(),
recording_state: RecordingState::Idle,
current_recording: None,
})
}
pub fn is_available(&self) -> bool {
self.config.enabled
}
pub async fn list_cameras(&self) -> Result<Vec<CameraDevice>> {
#[cfg(target_os = "macos")]
{
self.list_cameras_macos().await
}
#[cfg(target_os = "windows")]
{
self.list_cameras_windows().await
}
#[cfg(target_os = "linux")]
{
self.list_cameras_linux().await
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Ok(Vec::new())
}
}
#[cfg(target_os = "macos")]
async fn list_cameras_macos(&self) -> Result<Vec<CameraDevice>> {
use tokio::process::Command;
let output = Command::new("system_profiler")
.args(["SPCameraDataType", "-json"])
.output()
.await
.map_err(|e| DrivenError::Process(e.to_string()))?;
if !output.status.success() {
return Ok(Vec::new());
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| DrivenError::Parse(e.to_string()))?;
let mut cameras = Vec::new();
if let Some(camera_data) = json["SPCameraDataType"].as_array() {
for (idx, cam) in camera_data.iter().enumerate() {
cameras.push(CameraDevice {
id: format!("camera_{}", idx),
name: cam["_name"].as_str().unwrap_or("Unknown").to_string(),
is_default: idx == 0,
resolutions: vec!["1920x1080".to_string(), "1280x720".to_string()],
device_type: CameraType::Builtin,
});
}
}
Ok(cameras)
}
#[cfg(target_os = "windows")]
async fn list_cameras_windows(&self) -> Result<Vec<CameraDevice>> {
use tokio::process::Command;
let script = r#"
Get-CimInstance Win32_PnPEntity |
Where-Object { $_.PNPClass -eq 'Camera' -or $_.PNPClass -eq 'Image' } |
Select-Object Name, DeviceID |
ConvertTo-Json
"#;
let output = Command::new("powershell")
.args(["-Command", script])
.output()
.await
.map_err(|e| DrivenError::Process(e.to_string()))?;
if !output.status.success() {
return Ok(Vec::new());
}
let json_str = String::from_utf8_lossy(&output.stdout);
let devices: Vec<serde_json::Value> = serde_json::from_str(&json_str)
.unwrap_or_else(|_| Vec::new());
let cameras = devices
.into_iter()
.enumerate()
.map(|(idx, d)| CameraDevice {
id: d["DeviceID"].as_str().unwrap_or_default().to_string(),
name: d["Name"].as_str().unwrap_or("Unknown").to_string(),
is_default: idx == 0,
resolutions: vec!["1920x1080".to_string(), "1280x720".to_string()],
device_type: CameraType::Usb,
})
.collect();
Ok(cameras)
}
#[cfg(target_os = "linux")]
async fn list_cameras_linux(&self) -> Result<Vec<CameraDevice>> {
use tokio::process::Command;
let output = Command::new("v4l2-ctl")
.args(["--list-devices"])
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut cameras = Vec::new();
let mut current_name = String::new();
for line in stdout.lines() {
if !line.starts_with('\t') && !line.is_empty() {
current_name = line.trim_end_matches(':').to_string();
} else if line.contains("/dev/video") {
let device = line.trim();
cameras.push(CameraDevice {
id: device.to_string(),
name: current_name.clone(),
is_default: cameras.is_empty(),
resolutions: vec!["1920x1080".to_string(), "1280x720".to_string()],
device_type: CameraType::Usb,
});
}
}
return Ok(cameras);
}
}
Ok(Vec::new())
}
pub async fn capture_photo(&self) -> Result<CapturedPhoto> {
let filename = format!(
"photo_{}.jpg",
chrono::Utc::now().format("%Y%m%d_%H%M%S")
);
let path = self.config.output_dir.join(&filename);
#[cfg(target_os = "macos")]
{
self.capture_photo_macos(&path).await
}
#[cfg(target_os = "windows")]
{
self.capture_photo_windows(&path).await
}
#[cfg(target_os = "linux")]
{
self.capture_photo_linux(&path).await
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Err(DrivenError::Unsupported("Camera not supported on this platform".into()))
}
}
#[cfg(target_os = "macos")]
async fn capture_photo_macos(&self, path: &std::path::Path) -> Result<CapturedPhoto> {
use tokio::process::Command;
let mut cmd = Command::new("imagesnap");
cmd.arg("-q");
cmd.arg(path);
if let Some(ref device) = self.config.default_device {
cmd.arg("-d").arg(device);
}
let status = cmd
.status()
.await
.map_err(|e| DrivenError::Process(format!("Failed to capture photo: {}", e)))?;
if !status.success() {
return Err(DrivenError::Process("Photo capture failed".into()));
}
let metadata = tokio::fs::metadata(path)
.await
.map_err(|e| DrivenError::Io(e))?;
Ok(CapturedPhoto {
path: path.to_path_buf(),
width: 1920, height: 1080,
size: metadata.len(),
mime_type: "image/jpeg".to_string(),
timestamp: chrono::Utc::now(),
})
}
#[cfg(target_os = "windows")]
async fn capture_photo_windows(&self, path: &std::path::Path) -> Result<CapturedPhoto> {
use tokio::process::Command;
let status = Command::new("ffmpeg")
.args([
"-f", "dshow",
"-i", "video=0",
"-frames:v", "1",
"-y",
path.to_str().unwrap(),
])
.status()
.await
.map_err(|e| DrivenError::Process(format!("Failed to capture photo: {}", e)))?;
if !status.success() {
return Err(DrivenError::Process("Photo capture failed".into()));
}
let metadata = tokio::fs::metadata(path)
.await
.map_err(|e| DrivenError::Io(e))?;
Ok(CapturedPhoto {
path: path.to_path_buf(),
width: 1920,
height: 1080,
size: metadata.len(),
mime_type: "image/jpeg".to_string(),
timestamp: chrono::Utc::now(),
})
}
#[cfg(target_os = "linux")]
async fn capture_photo_linux(&self, path: &std::path::Path) -> Result<CapturedPhoto> {
use tokio::process::Command;
let device = self.config.default_device.clone()
.unwrap_or_else(|| "/dev/video0".to_string());
let status = Command::new("fswebcam")
.args([
"-d", &device,
"-r", "1920x1080",
"--jpeg", &self.config.photo_quality.to_string(),
"--no-banner",
path.to_str().unwrap(),
])
.status()
.await
.map_err(|e| DrivenError::Process(format!("Failed to capture photo: {}", e)))?;
if !status.success() {
return Err(DrivenError::Process("Photo capture failed".into()));
}
let metadata = tokio::fs::metadata(path)
.await
.map_err(|e| DrivenError::Io(e))?;
Ok(CapturedPhoto {
path: path.to_path_buf(),
width: 1920,
height: 1080,
size: metadata.len(),
mime_type: "image/jpeg".to_string(),
timestamp: chrono::Utc::now(),
})
}
pub async fn start_recording(&mut self) -> Result<PathBuf> {
if self.recording_state == RecordingState::Recording {
return Err(DrivenError::Conflict("Already recording".into()));
}
let filename = format!(
"video_{}.mp4",
chrono::Utc::now().format("%Y%m%d_%H%M%S")
);
let path = self.config.output_dir.join(&filename);
self.recording_state = RecordingState::Recording;
self.current_recording = Some(path.clone());
Ok(path)
}
pub async fn stop_recording(&mut self) -> Result<Option<VideoRecording>> {
if self.recording_state != RecordingState::Recording {
return Ok(None);
}
self.recording_state = RecordingState::Processing;
let path = self.current_recording.take();
self.recording_state = RecordingState::Idle;
if let Some(p) = path {
if p.exists() {
let metadata = tokio::fs::metadata(&p)
.await
.map_err(|e| DrivenError::Io(e))?;
return Ok(Some(VideoRecording {
path: p,
duration: 0.0, width: 1920,
height: 1080,
fps: self.config.video_fps,
size: metadata.len(),
mime_type: "video/mp4".to_string(),
timestamp: chrono::Utc::now(),
}));
}
}
Ok(None)
}
pub fn recording_state(&self) -> RecordingState {
self.recording_state
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CameraConfig::default();
assert!(config.enabled);
assert_eq!(config.photo_quality, 85);
assert_eq!(config.video_fps, 30);
}
}