Add Svelte 5 frontend and fix job cancellation
This commit is contained in:
parent
11598d2cbf
commit
5869d3c24b
18 changed files with 2509 additions and 48 deletions
|
|
@ -40,6 +40,7 @@ uuid = { version = "1", features = ["v4"] }
|
|||
chrono = { version = "0.4", features = ["serde"] }
|
||||
directories = "5"
|
||||
bytes = "1"
|
||||
tokio-util = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
|
|
|||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Immich Selfie Timelapse</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1399
frontend/package-lock.json
generated
Normal file
1399
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
frontend/package.json
Normal file
16
frontend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "immich-timelapse-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
11
frontend/public/favicon.svg
Normal file
11
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4f46e5"/>
|
||||
<stop offset="100%" style="stop-color:#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||
<circle cx="50" cy="40" r="18" fill="#fff" opacity="0.9"/>
|
||||
<path d="M25 70 Q50 95 75 70 Q75 55 50 55 Q25 55 25 70" fill="#fff" opacity="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
123
frontend/src/App.svelte
Normal file
123
frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script>
|
||||
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
|
||||
import PeopleSelector from './lib/components/PeopleSelector.svelte';
|
||||
import ProcessingControls from './lib/components/ProcessingControls.svelte';
|
||||
import ProgressDisplay from './lib/components/ProgressDisplay.svelte';
|
||||
import ResultsView from './lib/components/ResultsView.svelte';
|
||||
|
||||
let connectionOk = $state(false);
|
||||
let selectedPerson = $state(null);
|
||||
let jobStatus = $state('idle');
|
||||
let progress = $state({ completed: 0, total: 0, message: '' });
|
||||
|
||||
function handleConnectionChange(data) {
|
||||
connectionOk = data.connected;
|
||||
}
|
||||
|
||||
function handlePersonSelect(person) {
|
||||
selectedPerson = person;
|
||||
}
|
||||
|
||||
function handleJobUpdate(data) {
|
||||
jobStatus = data.status;
|
||||
progress = data;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>Immich Selfie Timelapse</h1>
|
||||
<ConnectionStatus onchange={handleConnectionChange} />
|
||||
</header>
|
||||
|
||||
{#if connectionOk}
|
||||
<section class="controls">
|
||||
<PeopleSelector
|
||||
onselect={handlePersonSelect}
|
||||
disabled={jobStatus === 'running' || jobStatus === 'compiling_video'}
|
||||
/>
|
||||
|
||||
{#if selectedPerson}
|
||||
<ProcessingControls
|
||||
personId={selectedPerson.id}
|
||||
personName={selectedPerson.name}
|
||||
{jobStatus}
|
||||
onupdate={handleJobUpdate}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if jobStatus !== 'idle'}
|
||||
<section class="progress">
|
||||
<ProgressDisplay {jobStatus} {progress} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if jobStatus === 'completed'}
|
||||
<section class="results">
|
||||
<ResultsView />
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<section class="not-connected">
|
||||
<p>Connect to your Immich server to get started.</p>
|
||||
<p class="hint">Make sure the backend is running and configured with your Immich API credentials.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.not-connected {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.not-connected p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/lib/components/ConnectionStatus.svelte
Normal file
100
frontend/src/lib/components/ConnectionStatus.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { onchange } = $props();
|
||||
|
||||
let status = $state('checking');
|
||||
let version = $state(null);
|
||||
let error = $state(null);
|
||||
|
||||
async function checkConnection() {
|
||||
status = 'checking';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/connection');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.connected) {
|
||||
status = 'connected';
|
||||
version = data.version;
|
||||
onchange?.({ connected: true, version: data.version });
|
||||
} else {
|
||||
status = 'error';
|
||||
error = data.error || 'Connection failed';
|
||||
onchange?.({ connected: false });
|
||||
}
|
||||
} catch (e) {
|
||||
status = 'error';
|
||||
error = 'Cannot reach backend server';
|
||||
onchange?.({ connected: false });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkConnection();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="connection-status" class:connected={status === 'connected'} class:error={status === 'error'}>
|
||||
{#if status === 'checking'}
|
||||
<span class="indicator checking"></span>
|
||||
<span>Connecting...</span>
|
||||
{:else if status === 'connected'}
|
||||
<span class="indicator connected"></span>
|
||||
<span>Immich {version}</span>
|
||||
{:else}
|
||||
<span class="indicator error"></span>
|
||||
<button onclick={checkConnection} class="retry">
|
||||
{error} - Retry
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.indicator.checking {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.indicator.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.indicator.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.retry {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.retry:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
196
frontend/src/lib/components/PeopleSelector.svelte
Normal file
196
frontend/src/lib/components/PeopleSelector.svelte
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { disabled = false, onselect } = $props();
|
||||
|
||||
let people = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
let searchQuery = $state('');
|
||||
let selectedId = $state(null);
|
||||
|
||||
let filteredPeople = $derived(
|
||||
people.filter(p => {
|
||||
const name = p.name || 'Unnamed';
|
||||
return name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
);
|
||||
|
||||
async function loadPeople() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/people');
|
||||
if (!res.ok) throw new Error('Failed to load people');
|
||||
people = await res.json();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person) {
|
||||
if (disabled) return;
|
||||
selectedId = person.id;
|
||||
onselect?.(person);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPeople();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="people-selector">
|
||||
<h2>Select Person</h2>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading people...</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
{error}
|
||||
<button onclick={loadPeople}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search people..."
|
||||
bind:value={searchQuery}
|
||||
{disabled}
|
||||
class="search"
|
||||
/>
|
||||
|
||||
<div class="people-grid">
|
||||
{#each filteredPeople as person (person.id)}
|
||||
<button
|
||||
class="person-card"
|
||||
class:selected={selectedId === person.id}
|
||||
onclick={() => selectPerson(person)}
|
||||
{disabled}
|
||||
>
|
||||
<div class="avatar">
|
||||
{(person.name || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="name">{person.name || 'Unnamed'}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="no-results">No people found</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.people-selector {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.search:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.people-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.person-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #252525;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.person-card:hover:not(:disabled) {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.person-card.selected {
|
||||
border-color: #4f46e5;
|
||||
background: #2a2a4a;
|
||||
}
|
||||
|
||||
.person-card:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.75rem;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.loading, .error, .no-results {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #333;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/lib/components/ProcessingControls.svelte
Normal file
218
frontend/src/lib/components/ProcessingControls.svelte
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { personId, personName, jobStatus, onupdate } = $props();
|
||||
|
||||
let dateFrom = $state('');
|
||||
let dateTo = $state('');
|
||||
let pollInterval = $state(null);
|
||||
|
||||
let isRunning = $derived(jobStatus === 'running' || jobStatus === 'compiling_video');
|
||||
|
||||
async function startProcessing() {
|
||||
try {
|
||||
const res = await fetch('/api/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
person_id: personId,
|
||||
date_from: dateFrom || null,
|
||||
date_to: dateTo || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
startPolling();
|
||||
} else {
|
||||
onupdate?.({
|
||||
status: 'error',
|
||||
completed: 0,
|
||||
total: 0,
|
||||
message: data.message || 'Failed to start processing',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
onupdate?.({
|
||||
status: 'error',
|
||||
completed: 0,
|
||||
total: 0,
|
||||
message: 'Network error: ' + e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelProcessing() {
|
||||
try {
|
||||
await fetch('/api/cancel', { method: 'POST' });
|
||||
} catch (e) {
|
||||
console.error('Cancel failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollProgress() {
|
||||
try {
|
||||
const res = await fetch('/api/progress');
|
||||
const data = await res.json();
|
||||
|
||||
onupdate?.(data);
|
||||
|
||||
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error') {
|
||||
stopPolling();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Poll failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollProgress();
|
||||
pollInterval = setInterval(pollProgress, 500);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Check if there's already a running job
|
||||
pollProgress();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="processing-controls">
|
||||
<h2>Create Timelapse</h2>
|
||||
|
||||
<p class="selected-person">
|
||||
Selected: <strong>{personName || 'Unnamed'}</strong>
|
||||
</p>
|
||||
|
||||
<div class="date-filters">
|
||||
<label>
|
||||
<span>From</span>
|
||||
<input type="date" bind:value={dateFrom} disabled={isRunning} />
|
||||
</label>
|
||||
<label>
|
||||
<span>To</span>
|
||||
<input type="date" bind:value={dateTo} disabled={isRunning} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if isRunning}
|
||||
<button class="cancel-btn" onclick={cancelProcessing}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="start-btn" onclick={startProcessing}>
|
||||
Start Processing
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.processing-controls {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.selected-person {
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.selected-person strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.date-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.date-filters label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.date-filters span {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.date-filters input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-filters input:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.date-filters input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
111
frontend/src/lib/components/ProgressDisplay.svelte
Normal file
111
frontend/src/lib/components/ProgressDisplay.svelte
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
let { jobStatus, progress } = $props();
|
||||
|
||||
let percentage = $derived(
|
||||
progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0
|
||||
);
|
||||
|
||||
let statusLabel = $derived({
|
||||
idle: 'Idle',
|
||||
running: 'Processing',
|
||||
compiling_video: 'Compiling Video',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
error: 'Error',
|
||||
}[jobStatus] || jobStatus);
|
||||
|
||||
let statusClass = $derived({
|
||||
idle: '',
|
||||
running: 'running',
|
||||
compiling_video: 'running',
|
||||
completed: 'success',
|
||||
cancelled: 'warning',
|
||||
error: 'error',
|
||||
}[jobStatus] || '');
|
||||
</script>
|
||||
|
||||
<div class="progress-display">
|
||||
<div class="header">
|
||||
<span class="status {statusClass}">{statusLabel}</span>
|
||||
{#if progress.total > 0}
|
||||
<span class="count">{progress.completed} / {progress.total}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if jobStatus === 'running' || jobStatus === 'compiling_video'}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {percentage}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if progress.message}
|
||||
<p class="message">{progress.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress-display {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.status.running {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #14532d;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
background: #422006;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #450a0a;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
166
frontend/src/lib/components/ResultsView.svelte
Normal file
166
frontend/src/lib/components/ResultsView.svelte
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<script>
|
||||
// Future: This component can be extended to show:
|
||||
// - Preview grid of processed faces
|
||||
// - Before/after alignment comparison
|
||||
// - Skipped images with reasons
|
||||
// - Face landmark visualization
|
||||
|
||||
let videoUrl = $state('/output/timelapse.mp4');
|
||||
let videoError = $state(false);
|
||||
|
||||
function handleVideoError() {
|
||||
videoError = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="results-view">
|
||||
<h2>Result</h2>
|
||||
|
||||
{#if videoError}
|
||||
<div class="video-error">
|
||||
<p>Video not available yet.</p>
|
||||
<p class="hint">The video file may still be processing or the path may have changed.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="video-container">
|
||||
<video
|
||||
controls
|
||||
autoplay
|
||||
loop
|
||||
src={videoUrl}
|
||||
onerror={handleVideoError}
|
||||
>
|
||||
<track kind="captions" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href={videoUrl} download="timelapse.mp4" class="download-btn">
|
||||
Download Video
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Future: Face preview grid -->
|
||||
<!--
|
||||
<div class="preview-section">
|
||||
<h3>Processed Faces</h3>
|
||||
<div class="preview-grid">
|
||||
{#each processedFaces as face}
|
||||
<div class="face-preview">
|
||||
<img src={face.url} alt="Processed face" />
|
||||
<span class="timestamp">{face.timestamp}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Future: Skipped images -->
|
||||
<!--
|
||||
<details class="skipped-section">
|
||||
<summary>Skipped Images ({skippedCount})</summary>
|
||||
<ul>
|
||||
{#each skippedImages as img}
|
||||
<li>{img.reason}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.results-view {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: #252525;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-error p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
/* Future styles for preview grid */
|
||||
/*
|
||||
.preview-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.face-preview img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skipped-section {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
}
|
||||
*/
|
||||
</style>
|
||||
7
frontend/src/main.js
Normal file
7
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
const target = document.getElementById('app');
|
||||
if (target) {
|
||||
mount(App, { target });
|
||||
}
|
||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -19,7 +19,8 @@ use std::io::Cursor;
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, Semaphore};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Parameters for starting a processing job.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -33,8 +34,11 @@ pub struct JobParams {
|
|||
///
|
||||
/// This is the main entry point for background job processing.
|
||||
/// It handles progress reporting and cancellation.
|
||||
pub async fn run_job(state: AppState, params: JobParams, cancel_rx: oneshot::Receiver<()>) {
|
||||
let result = run_job_inner(state.clone(), params, cancel_rx).await;
|
||||
pub async fn run_job(state: AppState, params: JobParams, cancel_token: CancellationToken) {
|
||||
let result = run_job_inner(state.clone(), params, cancel_token).await;
|
||||
|
||||
// Clear the cancellation token when done
|
||||
state.clear_cancel_token().await;
|
||||
|
||||
match result {
|
||||
Ok(output_path) => {
|
||||
|
|
@ -75,7 +79,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_rx: oneshot::Rec
|
|||
async fn run_job_inner(
|
||||
state: AppState,
|
||||
params: JobParams,
|
||||
mut cancel_rx: oneshot::Receiver<()>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<PathBuf> {
|
||||
let config = state.config.read().await.clone();
|
||||
|
||||
|
|
@ -97,7 +101,7 @@ async fn run_job_inner(
|
|||
.await;
|
||||
|
||||
// Check for cancellation
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
|
|
@ -156,26 +160,43 @@ async fn run_job_inner(
|
|||
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
||||
|
||||
for (asset, face_data) in assets_with_faces {
|
||||
// Check for cancellation before spawning more tasks
|
||||
if cancel_token.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let client = client.clone();
|
||||
let config = config.clone();
|
||||
let images_dir = images_dir.clone();
|
||||
let completed = completed.clone();
|
||||
let state = state.clone();
|
||||
let task_cancel_token = cancel_token.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(),
|
||||
};
|
||||
}
|
||||
|
||||
let result = process_single_asset(&client, &config, &asset, &face_data, &images_dir).await;
|
||||
|
||||
// Update progress
|
||||
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
state
|
||||
.update_progress(Progress {
|
||||
status: JobStatus::Running,
|
||||
completed: done,
|
||||
total,
|
||||
message: Some(format!("Processing images... ({}/{})", done, total)),
|
||||
})
|
||||
.await;
|
||||
// Update progress (only if not cancelled)
|
||||
if !task_cancel_token.is_cancelled() {
|
||||
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
state
|
||||
.update_progress(Progress {
|
||||
status: JobStatus::Running,
|
||||
completed: done,
|
||||
total,
|
||||
message: Some(format!("Processing images... ({}/{})", done, total)),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
drop(permit);
|
||||
result
|
||||
|
|
@ -184,22 +205,41 @@ async fn run_job_inner(
|
|||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Check for cancellation periodically while waiting
|
||||
// Collect abort handles for cancellation
|
||||
let abort_handles: Vec<_> = handles.iter().map(|h| h.abort_handle()).collect();
|
||||
|
||||
// Spawn a task to monitor cancellation and abort all tasks if cancelled
|
||||
let abort_handles_clone = abort_handles.clone();
|
||||
let cancel_monitor_token = cancel_token.clone();
|
||||
let cancel_monitor = tokio::spawn(async move {
|
||||
cancel_monitor_token.cancelled().await;
|
||||
for handle in abort_handles_clone {
|
||||
handle.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all tasks to complete
|
||||
let mut results = Vec::with_capacity(handles.len());
|
||||
for handle in handles {
|
||||
tokio::select! {
|
||||
_ = &mut cancel_rx => {
|
||||
return Err(Error::Cancelled);
|
||||
match handle.await {
|
||||
Ok(result) => results.push(result),
|
||||
Err(e) if e.is_cancelled() => {
|
||||
// Task was aborted due to cancellation
|
||||
}
|
||||
result = handle => {
|
||||
results.push(result.unwrap_or(AssetResult::Error {
|
||||
asset_id: "unknown".to_string(),
|
||||
error: "Task panicked".to_string(),
|
||||
}));
|
||||
Err(e) => {
|
||||
tracing::error!("Task panicked: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the cancel monitor
|
||||
cancel_monitor.abort();
|
||||
|
||||
// Check if we were cancelled
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
// Count results
|
||||
let successful = results
|
||||
.iter()
|
||||
|
|
@ -228,7 +268,7 @@ async fn run_job_inner(
|
|||
}
|
||||
|
||||
// Check for cancellation before video compilation
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,24 +6,58 @@ use crate::web::state::{AppState, JobStatus, Progress};
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
response::{Html, IntoResponse, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
/// Create the router with all routes.
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
// Get output directory from config for serving results
|
||||
let output_dir = state
|
||||
.config
|
||||
.try_read()
|
||||
.map(|c| c.output_dir.clone())
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("output"));
|
||||
|
||||
Router::new()
|
||||
// API routes
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/connection", get(check_connection))
|
||||
.route("/api/people", get(get_people))
|
||||
.route("/api/progress", get(get_progress))
|
||||
.route("/api/start", post(start_processing))
|
||||
.route("/api/cancel", post(cancel_processing))
|
||||
// Serve output files (video, images)
|
||||
.nest_service("/output", ServeDir::new(output_dir))
|
||||
// Serve frontend static files (fallback to index.html for SPA routing)
|
||||
.fallback_service(ServeDir::new("frontend/dist").fallback(get(serve_index)))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Serve index.html for SPA routing.
|
||||
async fn serve_index() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("frontend/dist/index.html").await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(_) => Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Immich Timelapse</title></head>
|
||||
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e0e0e0;">
|
||||
<div style="text-align: center;">
|
||||
<h1>Frontend not built</h1>
|
||||
<p>Run <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">cd frontend && npm install && npm run build</code></p>
|
||||
<p style="margin-top: 1rem; color: #888;">Or use <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">npm run dev</code> for development</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Health check endpoint.
|
||||
async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
|
|
@ -169,9 +203,8 @@ async fn start_processing(
|
|||
})
|
||||
.await;
|
||||
|
||||
// Create cancellation channel
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||
state.set_cancel_sender(cancel_tx).await;
|
||||
// Create cancellation token
|
||||
let cancel_token = state.create_cancel_token().await;
|
||||
|
||||
tracing::info!(
|
||||
"Starting processing for person {} (date range: {:?} - {:?})",
|
||||
|
|
@ -189,7 +222,7 @@ async fn start_processing(
|
|||
|
||||
let job_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
run_job(job_state, job_params, cancel_rx).await;
|
||||
run_job(job_state, job_params, cancel_token).await;
|
||||
});
|
||||
|
||||
Ok(Json(StartResponse {
|
||||
|
|
@ -203,15 +236,7 @@ async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse>
|
|||
let cancelled = state.request_cancel().await;
|
||||
|
||||
if cancelled {
|
||||
state
|
||||
.update_progress(Progress {
|
||||
status: JobStatus::Cancelled,
|
||||
completed: 0,
|
||||
total: 0,
|
||||
message: Some("Cancelled by user".to_string()),
|
||||
})
|
||||
.await;
|
||||
|
||||
// The job will update its own status when it detects cancellation
|
||||
Json(StartResponse {
|
||||
success: true,
|
||||
message: "Cancellation requested".to_string(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use crate::config::Config;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Job status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -47,8 +48,8 @@ pub struct AppState {
|
|||
/// Channel for broadcasting progress updates.
|
||||
pub progress_tx: broadcast::Sender<Progress>,
|
||||
|
||||
/// Cancellation signal sender.
|
||||
pub cancel_tx: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
|
||||
/// Cancellation token for the current job.
|
||||
pub cancel_token: Arc<RwLock<Option<CancellationToken>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -59,7 +60,7 @@ impl AppState {
|
|||
config: Arc::new(RwLock::new(config)),
|
||||
progress: Arc::new(RwLock::new(Progress::default())),
|
||||
progress_tx,
|
||||
cancel_tx: Arc::new(RwLock::new(None)),
|
||||
cancel_token: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,17 +72,24 @@ impl AppState {
|
|||
|
||||
/// Request cancellation of the current job.
|
||||
pub async fn request_cancel(&self) -> bool {
|
||||
let mut cancel_tx = self.cancel_tx.write().await;
|
||||
if let Some(tx) = cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
let cancel_token = self.cancel_token.read().await;
|
||||
if let Some(token) = cancel_token.as_ref() {
|
||||
token.cancel();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the cancellation sender for a new job.
|
||||
pub async fn set_cancel_sender(&self, tx: tokio::sync::oneshot::Sender<()>) {
|
||||
*self.cancel_tx.write().await = Some(tx);
|
||||
/// Create a new cancellation token for a job.
|
||||
pub async fn create_cancel_token(&self) -> CancellationToken {
|
||||
let token = CancellationToken::new();
|
||||
*self.cancel_token.write().await = Some(token.clone());
|
||||
token
|
||||
}
|
||||
|
||||
/// Clear the cancellation token when job completes.
|
||||
pub async fn clear_cancel_token(&self) {
|
||||
*self.cancel_token.write().await = None;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue