pub use crate::platform::{
capture_with_reconnect, get_existing_camera, get_or_create_camera, reconnect_camera,
PlatformCamera,
};
use crate::quality::QualityValidator;
use crate::types::{CameraFormat, CameraFrame};
use std::fs::File;
use tauri::command;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum CaptureMode {
Single,
Sequence {
count: u32,
interval_ms: u32,
},
QualityRetry {
max_attempts: Option<u32>,
min_quality_score: Option<f32>,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CaptureOptions {
pub device_id: Option<String>,
pub format: Option<CameraFormat>,
pub mode: CaptureMode,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CaptureResult {
pub frames: Vec<CameraFrame>,
pub mode: String,
pub quality_score: Option<f32>,
}
#[command]
pub async fn capture(options: CaptureOptions) -> Result<CaptureResult, String> {
match options.mode {
CaptureMode::Single => {
let frame = capture_single_photo(options.device_id, options.format).await?;
Ok(CaptureResult {
frames: vec![frame],
mode: "single".to_string(),
quality_score: None,
})
}
CaptureMode::Sequence { count, interval_ms } => {
let device_id = options.device_id.unwrap_or_else(|| "0".to_string());
let frames =
capture_photo_sequence(device_id, count, interval_ms, options.format).await?;
Ok(CaptureResult {
frames,
mode: "sequence".to_string(),
quality_score: None,
})
}
CaptureMode::QualityRetry {
max_attempts,
min_quality_score,
} => {
let frame = capture_with_quality_retry(
options.device_id,
max_attempts,
min_quality_score,
options.format,
)
.await?;
Ok(CaptureResult {
frames: vec![frame],
mode: "quality_retry".to_string(),
quality_score: min_quality_score,
})
}
}
}
#[command]
pub async fn capture_single_photo(
device_id: Option<String>,
format: Option<CameraFormat>,
) -> Result<CameraFrame, String> {
log::info!("Capturing single photo from camera: {device_id:?}");
let camera_id = device_id.unwrap_or_else(|| "0".to_string());
let capture_format = format.unwrap_or_else(CameraFormat::standard);
match capture_with_reconnect(camera_id, capture_format, 3).await {
Ok(frame) => {
log::info!(
"Successfully captured frame: {}x{} ({} bytes)",
frame.width,
frame.height,
frame.size_bytes
);
Ok(frame)
}
Err(e) => {
log::error!("Failed to capture frame: {e}");
Err(format!("Failed to capture frame: {e}"))
}
}
}
#[command]
pub async fn capture_photo_sequence(
device_id: String,
count: u32,
interval_ms: u32,
format: Option<CameraFormat>,
) -> Result<Vec<CameraFrame>, String> {
log::info!("Capturing {count} photos from camera {device_id} with {interval_ms}ms interval");
if count == 0 || count > 20 {
return Err("Invalid photo count (must be 1-20)".to_string());
}
let capture_format = format.unwrap_or_else(CameraFormat::standard);
let camera = match get_or_create_camera(device_id.clone(), capture_format).await {
Ok(cam) => cam,
Err(e) => return Err(e.to_string()),
};
{
let camera_clone = camera.clone();
tokio::task::spawn_blocking(move || {
if let Ok(mut camera_guard) = camera_clone.lock() {
if let Err(e) = camera_guard.start_stream() {
log::warn!("Failed to start camera stream: {e}");
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?;
}
let mut frames = Vec::new();
for i in 0..count {
log::debug!("Capturing photo {} of {}", i + 1, count);
let camera_clone = camera.clone();
let frame = tokio::task::spawn_blocking(move || {
let mut camera_guard = camera_clone
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
camera_guard
.capture_frame()
.map_err(|e| format!("Failed to capture frame: {e}"))
})
.await
.map_err(|e| format!("Task join error: {e}"))??;
frames.push(frame);
if i < count - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(interval_ms))).await;
}
}
log::info!("Successfully captured {} photos", frames.len());
Ok(frames)
}
#[command]
pub async fn capture_with_quality_retry(
device_id: Option<String>,
max_attempts: Option<u32>,
min_quality_score: Option<f32>,
format: Option<CameraFormat>,
) -> Result<CameraFrame, String> {
let camera_id = device_id.unwrap_or_else(|| "0".to_string());
let attempts = max_attempts.unwrap_or(10).min(50); let quality_threshold = min_quality_score.unwrap_or(0.7).clamp(0.0, 1.0);
let capture_format = format.unwrap_or_else(CameraFormat::standard);
log::info!(
"Starting quality capture: camera={camera_id}, max_attempts={attempts}, min_quality={quality_threshold}"
);
let camera = match get_or_create_camera(camera_id.clone(), capture_format).await {
Ok(cam) => cam,
Err(e) => return Err(e.to_string()),
};
{
let camera_clone = camera.clone();
tokio::task::spawn_blocking(move || {
if let Ok(mut camera_guard) = camera_clone.lock() {
if let Err(e) = camera_guard.start_stream() {
log::warn!("Failed to start camera stream: {e}");
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?;
}
let validator = QualityValidator::default();
let mut best_frame: Option<(CameraFrame, f32)> = None;
for attempt in 1..=attempts {
let frame = {
let camera_clone = camera.clone();
tokio::task::spawn_blocking(move || {
let mut camera_guard = camera_clone
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
camera_guard.capture_frame().map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("Task join error: {e}"))??
};
let quality = validator.validate_frame(&frame);
let score = quality.score.overall;
log::debug!(
"Attempt {}/{}: quality_score={:.3} (blur={:.3}, exposure={:.3})",
attempt,
attempts,
score,
quality.score.blur,
quality.score.exposure
);
if best_frame.as_ref().is_none_or(|b| score > b.1) {
best_frame = Some((frame.clone(), score));
}
if score >= quality_threshold {
log::info!(
"Quality threshold met on attempt {attempt}: score={score:.3} >= {quality_threshold:.3}"
);
return Ok(frame);
}
if attempt < attempts {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
}
if let Some((frame, score)) = best_frame {
log::warn!(
"Quality threshold not met after {attempts} attempts. Returning best frame: score={score:.3}"
);
Ok(frame)
} else {
Err(format!(
"Failed to capture any valid frames after {attempts} attempts"
))
}
}
#[command]
pub async fn release_camera(device_id: String) -> Result<String, String> {
crate::platform::release_camera(&device_id)
.await
.map_err(|e| e.to_string())
}
#[command]
pub async fn set_frame_callback(
device_id: String,
format: Option<CameraFormat>,
) -> Result<String, String> {
log::info!("Setting frame callback for device: {device_id}");
let capture_format = format.unwrap_or_else(CameraFormat::standard);
let camera = match get_or_create_camera(device_id.clone(), capture_format).await {
Ok(cam) => cam,
Err(e) => return Err(e.to_string()),
};
let device_id_clone = device_id.clone();
let callback = move |frame: CameraFrame| {
log::debug!(
"Callback received frame from {}: {}x{} ({} bytes)",
device_id_clone,
frame.width,
frame.height,
frame.size_bytes
);
};
let camera_clone = camera.clone();
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let mut camera_guard = camera_clone
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
camera_guard
.frame_callback(callback)
.map_err(|e| format!("Failed to set frame callback for device {device_id_clone}: {e}"))
})
.await
.map_err(|e| format!("Task join error: {e}"))??;
Ok(format!("Frame callback set for device: {device_id}"))
}
#[command]
pub async fn start_camera_preview(
device_id: String,
format: Option<CameraFormat>,
) -> Result<String, String> {
log::info!("Starting camera preview for device: {device_id}");
let capture_format = format.unwrap_or_else(CameraFormat::standard);
let camera = match get_or_create_camera(device_id.clone(), capture_format).await {
Ok(cam) => cam,
Err(e) => return Err(e.to_string()),
};
let camera_clone = camera.clone();
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let mut camera_guard = camera_clone
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
match camera_guard.start_stream() {
Ok(()) => {
log::info!("Camera preview started for device: {device_id_clone}");
Ok(format!("Preview started for camera {device_id_clone}"))
}
Err(e) => {
log::error!("Failed to start camera preview: {e}");
Err(format!("Failed to start camera preview: {e}"))
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[command]
pub async fn stop_camera_preview(device_id: String) -> Result<String, String> {
log::info!("Stopping camera preview for device: {device_id}");
if let Some(camera) = get_existing_camera(&device_id).await {
let camera_clone = camera.clone();
let device_id_clone = device_id.clone();
tokio::task::spawn_blocking(move || {
let mut camera_guard = camera_clone
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
match camera_guard.stop_stream() {
Ok(()) => {
log::info!("Camera preview stopped for device: {device_id_clone}");
Ok(format!("Preview stopped for camera {device_id_clone}"))
}
Err(e) => {
log::error!("Failed to stop camera preview: {e}");
Err(format!("Failed to stop camera preview: {e}"))
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
} else {
let msg = format!("No active camera found with ID: {device_id}");
log::warn!("{msg}");
Err(msg)
}
}
#[command]
pub async fn get_capture_stats(device_id: String) -> Result<CaptureStats, String> {
if let Some(camera) = get_existing_camera(&device_id).await {
let camera_clone = camera.clone();
let device_id_clone = device_id.clone();
let stats = tokio::task::spawn_blocking(move || {
let camera_guard = camera_clone
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
let is_active = camera_guard.is_available();
let device_id_opt = camera_guard.get_device_id();
Ok::<CaptureStats, String>(CaptureStats {
device_id: device_id_clone,
is_active,
device_info: device_id_opt.map(std::string::ToString::to_string),
})
})
.await
.map_err(|e| format!("Task join error: {e}"))??;
Ok(stats)
} else {
Ok(CaptureStats {
device_id: device_id.clone(),
is_active: false,
device_info: None,
})
}
}
#[command]
pub async fn save_frame_to_disk(frame: CameraFrame, file_path: String) -> Result<String, String> {
log::info!("Saving frame {} to disk: {}", frame.id, file_path);
let img = image::RgbImage::from_vec(frame.width, frame.height, frame.data)
.ok_or_else(|| "Failed to create image from frame data".to_string())?;
let dynamic_img = image::DynamicImage::ImageRgb8(img);
let format = if file_path.to_lowercase().ends_with(".jpg")
|| file_path.to_lowercase().ends_with(".jpeg")
{
image::ImageFormat::Jpeg
} else {
image::ImageFormat::Png
};
let file_path_clone = file_path.clone();
match tokio::task::spawn_blocking(move || {
dynamic_img.save_with_format(&file_path_clone, format)
})
.await
{
Ok(Ok(())) => {
log::info!("Frame saved successfully to: {file_path}");
Ok(format!("Frame saved to {file_path}"))
}
Ok(Err(e)) => {
log::error!("Failed to save frame: {e}");
Err(format!("Failed to save frame: {e}"))
}
Err(e) => {
log::error!("Task join error: {e}");
Err("Failed to execute save task".to_string())
}
}
}
#[command]
pub async fn save_frame_compressed(
frame: CameraFrame,
file_path: String,
quality: Option<u8>,
) -> Result<String, String> {
log::info!(
"Saving compressed frame {} to disk: {}",
frame.id,
file_path
);
let quality = quality.unwrap_or(85);
let img = image::RgbImage::from_vec(frame.width, frame.height, frame.data)
.ok_or_else(|| "Failed to create image from frame data".to_string())?;
let dynamic_img = image::DynamicImage::ImageRgb8(img);
let file_path_clone = file_path.clone();
match tokio::task::spawn_blocking(move || {
let mut file = File::create(&file_path_clone)?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
dynamic_img.write_with_encoder(encoder)
})
.await
{
Ok(Ok(())) => {
log::info!("Compressed frame saved to: {file_path}");
Ok(format!("Compressed frame saved to {file_path}"))
}
Ok(Err(e)) => {
log::error!("Failed to save compressed frame: {e}");
Err(format!("Failed to save compressed frame: {e}"))
}
Err(e) => {
log::error!("Task join error: {e}");
Err("Failed to execute save task".to_string())
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CaptureStats {
pub device_id: String,
pub is_active: bool,
pub device_info: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn enable_mock_camera() {
std::env::set_var("CRABCAMERA_USE_MOCK", "1");
}
#[tokio::test]
async fn test_quality_retry_returns_best_frame() {
let result = capture_with_quality_retry(
Some("test_device".to_string()),
Some(3),
Some(0.9), None,
)
.await;
assert!(result.is_err() || result.is_ok());
}
#[tokio::test]
async fn test_capture_single_photo_and_sequence_with_mock() {
enable_mock_camera();
let single = capture_single_photo(Some("0".to_string()), None)
.await
.expect("single capture should work with mock");
assert_eq!(single.device_id, "0");
let seq = capture_photo_sequence("0".to_string(), 2, 0, None)
.await
.expect("sequence capture should work with mock");
assert_eq!(seq.len(), 2);
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
#[tokio::test]
async fn test_consolidated_capture_routes_to_correct_mode() {
enable_mock_camera();
let single = capture(CaptureOptions {
device_id: Some("0".to_string()),
format: None,
mode: CaptureMode::Single,
})
.await
.expect("consolidated single capture should work");
assert_eq!(single.frames.len(), 1);
assert_eq!(single.mode, "single");
let seq = capture(CaptureOptions {
device_id: Some("0".to_string()),
format: None,
mode: CaptureMode::Sequence {
count: 3,
interval_ms: 0,
},
})
.await
.expect("consolidated sequence capture should work");
assert_eq!(seq.frames.len(), 3);
assert_eq!(seq.mode, "sequence");
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
#[tokio::test]
async fn test_capture_sequence_validation_and_preview_controls() {
enable_mock_camera();
let invalid = capture_photo_sequence("0".to_string(), 0, 0, None).await;
assert!(invalid.is_err());
let msg = set_frame_callback("0".to_string(), None)
.await
.expect("set callback should work");
assert!(msg.contains("Frame callback set"));
let started = start_camera_preview("0".to_string(), None)
.await
.expect("start preview should work");
assert!(started.contains("Preview started"));
let stats = get_capture_stats("0".to_string())
.await
.expect("stats should be available for active camera");
assert_eq!(stats.device_id, "0");
assert!(stats.is_active);
let stopped = stop_camera_preview("0".to_string())
.await
.expect("stop preview should work");
assert!(stopped.contains("Preview stopped"));
let release = release_camera("0".to_string())
.await
.expect("release camera should work");
assert!(release.contains("released") || release.contains("No active camera"));
std::env::remove_var("CRABCAMERA_USE_MOCK");
}
#[tokio::test]
async fn test_stop_preview_and_stats_for_missing_camera() {
let missing_id = format!(
"missing-cam-{}",
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
let _ = release_camera(missing_id.clone()).await;
let missing_preview = stop_camera_preview(missing_id.clone()).await;
assert!(missing_preview.is_err());
let missing_stats = get_capture_stats(missing_id).await;
assert!(missing_stats.is_err() || missing_stats.is_ok());
}
#[test]
fn test_quality_threshold_clamping() {
assert!((1.5_f32.clamp(0.0, 1.0) - 1.0).abs() < 1e-6);
assert!(((-0.5_f32).clamp(0.0, 1.0) - 0.0).abs() < 1e-6);
assert!((0.75_f32.clamp(0.0, 1.0) - 0.75).abs() < 1e-6);
}
#[test]
fn test_max_attempts_capping() {
let attempts = 50;
assert_eq!(attempts, 50);
let attempts = 10_u32;
assert_eq!(attempts, 10);
}
#[test]
fn test_best_frame_selection_map_or() {
let mut best: Option<(String, f32)> = None;
let score_a = 0.5_f32;
assert!(best.as_ref().is_none_or(|b| score_a > b.1));
best = Some(("frame_a".to_string(), score_a));
let score_lower = 0.3_f32;
assert!(!best.as_ref().is_none_or(|b| score_lower > b.1));
let score_higher = 0.8_f32;
assert!(best.as_ref().is_none_or(|b| score_higher > b.1));
assert!(!best.as_ref().is_none_or(|b| score_a > b.1));
}
}