Working on making each image processing step modularized and easy to expand
This commit is contained in:
parent
41d50167eb
commit
d7b0238c5b
9 changed files with 546 additions and 294 deletions
|
|
@ -10,36 +10,37 @@
|
||||||
let saveMessage = $state(null);
|
let saveMessage = $state(null);
|
||||||
let activeTab = $state('face');
|
let activeTab = $state('face');
|
||||||
|
|
||||||
// Config state
|
// Config state - matches backend nested structure
|
||||||
let config = $state({
|
let config = $state({
|
||||||
processing: {
|
processing: {
|
||||||
resize_size: 512,
|
|
||||||
face_resolution_threshold: 80,
|
|
||||||
pose_threshold: 25.0,
|
|
||||||
ear_threshold: 0.2,
|
|
||||||
max_workers: 4,
|
max_workers: 4,
|
||||||
keep_intermediates: false,
|
face_resolution: {
|
||||||
brightness: null,
|
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: {
|
video: {
|
||||||
framerate: 15,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
framerate: 15,
|
||||||
codec: 'libx264',
|
codec: 'libx264',
|
||||||
crf: 23,
|
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() {
|
async function loadConfig() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
@ -81,17 +82,29 @@
|
||||||
// Default configuration values (must match backend defaults in config.rs)
|
// Default configuration values (must match backend defaults in config.rs)
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
processing: {
|
processing: {
|
||||||
resize_size: 512,
|
|
||||||
face_resolution_threshold: 80,
|
|
||||||
pose_threshold: 25.0,
|
|
||||||
ear_threshold: 0.2,
|
|
||||||
max_workers: 4,
|
max_workers: 4,
|
||||||
keep_intermediates: false,
|
face_resolution: {
|
||||||
brightness: null,
|
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: {
|
video: {
|
||||||
framerate: 15,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
framerate: 15,
|
||||||
codec: 'libx264',
|
codec: 'libx264',
|
||||||
crf: 23,
|
crf: 23,
|
||||||
},
|
},
|
||||||
|
|
@ -159,72 +172,47 @@
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{#if activeTab === 'face'}
|
{#if activeTab === 'face'}
|
||||||
<fieldset disabled={disabled || saving}>
|
<fieldset disabled={disabled || saving}>
|
||||||
<div class="setting-row">
|
<!-- Face Resolution Section -->
|
||||||
<label>
|
<div class="setting-section">
|
||||||
<span class="setting-label">Min Face Size</span>
|
<div class="section-header">
|
||||||
<span class="setting-hint">Minimum face resolution in pixels</span>
|
<span class="section-title">Face Resolution</span>
|
||||||
</label>
|
|
||||||
<div class="setting-control">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="checkbox"
|
||||||
bind:value={config.processing.face_resolution_threshold}
|
bind:checked={config.processing.face_resolution.enabled}
|
||||||
min="20"
|
|
||||||
max="200"
|
|
||||||
step="10"
|
|
||||||
/>
|
/>
|
||||||
<span class="value">{config.processing.face_resolution_threshold}px</span>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="setting-row">
|
<!-- Brightness Section -->
|
||||||
<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 -->
|
|
||||||
<div class="setting-section">
|
<div class="setting-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title">Brightness Filter</span>
|
<span class="section-title">Brightness Filter</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={config.processing.brightness?.enabled ?? false}
|
bind:checked={config.processing.brightness.enabled}
|
||||||
onchange={(e) => {
|
|
||||||
ensureBrightnessConfig();
|
|
||||||
config.processing.brightness.enabled = e.target.checked;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if config.processing.brightness?.enabled}
|
{#if config.processing.brightness.enabled}
|
||||||
<div class="setting-row sub-setting">
|
<div class="setting-row sub-setting">
|
||||||
<label>
|
<label>
|
||||||
<span class="setting-label">Min Brightness</span>
|
<span class="setting-label">Min Brightness</span>
|
||||||
|
|
@ -233,16 +221,12 @@
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={config.processing.brightness?.min_brightness ?? 0.1}
|
bind:value={config.processing.brightness.min_brightness}
|
||||||
oninput={(e) => {
|
|
||||||
ensureBrightnessConfig();
|
|
||||||
config.processing.brightness.min_brightness = parseFloat(e.target.value);
|
|
||||||
}}
|
|
||||||
min="0"
|
min="0"
|
||||||
max="0.5"
|
max="0.5"
|
||||||
step="0.05"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -254,16 +238,12 @@
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={config.processing.brightness?.max_brightness ?? 0.95}
|
bind:value={config.processing.brightness.max_brightness}
|
||||||
oninput={(e) => {
|
|
||||||
ensureBrightnessConfig();
|
|
||||||
config.processing.brightness.max_brightness = parseFloat(e.target.value);
|
|
||||||
}}
|
|
||||||
min="0.5"
|
min="0.5"
|
||||||
max="1.0"
|
max="1.0"
|
||||||
step="0.05"
|
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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -279,12 +259,12 @@
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
bind:value={config.processing.resize_size}
|
bind:value={config.processing.output.size}
|
||||||
min="128"
|
min="128"
|
||||||
max="1024"
|
max="1024"
|
||||||
step="64"
|
step="64"
|
||||||
/>
|
/>
|
||||||
<span class="value">{config.processing.resize_size}px</span>
|
<span class="value">{config.processing.output.size}px</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -310,7 +290,7 @@
|
||||||
<span class="setting-label">Keep Debug Images</span>
|
<span class="setting-label">Keep Debug Images</span>
|
||||||
<span class="setting-hint">Save intermediate processing visualizations</span>
|
<span class="setting-hint">Save intermediate processing visualizations</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="checkbox" bind:checked={config.processing.keep_intermediates} />
|
<input type="checkbox" bind:checked={config.processing.output.keep_intermediates} />
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{:else if activeTab === 'video'}
|
{:else if activeTab === 'video'}
|
||||||
|
|
@ -565,9 +545,15 @@
|
||||||
|
|
||||||
/* Settings section with header */
|
/* Settings section with header */
|
||||||
.setting-section {
|
.setting-section {
|
||||||
margin-top: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-top: 1rem;
|
padding-bottom: 1rem;
|
||||||
border-top: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
|
|
|
||||||
366
src/config.rs
366
src/config.rs
|
|
@ -4,10 +4,26 @@
|
||||||
//! 1. TOML file (config.toml)
|
//! 1. TOML file (config.toml)
|
||||||
//! 2. Environment variables (prefixed with IMMICH_)
|
//! 2. Environment variables (prefixed with IMMICH_)
|
||||||
//! 3. Programmatic overrides
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
/// Main configuration struct.
|
/// Main configuration struct.
|
||||||
///
|
///
|
||||||
/// This is the single source of truth for all configuration.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
pub struct FaceResolutionConfig {
|
||||||
pub struct ProcessingConfig {
|
/// Whether face resolution validation is enabled.
|
||||||
/// Output image size (width and height in pixels).
|
pub enabled: bool,
|
||||||
pub resize_size: u32,
|
|
||||||
|
|
||||||
/// Minimum face width/height in pixels.
|
/// 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.
|
impl Default for FaceResolutionConfig {
|
||||||
pub pose_threshold: f32,
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
min_size: 80,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Eye Aspect Ratio threshold for eye visibility.
|
impl FaceResolutionConfig {
|
||||||
pub ear_threshold: f32,
|
/// Validate the configuration values.
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
/// Target position for left eye as (x%, y%).
|
if self.enabled && self.min_size == 0 {
|
||||||
pub left_eye_pos: (f32, f32),
|
return Err(Error::Config(
|
||||||
|
"Face resolution min_size must be greater than 0".to_string(),
|
||||||
/// Target position for right eye as (x%, y%).
|
));
|
||||||
pub right_eye_pos: (f32, f32),
|
}
|
||||||
|
Ok(())
|
||||||
/// 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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Brightness validation configuration.
|
/// 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 {
|
impl Default for ProcessingConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
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(),
|
max_workers: num_cpus(),
|
||||||
keep_intermediates: false,
|
face_resolution: FaceResolutionConfig::default(),
|
||||||
brightness: None,
|
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 {
|
fn num_cpus() -> usize {
|
||||||
std::thread::available_parallelism()
|
std::thread::available_parallelism()
|
||||||
.map(|p| p.get())
|
.map(|p| p.get())
|
||||||
.unwrap_or(4)
|
.unwrap_or(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Video Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Video compilation settings.
|
/// Video compilation settings.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct VideoConfig {
|
pub struct VideoConfig {
|
||||||
/// Output video framerate.
|
|
||||||
pub framerate: u32,
|
|
||||||
|
|
||||||
/// Whether to compile video after processing.
|
/// Whether to compile video after processing.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Output video framerate.
|
||||||
|
pub framerate: u32,
|
||||||
|
|
||||||
/// Video codec (e.g., "libx264").
|
/// Video codec (e.g., "libx264").
|
||||||
pub codec: String,
|
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,
|
pub crf: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for VideoConfig {
|
impl Default for VideoConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
framerate: 15,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
framerate: 15,
|
||||||
codec: "libx264".to_string(),
|
codec: "libx264".to_string(),
|
||||||
crf: 23,
|
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).
|
/// Persistable configuration (excludes sensitive API credentials).
|
||||||
///
|
///
|
||||||
/// This struct contains only the settings that can safely be written to disk.
|
/// 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 {
|
impl Config {
|
||||||
/// Load configuration from a TOML file.
|
/// 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 content = std::fs::read_to_string(path)?;
|
||||||
let config: Config =
|
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)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,12 +411,12 @@ impl Config {
|
||||||
// Processing settings
|
// Processing settings
|
||||||
if let Ok(val) = std::env::var("RESIZE_SIZE") {
|
if let Ok(val) = std::env::var("RESIZE_SIZE") {
|
||||||
if let Ok(size) = val.parse() {
|
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(val) = std::env::var("FACE_RESOLUTION_THRESHOLD") {
|
||||||
if let Ok(threshold) = val.parse() {
|
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") {
|
if let Ok(val) = std::env::var("MAX_WORKERS") {
|
||||||
|
|
@ -251,7 +425,8 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(val) = std::env::var("KEEP_INTERMEDIATES") {
|
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
|
// Video settings
|
||||||
|
|
@ -272,23 +447,20 @@ impl Config {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the configuration.
|
/// Validate the entire configuration.
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
// API validation
|
||||||
if self.api.api_key.is_empty() {
|
if self.api.api_key.is_empty() {
|
||||||
return Err(crate::error::Error::Config(
|
return Err(Error::Config("API key is required".to_string()));
|
||||||
"API key is required".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if self.api.base_url.is_empty() {
|
if self.api.base_url.is_empty() {
|
||||||
return Err(crate::error::Error::Config(
|
return Err(Error::Config("Base URL is required".to_string()));
|
||||||
"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(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delegate to sub-config validation
|
||||||
|
self.processing.validate()?;
|
||||||
|
self.video.validate()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,15 +468,19 @@ impl Config {
|
||||||
///
|
///
|
||||||
/// Only saves `processing` and `video` sections - API credentials are
|
/// Only saves `processing` and `video` sections - API credentials are
|
||||||
/// intentionally excluded as they should come from environment variables.
|
/// 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 persistable = PersistableConfig::from(self);
|
||||||
let content = toml::to_string_pretty(&persistable)
|
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)?;
|
std::fs::write(path, content)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -312,8 +488,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
assert_eq!(config.processing.resize_size, 512);
|
assert_eq!(config.processing.output.size, 512);
|
||||||
assert_eq!(config.processing.face_resolution_threshold, 80);
|
assert_eq!(config.processing.face_resolution.min_size, 80);
|
||||||
assert_eq!(config.video.framerate, 15);
|
assert_eq!(config.video.framerate, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,25 +500,83 @@ mod tests {
|
||||||
assert!(result.is_err());
|
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]
|
#[test]
|
||||||
fn test_save_and_load_config() {
|
fn test_save_and_load_config() {
|
||||||
// Create a config with custom values
|
// Create a config with custom values
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.processing.resize_size = 256;
|
config.processing.output.size = 256;
|
||||||
config.processing.face_resolution_threshold = 100;
|
config.processing.face_resolution.min_size = 100;
|
||||||
config.video.framerate = 30;
|
config.video.framerate = 30;
|
||||||
config.video.crf = 18;
|
config.video.crf = 18;
|
||||||
|
|
||||||
// Save to a temp file
|
// Save to a temp file
|
||||||
let temp_dir = std::env::temp_dir();
|
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");
|
config.save_to_file(&temp_path).expect("Failed to save config");
|
||||||
|
|
||||||
// Verify file was created and contains expected content
|
// Verify file was created and contains expected content
|
||||||
let content = std::fs::read_to_string(&temp_path).expect("Failed to read config");
|
let content = std::fs::read_to_string(&temp_path).expect("Failed to read config");
|
||||||
assert!(content.contains("resize_size = 256"));
|
assert!(content.contains("size = 256"));
|
||||||
assert!(content.contains("face_resolution_threshold = 100"));
|
assert!(content.contains("min_size = 100"));
|
||||||
assert!(content.contains("framerate = 30"));
|
assert!(content.contains("framerate = 30"));
|
||||||
assert!(content.contains("crf = 18"));
|
assert!(content.contains("crf = 18"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ async fn run_job_inner(
|
||||||
tokio::fs::create_dir_all(&images_dir).await?;
|
tokio::fs::create_dir_all(&images_dir).await?;
|
||||||
|
|
||||||
// Create debug directories if enabled
|
// 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 debug_base = person_dir.join("debug");
|
||||||
|
|
||||||
let crop_dir = debug_base.join("crop");
|
let crop_dir = debug_base.join("crop");
|
||||||
|
|
@ -499,7 +499,7 @@ async fn process_single_asset(
|
||||||
ctx = ctx.with_bytes(image_bytes);
|
ctx = ctx.with_bytes(image_bytes);
|
||||||
|
|
||||||
// Determine debug directory
|
// 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()))
|
output_dirs.debug.as_ref().and_then(|d| d.crop.as_ref().map(|p| p.parent().unwrap().to_path_buf()))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ impl Pipeline {
|
||||||
ctx = new_ctx;
|
ctx = new_ctx;
|
||||||
|
|
||||||
// Generate debug visualization if enabled
|
// 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) {
|
if let Some(debug_img) = step.debug_visualize(&ctx) {
|
||||||
ctx.add_debug_image(step.id(), debug_img);
|
ctx.add_debug_image(step.id(), debug_img);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ impl ProcessingStep for BrightnessStep {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
|
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
|
||||||
|
let step_config = &config.processing.brightness;
|
||||||
|
|
||||||
let image = match &ctx.image {
|
let image = match &ctx.image {
|
||||||
Some(img) => img,
|
Some(img) => img,
|
||||||
None => {
|
None => {
|
||||||
|
|
@ -67,31 +69,29 @@ impl ProcessingStep for BrightnessStep {
|
||||||
// Store computed brightness for potential use by other steps
|
// Store computed brightness for potential use by other steps
|
||||||
ctx.set_computed("brightness", ComputedValue::Float(brightness));
|
ctx.set_computed("brightness", ComputedValue::Float(brightness));
|
||||||
|
|
||||||
// Check brightness thresholds if configured
|
// Skip validation if disabled
|
||||||
if let Some(ref brightness_config) = config.processing.brightness {
|
if !step_config.enabled {
|
||||||
if !brightness_config.enabled {
|
return StepOutcome::Continue(ctx);
|
||||||
return StepOutcome::Continue(ctx);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if brightness < brightness_config.min_brightness {
|
if brightness < step_config.min_brightness {
|
||||||
return StepOutcome::Skip {
|
return StepOutcome::Skip {
|
||||||
reason: "too_dark".to_string(),
|
reason: "too_dark".to_string(),
|
||||||
detail: Some(format!(
|
detail: Some(format!(
|
||||||
"{:.2} (min: {:.2})",
|
"{:.2} (min: {:.2})",
|
||||||
brightness, brightness_config.min_brightness
|
brightness, step_config.min_brightness
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if brightness > brightness_config.max_brightness {
|
if brightness > step_config.max_brightness {
|
||||||
return StepOutcome::Skip {
|
return StepOutcome::Skip {
|
||||||
reason: "too_bright".to_string(),
|
reason: "too_bright".to_string(),
|
||||||
detail: Some(format!(
|
detail: Some(format!(
|
||||||
"{:.2} (max: {:.2})",
|
"{:.2} (max: {:.2})",
|
||||||
brightness, brightness_config.max_brightness
|
brightness, step_config.max_brightness
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StepOutcome::Continue(ctx)
|
StepOutcome::Continue(ctx)
|
||||||
|
|
@ -101,7 +101,6 @@ impl ProcessingStep for BrightnessStep {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::BrightnessConfig;
|
|
||||||
use crate::immich_api::FaceData;
|
use crate::immich_api::FaceData;
|
||||||
use image::{DynamicImage, RgbImage, Rgb};
|
use image::{DynamicImage, RgbImage, Rgb};
|
||||||
|
|
||||||
|
|
@ -146,15 +145,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_brightness_no_config() {
|
async fn test_brightness_disabled() {
|
||||||
let step = BrightnessStep;
|
let step = BrightnessStep;
|
||||||
let img = create_solid_image(128, 128, 128);
|
let img = create_solid_image(128, 128, 128);
|
||||||
let ctx = make_ctx_with_image(img);
|
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 {
|
match step.execute(ctx, &config).await {
|
||||||
StepOutcome::Continue(new_ctx) => {
|
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")
|
let brightness = new_ctx.get_computed("brightness")
|
||||||
.and_then(|v| v.as_float())
|
.and_then(|v| v.as_float())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -171,11 +170,9 @@ mod tests {
|
||||||
let ctx = make_ctx_with_image(img);
|
let ctx = make_ctx_with_image(img);
|
||||||
|
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.processing.brightness = Some(BrightnessConfig {
|
config.processing.brightness.enabled = true;
|
||||||
enabled: true,
|
config.processing.brightness.min_brightness = 0.2;
|
||||||
min_brightness: 0.2,
|
config.processing.brightness.max_brightness = 0.9;
|
||||||
max_brightness: 0.9,
|
|
||||||
});
|
|
||||||
|
|
||||||
match step.execute(ctx, &config).await {
|
match step.execute(ctx, &config).await {
|
||||||
StepOutcome::Skip { reason, .. } => {
|
StepOutcome::Skip { reason, .. } => {
|
||||||
|
|
@ -192,11 +189,9 @@ mod tests {
|
||||||
let ctx = make_ctx_with_image(img);
|
let ctx = make_ctx_with_image(img);
|
||||||
|
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.processing.brightness = Some(BrightnessConfig {
|
config.processing.brightness.enabled = true;
|
||||||
enabled: true,
|
config.processing.brightness.min_brightness = 0.1;
|
||||||
min_brightness: 0.1,
|
config.processing.brightness.max_brightness = 0.9;
|
||||||
max_brightness: 0.9,
|
|
||||||
});
|
|
||||||
|
|
||||||
match step.execute(ctx, &config).await {
|
match step.execute(ctx, &config).await {
|
||||||
StepOutcome::Skip { reason, .. } => {
|
StepOutcome::Skip { reason, .. } => {
|
||||||
|
|
@ -207,21 +202,19 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_brightness_disabled() {
|
async fn test_brightness_within_range() {
|
||||||
let step = BrightnessStep;
|
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 ctx = make_ctx_with_image(img);
|
||||||
|
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.processing.brightness = Some(BrightnessConfig {
|
config.processing.brightness.enabled = true;
|
||||||
enabled: false, // Disabled
|
config.processing.brightness.min_brightness = 0.1;
|
||||||
min_brightness: 0.2,
|
config.processing.brightness.max_brightness = 0.9;
|
||||||
max_brightness: 0.9,
|
|
||||||
});
|
|
||||||
|
|
||||||
match step.execute(ctx, &config).await {
|
match step.execute(ctx, &config).await {
|
||||||
StepOutcome::Continue(_) => {} // Should continue even though image is dark
|
StepOutcome::Continue(_) => {} // Should pass
|
||||||
_ => panic!("Expected Continue when disabled"),
|
_ => panic!("Expected Continue for mid-range brightness"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ impl ProcessingStep for CropFaceStep {
|
||||||
|
|
||||||
// Crop returns (full_res_crop, resized). We want the full_res_crop here
|
// Crop returns (full_res_crop, resized). We want the full_res_crop here
|
||||||
// and let the resize step handle the final sizing.
|
// 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)) => {
|
Ok((cropped_full, _resized)) => {
|
||||||
// Use the full-resolution cropped image; resize step will handle final size
|
// Use the full-resolution cropped image; resize step will handle final size
|
||||||
ctx.image = Some(cropped_full);
|
ctx.image = Some(cropped_full);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ impl ProcessingStep for FaceResolutionStep {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
|
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;
|
let face_data = &ctx.face_data;
|
||||||
|
|
||||||
// Calculate face size in pixels from bounding box
|
// Calculate face size in pixels from bounding box
|
||||||
|
|
@ -33,7 +40,7 @@ impl ProcessingStep for FaceResolutionStep {
|
||||||
// Store computed value for later steps or debugging
|
// Store computed value for later steps or debugging
|
||||||
ctx.set_computed("face_size", ComputedValue::Int(face_size));
|
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 {
|
if face_size < threshold {
|
||||||
return StepOutcome::Skip {
|
return StepOutcome::Skip {
|
||||||
|
|
@ -95,4 +102,18 @@ mod tests {
|
||||||
_ => panic!("Expected Continue"),
|
_ => 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Resize to square output
|
||||||
let resized = image.resize_exact(output_size, output_size, FilterType::Lanczos3);
|
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 ctx = make_ctx_with_image(img);
|
||||||
|
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.processing.resize_size = 512;
|
config.processing.output.size = 512;
|
||||||
|
|
||||||
match step.execute(ctx, &config).await {
|
match step.execute(ctx, &config).await {
|
||||||
StepOutcome::Continue(new_ctx) => {
|
StepOutcome::Continue(new_ctx) => {
|
||||||
|
|
@ -113,7 +113,7 @@ mod tests {
|
||||||
let ctx = make_ctx_with_image(img);
|
let ctx = make_ctx_with_image(img);
|
||||||
|
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.processing.resize_size = 256;
|
config.processing.output.size = 256;
|
||||||
|
|
||||||
match step.execute(ctx, &config).await {
|
match step.execute(ctx, &config).await {
|
||||||
StepOutcome::Continue(new_ctx) => {
|
StepOutcome::Continue(new_ctx) => {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,13 @@
|
||||||
//! Configuration endpoints.
|
//! Configuration endpoints.
|
||||||
|
|
||||||
use crate::config::{ProcessingConfig, VideoConfig};
|
use crate::config::{
|
||||||
use crate::web::state::{AppState, JobStatus};
|
AlignmentConfig, BrightnessConfig, FaceResolutionConfig, OutputConfig, ProcessingConfig,
|
||||||
use axum::{
|
VideoConfig,
|
||||||
extract::State,
|
|
||||||
http::StatusCode,
|
|
||||||
response::Json,
|
|
||||||
};
|
};
|
||||||
|
use crate::web::state::{AppState, JobStatus};
|
||||||
|
use axum::{extract::State, http::StatusCode, response::Json};
|
||||||
use serde::{Deserialize, Serialize};
|
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).
|
/// Configuration response (excludes sensitive API credentials).
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ConfigResponse {
|
pub struct ConfigResponse {
|
||||||
|
|
@ -42,6 +26,8 @@ pub async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration update request.
|
/// Configuration update request.
|
||||||
|
///
|
||||||
|
/// Uses the same nested structure as the config itself for consistency.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ConfigUpdateRequest {
|
pub struct ConfigUpdateRequest {
|
||||||
pub processing: Option<ProcessingConfigUpdate>,
|
pub processing: Option<ProcessingConfigUpdate>,
|
||||||
|
|
@ -51,57 +37,39 @@ pub struct ConfigUpdateRequest {
|
||||||
/// Processing configuration update fields.
|
/// Processing configuration update fields.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ProcessingConfigUpdate {
|
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 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.
|
/// Video configuration update fields.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct VideoConfigUpdate {
|
pub struct VideoConfigUpdate {
|
||||||
pub framerate: Option<u32>,
|
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
pub framerate: Option<u32>,
|
||||||
pub codec: Option<String>,
|
pub codec: Option<String>,
|
||||||
pub crf: Option<u32>,
|
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.
|
/// Validate processing configuration update values.
|
||||||
fn validate_processing_config(proc: &ProcessingConfigUpdate) -> Result<(), ValidationError> {
|
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 let Some(v) = proc.max_workers {
|
||||||
if v == 0 || v > 64 {
|
if v == 0 || v > 64 {
|
||||||
return Err(ValidationError::new(
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +154,6 @@ fn validate_video_config(vid: &VideoConfigUpdate) -> Result<(), ValidationError>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref v) = vid.codec {
|
if let Some(ref v) = vid.codec {
|
||||||
// Allow common video codecs
|
|
||||||
let valid_codecs = ["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"];
|
let valid_codecs = ["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"];
|
||||||
if !valid_codecs.contains(&v.as_str()) {
|
if !valid_codecs.contains(&v.as_str()) {
|
||||||
return Err(ValidationError::new(
|
return Err(ValidationError::new(
|
||||||
|
|
@ -187,33 +208,30 @@ pub async fn update_config(
|
||||||
let mut config = state.config.write().await;
|
let mut config = state.config.write().await;
|
||||||
|
|
||||||
if let Some(proc) = update.processing {
|
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 {
|
if let Some(v) = proc.max_workers {
|
||||||
config.processing.max_workers = v;
|
config.processing.max_workers = v;
|
||||||
}
|
}
|
||||||
if let Some(v) = proc.keep_intermediates {
|
if let Some(v) = proc.face_resolution {
|
||||||
config.processing.keep_intermediates = v;
|
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(vid) = update.video {
|
||||||
if let Some(v) = vid.framerate {
|
|
||||||
config.video.framerate = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = vid.enabled {
|
if let Some(v) = vid.enabled {
|
||||||
config.video.enabled = v;
|
config.video.enabled = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = vid.framerate {
|
||||||
|
config.video.framerate = v;
|
||||||
|
}
|
||||||
if let Some(v) = vid.codec {
|
if let Some(v) = vid.codec {
|
||||||
config.video.codec = v;
|
config.video.codec = v;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue