@@ -268,44 +345,98 @@
cursor: pointer;
}
- .settings-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
- gap: 1.5rem;
+ /* Tabs */
+ .tabs {
+ display: flex;
+ gap: 0.25rem;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid #333;
+ padding-bottom: 0.5rem;
+ }
+
+ .tab {
+ padding: 0.5rem 1rem;
+ background: none;
+ border: none;
+ border-radius: 4px 4px 0 0;
+ color: #888;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ }
+
+ .tab:hover {
+ color: #e0e0e0;
+ background: #252525;
+ }
+
+ .tab.active {
+ color: #e0e0e0;
+ background: #333;
+ font-weight: 600;
+ }
+
+ /* Tab content */
+ .tab-content {
+ min-height: 200px;
}
fieldset {
- border: 1px solid #333;
- border-radius: 6px;
- padding: 1rem;
+ border: none;
+ padding: 0;
}
fieldset:disabled {
opacity: 0.5;
}
- legend {
- font-size: 0.75rem;
- font-weight: 600;
- color: #888;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- padding: 0 0.5rem;
+ .setting-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #252525;
}
- label {
+ .setting-row:last-child {
+ border-bottom: none;
+ }
+
+ .setting-row label {
display: flex;
flex-direction: column;
- gap: 0.25rem;
- margin-bottom: 0.75rem;
+ gap: 0.125rem;
}
- label > span:first-child {
+ .setting-label {
+ font-size: 0.875rem;
+ color: #e0e0e0;
+ }
+
+ .setting-hint {
font-size: 0.75rem;
- color: #888;
+ color: #666;
+ }
+
+ .setting-control {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ }
+
+ input[type='range'] {
+ width: 120px;
+ accent-color: #4f46e5;
+ }
+
+ .value {
+ font-size: 0.875rem;
+ color: #4f46e5;
+ font-weight: 600;
+ min-width: 60px;
+ text-align: right;
}
- input[type='number'],
select {
padding: 0.5rem 0.75rem;
border: 1px solid #333;
@@ -313,38 +444,23 @@
background: #0f0f0f;
color: #e0e0e0;
font-size: 0.875rem;
+ cursor: pointer;
}
- input[type='number']:focus,
select:focus {
outline: none;
border-color: #4f46e5;
}
- select {
- cursor: pointer;
+ .checkbox-row {
+ justify-content: space-between;
}
- .checkbox-label {
- flex-direction: row;
- align-items: center;
- gap: 0.5rem;
- }
-
- .checkbox-label input {
- width: 1rem;
- height: 1rem;
+ .checkbox-row input[type='checkbox'] {
+ width: 1.25rem;
+ height: 1.25rem;
accent-color: #4f46e5;
- }
-
- .checkbox-label span {
- font-size: 0.875rem;
- color: #e0e0e0;
- }
-
- .hint {
- font-size: 0.7rem;
- color: #666;
+ cursor: pointer;
}
.actions {
diff --git a/src/face_processing/types.rs b/src/face_processing/types.rs
index f7c21ef..181cb41 100644
--- a/src/face_processing/types.rs
+++ b/src/face_processing/types.rs
@@ -39,13 +39,33 @@ impl BoundingBox {
}
/// Facial landmarks (68-point model).
+///
+/// This struct requires exactly 68 landmark points following the standard
+/// dlib/iBUG 68-point face landmark format. Use `Landmarks::new()` to
+/// construct with validation.
#[derive(Debug, Clone)]
pub struct Landmarks {
- /// All 68 landmark points.
- pub points: Vec
,
+ /// All 68 landmark points (validated on construction).
+ points: [Point; 68],
}
+/// Expected number of landmark points.
+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 })
+ }
+
+ /// Get all landmark points.
+ pub fn points(&self) -> &[Point; 68] {
+ &self.points
+ }
+
/// Left eye center (average of points 36-41).
pub fn left_eye_center(&self) -> Point {
let eye_points = &self.points[36..42];
@@ -126,13 +146,49 @@ pub struct ProcessedFace {
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: String },
+ Skipped {
+ asset_id: String,
+ reason: SkipReason,
+ detail: Option,
+ },
/// Error during processing.
Error { asset_id: String, error: String },
}
diff --git a/src/job/mod.rs b/src/job/mod.rs
index 8818a9a..5dc2d35 100644
--- a/src/job/mod.rs
+++ b/src/job/mod.rs
@@ -11,10 +11,10 @@ use crate::error::{Error, Result};
use crate::face_processing::crop_face_with_intermediate;
use crate::face_processing::debug::draw_crop_debug;
use crate::face_processing::load_image_with_orientation;
-use crate::face_processing::{AssetResult, ProcessedFace};
+use crate::face_processing::{AssetResult, ProcessedFace, SkipReason};
use crate::immich_api::{Asset, FaceData, ImmichClient};
use crate::video::compile_timelapse;
-use crate::web::{AppState, JobStatus, Progress};
+use crate::web::{AppState, JobStatus, Progress, SkipStats};
use image::ImageFormat;
use std::io::Cursor;
@@ -97,6 +97,9 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
// Clear the cancellation token when done
state.clear_cancel_token().await;
+ // Get final skip stats from progress
+ let final_skip_stats = state.progress.read().await.skip_stats.clone();
+
match result {
Ok(output_path) => {
state
@@ -105,6 +108,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
completed: 0,
total: 0,
message: Some(format!("Video saved to: {}", output_path.display())),
+ skip_stats: final_skip_stats,
})
.await;
}
@@ -115,6 +119,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
completed: 0,
total: 0,
message: Some("Job cancelled by user".to_string()),
+ skip_stats: final_skip_stats,
})
.await;
}
@@ -126,6 +131,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
completed: 0,
total: 0,
message: Some(e.to_string()),
+ skip_stats: final_skip_stats,
})
.await;
}
@@ -186,6 +192,7 @@ async fn run_job_inner(
completed: 0,
total: 0,
message: Some("Fetching assets from Immich...".to_string()),
+ skip_stats: SkipStats::default(),
})
.await;
@@ -236,6 +243,7 @@ async fn run_job_inner(
completed: 0,
total,
message: Some(format!("Processing {} images...", total)),
+ skip_stats: SkipStats::default(),
})
.await;
@@ -246,6 +254,17 @@ async fn run_job_inner(
let config = Arc::new(config);
let output_dirs = Arc::new(output_dirs);
+ // Atomic counters for real-time skip statistics
+ let skip_face_too_small = Arc::new(AtomicU32::new(0));
+ let skip_eyes_closed = Arc::new(AtomicU32::new(0));
+ let skip_head_turned = Arc::new(AtomicU32::new(0));
+ let skip_too_dark = Arc::new(AtomicU32::new(0));
+ let skip_too_bright = Arc::new(AtomicU32::new(0));
+ let skip_no_face = Arc::new(AtomicU32::new(0));
+ let skip_download_failed = Arc::new(AtomicU32::new(0));
+ let skip_decode_failed = Arc::new(AtomicU32::new(0));
+ let skip_crop_failed = Arc::new(AtomicU32::new(0));
+
let mut handles = Vec::with_capacity(assets_with_faces.len());
for (asset, face_data) in assets_with_faces {
@@ -265,13 +284,25 @@ async fn run_job_inner(
let state = state.clone();
let task_cancel_token = cancel_token.clone();
+ // Clone skip counters for this task
+ let skip_face_too_small = skip_face_too_small.clone();
+ let skip_eyes_closed = skip_eyes_closed.clone();
+ let skip_head_turned = skip_head_turned.clone();
+ let skip_too_dark = skip_too_dark.clone();
+ let skip_too_bright = skip_too_bright.clone();
+ let skip_no_face = skip_no_face.clone();
+ let skip_download_failed = skip_download_failed.clone();
+ let skip_decode_failed = skip_decode_failed.clone();
+ let skip_crop_failed = skip_crop_failed.clone();
+
let handle = tokio::spawn(async move {
// Check cancellation at start of task
if task_cancel_token.is_cancelled() {
drop(permit);
return AssetResult::Skipped {
asset_id: asset.id.clone(),
- reason: "Cancelled".to_string(),
+ reason: SkipReason::Cancelled,
+ detail: None,
};
}
@@ -285,15 +316,43 @@ async fn run_job_inner(
)
.await;
- // Update progress (only if not cancelled)
+ // Update skip counters based on result
+ if let AssetResult::Skipped { reason, .. } = &result {
+ match reason {
+ SkipReason::FaceTooSmall => skip_face_too_small.fetch_add(1, Ordering::SeqCst),
+ SkipReason::EyesClosed => skip_eyes_closed.fetch_add(1, Ordering::SeqCst),
+ SkipReason::HeadTurned => skip_head_turned.fetch_add(1, Ordering::SeqCst),
+ SkipReason::TooDark => skip_too_dark.fetch_add(1, Ordering::SeqCst),
+ SkipReason::TooBright => skip_too_bright.fetch_add(1, Ordering::SeqCst),
+ SkipReason::NoFaceDetected => skip_no_face.fetch_add(1, Ordering::SeqCst),
+ SkipReason::DownloadFailed => skip_download_failed.fetch_add(1, Ordering::SeqCst),
+ SkipReason::DecodeFailed => skip_decode_failed.fetch_add(1, Ordering::SeqCst),
+ SkipReason::CropFailed => skip_crop_failed.fetch_add(1, Ordering::SeqCst),
+ SkipReason::Cancelled => 0, // Don't count cancellation
+ };
+ }
+
+ // Update progress with current skip stats (only if not cancelled)
if !task_cancel_token.is_cancelled() {
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
+ let current_skip_stats = SkipStats {
+ face_too_small: skip_face_too_small.load(Ordering::SeqCst),
+ eyes_closed: skip_eyes_closed.load(Ordering::SeqCst),
+ head_turned: skip_head_turned.load(Ordering::SeqCst),
+ too_dark: skip_too_dark.load(Ordering::SeqCst),
+ too_bright: skip_too_bright.load(Ordering::SeqCst),
+ no_face_detected: skip_no_face.load(Ordering::SeqCst),
+ download_failed: skip_download_failed.load(Ordering::SeqCst),
+ decode_failed: skip_decode_failed.load(Ordering::SeqCst),
+ crop_failed: skip_crop_failed.load(Ordering::SeqCst),
+ };
state
.update_progress(Progress {
status: JobStatus::Running,
completed: done,
total,
message: Some(format!("Processing images... ({}/{})", done, total)),
+ skip_stats: current_skip_stats,
})
.await;
}
@@ -321,19 +380,29 @@ async fn run_job_inner(
return Err(Error::Cancelled);
}
+ // Get final skip statistics from atomic counters
+ let skip_stats = SkipStats {
+ face_too_small: skip_face_too_small.load(Ordering::SeqCst),
+ eyes_closed: skip_eyes_closed.load(Ordering::SeqCst),
+ head_turned: skip_head_turned.load(Ordering::SeqCst),
+ too_dark: skip_too_dark.load(Ordering::SeqCst),
+ too_bright: skip_too_bright.load(Ordering::SeqCst),
+ no_face_detected: skip_no_face.load(Ordering::SeqCst),
+ download_failed: skip_download_failed.load(Ordering::SeqCst),
+ decode_failed: skip_decode_failed.load(Ordering::SeqCst),
+ crop_failed: skip_crop_failed.load(Ordering::SeqCst),
+ };
+
// Count results
let successful = results
.iter()
.filter(|r| matches!(r, AssetResult::Success(_)))
.count();
- let skipped = results
- .iter()
- .filter(|r| matches!(r, AssetResult::Skipped { .. }))
- .count();
let errors = results
.iter()
.filter(|r| matches!(r, AssetResult::Error { .. }))
.count();
+ let skipped = skip_stats.total();
tracing::info!(
"Processing complete: {} successful, {} skipped, {} errors",
@@ -342,6 +411,17 @@ async fn run_job_inner(
errors
);
+ // Update progress with final skip stats
+ state
+ .update_progress(Progress {
+ status: JobStatus::Running,
+ completed: total,
+ total,
+ message: Some("Processing complete, preparing video...".to_string()),
+ skip_stats: skip_stats.clone(),
+ })
+ .await;
+
if successful == 0 {
return Err(Error::ImageProcessing(
"No images were successfully processed".to_string(),
@@ -361,6 +441,7 @@ async fn run_job_inner(
completed: 0,
total: successful as u32,
message: Some("Compiling video...".to_string()),
+ skip_stats: skip_stats.clone(),
})
.await;
@@ -418,10 +499,11 @@ async fn process_single_asset(
if face_size < config.processing.face_resolution_threshold {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
- reason: format!(
- "Face too small: {}px (threshold: {}px)",
+ reason: SkipReason::FaceTooSmall,
+ detail: Some(format!(
+ "{}px (threshold: {}px)",
face_size, config.processing.face_resolution_threshold
- ),
+ )),
};
}
@@ -429,7 +511,8 @@ async fn process_single_asset(
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
- reason: "Cancelled".to_string(),
+ reason: SkipReason::Cancelled,
+ detail: None,
};
}
@@ -470,7 +553,8 @@ async fn process_single_asset(
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
- reason: "Cancelled".to_string(),
+ reason: SkipReason::Cancelled,
+ detail: None,
};
}
@@ -516,7 +600,8 @@ async fn process_single_asset(
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
- reason: "Cancelled".to_string(),
+ reason: SkipReason::Cancelled,
+ detail: None,
};
}
diff --git a/src/web/handlers.rs b/src/web/handlers.rs
index cd08e9d..022eb1c 100644
--- a/src/web/handlers.rs
+++ b/src/web/handlers.rs
@@ -3,7 +3,7 @@
use crate::config::{ProcessingConfig, VideoConfig};
use crate::immich_api::ImmichClient;
use crate::job::{run_job, JobParams};
-use crate::web::state::{AppState, JobStatus, Progress};
+use crate::web::state::{AppState, JobStatus, Progress, SkipStats};
use axum::{
body::Body,
extract::{Path, State},
@@ -33,9 +33,14 @@ pub fn create_router(state: AppState) -> Router {
"/api/people/{person_id}/thumbnail",
get(get_person_thumbnail),
)
+ .route(
+ "/api/people/{person_id}/asset-count",
+ get(get_person_asset_count),
+ )
.route("/api/progress", get(get_progress))
.route("/api/start", post(start_processing))
.route("/api/cancel", post(cancel_processing))
+ .route("/api/output", get(list_output_folders))
.route("/api/output", delete(cleanup_all_output))
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
.route("/api/config", get(get_config))
@@ -179,6 +184,72 @@ async fn get_person_thumbnail(
})
}
+/// Asset count response for a person.
+#[derive(Serialize)]
+struct AssetCountResponse {
+ total_assets: u32,
+ assets_with_faces: u32,
+}
+
+/// Get the count of assets for a person.
+async fn get_person_asset_count(
+ State(state): State,
+ Path(person_id): Path,
+) -> Result, (StatusCode, String)> {
+ let config = state.config.read().await;
+ let client = ImmichClient::new(&config.api).map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Failed to create client: {}", e),
+ )
+ })?;
+
+ // Fetch assets for this person
+ let assets = client
+ .get_assets_with_person(&person_id, None, None)
+ .await
+ .map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Failed to get assets: {}", e),
+ )
+ })?;
+
+ let total_assets = assets.len() as u32;
+
+ // Count assets that have face data for the target person
+ let assets_with_faces = assets
+ .iter()
+ .filter(|asset| {
+ asset.people.as_ref().map_or(false, |people| {
+ people.iter().any(|p| {
+ p.id == person_id && p.faces.as_ref().map_or(false, |faces| !faces.is_empty())
+ })
+ })
+ })
+ .count() as u32;
+
+ Ok(Json(AssetCountResponse {
+ total_assets,
+ assets_with_faces,
+ }))
+}
+
+/// Skip statistics for API response.
+#[derive(Serialize)]
+struct SkipStatsResponse {
+ face_too_small: u32,
+ eyes_closed: u32,
+ head_turned: u32,
+ too_dark: u32,
+ too_bright: u32,
+ no_face_detected: u32,
+ download_failed: u32,
+ decode_failed: u32,
+ crop_failed: u32,
+ total: u32,
+}
+
/// Get current progress.
#[derive(Serialize)]
struct ProgressResponse {
@@ -186,6 +257,7 @@ struct ProgressResponse {
completed: u32,
total: u32,
message: Option,
+ skip_stats: SkipStatsResponse,
}
async fn get_progress(State(state): State) -> Json {
@@ -201,11 +273,25 @@ async fn get_progress(State(state): State) -> Json {
JobStatus::Error(_) => "error",
};
+ let skip_stats = &progress.skip_stats;
+
Json(ProgressResponse {
status: status_str.to_string(),
completed: progress.completed,
total: progress.total,
message: progress.message.clone(),
+ skip_stats: SkipStatsResponse {
+ face_too_small: skip_stats.face_too_small,
+ eyes_closed: skip_stats.eyes_closed,
+ head_turned: skip_stats.head_turned,
+ too_dark: skip_stats.too_dark,
+ too_bright: skip_stats.too_bright,
+ no_face_detected: skip_stats.no_face_detected,
+ download_failed: skip_stats.download_failed,
+ decode_failed: skip_stats.decode_failed,
+ crop_failed: skip_stats.crop_failed,
+ total: skip_stats.total(),
+ },
})
}
@@ -243,6 +329,7 @@ async fn start_processing(
completed: 0,
total: 0,
message: Some("Starting...".to_string()),
+ skip_stats: SkipStats::default(),
})
.await;
@@ -293,6 +380,80 @@ async fn cancel_processing(State(state): State) -> Json
}
}
+/// Output folder information.
+#[derive(Serialize)]
+struct OutputFolderInfo {
+ name: String,
+ image_count: u32,
+ size_bytes: u64,
+ has_video: bool,
+}
+
+/// List all output folders with their stats.
+async fn list_output_folders(
+ State(state): State,
+) -> Result>, (StatusCode, String)> {
+ let config = state.config.read().await;
+ let output_dir = &config.output_dir;
+
+ let mut folders = Vec::new();
+
+ if output_dir.exists() {
+ let mut entries = tokio::fs::read_dir(output_dir).await.map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Failed to read output directory: {}", e),
+ )
+ })?;
+
+ while let Some(entry) = entries.next_entry().await.map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Failed to read directory entry: {}", e),
+ )
+ })? {
+ let path = entry.path();
+ if path.is_dir() {
+ let name = entry.file_name().to_string_lossy().to_string();
+
+ // Count images in the images subfolder
+ let images_dir = path.join("images");
+ let mut image_count = 0u32;
+ let mut size_bytes = 0u64;
+
+ if images_dir.exists() {
+ 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();
+ if img_path.extension().map_or(false, |ext| ext == "jpg") {
+ image_count += 1;
+ if let Ok(metadata) = tokio::fs::metadata(&img_path).await {
+ size_bytes += metadata.len();
+ }
+ }
+ }
+ }
+ }
+
+ // Check for video file
+ let has_video = path.join("timelapse.mp4").exists();
+
+ folders.push(OutputFolderInfo {
+ name,
+ image_count,
+ size_bytes,
+ has_video,
+ });
+ }
+ }
+ }
+
+ // Sort by name
+ folders.sort_by(|a, b| a.name.cmp(&b.name));
+
+ Ok(Json(folders))
+}
+
/// Clean up all output folders.
async fn cleanup_all_output(
State(state): State,
diff --git a/src/web/state.rs b/src/web/state.rs
index 42c95bf..f672419 100644
--- a/src/web/state.rs
+++ b/src/web/state.rs
@@ -17,6 +17,35 @@ pub enum JobStatus {
Error(String),
}
+/// Detailed statistics for skipped images.
+#[derive(Debug, Clone, Default)]
+pub struct SkipStats {
+ pub face_too_small: u32,
+ pub eyes_closed: u32,
+ pub head_turned: u32,
+ pub too_dark: u32,
+ pub too_bright: u32,
+ pub no_face_detected: u32,
+ pub download_failed: u32,
+ pub decode_failed: u32,
+ pub crop_failed: u32,
+}
+
+impl SkipStats {
+ /// Total number of skipped images.
+ pub fn total(&self) -> u32 {
+ self.face_too_small
+ + self.eyes_closed
+ + self.head_turned
+ + self.too_dark
+ + self.too_bright
+ + self.no_face_detected
+ + self.download_failed
+ + self.decode_failed
+ + self.crop_failed
+ }
+}
+
/// Progress information for a running job.
#[derive(Debug, Clone)]
pub struct Progress {
@@ -24,6 +53,7 @@ pub struct Progress {
pub completed: u32,
pub total: u32,
pub message: Option,
+ pub skip_stats: SkipStats,
}
impl Default for Progress {
@@ -33,6 +63,7 @@ impl Default for Progress {
completed: 0,
total: 0,
message: None,
+ skip_stats: SkipStats::default(),
}
}
}