use crate::commands::capture::get_or_create_camera;
use crate::constants::{MAX_ISO, MIN_ISO};
use crate::platform::PlatformCamera;
use crate::types::{
BurstConfig, CameraControls, CameraFrame, ControlApplicationResult, WhiteBalance,
};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tauri::command;
#[command]
pub async fn set_camera_controls(
device_id: String,
controls: CameraControls,
) -> Result<ControlApplicationResult, String> {
log::info!("Setting camera controls for device: {device_id}");
let camera_arc =
get_or_create_camera(device_id.clone(), crate::types::CameraFormat::standard()).await?;
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let mut camera = camera_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
let result = camera.apply_controls(&controls).map_err(|e| {
log::error!("Failed to apply camera controls: {e}");
format!("Failed to apply controls: {e}")
})?;
log::info!(
"Camera controls applied for device {} (applied={}, rejected={})",
device_id_clone,
result.applied.len(),
result.rejected.len()
);
Ok(result)
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[command]
pub async fn get_camera_controls(device_id: String) -> Result<CameraControls, String> {
log::info!("Getting camera controls for device: {device_id}");
let camera_arc =
get_or_create_camera(device_id.clone(), crate::types::CameraFormat::standard()).await?;
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let camera = camera_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
match camera.get_controls() {
Ok(controls) => {
log::debug!("Retrieved camera controls for device: {device_id_clone}");
Ok(controls)
}
Err(e) => {
log::error!("Failed to get camera controls: {e}");
Err(format!("Failed to get controls: {e}"))
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[command]
pub async fn capture_burst_sequence(
device_id: String,
config: BurstConfig,
) -> Result<Vec<CameraFrame>, String> {
log::info!(
"Starting burst capture: {} frames from device {}",
config.count,
device_id
);
validate_burst_config(&config)?;
let camera_arc =
get_or_create_camera(device_id.clone(), crate::types::CameraFormat::hd()).await?;
start_burst_stream(camera_arc.clone()).await?;
let mut frames = Vec::with_capacity(config.count as usize);
let start_time = Instant::now();
for i in 0..config.count {
log::debug!("Capturing burst frame {} of {}", i + 1, config.count);
let frame = capture_burst_frame(camera_arc.clone(), config.clone(), i).await?;
frames.push(frame);
if i < config.count - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
config.interval_ms,
)))
.await;
}
}
let total_time = start_time.elapsed();
#[allow(clippy::cast_precision_loss)]
let burst_fps = frames.len() as f32 / total_time.as_secs_f32();
log::info!(
"Burst capture completed: {} frames in {:?} ({:.2} fps)",
frames.len(),
total_time,
burst_fps
);
if config.auto_save {
if let Some(ref save_dir) = config.save_directory {
save_burst_sequence(&frames, save_dir).await?;
}
}
Ok(frames)
}
fn validate_burst_config(config: &BurstConfig) -> Result<(), String> {
if config.count == 0 || config.count > 50 {
return Err("Invalid burst count (must be 1-50)".to_string());
}
if config.focus_stacking && config.count < 2 {
return Err("Focus stacking requires at least 2 frames (count >= 2)".to_string());
}
if let Some(ref bracketing) = config.bracketing {
if bracketing.stops.is_empty() {
return Err("Exposure bracketing requires at least one stop value".to_string());
}
if bracketing.base_exposure <= 0.0 {
return Err("Exposure bracketing base_exposure must be greater than zero".to_string());
}
}
Ok(())
}
async fn start_burst_stream(camera_arc: Arc<StdMutex<PlatformCamera>>) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
if let Ok(mut camera) = camera_arc.lock() {
if let Err(e) = camera.start_stream() {
log::warn!("Failed to start camera stream: {e}");
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))
}
async fn capture_burst_frame(
camera_arc: Arc<StdMutex<PlatformCamera>>,
config: BurstConfig,
index: u32,
) -> Result<CameraFrame, String> {
tokio::task::spawn_blocking(move || {
let mut camera = camera_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
if let Some(ref bracketing) = config.bracketing {
if let Some(stop) = bracketing
.stops
.get(index as usize % bracketing.stops.len())
{
let exposure_time = bracketing.base_exposure * 2.0_f32.powf(*stop);
let controls = CameraControls {
auto_exposure: Some(false),
exposure_time: Some(exposure_time),
..CameraControls::default()
};
if let Err(e) = camera.apply_controls(&controls) {
log::warn!("Failed to apply exposure bracketing: {e}");
}
}
}
if config.focus_stacking {
#[allow(clippy::cast_precision_loss)]
let focus_distance = index as f32 / (config.count as f32 - 1.0); let controls = CameraControls {
auto_focus: Some(false),
focus_distance: Some(focus_distance),
..CameraControls::default()
};
if let Err(e) = camera.apply_controls(&controls) {
log::warn!("Failed to apply focus stacking: {e}");
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let capture_start = Instant::now();
match camera.capture_frame() {
Ok(mut frame) => {
let capture_time = capture_start.elapsed();
frame.metadata.capture_settings = camera.get_controls().ok();
log::debug!("Burst frame {} captured in {:?}", index + 1, capture_time);
Ok(frame)
}
Err(e) => {
log::error!("Failed to capture burst frame {}: {}", index + 1, e);
Err(format!(
"Failed to capture burst frame {}: {}",
index + 1,
e
))
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CameraSettingsInput {
pub device_id: String,
pub focus_distance: Option<f32>,
pub exposure_time: Option<f32>,
pub iso_sensitivity: Option<u32>,
pub white_balance: Option<WhiteBalance>,
pub controls: Option<CameraControls>,
}
#[command]
pub async fn apply_camera_settings(
settings: CameraSettingsInput,
) -> Result<ControlApplicationResult, String> {
let mut combined = CameraControls::default();
if let Some(focus_distance) = settings.focus_distance {
if !(0.0..=1.0).contains(&focus_distance) {
return Err("Focus distance must be between 0.0 and 1.0".to_string());
}
combined.auto_focus = Some(false);
combined.focus_distance = Some(focus_distance);
}
if let Some(exposure_time) = settings.exposure_time {
if exposure_time <= 0.0 || exposure_time > 10.0 {
return Err("Exposure time must be between 0.0 and 10.0 seconds".to_string());
}
combined.auto_exposure = Some(false);
combined.exposure_time = Some(exposure_time);
}
if let Some(iso_sensitivity) = settings.iso_sensitivity {
if !(MIN_ISO..=MAX_ISO).contains(&iso_sensitivity) {
return Err(format!(
"ISO sensitivity must be between {MIN_ISO} and {MAX_ISO}"
));
}
combined.auto_exposure = Some(false);
combined.iso_sensitivity = Some(iso_sensitivity);
}
if let Some(white_balance) = settings.white_balance {
combined.white_balance = Some(white_balance);
}
if let Some(controls) = &settings.controls {
if controls.auto_focus.is_some() {
combined.auto_focus = controls.auto_focus;
}
if controls.focus_distance.is_some() {
combined.focus_distance = controls.focus_distance;
}
if controls.auto_exposure.is_some() {
combined.auto_exposure = controls.auto_exposure;
}
if controls.exposure_time.is_some() {
combined.exposure_time = controls.exposure_time;
}
if controls.iso_sensitivity.is_some() {
combined.iso_sensitivity = controls.iso_sensitivity;
}
if controls.white_balance.is_some() {
combined.white_balance.clone_from(&controls.white_balance);
}
}
set_camera_controls(settings.device_id, combined).await
}
#[command]
pub async fn set_manual_focus(
device_id: String,
focus_distance: f32,
) -> Result<ControlApplicationResult, String> {
if !(0.0..=1.0).contains(&focus_distance) {
return Err("Focus distance must be between 0.0 (infinity) and 1.0 (closest)".to_string());
}
let controls = CameraControls {
auto_focus: Some(false),
focus_distance: Some(focus_distance),
..CameraControls::default()
};
set_camera_controls(device_id, controls).await
}
#[command]
pub async fn set_manual_exposure(
device_id: String,
exposure_time: f32,
iso_sensitivity: u32,
) -> Result<ControlApplicationResult, String> {
if exposure_time <= 0.0 || exposure_time > 10.0 {
return Err("Exposure time must be between 0.0 and 10.0 seconds".to_string());
}
if !(MIN_ISO..=MAX_ISO).contains(&iso_sensitivity) {
return Err(format!(
"ISO sensitivity must be between {MIN_ISO} and {MAX_ISO}"
));
}
let controls = CameraControls {
auto_exposure: Some(false),
exposure_time: Some(exposure_time),
iso_sensitivity: Some(iso_sensitivity),
..CameraControls::default()
};
set_camera_controls(device_id, controls).await
}
#[command]
pub async fn set_white_balance(
device_id: String,
white_balance: WhiteBalance,
) -> Result<ControlApplicationResult, String> {
let controls = CameraControls {
white_balance: Some(white_balance),
..CameraControls::default()
};
set_camera_controls(device_id, controls).await
}
#[command]
pub async fn capture_hdr_sequence(device_id: String) -> Result<Vec<CameraFrame>, String> {
log::info!("Capturing HDR sequence from device: {device_id}");
let config = BurstConfig::hdr_burst();
capture_burst_sequence(device_id, config).await
}
#[command]
pub async fn capture_focus_stack_legacy(
device_id: String,
stack_count: u32,
) -> Result<Vec<CameraFrame>, String> {
log::info!("Capturing focus stack (legacy): {stack_count} frames from device {device_id}");
if !(3..=20).contains(&stack_count) {
return Err("Focus stack count must be between 3 and 20".to_string());
}
let config = BurstConfig {
count: stack_count,
interval_ms: 1000, bracketing: None,
focus_stacking: true,
auto_save: true,
save_directory: Some("focus_stack".to_string()),
};
capture_burst_sequence(device_id, config).await
}
#[command]
pub async fn get_camera_performance(
device_id: String,
) -> Result<crate::types::CameraPerformanceMetrics, String> {
let camera_arc =
get_or_create_camera(device_id.clone(), crate::types::CameraFormat::standard()).await?;
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let camera = camera_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
match camera.get_performance_metrics() {
Ok(metrics) => {
log::debug!(
"Performance metrics for {}: {:.2}ms latency, {:.2} fps",
device_id_clone,
metrics.capture_latency_ms,
metrics.fps_actual
);
Ok(metrics)
}
Err(e) => {
log::error!("Failed to get performance metrics: {e}");
Err(format!("Failed to get performance metrics: {e}"))
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[command]
pub async fn test_camera_capabilities(
device_id: String,
) -> Result<crate::types::CameraCapabilities, String> {
log::info!("Testing camera capabilities for device: {device_id}");
let camera_arc =
get_or_create_camera(device_id.clone(), crate::types::CameraFormat::standard()).await?;
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let camera = camera_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
match camera.test_capabilities() {
Ok(capabilities) => {
log::info!(
"Camera {} capabilities: manual_focus={}, manual_exposure={}, max_res={}x{}",
device_id_clone,
capabilities.supports.manual_focus,
capabilities.supports.manual_exposure,
capabilities.max_resolution.0,
capabilities.max_resolution.1
);
Ok(capabilities)
}
Err(e) => {
log::error!("Failed to test camera capabilities: {e}");
Err(format!("Failed to test capabilities: {e}"))
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
async fn save_burst_sequence(frames: &[CameraFrame], save_dir: &str) -> Result<(), String> {
log::info!("Saving {} frames to directory: {}", frames.len(), save_dir);
if let Err(e) = tokio::fs::create_dir_all(save_dir).await {
return Err(format!("Failed to create directory {save_dir}: {e}"));
}
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
for (i, frame) in frames.iter().enumerate() {
let filename = format!("{}/burst_{}_{:03}.jpg", save_dir, timestamp, i + 1);
let img = image::RgbImage::from_vec(frame.width, frame.height, frame.data.clone())
.ok_or_else(|| "Failed to create image from frame data".to_string())?;
let dynamic_img = image::DynamicImage::ImageRgb8(img);
let filename_clone = filename.clone();
match tokio::task::spawn_blocking(move || {
dynamic_img.save_with_format(&filename_clone, image::ImageFormat::Jpeg)
})
.await
{
Ok(Ok(())) => {
log::debug!("Saved frame {} to {}", i + 1, filename);
}
Ok(Err(e)) => {
log::error!("Failed to save frame {}: {}", i + 1, e);
return Err(format!("Failed to save frame {}: {}", i + 1, e));
}
Err(e) => {
log::error!("Task join error for frame {}: {}", i + 1, e);
return Err(format!("Failed to save frame {}: task error", i + 1));
}
}
}
log::info!("Successfully saved {} frames to {}", frames.len(), save_dir);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ExposureBracketing;
fn enable_mock_camera() {
std::env::set_var("CRABCAMERA_USE_MOCK", "1");
}
#[tokio::test]
async fn test_set_manual_focus_rejects_out_of_range_value() {
let result = set_manual_focus("0".to_string(), 1.5).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Focus distance must be between 0.0"));
}
#[tokio::test]
async fn test_set_manual_exposure_rejects_invalid_exposure_time() {
let result = set_manual_exposure("0".to_string(), 0.0, MIN_ISO).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Exposure time must be between 0.0 and 10.0 seconds"));
}
#[tokio::test]
async fn test_set_manual_exposure_rejects_invalid_iso() {
let result = set_manual_exposure("0".to_string(), 0.01, MIN_ISO.saturating_sub(1)).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("ISO sensitivity must be between"));
}
#[tokio::test]
async fn test_apply_camera_settings_batches_correctly() {
let result = apply_camera_settings(CameraSettingsInput {
device_id: "0".to_string(),
focus_distance: Some(1.5),
exposure_time: None,
iso_sensitivity: None,
white_balance: None,
controls: None,
})
.await;
assert!(result.is_err());
assert!(result
.expect_err("focus distance error expected")
.contains("Focus distance must be between 0.0"));
let result = apply_camera_settings(CameraSettingsInput {
device_id: "0".to_string(),
focus_distance: None,
exposure_time: Some(0.0),
iso_sensitivity: None,
white_balance: None,
controls: None,
})
.await;
assert!(result.is_err());
assert!(result
.expect_err("exposure time error expected")
.contains("Exposure time must be between 0.0 and 10.0 seconds"));
let result = apply_camera_settings(CameraSettingsInput {
device_id: "0".to_string(),
focus_distance: None,
exposure_time: None,
iso_sensitivity: Some(MIN_ISO.saturating_sub(1)),
white_balance: None,
controls: None,
})
.await;
assert!(result.is_err());
assert!(result
.expect_err("ISO sensitivity error expected")
.contains("ISO sensitivity must be between"));
}
#[tokio::test]
async fn test_capture_burst_sequence_rejects_invalid_count() {
let config = BurstConfig {
count: 0,
interval_ms: 10,
bracketing: None,
focus_stacking: false,
auto_save: false,
save_directory: None,
};
let result = capture_burst_sequence("0".to_string(), config).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Invalid burst count"));
}
#[tokio::test]
async fn test_capture_burst_sequence_rejects_invalid_focus_stacking_count() {
let config = BurstConfig {
count: 1,
interval_ms: 10,
bracketing: None,
focus_stacking: true,
auto_save: false,
save_directory: None,
};
let result = capture_burst_sequence("0".to_string(), config).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Focus stacking requires at least 2 frames"));
}
#[tokio::test]
async fn test_capture_burst_sequence_rejects_empty_bracketing_stops() {
let config = BurstConfig {
count: 3,
interval_ms: 10,
bracketing: Some(ExposureBracketing {
stops: vec![],
base_exposure: 0.01,
}),
focus_stacking: false,
auto_save: false,
save_directory: None,
};
let result = capture_burst_sequence("0".to_string(), config).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Exposure bracketing requires at least one stop value"));
}
#[tokio::test]
async fn test_capture_burst_sequence_rejects_non_positive_base_exposure() {
let config = BurstConfig {
count: 3,
interval_ms: 10,
bracketing: Some(ExposureBracketing {
stops: vec![-1.0, 0.0, 1.0],
base_exposure: 0.0,
}),
focus_stacking: false,
auto_save: false,
save_directory: None,
};
let result = capture_burst_sequence("0".to_string(), config).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Exposure bracketing base_exposure must be greater than zero"));
}
#[tokio::test]
async fn test_capture_focus_stack_legacy_rejects_out_of_range_stack_count() {
let result = capture_focus_stack_legacy("0".to_string(), 2).await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Focus stack count must be between 3 and 20"));
}
#[tokio::test]
async fn test_save_burst_sequence_rejects_invalid_frame_data_shape() {
let invalid_frame = CameraFrame::new(vec![1, 2, 3], 16, 16, "0".to_string());
let result = save_burst_sequence(&[invalid_frame], "test_outputs/invalid_burst").await;
assert!(result.is_err());
assert!(result
.err()
.unwrap_or_default()
.contains("Failed to create image from frame data"));
}
#[tokio::test]
async fn test_get_and_set_camera_controls_with_mock() {
enable_mock_camera();
let controls = CameraControls {
auto_focus: Some(true),
brightness: Some(0.1),
..Default::default()
};
let apply = set_camera_controls("0".to_string(), controls)
.await
.expect("set controls should succeed with mock");
assert!(!apply.applied.is_empty());
let fetched = get_camera_controls("0".to_string())
.await
.expect("get controls should succeed with mock");
assert_eq!(fetched.auto_focus, Some(true));
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
#[tokio::test]
async fn test_capture_burst_sequence_success_with_mock() {
enable_mock_camera();
let config = BurstConfig {
count: 2,
interval_ms: 0,
bracketing: None,
focus_stacking: false,
auto_save: false,
save_directory: None,
};
let frames = capture_burst_sequence("0".to_string(), config)
.await
.expect("burst capture should succeed with mock");
assert_eq!(frames.len(), 2);
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
#[tokio::test]
async fn test_performance_and_capabilities_with_mock() {
enable_mock_camera();
let metrics = get_camera_performance("0".to_string())
.await
.expect("performance should succeed");
assert!(metrics.fps_actual > 0.0);
let caps = test_camera_capabilities("0".to_string())
.await
.expect("capabilities should succeed");
assert!(caps.supports.manual_focus);
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
#[tokio::test]
async fn test_wrapper_commands_hdr_focus_legacy_and_white_balance() {
enable_mock_camera();
let wb = set_white_balance("0".to_string(), WhiteBalance::Daylight)
.await
.expect("set_white_balance should succeed with mock");
assert!(!wb.applied.is_empty());
let hdr = capture_hdr_sequence("0".to_string())
.await
.expect("hdr wrapper should succeed with mock");
assert!(!hdr.is_empty());
let stack = capture_focus_stack_legacy("0".to_string(), 3)
.await
.expect("focus stack legacy should succeed with mock");
assert_eq!(stack.len(), 3);
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
}