Added settigns validation, made skip reason more expansible
This commit is contained in:
parent
41506d6a3a
commit
a6d491d3c2
7 changed files with 321 additions and 88 deletions
|
|
@ -9,8 +9,38 @@
|
||||||
import ResultsView from './lib/components/ResultsView.svelte';
|
import ResultsView from './lib/components/ResultsView.svelte';
|
||||||
import SettingsPanel from './lib/components/SettingsPanel.svelte';
|
import SettingsPanel from './lib/components/SettingsPanel.svelte';
|
||||||
|
|
||||||
|
// LocalStorage keys
|
||||||
|
const STORAGE_KEY_PERSON = 'immich-timelapse-selected-person';
|
||||||
|
|
||||||
|
// Load persisted state from localStorage
|
||||||
|
function loadPersistedPersonId() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_PERSON);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return parsed?.id || null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load persisted person:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSelectedPerson(person) {
|
||||||
|
try {
|
||||||
|
if (person) {
|
||||||
|
localStorage.setItem(STORAGE_KEY_PERSON, JSON.stringify({ id: person.id, name: person.name }));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_PERSON);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to persist person:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let connectionOk = $state(false);
|
let connectionOk = $state(false);
|
||||||
let selectedPerson = $state(null);
|
let selectedPerson = $state(null);
|
||||||
|
let initialSelectedPersonId = $state(loadPersistedPersonId());
|
||||||
let jobStatus = $state('idle');
|
let jobStatus = $state('idle');
|
||||||
let progress = $state({ completed: 0, total: 0, message: '' });
|
let progress = $state({ completed: 0, total: 0, message: '' });
|
||||||
let pollInterval = $state(null);
|
let pollInterval = $state(null);
|
||||||
|
|
@ -93,6 +123,7 @@
|
||||||
|
|
||||||
function handlePersonSelect(person) {
|
function handlePersonSelect(person) {
|
||||||
selectedPerson = person;
|
selectedPerson = person;
|
||||||
|
persistSelectedPerson(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleJobUpdate(data) {
|
function handleJobUpdate(data) {
|
||||||
|
|
@ -194,6 +225,7 @@
|
||||||
<PeopleSelector
|
<PeopleSelector
|
||||||
onselect={handlePersonSelect}
|
onselect={handlePersonSelect}
|
||||||
disabled={isJobRunning}
|
disabled={isJobRunning}
|
||||||
|
initialSelectedId={initialSelectedPersonId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if selectedPerson && !isJobRunning}
|
{#if selectedPerson && !isJobRunning}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { disabled = false, onselect } = $props();
|
let { disabled = false, onselect, initialSelectedId = null } = $props();
|
||||||
|
|
||||||
let people = $state([]);
|
let people = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state(null);
|
let error = $state(null);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let selectedId = $state(null);
|
let selectedId = $state(initialSelectedId);
|
||||||
|
|
||||||
let filteredPeople = $derived(
|
let filteredPeople = $derived(
|
||||||
people.filter(p => {
|
people.filter(p => {
|
||||||
|
|
@ -24,6 +24,20 @@
|
||||||
const res = await fetch('/api/people');
|
const res = await fetch('/api/people');
|
||||||
if (!res.ok) throw new Error('Failed to load people');
|
if (!res.ok) throw new Error('Failed to load people');
|
||||||
people = await res.json();
|
people = await res.json();
|
||||||
|
|
||||||
|
// If we have an initial selection, find and notify parent about the full person object
|
||||||
|
if (initialSelectedId && !selectedId) {
|
||||||
|
selectedId = initialSelectedId;
|
||||||
|
}
|
||||||
|
if (selectedId) {
|
||||||
|
const person = people.find(p => p.id === selectedId);
|
||||||
|
if (person) {
|
||||||
|
onselect?.(person);
|
||||||
|
} else {
|
||||||
|
// Person no longer exists, clear selection
|
||||||
|
selectedId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -30,36 +30,32 @@
|
||||||
// Person being processed
|
// Person being processed
|
||||||
let personDisplay = $derived(progress.person_name || progress.person_id || null);
|
let personDisplay = $derived(progress.person_name || progress.person_id || null);
|
||||||
|
|
||||||
// Skip statistics from the backend
|
// Convert snake_case to readable label (e.g., "face_too_small" -> "Face too small")
|
||||||
let skipStats = $derived(progress.skip_stats || {
|
function formatLabel(key) {
|
||||||
face_too_small: 0,
|
return key
|
||||||
eyes_closed: 0,
|
.split('_')
|
||||||
head_turned: 0,
|
.map((word, i) => i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)
|
||||||
too_dark: 0,
|
.join(' ');
|
||||||
too_bright: 0,
|
}
|
||||||
no_face_detected: 0,
|
|
||||||
download_failed: 0,
|
// Skip statistics from the backend - dynamically handle any keys
|
||||||
decode_failed: 0,
|
let skipStats = $derived(progress.skip_stats || {});
|
||||||
crop_failed: 0,
|
|
||||||
total: 0,
|
// Calculate total from all numeric values in skip_stats
|
||||||
});
|
let skipTotal = $derived(
|
||||||
|
Object.values(skipStats).reduce((sum, val) => sum + (typeof val === 'number' ? val : 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate kept (successful) count: completed - skipped
|
// Calculate kept (successful) count: completed - skipped
|
||||||
let keptCount = $derived(Math.max(0, progress.completed - skipStats.total));
|
let keptCount = $derived(Math.max(0, progress.completed - skipTotal));
|
||||||
|
|
||||||
// Filter to only show non-zero skip reasons
|
// Dynamically build skip reasons from whatever the backend sends
|
||||||
|
// Filter to only show non-zero counts
|
||||||
let skipReasons = $derived(
|
let skipReasons = $derived(
|
||||||
[
|
Object.entries(skipStats)
|
||||||
{ label: 'Face too small', count: skipStats.face_too_small },
|
.filter(([_, count]) => typeof count === 'number' && count > 0)
|
||||||
{ label: 'Eyes closed', count: skipStats.eyes_closed },
|
.map(([key, count]) => ({ label: formatLabel(key), count }))
|
||||||
{ label: 'Head turned', count: skipStats.head_turned },
|
.sort((a, b) => b.count - a.count) // Sort by count descending
|
||||||
{ label: 'Too dark', count: skipStats.too_dark },
|
|
||||||
{ label: 'Too bright', count: skipStats.too_bright },
|
|
||||||
{ label: 'No face detected', count: skipStats.no_face_detected },
|
|
||||||
{ label: 'Download failed', count: skipStats.download_failed },
|
|
||||||
{ label: 'Decode failed', count: skipStats.decode_failed },
|
|
||||||
{ label: 'Crop failed', count: skipStats.crop_failed },
|
|
||||||
].filter(r => r.count > 0)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
async function cancelProcessing() {
|
async function cancelProcessing() {
|
||||||
|
|
@ -104,7 +100,7 @@
|
||||||
<div class="skip-header">
|
<div class="skip-header">
|
||||||
<span class="skip-label">Discarded:Kept</span>
|
<span class="skip-label">Discarded:Kept</span>
|
||||||
<span class="skip-totals">
|
<span class="skip-totals">
|
||||||
<span class="skipped-count">{skipStats.total}</span>
|
<span class="skipped-count">{skipTotal}</span>
|
||||||
<span class="separator">:</span>
|
<span class="separator">:</span>
|
||||||
<span class="kept-count">{keptCount}</span>
|
<span class="kept-count">{keptCount}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,25 @@ impl Default for VideoConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistable configuration (excludes sensitive API credentials).
|
||||||
|
///
|
||||||
|
/// This struct contains only the settings that can safely be written to disk.
|
||||||
|
/// API credentials should always come from environment variables.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PersistableConfig {
|
||||||
|
pub processing: ProcessingConfig,
|
||||||
|
pub video: VideoConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Config> for PersistableConfig {
|
||||||
|
fn from(config: &Config) -> Self {
|
||||||
|
Self {
|
||||||
|
processing: config.processing.clone(),
|
||||||
|
video: config.video.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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>) -> crate::error::Result<Self> {
|
||||||
|
|
@ -242,6 +261,18 @@ impl Config {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save processing and video configuration to a TOML file.
|
||||||
|
///
|
||||||
|
/// 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<()> {
|
||||||
|
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)))?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -262,4 +293,34 @@ mod tests {
|
||||||
let result = config.validate();
|
let result = config.validate();
|
||||||
assert!(result.is_err());
|
assert!(result.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.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");
|
||||||
|
|
||||||
|
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("framerate = 30"));
|
||||||
|
assert!(content.contains("crf = 18"));
|
||||||
|
|
||||||
|
// Verify API credentials are NOT in the file
|
||||||
|
assert!(!content.contains("api_key"));
|
||||||
|
assert!(!content.contains("base_url"));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use crate::face_processing::{AssetResult, ProcessedFace, SkipReason};
|
||||||
use crate::immich_api::{Asset, FaceData, ImmichClient};
|
use crate::immich_api::{Asset, FaceData, ImmichClient};
|
||||||
use crate::utils::sanitize_folder_name;
|
use crate::utils::sanitize_folder_name;
|
||||||
use crate::video::compile_timelapse;
|
use crate::video::compile_timelapse;
|
||||||
use crate::web::{AppState, JobStatus, Progress, SkipStats};
|
use crate::web::{AppState, AtomicSkipStats, JobStatus, Progress, SkipStats};
|
||||||
|
|
||||||
use image::ImageFormat;
|
use image::ImageFormat;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
@ -241,16 +241,8 @@ async fn run_job_inner(
|
||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
let output_dirs = Arc::new(output_dirs);
|
let output_dirs = Arc::new(output_dirs);
|
||||||
|
|
||||||
// Atomic counters for real-time skip statistics
|
// Atomic counters for real-time skip statistics (consolidated into single struct)
|
||||||
let skip_face_too_small = Arc::new(AtomicU32::new(0));
|
let skip_stats = Arc::new(AtomicSkipStats::new());
|
||||||
let skip_eyes_closed = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_head_turned = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_too_dark = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_too_bright = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_no_face = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_download_failed = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_decode_failed = Arc::new(AtomicU32::new(0));
|
|
||||||
let skip_crop_failed = Arc::new(AtomicU32::new(0));
|
|
||||||
|
|
||||||
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
||||||
|
|
||||||
|
|
@ -275,16 +267,8 @@ async fn run_job_inner(
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let task_cancel_token = cancel_token.clone();
|
let task_cancel_token = cancel_token.clone();
|
||||||
|
|
||||||
// Clone skip counters for this task
|
// Clone skip stats for this task
|
||||||
let skip_face_too_small = skip_face_too_small.clone();
|
let skip_stats = skip_stats.clone();
|
||||||
let skip_eyes_closed = skip_eyes_closed.clone();
|
|
||||||
let skip_head_turned = skip_head_turned.clone();
|
|
||||||
let skip_too_dark = skip_too_dark.clone();
|
|
||||||
let skip_too_bright = skip_too_bright.clone();
|
|
||||||
let skip_no_face = skip_no_face.clone();
|
|
||||||
let skip_download_failed = skip_download_failed.clone();
|
|
||||||
let skip_decode_failed = skip_decode_failed.clone();
|
|
||||||
let skip_crop_failed = skip_crop_failed.clone();
|
|
||||||
|
|
||||||
// Clone person info for this task
|
// Clone person info for this task
|
||||||
let person_id = task_person_id.clone();
|
let person_id = task_person_id.clone();
|
||||||
|
|
@ -313,34 +297,13 @@ async fn run_job_inner(
|
||||||
|
|
||||||
// Update skip counters based on result
|
// Update skip counters based on result
|
||||||
if let AssetResult::Skipped { reason, .. } = &result {
|
if let AssetResult::Skipped { reason, .. } = &result {
|
||||||
match reason {
|
skip_stats.increment(reason);
|
||||||
SkipReason::FaceTooSmall => skip_face_too_small.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::EyesClosed => skip_eyes_closed.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::HeadTurned => skip_head_turned.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::TooDark => skip_too_dark.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::TooBright => skip_too_bright.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::NoFaceDetected => skip_no_face.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::DownloadFailed => skip_download_failed.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::DecodeFailed => skip_decode_failed.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::CropFailed => skip_crop_failed.fetch_add(1, Ordering::SeqCst),
|
|
||||||
SkipReason::Cancelled => 0, // Don't count cancellation
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress with current skip stats (only if not cancelled)
|
// Update progress with current skip stats (only if not cancelled)
|
||||||
if !task_cancel_token.is_cancelled() {
|
if !task_cancel_token.is_cancelled() {
|
||||||
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
|
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
let current_skip_stats = SkipStats {
|
let current_skip_stats = skip_stats.snapshot();
|
||||||
face_too_small: skip_face_too_small.load(Ordering::SeqCst),
|
|
||||||
eyes_closed: skip_eyes_closed.load(Ordering::SeqCst),
|
|
||||||
head_turned: skip_head_turned.load(Ordering::SeqCst),
|
|
||||||
too_dark: skip_too_dark.load(Ordering::SeqCst),
|
|
||||||
too_bright: skip_too_bright.load(Ordering::SeqCst),
|
|
||||||
no_face_detected: skip_no_face.load(Ordering::SeqCst),
|
|
||||||
download_failed: skip_download_failed.load(Ordering::SeqCst),
|
|
||||||
decode_failed: skip_decode_failed.load(Ordering::SeqCst),
|
|
||||||
crop_failed: skip_crop_failed.load(Ordering::SeqCst),
|
|
||||||
};
|
|
||||||
state
|
state
|
||||||
.update_progress(Progress {
|
.update_progress(Progress {
|
||||||
status: JobStatus::Running,
|
status: JobStatus::Running,
|
||||||
|
|
@ -378,17 +341,7 @@ async fn run_job_inner(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get final skip statistics from atomic counters
|
// Get final skip statistics from atomic counters
|
||||||
let skip_stats = SkipStats {
|
let final_skip_stats = skip_stats.snapshot();
|
||||||
face_too_small: skip_face_too_small.load(Ordering::SeqCst),
|
|
||||||
eyes_closed: skip_eyes_closed.load(Ordering::SeqCst),
|
|
||||||
head_turned: skip_head_turned.load(Ordering::SeqCst),
|
|
||||||
too_dark: skip_too_dark.load(Ordering::SeqCst),
|
|
||||||
too_bright: skip_too_bright.load(Ordering::SeqCst),
|
|
||||||
no_face_detected: skip_no_face.load(Ordering::SeqCst),
|
|
||||||
download_failed: skip_download_failed.load(Ordering::SeqCst),
|
|
||||||
decode_failed: skip_decode_failed.load(Ordering::SeqCst),
|
|
||||||
crop_failed: skip_crop_failed.load(Ordering::SeqCst),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Count results
|
// Count results
|
||||||
let successful = results
|
let successful = results
|
||||||
|
|
@ -399,7 +352,7 @@ async fn run_job_inner(
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| matches!(r, AssetResult::Error { .. }))
|
.filter(|r| matches!(r, AssetResult::Error { .. }))
|
||||||
.count();
|
.count();
|
||||||
let skipped = skip_stats.total();
|
let skipped = final_skip_stats.total();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Processing complete: {} successful, {} skipped, {} errors",
|
"Processing complete: {} successful, {} skipped, {} errors",
|
||||||
|
|
@ -415,7 +368,7 @@ async fn run_job_inner(
|
||||||
completed: total,
|
completed: total,
|
||||||
total,
|
total,
|
||||||
message: Some("Processing complete, preparing video...".to_string()),
|
message: Some("Processing complete, preparing video...".to_string()),
|
||||||
skip_stats: skip_stats.clone(),
|
skip_stats: final_skip_stats.clone(),
|
||||||
person_id: Some(params.person_id.clone()),
|
person_id: Some(params.person_id.clone()),
|
||||||
person_name: params.person_name.clone(),
|
person_name: params.person_name.clone(),
|
||||||
})
|
})
|
||||||
|
|
@ -440,7 +393,7 @@ async fn run_job_inner(
|
||||||
completed: 0,
|
completed: 0,
|
||||||
total: successful as u32,
|
total: successful as u32,
|
||||||
message: Some("Compiling video...".to_string()),
|
message: Some("Compiling video...".to_string()),
|
||||||
skip_stats: skip_stats.clone(),
|
skip_stats: final_skip_stats.clone(),
|
||||||
person_id: Some(params.person_id.clone()),
|
person_id: Some(params.person_id.clone()),
|
||||||
person_name: params.person_name.clone(),
|
person_name: params.person_name.clone(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,21 @@ use axum::{
|
||||||
};
|
};
|
||||||
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 {
|
||||||
|
|
@ -53,6 +68,86 @@ pub struct VideoConfigUpdate {
|
||||||
pub crf: Option<u32>,
|
pub crf: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(
|
||||||
|
"processing.max_workers",
|
||||||
|
format!("must be between 1 and 64, got {}", v),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate video configuration update values.
|
||||||
|
fn validate_video_config(vid: &VideoConfigUpdate) -> Result<(), ValidationError> {
|
||||||
|
if let Some(v) = vid.framerate {
|
||||||
|
if v == 0 || v > 120 {
|
||||||
|
return Err(ValidationError::new(
|
||||||
|
"video.framerate",
|
||||||
|
format!("must be between 1 and 120, got {}", v),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(v) = vid.crf {
|
||||||
|
if v > 51 {
|
||||||
|
return Err(ValidationError::new(
|
||||||
|
"video.crf",
|
||||||
|
format!("must be between 0 and 51, got {}", v),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
"video.codec",
|
||||||
|
format!(
|
||||||
|
"must be one of: {}, got '{}'",
|
||||||
|
valid_codecs.join(", "),
|
||||||
|
v
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Update configuration.
|
/// Update configuration.
|
||||||
pub async fn update_config(
|
pub async fn update_config(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -69,6 +164,24 @@ pub async fn update_config(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate input before updating
|
||||||
|
if let Some(ref proc) = update.processing {
|
||||||
|
validate_processing_config(proc).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Invalid {}: {}", e.field, e.message),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Some(ref vid) = update.video {
|
||||||
|
validate_video_config(vid).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Invalid {}: {}", e.field, e.message),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
// Update config
|
// Update config
|
||||||
{
|
{
|
||||||
let mut config = state.config.write().await;
|
let mut config = state.config.write().await;
|
||||||
|
|
@ -109,7 +222,12 @@ pub async fn update_config(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Configuration updated");
|
// Persist to file (non-blocking, best-effort)
|
||||||
|
if let Err(e) = config.save_to_file("config.toml") {
|
||||||
|
tracing::warn!("Failed to persist config to file: {}", e);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Configuration updated and persisted to config.toml");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return updated config
|
// Return updated config
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Application state for the web server.
|
//! Application state for the web server.
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, RwLock};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
@ -54,6 +55,64 @@ impl SkipStats {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomic version of SkipStats for thread-safe concurrent updates.
|
||||||
|
///
|
||||||
|
/// This consolidates all skip counters into a single struct that can be
|
||||||
|
/// shared across async tasks with Arc, avoiding the need to clone multiple
|
||||||
|
/// individual atomic counters.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct AtomicSkipStats {
|
||||||
|
pub face_too_small: AtomicU32,
|
||||||
|
pub eyes_closed: AtomicU32,
|
||||||
|
pub head_turned: AtomicU32,
|
||||||
|
pub too_dark: AtomicU32,
|
||||||
|
pub too_bright: AtomicU32,
|
||||||
|
pub no_face_detected: AtomicU32,
|
||||||
|
pub download_failed: AtomicU32,
|
||||||
|
pub decode_failed: AtomicU32,
|
||||||
|
pub crop_failed: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AtomicSkipStats {
|
||||||
|
/// Create a new AtomicSkipStats with all counters at zero.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment a specific counter and return the new value.
|
||||||
|
pub fn increment(&self, reason: &crate::face_processing::SkipReason) -> u32 {
|
||||||
|
use crate::face_processing::SkipReason;
|
||||||
|
let counter = match reason {
|
||||||
|
SkipReason::FaceTooSmall => &self.face_too_small,
|
||||||
|
SkipReason::EyesClosed => &self.eyes_closed,
|
||||||
|
SkipReason::HeadTurned => &self.head_turned,
|
||||||
|
SkipReason::TooDark => &self.too_dark,
|
||||||
|
SkipReason::TooBright => &self.too_bright,
|
||||||
|
SkipReason::NoFaceDetected => &self.no_face_detected,
|
||||||
|
SkipReason::DownloadFailed => &self.download_failed,
|
||||||
|
SkipReason::DecodeFailed => &self.decode_failed,
|
||||||
|
SkipReason::CropFailed => &self.crop_failed,
|
||||||
|
SkipReason::Cancelled => return 0, // Don't count cancellation
|
||||||
|
};
|
||||||
|
counter.fetch_add(1, Ordering::SeqCst) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a snapshot of the current stats as a non-atomic SkipStats.
|
||||||
|
pub fn snapshot(&self) -> SkipStats {
|
||||||
|
SkipStats {
|
||||||
|
face_too_small: self.face_too_small.load(Ordering::SeqCst),
|
||||||
|
eyes_closed: self.eyes_closed.load(Ordering::SeqCst),
|
||||||
|
head_turned: self.head_turned.load(Ordering::SeqCst),
|
||||||
|
too_dark: self.too_dark.load(Ordering::SeqCst),
|
||||||
|
too_bright: self.too_bright.load(Ordering::SeqCst),
|
||||||
|
no_face_detected: self.no_face_detected.load(Ordering::SeqCst),
|
||||||
|
download_failed: self.download_failed.load(Ordering::SeqCst),
|
||||||
|
decode_failed: self.decode_failed.load(Ordering::SeqCst),
|
||||||
|
crop_failed: self.crop_failed.load(Ordering::SeqCst),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Progress information for a running job.
|
/// Progress information for a running job.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Progress {
|
pub struct Progress {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue