Improvements to the frontend : display skip reason, better settings organization.

This commit is contained in:
Arnaud_Cayrol 2026-01-28 20:03:33 +01:00
parent 38970b8849
commit d52e334ba7
9 changed files with 1128 additions and 163 deletions

View file

@ -1,5 +1,6 @@
<script>
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
import OutputManager from './lib/components/OutputManager.svelte';
import PeopleSelector from './lib/components/PeopleSelector.svelte';
import ProcessingControls from './lib/components/ProcessingControls.svelte';
import ProgressDisplay from './lib/components/ProgressDisplay.svelte';
@ -67,6 +68,10 @@
<ResultsView />
</section>
{/if}
<section class="output">
<OutputManager disabled={isJobRunning} />
</section>
{:else}
<section class="not-connected">
<p>Connect to your Immich server to get started.</p>

View file

@ -0,0 +1,327 @@
<script>
import { onMount } from 'svelte';
let { disabled = false } = $props();
let folders = $state([]);
let loading = $state(true);
let error = $state(null);
let deleting = $state(null);
async function loadFolders() {
loading = true;
error = null;
try {
const res = await fetch('/api/output');
if (!res.ok) throw new Error('Failed to load output folders');
folders = await res.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function deleteFolder(name) {
if (!confirm(`Delete output folder "${name}"? This cannot be undone.`)) {
return;
}
deleting = name;
try {
const res = await fetch(`/api/output/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete folder');
}
// Reload the list
await loadFolders();
} catch (e) {
alert(e.message);
} finally {
deleting = null;
}
}
async function deleteAll() {
if (!confirm('Delete ALL output folders? This cannot be undone.')) {
return;
}
deleting = '__all__';
try {
const res = await fetch('/api/output', {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete folders');
}
// Reload the list
await loadFolders();
} catch (e) {
alert(e.message);
} finally {
deleting = null;
}
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
onMount(() => {
loadFolders();
});
</script>
<div class="output-manager">
<div class="header">
<h3>Output Folders</h3>
<button class="refresh-btn" onclick={loadFolders} disabled={loading || disabled}>
{loading ? '...' : '↻'}
</button>
</div>
{#if loading}
<p class="status">Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if folders.length === 0}
<p class="status empty">No output folders yet</p>
{:else}
<ul class="folder-list">
{#each folders as folder}
<li class="folder-item">
<div class="folder-info">
<span class="folder-name">{folder.name}</span>
<span class="folder-stats">
{folder.image_count} images • {formatSize(folder.size_bytes)}
{#if folder.has_video}
<span class="video-badge">Video</span>
{/if}
</span>
</div>
<div class="folder-actions">
{#if folder.has_video}
<a
href="/output/{encodeURIComponent(folder.name)}/timelapse.mp4"
target="_blank"
class="view-btn"
>
View
</a>
{/if}
<button
class="delete-btn"
onclick={() => deleteFolder(folder.name)}
disabled={disabled || deleting !== null}
>
{deleting === folder.name ? '...' : '×'}
</button>
</div>
</li>
{/each}
</ul>
<div class="actions">
<button
class="delete-all-btn"
onclick={deleteAll}
disabled={disabled || deleting !== null || folders.length === 0}
>
{deleting === '__all__' ? 'Deleting...' : 'Delete All'}
</button>
</div>
{/if}
</div>
<style>
.output-manager {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h3 {
font-size: 0.875rem;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.refresh-btn {
width: 2rem;
height: 2rem;
background: #252525;
border: none;
border-radius: 4px;
color: #888;
font-size: 1rem;
cursor: pointer;
transition: all 0.15s ease;
}
.refresh-btn:hover:not(:disabled) {
background: #333;
color: #e0e0e0;
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
font-size: 0.875rem;
color: #888;
text-align: center;
padding: 1rem;
}
.status.empty {
color: #666;
font-style: italic;
}
.error {
font-size: 0.875rem;
color: #dc2626;
text-align: center;
padding: 1rem;
}
.folder-list {
list-style: none;
margin: 0;
padding: 0;
}
.folder-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #252525;
}
.folder-item:last-child {
border-bottom: none;
}
.folder-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.folder-name {
font-size: 0.875rem;
color: #e0e0e0;
font-weight: 500;
}
.folder-stats {
font-size: 0.75rem;
color: #666;
display: flex;
align-items: center;
gap: 0.5rem;
}
.video-badge {
background: #22c55e;
color: #0f0f0f;
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 2px;
text-transform: uppercase;
}
.folder-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.view-btn {
padding: 0.375rem 0.75rem;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.75rem;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.view-btn:hover {
background: #4f46e5;
}
.delete-btn {
width: 2rem;
height: 2rem;
background: #333;
border: none;
border-radius: 4px;
color: #888;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.delete-btn:hover:not(:disabled) {
background: #dc2626;
color: #fff;
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #252525;
}
.delete-all-btn {
padding: 0.5rem 1rem;
background: #333;
border: none;
border-radius: 4px;
color: #888;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.delete-all-btn:hover:not(:disabled) {
background: #dc2626;
color: #fff;
}
.delete-all-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -6,11 +6,35 @@
let dateFrom = $state('');
let dateTo = $state('');
let pollInterval = $state(null);
let assetCount = $state(null);
let loadingCount = $state(false);
let isRunning = $derived(
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
// Fetch asset count when personId changes
$effect(() => {
if (personId) {
fetchAssetCount(personId);
}
});
async function fetchAssetCount(id) {
loadingCount = true;
assetCount = null;
try {
const res = await fetch(`/api/people/${encodeURIComponent(id)}/asset-count`);
if (res.ok) {
assetCount = await res.json();
}
} catch (e) {
console.error('Failed to fetch asset count:', e);
} finally {
loadingCount = false;
}
}
async function startProcessing() {
try {
const res = await fetch('/api/start', {
@ -95,9 +119,21 @@
<div class="processing-controls">
<h2>Create Timelapse</h2>
<p class="selected-person">
Selected: <strong>{personName || 'Unnamed'}</strong>
</p>
<div class="selected-person">
<span class="person-name">
Selected: <strong>{personName || 'Unnamed'}</strong>
</span>
{#if loadingCount}
<span class="asset-count loading">Loading images...</span>
{:else if assetCount}
<span class="asset-count">
<span class="count-number">{assetCount.assets_with_faces}</span> images with face data
{#if assetCount.total_assets !== assetCount.assets_with_faces}
<span class="count-detail">({assetCount.total_assets} total)</span>
{/if}
</span>
{/if}
</div>
<div class="date-filters">
<label>
@ -139,15 +175,41 @@
}
.selected-person {
font-size: 0.875rem;
color: #888;
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
.selected-person strong {
.person-name {
font-size: 0.875rem;
color: #888;
}
.person-name strong {
color: #e0e0e0;
}
.asset-count {
font-size: 0.875rem;
color: #4f46e5;
}
.asset-count.loading {
color: #888;
font-style: italic;
}
.count-number {
font-weight: 600;
font-size: 1rem;
}
.count-detail {
color: #666;
font-size: 0.75rem;
}
.date-filters {
display: flex;
gap: 1rem;

View file

@ -24,6 +24,40 @@
cancelled: 'warning',
error: 'error',
}[jobStatus] || '');
// Skip statistics from the backend
let skipStats = $derived(progress.skip_stats || {
face_too_small: 0,
eyes_closed: 0,
head_turned: 0,
too_dark: 0,
too_bright: 0,
no_face_detected: 0,
download_failed: 0,
decode_failed: 0,
crop_failed: 0,
total: 0,
});
let hasSkips = $derived(skipStats.total > 0);
// Calculate kept (successful) count: completed - skipped
let keptCount = $derived(Math.max(0, progress.completed - skipStats.total));
// Filter to only show non-zero skip reasons
let skipReasons = $derived(
[
{ label: 'Face too small', count: skipStats.face_too_small },
{ label: 'Eyes closed', count: skipStats.eyes_closed },
{ label: 'Head turned', count: skipStats.head_turned },
{ 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)
);
</script>
<div class="progress-display">
@ -43,6 +77,27 @@
{#if progress.message}
<p class="message">{progress.message}</p>
{/if}
{#if (jobStatus === 'running' || jobStatus === 'completed' || jobStatus === 'cancelled') && progress.completed > 0}
<div class="skip-stats">
<div class="skip-header">
<span class="skip-label">Discarded:Kept</span>
<span class="skip-totals">
<span class="skipped-count">{skipStats.total}</span>
<span class="separator">:</span>
<span class="kept-count">{keptCount}</span>
</span>
</div>
<ul class="skip-reasons">
{#each skipReasons as reason}
<li class="skip-reason">
<span class="reason-label">{reason.label}</span>
<span class="reason-count">{reason.count}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<style>
@ -110,4 +165,71 @@
font-size: 0.875rem;
color: #888;
}
/* Skip statistics */
.skip-stats {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #252525;
}
.skip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.skip-label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.skip-totals {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
}
.skipped-count {
color: #f59e0b;
}
.separator {
color: #666;
}
.kept-count {
color: #22c55e;
}
.skip-reasons {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.25rem;
}
.skip-reason {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
background: #252525;
border-radius: 4px;
font-size: 0.75rem;
}
.reason-label {
color: #888;
}
.reason-count {
color: #e0e0e0;
font-weight: 500;
}
</style>

View file

@ -8,6 +8,7 @@
let saving = $state(false);
let error = $state(null);
let saveMessage = $state(null);
let activeTab = $state('face');
// Config state
let config = $state({
@ -91,111 +92,187 @@
<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>
<!-- Tab navigation -->
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'face'}
onclick={() => (activeTab = 'face')}
>
Face
</button>
<button
class="tab"
class:active={activeTab === 'output'}
onclick={() => (activeTab = 'output')}
>
Output
</button>
<button
class="tab"
class:active={activeTab === 'video'}
onclick={() => (activeTab = 'video')}
>
Video
</button>
</div>
<label>
<span>Output Size (px)</span>
<input
type="number"
bind:value={config.processing.resize_size}
min="128"
max="2048"
step="64"
/>
</label>
<!-- Tab content -->
<div class="tab-content">
{#if activeTab === 'face'}
<fieldset disabled={disabled || saving}>
<div class="setting-row">
<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_threshold}
min="20"
max="200"
step="10"
/>
<span class="value">{config.processing.face_resolution_threshold}px</span>
</div>
</div>
<label>
<span>Min Face Size (px)</span>
<input
type="number"
bind:value={config.processing.face_resolution_threshold}
min="20"
max="500"
step="10"
/>
</label>
<div class="setting-row">
<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>
<label>
<span>Pose Threshold (deg)</span>
<input
type="number"
bind:value={config.processing.pose_threshold}
min="5"
max="90"
step="5"
/>
</label>
<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>
</fieldset>
{:else if activeTab === 'output'}
<fieldset disabled={disabled || saving}>
<div class="setting-row">
<label>
<span class="setting-label">Output Size</span>
<span class="setting-hint">Final image dimensions (square)</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.processing.resize_size}
min="128"
max="1024"
step="64"
/>
<span class="value">{config.processing.resize_size}px</span>
</div>
</div>
<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>
<div class="setting-row">
<label>
<span class="setting-label">Parallel Workers</span>
<span class="setting-hint">Concurrent image processing tasks</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.processing.max_workers}
min="1"
max="16"
step="1"
/>
<span class="value">{config.processing.max_workers}</span>
</div>
</div>
<label>
<span>Parallel Workers</span>
<input
type="number"
bind:value={config.processing.max_workers}
min="1"
max="32"
step="1"
/>
</label>
<div class="setting-row checkbox-row">
<label>
<span class="setting-label">Keep Debug Images</span>
<span class="setting-hint">Save intermediate processing visualizations</span>
</label>
<input type="checkbox" bind:checked={config.processing.keep_intermediates} />
</div>
</fieldset>
{:else if activeTab === 'video'}
<fieldset disabled={disabled || saving}>
<div class="setting-row">
<label>
<span class="setting-label">Framerate</span>
<span class="setting-hint">Video frames per second</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.video.framerate}
min="1"
max="60"
step="1"
/>
<span class="value">{config.video.framerate} fps</span>
</div>
</div>
<label class="checkbox-label">
<input type="checkbox" bind:checked={config.processing.keep_intermediates} />
<span>Keep debug images</span>
</label>
</fieldset>
<div class="setting-row">
<label>
<span class="setting-label">Quality (CRF)</span>
<span class="setting-hint">Lower = better quality, larger file</span>
</label>
<div class="setting-control">
<input
type="range"
bind:value={config.video.crf}
min="15"
max="40"
step="1"
/>
<span class="value">{config.video.crf}</span>
</div>
</div>
<fieldset disabled={disabled || saving}>
<legend>Video</legend>
<div class="setting-row">
<label>
<span class="setting-label">Codec</span>
<span class="setting-hint">Video encoding format</span>
</label>
<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>
</div>
<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 class="setting-row checkbox-row">
<label>
<span class="setting-label">Auto-compile Video</span>
<span class="setting-hint">Automatically create video after processing</span>
</label>
<input type="checkbox" bind:checked={config.video.enabled} />
</div>
</fieldset>
{/if}
</div>
<div class="actions">
@ -268,44 +345,98 @@
cursor: pointer;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid #333;
padding-bottom: 0.5rem;
}
.tab {
padding: 0.5rem 1rem;
background: none;
border: none;
border-radius: 4px 4px 0 0;
color: #888;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.tab:hover {
color: #e0e0e0;
background: #252525;
}
.tab.active {
color: #e0e0e0;
background: #333;
font-weight: 600;
}
/* Tab content */
.tab-content {
min-height: 200px;
}
fieldset {
border: 1px solid #333;
border-radius: 6px;
padding: 1rem;
border: none;
padding: 0;
}
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;
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #252525;
}
label {
.setting-row:last-child {
border-bottom: none;
}
.setting-row label {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
gap: 0.125rem;
}
label > span:first-child {
.setting-label {
font-size: 0.875rem;
color: #e0e0e0;
}
.setting-hint {
font-size: 0.75rem;
color: #888;
color: #666;
}
.setting-control {
display: flex;
align-items: center;
gap: 0.75rem;
}
input[type='range'] {
width: 120px;
accent-color: #4f46e5;
}
.value {
font-size: 0.875rem;
color: #4f46e5;
font-weight: 600;
min-width: 60px;
text-align: right;
}
input[type='number'],
select {
padding: 0.5rem 0.75rem;
border: 1px solid #333;
@ -313,38 +444,23 @@
background: #0f0f0f;
color: #e0e0e0;
font-size: 0.875rem;
cursor: pointer;
}
input[type='number']:focus,
select:focus {
outline: none;
border-color: #4f46e5;
}
select {
cursor: pointer;
.checkbox-row {
justify-content: space-between;
}
.checkbox-label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.checkbox-label input {
width: 1rem;
height: 1rem;
.checkbox-row input[type='checkbox'] {
width: 1.25rem;
height: 1.25rem;
accent-color: #4f46e5;
}
.checkbox-label span {
font-size: 0.875rem;
color: #e0e0e0;
}
.hint {
font-size: 0.7rem;
color: #666;
cursor: pointer;
}
.actions {

View file

@ -39,13 +39,33 @@ impl BoundingBox {
}
/// Facial landmarks (68-point model).
///
/// This struct requires exactly 68 landmark points following the standard
/// dlib/iBUG 68-point face landmark format. Use `Landmarks::new()` to
/// construct with validation.
#[derive(Debug, Clone)]
pub struct Landmarks {
/// All 68 landmark points.
pub points: Vec<Point>,
/// All 68 landmark points (validated on construction).
points: [Point; 68],
}
/// Expected number of landmark points.
pub const LANDMARK_COUNT: usize = 68;
impl Landmarks {
/// Create a new Landmarks from a vector of points.
///
/// Returns `None` if the vector doesn't contain exactly 68 points.
pub fn new(points: Vec<Point>) -> Option<Self> {
let points: [Point; LANDMARK_COUNT] = points.try_into().ok()?;
Some(Self { points })
}
/// Get all landmark points.
pub fn points(&self) -> &[Point; 68] {
&self.points
}
/// Left eye center (average of points 36-41).
pub fn left_eye_center(&self) -> Point {
let eye_points = &self.points[36..42];
@ -126,13 +146,49 @@ pub struct ProcessedFace {
pub timestamp: String,
}
/// Reason why an image was skipped during processing.
#[derive(Debug, Clone, PartialEq)]
pub enum SkipReason {
FaceTooSmall,
EyesClosed,
HeadTurned,
TooDark,
TooBright,
NoFaceDetected,
DownloadFailed,
DecodeFailed,
CropFailed,
Cancelled,
}
impl std::fmt::Display for SkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkipReason::FaceTooSmall => write!(f, "Face too small"),
SkipReason::EyesClosed => write!(f, "Eyes closed"),
SkipReason::HeadTurned => write!(f, "Head turned"),
SkipReason::TooDark => write!(f, "Too dark"),
SkipReason::TooBright => write!(f, "Too bright"),
SkipReason::NoFaceDetected => write!(f, "No face detected"),
SkipReason::DownloadFailed => write!(f, "Download failed"),
SkipReason::DecodeFailed => write!(f, "Decode failed"),
SkipReason::CropFailed => write!(f, "Crop failed"),
SkipReason::Cancelled => write!(f, "Cancelled"),
}
}
}
/// Processing result for a single asset.
#[derive(Debug)]
pub enum AssetResult {
/// Successfully processed.
Success(ProcessedFace),
/// Skipped (face too small, bad pose, etc.).
Skipped { asset_id: String, reason: String },
Skipped {
asset_id: String,
reason: SkipReason,
detail: Option<String>,
},
/// Error during processing.
Error { asset_id: String, error: String },
}

View file

@ -11,10 +11,10 @@ use crate::error::{Error, Result};
use crate::face_processing::crop_face_with_intermediate;
use crate::face_processing::debug::draw_crop_debug;
use crate::face_processing::load_image_with_orientation;
use crate::face_processing::{AssetResult, ProcessedFace};
use crate::face_processing::{AssetResult, ProcessedFace, SkipReason};
use crate::immich_api::{Asset, FaceData, ImmichClient};
use crate::video::compile_timelapse;
use crate::web::{AppState, JobStatus, Progress};
use crate::web::{AppState, JobStatus, Progress, SkipStats};
use image::ImageFormat;
use std::io::Cursor;
@ -97,6 +97,9 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
// Clear the cancellation token when done
state.clear_cancel_token().await;
// Get final skip stats from progress
let final_skip_stats = state.progress.read().await.skip_stats.clone();
match result {
Ok(output_path) => {
state
@ -105,6 +108,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
completed: 0,
total: 0,
message: Some(format!("Video saved to: {}", output_path.display())),
skip_stats: final_skip_stats,
})
.await;
}
@ -115,6 +119,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
completed: 0,
total: 0,
message: Some("Job cancelled by user".to_string()),
skip_stats: final_skip_stats,
})
.await;
}
@ -126,6 +131,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
completed: 0,
total: 0,
message: Some(e.to_string()),
skip_stats: final_skip_stats,
})
.await;
}
@ -186,6 +192,7 @@ async fn run_job_inner(
completed: 0,
total: 0,
message: Some("Fetching assets from Immich...".to_string()),
skip_stats: SkipStats::default(),
})
.await;
@ -236,6 +243,7 @@ async fn run_job_inner(
completed: 0,
total,
message: Some(format!("Processing {} images...", total)),
skip_stats: SkipStats::default(),
})
.await;
@ -246,6 +254,17 @@ async fn run_job_inner(
let config = Arc::new(config);
let output_dirs = Arc::new(output_dirs);
// Atomic counters for real-time skip statistics
let skip_face_too_small = Arc::new(AtomicU32::new(0));
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());
for (asset, face_data) in assets_with_faces {
@ -265,13 +284,25 @@ async fn run_job_inner(
let state = state.clone();
let task_cancel_token = cancel_token.clone();
// Clone skip counters for this task
let skip_face_too_small = skip_face_too_small.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();
let handle = tokio::spawn(async move {
// Check cancellation at start of task
if task_cancel_token.is_cancelled() {
drop(permit);
return AssetResult::Skipped {
asset_id: asset.id.clone(),
reason: "Cancelled".to_string(),
reason: SkipReason::Cancelled,
detail: None,
};
}
@ -285,15 +316,43 @@ async fn run_job_inner(
)
.await;
// Update progress (only if not cancelled)
// Update skip counters based on result
if let AssetResult::Skipped { reason, .. } = &result {
match 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)
if !task_cancel_token.is_cancelled() {
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
let current_skip_stats = SkipStats {
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
.update_progress(Progress {
status: JobStatus::Running,
completed: done,
total,
message: Some(format!("Processing images... ({}/{})", done, total)),
skip_stats: current_skip_stats,
})
.await;
}
@ -321,19 +380,29 @@ async fn run_job_inner(
return Err(Error::Cancelled);
}
// Get final skip statistics from atomic counters
let skip_stats = SkipStats {
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
let successful = results
.iter()
.filter(|r| matches!(r, AssetResult::Success(_)))
.count();
let skipped = results
.iter()
.filter(|r| matches!(r, AssetResult::Skipped { .. }))
.count();
let errors = results
.iter()
.filter(|r| matches!(r, AssetResult::Error { .. }))
.count();
let skipped = skip_stats.total();
tracing::info!(
"Processing complete: {} successful, {} skipped, {} errors",
@ -342,6 +411,17 @@ async fn run_job_inner(
errors
);
// Update progress with final skip stats
state
.update_progress(Progress {
status: JobStatus::Running,
completed: total,
total,
message: Some("Processing complete, preparing video...".to_string()),
skip_stats: skip_stats.clone(),
})
.await;
if successful == 0 {
return Err(Error::ImageProcessing(
"No images were successfully processed".to_string(),
@ -361,6 +441,7 @@ async fn run_job_inner(
completed: 0,
total: successful as u32,
message: Some("Compiling video...".to_string()),
skip_stats: skip_stats.clone(),
})
.await;
@ -418,10 +499,11 @@ async fn process_single_asset(
if face_size < config.processing.face_resolution_threshold {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: format!(
"Face too small: {}px (threshold: {}px)",
reason: SkipReason::FaceTooSmall,
detail: Some(format!(
"{}px (threshold: {}px)",
face_size, config.processing.face_resolution_threshold
),
)),
};
}
@ -429,7 +511,8 @@ async fn process_single_asset(
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
reason: SkipReason::Cancelled,
detail: None,
};
}
@ -470,7 +553,8 @@ async fn process_single_asset(
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
reason: SkipReason::Cancelled,
detail: None,
};
}
@ -516,7 +600,8 @@ async fn process_single_asset(
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
reason: SkipReason::Cancelled,
detail: None,
};
}

View file

@ -3,7 +3,7 @@
use crate::config::{ProcessingConfig, VideoConfig};
use crate::immich_api::ImmichClient;
use crate::job::{run_job, JobParams};
use crate::web::state::{AppState, JobStatus, Progress};
use crate::web::state::{AppState, JobStatus, Progress, SkipStats};
use axum::{
body::Body,
extract::{Path, State},
@ -33,9 +33,14 @@ pub fn create_router(state: AppState) -> Router {
"/api/people/{person_id}/thumbnail",
get(get_person_thumbnail),
)
.route(
"/api/people/{person_id}/asset-count",
get(get_person_asset_count),
)
.route("/api/progress", get(get_progress))
.route("/api/start", post(start_processing))
.route("/api/cancel", post(cancel_processing))
.route("/api/output", get(list_output_folders))
.route("/api/output", delete(cleanup_all_output))
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
.route("/api/config", get(get_config))
@ -179,6 +184,72 @@ async fn get_person_thumbnail(
})
}
/// Asset count response for a person.
#[derive(Serialize)]
struct AssetCountResponse {
total_assets: u32,
assets_with_faces: u32,
}
/// Get the count of assets for a person.
async fn get_person_asset_count(
State(state): State<AppState>,
Path(person_id): Path<String>,
) -> Result<Json<AssetCountResponse>, (StatusCode, String)> {
let config = state.config.read().await;
let client = ImmichClient::new(&config.api).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create client: {}", e),
)
})?;
// Fetch assets for this person
let assets = client
.get_assets_with_person(&person_id, None, None)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get assets: {}", e),
)
})?;
let total_assets = assets.len() as u32;
// Count assets that have face data for the target person
let assets_with_faces = assets
.iter()
.filter(|asset| {
asset.people.as_ref().map_or(false, |people| {
people.iter().any(|p| {
p.id == person_id && p.faces.as_ref().map_or(false, |faces| !faces.is_empty())
})
})
})
.count() as u32;
Ok(Json(AssetCountResponse {
total_assets,
assets_with_faces,
}))
}
/// Skip statistics for API response.
#[derive(Serialize)]
struct SkipStatsResponse {
face_too_small: u32,
eyes_closed: u32,
head_turned: u32,
too_dark: u32,
too_bright: u32,
no_face_detected: u32,
download_failed: u32,
decode_failed: u32,
crop_failed: u32,
total: u32,
}
/// Get current progress.
#[derive(Serialize)]
struct ProgressResponse {
@ -186,6 +257,7 @@ struct ProgressResponse {
completed: u32,
total: u32,
message: Option<String>,
skip_stats: SkipStatsResponse,
}
async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
@ -201,11 +273,25 @@ async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
JobStatus::Error(_) => "error",
};
let skip_stats = &progress.skip_stats;
Json(ProgressResponse {
status: status_str.to_string(),
completed: progress.completed,
total: progress.total,
message: progress.message.clone(),
skip_stats: SkipStatsResponse {
face_too_small: skip_stats.face_too_small,
eyes_closed: skip_stats.eyes_closed,
head_turned: skip_stats.head_turned,
too_dark: skip_stats.too_dark,
too_bright: skip_stats.too_bright,
no_face_detected: skip_stats.no_face_detected,
download_failed: skip_stats.download_failed,
decode_failed: skip_stats.decode_failed,
crop_failed: skip_stats.crop_failed,
total: skip_stats.total(),
},
})
}
@ -243,6 +329,7 @@ async fn start_processing(
completed: 0,
total: 0,
message: Some("Starting...".to_string()),
skip_stats: SkipStats::default(),
})
.await;
@ -293,6 +380,80 @@ async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse>
}
}
/// Output folder information.
#[derive(Serialize)]
struct OutputFolderInfo {
name: String,
image_count: u32,
size_bytes: u64,
has_video: bool,
}
/// List all output folders with their stats.
async fn list_output_folders(
State(state): State<AppState>,
) -> Result<Json<Vec<OutputFolderInfo>>, (StatusCode, String)> {
let config = state.config.read().await;
let output_dir = &config.output_dir;
let mut folders = Vec::new();
if output_dir.exists() {
let mut entries = tokio::fs::read_dir(output_dir).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read output directory: {}", e),
)
})?;
while let Some(entry) = entries.next_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read directory entry: {}", e),
)
})? {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
// Count images in the images subfolder
let images_dir = path.join("images");
let mut image_count = 0u32;
let mut size_bytes = 0u64;
if images_dir.exists() {
if let Ok(mut img_entries) = tokio::fs::read_dir(&images_dir).await {
while let Ok(Some(img_entry)) = img_entries.next_entry().await {
let img_path = img_entry.path();
if img_path.extension().map_or(false, |ext| ext == "jpg") {
image_count += 1;
if let Ok(metadata) = tokio::fs::metadata(&img_path).await {
size_bytes += metadata.len();
}
}
}
}
}
// Check for video file
let has_video = path.join("timelapse.mp4").exists();
folders.push(OutputFolderInfo {
name,
image_count,
size_bytes,
has_video,
});
}
}
}
// Sort by name
folders.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Json(folders))
}
/// Clean up all output folders.
async fn cleanup_all_output(
State(state): State<AppState>,

View file

@ -17,6 +17,35 @@ pub enum JobStatus {
Error(String),
}
/// Detailed statistics for skipped images.
#[derive(Debug, Clone, Default)]
pub struct SkipStats {
pub face_too_small: u32,
pub eyes_closed: u32,
pub head_turned: u32,
pub too_dark: u32,
pub too_bright: u32,
pub no_face_detected: u32,
pub download_failed: u32,
pub decode_failed: u32,
pub crop_failed: u32,
}
impl SkipStats {
/// Total number of skipped images.
pub fn total(&self) -> u32 {
self.face_too_small
+ self.eyes_closed
+ self.head_turned
+ self.too_dark
+ self.too_bright
+ self.no_face_detected
+ self.download_failed
+ self.decode_failed
+ self.crop_failed
}
}
/// Progress information for a running job.
#[derive(Debug, Clone)]
pub struct Progress {
@ -24,6 +53,7 @@ pub struct Progress {
pub completed: u32,
pub total: u32,
pub message: Option<String>,
pub skip_stats: SkipStats,
}
impl Default for Progress {
@ -33,6 +63,7 @@ impl Default for Progress {
completed: 0,
total: 0,
message: None,
skip_stats: SkipStats::default(),
}
}
}