Add a galery view to manually cleanup the assets before timelapse compilation.
This commit is contained in:
parent
eadd1b5d7b
commit
1278c6290c
5 changed files with 1046 additions and 34 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
|
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
|
||||||
|
import GalleryView from './lib/components/GalleryView.svelte';
|
||||||
import OutputManager from './lib/components/OutputManager.svelte';
|
import OutputManager from './lib/components/OutputManager.svelte';
|
||||||
import PeopleSelector from './lib/components/PeopleSelector.svelte';
|
import PeopleSelector from './lib/components/PeopleSelector.svelte';
|
||||||
import ProcessingControls from './lib/components/ProcessingControls.svelte';
|
import ProcessingControls from './lib/components/ProcessingControls.svelte';
|
||||||
|
|
@ -14,10 +15,32 @@
|
||||||
let progress = $state({ completed: 0, total: 0, message: '' });
|
let progress = $state({ completed: 0, total: 0, message: '' });
|
||||||
let pollInterval = $state(null);
|
let pollInterval = $state(null);
|
||||||
|
|
||||||
|
// View state management for gallery
|
||||||
|
let currentView = $state('main'); // 'main' | 'gallery'
|
||||||
|
let galleryFolder = $state(null);
|
||||||
|
|
||||||
|
// Output folder management
|
||||||
|
let outputFolderRefreshKey = $state(0);
|
||||||
|
let outputFolders = $state([]);
|
||||||
|
|
||||||
let isJobRunning = $derived(
|
let isJobRunning = $derived(
|
||||||
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
|
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleFoldersLoaded(folders) {
|
||||||
|
outputFolders = folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGallery(folder) {
|
||||||
|
galleryFolder = folder;
|
||||||
|
currentView = 'gallery';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGallery() {
|
||||||
|
galleryFolder = null;
|
||||||
|
currentView = 'main';
|
||||||
|
}
|
||||||
|
|
||||||
function handleConnectionChange(data) {
|
function handleConnectionChange(data) {
|
||||||
connectionOk = data.connected;
|
connectionOk = data.connected;
|
||||||
// Check for running job when connection is established
|
// Check for running job when connection is established
|
||||||
|
|
@ -62,12 +85,17 @@
|
||||||
const res = await fetch('/api/progress');
|
const res = await fetch('/api/progress');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
const previousStatus = jobStatus;
|
||||||
jobStatus = data.status;
|
jobStatus = data.status;
|
||||||
progress = data;
|
progress = data;
|
||||||
|
|
||||||
// Stop polling when job completes
|
// Stop polling when job completes
|
||||||
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' || data.status === 'idle') {
|
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' || data.status === 'idle') {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
// Refresh output folders when job finishes (was running before)
|
||||||
|
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
|
||||||
|
outputFolderRefreshKey++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Poll failed:', e);
|
console.error('Poll failed:', e);
|
||||||
|
|
@ -102,41 +130,57 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if connectionOk}
|
{#if connectionOk}
|
||||||
<section class="settings">
|
{#if currentView === 'gallery' && galleryFolder}
|
||||||
<SettingsPanel disabled={isJobRunning} />
|
<section class="gallery">
|
||||||
</section>
|
<GalleryView
|
||||||
|
folderName={galleryFolder.name}
|
||||||
<section class="controls">
|
onBack={closeGallery}
|
||||||
<PeopleSelector
|
disabled={isJobRunning}
|
||||||
onselect={handlePersonSelect}
|
|
||||||
disabled={isJobRunning}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if selectedPerson && !isJobRunning}
|
|
||||||
<ProcessingControls
|
|
||||||
personId={selectedPerson.id}
|
|
||||||
personName={selectedPerson.name}
|
|
||||||
{jobStatus}
|
|
||||||
onupdate={handleJobUpdate}
|
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section class="settings">
|
||||||
|
<SettingsPanel disabled={isJobRunning} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="controls">
|
||||||
|
<PeopleSelector
|
||||||
|
onselect={handlePersonSelect}
|
||||||
|
disabled={isJobRunning}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if selectedPerson && !isJobRunning}
|
||||||
|
<ProcessingControls
|
||||||
|
personId={selectedPerson.id}
|
||||||
|
personName={selectedPerson.name}
|
||||||
|
{jobStatus}
|
||||||
|
{outputFolders}
|
||||||
|
onupdate={handleJobUpdate}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if jobStatus !== 'idle'}
|
||||||
|
<section class="progress">
|
||||||
|
<ProgressDisplay {jobStatus} {progress} />
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if jobStatus !== 'idle'}
|
{#if jobStatus === 'completed'}
|
||||||
<section class="progress">
|
<section class="results">
|
||||||
<ProgressDisplay {jobStatus} {progress} />
|
<ResultsView />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="output">
|
||||||
|
<OutputManager
|
||||||
|
disabled={isJobRunning}
|
||||||
|
onOpenGallery={openGallery}
|
||||||
|
refreshKey={outputFolderRefreshKey}
|
||||||
|
onFoldersLoaded={handleFoldersLoaded}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if jobStatus === 'completed'}
|
|
||||||
<section class="results">
|
|
||||||
<ResultsView />
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="output">
|
|
||||||
<OutputManager disabled={isJobRunning} />
|
|
||||||
</section>
|
|
||||||
{:else}
|
{:else}
|
||||||
<section class="not-connected">
|
<section class="not-connected">
|
||||||
<p>Connect to your Immich server to get started.</p>
|
<p>Connect to your Immich server to get started.</p>
|
||||||
|
|
|
||||||
465
frontend/src/lib/components/GalleryView.svelte
Normal file
465
frontend/src/lib/components/GalleryView.svelte
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
folderName,
|
||||||
|
onBack,
|
||||||
|
disabled = false
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let images = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state(null);
|
||||||
|
let selectedImages = $state(new Set());
|
||||||
|
let deleting = $state(false);
|
||||||
|
let compiling = $state(false);
|
||||||
|
let videoExists = $state(false);
|
||||||
|
|
||||||
|
let selectedCount = $derived(selectedImages.size);
|
||||||
|
let allSelected = $derived(images.length > 0 && selectedImages.size === images.length);
|
||||||
|
|
||||||
|
async function loadImages() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/images`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || 'Failed to load images');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
images = data.images;
|
||||||
|
videoExists = data.video_exists;
|
||||||
|
// Clear selection when reloading
|
||||||
|
selectedImages = new Set();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(filename) {
|
||||||
|
if (selectedImages.has(filename)) {
|
||||||
|
selectedImages.delete(filename);
|
||||||
|
selectedImages = new Set(selectedImages);
|
||||||
|
} else {
|
||||||
|
selectedImages.add(filename);
|
||||||
|
selectedImages = new Set(selectedImages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
selectedImages = new Set(images.map(img => img.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
selectedImages = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (selectedImages.size === 0) return;
|
||||||
|
|
||||||
|
const count = selectedImages.size;
|
||||||
|
if (!confirm(`Delete ${count} selected image${count > 1 ? 's' : ''}? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/images`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filenames: Array.from(selectedImages) })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || 'Failed to delete images');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (result.failed_count > 0) {
|
||||||
|
alert(`Deleted ${result.deleted_count} images. ${result.failed_count} failed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload images
|
||||||
|
await loadImages();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compileVideo() {
|
||||||
|
if (!confirm('Compile video from these images?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
compiling = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/compile`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || 'Failed to start video compilation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compilation runs in background - return to main view to see progress
|
||||||
|
alert('Video compilation started. Return to main view to see progress.');
|
||||||
|
onBack?.();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
compiling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
loadImages();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="gallery-view">
|
||||||
|
<header class="gallery-header">
|
||||||
|
<button class="back-btn" onclick={onBack} disabled={disabled || deleting || compiling}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h2>
|
||||||
|
{folderName}
|
||||||
|
{#if !loading}
|
||||||
|
<span class="image-count">({images.length} images)</span>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="status">Loading images...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{:else if images.length === 0}
|
||||||
|
<div class="status empty">No images in this folder</div>
|
||||||
|
{:else}
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="selection-controls">
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
onclick={selectAll}
|
||||||
|
disabled={disabled || deleting || compiling || allSelected}
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
onclick={deselectAll}
|
||||||
|
disabled={disabled || deleting || compiling || selectedCount === 0}
|
||||||
|
>
|
||||||
|
Deselect All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="toolbar-btn delete-btn"
|
||||||
|
onclick={deleteSelected}
|
||||||
|
disabled={disabled || deleting || compiling || selectedCount === 0}
|
||||||
|
>
|
||||||
|
{#if deleting}
|
||||||
|
Deleting...
|
||||||
|
{:else}
|
||||||
|
Delete Selected ({selectedCount})
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-grid">
|
||||||
|
{#each images as image}
|
||||||
|
<div
|
||||||
|
class="image-card"
|
||||||
|
class:selected={selectedImages.has(image.filename)}
|
||||||
|
onclick={() => toggleSelect(image.filename)}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selectedImages.has(image.filename)}
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && toggleSelect(image.filename)}
|
||||||
|
>
|
||||||
|
<div class="checkbox-overlay">
|
||||||
|
<span class="checkbox">{selectedImages.has(image.filename) ? '✓' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src="/output/{encodeURIComponent(folderName)}/images/{encodeURIComponent(image.filename)}"
|
||||||
|
alt={image.filename}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="image-info">
|
||||||
|
<span class="image-date">{image.filename.split('_')[0]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="gallery-footer">
|
||||||
|
<div class="footer-info">
|
||||||
|
{selectedCount} selected
|
||||||
|
{#if videoExists}
|
||||||
|
<span class="video-badge">Video exists</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="compile-btn"
|
||||||
|
onclick={compileVideo}
|
||||||
|
disabled={disabled || deleting || compiling || images.length === 0}
|
||||||
|
>
|
||||||
|
{#if compiling}
|
||||||
|
Starting...
|
||||||
|
{:else}
|
||||||
|
Compile Video
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gallery-view {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover:not(:disabled) {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.empty {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover:not(:disabled) {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.delete-btn:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card:hover {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card.selected {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card:focus {
|
||||||
|
outline: 2px solid #4f46e5;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
left: 0.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card.selected .checkbox-overlay {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-date {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-badge {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #0f0f0f;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compile-btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: #4f46e5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compile-btn:hover:not(:disabled) {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compile-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { disabled = false } = $props();
|
let { disabled = false, onOpenGallery, refreshKey = 0, onFoldersLoaded } = $props();
|
||||||
|
|
||||||
let folders = $state([]);
|
let folders = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state(null);
|
let error = $state(null);
|
||||||
let deleting = $state(null);
|
let deleting = $state(null);
|
||||||
|
|
||||||
|
// Re-fetch when refreshKey changes
|
||||||
|
$effect(() => {
|
||||||
|
if (refreshKey > 0) {
|
||||||
|
loadFolders();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadFolders() {
|
async function loadFolders() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
@ -15,6 +22,8 @@
|
||||||
const res = await fetch('/api/output');
|
const res = await fetch('/api/output');
|
||||||
if (!res.ok) throw new Error('Failed to load output folders');
|
if (!res.ok) throw new Error('Failed to load output folders');
|
||||||
folders = await res.json();
|
folders = await res.json();
|
||||||
|
// Notify parent about loaded folders (for existing folder warnings)
|
||||||
|
onFoldersLoaded?.(folders);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -116,6 +125,13 @@
|
||||||
View
|
View
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
class="gallery-btn"
|
||||||
|
onclick={() => onOpenGallery?.(folder)}
|
||||||
|
disabled={disabled || deleting !== null}
|
||||||
|
>
|
||||||
|
Gallery
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
onclick={() => deleteFolder(folder.name)}
|
onclick={() => deleteFolder(folder.name)}
|
||||||
|
|
@ -273,6 +289,26 @@
|
||||||
background: #4f46e5;
|
background: #4f46e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-btn:hover:not(:disabled) {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
let { personId, personName, jobStatus, onupdate } = $props();
|
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
|
||||||
|
|
||||||
let dateFrom = $state('');
|
let dateFrom = $state('');
|
||||||
let dateTo = $state('');
|
let dateTo = $state('');
|
||||||
|
|
@ -10,6 +10,29 @@
|
||||||
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
|
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Match backend's sanitize_folder_name logic
|
||||||
|
// Uses Unicode-aware regex to match Rust's is_alphanumeric()
|
||||||
|
function sanitizeFolderName(name, id) {
|
||||||
|
const base = name && name.trim() ? name : id;
|
||||||
|
let sanitized = '';
|
||||||
|
for (const c of base) {
|
||||||
|
// \p{L} = Unicode letter, \p{N} = Unicode number (matches Rust's is_alphanumeric)
|
||||||
|
if (/^[\p{L}\p{N}\-_ ]$/u.test(c)) {
|
||||||
|
sanitized += c;
|
||||||
|
} else {
|
||||||
|
sanitized += '_';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sanitized = sanitized.trim();
|
||||||
|
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an output folder already exists for this person
|
||||||
|
let existingFolder = $derived.by(() => {
|
||||||
|
const expectedName = sanitizeFolderName(personName, personId);
|
||||||
|
return outputFolders.find(f => f.name === expectedName);
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch asset count when personId changes
|
// Fetch asset count when personId changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (personId) {
|
if (personId) {
|
||||||
|
|
@ -80,6 +103,25 @@
|
||||||
console.error('Cancel failed:', e);
|
console.error('Cancel failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartClick() {
|
||||||
|
if (existingFolder) {
|
||||||
|
const folder = existingFolder;
|
||||||
|
const message = `"${folder.name}" already has ${folder.image_count} images (${formatSize(folder.size_bytes)})${folder.has_video ? ' and a compiled video' : ''}.\n\nClick OK to overwrite.`;
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
startProcessing();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startProcessing();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="processing-controls">
|
<div class="processing-controls">
|
||||||
|
|
@ -113,7 +155,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="start-btn" onclick={startProcessing}>
|
<button class="start-btn" onclick={handleStartClick}>
|
||||||
Start Processing
|
Start Processing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,7 +270,12 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-btn:hover {
|
.start-btn:hover:not(:disabled) {
|
||||||
background: #4338ca;
|
background: #4338ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.start-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,19 @@ pub fn create_router(state: AppState) -> Router {
|
||||||
.route("/api/output", get(list_output_folders))
|
.route("/api/output", get(list_output_folders))
|
||||||
.route("/api/output", delete(cleanup_all_output))
|
.route("/api/output", delete(cleanup_all_output))
|
||||||
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
|
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
|
||||||
|
.route("/api/output/{folder_name}/images", get(list_folder_images))
|
||||||
|
.route(
|
||||||
|
"/api/output/{folder_name}/images",
|
||||||
|
delete(delete_images_bulk),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/output/{folder_name}/images/{filename}",
|
||||||
|
delete(delete_single_image),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/output/{folder_name}/compile",
|
||||||
|
post(compile_folder_video),
|
||||||
|
)
|
||||||
.route("/api/config", get(get_config))
|
.route("/api/config", get(get_config))
|
||||||
.route("/api/config", put(update_config))
|
.route("/api/config", put(update_config))
|
||||||
// Serve output files (video, images)
|
// Serve output files (video, images)
|
||||||
|
|
@ -395,6 +408,37 @@ struct OutputFolderInfo {
|
||||||
has_video: bool,
|
has_video: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Image information for gallery view.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ImageInfo {
|
||||||
|
filename: String,
|
||||||
|
size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for listing images in a folder.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FolderImagesResponse {
|
||||||
|
folder_name: String,
|
||||||
|
images: Vec<ImageInfo>,
|
||||||
|
total_count: u32,
|
||||||
|
total_size_bytes: u64,
|
||||||
|
video_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request for bulk deleting images.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BulkDeleteRequest {
|
||||||
|
filenames: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for bulk delete operations.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct BulkDeleteResponse {
|
||||||
|
deleted_count: u32,
|
||||||
|
failed_count: u32,
|
||||||
|
remaining_images: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// List all output folders with their stats.
|
/// List all output folders with their stats.
|
||||||
async fn list_output_folders(
|
async fn list_output_folders(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -574,6 +618,382 @@ async fn cleanup_output_folder(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List images in a specific output folder.
|
||||||
|
async fn list_folder_images(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(folder_name): Path<String>,
|
||||||
|
) -> Result<Json<FolderImagesResponse>, (StatusCode, String)> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
|
||||||
|
// Sanitize folder name to prevent path traversal
|
||||||
|
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let images_dir = config.output_dir.join(&folder_name).join("images");
|
||||||
|
|
||||||
|
if !images_dir.exists() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Folder '{}' not found or has no images", folder_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut images = Vec::new();
|
||||||
|
let mut total_size_bytes = 0u64;
|
||||||
|
|
||||||
|
let mut entries = tokio::fs::read_dir(&images_dir).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to read images 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.extension().map_or(false, |ext| ext == "jpg") {
|
||||||
|
let filename = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let metadata = tokio::fs::metadata(&path).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to read file metadata: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let size_bytes = metadata.len();
|
||||||
|
total_size_bytes += size_bytes;
|
||||||
|
|
||||||
|
images.push(ImageInfo {
|
||||||
|
filename,
|
||||||
|
size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by filename (chronological since filenames are timestamp-based)
|
||||||
|
images.sort_by(|a, b| a.filename.cmp(&b.filename));
|
||||||
|
|
||||||
|
let total_count = images.len() as u32;
|
||||||
|
let video_exists = config
|
||||||
|
.output_dir
|
||||||
|
.join(&folder_name)
|
||||||
|
.join("timelapse.mp4")
|
||||||
|
.exists();
|
||||||
|
|
||||||
|
Ok(Json(FolderImagesResponse {
|
||||||
|
folder_name,
|
||||||
|
images,
|
||||||
|
total_count,
|
||||||
|
total_size_bytes,
|
||||||
|
video_exists,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a single image from an output folder.
|
||||||
|
async fn delete_single_image(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((folder_name, filename)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<StartResponse>, (StatusCode, String)> {
|
||||||
|
// Check if a job is running
|
||||||
|
{
|
||||||
|
let progress = state.progress.read().await;
|
||||||
|
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Cannot delete images while a job is running".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
|
||||||
|
// Sanitize folder name and filename to prevent path traversal
|
||||||
|
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
|
||||||
|
}
|
||||||
|
if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid filename".to_string()));
|
||||||
|
}
|
||||||
|
if !filename.ends_with(".jpg") {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Only .jpg files can be deleted".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = config
|
||||||
|
.output_dir
|
||||||
|
.join(&folder_name)
|
||||||
|
.join("images")
|
||||||
|
.join(&filename);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Image '{}' not found in folder '{}'", filename, folder_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::remove_file(&file_path).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to delete image: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Deleted image: {}/{}", folder_name, filename);
|
||||||
|
|
||||||
|
Ok(Json(StartResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Deleted image '{}'", filename),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk delete images from an output folder.
|
||||||
|
async fn delete_images_bulk(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(folder_name): Path<String>,
|
||||||
|
Json(request): Json<BulkDeleteRequest>,
|
||||||
|
) -> Result<Json<BulkDeleteResponse>, (StatusCode, String)> {
|
||||||
|
// Check if a job is running
|
||||||
|
{
|
||||||
|
let progress = state.progress.read().await;
|
||||||
|
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Cannot delete images while a job is running".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
|
||||||
|
// Sanitize folder name
|
||||||
|
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let images_dir = config.output_dir.join(&folder_name).join("images");
|
||||||
|
|
||||||
|
if !images_dir.exists() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Folder '{}' not found or has no images", folder_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deleted_count = 0u32;
|
||||||
|
let mut failed_count = 0u32;
|
||||||
|
|
||||||
|
for filename in &request.filenames {
|
||||||
|
// Sanitize each filename
|
||||||
|
if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
|
||||||
|
failed_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !filename.ends_with(".jpg") {
|
||||||
|
failed_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = images_dir.join(filename);
|
||||||
|
if file_path.exists() {
|
||||||
|
match tokio::fs::remove_file(&file_path).await {
|
||||||
|
Ok(_) => deleted_count += 1,
|
||||||
|
Err(_) => failed_count += 1,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failed_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count remaining images
|
||||||
|
let mut remaining_images = 0u32;
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
if entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.map_or(false, |ext| ext == "jpg")
|
||||||
|
{
|
||||||
|
remaining_images += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Bulk deleted {} images from folder {} ({} failed)",
|
||||||
|
deleted_count,
|
||||||
|
folder_name,
|
||||||
|
failed_count
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(BulkDeleteResponse {
|
||||||
|
deleted_count,
|
||||||
|
failed_count,
|
||||||
|
remaining_images,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile video for a specific output folder.
|
||||||
|
async fn compile_folder_video(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(folder_name): Path<String>,
|
||||||
|
) -> Result<Json<StartResponse>, (StatusCode, String)> {
|
||||||
|
// Check if a job is running
|
||||||
|
{
|
||||||
|
let progress = state.progress.read().await;
|
||||||
|
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
|
||||||
|
return Err((StatusCode::CONFLICT, "A job is already running".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
|
||||||
|
// Sanitize folder name
|
||||||
|
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_path = config.output_dir.join(&folder_name);
|
||||||
|
let images_dir = folder_path.join("images");
|
||||||
|
|
||||||
|
if !images_dir.exists() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Folder '{}' not found or has no images", folder_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count images to verify there are some
|
||||||
|
let mut image_count = 0u32;
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
if entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.map_or(false, |ext| ext == "jpg")
|
||||||
|
{
|
||||||
|
image_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if image_count == 0 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"No images found in folder to compile".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status to compiling video
|
||||||
|
state
|
||||||
|
.update_progress(Progress {
|
||||||
|
status: JobStatus::CompilingVideo,
|
||||||
|
completed: 0,
|
||||||
|
total: image_count,
|
||||||
|
message: Some(format!("Compiling video for {}...", folder_name)),
|
||||||
|
skip_stats: SkipStats::default(),
|
||||||
|
person_id: None,
|
||||||
|
person_name: Some(folder_name.clone()),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create cancellation token
|
||||||
|
let cancel_token = state.create_cancel_token().await;
|
||||||
|
|
||||||
|
// Clone values needed for the async task
|
||||||
|
let video_config = config.video.clone();
|
||||||
|
let output_path = folder_path.join("timelapse.mp4");
|
||||||
|
let job_state = state.clone();
|
||||||
|
let folder_name_clone = folder_name.clone();
|
||||||
|
|
||||||
|
// Spawn the compilation job in the background
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = crate::video::compile_timelapse(&images_dir, &output_path, &video_config, |current, total| {
|
||||||
|
// Check for cancellation
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress (fire and forget since we're in sync callback)
|
||||||
|
let state_clone = job_state.clone();
|
||||||
|
let folder_clone = folder_name_clone.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
state_clone
|
||||||
|
.update_progress(Progress {
|
||||||
|
status: JobStatus::CompilingVideo,
|
||||||
|
completed: current,
|
||||||
|
total,
|
||||||
|
message: Some(format!("Compiling video for {}...", folder_clone)),
|
||||||
|
skip_stats: SkipStats::default(),
|
||||||
|
person_id: None,
|
||||||
|
person_name: Some(folder_clone),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Update final status
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!("Video compilation complete for folder: {}", folder_name_clone);
|
||||||
|
job_state
|
||||||
|
.update_progress(Progress {
|
||||||
|
status: JobStatus::Completed,
|
||||||
|
completed: image_count,
|
||||||
|
total: image_count,
|
||||||
|
message: Some("Video compilation complete".to_string()),
|
||||||
|
skip_stats: SkipStats::default(),
|
||||||
|
person_id: None,
|
||||||
|
person_name: Some(folder_name_clone),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
job_state
|
||||||
|
.update_progress(Progress {
|
||||||
|
status: JobStatus::Cancelled,
|
||||||
|
completed: 0,
|
||||||
|
total: image_count,
|
||||||
|
message: Some("Video compilation cancelled".to_string()),
|
||||||
|
skip_stats: SkipStats::default(),
|
||||||
|
person_id: None,
|
||||||
|
person_name: Some(folder_name_clone),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
tracing::error!("Video compilation failed: {}", e);
|
||||||
|
job_state
|
||||||
|
.update_progress(Progress {
|
||||||
|
status: JobStatus::Error(e.to_string()),
|
||||||
|
completed: 0,
|
||||||
|
total: image_count,
|
||||||
|
message: Some(format!("Video compilation failed: {}", e)),
|
||||||
|
skip_stats: SkipStats::default(),
|
||||||
|
person_id: None,
|
||||||
|
person_name: Some(folder_name_clone),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job_state.clear_cancel_token().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(StartResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Video compilation started for '{}'", folder_name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration response (excludes sensitive API credentials).
|
/// Configuration response (excludes sensitive API credentials).
|
||||||
/// Reuses config types which already derive Serialize.
|
/// Reuses config types which already derive Serialize.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue