Implement a timestamp processing step, minor frontend fixes

This commit is contained in:
Arnaud_Cayrol 2026-02-09 21:24:52 +01:00
parent 2746f18621
commit fb4fc59e84
15 changed files with 485 additions and 63 deletions

View file

@ -9,7 +9,7 @@
import ResultsView from './lib/components/ResultsView.svelte';
import SettingsPanel from './lib/components/SettingsPanel.svelte';
import { sanitizeFolderName } from './lib/utils.js';
import { STORAGE_KEYS, WS, JOB_STATUS } from './lib/constants.js';
import { STORAGE_KEYS, WS, JOB_STATUS, API } from './lib/constants.js';
// Load persisted state from localStorage
function loadPersistedPersonId() {
@ -66,7 +66,7 @@
async function loadOutputFolders() {
try {
const res = await fetch('/api/output');
const res = await fetch(API.output);
if (res.ok) {
outputFolders = await res.json();
}

View file

@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { handleError } from '../errorHandler.js';
import { API } from '../constants.js';
let { onchange } = $props();
@ -13,7 +14,7 @@
error = null;
try {
const res = await fetch('/api/connection');
const res = await fetch(API.connection);
const data = await res.json();
if (data.connected) {

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { formatSize } from '../utils.js';
import { handleError, showErrorAlert } from '../errorHandler.js';
import { API } from '../constants.js';
let {
folderName,
@ -26,7 +27,7 @@
error = null;
cacheBust = Date.now();
try {
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/images`);
const res = await fetch(`${API.output}/${encodeURIComponent(folderName)}/images`);
if (!res.ok) throw res;
const data = await res.json();
images = data.images;
@ -68,7 +69,7 @@
deleting = true;
try {
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/images`, {
const res = await fetch(`${API.output}/${encodeURIComponent(folderName)}/images`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: Array.from(selectedImages) })
@ -98,7 +99,7 @@
compiling = true;
try {
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/compile`, {
const res = await fetch(`${API.output}/${encodeURIComponent(folderName)}/compile`, {
method: 'POST'
});

View file

@ -1,6 +1,7 @@
<script>
import { formatSize } from '../utils.js';
import { showErrorAlert } from '../errorHandler.js';
import { API } from '../constants.js';
let { disabled = false, folders = [], onOpenGallery, onFolderDeleted } = $props();
@ -13,7 +14,7 @@
deleting = name;
try {
const res = await fetch(`/api/output/${encodeURIComponent(name)}`, {
const res = await fetch(`${API.output}/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
if (!res.ok) throw res;
@ -33,7 +34,7 @@
deleting = '__all__';
try {
const res = await fetch('/api/output', {
const res = await fetch(API.output, {
method: 'DELETE',
});
if (!res.ok) throw res;

View file

@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { handleError } from '../errorHandler.js';
import { API } from '../constants.js';
let { disabled = false, onselect, initialSelectedId = null } = $props();
@ -27,7 +28,7 @@
error = null;
try {
const res = await fetch('/api/people');
const res = await fetch(API.people);
if (!res.ok) throw res;
people = await res.json();

View file

@ -1,6 +1,6 @@
<script>
import { sanitizeFolderName, formatSize } from '../utils.js';
import { JOB_STATUS } from '../constants.js';
import { JOB_STATUS, API } from '../constants.js';
import { handleError } from '../errorHandler.js';
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
@ -32,7 +32,7 @@
loadingCount = true;
assetCount = null;
try {
const res = await fetch(`/api/people/${encodeURIComponent(id)}/asset-count`);
const res = await fetch(`${API.people}/${encodeURIComponent(id)}/asset-count`);
if (!res.ok) throw res;
assetCount = await res.json();
} catch (e) {
@ -45,7 +45,7 @@
async function startProcessing() {
starting = true;
try {
const res = await fetch('/api/start', {
const res = await fetch(API.start, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -89,7 +89,7 @@
async function cancelProcessing() {
try {
const res = await fetch('/api/cancel', { method: 'POST' });
const res = await fetch(API.cancel, { method: 'POST' });
if (!res.ok) throw res;
} catch (e) {
await handleError('Cancel failed', e);

View file

@ -1,4 +1,6 @@
<script>
import { API } from '../constants.js';
let { jobStatus, progress } = $props();
let percentage = $derived(
@ -96,7 +98,7 @@
async function cancelProcessing() {
try {
await fetch('/api/cancel', { method: 'POST' });
await fetch(API.cancel, { method: 'POST' });
} catch (e) {
console.error('Cancel failed:', e);
}

View file

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { DEFAULT_CONFIG, TIMING } from '../constants.js';
import { DEFAULT_CONFIG, TIMING, API } from '../constants.js';
import { handleError } from '../errorHandler.js';
import EyeIndicator from './visual/EyeIndicator.svelte';
import AlignmentIndicator from './visual/AlignmentIndicator.svelte';
@ -15,54 +15,14 @@
let saveMessage = $state(null);
let activeTab = $state('face');
// Config state - matches backend nested structure
let config = $state({
processing: {
max_workers: 4,
face_resolution: {
enabled: true,
min_size: 80,
},
brightness: {
enabled: false,
min_brightness: 0.1,
max_brightness: 0.95,
},
blur: {
enabled: false,
min_sharpness: 15.0,
},
head_pose: {
enabled: true,
max_yaw: 35.0,
max_pitch: 35.0,
max_roll: 25.0,
},
eye_filter: {
enabled: false,
min_ear: 0.2,
},
output: {
size: 512,
keep_intermediates: false,
},
alignment: {
eye_distance: 0.3,
},
},
video: {
enabled: true,
framerate: 15,
codec: 'libx264',
crf: 23,
},
});
// Config state - initialized from DEFAULT_CONFIG, then loaded from API
let config = $state(JSON.parse(JSON.stringify(DEFAULT_CONFIG)));
async function loadConfig() {
loading = true;
error = null;
try {
const res = await fetch('/api/config');
const res = await fetch(API.config);
if (!res.ok) throw res;
config = await res.json();
} catch (e) {
@ -111,6 +71,13 @@
alignment: {
eye_distance: Number(config.processing.alignment.eye_distance),
},
timestamp: {
enabled: config.processing.timestamp.enabled,
position: config.processing.timestamp.position,
year: config.processing.timestamp.year,
month: config.processing.timestamp.month,
day: config.processing.timestamp.day,
},
},
video: {
enabled: config.video.enabled,
@ -120,7 +87,7 @@
},
};
const res = await fetch('/api/config', {
const res = await fetch(API.config, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configToSend),
@ -242,7 +209,7 @@
{#if config.processing.brightness.enabled}
<div class="brightness-visual-control sub-setting">
<div class="brightness-hint">
Drag the markers to set the acceptable brightness range
Discard photos if the face is under/over exposed
</div>
<div class="brightness-indicator-container">
<BrightnessIndicator
@ -440,6 +407,56 @@
</label>
<input id="keep-intermediates" type="checkbox" bind:checked={config.processing.output.keep_intermediates} />
</div>
<!-- Timestamp Section -->
<div class="setting-section">
<div class="section-header">
<span class="section-title">Timestamp Overlay</span>
<input
type="checkbox"
bind:checked={config.processing.timestamp.enabled}
/>
</div>
{#if config.processing.timestamp.enabled}
<div class="setting-row sub-setting">
<label for="timestamp-position">
<span class="setting-label">Position</span>
<span class="setting-hint">Where to place the timestamp</span>
</label>
<select id="timestamp-position" bind:value={config.processing.timestamp.position}>
<option value="top_left">Top Left</option>
<option value="top_right">Top Right</option>
<option value="bottom_left">Bottom Left</option>
<option value="bottom_right">Bottom Right</option>
</select>
</div>
<div class="setting-row sub-setting checkbox-row">
<label for="timestamp-year">
<span class="setting-label">Show Year</span>
<span class="setting-hint">Display year in timestamp</span>
</label>
<input id="timestamp-year" type="checkbox" bind:checked={config.processing.timestamp.year} />
</div>
<div class="setting-row sub-setting checkbox-row">
<label for="timestamp-month">
<span class="setting-label">Show Month</span>
<span class="setting-hint">Display month in timestamp</span>
</label>
<input id="timestamp-month" type="checkbox" bind:checked={config.processing.timestamp.month} />
</div>
<div class="setting-row sub-setting checkbox-row">
<label for="timestamp-day">
<span class="setting-label">Show Day</span>
<span class="setting-hint">Display day in timestamp</span>
</label>
<input id="timestamp-day" type="checkbox" bind:checked={config.processing.timestamp.day} />
</div>
{/if}
</div>
</fieldset>
{:else if activeTab === 'video'}
<fieldset disabled={disabled || saving}>

View file

@ -159,6 +159,13 @@ export const DEFAULT_CONFIG = {
alignment: {
eye_distance: 0.3,
},
timestamp: {
enabled: false,
position: 'bottom_left',
year: true,
month: false,
day: false,
},
},
video: {
enabled: true,

View file

@ -393,6 +393,58 @@ impl AlignmentConfig {
}
}
/// Position for timestamp text overlay.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TextPosition {
#[default]
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// Timestamp overlay configuration.
///
/// Overlays date information on the processed image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampConfig {
/// Whether timestamp overlay is enabled.
pub enabled: bool,
/// Position of the timestamp text.
pub position: TextPosition,
/// Whether to display the year.
pub year: bool,
/// Whether to display the month.
pub month: bool,
/// Whether to display the day.
pub day: bool,
}
impl Default for TimestampConfig {
fn default() -> Self {
Self {
enabled: false,
position: TextPosition::BottomLeft,
year: true,
month: false,
day: false,
}
}
}
impl TimestampConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
// No validation needed for booleans and enum
Ok(())
}
}
// ============================================================================
// Main Processing Configuration
// ============================================================================
@ -434,6 +486,10 @@ pub struct ProcessingConfig {
/// Face alignment settings (landmark-based).
#[serde(default)]
pub alignment: AlignmentConfig,
/// Timestamp overlay settings.
#[serde(default)]
pub timestamp: TimestampConfig,
}
impl Default for ProcessingConfig {
@ -447,6 +503,7 @@ impl Default for ProcessingConfig {
eye_filter: EyeFilterConfig::default(),
output: OutputConfig::default(),
alignment: AlignmentConfig::default(),
timestamp: TimestampConfig::default(),
}
}
}
@ -461,6 +518,7 @@ impl ProcessingConfig {
self.eye_filter.validate()?;
self.output.validate()?;
self.alignment.validate()?;
self.timestamp.validate()?;
Ok(())
}
}

View file

@ -100,10 +100,12 @@ impl Pipeline {
/// 2. DecodeImageStep - Load and orient the image (always)
/// 3. CropAndResizeStep - Extract face region with padding and resize to output size (always)
/// 4. BrightnessStep - Filter by luminance on face region within cropped image (if enabled)
/// 5. BlurStep - Filter blurry images using gradient magnitude (if enabled) /// 6. HeadPoseStep - Filter non-frontal faces (if enabled)
/// 5. BlurStep - Filter blurry images using gradient magnitude (if enabled)
/// 6. HeadPoseStep - Filter non-frontal faces (if enabled)
/// 7. LandmarksStep - Detect 68 facial landmarks (always)
/// 8. EyeFilterStep - Filter closed eyes by EAR (if enabled)
/// 9. AlignmentStep - Align face based on eye positions and resize to final output (always)
/// 10. TimestampStep - Overlay date on image (if enabled)
pub fn with_steps_from_config(config: &Config) -> Self {
use steps::*;
@ -146,6 +148,11 @@ impl Pipeline {
// Core: Always align face based on eye positions (also performs final resize)
pipeline.add_step(Box::new(AlignmentStep));
// Optional: Timestamp overlay
if config.processing.timestamp.enabled {
pipeline.add_step(Box::new(TimestampStep));
}
pipeline
}
@ -164,6 +171,7 @@ impl Pipeline {
pipeline.add_step(Box::new(LandmarksStep));
pipeline.add_step(Box::new(EyeFilterStep));
pipeline.add_step(Box::new(AlignmentStep));
pipeline.add_step(Box::new(TimestampStep));
pipeline
}
@ -366,6 +374,7 @@ mod tests {
assert!(ids.contains(&"landmarks"));
assert!(ids.contains(&"eye_filter"));
assert!(ids.contains(&"alignment"));
assert!(ids.contains(&"timestamp"));
}
#[test]

View file

@ -99,5 +99,4 @@ mod tests {
_ => panic!("Expected Continue"),
}
}
}

View file

@ -12,6 +12,7 @@ mod eye_filter;
mod face_resolution;
mod head_pose;
mod landmarks;
mod timestamp;
pub use alignment::AlignmentStep;
pub use blur::BlurStep;
@ -22,3 +23,4 @@ pub use eye_filter::EyeFilterStep;
pub use face_resolution::FaceResolutionStep;
pub use head_pose::HeadPoseStep;
pub use landmarks::LandmarksStep;
pub use timestamp::TimestampStep;

View file

@ -0,0 +1,320 @@
//! Timestamp overlay step.
//!
//! Overlays date information on the processed image.
use crate::config::{Config, TextPosition};
use crate::pipeline::{draw_simple_text, PipelineContext, ProcessingStep, StepOutcome};
use async_trait::async_trait;
use image::{DynamicImage, Rgb};
/// Overlays timestamp on the image.
///
/// This step renders date information (year, month, day) on the final image
/// at the specified position.
pub struct TimestampStep;
impl TimestampStep {
/// Parse a timestamp string and extract year, month, day.
///
/// Supports formats like:
/// - "2024-01-15" (ISO date)
/// - "2024-01-15T12:34:56" (ISO datetime)
/// - "2024-01-15T12:34:56.789Z" (ISO datetime with milliseconds)
///
/// Returns (year, month, day) or None if parsing fails.
fn parse_timestamp(timestamp: &str) -> Option<(String, String, String)> {
// Try to extract date portion (before 'T' if present)
let date_part = timestamp.split('T').next()?;
// Split by '-' to get year, month, day
let parts: Vec<&str> = date_part.split('-').collect();
if parts.len() >= 3 {
Some((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
))
} else {
None
}
}
/// Format the date string based on configuration.
fn format_date(
year: &str,
month: &str,
day: &str,
config: &crate::config::TimestampConfig,
) -> String {
let mut parts = Vec::new();
if config.year {
parts.push(year.to_string());
}
if config.month {
parts.push(month.to_string());
}
if config.day {
parts.push(day.to_string());
}
if parts.is_empty() {
return String::new();
}
parts.join("-")
}
/// Calculate text position based on configuration.
///
/// Returns (x, y) coordinates for the top-left corner of the text.
fn calculate_position(
position: TextPosition,
text: &str,
img_width: u32,
img_height: u32,
) -> (u32, u32) {
const CHAR_WIDTH: u32 = 6; // 5 pixels + 1 spacing
const CHAR_HEIGHT: u32 = 7;
const MARGIN: u32 = 8;
let text_width = text.len() as u32 * CHAR_WIDTH;
let text_height = CHAR_HEIGHT;
match position {
TextPosition::TopLeft => (MARGIN, MARGIN),
TextPosition::TopRight => (img_width.saturating_sub(text_width + MARGIN), MARGIN),
TextPosition::BottomLeft => (MARGIN, img_height.saturating_sub(text_height + MARGIN)),
TextPosition::BottomRight => (
img_width.saturating_sub(text_width + MARGIN),
img_height.saturating_sub(text_height + MARGIN),
),
}
}
}
#[async_trait]
impl ProcessingStep for TimestampStep {
fn id(&self) -> &'static str {
"timestamp"
}
fn name(&self) -> &'static str {
"Timestamp Overlay"
}
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
let step_config = &config.processing.timestamp;
let image = match ctx.take_image("timestamp overlay") {
Ok(img) => img,
Err(e) => return StepOutcome::Error { ctx, error: e },
};
// Parse the timestamp
let (year, month, day) = match Self::parse_timestamp(&ctx.timestamp) {
Some((y, m, d)) => (y, m, d),
None => {
// Failed to parse timestamp - continue without overlay
ctx.image = Some(image);
return StepOutcome::Continue(ctx);
}
};
// Format the date string
let date_str = Self::format_date(&year, &month, &day, step_config);
// If no components are enabled, skip overlay
if date_str.is_empty() {
ctx.image = Some(image);
return StepOutcome::Continue(ctx);
}
// Convert to RGB8 for drawing
let mut rgb = image.to_rgb8();
let (img_width, img_height) = (rgb.width(), rgb.height());
// Calculate text position
let (x, y) =
Self::calculate_position(step_config.position, &date_str, img_width, img_height);
// Draw the text (white with black shadow for visibility)
let black = Rgb([0, 0, 0]);
let white = Rgb([255, 255, 255]);
// Draw shadow (offset by 1 pixel)
draw_simple_text(&mut rgb, x + 1, y + 1, &date_str, black);
// Draw text
draw_simple_text(&mut rgb, x, y, &date_str, white);
// Update context with modified image
ctx.image = Some(DynamicImage::ImageRgb8(rgb));
StepOutcome::Continue(ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, TextPosition, TimestampConfig};
use crate::immich_api::FaceData;
use image::{DynamicImage, RgbImage};
fn make_ctx_with_image(timestamp: &str) -> PipelineContext {
let face_data = FaceData {
bounding_box_x1: 0.0,
bounding_box_y1: 0.0,
bounding_box_x2: 10.0,
bounding_box_y2: 10.0,
image_width: 10,
image_height: 10,
};
let img = RgbImage::new(512, 512);
PipelineContext::new("test".to_string(), timestamp.to_string(), face_data)
.with_image(DynamicImage::ImageRgb8(img))
}
#[test]
fn test_parse_timestamp_iso_date() {
let result = TimestampStep::parse_timestamp("2024-01-15");
assert_eq!(
result,
Some(("2024".to_string(), "01".to_string(), "15".to_string()))
);
}
#[test]
fn test_parse_timestamp_iso_datetime() {
let result = TimestampStep::parse_timestamp("2024-01-15T12:34:56");
assert_eq!(
result,
Some(("2024".to_string(), "01".to_string(), "15".to_string()))
);
}
#[test]
fn test_parse_timestamp_iso_datetime_with_millis() {
let result = TimestampStep::parse_timestamp("2024-01-15T12:34:56.789Z");
assert_eq!(
result,
Some(("2024".to_string(), "01".to_string(), "15".to_string()))
);
}
#[test]
fn test_parse_timestamp_invalid() {
let result = TimestampStep::parse_timestamp("invalid");
assert_eq!(result, None);
}
#[test]
fn test_format_date_all() {
let config = TimestampConfig {
enabled: true,
position: TextPosition::TopLeft,
year: true,
month: true,
day: true,
};
let result = TimestampStep::format_date("2024", "01", "15", &config);
assert_eq!(result, "2024-01-15");
}
#[test]
fn test_format_date_year_month() {
let config = TimestampConfig {
enabled: true,
position: TextPosition::TopLeft,
year: true,
month: true,
day: false,
};
let result = TimestampStep::format_date("2024", "01", "15", &config);
assert_eq!(result, "2024-01");
}
#[test]
fn test_format_date_month_day() {
let config = TimestampConfig {
enabled: true,
position: TextPosition::TopLeft,
year: false,
month: true,
day: true,
};
let result = TimestampStep::format_date("2024", "01", "15", &config);
assert_eq!(result, "01-15");
}
#[test]
fn test_format_date_none() {
let config = TimestampConfig {
enabled: true,
position: TextPosition::TopLeft,
year: false,
month: false,
day: false,
};
let result = TimestampStep::format_date("2024", "01", "15", &config);
assert_eq!(result, "");
}
#[tokio::test]
async fn test_timestamp_overlay() {
let step = TimestampStep;
let ctx = make_ctx_with_image("2024-01-15");
let mut config = Config::default();
config.processing.timestamp.enabled = true;
config.processing.timestamp.year = true;
config.processing.timestamp.month = true;
config.processing.timestamp.day = true;
match step.execute(ctx, &config).await {
StepOutcome::Continue(ctx) => {
assert!(ctx.image.is_some());
}
_ => panic!("Expected Continue"),
}
}
#[tokio::test]
async fn test_timestamp_overlay_no_components() {
let step = TimestampStep;
let ctx = make_ctx_with_image("2024-01-15");
let mut config = Config::default();
config.processing.timestamp.enabled = true;
config.processing.timestamp.year = false;
config.processing.timestamp.month = false;
config.processing.timestamp.day = false;
match step.execute(ctx, &config).await {
StepOutcome::Continue(ctx) => {
// Should still have image, just no overlay
assert!(ctx.image.is_some());
}
_ => panic!("Expected Continue"),
}
}
#[test]
fn test_calculate_position_top_left() {
let (x, y) =
TimestampStep::calculate_position(TextPosition::TopLeft, "2024-01-15", 512, 512);
assert_eq!(x, 8);
assert_eq!(y, 8);
}
#[test]
fn test_calculate_position_bottom_right() {
let (x, y) =
TimestampStep::calculate_position(TextPosition::BottomRight, "2024-01-15", 512, 512);
// Text is 10 chars * 6px = 60px wide, 7px tall
// x = 512 - 60 - 8 = 444
// y = 512 - 7 - 8 = 497
assert_eq!(x, 444);
assert_eq!(y, 497);
}
}

View file

@ -2,7 +2,7 @@
use crate::config::{
AlignmentConfig, BlurConfig, BrightnessConfig, EyeFilterConfig, FaceResolutionConfig,
HeadPoseConfig, OutputConfig, ProcessingConfig, VideoConfig,
HeadPoseConfig, OutputConfig, ProcessingConfig, TimestampConfig, VideoConfig,
};
use crate::web::state::AppState;
use axum::{extract::State, http::StatusCode, response::Json};
@ -45,6 +45,7 @@ pub struct ProcessingConfigUpdate {
pub eye_filter: Option<EyeFilterConfig>,
pub output: Option<OutputConfig>,
pub alignment: Option<AlignmentConfig>,
pub timestamp: Option<TimestampConfig>,
}
/// Video configuration update fields.
@ -266,6 +267,9 @@ pub async fn update_config(
if let Some(v) = proc.alignment {
config.processing.alignment = v;
}
if let Some(v) = proc.timestamp {
config.processing.timestamp = v;
}
}
if let Some(vid) = update.video {