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>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
|
||||
import GalleryView from './lib/components/GalleryView.svelte';
|
||||
import OutputManager from './lib/components/OutputManager.svelte';
|
||||
import PeopleSelector from './lib/components/PeopleSelector.svelte';
|
||||
import ProcessingControls from './lib/components/ProcessingControls.svelte';
|
||||
|
|
@ -14,10 +15,32 @@
|
|||
let progress = $state({ completed: 0, total: 0, message: '' });
|
||||
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(
|
||||
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) {
|
||||
connectionOk = data.connected;
|
||||
// Check for running job when connection is established
|
||||
|
|
@ -62,12 +85,17 @@
|
|||
const res = await fetch('/api/progress');
|
||||
const data = await res.json();
|
||||
|
||||
const previousStatus = jobStatus;
|
||||
jobStatus = data.status;
|
||||
progress = data;
|
||||
|
||||
// Stop polling when job completes
|
||||
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' || data.status === 'idle') {
|
||||
stopPolling();
|
||||
// Refresh output folders when job finishes (was running before)
|
||||
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
|
||||
outputFolderRefreshKey++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Poll failed:', e);
|
||||
|
|
@ -102,41 +130,57 @@
|
|||
</header>
|
||||
|
||||
{#if connectionOk}
|
||||
<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}
|
||||
onupdate={handleJobUpdate}
|
||||
{#if currentView === 'gallery' && galleryFolder}
|
||||
<section class="gallery">
|
||||
<GalleryView
|
||||
folderName={galleryFolder.name}
|
||||
onBack={closeGallery}
|
||||
disabled={isJobRunning}
|
||||
/>
|
||||
</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}
|
||||
</section>
|
||||
|
||||
{#if jobStatus !== 'idle'}
|
||||
<section class="progress">
|
||||
<ProgressDisplay {jobStatus} {progress} />
|
||||
{#if jobStatus === 'completed'}
|
||||
<section class="results">
|
||||
<ResultsView />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="output">
|
||||
<OutputManager
|
||||
disabled={isJobRunning}
|
||||
onOpenGallery={openGallery}
|
||||
refreshKey={outputFolderRefreshKey}
|
||||
onFoldersLoaded={handleFoldersLoaded}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if jobStatus === 'completed'}
|
||||
<section class="results">
|
||||
<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>
|
||||
|
|
|
|||
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>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { disabled = false } = $props();
|
||||
let { disabled = false, onOpenGallery, refreshKey = 0, onFoldersLoaded } = $props();
|
||||
|
||||
let folders = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
let deleting = $state(null);
|
||||
|
||||
// Re-fetch when refreshKey changes
|
||||
$effect(() => {
|
||||
if (refreshKey > 0) {
|
||||
loadFolders();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFolders() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
|
@ -15,6 +22,8 @@
|
|||
const res = await fetch('/api/output');
|
||||
if (!res.ok) throw new Error('Failed to load output folders');
|
||||
folders = await res.json();
|
||||
// Notify parent about loaded folders (for existing folder warnings)
|
||||
onFoldersLoaded?.(folders);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
|
|
@ -116,6 +125,13 @@
|
|||
View
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
class="gallery-btn"
|
||||
onclick={() => onOpenGallery?.(folder)}
|
||||
disabled={disabled || deleting !== null}
|
||||
>
|
||||
Gallery
|
||||
</button>
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={() => deleteFolder(folder.name)}
|
||||
|
|
@ -273,6 +289,26 @@
|
|||
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 {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
let { personId, personName, jobStatus, onupdate } = $props();
|
||||
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
|
||||
|
||||
let dateFrom = $state('');
|
||||
let dateTo = $state('');
|
||||
|
|
@ -10,6 +10,29 @@
|
|||
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
|
||||
$effect(() => {
|
||||
if (personId) {
|
||||
|
|
@ -80,6 +103,25 @@
|
|||
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>
|
||||
|
||||
<div class="processing-controls">
|
||||
|
|
@ -113,7 +155,7 @@
|
|||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="start-btn" onclick={startProcessing}>
|
||||
<button class="start-btn" onclick={handleStartClick}>
|
||||
Start Processing
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -228,7 +270,12 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
.start-btn:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.start-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ pub fn create_router(state: AppState) -> Router {
|
|||
.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/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", put(update_config))
|
||||
// Serve output files (video, images)
|
||||
|
|
@ -395,6 +408,37 @@ struct OutputFolderInfo {
|
|||
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.
|
||||
async fn list_output_folders(
|
||||
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).
|
||||
/// Reuses config types which already derive Serialize.
|
||||
#[derive(Serialize)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue