diff --git a/Cargo.toml b/Cargo.toml index d4f85f3..b81c658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ futures-util = "0.3" async-trait = "0.1" dotenvy = "0.15" unicode-normalization = "0.1" +urlencoding = "2.1.3" [dev-dependencies] tokio-test = "0.4" diff --git a/src/config.rs b/src/config.rs index 2e5309d..542e229 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,7 +56,7 @@ impl Default for Config { } /// Immich API connection settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct ApiConfig { /// API key for authentication. pub api_key: String, @@ -69,6 +69,16 @@ pub struct ApiConfig { pub timeout_secs: u64, } +impl std::fmt::Debug for ApiConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiConfig") + .field("api_key", &"[REDACTED]") + .field("base_url", &self.base_url) + .field("timeout_secs", &self.timeout_secs) + .finish() + } +} + fn default_timeout() -> u64 { 30 } @@ -371,11 +381,6 @@ impl AlignmentConfig { "Alignment eye_distance must be between 0.0 and 1.0 (exclusive)".to_string(), )); } - if self.eye_distance < 0.05 || self.eye_distance > 0.4 { - return Err(Error::Config( - "Alignment eye_distance should be between 0.05 and 0.4 for best results".to_string(), - )); - } Ok(()) } @@ -569,6 +574,11 @@ impl Default for ProcessingConfig { impl ProcessingConfig { /// Validate all step configurations. pub fn validate(&self) -> Result<()> { + if self.max_workers == 0 { + return Err(Error::Config( + "max_workers must be greater than 0".to_string(), + )); + } self.face_resolution.validate()?; self.blur.validate()?; self.brightness.validate()?; @@ -620,6 +630,9 @@ impl Default for VideoConfig { } } +/// Valid video codecs that can be used with FFmpeg. +const VALID_CODECS: &[&str] = &["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"]; + impl VideoConfig { /// Validate the configuration values. pub fn validate(&self) -> Result<()> { @@ -638,6 +651,13 @@ impl VideoConfig { "Video CRF must be between 0 and 51".to_string(), )); } + if !VALID_CODECS.contains(&self.codec.as_str()) { + return Err(Error::Config(format!( + "Video codec must be one of: {}, got '{}'", + VALID_CODECS.join(", "), + self.codec + ))); + } Ok(()) } } @@ -769,10 +789,15 @@ impl Config { /// Only saves `processing` and `video` sections - API credentials are /// intentionally excluded as they should come from environment variables. pub fn save_to_file(&self, path: impl AsRef) -> Result<()> { + let path = path.as_ref(); let persistable = PersistableConfig::from(self); let content = toml::to_string_pretty(&persistable) .map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?; - std::fs::write(path, content)?; + + // Write to a temp file then atomically rename to avoid partial writes + let tmp_path = path.with_extension("toml.tmp"); + std::fs::write(&tmp_path, content)?; + std::fs::rename(&tmp_path, path)?; Ok(()) } } diff --git a/src/immich_api/mod.rs b/src/immich_api/mod.rs index 368721e..92a2e39 100644 --- a/src/immich_api/mod.rs +++ b/src/immich_api/mod.rs @@ -5,6 +5,7 @@ use crate::error::{Error, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; +use urlencoding::encode as urlencode; /// Client for interacting with the Immich API. #[derive(Clone)] @@ -190,15 +191,23 @@ impl ImmichClient { break; } page += 1; + if page > 1000 { + tracing::warn!("Pagination limit reached (1000 pages), stopping asset fetch"); + break; + } } tracing::info!("Found {} assets for person {}", all_assets.len(), person_id); Ok(all_assets) } + /// Maximum download size for a single asset (100 MB). + const MAX_DOWNLOAD_SIZE: u64 = 100 * 1024 * 1024; + /// Download an asset's original image. pub async fn download_asset(&self, asset_id: &str) -> Result { - let url = format!("{}/assets/{}/original", self.base_url, asset_id); + let encoded_id = urlencode(asset_id); + let url = format!("{}/assets/{}/original", self.base_url, encoded_id); let response = self .client @@ -215,6 +224,18 @@ impl ImmichClient { ))); } + // Check Content-Length before downloading to reject unexpectedly large files + if let Some(content_length) = response.content_length() { + if content_length > Self::MAX_DOWNLOAD_SIZE { + return Err(Error::ImmichApi(format!( + "Asset {} is too large ({} bytes, max {} bytes)", + asset_id, + content_length, + Self::MAX_DOWNLOAD_SIZE + ))); + } + } + let bytes = response.bytes().await?; Ok(bytes) } @@ -249,7 +270,8 @@ impl ImmichClient { /// Get a person's thumbnail image. /// Returns the image bytes and content-type. pub async fn get_person_thumbnail(&self, person_id: &str) -> Result<(bytes::Bytes, String)> { - let url = format!("{}/people/{}/thumbnail", self.base_url, person_id); + let encoded_id = urlencode(person_id); + let url = format!("{}/people/{}/thumbnail", self.base_url, encoded_id); // tracing::debug!("Fetching thumbnail from: {}", url); let response = self diff --git a/src/job/mod.rs b/src/job/mod.rs index 6afc82f..eb25ef2 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -57,12 +57,12 @@ impl TimeIntervalTracker { // Get or create the atomic counter for this bucket let counter = { // Try read lock first - let buckets = self.buckets.read().unwrap(); + let buckets = self.buckets.read().expect("bucket lock poisoned"); if let Some(counter) = buckets.get(&key) { counter.clone() } else { drop(buckets); - let mut buckets = self.buckets.write().unwrap(); + let mut buckets = self.buckets.write().expect("bucket lock poisoned"); buckets .entry(key) .or_insert_with(|| Arc::new(AtomicU32::new(0))) @@ -84,7 +84,7 @@ impl TimeIntervalTracker { /// This is a non-mutating check used to skip processing early. pub fn is_full(&self, timestamp: &str) -> bool { let key = self.bucket_key(timestamp); - let buckets = self.buckets.read().unwrap(); + let buckets = self.buckets.read().expect("bucket lock poisoned"); if let Some(counter) = buckets.get(&key) { counter.load(Ordering::SeqCst) >= self.max_photos } else { @@ -95,7 +95,7 @@ impl TimeIntervalTracker { /// Compute the bucket key from a timestamp string. fn bucket_key(&self, timestamp: &str) -> String { // Parse date from ISO 8601 timestamp (e.g. "2024-01-15" or "2024-01-15T12:34:56Z") - let date_str = ×tamp[..timestamp.len().min(10)]; + let date_str = timestamp.get(..10).unwrap_or(timestamp); let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap_or_default(); match self.time_range { @@ -289,7 +289,8 @@ async fn run_job_inner( tracing::debug!("Pipeline steps: {:?}", pipeline.step_ids()); // Create photo limit tracker if enabled - let time_interval_tracker = TimeIntervalTracker::new(&config.processing.time_interval).map(Arc::new); + let time_interval_tracker = + TimeIntervalTracker::new(&config.processing.time_interval).map(Arc::new); // Process images in parallel with concurrency limit let completed = Arc::new(AtomicU32::new(0)); @@ -460,6 +461,7 @@ async fn run_job_inner( &output_dirs.images, &output_dirs.video, &config.video, + Some(&cancel_token), |frame, total| { // Sync callback - just log progress. State updates would require // async context or channels, which adds complexity for minimal benefit diff --git a/src/job/processing.rs b/src/job/processing.rs index 98f7572..5095fe7 100644 --- a/src/job/processing.rs +++ b/src/job/processing.rs @@ -88,7 +88,11 @@ pub async fn process_single_asset( // Early check: skip if the time slot is already full (avoids wasting processing time) if let Some(tracker) = time_interval { if tracker.is_full(×tamp) { - tracing::debug!("Asset {} skipped early: time slot already full (timestamp: {})", asset_id, timestamp); + tracing::debug!( + "Asset {} skipped early: time slot already full (timestamp: {})", + asset_id, + timestamp + ); skip_stats.increment("time_interval"); return AssetProcessResult::Skipped { asset_id: asset_id.clone(), @@ -137,7 +141,11 @@ pub async fn process_single_asset( // Check photo limit before saving if let Some(tracker) = time_interval { if !tracker.try_claim(×tamp) { - tracing::debug!("Asset {} skipped: time interval too short (timestamp: {})", asset_id, timestamp); + tracing::debug!( + "Asset {} skipped: time interval too short (timestamp: {})", + asset_id, + timestamp + ); skip_stats.increment("time_interval"); return AssetProcessResult::Skipped { asset_id, @@ -161,16 +169,32 @@ pub async fn process_single_asset( let filename = format!("{}_{}.jpg", safe_timestamp, asset_id); let output_path = output_dirs.images.join(&filename); - // Encode and save final image - let mut buffer = Cursor::new(Vec::new()); - if let Err(e) = image.write_to(&mut buffer, ImageFormat::Jpeg) { - return AssetProcessResult::Error { - asset_id, - error: format!("Failed to encode image: {}", e), - }; - } + // Encode image in a blocking task (CPU-bound JPEG compression) + let encoded = tokio::task::spawn_blocking(move || { + let mut buffer = Cursor::new(Vec::new()); + image + .write_to(&mut buffer, ImageFormat::Jpeg) + .map(|_| buffer.into_inner()) + }) + .await; - if let Err(e) = tokio::fs::write(&output_path, buffer.into_inner()).await { + let jpeg_bytes = match encoded { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + return AssetProcessResult::Error { + asset_id, + error: format!("Failed to encode image: {}", e), + }; + } + Err(e) => { + return AssetProcessResult::Error { + asset_id, + error: format!("Image encoding task panicked: {}", e), + }; + } + }; + + if let Err(e) = tokio::fs::write(&output_path, jpeg_bytes).await { return AssetProcessResult::Error { asset_id, error: format!("Failed to save image: {}", e), diff --git a/src/models/dlib_landmarks.rs b/src/models/dlib_landmarks.rs index ba2041f..e772c50 100644 --- a/src/models/dlib_landmarks.rs +++ b/src/models/dlib_landmarks.rs @@ -24,7 +24,9 @@ pub struct DlibLandmarks { predictor: Mutex, } -// Safety: The Mutex ensures thread-safe access to the inner types +// Safety: The Mutex ensures exclusive access to the inner dlib types (FaceDetector and +// LandmarkPredictor), which are not Send/Sync themselves. All access goes through +// Mutex::lock(), guaranteeing only one thread uses them at a time. unsafe impl Send for DlibLandmarks {} unsafe impl Sync for DlibLandmarks {} @@ -81,7 +83,19 @@ impl DlibLandmarks { pixels: &[u8], face_rect: Option<(i64, i64, i64, i64)>, ) -> Result { + // Verify buffer is large enough for the given dimensions + assert!( + pixels.len() >= width * height * 3, + "pixel buffer too small: need {} bytes for {}x{} RGB, got {}", + width * height * 3, + width, + height, + pixels.len() + ); + // Create image matrix for dlib + // Safety: We verified above that `pixels` has at least width*height*3 bytes, + // matching the RGB layout that ImageMatrix::new expects. let matrix = unsafe { ImageMatrix::new(width, height, pixels.as_ptr()) }; // Lock detector and predictor @@ -129,8 +143,7 @@ impl DlibLandmarks { .map(|p| Point::new(p.x() as f32, p.y() as f32)) .collect(); - Landmarks::new(points) - .ok_or_else(|| Error::Model("Could not detect 68 facial landmarks".to_string())) + Landmarks::new(points).map_err(Error::Model) } } diff --git a/src/pipeline/crop_utils.rs b/src/pipeline/crop_utils.rs index ea987d4..ee39a6a 100644 --- a/src/pipeline/crop_utils.rs +++ b/src/pipeline/crop_utils.rs @@ -7,6 +7,7 @@ use crate::immich_api::FaceData; use crate::pipeline::BoundingBox; use image::imageops::FilterType; use image::{DynamicImage, GenericImageView, RgbImage}; +use std::borrow::Cow; /// Result of cropping a face from an image. pub struct CropResult { @@ -101,7 +102,11 @@ fn crop_with_replicate_fill( y_offset: i32, size: u32, ) -> DynamicImage { - let rgb = img.to_rgb8(); + // Avoid allocation if the image is already RGB8 + let rgb: Cow<'_, RgbImage> = match img.as_rgb8() { + Some(r) => Cow::Borrowed(r), + None => Cow::Owned(img.to_rgb8()), + }; let (w, h) = (rgb.width() as i32, rgb.height() as i32); let out = RgbImage::from_fn(size, size, |px, py| { diff --git a/src/pipeline/debug_utils.rs b/src/pipeline/debug_utils.rs index 348715b..673ffd0 100644 --- a/src/pipeline/debug_utils.rs +++ b/src/pipeline/debug_utils.rs @@ -2,8 +2,34 @@ //! //! Shared functions for generating debug visualizations in pipeline steps. +use super::traits::{computed_keys, PipelineContext}; use image::{Rgb, RgbImage}; +/// Extract the face region pixel coordinates from the pipeline context. +/// +/// Uses the FACE_RECT computed value if available (face region within the cropped image), +/// otherwise falls back to the full image dimensions. +/// +/// Returns `(x1, y1, x2, y2)` clamped to valid image bounds. +pub fn face_rect_pixels( + ctx: &PipelineContext, + img_width: u32, + img_height: u32, +) -> (u32, u32, u32, u32) { + if let Some(face_rect) = ctx + .get_computed(computed_keys::FACE_RECT) + .and_then(|v| v.as_face_rect()) + { + let x1 = (face_rect.x1.max(0.0) as u32).min(img_width.saturating_sub(1)); + let y1 = (face_rect.y1.max(0.0) as u32).min(img_height.saturating_sub(1)); + let x2 = (face_rect.x2.max(0.0) as u32).min(img_width); + let y2 = (face_rect.y2.max(0.0) as u32).min(img_height); + (x1, y1, x2, y2) + } else { + (0, 0, img_width, img_height) + } +} + /// Draw simple text using a basic 5x7 pixel font. /// /// # Arguments diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index b850f32..29de469 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -21,7 +21,7 @@ mod traits; mod types; pub use crop_utils::{crop_face_with_intermediate, CropResult}; -pub use debug_utils::draw_simple_text; +pub use debug_utils::{draw_simple_text, face_rect_pixels}; pub use orientation::load_image_with_orientation; pub use traits::*; pub use types::*; diff --git a/src/pipeline/steps/alignment.rs b/src/pipeline/steps/alignment.rs index 3e41aa1..85674be 100644 --- a/src/pipeline/steps/alignment.rs +++ b/src/pipeline/steps/alignment.rs @@ -60,6 +60,17 @@ impl ProcessingStep for AlignmentStep { let left_eye = landmarks.left_eye_center(); let right_eye = landmarks.right_eye_center(); + // Guard against coincident eyes (would cause division by zero in transform) + let eye_distance = + ((right_eye.x - left_eye.x).powi(2) + (right_eye.y - left_eye.y).powi(2)).sqrt(); + if eye_distance < 1e-6 { + return StepOutcome::Skip { + ctx, + reason: "alignment".to_string(), + detail: Some("Eye positions are too close together for alignment".to_string()), + }; + } + // Calculate the transformation matrix let transform = calculate_eye_alignment_transform( left_eye, @@ -269,15 +280,21 @@ fn apply_affine_transform( let r = (p00[0] as f32 * (1.0 - dx) * (1.0 - dy) + p10[0] as f32 * dx * (1.0 - dy) + p01[0] as f32 * (1.0 - dx) * dy - + p11[0] as f32 * dx * dy) as u8; + + p11[0] as f32 * dx * dy) + .round() + .clamp(0.0, 255.0) as u8; let g = (p00[1] as f32 * (1.0 - dx) * (1.0 - dy) + p10[1] as f32 * dx * (1.0 - dy) + p01[1] as f32 * (1.0 - dx) * dy - + p11[1] as f32 * dx * dy) as u8; + + p11[1] as f32 * dx * dy) + .round() + .clamp(0.0, 255.0) as u8; let b = (p00[2] as f32 * (1.0 - dx) * (1.0 - dy) + p10[2] as f32 * dx * (1.0 - dy) + p01[2] as f32 * (1.0 - dx) * dy - + p11[2] as f32 * dx * dy) as u8; + + p11[2] as f32 * dx * dy) + .round() + .clamp(0.0, 255.0) as u8; Rgb([r, g, b]) }); diff --git a/src/pipeline/steps/blur.rs b/src/pipeline/steps/blur.rs index 35244dc..e789dac 100644 --- a/src/pipeline/steps/blur.rs +++ b/src/pipeline/steps/blur.rs @@ -9,7 +9,8 @@ use crate::config::Config; use crate::pipeline::{ - computed_keys, draw_simple_text, ComputedValue, PipelineContext, ProcessingStep, StepOutcome, + computed_keys, draw_simple_text, face_rect_pixels, ComputedValue, PipelineContext, + ProcessingStep, StepOutcome, }; use async_trait::async_trait; use image::{DynamicImage, Rgb}; @@ -115,25 +116,8 @@ impl ProcessingStep for BlurStep { Err(e) => return StepOutcome::Error { ctx, error: e }, }; - let img_width = image.width(); - let img_height = image.height(); - - // Use FACE_RECT if available (face region within the cropped image), - // otherwise analyze the entire cropped image - let (x1, y1, x2, y2) = if let Some(face_rect) = ctx - .get_computed(computed_keys::FACE_RECT) - .and_then(|v| v.as_face_rect()) - { - // Use the face rectangle within the cropped image - let x1 = (face_rect.x1.max(0.0) as u32).min(img_width.saturating_sub(1)); - let y1 = (face_rect.y1.max(0.0) as u32).min(img_height.saturating_sub(1)); - let x2 = (face_rect.x2.max(0.0) as u32).min(img_width); - let y2 = (face_rect.y2.max(0.0) as u32).min(img_height); - (x1, y1, x2, y2) - } else { - // No face rect available, use the entire cropped image - (0, 0, img_width, img_height) - }; + let (img_width, img_height) = (image.width(), image.height()); + let (x1, y1, x2, y2) = face_rect_pixels(&ctx, img_width, img_height); let gradient_magnitude = Self::calculate_gradient_magnitude_in_region(image, x1, y1, x2, y2); @@ -172,20 +156,7 @@ impl ProcessingStep for BlurStep { // Create a copy for visualization let mut debug_img = rgb.clone(); - // Draw the face bounding box (FACE_RECT if available, otherwise full image) - let (x1, y1, x2, y2) = if let Some(face_rect) = ctx - .get_computed(computed_keys::FACE_RECT) - .and_then(|v| v.as_face_rect()) - { - let x1 = (face_rect.x1.max(0.0) as u32).min(width.saturating_sub(1)); - let y1 = (face_rect.y1.max(0.0) as u32).min(height.saturating_sub(1)); - let x2 = (face_rect.x2.max(0.0) as u32).min(width); - let y2 = (face_rect.y2.max(0.0) as u32).min(height); - (x1, y1, x2, y2) - } else { - // No face rect, show that we analyzed the full cropped image - (0, 0, width, height) - }; + let (x1, y1, x2, y2) = face_rect_pixels(ctx, width, height); // Draw rectangle outline (cyan color for visibility) let rect_color = Rgb([0, 255, 255]); diff --git a/src/pipeline/steps/brightness.rs b/src/pipeline/steps/brightness.rs index 9943df3..3e6443a 100644 --- a/src/pipeline/steps/brightness.rs +++ b/src/pipeline/steps/brightness.rs @@ -6,7 +6,8 @@ use crate::config::Config; use crate::pipeline::{ - computed_keys, draw_simple_text, ComputedValue, PipelineContext, ProcessingStep, StepOutcome, + computed_keys, draw_simple_text, face_rect_pixels, ComputedValue, PipelineContext, + ProcessingStep, StepOutcome, }; use async_trait::async_trait; use image::{DynamicImage, Rgb}; @@ -34,8 +35,8 @@ impl BrightnessStep { x2: u32, y2: u32, ) -> f32 { - let rgb = image.to_rgb8(); - let (img_width, img_height) = (rgb.width(), rgb.height()); + let luma = image.to_luma8(); + let (img_width, img_height) = luma.dimensions(); // Clamp coordinates to image bounds let x1 = x1.min(img_width.saturating_sub(1)); @@ -56,14 +57,12 @@ impl BrightnessStep { return 0.0; } - // Calculate average luminance using standard RGB to luminance conversion - // Y = 0.299*R + 0.587*G + 0.114*B + // Sum luminance values from the pre-computed luma image + // (to_luma8 uses the standard Y = 0.299*R + 0.587*G + 0.114*B conversion) let mut total_luminance: u64 = 0; for y in y1..y2 { for x in x1..x2 { - let pixel = rgb.get_pixel(x, y); - let [r, g, b] = pixel.0; - total_luminance += (299 * r as u64 + 587 * g as u64 + 114 * b as u64) / 1000; + total_luminance += luma.get_pixel(x, y)[0] as u64; } } @@ -93,25 +92,8 @@ impl ProcessingStep for BrightnessStep { Err(e) => return StepOutcome::Error { ctx, error: e }, }; - let img_width = image.width(); - let img_height = image.height(); - - // Use FACE_RECT if available (face region within the cropped image), - // otherwise analyze the entire cropped image - let (x1, y1, x2, y2) = if let Some(face_rect) = ctx - .get_computed(computed_keys::FACE_RECT) - .and_then(|v| v.as_face_rect()) - { - // Use the face rectangle within the cropped image - let x1 = (face_rect.x1.max(0.0) as u32).min(img_width.saturating_sub(1)); - let y1 = (face_rect.y1.max(0.0) as u32).min(img_height.saturating_sub(1)); - let x2 = (face_rect.x2.max(0.0) as u32).min(img_width); - let y2 = (face_rect.y2.max(0.0) as u32).min(img_height); - (x1, y1, x2, y2) - } else { - // No face rect available, use the entire cropped image - (0, 0, img_width, img_height) - }; + let (img_width, img_height) = (image.width(), image.height()); + let (x1, y1, x2, y2) = face_rect_pixels(&ctx, img_width, img_height); let brightness = Self::calculate_brightness_in_region(image, x1, y1, x2, y2); @@ -157,20 +139,7 @@ impl ProcessingStep for BrightnessStep { // Create a copy for visualization let mut debug_img = rgb.clone(); - // Draw the face bounding box (FACE_RECT if available, otherwise full image) - let (x1, y1, x2, y2) = if let Some(face_rect) = ctx - .get_computed(computed_keys::FACE_RECT) - .and_then(|v| v.as_face_rect()) - { - let x1 = (face_rect.x1.max(0.0) as u32).min(width.saturating_sub(1)); - let y1 = (face_rect.y1.max(0.0) as u32).min(height.saturating_sub(1)); - let x2 = (face_rect.x2.max(0.0) as u32).min(width); - let y2 = (face_rect.y2.max(0.0) as u32).min(height); - (x1, y1, x2, y2) - } else { - // No face rect, show that we analyzed the full cropped image - (0, 0, width, height) - }; + let (x1, y1, x2, y2) = face_rect_pixels(ctx, width, height); // Draw rectangle outline (cyan color for visibility) let rect_color = Rgb([0, 255, 255]); diff --git a/src/pipeline/steps/decode.rs b/src/pipeline/steps/decode.rs index a55e962..6e803aa 100644 --- a/src/pipeline/steps/decode.rs +++ b/src/pipeline/steps/decode.rs @@ -38,6 +38,7 @@ impl ProcessingStep for DecodeImageStep { match load_image_with_orientation(raw_bytes) { Ok(image) => { ctx.image = Some(image); + ctx.raw_bytes = None; // Free raw bytes after successful decode StepOutcome::Continue(ctx) } Err(e) => StepOutcome::Skip { diff --git a/src/pipeline/steps/eye_filter.rs b/src/pipeline/steps/eye_filter.rs index 7d06c45..40bfd38 100644 --- a/src/pipeline/steps/eye_filter.rs +++ b/src/pipeline/steps/eye_filter.rs @@ -65,7 +65,7 @@ impl ProcessingStep for EyeFilterStep { StepOutcome::Continue(ctx) } - fn debug_visualize(&self, ctx: &PipelineContext, _config: &Config) -> Option { + fn debug_visualize(&self, ctx: &PipelineContext, config: &Config) -> Option { // Get landmarks for eye visualization let landmarks: &Landmarks = ctx .get_computed(computed_keys::LANDMARKS) @@ -114,12 +114,13 @@ impl ProcessingStep for EyeFilterStep { // Draw landmarks on the zoomed image (coordinates transformed to zoomed space) let points = landmarks.points(); - let left_color = if ear.left >= 0.2 { + let min_ear = config.processing.eye_filter.min_ear; + let left_color = if ear.left >= min_ear { Rgb([0, 255, 0]) } else { Rgb([255, 0, 0]) }; - let right_color = if ear.right >= 0.2 { + let right_color = if ear.right >= min_ear { Rgb([0, 255, 0]) } else { Rgb([255, 0, 0]) diff --git a/src/pipeline/steps/head_pose.rs b/src/pipeline/steps/head_pose.rs index 271bf36..62a57b4 100644 --- a/src/pipeline/steps/head_pose.rs +++ b/src/pipeline/steps/head_pose.rs @@ -235,15 +235,21 @@ impl ProcessingStep for HeadPoseStep { image.clone() }; - // Run inference on the face crop - let pose = match model.estimate(&face_image) { - Ok(p) => p, - Err(e) => { + // Run inference on the face crop (blocking CPU work offloaded from async runtime) + let pose = match tokio::task::spawn_blocking(move || model.estimate(&face_image)).await { + Ok(Ok(p)) => p, + Ok(Err(e)) => { return StepOutcome::Error { ctx, error: format!("Head pose estimation failed: {}", e), }; } + Err(e) => { + return StepOutcome::Error { + ctx, + error: format!("Head pose inference task panicked: {}", e), + }; + } }; // Store pose in computed values diff --git a/src/pipeline/traits.rs b/src/pipeline/traits.rs index 4c324c4..6681c73 100644 --- a/src/pipeline/traits.rs +++ b/src/pipeline/traits.rs @@ -259,7 +259,7 @@ pub trait ProcessingStep: Send + Sync { /// Computed value keys that this step depends on (must be present in `ctx.computed`). /// /// Return a list of keys from `computed_keys` that must be available for this step. - /// The pipeline will verify these dependencies are satisfied before execution. + /// These are informational only; the pipeline does not currently enforce them. fn dependencies(&self) -> Vec<&'static str> { vec![] } @@ -267,7 +267,7 @@ pub trait ProcessingStep: Send + Sync { /// Computed value keys that this step provides (sets in `ctx.computed`). /// /// Return a list of keys from `computed_keys` that this step will set. - /// This is used for documentation and dependency verification. + /// These are informational only; the pipeline does not currently enforce them. fn provides(&self) -> Vec<&'static str> { vec![] } diff --git a/src/pipeline/types.rs b/src/pipeline/types.rs index 3413ddc..aee1c69 100644 --- a/src/pipeline/types.rs +++ b/src/pipeline/types.rs @@ -25,6 +25,16 @@ pub struct BoundingBox { } impl BoundingBox { + /// Create a new bounding box, ensuring x1 <= x2 and y1 <= y2. + pub fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self { + Self { + x1: x1.min(x2), + y1: y1.min(y2), + x2: x1.max(x2), + y2: y1.max(y2), + } + } + pub fn width(&self) -> f32 { self.x2 - self.x1 } @@ -55,10 +65,16 @@ pub const LANDMARK_COUNT: usize = 68; impl Landmarks { /// Create a new Landmarks from a vector of points. /// - /// Returns `None` if the vector doesn't contain exactly 68 points. - pub fn new(points: Vec) -> Option { - let points: [Point; LANDMARK_COUNT] = points.try_into().ok()?; - Some(Self { points }) + /// Returns an error if the vector doesn't contain exactly 68 points. + pub fn new(points: Vec) -> Result { + let len = points.len(); + let points: [Point; LANDMARK_COUNT] = points.try_into().map_err(|_| { + format!( + "Expected exactly {} landmark points, got {}", + LANDMARK_COUNT, len + ) + })?; + Ok(Self { points }) } /// Get all landmark points. @@ -192,61 +208,3 @@ impl EyeAspectRatio { self.left >= threshold && self.right >= threshold } } - -/// Result of processing a single face. -#[derive(Debug)] -pub struct ProcessedFace { - /// The aligned and cropped face image data. - pub image_data: Vec, - /// Original asset ID. - pub asset_id: String, - /// Timestamp for sorting/naming. - pub timestamp: String, -} - -/// Reason why an image was skipped during processing. -#[derive(Debug, Clone, PartialEq)] -pub enum SkipReason { - FaceTooSmall, - EyesClosed, - HeadTurned, - TooDark, - TooBright, - NoFaceDetected, - DownloadFailed, - DecodeFailed, - CropFailed, - Cancelled, -} - -impl std::fmt::Display for SkipReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SkipReason::FaceTooSmall => write!(f, "Face too small"), - SkipReason::EyesClosed => write!(f, "Eyes closed"), - SkipReason::HeadTurned => write!(f, "Head turned"), - SkipReason::TooDark => write!(f, "Too dark"), - SkipReason::TooBright => write!(f, "Too bright"), - SkipReason::NoFaceDetected => write!(f, "No face detected"), - SkipReason::DownloadFailed => write!(f, "Download failed"), - SkipReason::DecodeFailed => write!(f, "Decode failed"), - SkipReason::CropFailed => write!(f, "Crop failed"), - SkipReason::Cancelled => write!(f, "Cancelled"), - } - } -} - -/// Processing result for a single asset. -#[derive(Debug)] -pub enum AssetResult { - /// Successfully processed. - Success(ProcessedFace), - /// Skipped (face too small, bad pose, etc.). - Skipped { - asset_id: String, - reason: SkipReason, - detail: Option, - }, - /// Error during processing. - Error { asset_id: String, error: String }, -} diff --git a/src/utils.rs b/src/utils.rs index 4d64e8d..53fd9dd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -34,13 +34,9 @@ pub fn sanitize_folder_name(name: Option<&str>, id: &str) -> String { }) .collect(); - // Trim whitespace/underscores and limit length + // Trim whitespace/underscores and limit length (char-based to avoid UTF-8 boundary panics) let trimmed = sanitized.trim_matches(|c: char| c.is_whitespace() || c == '_'); - if trimmed.len() > 50 { - trimmed[..50].to_string() - } else { - trimmed.to_string() - } + trimmed.chars().take(50).collect() } #[cfg(test)] @@ -75,7 +71,15 @@ mod tests { fn test_sanitize_folder_name_limits_length() { let long_name = "A".repeat(100); let result = sanitize_folder_name(Some(&long_name), "id"); - assert_eq!(result.len(), 50); + assert_eq!(result.chars().count(), 50); + } + + #[test] + fn test_sanitize_folder_name_limits_length_multibyte() { + // CJK characters are multi-byte in UTF-8 but pass is_alphanumeric() + let long_name = "\u{4e2d}".repeat(100); // 100 CJK characters + let result = sanitize_folder_name(Some(&long_name), "id"); + assert_eq!(result.chars().count(), 50); } #[test] diff --git a/src/video/ffmpeg.rs b/src/video/ffmpeg.rs index 61c10ad..44d0803 100644 --- a/src/video/ffmpeg.rs +++ b/src/video/ffmpeg.rs @@ -6,6 +6,7 @@ use std::path::Path; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; +use tokio_util::sync::CancellationToken; /// Compile images into a timelapse video. /// @@ -13,11 +14,13 @@ use tokio::process::Command; /// * `input_dir` - Directory containing JPEG images (sorted alphabetically for order) /// * `output_path` - Path for the output video file /// * `config` - Video configuration +/// * `cancel_token` - Optional cancellation token to kill the ffmpeg process /// * `progress_callback` - Called with (current_frame, total_frames) pub async fn compile_timelapse( input_dir: &Path, output_path: &Path, config: &VideoConfig, + cancel_token: Option<&CancellationToken>, mut progress_callback: F, ) -> Result<()> where @@ -73,6 +76,35 @@ where file.write_all(concat_content.as_bytes()).await?; file.flush().await?; + // Use a closure to ensure concat file is cleaned up on all paths + let result = run_ffmpeg( + &concat_path, + output_path, + config, + cancel_token, + image_count, + &mut progress_callback, + ) + .await; + + // Always clean up concat file, even on error + let _ = tokio::fs::remove_file(&concat_path).await; + + result +} + +/// Run the ffmpeg process and handle progress/cancellation. +async fn run_ffmpeg( + concat_path: &Path, + output_path: &Path, + config: &VideoConfig, + cancel_token: Option<&CancellationToken>, + image_count: u32, + progress_callback: &mut F, +) -> Result<()> +where + F: FnMut(u32, u32), +{ // Build ffmpeg command using concat demuxer let mut cmd = Command::new("ffmpeg"); cmd.arg("-y") // Overwrite output @@ -81,7 +113,7 @@ where .arg("-safe") .arg("0") // Allow absolute paths .arg("-i") - .arg(&concat_path) + .arg(concat_path) .arg("-c:v") .arg(&config.codec) .arg("-crf") @@ -119,15 +151,33 @@ where stderr_output }); - // Parse progress from stdout + // Parse progress from stdout, with cancellation support let mut reader = BufReader::new(stdout).lines(); - while let Some(line) = reader.next_line().await? { - if line.starts_with("frame=") { - if let Some(frame_str) = line.strip_prefix("frame=") { - if let Ok(frame) = frame_str.trim().parse::() { - // Clamp to image_count - ffmpeg can report higher values internally - let clamped_frame = frame.min(image_count); - progress_callback(clamped_frame, image_count); + + // Create a dummy token for when no cancel_token is provided + let owned_token = CancellationToken::new(); + let token = cancel_token.unwrap_or(&owned_token); + + loop { + tokio::select! { + _ = token.cancelled() => { + // Kill the ffmpeg process on cancellation + let _ = child.kill().await; + return Err(Error::Cancelled); + } + line = reader.next_line() => { + match line? { + Some(line) => { + if line.starts_with("frame=") { + if let Some(frame_str) = line.strip_prefix("frame=") { + if let Ok(frame) = frame_str.trim().parse::() { + let clamped_frame = frame.min(image_count); + progress_callback(clamped_frame, image_count); + } + } + } + } + None => break, // EOF } } } @@ -136,9 +186,6 @@ where let status = child.wait().await?; let stderr_output = stderr_handle.await.unwrap_or_default(); - // Clean up concat file - let _ = tokio::fs::remove_file(&concat_path).await; - if !status.success() { // Get last few lines of stderr for the error message let error_lines: Vec<&str> = stderr_output.lines().rev().take(10).collect(); diff --git a/src/web/handlers/config.rs b/src/web/handlers/config.rs index 1156533..b3b50df 100644 --- a/src/web/handlers/config.rs +++ b/src/web/handlers/config.rs @@ -2,7 +2,8 @@ use crate::config::{ AlignmentConfig, BlurConfig, BrightnessConfig, EyeFilterConfig, FaceResolutionConfig, - HeadPoseConfig, OutputConfig, TimeIntervalConfig, ProcessingConfig, TimestampConfig, VideoConfig, + HeadPoseConfig, OutputConfig, ProcessingConfig, TimeIntervalConfig, TimestampConfig, + VideoConfig, }; use crate::web::state::AppState; use axum::{extract::State, http::StatusCode, response::Json}; @@ -254,8 +255,8 @@ pub async fn update_config( })?; } - // Update config - { + // Update config in-memory, then save outside the lock + let config_snapshot = { let mut config = state.config.write().await; if let Some(proc) = update.processing { @@ -306,12 +307,17 @@ pub async fn update_config( } } - // Persist to file (non-blocking, best-effort) - if let Err(e) = config.save_to_file("config.toml") { - tracing::warn!("Failed to persist config to file: {}", e); - } else { - tracing::info!("Configuration updated and persisted to config.toml"); - } + config.clone() + }; // Write lock dropped here + + // Persist to file outside the lock using spawn_blocking for the fs write + let save_result = + tokio::task::spawn_blocking(move || config_snapshot.save_to_file("config.toml")).await; + + match save_result { + Ok(Ok(())) => tracing::info!("Configuration updated and persisted to config.toml"), + Ok(Err(e)) => tracing::warn!("Failed to persist config to file: {}", e), + Err(e) => tracing::warn!("Config save task panicked: {}", e), } // Return updated config diff --git a/src/web/handlers/output.rs b/src/web/handlers/output.rs index b8f48bc..fefef47 100644 --- a/src/web/handlers/output.rs +++ b/src/web/handlers/output.rs @@ -50,6 +50,19 @@ pub struct BulkDeleteResponse { pub remaining_images: u32, } +/// Check if a path exists asynchronously. +async fn path_exists(path: &std::path::Path) -> bool { + tokio::fs::metadata(path).await.is_ok() +} + +/// Check if a path is a directory asynchronously. +async fn path_is_dir(path: &std::path::Path) -> bool { + tokio::fs::metadata(path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) +} + /// List all output folders with their stats. pub async fn list_output_folders( State(state): State, @@ -59,7 +72,7 @@ pub async fn list_output_folders( let mut folders = Vec::new(); - if output_dir.exists() { + if path_exists(output_dir).await { let mut entries = tokio::fs::read_dir(output_dir).await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -74,7 +87,7 @@ pub async fn list_output_folders( ) })? { let path = entry.path(); - if path.is_dir() { + if path_is_dir(&path).await { let name = entry.file_name().to_string_lossy().to_string(); // Count images in the images subfolder @@ -82,7 +95,7 @@ pub async fn list_output_folders( let mut image_count = 0u32; let mut size_bytes = 0u64; - if images_dir.exists() { + if path_exists(&images_dir).await { if let Ok(mut img_entries) = tokio::fs::read_dir(&images_dir).await { while let Ok(Some(img_entry)) = img_entries.next_entry().await { let img_path = img_entry.path(); @@ -98,7 +111,7 @@ pub async fn list_output_folders( // Check for video file (named after the folder/person) let video_filename = format!("{}.mp4", name); - let has_video = path.join(&video_filename).exists(); + let has_video = path_exists(&path.join(&video_filename)).await; folders.push(OutputFolderInfo { name, @@ -129,7 +142,7 @@ pub async fn cleanup_all_output( let output_dir = &config.output_dir; // Remove all contents of output directory - if output_dir.exists() { + if path_exists(output_dir).await { let mut entries = tokio::fs::read_dir(output_dir).await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -145,7 +158,7 @@ pub async fn cleanup_all_output( ) })? { let path = entry.path(); - if path.is_dir() { + if path_is_dir(&path).await { tokio::fs::remove_dir_all(&path).await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -187,14 +200,14 @@ pub async fn cleanup_output_folder( let folder_path = config.output_dir.join(&folder_name); - if !folder_path.exists() { - return Err(( + let folder_meta = tokio::fs::metadata(&folder_path).await.map_err(|_| { + ( StatusCode::NOT_FOUND, format!("Folder '{}' not found", folder_name), - )); - } + ) + })?; - if !folder_path.is_dir() { + if !folder_meta.is_dir() { return Err(( StatusCode::BAD_REQUEST, format!("'{}' is not a directory", folder_name), @@ -228,7 +241,7 @@ pub async fn list_folder_images( let images_dir = config.output_dir.join(&folder_name).join("images"); - if !images_dir.exists() { + if !path_exists(&images_dir).await { return Err(( StatusCode::NOT_FOUND, format!("Folder '{}' not found or has no images", folder_name), @@ -275,11 +288,8 @@ pub async fn list_folder_images( let total_count = images.len() as u32; let video_filename = format!("{}.mp4", folder_name); - let video_exists = config - .output_dir - .join(&folder_name) - .join(&video_filename) - .exists(); + let video_exists = + path_exists(&config.output_dir.join(&folder_name).join(&video_filename)).await; Ok(Json(FolderImagesResponse { folder_name, @@ -319,7 +329,7 @@ pub async fn delete_single_image( .join("images") .join(&filename); - if !file_path.exists() { + if !path_exists(&file_path).await { return Err(( StatusCode::NOT_FOUND, format!("Image '{}' not found in folder '{}'", filename, folder_name), @@ -359,7 +369,7 @@ pub async fn delete_images_bulk( let images_dir = config.output_dir.join(&folder_name).join("images"); - if !images_dir.exists() { + if !path_exists(&images_dir).await { return Err(( StatusCode::NOT_FOUND, format!("Folder '{}' not found or has no images", folder_name), @@ -381,13 +391,9 @@ pub async fn delete_images_bulk( } let file_path = images_dir.join(filename); - if file_path.exists() { - match tokio::fs::remove_file(&file_path).await { - Ok(_) => deleted_count += 1, - Err(_) => failed_count += 1, - } - } else { - failed_count += 1; + match tokio::fs::remove_file(&file_path).await { + Ok(_) => deleted_count += 1, + Err(_) => failed_count += 1, } } @@ -420,11 +426,6 @@ pub async fn compile_folder_video( State(state): State, Path(folder_name): Path, ) -> Result, (StatusCode, String)> { - state - .ensure_no_job_running() - .await - .map_err(|e| (StatusCode::CONFLICT, e.to_string()))?; - validate_path_component(&folder_name, "folder name") .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; @@ -433,7 +434,7 @@ pub async fn compile_folder_video( let folder_path = config.output_dir.join(&folder_name); let images_dir = folder_path.join("images"); - if !images_dir.exists() { + if !path_exists(&images_dir).await { return Err(( StatusCode::NOT_FOUND, format!("Folder '{}' not found or has no images", folder_name), @@ -457,9 +458,14 @@ pub async fn compile_folder_video( )); } - // Set status to compiling video (bypasses terminal state check) - state - .reset_progress(Progress { + // Atomically check-and-set: hold write lock across both check and status update + // to prevent TOCTOU race conditions between concurrent requests. + { + let mut progress = state.progress.write().await; + if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo { + return Err((StatusCode::CONFLICT, "A job is already running".to_string())); + } + let new_progress = Progress { status: JobStatus::CompilingVideo, completed: 0, total: image_count, @@ -467,8 +473,10 @@ pub async fn compile_folder_video( skip_stats: SkipStats::default(), person_id: None, person_name: Some(folder_name.clone()), - }) - .await; + }; + *progress = new_progress.clone(); + let _ = state.progress_tx.send(new_progress); + } // Create cancellation token let cancel_token = state.create_cancel_token().await; @@ -486,6 +494,7 @@ pub async fn compile_folder_video( &images_dir, &output_path, &video_config, + Some(&cancel_token), |current, total| { // Check for cancellation if cancel_token.is_cancelled() { diff --git a/src/web/handlers/processing.rs b/src/web/handlers/processing.rs index d1f3d10..04638a0 100644 --- a/src/web/handlers/processing.rs +++ b/src/web/handlers/processing.rs @@ -45,18 +45,8 @@ pub struct ProgressResponse { pub async fn get_progress(State(state): State) -> Json { let progress = state.progress.read().await; - let status_str = match &progress.status { - JobStatus::Idle => "idle", - JobStatus::Running => "running", - JobStatus::Cancelling => "cancelling", - JobStatus::CompilingVideo => "compiling_video", - JobStatus::Completed => "completed", - JobStatus::Cancelled => "cancelled", - JobStatus::Error(_) => "error", - }; - Json(ProgressResponse { - status: status_str.to_string(), + status: progress.status.as_str().to_string(), completed: progress.completed, total: progress.total, message: progress.message.clone(), @@ -75,22 +65,48 @@ pub struct StartRequest { pub date_to: Option, } +/// Validate that a person_id looks reasonable (non-empty, reasonable length, safe characters). +fn validate_person_id(person_id: &str) -> Result<(), (StatusCode, String)> { + if person_id.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "person_id must not be empty".to_string(), + )); + } + if person_id.len() > 128 { + return Err(( + StatusCode::BAD_REQUEST, + "person_id is too long (max 128 characters)".to_string(), + )); + } + if !person_id + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(( + StatusCode::BAD_REQUEST, + "person_id contains invalid characters (only alphanumeric, hyphens, underscores allowed)" + .to_string(), + )); + } + Ok(()) +} + /// Start processing for a person. pub async fn start_processing( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { - // Check if already running + validate_person_id(&request.person_id)?; + + // Atomically check-and-set: hold write lock across both check and status update + // to prevent TOCTOU race conditions between concurrent requests. { - let progress = state.progress.read().await; + let mut progress = state.progress.write().await; if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo { return Err((StatusCode::CONFLICT, "A job is already running".to_string())); } - } - - // Reset progress with person info (bypasses terminal state check) - state - .reset_progress(Progress { + let new_progress = Progress { status: JobStatus::Running, completed: 0, total: 0, @@ -98,8 +114,10 @@ pub async fn start_processing( skip_stats: SkipStats::default(), person_id: Some(request.person_id.clone()), person_name: request.person_name.clone(), - }) - .await; + }; + *progress = new_progress.clone(); + let _ = state.progress_tx.send(new_progress); + } // Create cancellation token let cancel_token = state.create_cancel_token().await; diff --git a/src/web/handlers/ws.rs b/src/web/handlers/ws.rs index 9464784..91d70b3 100644 --- a/src/web/handlers/ws.rs +++ b/src/web/handlers/ws.rs @@ -1,7 +1,7 @@ //! WebSocket handler for real-time progress updates. use crate::web::handlers::ProgressResponse; -use crate::web::state::{AppState, JobStatus}; +use crate::web::state::AppState; use axum::{ extract::{ ws::{Message, WebSocket}, @@ -47,15 +47,24 @@ async fn handle_socket(socket: WebSocket, state: AppState) { // Send progress updates to the client let mut send_task = tokio::spawn(async move { - while let Ok(progress) = progress_rx.recv().await { - let response = progress_to_response(&progress); - match serde_json::to_string(&response) { - Ok(json) => { - if sender.send(Message::Text(json.into())).await.is_err() { - break; + loop { + match progress_rx.recv().await { + Ok(progress) => { + let response = progress_to_response(&progress); + match serde_json::to_string(&response) { + Ok(json) => { + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + Err(_) => break, } } - Err(_) => break, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::debug!("WebSocket client lagged, skipped {} messages", n); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }); @@ -75,18 +84,8 @@ async fn handle_socket(socket: WebSocket, state: AppState) { fn progress_to_response(progress: &crate::web::state::Progress) -> ProgressResponse { use crate::web::handlers::SkipStatsResponse; - let status_str = match &progress.status { - JobStatus::Idle => "idle", - JobStatus::Running => "running", - JobStatus::Cancelling => "cancelling", - JobStatus::CompilingVideo => "compiling_video", - JobStatus::Completed => "completed", - JobStatus::Cancelled => "cancelled", - JobStatus::Error(_) => "error", - }; - ProgressResponse { - status: status_str.to_string(), + status: progress.status.as_str().to_string(), completed: progress.completed, total: progress.total, message: progress.message.clone(), diff --git a/src/web/state.rs b/src/web/state.rs index bf9bb50..4beab83 100644 --- a/src/web/state.rs +++ b/src/web/state.rs @@ -28,6 +28,25 @@ impl JobStatus { JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_) ) } + + /// Get the status as a string identifier for API responses. + pub fn as_str(&self) -> &str { + match self { + JobStatus::Idle => "idle", + JobStatus::Running => "running", + JobStatus::Cancelling => "cancelling", + JobStatus::CompilingVideo => "compiling_video", + JobStatus::Completed => "completed", + JobStatus::Cancelled => "cancelled", + JobStatus::Error(_) => "error", + } + } +} + +impl std::fmt::Display for JobStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } } /// Detailed statistics for skipped images.