Spaces:
Running
Running
| // Configuration and state | |
| let uploadedImages = []; | |
| let imageDimensions = []; | |
| let generationHistory = []; | |
| let currentGeneration = null; | |
| let activeTab = 'current'; | |
| // Initialize local storage | |
| const HISTORY_KEY = 'seedream_generation_history'; | |
| // Load history from localStorage on startup | |
| function loadHistory() { | |
| try { | |
| const saved = localStorage.getItem(HISTORY_KEY); | |
| if (saved) { | |
| generationHistory = JSON.parse(saved); | |
| updateHistoryCount(); | |
| } | |
| } catch (error) { | |
| console.error('Error loading history:', error); | |
| generationHistory = []; | |
| } | |
| } | |
| // Save history to localStorage | |
| function saveHistory() { | |
| try { | |
| // Keep only last 100 generations to avoid storage limits | |
| if (generationHistory.length > 100) { | |
| generationHistory = generationHistory.slice(-100); | |
| } | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(generationHistory)); | |
| updateHistoryCount(); | |
| } catch (error) { | |
| console.error('Error saving history:', error); | |
| } | |
| } | |
| // DOM Elements | |
| const fileInput = document.getElementById('fileInput'); | |
| const imagePreview = document.getElementById('imagePreview'); | |
| const imageUrls = document.getElementById('imageUrls'); | |
| const generateBtn = document.getElementById('generateBtn'); | |
| const statusMessage = document.getElementById('statusMessage'); | |
| const progressLogs = document.getElementById('progressLogs'); | |
| const currentResults = document.getElementById('currentResults'); | |
| const currentInfo = document.getElementById('currentInfo'); | |
| const historyGrid = document.getElementById('historyGrid'); | |
| const imageSizeSelect = document.getElementById('imageSize'); | |
| const customSizeElements = document.querySelectorAll('.custom-size'); | |
| const modelSelect = document.getElementById('modelSelect'); | |
| const promptTitle = document.getElementById('promptTitle'); | |
| const promptLabel = document.getElementById('promptLabel'); | |
| const imageInputCard = document.getElementById('imageInputCard'); | |
| const settingsCard = document.getElementById('settingsCard'); | |
| // Event Listeners | |
| fileInput.addEventListener('change', handleFileUpload); | |
| generateBtn.addEventListener('click', generateEdit); | |
| imageSizeSelect.addEventListener('change', handleImageSizeChange); | |
| modelSelect.addEventListener('change', handleModelChange); | |
| // Tab switching | |
| function switchTab(tabName) { | |
| activeTab = tabName; | |
| // Update tab buttons | |
| document.querySelectorAll('.tab-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| }); | |
| event.target.classList.add('active'); | |
| // Update tab content | |
| document.querySelectorAll('.tab-content').forEach(content => { | |
| content.classList.remove('active'); | |
| }); | |
| if (tabName === 'current') { | |
| document.getElementById('currentTab').classList.add('active'); | |
| } else { | |
| document.getElementById('historyTab').classList.add('active'); | |
| displayHistory(); | |
| } | |
| } | |
| // Toggle settings panel | |
| function toggleSettings() { | |
| settingsCard.classList.toggle('collapsed'); | |
| } | |
| // Handle image size dropdown change | |
| function handleImageSizeChange() { | |
| if (imageSizeSelect.value === 'custom') { | |
| customSizeElements.forEach(el => el.style.display = 'block'); | |
| } else { | |
| customSizeElements.forEach(el => el.style.display = 'none'); | |
| } | |
| } | |
| // Handle model dropdown change | |
| function handleModelChange() { | |
| const value = modelSelect.value; | |
| const isTextToImage = value === 'fal-ai/bytedance/seedream/v4/text-to-image' || | |
| value === 'fal-ai/bytedance/seedream/v4.5/text-to-image' || | |
| value === 'fal-ai/hunyuan-image/v3/text-to-image'; | |
| const isTextToVideo = value === 'fal-ai/bytedance/seedance/v1/pro/fast/text-to-video'; | |
| const isImageToVideo = value === 'fal-ai/bytedance/seedance/v1/pro/fast/image-to-video'; | |
| // Toggle prompt and input image area | |
| if (isTextToImage || isTextToVideo) { | |
| promptTitle.textContent = 'Generation Prompt'; | |
| promptLabel.textContent = 'Generation Prompt'; | |
| document.getElementById('prompt').placeholder = 'e.g., A cinematic shot of a subject, trending film look'; | |
| imageInputCard.style.display = isTextToVideo ? 'none' : 'none'; // text-to-image also hides inputs by design here | |
| uploadedImages = []; | |
| imageDimensions = []; | |
| renderImagePreviews(); | |
| } else if (isImageToVideo) { | |
| promptTitle.textContent = 'Generation Prompt'; | |
| promptLabel.textContent = 'Generation Prompt'; | |
| document.getElementById('prompt').placeholder = 'e.g., Animate this image with smooth camera motion'; | |
| imageInputCard.style.display = 'block'; | |
| } else { | |
| promptTitle.textContent = 'Edit Instructions'; | |
| promptLabel.textContent = 'Editing Prompt'; | |
| document.getElementById('prompt').placeholder = 'e.g., Dress the model in the clothes and shoes.'; | |
| imageInputCard.style.display = 'block'; | |
| } | |
| // Toggle Settings sections | |
| const imageSettings = document.getElementById('imageSettings'); | |
| const videoSettings = document.getElementById('videoSettings'); | |
| if (imageSettings && videoSettings) { | |
| if (isTextToVideo || isImageToVideo) { | |
| imageSettings.style.display = 'none'; | |
| videoSettings.style.display = 'block'; | |
| } else { | |
| imageSettings.style.display = 'block'; | |
| videoSettings.style.display = 'none'; | |
| } | |
| } | |
| } | |
| // Initialize on page load | |
| window.addEventListener('DOMContentLoaded', () => { | |
| // Load saved API key | |
| const savedKey = localStorage.getItem('fal_api_key'); | |
| if (savedKey) { | |
| document.getElementById('apiKey').value = savedKey; | |
| } | |
| // Initialize UI state | |
| handleImageSizeChange(); | |
| handleModelChange(); | |
| loadHistory(); | |
| displayHistory(); | |
| // Collapse settings by default | |
| settingsCard.classList.add('collapsed'); | |
| }); | |
| // Handle file upload with immediate preview | |
| async function handleFileUpload(event) { | |
| const files = Array.from(event.target.files); | |
| if (files.length === 0) return; | |
| showStatus(`Processing ${files.length} image(s)...`, 'info'); | |
| let processedCount = 0; | |
| let errorCount = 0; | |
| for (const file of files) { | |
| if (uploadedImages.length >= 10) { | |
| showStatus('Maximum 10 images allowed. Some images were not added.', 'error'); | |
| break; | |
| } | |
| if (file.type.startsWith('image/')) { | |
| try { | |
| const tempIndex = uploadedImages.length; | |
| const loadingId = `loading-${Date.now()}-${tempIndex}`; | |
| // Add loading placeholder | |
| const loadingPreview = document.createElement('div'); | |
| loadingPreview.className = 'image-preview-item loading-preview'; | |
| loadingPreview.id = loadingId; | |
| loadingPreview.innerHTML = ` | |
| <div class="loading-placeholder"> | |
| <div class="spinner"></div> | |
| <p>${file.name}</p> | |
| </div> | |
| `; | |
| imagePreview.appendChild(loadingPreview); | |
| const reader = new FileReader(); | |
| reader.onerror = (error) => { | |
| console.error('Error reading file:', file.name, error); | |
| errorCount++; | |
| document.getElementById(loadingId)?.remove(); | |
| showStatus(`Failed to read file: ${file.name}`, 'error'); | |
| }; | |
| reader.onload = (e) => { | |
| const dataUrl = e.target.result; | |
| document.getElementById(loadingId)?.remove(); | |
| uploadedImages.push(dataUrl); | |
| processedCount++; | |
| // Get image dimensions | |
| const img = new Image(); | |
| img.onload = function() { | |
| imageDimensions.push({ | |
| width: this.width, | |
| height: this.height | |
| }); | |
| addImagePreview(dataUrl, uploadedImages.length - 1); | |
| updateCustomSizeFromLastImage(); | |
| if (processedCount + errorCount === files.length) { | |
| if (errorCount === 0) { | |
| showStatus(`Successfully added ${processedCount} image(s) (${uploadedImages.length}/10 slots used)`, 'success'); | |
| } else { | |
| showStatus(`Added ${processedCount} image(s), ${errorCount} failed (${uploadedImages.length}/10 slots used)`, 'warning'); | |
| } | |
| } | |
| }; | |
| img.onerror = () => { | |
| console.error('Error loading image dimensions for:', file.name); | |
| imageDimensions.push({ width: 1280, height: 1280 }); | |
| addImagePreview(dataUrl, uploadedImages.length - 1); | |
| updateCustomSizeFromLastImage(); | |
| }; | |
| img.src = dataUrl; | |
| }; | |
| reader.readAsDataURL(file); | |
| } catch (error) { | |
| console.error('Error processing file:', file.name, error); | |
| errorCount++; | |
| showStatus(`Error processing ${file.name}`, 'error'); | |
| } | |
| } else { | |
| errorCount++; | |
| showStatus(`${file.name} is not an image file`, 'error'); | |
| } | |
| } | |
| event.target.value = ''; | |
| } | |
| // Add image preview with upload status indicator | |
| function addImagePreview(src, index) { | |
| const previewItem = document.createElement('div'); | |
| previewItem.className = 'image-preview-item'; | |
| previewItem.dataset.imageIndex = index; | |
| const imageId = `upload-img-${Date.now()}-${index}`; | |
| previewItem.innerHTML = ` | |
| <img id="${imageId}" src="${src}" alt="Upload ${index + 1}" | |
| onclick="openImageModal('${imageId}', '${src}', 'Uploaded Image ${index + 1}', 'Input Image')" | |
| style="cursor: pointer;"> | |
| <button class="remove-btn" onclick="removeImage(${index})">×</button> | |
| <div class="image-upload-status" style="display: none;"> | |
| <div class="upload-progress-mini"> | |
| <div class="upload-progress-mini-bar"></div> | |
| </div> | |
| <span class="upload-status-text"></span> | |
| </div> | |
| `; | |
| imagePreview.appendChild(previewItem); | |
| } | |
| // Remove image | |
| function removeImage(index) { | |
| uploadedImages.splice(index, 1); | |
| imageDimensions.splice(index, 1); | |
| renderImagePreviews(); | |
| updateCustomSizeFromLastImage(); | |
| } | |
| // Update custom size based on last image | |
| function updateCustomSizeFromLastImage() { | |
| if (imageDimensions.length > 0) { | |
| const lastDims = imageDimensions[imageDimensions.length - 1]; | |
| let width = lastDims.width; | |
| let height = lastDims.height; | |
| // Calculate aspect ratio | |
| const aspectRatio = width / height; | |
| // If both dimensions are less than 1024 (minimum allowed), scale up proportionally | |
| if (width < 1024 && height < 1024) { | |
| // Scale based on which dimension needs more scaling to reach 1024 | |
| if (width < height) { | |
| // Height is larger, scale based on width minimum | |
| width = 1024; | |
| height = Math.round(1024 / aspectRatio); | |
| } else { | |
| // Width is larger or equal, scale based on height minimum | |
| height = 1024; | |
| width = Math.round(1024 * aspectRatio); | |
| } | |
| } | |
| // If any dimension exceeds 4096, scale down proportionally | |
| if (width > 4096 || height > 4096) { | |
| if (width > height) { | |
| // Width exceeds more, scale based on width maximum | |
| const scaleFactor = 4096 / width; | |
| width = 4096; | |
| height = Math.round(height * scaleFactor); | |
| } else { | |
| // Height exceeds more, scale based on height maximum | |
| const scaleFactor = 4096 / height; | |
| height = 4096; | |
| width = Math.round(width * scaleFactor); | |
| } | |
| } | |
| // Final bounds check while maintaining aspect ratio | |
| // If width is still below minimum after height-based scaling | |
| if (width < 1024) { | |
| const scaleFactor = 1024 / width; | |
| width = 1024; | |
| height = Math.round(height * scaleFactor); | |
| } | |
| // If height is still below minimum after width-based scaling | |
| if (height < 1024) { | |
| const scaleFactor = 1024 / height; | |
| height = 1024; | |
| width = Math.round(width * scaleFactor); | |
| } | |
| // Final check to ensure we don't exceed maximums | |
| width = Math.min(4096, width); | |
| height = Math.min(4096, height); | |
| document.getElementById('customWidth').value = width; | |
| document.getElementById('customHeight').value = height; | |
| if (imageSizeSelect.value !== 'custom') { | |
| imageSizeSelect.value = 'custom'; | |
| handleImageSizeChange(); | |
| } | |
| } | |
| } | |
| // Re-render all image previews | |
| function renderImagePreviews() { | |
| imagePreview.innerHTML = ''; | |
| uploadedImages.forEach((src, index) => { | |
| addImagePreview(src, index); | |
| }); | |
| } | |
| // Show status message | |
| function showStatus(message, type = 'info') { | |
| statusMessage.className = `status-message ${type}`; | |
| statusMessage.textContent = message; | |
| statusMessage.style.display = 'block'; | |
| if (type === 'success' || type === 'error') { | |
| setTimeout(() => { | |
| statusMessage.style.display = 'none'; | |
| }, 5000); | |
| } | |
| } | |
| // Add log entry | |
| function addLog(message) { | |
| const logEntry = document.createElement('div'); | |
| logEntry.className = 'log-entry'; | |
| logEntry.textContent = `${new Date().toLocaleTimeString()}: ${message}`; | |
| progressLogs.appendChild(logEntry); | |
| progressLogs.scrollTop = progressLogs.scrollHeight; | |
| } | |
| // Clear logs | |
| function clearLogs() { | |
| progressLogs.innerHTML = ''; | |
| progressLogs.classList.remove('active'); | |
| } | |
| // Get image size configuration | |
| function getImageSize() { | |
| const size = imageSizeSelect.value; | |
| if (size === 'custom') { | |
| return { | |
| width: parseInt(document.getElementById('customWidth').value), | |
| height: parseInt(document.getElementById('customHeight').value) | |
| }; | |
| } | |
| return size; | |
| } | |
| // Upload image to FAL storage with progress tracking | |
| async function uploadImageToFal(imageData, apiKey, imageIndex, totalImages, actualIndex) { | |
| try { | |
| // Show individual image upload status if preview exists | |
| const previewItem = document.querySelector(`.image-preview-item[data-image-index="${actualIndex}"]`); | |
| if (previewItem) { | |
| const statusDiv = previewItem.querySelector('.image-upload-status'); | |
| const statusText = previewItem.querySelector('.upload-status-text'); | |
| const progressBar = previewItem.querySelector('.upload-progress-mini-bar'); | |
| if (statusDiv) { | |
| statusDiv.style.display = 'block'; | |
| statusText.textContent = 'Uploading...'; | |
| progressBar.style.width = '30%'; | |
| previewItem.classList.add('uploading'); | |
| } | |
| } | |
| // Update main progress bar | |
| updateUploadProgress(imageIndex - 1, totalImages, `Uploading image ${imageIndex}/${totalImages}...`); | |
| // Show upload start message | |
| addLog(`Uploading image ${imageIndex}/${totalImages} to FAL storage...`); | |
| // Calculate approximate size for logging | |
| const sizeInMB = (imageData.length * 0.75 / 1024 / 1024).toFixed(2); | |
| addLog(`Image ${imageIndex} size: ~${sizeInMB} MB`); | |
| const response = await fetch('/api/upload-to-fal', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${apiKey}` | |
| }, | |
| body: JSON.stringify({ image_data: imageData }) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.text(); | |
| throw new Error(error || 'Failed to upload image to FAL'); | |
| } | |
| const data = await response.json(); | |
| addLog(`✓ Image ${imageIndex}/${totalImages} uploaded successfully`); | |
| // Update progress after successful upload | |
| updateUploadProgress(imageIndex, totalImages, `Completed ${imageIndex}/${totalImages}`); | |
| // Update individual image status for success | |
| if (previewItem) { | |
| const statusText = previewItem.querySelector('.upload-status-text'); | |
| const progressBar = previewItem.querySelector('.upload-progress-mini-bar'); | |
| if (progressBar && statusText) { | |
| progressBar.style.width = '100%'; | |
| statusText.textContent = 'Uploaded ✓'; | |
| previewItem.classList.remove('uploading'); | |
| previewItem.classList.add('uploaded'); | |
| // Fade out status after 3 seconds | |
| setTimeout(() => { | |
| const statusDiv = previewItem.querySelector('.image-upload-status'); | |
| if (statusDiv) { | |
| statusDiv.style.opacity = '0'; | |
| setTimeout(() => { | |
| statusDiv.style.display = 'none'; | |
| statusDiv.style.opacity = '1'; | |
| previewItem.classList.remove('uploaded'); | |
| }, 300); | |
| } | |
| }, 3000); | |
| } | |
| } | |
| return data.url; | |
| } catch (error) { | |
| console.error('Error uploading to FAL:', error); | |
| addLog(`✗ Failed to upload image ${imageIndex}: ${error.message}`); | |
| // Update individual image status for error | |
| const previewItem = document.querySelector(`.image-preview-item[data-image-index="${actualIndex}"]`); | |
| if (previewItem) { | |
| const statusText = previewItem.querySelector('.upload-status-text'); | |
| const progressBar = previewItem.querySelector('.upload-progress-mini-bar'); | |
| if (progressBar && statusText) { | |
| progressBar.style.width = '100%'; | |
| progressBar.style.backgroundColor = '#dc3545'; | |
| statusText.textContent = 'Upload failed ✗'; | |
| previewItem.classList.remove('uploading'); | |
| previewItem.classList.add('upload-failed'); | |
| } | |
| } | |
| throw error; | |
| } | |
| } | |
| // Update upload progress bar | |
| function updateUploadProgress(completed, total, message) { | |
| const progressFill = document.getElementById('uploadProgressFill'); | |
| const progressText = document.getElementById('uploadProgressText'); | |
| const container = document.getElementById('uploadProgressContainer'); | |
| if (progressFill && progressText && container) { | |
| const percentage = Math.round((completed / total) * 100); | |
| progressFill.style.width = `${percentage}%`; | |
| progressText.textContent = `${message} (${percentage}%)`; | |
| // Add animation class on completion | |
| if (percentage === 100) { | |
| progressFill.classList.add('complete'); | |
| // Wait briefly to show completion state, then fade out | |
| setTimeout(() => { | |
| // Add transition for smooth fade | |
| container.style.transition = 'opacity 0.5s ease'; | |
| container.style.opacity = '0'; | |
| // Remove from DOM after fade completes | |
| setTimeout(() => { | |
| if (container.parentNode) { | |
| container.parentNode.removeChild(container); | |
| } | |
| }, 500); | |
| }, 1500); | |
| } else { | |
| // Ensure visible if not complete | |
| container.style.opacity = '1'; | |
| } | |
| } | |
| } | |
| // Prepare image URLs for API with detailed progress tracking | |
| async function getImageUrlsForAPI() { | |
| const urls = []; | |
| const apiKey = getAPIKey(); | |
| // Count total images to upload | |
| const base64Images = uploadedImages.filter(img => img.startsWith('data:')); | |
| const urlImages = uploadedImages.filter(img => !img.startsWith('data:')); | |
| const textUrls = imageUrls.value.trim().split('\n').filter(url => url.trim()); | |
| const totalUploads = base64Images.length; | |
| const totalImages = uploadedImages.length + textUrls.length; | |
| if (totalUploads > 0) { | |
| addLog(`Preparing to upload ${totalUploads} image(s) to FAL storage...`); | |
| showStatus(`Uploading ${totalUploads} image(s) to FAL storage...`, 'info'); | |
| } | |
| // Process uploaded base64 images - upload to FAL first | |
| let uploadCount = 0; | |
| for (let i = 0; i < uploadedImages.length; i++) { | |
| const imageData = uploadedImages[i]; | |
| // If it's a base64 data URL, upload to FAL | |
| if (imageData.startsWith('data:')) { | |
| uploadCount++; | |
| try { | |
| const falUrl = await uploadImageToFal(imageData, apiKey, uploadCount, totalUploads, i); | |
| urls.push(falUrl); | |
| // Update progress status | |
| if (uploadCount < totalUploads) { | |
| const percentage = Math.round((uploadCount / totalUploads) * 100); | |
| showStatus(`Upload progress: ${uploadCount}/${totalUploads} (${percentage}%)`, 'info'); | |
| } | |
| } catch (error) { | |
| showStatus(`Upload failed for image ${uploadCount}: ${error.message}`, 'error'); | |
| throw error; | |
| } | |
| } else { | |
| // Already a URL, use as-is | |
| urls.push(imageData); | |
| addLog(`Using existing URL for image ${i + 1}`); | |
| } | |
| } | |
| // Add text URLs directly | |
| if (textUrls.length > 0) { | |
| addLog(`Processing ${textUrls.length} URL(s) from text input...`); | |
| } | |
| for (const url of textUrls) { | |
| urls.push(url); | |
| addLog(`Added URL: ${url.substring(0, 50)}...`); | |
| await getImageDimensionsFromUrl(url); | |
| } | |
| if (totalUploads > 0) { | |
| showStatus(`All ${totalUploads} image(s) uploaded successfully!`, 'success'); | |
| addLog(`Upload complete: ${totalImages} total image(s) ready for generation`); | |
| } | |
| return urls.slice(0, 10); | |
| } | |
| // Get image dimensions from URL | |
| async function getImageDimensionsFromUrl(url) { | |
| return new Promise((resolve) => { | |
| const img = new Image(); | |
| img.onload = function() { | |
| imageDimensions.push({ | |
| width: this.width, | |
| height: this.height | |
| }); | |
| updateCustomSizeFromLastImage(); | |
| resolve(); | |
| }; | |
| img.onerror = function() { | |
| resolve(); | |
| }; | |
| img.src = url; | |
| }); | |
| } | |
| // Generate edit | |
| async function generateEdit() { | |
| const prompt = document.getElementById('prompt').value.trim(); | |
| if (!prompt) { | |
| showStatus('Please enter a prompt', 'error'); | |
| return; | |
| } | |
| const selectedModel = modelSelect.value; | |
| const isTextToImage = selectedModel === 'fal-ai/bytedance/seedream/v4/text-to-image' || | |
| selectedModel === 'fal-ai/bytedance/seedream/v4.5/text-to-image' || | |
| selectedModel === 'fal-ai/hunyuan-image/v3/text-to-image'; | |
| const isTextToVideo = selectedModel === 'fal-ai/bytedance/seedance/v1/pro/fast/text-to-video'; | |
| const isImageToVideo = selectedModel === 'fal-ai/bytedance/seedance/v1/pro/fast/image-to-video'; | |
| // Determine if current model requires image inputs | |
| const needsImageInputs = (!isTextToImage && !isTextToVideo); | |
| let imageUrlsArray = []; | |
| if (needsImageInputs) { | |
| // Prepare upload progress UI early if there are base64 uploads | |
| const base64Images = uploadedImages.filter(img => img.startsWith('data:')); | |
| const totalUploads = base64Images.length; | |
| // Remove any existing progress container | |
| const existingProgress = document.getElementById('uploadProgressContainer'); | |
| if (existingProgress && existingProgress.parentNode) { | |
| existingProgress.parentNode.removeChild(existingProgress); | |
| } | |
| if (totalUploads > 0) { | |
| const progressContainer = document.createElement('div'); | |
| progressContainer.id = 'uploadProgressContainer'; | |
| progressContainer.className = 'upload-progress-container'; | |
| progressContainer.innerHTML = ` | |
| <div class="upload-progress-bar"> | |
| <div class="upload-progress-fill" id="uploadProgressFill"></div> | |
| </div> | |
| <div class="upload-progress-text" id="uploadProgressText">Initializing...</div> | |
| `; | |
| // Insert progress container after status message | |
| if (statusMessage.parentNode) { | |
| statusMessage.parentNode.insertBefore(progressContainer, statusMessage.nextSibling); | |
| } | |
| } | |
| // Now resolve image URLs (this may trigger uploads and progress updates) | |
| try { | |
| imageUrlsArray = await getImageUrlsForAPI(); | |
| } catch (error) { | |
| // Ensure progress UI is removed on upload failure | |
| const pc = document.getElementById('uploadProgressContainer'); | |
| if (pc && pc.parentNode) { | |
| pc.parentNode.removeChild(pc); | |
| } | |
| addLog(`Upload error: ${error.message || error}`); | |
| showStatus(`Upload error: ${error.message || error}`, 'error'); | |
| return; | |
| } | |
| // Validate inputs for specific modes | |
| if (isImageToVideo && imageUrlsArray.length === 0) { | |
| showStatus('Please upload an image or provide an image URL for image-to-video', 'error'); | |
| const pc = document.getElementById('uploadProgressContainer'); | |
| if (pc && pc.parentNode) pc.parentNode.removeChild(pc); | |
| return; | |
| } else if (!isImageToVideo && imageUrlsArray.length === 0) { | |
| showStatus('Please upload images or provide image URLs for image editing', 'error'); | |
| const pc = document.getElementById('uploadProgressContainer'); | |
| if (pc && pc.parentNode) pc.parentNode.removeChild(pc); | |
| return; | |
| } | |
| } | |
| generateBtn.disabled = true; | |
| generateBtn.querySelector('.btn-text').textContent = 'Generating...'; | |
| generateBtn.querySelector('.spinner').style.display = 'block'; | |
| // Clear current results | |
| currentResults.innerHTML = '<div class="empty-state"><p>Preparing generation...</p></div>'; | |
| currentInfo.innerHTML = ''; | |
| clearLogs(); | |
| showStatus('Starting generation process...', 'info'); | |
| progressLogs.classList.add('active'); | |
| // Show initial status | |
| if (!isTextToImage && imageUrlsArray.length > 0) { | |
| addLog(`Processing ${imageUrlsArray.length} input image(s)...`); | |
| } | |
| let requestData; | |
| if (isTextToVideo) { | |
| requestData = { | |
| prompt: prompt, | |
| aspect_ratio: (document.getElementById('videoAspectRatio')?.value) || 'auto', | |
| resolution: (document.getElementById('videoResolution')?.value) || '1080p', | |
| duration: (document.getElementById('videoDuration')?.value) || '5', | |
| camera_fixed: !!(document.getElementById('cameraFixed')?.checked), | |
| enable_safety_checker: false | |
| }; | |
| } else if (isImageToVideo) { | |
| requestData = { | |
| prompt: prompt, | |
| image_url: imageUrlsArray[0], | |
| aspect_ratio: (document.getElementById('videoAspectRatio')?.value) || 'auto', | |
| resolution: (document.getElementById('videoResolution')?.value) || '1080p', | |
| duration: (document.getElementById('videoDuration')?.value) || '5', | |
| camera_fixed: !!(document.getElementById('cameraFixed')?.checked), | |
| enable_safety_checker: false | |
| }; | |
| } else { | |
| requestData = { | |
| prompt: prompt, | |
| image_size: getImageSize(), | |
| num_images: parseInt(document.getElementById('numImages').value), | |
| enable_safety_checker: false | |
| }; | |
| if (!isTextToImage) { | |
| // Note: imageUrlsArray will now contain FAL URLs after upload | |
| requestData.image_urls = imageUrlsArray; | |
| requestData.max_images = parseInt(document.getElementById('maxImages').value); | |
| } | |
| } | |
| const seed = document.getElementById('seed').value; | |
| if (seed) { | |
| requestData.seed = parseInt(seed); | |
| } | |
| // Store generation metadata | |
| currentGeneration = { | |
| id: Date.now(), | |
| timestamp: new Date().toISOString(), | |
| prompt: prompt, | |
| model: selectedModel, | |
| settings: { | |
| image_size: requestData.image_size, | |
| num_images: requestData.num_images, | |
| seed: requestData.seed | |
| } | |
| }; | |
| try { | |
| const apiKey = getAPIKey(); | |
| if (!apiKey) { | |
| showStatus('Please enter your FAL API key', 'error'); | |
| addLog('API key not found'); | |
| document.getElementById('apiKey').focus(); | |
| return; | |
| } | |
| addLog('Submitting request to FAL API...'); | |
| addLog(`Model: ${selectedModel}`); | |
| addLog(`Prompt: ${prompt}`); | |
| if (!isTextToImage && !isTextToVideo) { | |
| addLog(`Number of input images: ${imageUrlsArray.length}`); | |
| } | |
| const response = await callFalAPI(apiKey, requestData, selectedModel); | |
| // Store results in current generation | |
| currentGeneration.results = response; | |
| // Display results | |
| displayCurrentResults(response); | |
| // Add to history | |
| generationHistory.push(currentGeneration); | |
| saveHistory(); | |
| showStatus('Generation completed successfully!', 'success'); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| showStatus(`Error: ${error.message}`, 'error'); | |
| addLog(`Error: ${error.message}`); | |
| } finally { | |
| generateBtn.disabled = false; | |
| generateBtn.querySelector('.btn-text').textContent = 'Generate'; | |
| generateBtn.querySelector('.spinner').style.display = 'none'; | |
| // Ensure any lingering upload progress UI is removed | |
| const pc2 = document.getElementById('uploadProgressContainer'); | |
| if (pc2 && pc2.parentNode) { | |
| pc2.parentNode.removeChild(pc2); | |
| } | |
| } | |
| } | |
| // Get API key | |
| function getAPIKey() { | |
| const apiKeyInput = document.getElementById('apiKey'); | |
| const apiKey = apiKeyInput.value.trim(); | |
| if (apiKey) { | |
| localStorage.setItem('fal_api_key', apiKey); | |
| } | |
| return apiKey || localStorage.getItem('fal_api_key'); | |
| } | |
| // Call FAL API (non-blocking) | |
| async function callFalAPI(apiKey, requestData, model) { | |
| const submitResponse = await fetch('/api/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${apiKey}`, | |
| 'X-Model-Endpoint': model | |
| }, | |
| body: JSON.stringify(requestData) | |
| }); | |
| if (!submitResponse.ok) { | |
| const error = await submitResponse.text(); | |
| throw new Error(error || 'API request failed'); | |
| } | |
| const submitData = await submitResponse.json(); | |
| const { request_id } = submitData; | |
| addLog(`Request submitted with ID: ${request_id}`); | |
| // Poll for results | |
| let attempts = 0; | |
| const isVideoModel = model.includes('text-to-video') || model.includes('image-to-video'); | |
| const maxAttempts = isVideoModel ? 900 : 120; | |
| const pollInterval = 1000; | |
| let previousLogCount = 0; | |
| while (attempts < maxAttempts) { | |
| await new Promise(resolve => setTimeout(resolve, pollInterval)); | |
| const statusUrl = `/api/status/${request_id}`; | |
| const statusResponse = await fetch(statusUrl); | |
| if (!statusResponse.ok) { | |
| throw new Error('Failed to check request status'); | |
| } | |
| const statusData = await statusResponse.json(); | |
| // Add any new logs | |
| if (statusData.logs && statusData.logs.length > previousLogCount) { | |
| const newLogs = statusData.logs.slice(previousLogCount); | |
| newLogs.forEach(log => { | |
| if (log && !log.includes('Request submitted')) { | |
| addLog(log); | |
| } | |
| }); | |
| previousLogCount = statusData.logs.length; | |
| } | |
| if (statusData.status === 'completed') { | |
| return statusData.result; | |
| } else if (statusData.status === 'error') { | |
| throw new Error(statusData.error || 'Generation failed'); | |
| } | |
| attempts++; | |
| if (attempts % 5 === 0) { | |
| addLog(`Processing... (${attempts}s elapsed)`); | |
| } | |
| } | |
| throw new Error('Request timed out'); | |
| } | |
| // Display current results | |
| function displayCurrentResults(response) { | |
| currentResults.innerHTML = ''; | |
| let displayed = false; | |
| // Images | |
| if (response && response.images && response.images.length > 0) { | |
| response.images.forEach((image, index) => { | |
| const imgSrc = image.url || image.file_data || ''; | |
| const imageId = `current-img-${Date.now()}-${index}`; | |
| const item = document.createElement('div'); | |
| item.className = 'generation-item'; | |
| item.innerHTML = ` | |
| <img id="${imageId}" src="${imgSrc}" alt="Result ${index + 1}"> | |
| <button class="use-as-input-btn" onclick="useAsInput('${imageId}', '${imgSrc}')" title="Use as input"> | |
| ↻ Use as Input | |
| </button> | |
| `; | |
| currentResults.appendChild(item); | |
| }); | |
| displayed = true; | |
| } | |
| // Video | |
| if (response && response.video) { | |
| const vidSrc = response.video.url || response.video.file_data || ''; | |
| if (vidSrc) { | |
| const item = document.createElement('div'); | |
| item.className = 'generation-item'; | |
| item.innerHTML = ` | |
| <video src="${vidSrc}" controls style="max-width: 100%; border-radius: 8px;"></video> | |
| `; | |
| currentResults.appendChild(item); | |
| displayed = true; | |
| } | |
| } | |
| // Display generation info | |
| if (response && response.seed) { | |
| currentInfo.innerHTML = `<strong>Seed:</strong> ${response.seed}`; | |
| addLog(`Seed used: ${response.seed}`); | |
| } | |
| if (!displayed) { | |
| currentResults.innerHTML = '<div class="empty-state"><p>No results</p></div>'; | |
| } else { | |
| if (response.images && response.images.length) { | |
| addLog(`Generated ${response.images.length} image(s)`); | |
| } | |
| if (response.video) { | |
| addLog('Generated 1 video'); | |
| } | |
| } | |
| } | |
| // Display history | |
| function displayHistory() { | |
| if (generationHistory.length === 0) { | |
| historyGrid.innerHTML = ` | |
| <div class="empty-state"> | |
| <p>No generation history</p> | |
| <small>Your generated content will be saved here</small> | |
| </div> | |
| `; | |
| return; | |
| } | |
| historyGrid.innerHTML = ''; | |
| // Display history in reverse order (newest first) | |
| [...generationHistory].reverse().forEach((generation) => { | |
| if (!generation.results) return; | |
| // Images | |
| if (generation.results.images) { | |
| generation.results.images.forEach((image, imgIndex) => { | |
| const imgSrc = image.url || image.file_data || ''; | |
| const imageId = `history-img-${generation.id}-${imgIndex}`; | |
| const item = document.createElement('div'); | |
| item.className = 'generation-item'; | |
| item.innerHTML = ` | |
| <img id="${imageId}" src="${imgSrc}" alt="Generation" | |
| onclick="openImageModal('${imageId}', '${imgSrc}', '${generation.prompt.replace(/'/g, "\\'")}', '${new Date(generation.timestamp).toLocaleString()}')"> | |
| <button class="use-as-input-btn" onclick="useAsInput('${imageId}', '${imgSrc}')" title="Use as input"> | |
| ↻ Use as Input | |
| </button> | |
| <div class="generation-meta"> | |
| <span class="timestamp">${new Date(generation.timestamp).toLocaleString()}</span> | |
| <span class="prompt-preview">${generation.prompt}</span> | |
| </div> | |
| `; | |
| historyGrid.appendChild(item); | |
| }); | |
| } | |
| // Video | |
| if (generation.results.video) { | |
| const vidSrc = generation.results.video.url || generation.results.video.file_data || ''; | |
| if (vidSrc) { | |
| const item = document.createElement('div'); | |
| item.className = 'generation-item'; | |
| item.innerHTML = ` | |
| <video src="${vidSrc}" controls style="max-width: 100%; border-radius: 8px;"></video> | |
| <div class="generation-meta"> | |
| <span class="timestamp">${new Date(generation.timestamp).toLocaleString()}</span> | |
| <span class="prompt-preview">${generation.prompt}</span> | |
| </div> | |
| `; | |
| historyGrid.appendChild(item); | |
| } | |
| } | |
| }); | |
| } | |
| // Use image as input | |
| async function useAsInput(imageId, imageSrc) { | |
| try { | |
| // Switch to edit mode if in text-to-image mode | |
| const currentModel = modelSelect.value; | |
| if (currentModel === 'fal-ai/bytedance/seedream/v4/text-to-image' || | |
| currentModel === 'fal-ai/bytedance/seedream/v4.5/text-to-image' || | |
| currentModel === 'fal-ai/hunyuan-image/v3/text-to-image') { | |
| modelSelect.value = 'fal-ai/bytedance/seedream/v4.5/edit'; | |
| handleModelChange(); | |
| showStatus('Switched to Image Edit mode', 'info'); | |
| } | |
| if (uploadedImages.length >= 10) { | |
| showStatus('Maximum 10 images allowed. Please remove some images first.', 'error'); | |
| return; | |
| } | |
| // If the image is already a FAL URL (from history), use it directly | |
| // Otherwise, it's a base64 image that will be uploaded when generating | |
| uploadedImages.push(imageSrc); | |
| // Get dimensions | |
| const imgElement = document.getElementById(imageId); | |
| if (imgElement) { | |
| if (!imgElement.complete) { | |
| await new Promise((resolve) => { | |
| imgElement.onload = resolve; | |
| imgElement.onerror = resolve; | |
| }); | |
| } | |
| imageDimensions.push({ | |
| width: imgElement.naturalWidth || imgElement.width, | |
| height: imgElement.naturalHeight || imgElement.height | |
| }); | |
| updateCustomSizeFromLastImage(); | |
| } else { | |
| const img = new Image(); | |
| img.onload = function() { | |
| imageDimensions.push({ | |
| width: this.width, | |
| height: this.height | |
| }); | |
| updateCustomSizeFromLastImage(); | |
| }; | |
| img.onerror = function() { | |
| imageDimensions.push({ width: 1280, height: 1280 }); | |
| updateCustomSizeFromLastImage(); | |
| }; | |
| img.src = imageSrc; | |
| } | |
| renderImagePreviews(); | |
| const totalImages = uploadedImages.length; | |
| showStatus(`Image added as input (${totalImages}/10 slots used)`, 'success'); | |
| addLog(`Added image as input (${totalImages}/10 images)`); | |
| // Flash animation | |
| imagePreview.style.animation = 'flash 0.5s'; | |
| setTimeout(() => { | |
| imagePreview.style.animation = ''; | |
| }, 500); | |
| } catch (error) { | |
| console.error('Error using image as input:', error); | |
| showStatus('Failed to add image as input', 'error'); | |
| } | |
| } | |
| // Clear all input images | |
| function clearAllInputImages() { | |
| uploadedImages = []; | |
| imageDimensions = []; | |
| renderImagePreviews(); | |
| showStatus('All input images cleared', 'info'); | |
| } | |
| // Clear history | |
| function clearHistory() { | |
| if (confirm('Are you sure you want to clear all generation history? This cannot be undone.')) { | |
| generationHistory = []; | |
| localStorage.removeItem(HISTORY_KEY); | |
| displayHistory(); | |
| updateHistoryCount(); | |
| showStatus('History cleared', 'info'); | |
| } | |
| } | |
| // Download all history | |
| function downloadAllHistory() { | |
| if (generationHistory.length === 0) { | |
| showStatus('No history to download', 'error'); | |
| return; | |
| } | |
| // Create a zip file or download each asset | |
| generationHistory.forEach((generation, genIndex) => { | |
| if (generation.results) { | |
| if (generation.results.images) { | |
| generation.results.images.forEach((image, imgIndex) => { | |
| const imgSrc = image.url || image.file_data || ''; | |
| if (imgSrc) { | |
| const link = document.createElement('a'); | |
| link.href = imgSrc; | |
| link.download = `seedream-${generation.id}-${imgIndex}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| }); | |
| } | |
| if (generation.results.video) { | |
| const vidSrc = generation.results.video.url || generation.results.video.file_data || ''; | |
| if (vidSrc) { | |
| const link = document.createElement('a'); | |
| link.href = vidSrc; | |
| link.download = `seedance-${generation.id}.mp4`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| } | |
| } | |
| }); | |
| showStatus('Downloading all assets...', 'info'); | |
| } | |
| // Update history count | |
| function updateHistoryCount() { | |
| const countElement = document.getElementById('historyCount'); | |
| if (countElement) { | |
| let totalItems = 0; | |
| generationHistory.forEach(gen => { | |
| if (gen.results && gen.results.images) { | |
| totalItems += gen.results.images.length; | |
| } | |
| if (gen.results && gen.results.video) { | |
| totalItems += 1; | |
| } | |
| }); | |
| countElement.textContent = totalItems; | |
| } | |
| } | |
| // Modal functionality for viewing large images | |
| let currentModalImage = null; | |
| function openImageModal(imageId, imageSrc, prompt, timestamp) { | |
| const modal = document.getElementById('imageModal'); | |
| const modalImg = document.getElementById('modalImage'); | |
| const modalCaption = document.getElementById('modalCaption'); | |
| // Store current image info for use in "Use as Input" | |
| currentModalImage = { id: imageId, src: imageSrc }; | |
| // Set modal content | |
| modalImg.src = imageSrc; | |
| modalCaption.innerHTML = ` | |
| <strong>Generated:</strong> ${timestamp}<br> | |
| <strong>Prompt:</strong> ${prompt} | |
| `; | |
| // Show modal | |
| modal.classList.add('show'); | |
| // Prevent body scroll when modal is open | |
| document.body.style.overflow = 'hidden'; | |
| // Close modal on escape key | |
| document.addEventListener('keydown', handleModalEscape); | |
| // Close modal on clicking outside the image | |
| modal.onclick = function(event) { | |
| if (event.target === modal || event.target === modalImg.parentElement) { | |
| closeImageModal(); | |
| } | |
| }; | |
| } | |
| function closeImageModal() { | |
| const modal = document.getElementById('imageModal'); | |
| modal.classList.remove('show'); | |
| document.body.style.overflow = ''; | |
| document.removeEventListener('keydown', handleModalEscape); | |
| currentModalImage = null; | |
| } | |
| function handleModalEscape(event) { | |
| if (event.key === 'Escape') { | |
| closeImageModal(); | |
| } | |
| } | |
| function useModalImageAsInput() { | |
| if (currentModalImage) { | |
| useAsInput(currentModalImage.id, currentModalImage.src); | |
| closeImageModal(); | |
| } | |
| } | |