Implement a timestamp processing step, minor frontend fixes
This commit is contained in:
parent
2746f18621
commit
fb4fc59e84
15 changed files with 485 additions and 63 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -99,5 +99,4 @@ mod tests {
|
|||
_ => panic!("Expected Continue"),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
320
src/pipeline/steps/timestamp.rs
Normal file
320
src/pipeline/steps/timestamp.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue