Add settings control in web UI

This commit is contained in:
Arnaud_Cayrol 2026-01-27 22:36:26 +01:00
parent ca29854ab3
commit 38970b8849
3 changed files with 502 additions and 2 deletions

View file

@ -4,12 +4,17 @@
import ProcessingControls from './lib/components/ProcessingControls.svelte';
import ProgressDisplay from './lib/components/ProgressDisplay.svelte';
import ResultsView from './lib/components/ResultsView.svelte';
import SettingsPanel from './lib/components/SettingsPanel.svelte';
let connectionOk = $state(false);
let selectedPerson = $state(null);
let jobStatus = $state('idle');
let progress = $state({ completed: 0, total: 0, message: '' });
let isJobRunning = $derived(
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
function handleConnectionChange(data) {
connectionOk = data.connected;
}
@ -31,10 +36,14 @@
</header>
{#if connectionOk}
<section class="settings">
<SettingsPanel disabled={isJobRunning} />
</section>
<section class="controls">
<PeopleSelector
onselect={handlePersonSelect}
disabled={jobStatus === 'running' || jobStatus === 'compiling_video'}
disabled={isJobRunning}
/>
{#if selectedPerson}

View file

@ -0,0 +1,382 @@
<script>
import { onMount } from 'svelte';
let { disabled = false } = $props();
let isOpen = $state(false);
let loading = $state(true);
let saving = $state(false);
let error = $state(null);
let saveMessage = $state(null);
// Config state
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,
},
video: {
framerate: 15,
enabled: true,
codec: 'libx264',
crf: 23,
},
});
async function loadConfig() {
loading = true;
error = null;
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('Failed to load config');
config = await res.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function saveConfig() {
saving = true;
error = null;
saveMessage = null;
try {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to save config');
}
config = await res.json();
saveMessage = 'Settings saved';
setTimeout(() => (saveMessage = null), 2000);
} catch (e) {
error = e.message;
} finally {
saving = false;
}
}
function toggle() {
isOpen = !isOpen;
if (isOpen && loading) {
loadConfig();
}
}
onMount(() => {
loadConfig();
});
</script>
<div class="settings-panel">
<button class="toggle-btn" onclick={toggle}>
<span class="icon">{isOpen ? '▼' : '▶'}</span>
Settings
</button>
{#if isOpen}
<div class="settings-content">
{#if loading}
<p class="loading">Loading settings...</p>
{:else if error}
<p class="error">{error}</p>
<button class="retry-btn" onclick={loadConfig}>Retry</button>
{:else}
<div class="settings-grid">
<fieldset disabled={disabled || saving}>
<legend>Processing</legend>
<label>
<span>Output Size (px)</span>
<input
type="number"
bind:value={config.processing.resize_size}
min="128"
max="2048"
step="64"
/>
</label>
<label>
<span>Min Face Size (px)</span>
<input
type="number"
bind:value={config.processing.face_resolution_threshold}
min="20"
max="500"
step="10"
/>
</label>
<label>
<span>Pose Threshold (deg)</span>
<input
type="number"
bind:value={config.processing.pose_threshold}
min="5"
max="90"
step="5"
/>
</label>
<label>
<span>Eye Aspect Ratio</span>
<input
type="number"
bind:value={config.processing.ear_threshold}
min="0.1"
max="0.5"
step="0.05"
/>
</label>
<label>
<span>Parallel Workers</span>
<input
type="number"
bind:value={config.processing.max_workers}
min="1"
max="32"
step="1"
/>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={config.processing.keep_intermediates} />
<span>Keep debug images</span>
</label>
</fieldset>
<fieldset disabled={disabled || saving}>
<legend>Video</legend>
<label>
<span>Framerate (fps)</span>
<input
type="number"
bind:value={config.video.framerate}
min="1"
max="60"
step="1"
/>
</label>
<label>
<span>Quality (CRF)</span>
<input
type="number"
bind:value={config.video.crf}
min="0"
max="51"
step="1"
/>
<span class="hint">Lower = better quality, larger file</span>
</label>
<label>
<span>Codec</span>
<select bind:value={config.video.codec}>
<option value="libx264">H.264 (libx264)</option>
<option value="libx265">H.265 (libx265)</option>
<option value="libvpx-vp9">VP9 (libvpx-vp9)</option>
</select>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={config.video.enabled} />
<span>Auto-compile video</span>
</label>
</fieldset>
</div>
<div class="actions">
<button class="save-btn" onclick={saveConfig} disabled={disabled || saving}>
{saving ? 'Saving...' : 'Save Settings'}
</button>
{#if saveMessage}
<span class="save-message">{saveMessage}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<style>
.settings-panel {
background: #1a1a1a;
border-radius: 8px;
margin-bottom: 1rem;
}
.toggle-btn {
width: 100%;
padding: 1rem 1.5rem;
background: none;
border: none;
color: #e0e0e0;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
text-align: left;
}
.toggle-btn:hover {
background: #252525;
}
.icon {
font-size: 0.75rem;
color: #888;
}
.settings-content {
padding: 0 1.5rem 1.5rem;
}
.loading,
.error {
font-size: 0.875rem;
padding: 1rem;
text-align: center;
}
.error {
color: #dc2626;
}
.retry-btn {
display: block;
margin: 0 auto;
padding: 0.5rem 1rem;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
fieldset {
border: 1px solid #333;
border-radius: 6px;
padding: 1rem;
}
fieldset:disabled {
opacity: 0.5;
}
legend {
font-size: 0.75rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 0.5rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
label > span:first-child {
font-size: 0.75rem;
color: #888;
}
input[type='number'],
select {
padding: 0.5rem 0.75rem;
border: 1px solid #333;
border-radius: 4px;
background: #0f0f0f;
color: #e0e0e0;
font-size: 0.875rem;
}
input[type='number']:focus,
select:focus {
outline: none;
border-color: #4f46e5;
}
select {
cursor: pointer;
}
.checkbox-label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.checkbox-label input {
width: 1rem;
height: 1rem;
accent-color: #4f46e5;
}
.checkbox-label span {
font-size: 0.875rem;
color: #e0e0e0;
}
.hint {
font-size: 0.7rem;
color: #666;
}
.actions {
margin-top: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.save-btn {
padding: 0.75rem 1.5rem;
background: #4f46e5;
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.save-btn:hover:not(:disabled) {
background: #4338ca;
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.save-message {
font-size: 0.875rem;
color: #22c55e;
}
</style>

View file

@ -1,5 +1,6 @@
//! HTTP route handlers.
use crate::config::{ProcessingConfig, VideoConfig};
use crate::immich_api::ImmichClient;
use crate::job::{run_job, JobParams};
use crate::web::state::{AppState, JobStatus, Progress};
@ -8,7 +9,7 @@ use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Json, Response},
routing::{delete, get, post},
routing::{delete, get, post, put},
Router,
};
use serde::{Deserialize, Serialize};
@ -37,6 +38,8 @@ pub fn create_router(state: AppState) -> Router {
.route("/api/cancel", post(cancel_processing))
.route("/api/output", delete(cleanup_all_output))
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
.route("/api/config", get(get_config))
.route("/api/config", put(update_config))
// Serve output files (video, images)
.nest_service("/output", ServeDir::new(output_dir))
// Serve frontend static files (fallback to index.html for SPA routing)
@ -403,3 +406,109 @@ async fn cleanup_output_folder(
message: format!("Deleted folder '{}'", folder_name),
}))
}
/// Configuration response (excludes sensitive API credentials).
/// Reuses config types which already derive Serialize.
#[derive(Serialize)]
struct ConfigResponse {
processing: ProcessingConfig,
video: VideoConfig,
}
/// Get current configuration (excluding sensitive data).
async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
let config = state.config.read().await;
Json(ConfigResponse {
processing: config.processing.clone(),
video: config.video.clone(),
})
}
/// Configuration update request.
#[derive(Deserialize)]
struct ConfigUpdateRequest {
processing: Option<ProcessingConfigUpdate>,
video: Option<VideoConfigUpdate>,
}
#[derive(Deserialize)]
struct ProcessingConfigUpdate {
resize_size: Option<u32>,
face_resolution_threshold: Option<u32>,
pose_threshold: Option<f32>,
ear_threshold: Option<f32>,
max_workers: Option<usize>,
keep_intermediates: Option<bool>,
}
#[derive(Deserialize)]
struct VideoConfigUpdate {
framerate: Option<u32>,
enabled: Option<bool>,
codec: Option<String>,
crf: Option<u32>,
}
/// Update configuration.
async fn update_config(
State(state): State<AppState>,
Json(update): Json<ConfigUpdateRequest>,
) -> Result<Json<ConfigResponse>, (StatusCode, String)> {
// Check if a job is running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((
StatusCode::CONFLICT,
"Cannot update config while a job is running".to_string(),
));
}
}
// 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(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.codec {
config.video.codec = v;
}
if let Some(v) = vid.crf {
config.video.crf = v;
}
}
tracing::info!("Configuration updated");
}
// Return updated config
Ok(get_config(State(state)).await)
}