Improvements to the frontend : display skip reason, better settings organization.
This commit is contained in:
parent
38970b8849
commit
d52e334ba7
9 changed files with 1128 additions and 163 deletions
|
|
@ -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>
|
||||
|
|
|
|||
327
frontend/src/lib/components/OutputManager.svelte
Normal file
327
frontend/src/lib/components/OutputManager.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
113
src/job/mod.rs
113
src/job/mod.rs
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue