Working on making each image processing step modularized and easy to expand

This commit is contained in:
Arnaud_Cayrol 2026-01-31 15:24:22 +01:00
parent 41d50167eb
commit d7b0238c5b
9 changed files with 546 additions and 294 deletions

View file

@ -10,36 +10,37 @@
let saveMessage = $state(null);
let activeTab = $state('face');
// Config state
// Config state - matches backend nested structure
let config = $state({
processing: {
resize_size: 512,
face_resolution_threshold: 80,
pose_threshold: 25.0,
ear_threshold: 0.2,
max_workers: 4,
keep_intermediates: false,
brightness: null,
face_resolution: {
enabled: true,
min_size: 80,
},
brightness: {
enabled: false,
min_brightness: 0.1,
max_brightness: 0.95,
},
output: {
size: 512,
keep_intermediates: false,
},
alignment: {
enabled: false,
left_eye_pos: [0.35, 0.4],
right_eye_pos: [0.65, 0.4],
},
},
video: {
framerate: 15,
enabled: true,
framerate: 15,
codec: 'libx264',
crf: 23,
},
});
// Helper to ensure brightness config exists
function ensureBrightnessConfig() {
if (!config.processing.brightness) {
config.processing.brightness = {
enabled: false,
min_brightness: 0.1,
max_brightness: 0.95,
};
}
}
async function loadConfig() {
loading = true;
error = null;
@ -81,17 +82,29 @@
// Default configuration values (must match backend defaults in config.rs)
const DEFAULT_CONFIG = {
processing: {
resize_size: 512,
face_resolution_threshold: 80,
pose_threshold: 25.0,
ear_threshold: 0.2,
max_workers: 4,
keep_intermediates: false,
brightness: null,
face_resolution: {
enabled: true,
min_size: 80,
},
brightness: {
enabled: false,
min_brightness: 0.1,
max_brightness: 0.95,
},
output: {
size: 512,
keep_intermediates: false,
},
alignment: {
enabled: false,
left_eye_pos: [0.35, 0.4],
right_eye_pos: [0.65, 0.4],
},
},
video: {
framerate: 15,
enabled: true,
framerate: 15,
codec: 'libx264',
crf: 23,
},
@ -159,72 +172,47 @@
<div class="tab-content">
{#if activeTab === 'face'}
<fieldset disabled={disabled || saving}>
<div class="setting-row">
<label>
<span class="setting-label">Min Face Size</span>
<span class="setting-hint">Minimum face resolution in pixels</span>
</label>
<div class="setting-control">
<!-- Face Resolution Section -->
<div class="setting-section">
<div class="section-header">
<span class="section-title">Face Resolution</span>
<input
type="range"
bind:value={config.processing.face_resolution_threshold}
min="20"
max="200"
step="10"
type="checkbox"
bind:checked={config.processing.face_resolution.enabled}
/>
<span class="value">{config.processing.face_resolution_threshold}px</span>
</div>
{#if config.processing.face_resolution.enabled}
<div class="setting-row sub-setting">
<label>
<span class="setting-label">Min Face Size</span>
<span class="setting-hint">Minimum face resolution in pixels</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.processing.face_resolution.min_size}
min="20"
max="200"
step="10"
/>
<span class="value">{config.processing.face_resolution.min_size}px</span>
</div>
</div>
{/if}
</div>
<div class="setting-row">
<label>
<span class="setting-label">Eye Aspect Ratio</span>
<span class="setting-hint">Threshold for eye openness detection</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.processing.ear_threshold}
min="0.1"
max="0.5"
step="0.05"
/>
<span class="value">{config.processing.ear_threshold.toFixed(2)}</span>
</div>
</div>
<div class="setting-row">
<label>
<span class="setting-label">Max Head Rotation</span>
<span class="setting-hint">Maximum yaw angle in degrees</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.processing.pose_threshold}
min="5"
max="90"
step="5"
/>
<span class="value">{config.processing.pose_threshold}°</span>
</div>
</div>
<!-- Brightness validation section -->
<!-- Brightness Section -->
<div class="setting-section">
<div class="section-header">
<span class="section-title">Brightness Filter</span>
<input
type="checkbox"
checked={config.processing.brightness?.enabled ?? false}
onchange={(e) => {
ensureBrightnessConfig();
config.processing.brightness.enabled = e.target.checked;
}}
bind:checked={config.processing.brightness.enabled}
/>
</div>
{#if config.processing.brightness?.enabled}
{#if config.processing.brightness.enabled}
<div class="setting-row sub-setting">
<label>
<span class="setting-label">Min Brightness</span>
@ -233,16 +221,12 @@
<div class="setting-control">
<input
type="range"
value={config.processing.brightness?.min_brightness ?? 0.1}
oninput={(e) => {
ensureBrightnessConfig();
config.processing.brightness.min_brightness = parseFloat(e.target.value);
}}
bind:value={config.processing.brightness.min_brightness}
min="0"
max="0.5"
step="0.05"
/>
<span class="value">{(config.processing.brightness?.min_brightness ?? 0.1).toFixed(2)}</span>
<span class="value">{config.processing.brightness.min_brightness.toFixed(2)}</span>
</div>
</div>
@ -254,16 +238,12 @@
<div class="setting-control">
<input
type="range"
value={config.processing.brightness?.max_brightness ?? 0.95}
oninput={(e) => {
ensureBrightnessConfig();
config.processing.brightness.max_brightness = parseFloat(e.target.value);
}}
bind:value={config.processing.brightness.max_brightness}
min="0.5"
max="1.0"
step="0.05"
/>
<span class="value">{(config.processing.brightness?.max_brightness ?? 0.95).toFixed(2)}</span>
<span class="value">{config.processing.brightness.max_brightness.toFixed(2)}</span>
</div>
</div>
{/if}
@ -279,12 +259,12 @@
<div class="setting-control">
<input
type="range"
bind:value={config.processing.resize_size}
bind:value={config.processing.output.size}
min="128"
max="1024"
step="64"
/>
<span class="value">{config.processing.resize_size}px</span>
<span class="value">{config.processing.output.size}px</span>
</div>
</div>
@ -310,7 +290,7 @@
<span class="setting-label">Keep Debug Images</span>
<span class="setting-hint">Save intermediate processing visualizations</span>
</label>
<input type="checkbox" bind:checked={config.processing.keep_intermediates} />
<input type="checkbox" bind:checked={config.processing.output.keep_intermediates} />
</div>
</fieldset>
{:else if activeTab === 'video'}
@ -565,9 +545,15 @@
/* Settings section with header */
.setting-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #333;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #333;
}
.setting-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.section-header {

View file

@ -4,10 +4,26 @@
//! 1. TOML file (config.toml)
//! 2. Environment variables (prefixed with IMMICH_)
//! 3. Programmatic overrides
//!
//! ## Step Configuration Pattern
//!
//! Each pipeline step has its own optional sub-config with an `enabled` flag:
//! ```ignore
//! processing:
//! face_resolution:
//! enabled: true
//! min_size: 80
//! brightness:
//! enabled: false
//! min_brightness: 0.1
//! max_brightness: 0.95
//! ```
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::error::{Error, Result};
/// Main configuration struct.
///
/// This is the single source of truth for all configuration.
@ -67,37 +83,40 @@ impl Default for ApiConfig {
}
}
/// Face processing parameters.
// ============================================================================
// Step Configurations
// ============================================================================
/// Face resolution validation configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProcessingConfig {
/// Output image size (width and height in pixels).
pub resize_size: u32,
pub struct FaceResolutionConfig {
/// Whether face resolution validation is enabled.
pub enabled: bool,
/// Minimum face width/height in pixels.
pub face_resolution_threshold: u32,
/// Faces smaller than this will be skipped.
pub min_size: u32,
}
/// Maximum allowed head yaw angle in degrees.
pub pose_threshold: f32,
impl Default for FaceResolutionConfig {
fn default() -> Self {
Self {
enabled: true,
min_size: 80,
}
}
}
/// Eye Aspect Ratio threshold for eye visibility.
pub ear_threshold: f32,
/// Target position for left eye as (x%, y%).
pub left_eye_pos: (f32, f32),
/// Target position for right eye as (x%, y%).
pub right_eye_pos: (f32, f32),
/// Number of parallel workers for processing.
pub max_workers: usize,
/// Keep intermediate images (original, cropped) for inspection.
pub keep_intermediates: bool,
/// Brightness validation settings (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub brightness: Option<BrightnessConfig>,
impl FaceResolutionConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled && self.min_size == 0 {
return Err(Error::Config(
"Face resolution min_size must be greater than 0".to_string(),
));
}
Ok(())
}
}
/// Brightness validation configuration.
@ -125,56 +144,207 @@ impl Default for BrightnessConfig {
}
}
impl BrightnessConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if !self.enabled {
return Ok(());
}
if self.min_brightness < 0.0 || self.min_brightness > 1.0 {
return Err(Error::Config(
"Brightness min_brightness must be between 0.0 and 1.0".to_string(),
));
}
if self.max_brightness < 0.0 || self.max_brightness > 1.0 {
return Err(Error::Config(
"Brightness max_brightness must be between 0.0 and 1.0".to_string(),
));
}
if self.min_brightness >= self.max_brightness {
return Err(Error::Config(
"Brightness min_brightness must be less than max_brightness".to_string(),
));
}
Ok(())
}
}
/// Output/resize configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
/// Output image size (width and height in pixels).
pub size: u32,
/// Keep intermediate images (original, cropped) for inspection.
pub keep_intermediates: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
size: 512,
keep_intermediates: false,
}
}
}
impl OutputConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.size < 64 {
return Err(Error::Config(
"Output size must be at least 64 pixels".to_string(),
));
}
if self.size > 4096 {
return Err(Error::Config(
"Output size must be at most 4096 pixels".to_string(),
));
}
Ok(())
}
}
/// Face alignment configuration (for future landmark-based alignment).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlignmentConfig {
/// Whether face alignment is enabled.
pub enabled: bool,
/// Target position for left eye as (x%, y%) of output image.
pub left_eye_pos: (f32, f32),
/// Target position for right eye as (x%, y%) of output image.
pub right_eye_pos: (f32, f32),
}
impl Default for AlignmentConfig {
fn default() -> Self {
Self {
enabled: false,
left_eye_pos: (0.35, 0.4),
right_eye_pos: (0.65, 0.4),
}
}
}
// ============================================================================
// Main Processing Configuration
// ============================================================================
/// Face processing parameters.
///
/// Each pipeline step has its own sub-configuration with an `enabled` flag.
/// This allows fine-grained control over which steps run and their parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProcessingConfig {
/// Number of parallel workers for processing.
pub max_workers: usize,
/// Face resolution validation settings.
#[serde(default)]
pub face_resolution: FaceResolutionConfig,
/// Brightness validation settings.
#[serde(default)]
pub brightness: BrightnessConfig,
/// Output image settings.
#[serde(default)]
pub output: OutputConfig,
/// Face alignment settings (requires landmarks - future feature).
#[serde(default)]
pub alignment: AlignmentConfig,
}
impl Default for ProcessingConfig {
fn default() -> Self {
Self {
resize_size: 512,
face_resolution_threshold: 80,
pose_threshold: 25.0,
ear_threshold: 0.2,
left_eye_pos: (0.35, 0.4),
right_eye_pos: (0.65, 0.4),
max_workers: num_cpus(),
keep_intermediates: false,
brightness: None,
face_resolution: FaceResolutionConfig::default(),
brightness: BrightnessConfig::default(),
output: OutputConfig::default(),
alignment: AlignmentConfig::default(),
}
}
}
impl ProcessingConfig {
/// Validate all step configurations.
pub fn validate(&self) -> Result<()> {
self.face_resolution.validate()?;
self.brightness.validate()?;
self.output.validate()?;
Ok(())
}
}
fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4)
}
// ============================================================================
// Video Configuration
// ============================================================================
/// Video compilation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoConfig {
/// Output video framerate.
pub framerate: u32,
/// Whether to compile video after processing.
pub enabled: bool,
/// Output video framerate.
pub framerate: u32,
/// Video codec (e.g., "libx264").
pub codec: String,
/// Constant Rate Factor for quality (lower = better, 18-28 recommended).
/// Constant Rate Factor for quality (0-51, lower = better, 18-28 recommended).
pub crf: u32,
}
impl Default for VideoConfig {
fn default() -> Self {
Self {
framerate: 15,
enabled: true,
framerate: 15,
codec: "libx264".to_string(),
crf: 23,
}
}
}
impl VideoConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.framerate == 0 {
return Err(Error::Config(
"Video framerate must be greater than 0".to_string(),
));
}
if self.framerate > 120 {
return Err(Error::Config(
"Video framerate must be at most 120".to_string(),
));
}
if self.crf > 51 {
return Err(Error::Config(
"Video CRF must be between 0 and 51".to_string(),
));
}
Ok(())
}
}
// ============================================================================
// Persistence
// ============================================================================
/// Persistable configuration (excludes sensitive API credentials).
///
/// This struct contains only the settings that can safely be written to disk.
@ -194,12 +364,16 @@ impl From<&Config> for PersistableConfig {
}
}
// ============================================================================
// Config Loading and Validation
// ============================================================================
impl Config {
/// Load configuration from a TOML file.
pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
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| crate::error::Error::Config(e.to_string()))?;
toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
Ok(config)
}
@ -237,12 +411,12 @@ impl Config {
// Processing settings
if let Ok(val) = std::env::var("RESIZE_SIZE") {
if let Ok(size) = val.parse() {
self.processing.resize_size = size;
self.processing.output.size = size;
}
}
if let Ok(val) = std::env::var("FACE_RESOLUTION_THRESHOLD") {
if let Ok(threshold) = val.parse() {
self.processing.face_resolution_threshold = threshold;
self.processing.face_resolution.min_size = threshold;
}
}
if let Ok(val) = std::env::var("MAX_WORKERS") {
@ -251,7 +425,8 @@ impl Config {
}
}
if let Ok(val) = std::env::var("KEEP_INTERMEDIATES") {
self.processing.keep_intermediates = val.eq_ignore_ascii_case("true") || val == "1";
self.processing.output.keep_intermediates =
val.eq_ignore_ascii_case("true") || val == "1";
}
// Video settings
@ -272,23 +447,20 @@ impl Config {
self
}
/// Validate the configuration.
pub fn validate(&self) -> crate::error::Result<()> {
/// Validate the entire configuration.
pub fn validate(&self) -> Result<()> {
// API validation
if self.api.api_key.is_empty() {
return Err(crate::error::Error::Config(
"API key is required".to_string(),
));
return Err(Error::Config("API key is required".to_string()));
}
if self.api.base_url.is_empty() {
return Err(crate::error::Error::Config(
"Base URL is required".to_string(),
));
}
if self.processing.resize_size == 0 {
return Err(crate::error::Error::Config(
"Resize size must be greater than 0".to_string(),
));
return Err(Error::Config("Base URL is required".to_string()));
}
// Delegate to sub-config validation
self.processing.validate()?;
self.video.validate()?;
Ok(())
}
@ -296,15 +468,19 @@ 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<std::path::Path>) -> crate::error::Result<()> {
pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
let persistable = PersistableConfig::from(self);
let content = toml::to_string_pretty(&persistable)
.map_err(|e| crate::error::Error::Config(format!("Failed to serialize config: {}", e)))?;
.map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
std::fs::write(path, content)?;
Ok(())
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
@ -312,8 +488,8 @@ mod tests {
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.processing.resize_size, 512);
assert_eq!(config.processing.face_resolution_threshold, 80);
assert_eq!(config.processing.output.size, 512);
assert_eq!(config.processing.face_resolution.min_size, 80);
assert_eq!(config.video.framerate, 15);
}
@ -324,25 +500,83 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_brightness_validation() {
let mut config = BrightnessConfig::default();
config.enabled = true;
// Valid config
config.min_brightness = 0.1;
config.max_brightness = 0.9;
assert!(config.validate().is_ok());
// Invalid: min >= max
config.min_brightness = 0.9;
config.max_brightness = 0.1;
assert!(config.validate().is_err());
// Invalid: out of range
config.min_brightness = -0.1;
config.max_brightness = 0.9;
assert!(config.validate().is_err());
// Disabled config skips validation
config.enabled = false;
assert!(config.validate().is_ok());
}
#[test]
fn test_video_validation() {
let mut config = VideoConfig::default();
// Valid
assert!(config.validate().is_ok());
// Invalid CRF
config.crf = 52;
assert!(config.validate().is_err());
// Invalid framerate
config.crf = 23;
config.framerate = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_output_validation() {
let mut config = OutputConfig::default();
// Valid
assert!(config.validate().is_ok());
// Too small
config.size = 32;
assert!(config.validate().is_err());
// Too large
config.size = 8192;
assert!(config.validate().is_err());
}
#[test]
fn test_save_and_load_config() {
// Create a config with custom values
let mut config = Config::default();
config.processing.resize_size = 256;
config.processing.face_resolution_threshold = 100;
config.processing.output.size = 256;
config.processing.face_resolution.min_size = 100;
config.video.framerate = 30;
config.video.crf = 18;
// Save to a temp file
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join("test_config.toml");
let temp_path = temp_dir.join("test_config_new.toml");
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");
assert!(content.contains("resize_size = 256"));
assert!(content.contains("face_resolution_threshold = 100"));
assert!(content.contains("size = 256"));
assert!(content.contains("min_size = 100"));
assert!(content.contains("framerate = 30"));
assert!(content.contains("crf = 18"));

View file

@ -135,7 +135,7 @@ async fn run_job_inner(
tokio::fs::create_dir_all(&images_dir).await?;
// Create debug directories if enabled
let debug = if config.processing.keep_intermediates {
let debug = if config.processing.output.keep_intermediates {
let debug_base = person_dir.join("debug");
let crop_dir = debug_base.join("crop");
@ -499,7 +499,7 @@ async fn process_single_asset(
ctx = ctx.with_bytes(image_bytes);
// Determine debug directory
let debug_dir = if config.processing.keep_intermediates {
let debug_dir = if config.processing.output.keep_intermediates {
output_dirs.debug.as_ref().and_then(|d| d.crop.as_ref().map(|p| p.parent().unwrap().to_path_buf()))
} else {
None

View file

@ -133,7 +133,7 @@ impl Pipeline {
ctx = new_ctx;
// Generate debug visualization if enabled
if config.processing.keep_intermediates {
if config.processing.output.keep_intermediates {
if let Some(debug_img) = step.debug_visualize(&ctx) {
ctx.add_debug_image(step.id(), debug_img);
}

View file

@ -53,6 +53,8 @@ impl ProcessingStep for BrightnessStep {
}
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
let step_config = &config.processing.brightness;
let image = match &ctx.image {
Some(img) => img,
None => {
@ -67,31 +69,29 @@ impl ProcessingStep for BrightnessStep {
// Store computed brightness for potential use by other steps
ctx.set_computed("brightness", ComputedValue::Float(brightness));
// Check brightness thresholds if configured
if let Some(ref brightness_config) = config.processing.brightness {
if !brightness_config.enabled {
return StepOutcome::Continue(ctx);
}
// Skip validation if disabled
if !step_config.enabled {
return StepOutcome::Continue(ctx);
}
if brightness < brightness_config.min_brightness {
return StepOutcome::Skip {
reason: "too_dark".to_string(),
detail: Some(format!(
"{:.2} (min: {:.2})",
brightness, brightness_config.min_brightness
)),
};
}
if brightness < step_config.min_brightness {
return StepOutcome::Skip {
reason: "too_dark".to_string(),
detail: Some(format!(
"{:.2} (min: {:.2})",
brightness, step_config.min_brightness
)),
};
}
if brightness > brightness_config.max_brightness {
return StepOutcome::Skip {
reason: "too_bright".to_string(),
detail: Some(format!(
"{:.2} (max: {:.2})",
brightness, brightness_config.max_brightness
)),
};
}
if brightness > step_config.max_brightness {
return StepOutcome::Skip {
reason: "too_bright".to_string(),
detail: Some(format!(
"{:.2} (max: {:.2})",
brightness, step_config.max_brightness
)),
};
}
StepOutcome::Continue(ctx)
@ -101,7 +101,6 @@ impl ProcessingStep for BrightnessStep {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::BrightnessConfig;
use crate::immich_api::FaceData;
use image::{DynamicImage, RgbImage, Rgb};
@ -146,15 +145,15 @@ mod tests {
}
#[tokio::test]
async fn test_brightness_no_config() {
async fn test_brightness_disabled() {
let step = BrightnessStep;
let img = create_solid_image(128, 128, 128);
let ctx = make_ctx_with_image(img);
let config = Config::default(); // No brightness config
let config = Config::default(); // brightness.enabled = false by default
match step.execute(ctx, &config).await {
StepOutcome::Continue(new_ctx) => {
// Should still compute brightness even without thresholds
// Should still compute brightness even when disabled
let brightness = new_ctx.get_computed("brightness")
.and_then(|v| v.as_float())
.unwrap();
@ -171,11 +170,9 @@ mod tests {
let ctx = make_ctx_with_image(img);
let mut config = Config::default();
config.processing.brightness = Some(BrightnessConfig {
enabled: true,
min_brightness: 0.2,
max_brightness: 0.9,
});
config.processing.brightness.enabled = true;
config.processing.brightness.min_brightness = 0.2;
config.processing.brightness.max_brightness = 0.9;
match step.execute(ctx, &config).await {
StepOutcome::Skip { reason, .. } => {
@ -192,11 +189,9 @@ mod tests {
let ctx = make_ctx_with_image(img);
let mut config = Config::default();
config.processing.brightness = Some(BrightnessConfig {
enabled: true,
min_brightness: 0.1,
max_brightness: 0.9,
});
config.processing.brightness.enabled = true;
config.processing.brightness.min_brightness = 0.1;
config.processing.brightness.max_brightness = 0.9;
match step.execute(ctx, &config).await {
StepOutcome::Skip { reason, .. } => {
@ -207,21 +202,19 @@ mod tests {
}
#[tokio::test]
async fn test_brightness_disabled() {
async fn test_brightness_within_range() {
let step = BrightnessStep;
let img = create_solid_image(10, 10, 10); // Very dark
let img = create_solid_image(128, 128, 128); // Mid-gray
let ctx = make_ctx_with_image(img);
let mut config = Config::default();
config.processing.brightness = Some(BrightnessConfig {
enabled: false, // Disabled
min_brightness: 0.2,
max_brightness: 0.9,
});
config.processing.brightness.enabled = true;
config.processing.brightness.min_brightness = 0.1;
config.processing.brightness.max_brightness = 0.9;
match step.execute(ctx, &config).await {
StepOutcome::Continue(_) => {} // Should continue even though image is dark
_ => panic!("Expected Continue when disabled"),
StepOutcome::Continue(_) => {} // Should pass
_ => panic!("Expected Continue for mid-range brightness"),
}
}
}

View file

@ -35,7 +35,7 @@ impl ProcessingStep for CropFaceStep {
// Crop returns (full_res_crop, resized). We want the full_res_crop here
// and let the resize step handle the final sizing.
match crop_face_with_intermediate(image, &ctx.face_data, config.processing.resize_size) {
match crop_face_with_intermediate(image, &ctx.face_data, config.processing.output.size) {
Ok((cropped_full, _resized)) => {
// Use the full-resolution cropped image; resize step will handle final size
ctx.image = Some(cropped_full);

View file

@ -23,6 +23,13 @@ impl ProcessingStep for FaceResolutionStep {
}
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
let step_config = &config.processing.face_resolution;
// Skip this step if disabled
if !step_config.enabled {
return StepOutcome::Continue(ctx);
}
let face_data = &ctx.face_data;
// Calculate face size in pixels from bounding box
@ -33,7 +40,7 @@ impl ProcessingStep for FaceResolutionStep {
// Store computed value for later steps or debugging
ctx.set_computed("face_size", ComputedValue::Int(face_size));
let threshold = config.processing.face_resolution_threshold as i32;
let threshold = step_config.min_size as i32;
if face_size < threshold {
return StepOutcome::Skip {
@ -95,4 +102,18 @@ mod tests {
_ => panic!("Expected Continue"),
}
}
#[tokio::test]
async fn test_step_disabled() {
let step = FaceResolutionStep;
let ctx = make_ctx(50.0); // Would normally be too small
let mut config = Config::default();
config.processing.face_resolution.enabled = false;
match step.execute(ctx, &config).await {
StepOutcome::Continue(_) => {} // Should pass through when disabled
_ => panic!("Expected Continue when disabled"),
}
}
}

View file

@ -31,7 +31,7 @@ impl ProcessingStep for ResizeStep {
}
};
let output_size = config.processing.resize_size;
let output_size = config.processing.output.size;
// Resize to square output
let resized = image.resize_exact(output_size, output_size, FilterType::Lanczos3);
@ -72,7 +72,7 @@ mod tests {
let ctx = make_ctx_with_image(img);
let mut config = Config::default();
config.processing.resize_size = 512;
config.processing.output.size = 512;
match step.execute(ctx, &config).await {
StepOutcome::Continue(new_ctx) => {
@ -113,7 +113,7 @@ mod tests {
let ctx = make_ctx_with_image(img);
let mut config = Config::default();
config.processing.resize_size = 256;
config.processing.output.size = 256;
match step.execute(ctx, &config).await {
StepOutcome::Continue(new_ctx) => {

View file

@ -1,29 +1,13 @@
//! Configuration endpoints.
use crate::config::{ProcessingConfig, VideoConfig};
use crate::web::state::{AppState, JobStatus};
use axum::{
extract::State,
http::StatusCode,
response::Json,
use crate::config::{
AlignmentConfig, BrightnessConfig, FaceResolutionConfig, OutputConfig, ProcessingConfig,
VideoConfig,
};
use crate::web::state::{AppState, JobStatus};
use axum::{extract::State, http::StatusCode, response::Json};
use serde::{Deserialize, Serialize};
/// Validation error with field name and message.
struct ValidationError {
field: &'static str,
message: String,
}
impl ValidationError {
fn new(field: &'static str, message: impl Into<String>) -> Self {
Self {
field,
message: message.into(),
}
}
}
/// Configuration response (excludes sensitive API credentials).
#[derive(Serialize)]
pub struct ConfigResponse {
@ -42,6 +26,8 @@ pub async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
}
/// Configuration update request.
///
/// Uses the same nested structure as the config itself for consistency.
#[derive(Deserialize)]
pub struct ConfigUpdateRequest {
pub processing: Option<ProcessingConfigUpdate>,
@ -51,57 +37,39 @@ pub struct ConfigUpdateRequest {
/// Processing configuration update fields.
#[derive(Deserialize)]
pub struct ProcessingConfigUpdate {
pub resize_size: Option<u32>,
pub face_resolution_threshold: Option<u32>,
pub pose_threshold: Option<f32>,
pub ear_threshold: Option<f32>,
pub max_workers: Option<usize>,
pub keep_intermediates: Option<bool>,
pub face_resolution: Option<FaceResolutionConfig>,
pub brightness: Option<BrightnessConfig>,
pub output: Option<OutputConfig>,
pub alignment: Option<AlignmentConfig>,
}
/// Video configuration update fields.
#[derive(Deserialize)]
pub struct VideoConfigUpdate {
pub framerate: Option<u32>,
pub enabled: Option<bool>,
pub framerate: Option<u32>,
pub codec: Option<String>,
pub crf: Option<u32>,
}
/// Validation error with field name and message.
struct ValidationError {
field: &'static str,
message: String,
}
impl ValidationError {
fn new(field: &'static str, message: impl Into<String>) -> Self {
Self {
field,
message: message.into(),
}
}
}
/// Validate processing configuration update values.
fn validate_processing_config(proc: &ProcessingConfigUpdate) -> Result<(), ValidationError> {
if let Some(v) = proc.resize_size {
if v < 64 || v > 4096 {
return Err(ValidationError::new(
"processing.resize_size",
format!("must be between 64 and 4096, got {}", v),
));
}
}
if let Some(v) = proc.face_resolution_threshold {
if v == 0 || v > 1000 {
return Err(ValidationError::new(
"processing.face_resolution_threshold",
format!("must be between 1 and 1000, got {}", v),
));
}
}
if let Some(v) = proc.pose_threshold {
if v <= 0.0 || v > 90.0 {
return Err(ValidationError::new(
"processing.pose_threshold",
format!("must be between 0 and 90 degrees, got {}", v),
));
}
}
if let Some(v) = proc.ear_threshold {
if v <= 0.0 || v > 1.0 {
return Err(ValidationError::new(
"processing.ear_threshold",
format!("must be between 0 and 1, got {}", v),
));
}
}
if let Some(v) = proc.max_workers {
if v == 0 || v > 64 {
return Err(ValidationError::new(
@ -110,6 +78,60 @@ fn validate_processing_config(proc: &ProcessingConfigUpdate) -> Result<(), Valid
));
}
}
if let Some(ref fr) = proc.face_resolution {
if fr.enabled && fr.min_size == 0 {
return Err(ValidationError::new(
"processing.face_resolution.min_size",
"must be greater than 0 when enabled",
));
}
if fr.min_size > 1000 {
return Err(ValidationError::new(
"processing.face_resolution.min_size",
format!("must be at most 1000, got {}", fr.min_size),
));
}
}
if let Some(ref br) = proc.brightness {
if br.enabled {
if br.min_brightness < 0.0 || br.min_brightness > 1.0 {
return Err(ValidationError::new(
"processing.brightness.min_brightness",
format!("must be between 0.0 and 1.0, got {}", br.min_brightness),
));
}
if br.max_brightness < 0.0 || br.max_brightness > 1.0 {
return Err(ValidationError::new(
"processing.brightness.max_brightness",
format!("must be between 0.0 and 1.0, got {}", br.max_brightness),
));
}
if br.min_brightness >= br.max_brightness {
return Err(ValidationError::new(
"processing.brightness",
"min_brightness must be less than max_brightness",
));
}
}
}
if let Some(ref out) = proc.output {
if out.size < 64 {
return Err(ValidationError::new(
"processing.output.size",
format!("must be at least 64, got {}", out.size),
));
}
if out.size > 4096 {
return Err(ValidationError::new(
"processing.output.size",
format!("must be at most 4096, got {}", out.size),
));
}
}
Ok(())
}
@ -132,7 +154,6 @@ fn validate_video_config(vid: &VideoConfigUpdate) -> Result<(), ValidationError>
}
}
if let Some(ref v) = vid.codec {
// Allow common video codecs
let valid_codecs = ["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"];
if !valid_codecs.contains(&v.as_str()) {
return Err(ValidationError::new(
@ -187,33 +208,30 @@ pub async fn update_config(
let mut config = state.config.write().await;
if let Some(proc) = update.processing {
if let Some(v) = proc.resize_size {
config.processing.resize_size = v;
}
if let Some(v) = proc.face_resolution_threshold {
config.processing.face_resolution_threshold = v;
}
if let Some(v) = proc.pose_threshold {
config.processing.pose_threshold = v;
}
if let Some(v) = proc.ear_threshold {
config.processing.ear_threshold = v;
}
if let Some(v) = proc.max_workers {
config.processing.max_workers = v;
}
if let Some(v) = proc.keep_intermediates {
config.processing.keep_intermediates = v;
if let Some(v) = proc.face_resolution {
config.processing.face_resolution = v;
}
if let Some(v) = proc.brightness {
config.processing.brightness = v;
}
if let Some(v) = proc.output {
config.processing.output = v;
}
if let Some(v) = proc.alignment {
config.processing.alignment = v;
}
}
if let Some(vid) = update.video {
if let Some(v) = vid.framerate {
config.video.framerate = v;
}
if let Some(v) = vid.enabled {
config.video.enabled = v;
}
if let Some(v) = vid.framerate {
config.video.framerate = v;
}
if let Some(v) = vid.codec {
config.video.codec = v;
}