Working on the output folder structure + debug visualization
This commit is contained in:
parent
3fd9f747d7
commit
95a935ee1f
5 changed files with 356 additions and 58 deletions
|
|
@ -37,6 +37,11 @@ right_eye_pos = [0.65, 0.4]
|
||||||
# Number of parallel workers (defaults to CPU count)
|
# Number of parallel workers (defaults to CPU count)
|
||||||
# max_workers = 4
|
# max_workers = 4
|
||||||
|
|
||||||
|
# Save debug visualizations showing processing steps
|
||||||
|
# Saves to: output/{person}/debug/crop/ (image with bounding box and crop region overlayed)
|
||||||
|
# Future: debug/landmarks/, debug/alignment/
|
||||||
|
keep_intermediates = false
|
||||||
|
|
||||||
[video]
|
[video]
|
||||||
# Output video framerate
|
# Output video framerate
|
||||||
framerate = 15
|
framerate = 15
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
person_id: personId,
|
person_id: personId,
|
||||||
|
person_name: personName || null,
|
||||||
date_from: dateFrom || null,
|
date_from: dateFrom || null,
|
||||||
date_to: dateTo || null,
|
date_to: dateTo || null,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ pub struct ProcessingConfig {
|
||||||
|
|
||||||
/// Number of parallel workers for processing.
|
/// Number of parallel workers for processing.
|
||||||
pub max_workers: usize,
|
pub max_workers: usize,
|
||||||
|
|
||||||
|
/// Keep intermediate images (original, cropped) for inspection.
|
||||||
|
pub keep_intermediates: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProcessingConfig {
|
impl Default for ProcessingConfig {
|
||||||
|
|
@ -103,6 +106,7 @@ impl Default for ProcessingConfig {
|
||||||
left_eye_pos: (0.35, 0.4),
|
left_eye_pos: (0.35, 0.4),
|
||||||
right_eye_pos: (0.65, 0.4),
|
right_eye_pos: (0.65, 0.4),
|
||||||
max_workers: num_cpus(),
|
max_workers: num_cpus(),
|
||||||
|
keep_intermediates: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
283
src/job/mod.rs
283
src/job/mod.rs
|
|
@ -14,9 +14,11 @@ use crate::video::compile_timelapse;
|
||||||
use crate::web::{AppState, JobStatus, Progress};
|
use crate::web::{AppState, JobStatus, Progress};
|
||||||
|
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::{DynamicImage, GenericImageView, ImageFormat};
|
use image::{DynamicImage, GenericImageView, ImageFormat, Rgb};
|
||||||
|
use imageproc::drawing::{draw_hollow_rect_mut, draw_line_segment_mut};
|
||||||
|
use imageproc::rect::Rect;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
@ -26,10 +28,60 @@ use tokio_util::sync::CancellationToken;
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct JobParams {
|
pub struct JobParams {
|
||||||
pub person_id: String,
|
pub person_id: String,
|
||||||
|
pub person_name: Option<String>,
|
||||||
pub date_from: Option<String>,
|
pub date_from: Option<String>,
|
||||||
pub date_to: Option<String>,
|
pub date_to: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Output directories for a processing job.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct OutputDirs {
|
||||||
|
/// Directory for final processed images (used for video)
|
||||||
|
images: PathBuf,
|
||||||
|
/// Path for output video
|
||||||
|
video: PathBuf,
|
||||||
|
/// Optional debug directories for visualizing processing stages.
|
||||||
|
debug: Option<DebugDirs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug output directories for visualizing each processing stage.
|
||||||
|
/// Each folder contains images with debug overlays showing what happened at that step.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct DebugDirs {
|
||||||
|
/// Original image with bounding box and crop region overlayed
|
||||||
|
crop: Option<PathBuf>,
|
||||||
|
/// Face with landmark points drawn (future)
|
||||||
|
landmarks: Option<PathBuf>,
|
||||||
|
/// Before/after alignment visualization (future)
|
||||||
|
alignment: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a safe folder name from person name or ID.
|
||||||
|
fn sanitize_folder_name(name: Option<&str>, id: &str) -> String {
|
||||||
|
let base = name.filter(|n| !n.is_empty()).unwrap_or(id);
|
||||||
|
|
||||||
|
// Replace unsafe characters with underscores
|
||||||
|
let sanitized: String = base
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Trim whitespace and limit length
|
||||||
|
let trimmed = sanitized.trim();
|
||||||
|
if trimmed.len() > 50 {
|
||||||
|
trimmed[..50].to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the complete processing pipeline.
|
/// Run the complete processing pipeline.
|
||||||
///
|
///
|
||||||
/// This is the main entry point for background job processing.
|
/// This is the main entry point for background job processing.
|
||||||
|
|
@ -83,10 +135,42 @@ async fn run_job_inner(
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
let config = state.config.read().await.clone();
|
let config = state.config.read().await.clone();
|
||||||
|
|
||||||
|
// Create person-specific output directory
|
||||||
|
let folder_name = sanitize_folder_name(params.person_name.as_deref(), ¶ms.person_id);
|
||||||
|
let person_dir = config.output_dir.join(&folder_name);
|
||||||
|
|
||||||
// Create output directories
|
// Create output directories
|
||||||
let images_dir = config.output_dir.join("images");
|
let images_dir = person_dir.join("images");
|
||||||
tokio::fs::create_dir_all(&images_dir).await?;
|
tokio::fs::create_dir_all(&images_dir).await?;
|
||||||
|
|
||||||
|
// Create debug directories if enabled
|
||||||
|
let debug = if config.processing.keep_intermediates {
|
||||||
|
let debug_base = person_dir.join("debug");
|
||||||
|
|
||||||
|
let crop_dir = debug_base.join("crop");
|
||||||
|
tokio::fs::create_dir_all(&crop_dir).await?;
|
||||||
|
|
||||||
|
// Future directories (created on-demand when those features are implemented):
|
||||||
|
// - debug_base.join("landmarks") - face with landmark points
|
||||||
|
// - debug_base.join("alignment") - before/after alignment
|
||||||
|
|
||||||
|
Some(DebugDirs {
|
||||||
|
crop: Some(crop_dir),
|
||||||
|
landmarks: None,
|
||||||
|
alignment: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_dirs = OutputDirs {
|
||||||
|
images: images_dir.clone(),
|
||||||
|
video: person_dir.join("timelapse.mp4"),
|
||||||
|
debug,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Output directory: {}", person_dir.display());
|
||||||
|
|
||||||
// Create Immich client
|
// Create Immich client
|
||||||
let client = ImmichClient::new(&config.api)?;
|
let client = ImmichClient::new(&config.api)?;
|
||||||
|
|
||||||
|
|
@ -155,7 +239,7 @@ async fn run_job_inner(
|
||||||
let semaphore = Arc::new(Semaphore::new(config.processing.max_workers));
|
let semaphore = Arc::new(Semaphore::new(config.processing.max_workers));
|
||||||
let client = Arc::new(client);
|
let client = Arc::new(client);
|
||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
let images_dir = Arc::new(images_dir);
|
let output_dirs = Arc::new(output_dirs);
|
||||||
|
|
||||||
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
||||||
|
|
||||||
|
|
@ -168,7 +252,7 @@ async fn run_job_inner(
|
||||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
let images_dir = images_dir.clone();
|
let output_dirs = output_dirs.clone();
|
||||||
let completed = completed.clone();
|
let completed = completed.clone();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let task_cancel_token = cancel_token.clone();
|
let task_cancel_token = cancel_token.clone();
|
||||||
|
|
@ -188,7 +272,7 @@ async fn run_job_inner(
|
||||||
&config,
|
&config,
|
||||||
&asset,
|
&asset,
|
||||||
&face_data,
|
&face_data,
|
||||||
&images_dir,
|
&output_dirs,
|
||||||
&task_cancel_token,
|
&task_cancel_token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -272,12 +356,11 @@ async fn run_job_inner(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let output_video = config.output_dir.join("timelapse.mp4");
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
|
|
||||||
compile_timelapse(
|
compile_timelapse(
|
||||||
&images_dir,
|
&output_dirs.images,
|
||||||
&output_video,
|
&output_dirs.video,
|
||||||
&config.video,
|
&config.video,
|
||||||
move |frame, total| {
|
move |frame, total| {
|
||||||
// Note: This callback is sync, so we can't easily update state here.
|
// Note: This callback is sync, so we can't easily update state here.
|
||||||
|
|
@ -288,9 +371,9 @@ async fn run_job_inner(
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(output_video)
|
Ok(output_dirs.video.clone())
|
||||||
} else {
|
} else {
|
||||||
Ok(images_dir.to_path_buf())
|
Ok(output_dirs.images.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +399,7 @@ async fn process_single_asset(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
asset: &Asset,
|
asset: &Asset,
|
||||||
face_data: &FaceData,
|
face_data: &FaceData,
|
||||||
output_dir: &Path,
|
output_dirs: &OutputDirs,
|
||||||
cancel_token: &CancellationToken,
|
cancel_token: &CancellationToken,
|
||||||
) -> AssetResult {
|
) -> AssetResult {
|
||||||
let asset_id = &asset.id;
|
let asset_id = &asset.id;
|
||||||
|
|
@ -362,44 +445,6 @@ async fn process_single_asset(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check after download, before CPU-intensive processing
|
|
||||||
if cancel_token.is_cancelled() {
|
|
||||||
return AssetResult::Skipped {
|
|
||||||
asset_id: asset_id.clone(),
|
|
||||||
reason: "Cancelled".to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode image
|
|
||||||
let img = match image::load_from_memory(&image_bytes) {
|
|
||||||
Ok(img) => img,
|
|
||||||
Err(e) => {
|
|
||||||
return AssetResult::Error {
|
|
||||||
asset_id: asset_id.clone(),
|
|
||||||
error: format!("Failed to decode image: {}", e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Crop and resize face
|
|
||||||
let cropped = match crop_face(&img, face_data, config.processing.resize_size) {
|
|
||||||
Ok(cropped) => cropped,
|
|
||||||
Err(e) => {
|
|
||||||
return AssetResult::Error {
|
|
||||||
asset_id: asset_id.clone(),
|
|
||||||
error: format!("Failed to crop face: {}", e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check before file I/O
|
|
||||||
if cancel_token.is_cancelled() {
|
|
||||||
return AssetResult::Skipped {
|
|
||||||
asset_id: asset_id.clone(),
|
|
||||||
reason: "Cancelled".to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate timestamp-based filename for sorting
|
// Generate timestamp-based filename for sorting
|
||||||
let timestamp = asset
|
let timestamp = asset
|
||||||
.file_created_at
|
.file_created_at
|
||||||
|
|
@ -420,11 +465,66 @@ async fn process_single_asset(
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let output_path = output_dir.join(format!("{}_{}.jpg", safe_timestamp, asset_id));
|
let filename = format!("{}_{}.jpg", safe_timestamp, asset_id);
|
||||||
|
|
||||||
// Encode and save
|
// Check after download, before CPU-intensive processing
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
return AssetResult::Skipped {
|
||||||
|
asset_id: asset_id.clone(),
|
||||||
|
reason: "Cancelled".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode image
|
||||||
|
let img = match image::load_from_memory(&image_bytes) {
|
||||||
|
Ok(img) => img,
|
||||||
|
Err(e) => {
|
||||||
|
return AssetResult::Error {
|
||||||
|
asset_id: asset_id.clone(),
|
||||||
|
error: format!("Failed to decode image: {}", e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crop face
|
||||||
|
let (_cropped_full, final_image) =
|
||||||
|
match crop_face_with_intermediate(&img, face_data, config.processing.resize_size) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
return AssetResult::Error {
|
||||||
|
asset_id: asset_id.clone(),
|
||||||
|
error: format!("Failed to crop face: {}", e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save debug visualization if enabled
|
||||||
|
if let Some(ref debug) = output_dirs.debug {
|
||||||
|
if let Some(ref crop_dir) = debug.crop {
|
||||||
|
let debug_img = draw_crop_debug(&img, face_data);
|
||||||
|
let debug_path = crop_dir.join(&filename);
|
||||||
|
let mut buffer = Cursor::new(Vec::new());
|
||||||
|
if let Err(e) = debug_img.write_to(&mut buffer, ImageFormat::Jpeg) {
|
||||||
|
tracing::warn!("Failed to encode debug image: {}", e);
|
||||||
|
} else if let Err(e) = tokio::fs::write(&debug_path, buffer.into_inner()).await {
|
||||||
|
tracing::warn!("Failed to save debug image: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check before file I/O
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
return AssetResult::Skipped {
|
||||||
|
asset_id: asset_id.clone(),
|
||||||
|
reason: "Cancelled".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path = output_dirs.images.join(&filename);
|
||||||
|
|
||||||
|
// Encode and save final image
|
||||||
let mut buffer = Cursor::new(Vec::new());
|
let mut buffer = Cursor::new(Vec::new());
|
||||||
if let Err(e) = cropped.write_to(&mut buffer, ImageFormat::Jpeg) {
|
if let Err(e) = final_image.write_to(&mut buffer, ImageFormat::Jpeg) {
|
||||||
return AssetResult::Error {
|
return AssetResult::Error {
|
||||||
asset_id: asset_id.clone(),
|
asset_id: asset_id.clone(),
|
||||||
error: format!("Failed to encode image: {}", e),
|
error: format!("Failed to encode image: {}", e),
|
||||||
|
|
@ -446,10 +546,15 @@ async fn process_single_asset(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crop and resize the face from an image using bounding box.
|
/// Crop and resize the face from an image using bounding box.
|
||||||
|
/// Returns (cropped_full_res, resized_final) for intermediate saving.
|
||||||
///
|
///
|
||||||
/// This is a simplified version that just uses the bounding box.
|
/// This is a simplified version that just uses the bounding box.
|
||||||
/// A full implementation would use facial landmarks for alignment.
|
/// A full implementation would use facial landmarks for alignment.
|
||||||
fn crop_face(img: &DynamicImage, face_data: &FaceData, output_size: u32) -> Result<DynamicImage> {
|
fn crop_face_with_intermediate(
|
||||||
|
img: &DynamicImage,
|
||||||
|
face_data: &FaceData,
|
||||||
|
output_size: u32,
|
||||||
|
) -> Result<(DynamicImage, DynamicImage)> {
|
||||||
let (img_width, img_height) = img.dimensions();
|
let (img_width, img_height) = img.dimensions();
|
||||||
|
|
||||||
// Convert normalized bounding box to pixel coordinates
|
// Convert normalized bounding box to pixel coordinates
|
||||||
|
|
@ -492,13 +597,79 @@ fn crop_face(img: &DynamicImage, face_data: &FaceData, output_size: u32) -> Resu
|
||||||
return Err(Error::ImageProcessing("Crop area too small".to_string()));
|
return Err(Error::ImageProcessing("Crop area too small".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crop the face region
|
// Crop the face region (full resolution)
|
||||||
let cropped = img.crop_imm(crop_x1, crop_y1, actual_crop_size, actual_crop_size);
|
let cropped = img.crop_imm(crop_x1, crop_y1, actual_crop_size, actual_crop_size);
|
||||||
|
|
||||||
// Resize to output size
|
// Resize to output size
|
||||||
let resized = cropped.resize_exact(output_size, output_size, FilterType::Lanczos3);
|
let resized = cropped.resize_exact(output_size, output_size, FilterType::Lanczos3);
|
||||||
|
|
||||||
Ok(resized)
|
Ok((cropped, resized))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw debug visualization showing bounding box and crop region.
|
||||||
|
/// - Red rectangle: face bounding box from Immich
|
||||||
|
/// - Green rectangle: expanded crop region used for processing
|
||||||
|
fn draw_crop_debug(img: &DynamicImage, face_data: &FaceData) -> DynamicImage {
|
||||||
|
let (img_width, img_height) = img.dimensions();
|
||||||
|
let mut rgb_img = img.to_rgb8();
|
||||||
|
|
||||||
|
// Face bounding box in pixel coordinates
|
||||||
|
let bbox_x1 = (face_data.bounding_box_x1 * img_width as f32) as i32;
|
||||||
|
let bbox_y1 = (face_data.bounding_box_y1 * img_height as f32) as i32;
|
||||||
|
let bbox_x2 = (face_data.bounding_box_x2 * img_width as f32) as i32;
|
||||||
|
let bbox_y2 = (face_data.bounding_box_y2 * img_height as f32) as i32;
|
||||||
|
|
||||||
|
let face_width = (bbox_x2 - bbox_x1) as u32;
|
||||||
|
let face_height = (bbox_y2 - bbox_y1) as u32;
|
||||||
|
|
||||||
|
// Calculate crop region (same logic as crop_face_with_intermediate)
|
||||||
|
let face_size = face_width.max(face_height);
|
||||||
|
let padding = face_size / 2;
|
||||||
|
let crop_size = face_size + padding * 2;
|
||||||
|
|
||||||
|
let center_x = (bbox_x1 + bbox_x2) / 2;
|
||||||
|
let center_y = (bbox_y1 + bbox_y2) / 2;
|
||||||
|
|
||||||
|
let crop_x1 = (center_x - crop_size as i32 / 2).max(0) as u32;
|
||||||
|
let crop_y1 = (center_y - crop_size as i32 / 2).max(0) as u32;
|
||||||
|
let crop_x1 = crop_x1.min(img_width.saturating_sub(crop_size));
|
||||||
|
let crop_y1 = crop_y1.min(img_height.saturating_sub(crop_size));
|
||||||
|
let actual_crop_size = crop_size.min(img_width - crop_x1).min(img_height - crop_y1);
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
let red = Rgb([255u8, 0, 0]);
|
||||||
|
let green = Rgb([0u8, 255, 0]);
|
||||||
|
|
||||||
|
// Draw face bounding box (red) - draw multiple times for thickness
|
||||||
|
for offset in 0..3i32 {
|
||||||
|
let rect = Rect::at(bbox_x1 - offset, bbox_y1 - offset)
|
||||||
|
.of_size(face_width + offset as u32 * 2, face_height + offset as u32 * 2);
|
||||||
|
draw_hollow_rect_mut(&mut rgb_img, rect, red);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw crop region (green) - draw multiple times for thickness
|
||||||
|
for offset in 0..3i32 {
|
||||||
|
let rect = Rect::at(crop_x1 as i32 - offset, crop_y1 as i32 - offset)
|
||||||
|
.of_size(actual_crop_size + offset as u32 * 2, actual_crop_size + offset as u32 * 2);
|
||||||
|
draw_hollow_rect_mut(&mut rgb_img, rect, green);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw crosshair at face center
|
||||||
|
let cross_size = 20i32;
|
||||||
|
draw_line_segment_mut(
|
||||||
|
&mut rgb_img,
|
||||||
|
((center_x - cross_size) as f32, center_y as f32),
|
||||||
|
((center_x + cross_size) as f32, center_y as f32),
|
||||||
|
red,
|
||||||
|
);
|
||||||
|
draw_line_segment_mut(
|
||||||
|
&mut rgb_img,
|
||||||
|
(center_x as f32, (center_y - cross_size) as f32),
|
||||||
|
(center_x as f32, (center_y + cross_size) as f32),
|
||||||
|
red,
|
||||||
|
);
|
||||||
|
|
||||||
|
DynamicImage::ImageRgb8(rgb_img)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{Html, IntoResponse, Json, Response},
|
response::{Html, IntoResponse, Json, Response},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -32,6 +32,8 @@ pub fn create_router(state: AppState) -> Router {
|
||||||
.route("/api/progress", get(get_progress))
|
.route("/api/progress", get(get_progress))
|
||||||
.route("/api/start", post(start_processing))
|
.route("/api/start", post(start_processing))
|
||||||
.route("/api/cancel", post(cancel_processing))
|
.route("/api/cancel", post(cancel_processing))
|
||||||
|
.route("/api/output", delete(cleanup_all_output))
|
||||||
|
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
|
||||||
// Serve output files (video, images)
|
// Serve output files (video, images)
|
||||||
.nest_service("/output", ServeDir::new(output_dir))
|
.nest_service("/output", ServeDir::new(output_dir))
|
||||||
// Serve frontend static files (fallback to index.html for SPA routing)
|
// Serve frontend static files (fallback to index.html for SPA routing)
|
||||||
|
|
@ -207,9 +209,9 @@ async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct StartRequest {
|
struct StartRequest {
|
||||||
person_id: String,
|
person_id: String,
|
||||||
|
person_name: Option<String>,
|
||||||
date_from: Option<String>,
|
date_from: Option<String>,
|
||||||
date_to: Option<String>,
|
date_to: Option<String>,
|
||||||
// Processing options can be added here
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -256,6 +258,7 @@ async fn start_processing(
|
||||||
// Spawn the processing job in the background
|
// Spawn the processing job in the background
|
||||||
let job_params = JobParams {
|
let job_params = JobParams {
|
||||||
person_id: request.person_id,
|
person_id: request.person_id,
|
||||||
|
person_name: request.person_name,
|
||||||
date_from: request.date_from,
|
date_from: request.date_from,
|
||||||
date_to: request.date_to,
|
date_to: request.date_to,
|
||||||
};
|
};
|
||||||
|
|
@ -288,3 +291,117 @@ async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clean up all output folders.
|
||||||
|
async fn cleanup_all_output(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<StartResponse>, (StatusCode, String)> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let output_dir = &config.output_dir;
|
||||||
|
|
||||||
|
// Check if a job is running
|
||||||
|
{
|
||||||
|
let progress = state.progress.read().await;
|
||||||
|
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Cannot cleanup while a job is running".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all contents of output directory
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut deleted_count = 0;
|
||||||
|
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() {
|
||||||
|
tokio::fs::remove_dir_all(&path).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to remove directory {}: {}", path.display(), e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
deleted_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Cleaned up {} output folders", deleted_count);
|
||||||
|
|
||||||
|
Ok(Json(StartResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Deleted {} output folders", deleted_count),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(Json(StartResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Output directory does not exist".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up a specific output folder by name.
|
||||||
|
async fn cleanup_output_folder(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(folder_name): Path<String>,
|
||||||
|
) -> Result<Json<StartResponse>, (StatusCode, String)> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
|
||||||
|
// Check if a job is running
|
||||||
|
{
|
||||||
|
let progress = state.progress.read().await;
|
||||||
|
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Cannot cleanup while a job is running".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize folder name to prevent path traversal
|
||||||
|
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_path = config.output_dir.join(&folder_name);
|
||||||
|
|
||||||
|
if !folder_path.exists() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Folder '{}' not found", folder_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !folder_path.is_dir() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("'{}' is not a directory", folder_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::remove_dir_all(&folder_path).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to remove folder: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Cleaned up output folder: {}", folder_name);
|
||||||
|
|
||||||
|
Ok(Json(StartResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Deleted folder '{}'", folder_name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue