Fix clippy warnings and apply auto formatting.

This commit is contained in:
Arnaud_Cayrol 2026-02-05 21:55:10 +01:00
parent 06098add58
commit 6ba592a84e
21 changed files with 239 additions and 153 deletions

View file

@ -330,12 +330,14 @@ impl AlignmentConfig {
}
if self.eye_y_position < 0.2 || self.eye_y_position > 0.5 {
return Err(Error::Config(
"Alignment eye_y_position should be between 0.2 and 0.5 for best results".to_string(),
"Alignment eye_y_position should be between 0.2 and 0.5 for best results"
.to_string(),
));
}
if self.inter_eye_distance <= 0.0 {
return Err(Error::Config(
"Alignment inter_eye_distance must be greater than 0 to prevent division by zero".to_string(),
"Alignment inter_eye_distance must be greater than 0 to prevent division by zero"
.to_string(),
));
}
if self.inter_eye_distance >= 1.0 {
@ -345,7 +347,8 @@ impl AlignmentConfig {
}
if self.inter_eye_distance < 0.2 || self.inter_eye_distance > 0.5 {
return Err(Error::Config(
"Alignment inter_eye_distance should be between 0.2 and 0.5 for best results".to_string(),
"Alignment inter_eye_distance should be between 0.2 and 0.5 for best results"
.to_string(),
));
}
Ok(())
@ -509,8 +512,7 @@ impl Config {
/// Load configuration from a TOML file.
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config =
toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
let config: Config = toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
Ok(config)
}
@ -708,7 +710,9 @@ mod tests {
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join("test_config_new.toml");
config.save_to_file(&temp_path).expect("Failed to save config");
config
.save_to_file(&temp_path)
.expect("Failed to save config");
// Verify file was created and contains expected content
let content = std::fs::read_to_string(&temp_path).expect("Failed to read config");

View file

@ -38,7 +38,10 @@ async fn main() -> anyhow::Result<()> {
// Pre-load ML models to avoid loading during processing
match DlibLandmarks::init() {
Ok(_) => tracing::info!("Dlib landmarks model loaded"),
Err(e) => tracing::warn!("Dlib landmarks model not available: {} - landmark detection will be skipped", e),
Err(e) => tracing::warn!(
"Dlib landmarks model not available: {} - landmark detection will be skipped",
e
),
}
// Create application state
@ -69,7 +72,10 @@ fn load_config() -> anyhow::Result<Config> {
// Validation is optional at startup - API key might be set via web UI later
if let Err(e) = config.validate() {
tracing::warn!("Configuration incomplete: {} - some features may not work", e);
tracing::warn!(
"Configuration incomplete: {} - some features may not work",
e
);
}
Ok(config)

View file

@ -129,9 +129,8 @@ 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)
.ok_or_else(|| Error::Model("Could not detect 68 facial landmarks".to_string()))
}
}

View file

@ -94,7 +94,12 @@ pub fn crop_face_with_intermediate(
///
/// When the crop region extends past the image borders, edge pixels are repeated
/// (e.g., column -1 uses column 0, column -2 uses column 0, etc.).
fn crop_with_replicate_fill(img: &DynamicImage, x_offset: i32, y_offset: i32, size: u32) -> DynamicImage {
fn crop_with_replicate_fill(
img: &DynamicImage,
x_offset: i32,
y_offset: i32,
size: u32,
) -> DynamicImage {
let rgb = img.to_rgb8();
let (w, h) = (rgb.width() as i32, rgb.height() as i32);

View file

@ -36,31 +36,81 @@ pub fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: R
/// Get a 5x7 pixel pattern for a character.
fn get_char_pattern(ch: char) -> [u8; 7] {
match ch {
'0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
'1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'2' => [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111],
'3' => [0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110],
'4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
'5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
'6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
'7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
'8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
'9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
'.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100],
'=' => [0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000],
'-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
'°' => [0b00110, 0b01001, 0b01001, 0b00110, 0b00000, 0b00000, 0b00000],
' ' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
_ => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
'0' => [
0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110,
],
'1' => [
0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110,
],
'2' => [
0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111,
],
'3' => [
0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110,
],
'4' => [
0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010,
],
'5' => [
0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110,
],
'6' => [
0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110,
],
'7' => [
0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000,
],
'8' => [
0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110,
],
'9' => [
0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100,
],
'A' => [
0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
],
'B' => [
0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110,
],
'E' => [
0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111,
],
'H' => [
0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
],
'L' => [
0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111,
],
'P' => [
0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000,
],
'R' => [
0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001,
],
'Y' => [
0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100,
],
':' => [
0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000,
],
'.' => [
0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100,
],
'=' => [
0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000,
],
'-' => [
0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000,
],
'°' => [
0b00110, 0b01001, 0b01001, 0b00110, 0b00000, 0b00000, 0b00000,
],
' ' => [
0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000,
],
_ => [
0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000,
],
}
}

View file

@ -4,7 +4,7 @@
//! across all images in the timelapse.
use crate::config::Config;
use crate::pipeline::{computed_keys, Point, PipelineContext, ProcessingStep, StepOutcome};
use crate::pipeline::{computed_keys, PipelineContext, Point, ProcessingStep, StepOutcome};
use async_trait::async_trait;
use image::{DynamicImage, GenericImageView, Rgb};
use imageproc::geometric_transformations::{rotate_about_center, Interpolation};
@ -39,7 +39,8 @@ impl ProcessingStep for AlignmentStep {
// Landmarks should always be available since LandmarksStep is mandatory
return StepOutcome::Error {
ctx,
error: "Landmarks not available for alignment - pipeline misconfigured".to_string(),
error: "Landmarks not available for alignment - pipeline misconfigured"
.to_string(),
};
}
};
@ -101,10 +102,8 @@ impl ProcessingStep for AlignmentStep {
// Rotate eye_center around image center
let dx = eye_center.x - cx;
let dy = eye_center.y - cy;
let rotated_eye_center = Point::new(
cx + dx * cos_a + dy * sin_a,
cy - dx * sin_a + dy * cos_a,
);
let rotated_eye_center =
Point::new(cx + dx * cos_a + dy * sin_a, cy - dx * sin_a + dy * cos_a);
// Now calculate crop region to achieve the desired scale and positioning
// We want the eye center at (output_size/2, target_eye_y)
@ -116,8 +115,10 @@ impl ProcessingStep for AlignmentStep {
let crop_size = (output_size as f32 / scale) as u32;
// Crop center in source image (accounting for where we want eyes to end up)
let crop_center_x = rotated_eye_center.x - (target_center_x - output_size as f32 / 2.0) / scale;
let crop_center_y = rotated_eye_center.y + (target_eye_y - output_size as f32 / 2.0) / scale;
let crop_center_x =
rotated_eye_center.x - (target_center_x - output_size as f32 / 2.0) / scale;
let crop_center_y =
rotated_eye_center.y + (target_eye_y - output_size as f32 / 2.0) / scale;
// Calculate crop bounds
let crop_x = (crop_center_x - crop_size as f32 / 2.0).max(0.0) as u32;

View file

@ -3,7 +3,9 @@
//! Calculates average image brightness and skips images that are too dark or too bright.
use crate::config::Config;
use crate::pipeline::{computed_keys, draw_simple_text, ComputedValue, PipelineContext, ProcessingStep, StepOutcome};
use crate::pipeline::{
computed_keys, draw_simple_text, ComputedValue, PipelineContext, ProcessingStep, StepOutcome,
};
use async_trait::async_trait;
use image::{DynamicImage, Rgb};
@ -173,7 +175,7 @@ fn brightness_to_color(brightness: f32) -> Rgb<u8> {
mod tests {
use super::*;
use crate::immich_api::FaceData;
use image::{DynamicImage, RgbImage, Rgb};
use image::{DynamicImage, Rgb, RgbImage};
fn make_ctx_with_image(image: DynamicImage) -> PipelineContext {
let face_data = FaceData {
@ -225,7 +227,8 @@ mod tests {
match step.execute(ctx, &config).await {
StepOutcome::Continue(new_ctx) => {
// Should still compute brightness even when disabled
let brightness = new_ctx.get_computed(computed_keys::BRIGHTNESS)
let brightness = new_ctx
.get_computed(computed_keys::BRIGHTNESS)
.and_then(|v| v.as_float())
.unwrap();
assert!(brightness > 0.45 && brightness < 0.55);

View file

@ -5,7 +5,9 @@
use crate::config::Config;
use crate::pipeline::crop_face_with_intermediate;
use crate::pipeline::{computed_keys, BoundingBox, ComputedValue, PipelineContext, ProcessingStep, StepOutcome};
use crate::pipeline::{
computed_keys, BoundingBox, ComputedValue, PipelineContext, ProcessingStep, StepOutcome,
};
use async_trait::async_trait;
/// Crops the face region from the full image and resizes it.
@ -50,7 +52,10 @@ impl ProcessingStep for CropAndResizeStep {
};
// Store the scaled face rectangle for later steps
ctx.set_computed(computed_keys::FACE_RECT, ComputedValue::FaceRect(scaled_face_rect));
ctx.set_computed(
computed_keys::FACE_RECT,
ComputedValue::FaceRect(scaled_face_rect),
);
StepOutcome::Continue(ctx)
}
@ -67,7 +72,7 @@ impl ProcessingStep for CropAndResizeStep {
mod tests {
use super::*;
use crate::immich_api::FaceData;
use image::{DynamicImage, RgbImage, Rgb};
use image::{DynamicImage, Rgb, RgbImage};
fn make_ctx_with_image(image: DynamicImage) -> PipelineContext {
// Face in the center of a 100x100 image

View file

@ -52,8 +52,8 @@ impl ProcessingStep for DecodeImageStep {
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use crate::immich_api::FaceData;
use bytes::Bytes;
use image::{DynamicImage, RgbImage};
use std::io::Cursor;
@ -63,7 +63,9 @@ mod tests {
let dynamic = DynamicImage::ImageRgb8(img);
let mut buffer = Cursor::new(Vec::new());
dynamic.write_to(&mut buffer, image::ImageFormat::Jpeg).unwrap();
dynamic
.write_to(&mut buffer, image::ImageFormat::Jpeg)
.unwrap();
Bytes::from(buffer.into_inner())
}

View file

@ -4,7 +4,9 @@
//! from facial landmarks.
use crate::config::Config;
use crate::pipeline::{computed_keys, draw_simple_text, Landmarks, PipelineContext, ProcessingStep, StepOutcome};
use crate::pipeline::{
computed_keys, draw_simple_text, Landmarks, PipelineContext, ProcessingStep, StepOutcome,
};
use async_trait::async_trait;
use image::{DynamicImage, Rgb, RgbImage};
@ -33,7 +35,10 @@ impl ProcessingStep for EyeFilterStep {
}
// Get EAR from computed values (set by LandmarksStep)
let avg_ear = match ctx.get_computed(computed_keys::EAR).and_then(|v| v.as_float()) {
let avg_ear = match ctx
.get_computed(computed_keys::EAR)
.and_then(|v| v.as_float())
{
Some(ear) => ear,
None => {
// No EAR available - landmarks step must have been skipped
@ -85,8 +90,7 @@ impl ProcessingStep for EyeFilterStep {
Rgb([255, 0, 0]) // Red - closed
};
for i in 36..42 {
let point = &points[i];
for point in points.iter().skip(36).take(6) {
draw_cross(&mut debug_img, point.x as u32, point.y as u32, left_color);
}
@ -97,16 +101,25 @@ impl ProcessingStep for EyeFilterStep {
Rgb([255, 0, 0]) // Red - closed
};
for i in 42..48 {
let point = &points[i];
for point in points.iter().skip(42).take(6) {
draw_cross(&mut debug_img, point.x as u32, point.y as u32, right_color);
}
// Draw eye centers
let left_eye = landmarks.left_eye_center();
let right_eye = landmarks.right_eye_center();
draw_marker(&mut debug_img, left_eye.x as u32, left_eye.y as u32, Rgb([0, 255, 255]));
draw_marker(&mut debug_img, right_eye.x as u32, right_eye.y as u32, Rgb([0, 255, 255]));
draw_marker(
&mut debug_img,
left_eye.x as u32,
left_eye.y as u32,
Rgb([0, 255, 255]),
);
draw_marker(
&mut debug_img,
right_eye.x as u32,
right_eye.y as u32,
Rgb([0, 255, 255]),
);
// Draw info bar at bottom
let bar_height = 20u32;
@ -120,10 +133,7 @@ impl ProcessingStep for EyeFilterStep {
}
// Draw EAR values
let text = format!(
"L:{:.2} R:{:.2} Avg:{:.2}",
ear.left, ear.right, avg_ear
);
let text = format!("L:{:.2} R:{:.2} Avg:{:.2}", ear.left, ear.right, avg_ear);
draw_simple_text(&mut debug_img, 5, bar_y + 6, &text, Rgb([255, 255, 255]));
Some(DynamicImage::ImageRgb8(debug_img))

View file

@ -96,7 +96,9 @@ mod tests {
StepOutcome::Continue(new_ctx) => {
// Should have stored face_size
assert_eq!(
new_ctx.get_computed(computed_keys::FACE_SIZE).and_then(|v| v.as_int()),
new_ctx
.get_computed(computed_keys::FACE_SIZE)
.and_then(|v| v.as_int()),
Some(100)
);
}

View file

@ -85,9 +85,9 @@ fn draw_pose_axes(
// Y axis (green) - points down (image coordinates)
// Z axis (blue) - points out of screen (towards camera)
let axes = [
(Point3D::new(axis_length, 0.0, 0.0), Rgb([255, 0, 0])), // X - red
(Point3D::new(0.0, axis_length, 0.0), Rgb([0, 255, 0])), // Y - green
(Point3D::new(0.0, 0.0, -axis_length), Rgb([0, 0, 255])), // Z - blue (negative = towards camera)
(Point3D::new(axis_length, 0.0, 0.0), Rgb([255, 0, 0])), // X - red
(Point3D::new(0.0, axis_length, 0.0), Rgb([0, 255, 0])), // Y - green
(Point3D::new(0.0, 0.0, -axis_length), Rgb([0, 0, 255])), // Z - blue (negative = towards camera)
];
// Negate roll and pitch to convert from model convention to image coordinates
@ -333,7 +333,15 @@ impl ProcessingStep for HeadPoseStep {
let cx = (x1 + x2) as f32 / 2.0;
let cy = (y1 + y2) as f32 / 2.0;
let axis_length = ((x2 - x1).max(y2 - y1) as f32) * 0.6;
draw_pose_axes(&mut debug_img, cx, cy, axis_length, pose.yaw, pose.pitch, pose.roll);
draw_pose_axes(
&mut debug_img,
cx,
cy,
axis_length,
pose.yaw,
pose.pitch,
pose.roll,
);
}
// Draw text background bar at bottom for pose values

View file

@ -4,7 +4,9 @@
use crate::config::Config;
use crate::models::DlibLandmarks;
use crate::pipeline::{computed_keys, ComputedValue, Landmarks, PipelineContext, ProcessingStep, StepOutcome};
use crate::pipeline::{
computed_keys, ComputedValue, Landmarks, PipelineContext, ProcessingStep, StepOutcome,
};
use async_trait::async_trait;
use tokio::task;
@ -90,7 +92,10 @@ impl ProcessingStep for LandmarksStep {
ctx.set_computed(computed_keys::EAR, ComputedValue::Float(avg_ear));
// Store landmarks
ctx.set_computed(computed_keys::LANDMARKS, ComputedValue::Landmarks(Box::new(landmarks)));
ctx.set_computed(
computed_keys::LANDMARKS,
ComputedValue::Landmarks(Box::new(landmarks)),
);
tracing::trace!(
"Landmarks detected: EAR left={:.3}, right={:.3}, avg={:.3}",

View file

@ -4,8 +4,8 @@
//! extensible image processing pipelines.
use crate::config::Config;
use crate::pipeline::types::{BoundingBox, HeadPose, Landmarks};
use crate::immich_api::FaceData;
use crate::pipeline::types::{BoundingBox, HeadPose, Landmarks};
use async_trait::async_trait;
use bytes::Bytes;
use image::DynamicImage;
@ -41,10 +41,7 @@ pub enum StepOutcome {
detail: Option<String>,
},
/// An error occurred during processing. Context is preserved for debug visualization.
Error {
ctx: PipelineContext,
error: String,
},
Error { ctx: PipelineContext, error: String },
}
/// Values computed by pipeline steps that can be shared with subsequent steps.
@ -232,8 +229,14 @@ impl PipelineContext {
/// * `step_id` - The identifier of the step that generated this image
/// * `image` - The debug visualization image
/// * `passed` - Whether the step passed (true) or failed/skipped (false)
pub fn add_debug_image(&mut self, step_id: impl Into<String>, image: DynamicImage, passed: bool) {
self.debug_images.insert(step_id.into(), DebugImage::new(image, passed));
pub fn add_debug_image(
&mut self,
step_id: impl Into<String>,
image: DynamicImage,
passed: bool,
) {
self.debug_images
.insert(step_id.into(), DebugImage::new(image, passed));
}
}
@ -328,21 +331,20 @@ mod tests {
image_height: 1080,
};
let mut ctx = PipelineContext::new(
"asset123".to_string(),
"2024-01-15".to_string(),
face_data,
);
let mut ctx =
PipelineContext::new("asset123".to_string(), "2024-01-15".to_string(), face_data);
ctx.set_computed(computed_keys::BRIGHTNESS, ComputedValue::Float(0.65));
ctx.set_computed(computed_keys::FACE_SIZE, ComputedValue::Int(150));
assert_eq!(
ctx.get_computed(computed_keys::BRIGHTNESS).and_then(|v| v.as_float()),
ctx.get_computed(computed_keys::BRIGHTNESS)
.and_then(|v| v.as_float()),
Some(0.65)
);
assert_eq!(
ctx.get_computed(computed_keys::FACE_SIZE).and_then(|v| v.as_int()),
ctx.get_computed(computed_keys::FACE_SIZE)
.and_then(|v| v.as_int()),
Some(150)
);
assert!(ctx.get_computed("nonexistent").is_none());

View file

@ -19,10 +19,7 @@ pub fn sanitize_folder_name(name: Option<&str>, id: &str) -> String {
let base = name.filter(|n| !n.is_empty()).unwrap_or(id);
// Remove accents by decomposing to NFD and filtering combining marks
let without_accents: String = base
.nfd()
.filter(|c| !is_combining_mark(*c))
.collect();
let without_accents: String = base.nfd().filter(|c| !is_combining_mark(*c)).collect();
// Convert to lowercase and replace unsafe characters with underscores
let sanitized: String = without_accents
@ -71,10 +68,7 @@ mod tests {
#[test]
fn test_sanitize_folder_name_trims_whitespace() {
assert_eq!(
sanitize_folder_name(Some(" John Doe "), "id"),
"john_doe"
);
assert_eq!(sanitize_folder_name(Some(" John Doe "), "id"), "john_doe");
}
#[test]
@ -99,6 +93,9 @@ mod tests {
assert_eq!(sanitize_folder_name(Some("Peña"), "id"), "pena");
// Mixed accents
assert_eq!(sanitize_folder_name(Some("Élève Français"), "id"), "eleve_francais");
assert_eq!(
sanitize_folder_name(Some("Élève Français"), "id"),
"eleve_francais"
);
}
}

View file

@ -94,12 +94,9 @@ where
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
Error::FFmpeg(format!(
"Failed to spawn ffmpeg (is it installed?): {}",
e
))
})?;
let mut child = cmd
.spawn()
.map_err(|e| Error::FFmpeg(format!("Failed to spawn ffmpeg (is it installed?): {}", e)))?;
// Capture stdout for progress and stderr for errors
let stdout = child
@ -149,8 +146,7 @@ where
return Err(Error::FFmpeg(format!(
"ffmpeg exited with status {}\n{}",
status,
error_detail
status, error_detail
)));
}

View file

@ -192,11 +192,7 @@ fn validate_video_config(vid: &VideoConfigUpdate) -> Result<(), ValidationError>
if !valid_codecs.contains(&v.as_str()) {
return Err(ValidationError::new(
"video.codec",
format!(
"must be one of: {}, got '{}'",
valid_codecs.join(", "),
v
),
format!("must be one of: {}, got '{}'", valid_codecs.join(", "), v),
));
}
}

View file

@ -395,11 +395,7 @@ pub async fn delete_images_bulk(
let mut remaining_images = 0u32;
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry
.path()
.extension()
.is_some_and(|ext| ext == "jpg")
{
if entry.path().extension().is_some_and(|ext| ext == "jpg") {
remaining_images += 1;
}
}
@ -448,11 +444,7 @@ pub async fn compile_folder_video(
let mut image_count = 0u32;
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry
.path()
.extension()
.is_some_and(|ext| ext == "jpg")
{
if entry.path().extension().is_some_and(|ext| ext == "jpg") {
image_count += 1;
}
}
@ -490,29 +482,34 @@ pub async fn compile_folder_video(
// Spawn the compilation job in the background
tokio::spawn(async move {
let result = crate::video::compile_timelapse(&images_dir, &output_path, &video_config, |current, total| {
// Check for cancellation
if cancel_token.is_cancelled() {
return;
}
let result = crate::video::compile_timelapse(
&images_dir,
&output_path,
&video_config,
|current, total| {
// Check for cancellation
if cancel_token.is_cancelled() {
return;
}
// Update progress (fire and forget since we're in sync callback)
let state_clone = job_state.clone();
let folder_clone = folder_name_clone.clone();
tokio::spawn(async move {
state_clone
.update_progress(Progress {
status: JobStatus::CompilingVideo,
completed: current,
total,
message: Some(format!("Compiling video for {}...", folder_clone)),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_clone),
})
.await;
});
})
// Update progress (fire and forget since we're in sync callback)
let state_clone = job_state.clone();
let folder_clone = folder_name_clone.clone();
tokio::spawn(async move {
state_clone
.update_progress(Progress {
status: JobStatus::CompilingVideo,
completed: current,
total,
message: Some(format!("Compiling video for {}...", folder_clone)),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_clone),
})
.await;
});
},
)
.await;
// Update final status

View file

@ -2,11 +2,7 @@
use crate::job::{run_job, JobParams};
use crate::web::state::{AppState, JobStatus, Progress, SkipStats};
use axum::{
extract::State,
http::StatusCode,
response::Json,
};
use axum::{extract::State, http::StatusCode, response::Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

View file

@ -12,10 +12,7 @@ use axum::{
use futures_util::{stream::StreamExt, SinkExt};
/// WebSocket upgrade handler.
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, state))
}

View file

@ -23,7 +23,10 @@ impl JobStatus {
/// Returns true if this is a terminal status (job has finished).
/// Terminal statuses should not be overwritten by non-terminal statuses.
pub fn is_terminal(&self) -> bool {
matches!(self, JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_))
matches!(
self,
JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_)
)
}
}
@ -221,8 +224,7 @@ impl AppState {
// Update progress to show cancelling state immediately.
// Only change to Cancelling if in an active (non-terminal) state.
let mut progress = self.progress.write().await;
if progress.status == JobStatus::Running
|| progress.status == JobStatus::CompilingVideo
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo
{
progress.status = JobStatus::Cancelling;
progress.message = Some("Cancelling...".to_string());
@ -273,7 +275,10 @@ impl std::error::Error for JobRunningError {}
/// Validate that a path component (folder name or filename) is safe.
///
/// Returns an error if the path contains traversal sequences or separators.
pub fn validate_path_component(name: &str, component_type: &str) -> Result<(), PathValidationError> {
pub fn validate_path_component(
name: &str,
component_type: &str,
) -> Result<(), PathValidationError> {
if name.contains("..") || name.contains('/') || name.contains('\\') {
Err(PathValidationError {
component_type: component_type.to_string(),