genything / src /routes /+page.svelte
burtenshaw
add transition
a3c89f3
raw
history blame contribute delete
24.9 kB
<script>
import { onDestroy, tick } from 'svelte';
// Core state
let mainPrompt = '';
let loraSearchQuery = '';
let foundLoras = []; // Array to store {id: string, author: string}
let selectedLoraId = '';
let generatedImageUrl = '';
// Status flags
let isSearchingLoras = false;
let isGeneratingImage = false;
let searchError = '';
let generationError = '';
// Interaction state
let showLoraSearch = false; // Controls visibility of LoRA search section
let debounceTimeout;
let animatedLoadingText = ''; // For animated text during generation
let loadingIntervalId = null;
// --- Phases (Derived Reactively) ---
$: isInPromptPhase = !showLoraSearch && !isGeneratingImage && !generatedImageUrl;
$: isInLoraSearchPhase = showLoraSearch && !selectedLoraId && !isGeneratingImage && !generatedImageUrl;
$: isInLoraSelectedPhase = showLoraSearch && selectedLoraId && !isGeneratingImage && !generatedImageUrl;
$: isInGeneratingPhase = isGeneratingImage;
$: isInResultPhase = generatedImageUrl && !isGeneratingImage;
// --- Debounced LoRA Search ---
function debounce(func, delay) {
return function(...args) {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
async function searchLoras(query) {
if (!query || query.trim().length < 3) {
foundLoras = [];
searchError = query.trim().length > 0 ? 'Enter at least 3 characters.' : '';
isSearchingLoras = false;
// Don't reset selection if query becomes too short temporarily
// selectedLoraId = '';
return;
}
isSearchingLoras = true;
searchError = '';
// Don't clear foundLoras immediately, prevents flicker
// foundLoras = [];
// selectedLoraId = ''; // Reset selection on new search? Maybe not, allow refinement.
generationError = ''; // Clear generation error
console.log(`Searching for: ${query}`);
try {
const response = await fetch('/api/search-loras', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error ${response.status}`);
}
const results = await response.json();
console.log('Found LoRAs:', results.length, results); // Log results received
// Check if the previously selected LoRA is still valid in the new results
let previousSelectionStillValid = selectedLoraId && results.some(lora => lora.id === selectedLoraId);
foundLoras = results;
// If the previous selection is no longer valid, or if nothing was selected,
// automatically select the first result if available.
if (!previousSelectionStillValid && foundLoras.length > 0) {
selectedLoraId = foundLoras[0].id;
// Clear any generation error since we made a selection
generationError = '';
} else if (!previousSelectionStillValid) {
// If previous selection invalid AND no new results, clear selection
selectedLoraId = '';
}
// If previousSelectionStillValid is true, selectedLoraId remains unchanged.
if (foundLoras.length === 0) {
searchError = 'No matching LoRAs found.';
}
} catch (err) {
console.error('Search error:', err);
searchError = err.message || 'Failed to search.';
selectedLoraId = ''; // Reset selection on error
foundLoras = []; // Clear results on error
} finally {
isSearchingLoras = false;
}
}
const debouncedSearchLoras = debounce(searchLoras, 400); // 400ms delay
function handleLoraSearchInput(event) {
const query = event.target.value;
loraSearchQuery = query;
selectedLoraId = ''; // Clear selection when search query changes
searchError = '';
isSearchingLoras = query.trim().length >= 3;
debouncedSearchLoras(query);
}
function handleLoraSelection(loraId) {
selectedLoraId = loraId;
generationError = ''; // Clear error on selection
// No automatic generation, wait for button click
}
// --- Image Generation ---
async function generateImage() {
if (!selectedLoraId || !mainPrompt || isGeneratingImage) {
generationError = 'Prompt and LoRA style must be selected.';
return;
}
isGeneratingImage = true;
generationError = '';
showLoraSearch = false; // Hide search during generation
// Clean up previous object URL if it exists
if (generatedImageUrl.startsWith('blob:')) {
URL.revokeObjectURL(generatedImageUrl);
generatedImageUrl = '';
}
try {
const response = await fetch('/api/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loraModelId: selectedLoraId, prompt: mainPrompt })
});
if (!response.ok) {
let errorMsg = `Generation failed (${response.status})`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) { /* Ignore */ }
throw new Error(errorMsg);
}
const imageBlob = await response.blob();
generatedImageUrl = URL.createObjectURL(imageBlob);
} catch (err) {
console.error('Generation error:', err);
generationError = err.message || 'Failed to generate image.';
generatedImageUrl = ''; // Ensure no broken image shows
} finally {
isGeneratingImage = false;
// Stay in result phase (or error)
}
}
// --- Keyboard Handling ---
function handlePromptKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey && mainPrompt.trim()) {
event.preventDefault(); // Prevent newline
proceedFromPrompt();
}
}
// --- Actions ---
function proceedFromPrompt() {
if (mainPrompt.trim()) {
showLoraSearch = true;
// Reset LoRA search state if needed
loraSearchQuery = '';
foundLoras = [];
selectedLoraId = '';
searchError = '';
generationError = '';
// Focus the LoRA input shortly after it appears
tick().then(() => {
const loraInput = document.querySelector('.lora-search-input');
if (loraInput) loraInput.focus();
});
}
}
function goBackToPrompt() {
showLoraSearch = false;
generationError = '';
searchError = '';
// Keep prompt value
// Focus prompt input
tick().then(() => {
const promptInput = document.querySelector('.prompt-input');
if (promptInput) promptInput.focus();
});
}
function startOver() {
mainPrompt = '';
loraSearchQuery = '';
foundLoras = [];
selectedLoraId = '';
if (generatedImageUrl.startsWith('blob:')) {
URL.revokeObjectURL(generatedImageUrl);
}
generatedImageUrl = '';
isSearchingLoras = false;
isGeneratingImage = false;
searchError = '';
generationError = '';
showLoraSearch = false;
// Focus prompt input
tick().then(() => {
const promptInput = document.querySelector('.prompt-input');
if (promptInput) promptInput.focus();
});
}
// --- Single Button Logic ---
$: buttonText = (() => {
if (isInGeneratingPhase) return 'Generating...';
if (isInResultPhase) return 'Start Over';
if (isInLoraSelectedPhase) return 'Generate Image';
if (isInLoraSearchPhase) return 'Select a Style'; // Or maybe disabled?
if (isInPromptPhase) return 'Choose Style →';
return '...'; // Default/fallback
})();
$: isButtonDisabled = (() => {
if (isInGeneratingPhase) return true;
if (isInResultPhase) return false; // Always allow start over
if (isInLoraSelectedPhase) return false; // Ready to generate
if (isInLoraSearchPhase) return !selectedLoraId; // Disabled until selection (or enable to go back?) No, separate back button is better
if (isInPromptPhase) return !mainPrompt.trim();
return true;
})();
function handleButtonClick() {
if (isInGeneratingPhase) return; // Should be disabled anyway
if (isInResultPhase) {
startOver();
} else if (isInLoraSelectedPhase) {
generateImage();
} else if (isInLoraSearchPhase) {
// Button should ideally be disabled here until selection. If clicked, do nothing or maybe focus input.
} else if (isInPromptPhase) {
proceedFromPrompt();
}
}
// --- Image Error Handling ---
function handleImageError(event) {
// Hide the broken image
event.target.style.display = 'none';
// Find and show the error message paragraph that should be right after the image
const errorMsgElement = event.target.nextElementSibling;
if (errorMsgElement && errorMsgElement.classList.contains('image-load-error')) {
errorMsgElement.style.display = 'block';
}
}
// --- Reactive Animation Logic ---
$: {
if (isGeneratingImage && !loadingIntervalId) {
const steps = [
`Prompt: ${mainPrompt}`,
`Style: ${selectedLoraId}`,
'Generating...'
];
let currentStepIndex = 0;
// Set initial text immediately
animatedLoadingText = steps[currentStepIndex];
loadingIntervalId = setInterval(() => {
currentStepIndex = (currentStepIndex + 1) % steps.length;
animatedLoadingText = steps[currentStepIndex];
}, 1000); // Change text every 1 second
} else if (!isGeneratingImage && loadingIntervalId) {
clearInterval(loadingIntervalId);
loadingIntervalId = null;
animatedLoadingText = ''; // Clear text when done
}
}
// --- Lifecycle ---
onDestroy(() => {
clearTimeout(debounceTimeout);
if (loadingIntervalId) {
clearInterval(loadingIntervalId); // Ensure cleanup on destroy
}
if (generatedImageUrl.startsWith('blob:')) {
URL.revokeObjectURL(generatedImageUrl);
}
});
</script>
<svelte:head>
<title>Image Generator</title>
</svelte:head>
<main class="app-container">
<!-- Prompt Input -->
{#if isInPromptPhase || isInLoraSearchPhase || isInLoraSelectedPhase}
<section class="prompt-section" class:hidden={!isInPromptPhase}>
<textarea
class="prompt-input"
bind:value={mainPrompt}
placeholder="Describe your image..."
rows="3"
on:keydown={handlePromptKeydown}
disabled={!isInPromptPhase}
></textarea>
</section>
{/if}
<!-- LoRA Search & Selection -->
{#if showLoraSearch && !isInGeneratingPhase && !isInResultPhase}
<section class="lora-section">
<button class="back-button" on:click={goBackToPrompt} title="Back to Prompt">&larr; Edit Prompt</button>
<div class="search-container">
<input
type="search"
class="lora-search-input"
bind:value={loraSearchQuery}
on:input={handleLoraSearchInput}
placeholder="Search for a style (e.g., watercolor)..."
disabled={isGeneratingImage}
/>
{#if isSearchingLoras}<div class="spinner"></div>{/if}
</div>
{#if searchError}<p class="error-message">{searchError}</p>{/if}
{#if !isSearchingLoras && foundLoras.length > 0}
<ul class="lora-list">
{#each foundLoras as lora (lora.id)}
<li>
<button
class="lora-item"
class:selected={selectedLoraId === lora.id}
on:click={() => handleLoraSelection(lora.id)}
title={lora.id}
>
{#if lora.thumbnail}
<img
src={lora.thumbnail}
alt="{lora.id} thumbnail"
class="lora-thumbnail"
loading="lazy"
/>
{:else}
<div class="lora-thumbnail-placeholder"></div>
{/if}
<div class="lora-info">
<span class="lora-name">{lora.id.split('/').pop()}</span>
<span class="lora-author">by {lora.author}</span>
</div>
</button>
</li>
{/each}
</ul>
{:else if !isSearchingLoras && loraSearchQuery.trim().length >= 3 && !searchError}
<p class="info-message">No styles found matching "{loraSearchQuery}".</p>
{/if}
</section>
{/if}
<!-- Generating State -->
{#if isInGeneratingPhase}
<section class="generating-section">
<div class="spinner large"></div>
{#key animatedLoadingText} <!-- Use key block for animation trigger -->
<p class="loading-text" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
{animatedLoadingText}
</p>
{/key}
</section>
{/if}
<!-- Result Display -->
{#if isInResultPhase}
<section class="result-section">
{#if generationError}
<p class="error-message fatal">{generationError}</p>
{/if}
{#if generatedImageUrl}
<img
class="result-image"
src={generatedImageUrl}
alt="Generated: {mainPrompt}"
on:error={handleImageError}
/>
<p class="error-message image-load-error" style="display: none;">Failed to load generated image.</p>
{/if}
</section>
{/if}
<!-- Action Button -->
<div class="action-button-container">
{#if !isInGeneratingPhase}
<button
class="action-button"
on:click={handleButtonClick}
disabled={isButtonDisabled}
>
{buttonText}
</button>
{/if}
</div>
</main>
<style>
:root {
--bg-color: #121212; /* Dark background */
--primary-color: #bb86fc; /* Purple accent */
--primary-variant: #3700b3;
--secondary-color: #03dac6; /* Teal accent */
--text-color: #e0e0e0; /* Light text */
--text-secondary: #a0a0a0; /* Dimmer text */
--surface-color: #1e1e1e; /* Slightly lighter surface */
--error-color: #cf6679;
--border-color: #333;
--input-bg: #2a2a2a;
--selected-bg: rgba(187, 134, 252, 0.2); /* Semi-transparent primary */
/* Animated Background */
--gradient-start: #1a1a2e; /* Dark Blue/Purple */
--gradient-mid: #16213e; /* Darker Blue */
--gradient-end: #0f3460; /* Deep Blue */
background: linear-gradient(-45deg, var(--gradient-start), var(--gradient-mid), var(--gradient-end), var(--gradient-mid));
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
}
/* Basic Reset & Body */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background-color: var(--bg-color);
color: var(--text-color);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
overflow: hidden; /* Prevent body scroll */
}
/* Main App Container */
.app-container {
display: flex;
flex-direction: column;
height: 100vh; /* Full viewport height */
width: 100vw; /* Full viewport width */
padding: 2rem;
padding-bottom: 8rem; /* Space for action button */
overflow-y: auto; /* Allow content scrolling if needed */
position: relative;
}
/* Sections */
section {
width: 100%;
max-width: 800px; /* Limit content width */
margin: 0 auto; /* Center content */
padding: 1rem 0;
}
section.hidden {
display: none;
}
/* Prompt Input */
.prompt-section {
flex-grow: 1; /* Takes up space initially */
display: flex;
align-items: center; /* Vertically center */
justify-content: center;
}
.prompt-input {
width: 100%;
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 1.5rem; /* Larger font */
line-height: 1.4;
background-color: var(--input-bg);
color: var(--text-color);
resize: none; /* Disable manual resize */
transition: all 0.2s ease;
min-height: 100px; /* Ensure decent initial height */
}
.prompt-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(187, 134, 252, 0.3);
}
.prompt-input:disabled {
opacity: 0.5;
background-color: #333;
}
/* LoRA Section */
.lora-section {
position: relative; /* For back button positioning */
padding-top: 0; /* Reduce top padding */
}
.back-button {
position: absolute;
top: -10px; /* Adjust as needed */
left: 0;
background: none;
border: none;
color: var(--text-secondary);
padding: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.back-button:hover {
color: var(--primary-color);
}
.search-container {
position: relative;
margin-bottom: 1rem;
}
.lora-search-input {
width: 100%;
padding: 1rem 1.5rem;
padding-right: 3rem; /* Space for spinner */
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 1.2rem;
background-color: var(--input-bg);
color: var(--text-color);
transition: all 0.2s ease;
}
.lora-search-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(187, 134, 252, 0.3);
}
.lora-list {
list-style: none;
max-height: calc(100vh - 260px); /* Increased height */
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--surface-color);
}
.lora-list li {
border-bottom: 1px solid var(--border-color);
}
.lora-list li:last-child {
border-bottom: none;
}
.lora-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.8rem 1.2rem;
background: none;
border: none;
color: var(--text-color);
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
font-size: 1rem;
}
.lora-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.lora-item.selected {
background-color: var(--selected-bg);
/* font-weight: 600; */ /* Removed to avoid layout shift */
/* color: var(--primary-color); */ /* Color applied below */
}
.lora-thumbnail,
.lora-thumbnail-placeholder {
width: 40px;
height: 40px;
border-radius: 4px;
margin-right: 12px;
object-fit: cover;
flex-shrink: 0; /* Prevent shrinking */
background-color: var(--input-bg); /* Placeholder bg */
}
.lora-thumbnail-placeholder {
border: 1px dashed var(--border-color);
}
.lora-info {
display: flex;
flex-direction: column;
overflow: hidden; /* Prevent text overflow */
flex-grow: 1;
}
.lora-item.selected .lora-info .lora-name {
color: var(--primary-color);
font-weight: 600;
}
.lora-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* margin-right: 1rem; */ /* Removed as info is now a column */
font-size: 1rem;
margin-bottom: 2px; /* Small space between name and author */
}
.lora-author {
font-size: 0.85rem;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0; /* Prevent author from shrinking */
}
/* Generating & Result */
.generating-section, .result-section {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.result-image {
max-width: 100%;
max-height: calc(100vh - 12rem); /* Adjust based on padding/button */
height: auto;
border-radius: 8px;
object-fit: contain;
background-color: var(--input-bg); /* Placeholder BG */
margin-bottom: 1rem; /* Space before button if image fails */
}
.generating-prompt, .generating-lora {
color: var(--text-secondary);
font-size: 0.9rem;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Action Button Container */
.action-button-container {
position: fixed; /* Stick to bottom */
bottom: 0;
left: 0;
width: 100%;
padding: 1.5rem 2rem;
display: flex;
justify-content: center;
/* background: linear-gradient(to top, var(--bg-color) 60%, transparent); */ /* Removed fade background */
z-index: 10;
}
.action-button {
padding: 1rem 2.5rem;
background-color: var(--primary-color);
color: #000; /* Black text on primary */
border: none;
border-radius: 30px; /* Pill shape */
font-size: 1.2rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.action-button:hover:not(:disabled) {
background-color: var(--secondary-color);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
}
.action-button:disabled {
background-color: #555;
color: #999;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
/* Spinner */
.spinner {
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: var(--secondary-color); /* Teal spinner */
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
.spinner.large {
width: 50px;
height: 50px;
border-width: 5px;
position: static; /* Reset position */
transform: none;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}
@keyframes spin-large {
to { transform: rotate(360deg); }
}
.spinner.large {
animation-name: spin-large;
transform: none; /* Override */
}
@keyframes gradientBG {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Error/Info Messages */
.error-message, .info-message {
padding: 0.8rem 1.2rem;
border-radius: 6px;
margin: 1rem auto;
text-align: center;
max-width: 600px;
font-size: 0.95rem;
}
.error-message {
background-color: rgba(207, 102, 121, 0.2); /* Error bg */
color: var(--error-color);
border: 1px solid var(--error-color);
}
.error-message.fatal { /* For generation errors */
font-size: 1.1rem;
margin-bottom: 1rem;
}
.info-message {
background-color: rgba(3, 218, 198, 0.1); /* Secondary color bg */
color: var(--secondary-color);
border: 1px solid var(--secondary-color);
}
/* Animated Loading Text */
.loading-text {
margin-top: 1rem;
font-size: 1.1rem;
color: var(--text-secondary);
min-height: 1.5em; /* Prevent layout jump */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90%; /* Ensure it doesn't overflow container */
}
</style>