Download static/script.js from wapadil/seedream4: direct link, hf CLI and curl.
- Browser
- Download file 106 kB
-
https://huggingface.co/spaces/wapadil/seedream4/resolve/main/static/script.js
- Command line
-
hf download hf://spaces/wapadil/seedream4/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/wapadil/seedream4/resolve/main/static/script.js
106 kB
| // Configuration and state | |
| let uploadedImages = []; // Unified structure: [{src, width, height, id}] | |
| let generationHistory = []; | |
| let currentGeneration = null; | |
| let activeTab = 'current'; | |
| let currentGenerationAbort = null; | |
| let lastGenerationTime = 0; | |
| let generationInProgress = false; | |
| // Simplified unified status management | |
| class StatusManager { | |
| static show(message, type = 'info', persistent = false) { | |
| // Hide all existing notifications | |
| this.hideAll(); | |
| const statusEl = document.getElementById('statusMessage'); | |
| statusEl.textContent = message; | |
| statusEl.className = `status-message ${type}`; | |
| statusEl.style.display = 'block'; | |
| // Auto-hide non-error messages | |
| if (!persistent && type !== 'error') { | |
| setTimeout(() => { | |
| statusEl.style.display = 'none'; | |
| }, type === 'success' ? 2000 : 3000); | |
| } | |
| } | |
| static hideAll() { | |
| // Clear main status | |
| const statusEl = document.getElementById('statusMessage'); | |
| if (statusEl) statusEl.style.display = 'none'; | |
| // Remove any lingering toasts | |
| document.querySelectorAll('.toast, .banner').forEach(el => el.remove()); | |
| } | |
| static showProgress(message) { | |
| this.show(message, 'info', true); | |
| } | |
| } | |
| // ============================= // | |
| // Apple HIG Enhanced Notifications & Progress System // | |
| // ============================= // | |
| // Toast notification system | |
| function showToast(message, type = 'info', duration = 5000, actions = null) { | |
| const toasts = document.getElementById('toasts'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| const toastId = 'toast-' + Date.now(); | |
| toast.id = toastId; | |
| let actionButtons = ''; | |
| if (actions) { | |
| actionButtons = actions.map(action => | |
| `<button class="toast-action" onclick="${action.onclick}">${action.text}</button>` | |
| ).join(''); | |
| } | |
| toast.innerHTML = ` | |
| <div class="toast-header"> | |
| <span>${getToastIcon(type)} ${getToastTitle(type)}</span> | |
| <button class="toast-close" onclick="hideToast('${toastId}')" aria-label="关闭通知">×</button> | |
| </div> | |
| <div class="toast-body"> | |
| ${message} | |
| ${actionButtons ? `<div class="toast-actions">${actionButtons}</div>` : ''} | |
| </div> | |
| `; | |
| toasts.appendChild(toast); | |
| // Trigger show animation | |
| requestAnimationFrame(() => { | |
| toast.classList.add('show'); | |
| }); | |
| // Auto-hide unless it's an error | |
| if (type !== 'error' && duration > 0) { | |
| setTimeout(() => hideToast(toastId), duration); | |
| } | |
| return toastId; | |
| } | |
| function hideToast(toastId) { | |
| const toast = document.getElementById(toastId); | |
| if (toast) { | |
| toast.classList.remove('show'); | |
| setTimeout(() => { | |
| if (toast.parentNode) { | |
| toast.parentNode.removeChild(toast); | |
| } | |
| }, 300); | |
| } | |
| } | |
| function getToastIcon(type) { | |
| const icons = { | |
| success: '✓', | |
| error: '⚠', | |
| warning: '⚡', | |
| info: 'ⓘ' | |
| }; | |
| return icons[type] || icons.info; | |
| } | |
| function getToastTitle(type) { | |
| const titles = { | |
| success: '成功', | |
| error: '错误', | |
| warning: '警告', | |
| info: '提示' | |
| }; | |
| return titles[type] || titles.info; | |
| } | |
| // Banner notification system (for top-level status) | |
| function showBanner(message, type = 'info', duration = 4000) { | |
| const banner = document.getElementById('banner'); | |
| banner.textContent = message; | |
| banner.className = `banner ${type}`; | |
| banner.hidden = false; | |
| banner.classList.add('show'); | |
| if (duration > 0) { | |
| setTimeout(() => { | |
| banner.classList.remove('show'); | |
| setTimeout(() => { | |
| banner.hidden = true; | |
| }, 300); | |
| }, duration); | |
| } | |
| } | |
| // Enhanced progress feedback for generate button | |
| function setGenerateButtonProgress(isLoading, text = '生成图像') { | |
| const btn = document.getElementById('generateBtn'); | |
| const spinner = btn.querySelector('.spinner'); | |
| const progressRing = btn.querySelector('.progress-ring'); | |
| const btnText = btn.querySelector('.btn-text'); | |
| if (isLoading) { | |
| btn.setAttribute('aria-busy', 'true'); | |
| btn.disabled = true; | |
| spinner.style.display = 'block'; | |
| progressRing.style.display = 'block'; | |
| btnText.textContent = text; | |
| } else { | |
| btn.setAttribute('aria-busy', 'false'); | |
| btn.disabled = false; | |
| spinner.style.display = 'none'; | |
| progressRing.style.display = 'none'; | |
| btnText.textContent = '生成图像'; | |
| } | |
| } | |
| // Legacy function for compatibility - delegates to StatusManager | |
| function showStatus(message, type = 'info', persistent = false) { | |
| StatusManager.show(message, type, persistent); | |
| } | |
| // Progress logs with collapsible details | |
| function addProgressLog(message, type = 'info') { | |
| const logs = document.getElementById('progressLogs'); | |
| if (!logs.classList.contains('active')) { | |
| logs.classList.add('active'); | |
| } | |
| const entry = document.createElement('div'); | |
| entry.className = `log-entry ${type}`; | |
| entry.textContent = `${new Date().toLocaleTimeString()}: ${message}`; | |
| logs.appendChild(entry); | |
| logs.scrollTop = logs.scrollHeight; | |
| } | |
| function clearProgressLogs() { | |
| const logs = document.getElementById('progressLogs'); | |
| logs.innerHTML = ''; | |
| logs.classList.remove('active'); | |
| } | |
| // Skeleton loader for preview areas | |
| function showPreviewSkeleton(containerId, count = 1) { | |
| const container = document.getElementById(containerId); | |
| container.innerHTML = ''; | |
| for (let i = 0; i < count; i++) { | |
| const skeleton = document.createElement('div'); | |
| skeleton.className = 'preview-skeleton skeleton'; | |
| container.appendChild(skeleton); | |
| } | |
| } | |
| // 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); | |
| document.getElementById('toggleApiKey').addEventListener('click', toggleApiKeyVisibility); | |
| document.getElementById('testApiKey').addEventListener('click', testApiKeyConnection); | |
| // 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() { | |
| const isCollapsed = settingsCard.classList.contains('collapsed'); | |
| const toggleBtn = document.querySelector('.settings-toggle-btn'); | |
| settingsCard.classList.toggle('collapsed'); | |
| // Update aria-expanded | |
| toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'true' : 'false'); | |
| } | |
| function toggleApiConfig() { | |
| const apiConfigCard = document.getElementById('apiConfigCard'); | |
| const isCollapsed = apiConfigCard.classList.contains('collapsed'); | |
| const toggleBtn = apiConfigCard.querySelector('.settings-toggle-btn'); | |
| apiConfigCard.classList.toggle('collapsed'); | |
| // Update aria-expanded | |
| toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'true' : 'false'); | |
| } | |
| function toggleApiConfig2() { | |
| const apiConfigCard = document.getElementById('apiConfigCard2'); | |
| const isCollapsed = apiConfigCard.classList.contains('collapsed'); | |
| const toggleBtn = apiConfigCard.querySelector('.settings-toggle-btn'); | |
| apiConfigCard.classList.toggle('collapsed'); | |
| // Update aria-expanded | |
| toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'true' : 'false'); | |
| } | |
| // 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 modelValue = modelSelect.value; | |
| const isTextToImage = modelValue === 'fal-ai/bytedance/seedream/v4/text-to-image'; | |
| const isVideoModel = modelValue.includes('wan-25-preview') || modelValue.includes('wan/v2.2'); | |
| const isWan25 = modelValue.includes('wan-25-preview'); | |
| const isWan22 = modelValue.includes('wan/v2.2'); | |
| const isT2V = modelValue.includes('text-to-video'); | |
| // Toggle video params visibility | |
| const videoParams = document.getElementById('videoParams'); | |
| const settingsGrid = document.querySelector('.settings-grid'); | |
| if (videoParams) { | |
| videoParams.style.display = isVideoModel ? 'block' : 'none'; | |
| } | |
| if (settingsGrid) { | |
| settingsGrid.style.display = isVideoModel ? 'none' : 'grid'; | |
| } | |
| // Show/hide WAN-specific params | |
| const wan25Params = document.querySelectorAll('.video-param-wan25'); | |
| const wan22Params = document.querySelectorAll('.video-param-wan22'); | |
| wan25Params.forEach(el => el.style.display = isWan25 ? 'block' : 'none'); | |
| wan22Params.forEach(el => el.style.display = isWan22 ? 'block' : 'none'); | |
| // Update UI labels based on model type | |
| if (isVideoModel) { | |
| if (isT2V) { | |
| promptTitle.textContent = '✏️ 视频提示词'; | |
| promptLabel.textContent = '提示词'; | |
| document.getElementById('prompt').placeholder = '例如:moody cyberpunk alley, steady cam forward, rain reflections'; | |
| imageInputCard.style.display = 'none'; | |
| uploadedImages = []; | |
| renderImagePreviews(); | |
| } else { | |
| promptTitle.textContent = '✏️ 视频提示词'; | |
| promptLabel.textContent = '提示词'; | |
| document.getElementById('prompt').placeholder = '例如:cinematic slow push-in on the subject, volumetric light beams'; | |
| imageInputCard.style.display = 'block'; | |
| } | |
| document.getElementById('generateBtn').querySelector('.btn-text').textContent = '生成视频'; | |
| updateVideoPriceEstimate(); | |
| } else if (isTextToImage) { | |
| promptTitle.textContent = '生成提示词'; | |
| promptLabel.textContent = '提示词'; | |
| document.getElementById('prompt').placeholder = '例如:美丽的山水风景,湖泊和夕阳'; | |
| imageInputCard.style.display = 'none'; | |
| uploadedImages = []; | |
| renderImagePreviews(); | |
| document.getElementById('generateBtn').querySelector('.btn-text').textContent = '生成图像'; | |
| } else { | |
| promptTitle.textContent = '编辑指令'; | |
| promptLabel.textContent = '编辑提示词'; | |
| document.getElementById('prompt').placeholder = '例如:给模特穿上衣服和鞋子'; | |
| imageInputCard.style.display = 'block'; | |
| document.getElementById('generateBtn').querySelector('.btn-text').textContent = '生成图像'; | |
| } | |
| // Add event listeners for video params to update price | |
| if (isVideoModel) { | |
| const videoResolution = document.getElementById('videoResolution'); | |
| const videoDuration = document.getElementById('videoDuration'); | |
| const videoFPS = document.getElementById('videoFPS'); | |
| const videoNumFrames = document.getElementById('videoNumFrames'); | |
| [videoResolution, videoDuration, videoFPS, videoNumFrames].forEach(el => { | |
| if (el) { | |
| el.removeEventListener('change', updateVideoPriceEstimate); | |
| el.removeEventListener('input', updateVideoPriceEstimate); | |
| el.addEventListener('change', updateVideoPriceEstimate); | |
| el.addEventListener('input', updateVideoPriceEstimate); | |
| } | |
| }); | |
| } | |
| } | |
| // Setup paste upload functionality | |
| function setupPasteUpload() { | |
| document.addEventListener('paste', async (e) => { | |
| // Check if we're focused on a text input that should handle paste normally | |
| const activeElement = document.activeElement; | |
| if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { | |
| return; // Let the text input handle the paste | |
| } | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| const imageFiles = []; | |
| const textItems = []; | |
| // Process clipboard items | |
| for (let i = 0; i < items.length; i++) { | |
| const item = items[i]; | |
| if (item.type.indexOf('image') === 0) { | |
| // Handle image files | |
| const file = item.getAsFile(); | |
| if (file) { | |
| imageFiles.push(file); | |
| } | |
| } else if (item.type === 'text/plain') { | |
| // Handle text (potentially URLs) | |
| item.getAsString((text) => { | |
| textItems.push(text); | |
| }); | |
| } | |
| } | |
| // Process images if found | |
| if (imageFiles.length > 0) { | |
| e.preventDefault(); | |
| showToast(`正在粘贴 ${imageFiles.length} 张图像...`, 'info'); | |
| try { | |
| for (const file of imageFiles) { | |
| await processImageFile(file); | |
| } | |
| showToast('图像粘贴成功!', 'success'); | |
| updateImagePreview(); | |
| } catch (error) { | |
| showToast(`粘贴失败: ${error.message}`, 'error'); | |
| } | |
| } | |
| // Process URLs in text (delayed to handle async string retrieval) | |
| setTimeout(() => { | |
| for (const text of textItems) { | |
| const urlPattern = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|bmp)/i; | |
| const match = text.match(urlPattern); | |
| if (match) { | |
| e.preventDefault(); | |
| const imageUrls = document.getElementById('imageUrls'); | |
| const currentUrls = imageUrls.value.trim(); | |
| const newUrl = match[0]; | |
| imageUrls.value = currentUrls ? `${currentUrls}\n${newUrl}` : newUrl; | |
| showToast('图像URL已粘贴到输入框', 'success'); | |
| } | |
| } | |
| }, 10); | |
| }); | |
| } | |
| // Setup extended drag and drop functionality | |
| function setupExtendedDragDrop() { | |
| const dropZones = [ | |
| document.getElementById('historyGrid'), | |
| document.getElementById('currentResults'), | |
| document.querySelector('.right-panel'), | |
| document.querySelector('.empty-state') | |
| ].filter(Boolean); // Remove null elements | |
| dropZones.forEach(zone => { | |
| zone.addEventListener('dragover', handleDragOver); | |
| zone.addEventListener('dragenter', handleDragEnter); | |
| zone.addEventListener('dragleave', handleDragLeave); | |
| zone.addEventListener('drop', handleDrop); | |
| }); | |
| // Also add body as fallback drop zone | |
| document.body.addEventListener('dragover', handleDragOver); | |
| document.body.addEventListener('drop', handleDrop); | |
| } | |
| function handleDragOver(e) { | |
| e.preventDefault(); | |
| e.dataTransfer.dropEffect = 'copy'; | |
| } | |
| function handleDragEnter(e) { | |
| e.preventDefault(); | |
| if (e.target.classList.contains('empty-state') || | |
| e.target.closest('.empty-state') || | |
| e.target.id === 'historyGrid' || | |
| e.target.id === 'currentResults') { | |
| e.target.style.backgroundColor = 'color-mix(in oklab, var(--brand-primary) 8%, Canvas 92%)'; | |
| e.target.style.border = '2px dashed var(--brand-primary)'; | |
| } | |
| } | |
| function handleDragLeave(e) { | |
| if (e.target.classList.contains('empty-state') || | |
| e.target.closest('.empty-state') || | |
| e.target.id === 'historyGrid' || | |
| e.target.id === 'currentResults') { | |
| e.target.style.backgroundColor = ''; | |
| e.target.style.border = ''; | |
| } | |
| } | |
| async function handleDrop(e) { | |
| e.preventDefault(); | |
| // Reset visual feedback | |
| if (e.target.classList.contains('empty-state') || | |
| e.target.closest('.empty-state') || | |
| e.target.id === 'historyGrid' || | |
| e.target.id === 'currentResults') { | |
| e.target.style.backgroundColor = ''; | |
| e.target.style.border = ''; | |
| } | |
| const files = Array.from(e.dataTransfer.files); | |
| const imageFiles = files.filter(file => file.type.startsWith('image/')); | |
| if (imageFiles.length > 0) { | |
| showToast(`正在处理 ${imageFiles.length} 张拖拽图像...`, 'info'); | |
| try { | |
| for (const file of imageFiles) { | |
| await processImageFile(file); | |
| } | |
| showToast('图像拖拽成功!', 'success'); | |
| updateImagePreview(); | |
| } catch (error) { | |
| showToast(`拖拽失败: ${error.message}`, 'error'); | |
| } | |
| } | |
| // Handle text/URLs | |
| const text = e.dataTransfer.getData('text/plain'); | |
| if (text) { | |
| const urlPattern = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|bmp)/i; | |
| const match = text.match(urlPattern); | |
| if (match) { | |
| const imageUrls = document.getElementById('imageUrls'); | |
| const currentUrls = imageUrls.value.trim(); | |
| const newUrl = match[0]; | |
| imageUrls.value = currentUrls ? `${currentUrls}\n${newUrl}` : newUrl; | |
| showToast('图像URL已添加', 'success'); | |
| } | |
| } | |
| } | |
| // 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; | |
| } | |
| // Load saved textarea sizes | |
| loadTextareaSizes(); | |
| // Load SDE preferences | |
| loadSDEPreferences(); | |
| // Setup SDE event listeners | |
| const enableSDECheckbox = document.getElementById('enableSDE'); | |
| if (enableSDECheckbox) { | |
| enableSDECheckbox.addEventListener('change', syncSDEMode); | |
| } | |
| // Initialize UI state | |
| handleImageSizeChange(); | |
| handleModelChange(); | |
| loadHistory(); | |
| setupPasteUpload(); | |
| setupExtendedDragDrop(); | |
| initializeKeyboardShortcuts(); | |
| initializeAccessibility(); | |
| displayHistory(); | |
| // Smart API Key onboarding for first-time users | |
| if (!savedKey && !localStorage.getItem('hasSeenApiKeyGuide')) { | |
| // Keep settings expanded for new users | |
| settingsCard.classList.remove('collapsed'); | |
| // Highlight API Key input with pulse animation | |
| const apiKeyInput = document.getElementById('apiKey'); | |
| apiKeyInput.style.animation = 'pulse-highlight 2s ease-in-out 3'; | |
| // Show welcoming guide toast | |
| showToast('👋 欢迎使用SeedDream!请先配置FAL API密钥以开始生成图像', 'info', 0); | |
| // Focus API key input after a brief delay | |
| setTimeout(() => { | |
| apiKeyInput.focus(); | |
| apiKeyInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| }, 500); | |
| // Mark guide as seen (set after first API key save) | |
| apiKeyInput.addEventListener('blur', () => { | |
| if (apiKeyInput.value.trim()) { | |
| localStorage.setItem('hasSeenApiKeyGuide', '1'); | |
| } | |
| }); | |
| } else { | |
| // Collapse settings by default for returning users | |
| 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(`正在处理 ${files.length} 张图像...`, 'info'); | |
| let processedCount = 0; | |
| let errorCount = 0; | |
| for (const file of files) { | |
| if (uploadedImages.length >= 10) { | |
| showStatus('最多允许10张图像。部分图像未被添加。', '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(`读取文件失败: ${file.name}`, 'error'); | |
| }; | |
| reader.onload = (e) => { | |
| const dataUrl = e.target.result; | |
| document.getElementById(loadingId)?.remove(); | |
| // Get image dimensions and create unified structure | |
| const img = new Image(); | |
| img.onload = function() { | |
| const imageObj = { | |
| src: dataUrl, | |
| width: this.width, | |
| height: this.height, | |
| id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` | |
| }; | |
| uploadedImages.push(imageObj); | |
| processedCount++; | |
| addImagePreview(imageObj.src, uploadedImages.length - 1); | |
| if (processedCount + errorCount === files.length) { | |
| if (errorCount === 0) { | |
| showStatus(`成功添加 ${processedCount} 张图像 (已使用 ${uploadedImages.length}/10 个位置)`, 'success'); | |
| } else { | |
| showStatus(`添加了 ${processedCount} 张图像,${errorCount} 张失败 (已使用 ${uploadedImages.length}/10 个位置)`, 'warning'); | |
| } | |
| } | |
| }; | |
| img.onerror = () => { | |
| console.error('Error loading image dimensions for:', file.name); | |
| const imageObj = { | |
| src: dataUrl, | |
| width: 1280, | |
| height: 1280, | |
| id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` | |
| }; | |
| uploadedImages.push(imageObj); | |
| processedCount++; | |
| addImagePreview(imageObj.src, uploadedImages.length - 1); | |
| }; | |
| img.src = dataUrl; | |
| }; | |
| reader.readAsDataURL(file); | |
| } catch (error) { | |
| console.error('Error processing file:', file.name, error); | |
| errorCount++; | |
| showStatus(`处理文件出错: ${file.name}`, 'error'); | |
| } | |
| } else { | |
| errorCount++; | |
| showStatus(`${file.name} 不是图像文件`, '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')" | |
| loading="lazy" decoding="async" 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); | |
| renderImagePreviews(); | |
| } | |
| // Fill example prompt | |
| function fillExample(exampleText) { | |
| const promptTextarea = document.getElementById('prompt'); | |
| const drawerPromptTextarea = document.getElementById('drawerPrompt'); | |
| if (promptTextarea) { | |
| promptTextarea.value = exampleText; | |
| promptTextarea.focus(); | |
| } | |
| if (drawerPromptTextarea) { | |
| drawerPromptTextarea.value = exampleText; | |
| } | |
| // Switch to edit mode if needed | |
| const currentModel = modelSelect.value; | |
| if (currentModel === 'fal-ai/bytedance/seedream/v4/text-to-image') { | |
| modelSelect.value = 'fal-ai/bytedance/seedream/v4/edit'; | |
| handleModelChange(); | |
| } | |
| showToast('示例提示词已填入,上传图像后即可生成', 'success', 3000); | |
| } | |
| // Download image | |
| function downloadImage(imageSrc, imageId) { | |
| const link = document.createElement('a'); | |
| link.href = imageSrc; | |
| link.download = `seedream-${imageId}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| showToast('图像下载中...', 'success', 2000); | |
| } | |
| // Update custom size based on last image | |
| function updateCustomSizeFromLastImage() { | |
| if (uploadedImages.length > 0) { | |
| const lastImage = uploadedImages[uploadedImages.length - 1]; | |
| let width = lastImage.width; | |
| let height = lastImage.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((image, index) => { | |
| addImagePreview(image.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 = '上传中...'; | |
| 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(`正在上传图像 ${imageIndex}/${totalImages} 到FAL存储...`); | |
| // Calculate approximate size for logging | |
| const sizeInMB = (imageData.length * 0.75 / 1024 / 1024).toFixed(2); | |
| addLog(`图像 ${imageIndex} 大小: ~${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(`✓ 图像 ${imageIndex}/${totalImages} 上传成功`); | |
| // 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 = '已上传 ✓'; | |
| 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(`✗ 图像 ${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 = '上传失败 ✗'; | |
| 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.src.startsWith('data:')); | |
| const urlImages = uploadedImages.filter(img => !img.src.startsWith('data:')); | |
| // Safely get text URLs if element exists | |
| const imageUrlsEl = document.getElementById('imageUrls'); | |
| const textUrls = imageUrlsEl ? imageUrlsEl.value.trim().split('\n').filter(url => url.trim()) : []; | |
| const totalUploads = base64Images.length; | |
| const totalImages = uploadedImages.length + textUrls.length; | |
| if (totalUploads > 0) { | |
| addLog(`准备上传 ${totalUploads} 张图像到FAL存储...`); | |
| showStatus(`正在上传 ${totalUploads} 张图像到FAL存储...`, 'info'); | |
| } | |
| // Concurrent upload with failure tolerance | |
| const uploadPromises = uploadedImages.map(async (image, index) => { | |
| if (image.src.startsWith('data:')) { | |
| // Base64 image needs upload | |
| try { | |
| const falUrl = await uploadImageToFal(image.src, apiKey, index + 1, totalUploads, index); | |
| return { success: true, url: falUrl, index }; | |
| } catch (error) { | |
| console.error(`Image ${index + 1} upload failed:`, error); | |
| return { success: false, error: error.message, index }; | |
| } | |
| } else { | |
| // Already a URL | |
| addLog(`使用现有URL作为图像 ${index + 1}`); | |
| return { success: true, url: image.src, index }; | |
| } | |
| }); | |
| // Wait for all uploads (concurrent execution) | |
| const results = await Promise.allSettled(uploadPromises); | |
| // Process results | |
| let successCount = 0; | |
| let failureCount = 0; | |
| const failedIndices = []; | |
| results.forEach((result, index) => { | |
| if (result.status === 'fulfilled' && result.value.success) { | |
| urls.push(result.value.url); | |
| successCount++; | |
| } else { | |
| failureCount++; | |
| failedIndices.push(index + 1); | |
| if (result.status === 'fulfilled') { | |
| addLog(`图像 ${index + 1} 上传失败: ${result.value.error}`); | |
| } else { | |
| addLog(`图像 ${index + 1} 上传失败: ${result.reason}`); | |
| } | |
| } | |
| }); | |
| // Report upload results | |
| if (failureCount > 0 && successCount === 0) { | |
| throw new Error('所有图像上传失败,无法继续生成'); | |
| } | |
| if (failureCount > 0) { | |
| showToast( | |
| `${failureCount} 张图像上传失败(编号: ${failedIndices.join(', ')}),将使用 ${successCount} 张成功上传的图像继续生成`, | |
| 'warning', | |
| 5000 | |
| ); | |
| addLog(`部分上传失败,继续使用 ${successCount}/${totalImages} 张图像`); | |
| } else if (totalUploads > 0) { | |
| showStatus(`所有 ${totalUploads} 张图像上传成功!`, 'success'); | |
| addLog(`上传完成: 共 ${totalImages} 张图像已准备好生成`); | |
| } | |
| // Add text URLs directly | |
| if (textUrls.length > 0) { | |
| addLog(`正在处理文本输入中的 ${textUrls.length} 个URL...`); | |
| } | |
| for (const url of textUrls) { | |
| urls.push(url); | |
| addLog(`已添加URL: ${url.substring(0, 50)}...`); | |
| } | |
| return urls.slice(0, 10); | |
| } | |
| // Generate edit | |
| async function generateEdit() { | |
| // Debounce: prevent rapid fire clicks | |
| const now = Date.now(); | |
| if (now - lastGenerationTime < 500) { | |
| showToast('请勿频繁点击生成按钮', 'warning', 2000); | |
| return; | |
| } | |
| lastGenerationTime = now; | |
| // Prevent concurrent generations | |
| if (generationInProgress) { | |
| showToast('已有生成任务在进行中', 'warning', 2000); | |
| return; | |
| } | |
| const prompt = getCurrentPrompt().trim(); | |
| if (!prompt) { | |
| showStatus('请输入提示词', 'error'); | |
| return; | |
| } | |
| const selectedModel = modelSelect.value; | |
| const isTextToImage = selectedModel === 'fal-ai/bytedance/seedream/v4/text-to-image'; | |
| const isVideo = isVideoModel(selectedModel); | |
| const isVideoI2V = isVideo && !selectedModel.includes('text-to-video'); | |
| // Prepare upload progress UI early if there are base64 uploads | |
| const base64Images = uploadedImages.filter(img => img.src.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) | |
| let imageUrlsArray; | |
| 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(`上传错误: ${error.message || error}`); | |
| showStatus(`上传错误: ${error.message || error}`, 'error'); | |
| return; | |
| } | |
| // Check image requirements based on model type | |
| if (!isTextToImage && !isVideo && imageUrlsArray.length === 0) { | |
| showStatus('请上传图像或提供图像URL进行图像编辑', 'error'); | |
| const pc = document.getElementById('uploadProgressContainer'); | |
| if (pc && pc.parentNode) pc.parentNode.removeChild(pc); | |
| return; | |
| } | |
| if (isVideoI2V && imageUrlsArray.length === 0) { | |
| showStatus('视频 I2V 模式需要上传首帧图像', 'error'); | |
| const pc = document.getElementById('uploadProgressContainer'); | |
| if (pc && pc.parentNode) pc.parentNode.removeChild(pc); | |
| return; | |
| } | |
| generationInProgress = true; | |
| generateBtn.disabled = true; | |
| generateBtn.querySelector('.btn-text').textContent = '生成中...'; | |
| generateBtn.querySelector('.spinner').style.display = 'block'; | |
| // 同时禁用drawer内联按钮 | |
| const drawerBtnInline = document.getElementById('drawerGenerateBtnInline'); | |
| const drawerBtnInlineSDE = document.getElementById('drawerGenerateBtnInlineSDE'); | |
| if (drawerBtnInline) { | |
| drawerBtnInline.disabled = true; | |
| drawerBtnInline.querySelector('.btn-text').textContent = '生成中'; | |
| drawerBtnInline.querySelector('.spinner').style.display = 'block'; | |
| } | |
| if (drawerBtnInlineSDE) { | |
| drawerBtnInlineSDE.disabled = true; | |
| drawerBtnInlineSDE.querySelector('.btn-text').textContent = '生成中'; | |
| drawerBtnInlineSDE.querySelector('.spinner').style.display = 'block'; | |
| } | |
| // Clear current results | |
| currentResults.innerHTML = '<div class="empty-state"><p>准备生成...</p></div>'; | |
| currentInfo.innerHTML = ''; | |
| clearLogs(); | |
| showStatus('开始生成进程...', 'info'); | |
| progressLogs.classList.add('active'); | |
| // Show initial status | |
| if (!isTextToImage && imageUrlsArray.length > 0) { | |
| addLog(`正在处理 ${imageUrlsArray.length} 张输入图像...`); | |
| } | |
| const requestData = { | |
| prompt: prompt | |
| }; | |
| if (isVideo) { | |
| // Video generation parameters | |
| if (isVideoI2V && imageUrlsArray.length > 0) { | |
| requestData.image_url = imageUrlsArray[0]; // Use first image as frame | |
| } | |
| // Add video-specific params from buildVideoParams() | |
| Object.assign(requestData, buildVideoParams()); | |
| } else { | |
| // Image generation parameters | |
| requestData.image_size = getImageSize(); | |
| requestData.num_images = parseInt(document.getElementById('numImages').value); | |
| requestData.enable_safety_checker = false; | |
| if (!isTextToImage) { | |
| 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 { | |
| // Cancel any existing generation | |
| if (currentGenerationAbort) { | |
| currentGenerationAbort.abort(); | |
| addLog('已取消前一次生成请求'); | |
| } | |
| // Create new abort controller | |
| currentGenerationAbort = new AbortController(); | |
| const apiKey = getAPIKey(); | |
| if (!apiKey) { | |
| showStatus('请输入您的FAL API密钥', 'error'); | |
| addLog('未找到API密钥'); | |
| document.getElementById('apiKey').focus(); | |
| return; | |
| } | |
| addLog('正在向FAL API提交请求...'); | |
| addLog(`模型: ${selectedModel}`); | |
| addLog(`提示词: ${prompt}`); | |
| if (!isTextToImage) { | |
| addLog(`输入图像数量: ${imageUrlsArray.length}`); | |
| } | |
| const response = await callFalAPI(apiKey, requestData, selectedModel, currentGenerationAbort.signal); | |
| // Store results in current generation | |
| currentGeneration.results = response; | |
| // Display results | |
| displayCurrentResults(response); | |
| // Add to history | |
| generationHistory.push(currentGeneration); | |
| saveHistory(); | |
| showStatus('生成完成!', 'success'); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| const errorMessage = error.name === 'AbortError' ? '生成已取消' : `错误: ${error.message}`; | |
| showStatus(errorMessage, error.name === 'AbortError' ? 'warning' : 'error'); | |
| addLog(errorMessage); | |
| } finally { | |
| generationInProgress = false; | |
| generateBtn.disabled = false; | |
| generateBtn.querySelector('.btn-text').textContent = '生成图像'; | |
| generateBtn.querySelector('.spinner').style.display = 'none'; | |
| // 恢复drawer内联按钮状态 | |
| const drawerBtnInline = document.getElementById('drawerGenerateBtnInline'); | |
| const drawerBtnInlineSDE = document.getElementById('drawerGenerateBtnInlineSDE'); | |
| if (drawerBtnInline) { | |
| drawerBtnInline.disabled = false; | |
| drawerBtnInline.querySelector('.btn-text').textContent = '生成'; | |
| drawerBtnInline.querySelector('.spinner').style.display = 'none'; | |
| } | |
| if (drawerBtnInlineSDE) { | |
| drawerBtnInlineSDE.disabled = false; | |
| drawerBtnInlineSDE.querySelector('.btn-text').textContent = '生成'; | |
| drawerBtnInlineSDE.querySelector('.spinner').style.display = 'none'; | |
| } | |
| // Clear abort controller | |
| currentGenerationAbort = null; | |
| // 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 with improved polling strategy | |
| async function callFalAPI(apiKey, requestData, model, signal) { | |
| const submitResponse = await fetch('/api/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${apiKey}`, | |
| 'X-Model-Endpoint': model | |
| }, | |
| body: JSON.stringify(requestData), | |
| keepalive: true | |
| }); | |
| 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(`请求已提交,ID: ${request_id}`); | |
| // Enhanced polling with exponential backoff and cancellation | |
| let attempts = 0; | |
| const maxAttempts = 120; | |
| let delay = 800; // Start with 800ms | |
| let previousLogCount = 0; | |
| while (attempts < maxAttempts) { | |
| // Check for cancellation | |
| if (signal?.aborted) { | |
| throw new Error('Generation cancelled'); | |
| } | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| const statusUrl = `/api/status/${request_id}`; | |
| const statusResponse = await fetch(statusUrl, { | |
| headers: { | |
| 'Authorization': `Bearer ${apiKey}`, | |
| 'X-Model-Endpoint': model | |
| }, | |
| signal, // Pass abort signal to fetch | |
| keepalive: true | |
| }); | |
| let statusData; | |
| try { | |
| statusData = await statusResponse.json(); | |
| } catch (_) { | |
| statusData = {}; | |
| } | |
| if (!statusResponse.ok) { | |
| const errorMsg = statusData?.error || `HTTP ${statusResponse.status}`; | |
| // Implement retry logic for server errors | |
| if (statusResponse.status >= 500 || statusResponse.status === 429) { | |
| addLog(`服务器暂时不可用 (${statusResponse.status}),将重试...`); | |
| attempts++; | |
| delay = Math.min(delay * 1.5, 4000); // Increase delay for retries | |
| continue; | |
| } | |
| addLog(`状态查询失败: ${errorMsg}`, 'error'); | |
| throw new Error('Failed to check request status'); | |
| } | |
| // 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; | |
| } | |
| // ✅ Support both uppercase and lowercase status (FAL queue returns uppercase) | |
| const status = (statusData.status || '').toUpperCase(); | |
| if (status === 'COMPLETED') { | |
| return statusData.result; | |
| } else if (status === 'ERROR') { | |
| throw new Error(statusData.error || 'Generation failed'); | |
| } | |
| attempts++; | |
| // Exponential backoff: increase delay gradually | |
| delay = Math.min(delay * 1.35, 4000); // Cap at 4 seconds | |
| if (attempts % 5 === 0) { | |
| addLog(`处理中... (已轮询 ${attempts} 次,下次等待 ${Math.round(delay/1000)}s)`); | |
| } | |
| } | |
| throw new Error('Request timed out after maximum attempts'); | |
| } | |
| // Display current results | |
| function displayCurrentResults(response) { | |
| // Handle video results | |
| if (response && response.video) { | |
| currentResults.innerHTML = ''; | |
| const videoUrl = response.video.url || ''; | |
| const videoId = `current-video-${Date.now()}`; | |
| const item = document.createElement('div'); | |
| item.className = 'generation-item video-item'; | |
| item.innerHTML = ` | |
| <video id="${videoId}" controls style="width: 100%; border-radius: var(--radius-md);"> | |
| <source src="${videoUrl}" type="video/mp4"> | |
| 您的浏览器不支持视频播放。 | |
| </video> | |
| <div class="generation-footer"> | |
| <div class="generation-timestamp">刚刚生成</div> | |
| <div class="generation-actions-bar"> | |
| <button class="action-icon" onclick="downloadVideo('${videoUrl}', 'video'); event.stopPropagation();" title="下载视频"> | |
| ⬇️ MP4 | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| currentResults.appendChild(item); | |
| if (response.seed) { | |
| currentInfo.innerHTML = `<strong>随机种子:</strong> ${response.seed}`; | |
| addLog(`使用的随机种子: ${response.seed}`); | |
| } | |
| addLog('视频生成完成'); | |
| return; | |
| } | |
| // Handle image results | |
| if (!response || !response.images || response.images.length === 0) { | |
| currentResults.innerHTML = '<div class="empty-state"><p>未生成图像</p></div>'; | |
| return; | |
| } | |
| currentResults.innerHTML = ''; | |
| 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}" loading="lazy" decoding="async" | |
| onclick="openImageModal('${imageId}', '${imgSrc}', '当前生成', '${new Date().toLocaleString()}')"> | |
| <div class="generation-footer"> | |
| <div class="generation-timestamp">刚刚生成</div> | |
| <div class="generation-actions-bar"> | |
| <button class="action-icon" onclick="useAsInput('${imageId}', '${imgSrc}'); event.stopPropagation();" title="作为输入"> | |
| ↻ | |
| </button> | |
| <button class="action-icon" onclick="downloadImage('${imgSrc}', 'current-${index}'); event.stopPropagation();" title="下载"> | |
| ⬇️ | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| currentResults.appendChild(item); | |
| }); | |
| // Display generation info | |
| if (response.seed) { | |
| currentInfo.innerHTML = `<strong>随机种子:</strong> ${response.seed}`; | |
| addLog(`使用的随机种子: ${response.seed}`); | |
| } | |
| addLog(`已生成 ${response.images.length} 张图像`); | |
| } | |
| // Display history | |
| function displayHistory() { | |
| if (generationHistory.length === 0) { | |
| historyGrid.innerHTML = ` | |
| <div class="empty-state"> | |
| <p>No generation history</p> | |
| <small>Your generated images will be saved here</small> | |
| </div> | |
| `; | |
| return; | |
| } | |
| historyGrid.innerHTML = ''; | |
| // Display history in reverse order (newest first) | |
| [...generationHistory].reverse().forEach((generation) => { | |
| if (!generation.results) return; | |
| // Handle video results | |
| if (generation.results.video) { | |
| const videoUrl = generation.results.video.url || ''; | |
| const videoId = `history-video-${generation.id}`; | |
| const item = document.createElement('div'); | |
| item.className = 'generation-item video-item'; | |
| item.innerHTML = ` | |
| <video id="${videoId}" controls style="width: 100%; border-radius: var(--radius-md);"> | |
| <source src="${videoUrl}" type="video/mp4"> | |
| 您的浏览器不支持视频播放。 | |
| </video> | |
| <div class="generation-footer"> | |
| <div class="generation-timestamp">${new Date(generation.timestamp).toLocaleString()}</div> | |
| <div class="generation-actions-bar"> | |
| <button class="action-icon" onclick="copyPromptFromHistory('${generation.prompt.replace(/'/g, "\\'")}', event)" title="复制提示词"> | |
| 📋 | |
| </button> | |
| <button class="action-icon" onclick="downloadVideo('${videoUrl}', 'history-${generation.id}'); event.stopPropagation();" title="下载视频"> | |
| ⬇️ MP4 | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| historyGrid.appendChild(item); | |
| } | |
| // Handle image results | |
| 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" loading="lazy" decoding="async" | |
| onclick="openImageModal('${imageId}', '${imgSrc}', '${generation.prompt.replace(/'/g, "\\'")}', '${new Date(generation.timestamp).toLocaleString()}')"> | |
| <div class="generation-footer"> | |
| <div class="generation-timestamp">${new Date(generation.timestamp).toLocaleString()}</div> | |
| <div class="generation-actions-bar"> | |
| <button class="action-icon" onclick="useAsInput('${imageId}', '${imgSrc}'); event.stopPropagation();" title="作为输入"> | |
| ↻ | |
| </button> | |
| <button class="action-icon" onclick="copyPromptFromHistory('${generation.prompt.replace(/'/g, "\\'")}', event)" title="复制提示词"> | |
| 📋 | |
| </button> | |
| <button class="action-icon" onclick="downloadImage('${imgSrc}', '${generation.id}-${imgIndex}'); event.stopPropagation();" title="下载"> | |
| ⬇️ | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| historyGrid.appendChild(item); | |
| }); | |
| } | |
| }); | |
| } | |
| // Adjust textarea size for mobile/iPad | |
| function adjustTextareaSize(textareaId, direction) { | |
| const textarea = document.getElementById(textareaId); | |
| if (!textarea) return; | |
| const currentRows = parseInt(textarea.getAttribute('rows') || '3'); | |
| let newRows = currentRows; | |
| if (direction === 'larger' && currentRows < 12) { | |
| newRows = currentRows + 1; | |
| } else if (direction === 'smaller' && currentRows > 1) { | |
| newRows = currentRows - 1; | |
| } | |
| if (newRows !== currentRows) { | |
| textarea.setAttribute('rows', newRows.toString()); | |
| // 保存用户偏好 | |
| localStorage.setItem(`textarea-size-${textareaId}`, newRows.toString()); | |
| // 给用户反馈 | |
| const sizeText = newRows <= 2 ? '小' : newRows <= 4 ? '中' : newRows <= 6 ? '大' : '超大'; | |
| StatusManager.show(`输入框大小已调整为: ${sizeText}`, 'success'); | |
| } | |
| } | |
| // Load saved textarea sizes on page load | |
| function loadTextareaSizes() { | |
| ['prompt', 'drawerPrompt'].forEach(id => { | |
| const savedSize = localStorage.getItem(`textarea-size-${id}`); | |
| if (savedSize) { | |
| const textarea = document.getElementById(id); | |
| if (textarea) { | |
| textarea.setAttribute('rows', savedSize); | |
| } | |
| } | |
| }); | |
| } | |
| // ============================= // | |
| // 结构化指令编辑器 (SDE) 功能 | |
| // ============================= // | |
| // SDE模板定义 | |
| const SDE_TEMPLATES = { | |
| single_female: `使用我提供的参考照片,生成一张包含其中真人女性的图像。她的相貌、体型和特征必须与参考照片中的人物完全一致。接下来的描述将把这位女性置于一个新的、详细的的场景中:`, | |
| multiple_female: `使用我提供的多张参考照片,生成一张包含其中多位真人女性的图像。她们各自的相貌、体型和特征必须与对应的参考照片完全一致。接下来的描述将把她们置于一个新的、详细的的场景中:`, | |
| mixed_gender: `使用我提供的多张参考照片,生成一张包含其中多位真人的图像。其中,女性参考照片对应场景中的女主角,男性参考照片对应场景中的男性。他们各自的相貌、体型和特征必须与对应的参考照片完全一致。接下来的描述将把他们置于一个新的、详细的的场景中:`, | |
| multi_character: `使用我提供的多张参考照片,生成一张包含其中多位真人的图像。他们各自的相貌、体型和特征必须与对应的参考照片完全一致。接下来的描述将把他们置于一个新的、详细的(动作和姿势参考动漫插图)的场景中:` | |
| }; | |
| const GSP_STYLE_TEXT = `由于是随意抓拍,构图混乱且角度尴尬,没有明确的主体。房间里不均匀的灯光导致画面部分区域曝光不均,同时带有轻微的运动模糊和强烈的数字噪点。整体呈现出一种刻意平庸的、混乱且私密的纪实感。`; | |
| // 同步SDE模式状态 | |
| function syncSDEMode() { | |
| const mainCheckbox = document.getElementById('enableSDE'); | |
| const drawerCheckbox = document.getElementById('drawerEnableSDE'); | |
| if (event.target === drawerCheckbox) { | |
| mainCheckbox.checked = drawerCheckbox.checked; | |
| } else { | |
| drawerCheckbox.checked = mainCheckbox.checked; | |
| } | |
| toggleSDEMode(mainCheckbox.checked); | |
| } | |
| // 切换SDE模式显示 | |
| function toggleSDEMode(enabled) { | |
| const traditionalMode = document.getElementById('traditionalPromptMode'); | |
| const structuredMode = document.getElementById('structuredPromptMode'); | |
| const drawerTraditionalMode = document.getElementById('drawerTraditionalMode'); | |
| const drawerStructuredMode = document.getElementById('drawerStructuredMode'); | |
| // Data protection: check for content loss | |
| if (enabled) { | |
| // Switching TO SDE mode - check traditional prompt | |
| const traditionalPrompt = document.getElementById('prompt').value; | |
| if (traditionalPrompt.trim().length > 0) { | |
| const confirmed = confirm( | |
| '切换到结构化编辑器将替换当前提示词。是否继续?\n\n' + | |
| '当前内容将保存在草稿中,可通过"恢复草稿"找回。' | |
| ); | |
| if (!confirmed) { | |
| // User cancelled - revert checkbox state | |
| const mainCheckbox = document.getElementById('enableSDE'); | |
| const drawerCheckbox = document.getElementById('drawerEnableSDE'); | |
| if (mainCheckbox) mainCheckbox.checked = false; | |
| if (drawerCheckbox) drawerCheckbox.checked = false; | |
| return; | |
| } | |
| // Save to draft | |
| localStorage.setItem('sde_draft_traditional', traditionalPrompt); | |
| showToast('原始提示词已保存到草稿', 'info', 3000); | |
| } | |
| traditionalMode.style.display = 'none'; | |
| structuredMode.style.display = 'block'; | |
| drawerTraditionalMode.style.display = 'none'; | |
| drawerStructuredMode.style.display = 'block'; | |
| updateCombinedPrompt(); | |
| } else { | |
| // Switching FROM SDE mode - check structured content | |
| const sceneDescription = document.getElementById('sceneDescription').value; | |
| if (sceneDescription.trim().length > 0) { | |
| const confirmed = confirm( | |
| '切换到传统模式将清空结构化编辑器内容。是否继续?\n\n' + | |
| '当前内容将保存在草稿中,可通过"恢复草稿"找回。' | |
| ); | |
| if (!confirmed) { | |
| // User cancelled - revert checkbox state | |
| const mainCheckbox = document.getElementById('enableSDE'); | |
| const drawerCheckbox = document.getElementById('drawerEnableSDE'); | |
| if (mainCheckbox) mainCheckbox.checked = true; | |
| if (drawerCheckbox) drawerCheckbox.checked = true; | |
| return; | |
| } | |
| // Save to draft | |
| localStorage.setItem('sde_draft_structured', sceneDescription); | |
| showToast('结构化内容已保存到草稿', 'info', 3000); | |
| } | |
| traditionalMode.style.display = 'block'; | |
| structuredMode.style.display = 'none'; | |
| drawerTraditionalMode.style.display = 'block'; | |
| drawerStructuredMode.style.display = 'none'; | |
| } | |
| // 保存用户偏好 | |
| localStorage.setItem('sde-enabled', enabled.toString()); | |
| } | |
| // Restore draft content | |
| function restoreDraft() { | |
| const enableSDE = document.getElementById('enableSDE').checked; | |
| if (enableSDE) { | |
| // In SDE mode - restore structured draft | |
| const draft = localStorage.getItem('sde_draft_structured'); | |
| if (draft) { | |
| document.getElementById('sceneDescription').value = draft; | |
| document.getElementById('drawerSceneDescription').value = draft; | |
| showToast('已恢复结构化草稿内容', 'success', 3000); | |
| localStorage.removeItem('sde_draft_structured'); | |
| updateCombinedPrompt(); | |
| } else { | |
| showToast('没有可恢复的草稿', 'info', 2000); | |
| } | |
| } else { | |
| // In traditional mode - restore traditional draft | |
| const draft = localStorage.getItem('sde_draft_traditional'); | |
| if (draft) { | |
| document.getElementById('prompt').value = draft; | |
| document.getElementById('drawerPrompt').value = draft; | |
| showToast('已恢复传统提示词草稿', 'success', 3000); | |
| localStorage.removeItem('sde_draft_traditional'); | |
| } else { | |
| showToast('没有可恢复的草稿', 'info', 2000); | |
| } | |
| } | |
| } | |
| // 更新合并后的提示词 | |
| function updateCombinedPrompt() { | |
| const referenceSelect = document.getElementById('referenceProtocol'); | |
| const sceneTextarea = document.getElementById('sceneDescription'); | |
| const gspCheckbox = document.getElementById('gspStyle'); | |
| const previewTextarea = document.getElementById('combinedPromptPreview'); | |
| // 同步移动端的值 | |
| const drawerReferenceSelect = document.getElementById('drawerReferenceProtocol'); | |
| const drawerSceneTextarea = document.getElementById('drawerSceneDescription'); | |
| const drawerGspCheckbox = document.getElementById('drawerGspStyle'); | |
| if (drawerReferenceSelect) drawerReferenceSelect.value = referenceSelect.value; | |
| if (drawerSceneTextarea) drawerSceneTextarea.value = sceneTextarea.value; | |
| if (drawerGspCheckbox) drawerGspCheckbox.checked = gspCheckbox.checked; | |
| // 构建完整提示词 | |
| const parts = []; | |
| // 模块一:参考协议 | |
| if (referenceSelect.value && SDE_TEMPLATES[referenceSelect.value]) { | |
| parts.push(SDE_TEMPLATES[referenceSelect.value]); | |
| } | |
| // 模块二:场景描述 | |
| if (sceneTextarea.value.trim()) { | |
| parts.push(sceneTextarea.value.trim()); | |
| } | |
| // 模块三:风格化协议 | |
| if (gspCheckbox.checked) { | |
| parts.push(GSP_STYLE_TEXT); | |
| } | |
| const combinedPrompt = parts.join('\n\n'); | |
| if (previewTextarea) { | |
| previewTextarea.value = combinedPrompt; | |
| } | |
| // 同时更新传统模式的textarea以保持兼容性 | |
| const promptTextarea = document.getElementById('prompt'); | |
| const drawerPromptTextarea = document.getElementById('drawerPrompt'); | |
| if (document.getElementById('enableSDE').checked) { | |
| if (promptTextarea) promptTextarea.value = combinedPrompt; | |
| if (drawerPromptTextarea) drawerPromptTextarea.value = combinedPrompt; | |
| } | |
| } | |
| // 从移动端同步SDE数据到桌面端 | |
| function syncSDEFromDrawer() { | |
| const drawerReferenceSelect = document.getElementById('drawerReferenceProtocol'); | |
| const drawerSceneTextarea = document.getElementById('drawerSceneDescription'); | |
| const drawerGspCheckbox = document.getElementById('drawerGspStyle'); | |
| const referenceSelect = document.getElementById('referenceProtocol'); | |
| const sceneTextarea = document.getElementById('sceneDescription'); | |
| const gspCheckbox = document.getElementById('gspStyle'); | |
| if (drawerReferenceSelect && referenceSelect) { | |
| referenceSelect.value = drawerReferenceSelect.value; | |
| } | |
| if (drawerSceneTextarea && sceneTextarea) { | |
| sceneTextarea.value = drawerSceneTextarea.value; | |
| } | |
| if (drawerGspCheckbox && gspCheckbox) { | |
| gspCheckbox.checked = drawerGspCheckbox.checked; | |
| } | |
| updateCombinedPrompt(); | |
| } | |
| // 获取当前有效的提示词 | |
| function getCurrentPrompt() { | |
| const sdeEnabled = document.getElementById('enableSDE').checked; | |
| if (sdeEnabled) { | |
| const previewTextarea = document.getElementById('combinedPromptPreview'); | |
| return previewTextarea ? previewTextarea.value : ''; | |
| } else { | |
| const promptTextarea = document.getElementById('prompt'); | |
| return promptTextarea ? promptTextarea.value : ''; | |
| } | |
| } | |
| // 加载SDE用户偏好 | |
| function loadSDEPreferences() { | |
| const savedEnabled = localStorage.getItem('sde-enabled'); | |
| if (savedEnabled === 'true') { | |
| document.getElementById('enableSDE').checked = true; | |
| document.getElementById('drawerEnableSDE').checked = true; | |
| toggleSDEMode(true); | |
| } | |
| } | |
| // Copy prompt from history | |
| function copyPromptFromHistory(prompt, event) { | |
| event.stopPropagation(); // 防止触发图片点击事件 | |
| // 解码HTML实体和转义字符 | |
| const decodedPrompt = prompt.replace(/\\'/g, "'").replace(/"/g, '"').replace(/&/g, '&'); | |
| navigator.clipboard.writeText(decodedPrompt).then(() => { | |
| // 显示成功提示 | |
| StatusManager.show('提示词已复制到剪贴板', 'success'); | |
| // 可选:同时填入当前的prompt输入框 | |
| const promptTextarea = document.getElementById('prompt'); | |
| const drawerPromptTextarea = document.getElementById('drawerPrompt'); | |
| if (promptTextarea) { | |
| promptTextarea.value = decodedPrompt; | |
| } | |
| if (drawerPromptTextarea) { | |
| drawerPromptTextarea.value = decodedPrompt; | |
| } | |
| }).catch(err => { | |
| console.error('复制失败:', err); | |
| StatusManager.show('复制失败,请手动选择文本复制', 'error'); | |
| }); | |
| } | |
| // 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') { | |
| modelSelect.value = 'fal-ai/bytedance/seedream/v4/edit'; | |
| handleModelChange(); | |
| showStatus('已切换到图像编辑模式', 'info'); | |
| } | |
| if (uploadedImages.length >= 10) { | |
| showStatus('最多允许10张图像。请先删除一些图像。', 'error'); | |
| return; | |
| } | |
| // Get dimensions and create unified image object | |
| const imgElement = document.getElementById(imageId); | |
| let width, height; | |
| if (imgElement) { | |
| if (!imgElement.complete) { | |
| await new Promise((resolve) => { | |
| imgElement.onload = resolve; | |
| imgElement.onerror = resolve; | |
| }); | |
| } | |
| width = imgElement.naturalWidth || imgElement.width; | |
| height = imgElement.naturalHeight || imgElement.height; | |
| } else { | |
| // Load image to get dimensions | |
| await new Promise((resolve) => { | |
| const img = new Image(); | |
| img.onload = function() { | |
| width = this.width; | |
| height = this.height; | |
| resolve(); | |
| }; | |
| img.onerror = function() { | |
| width = 1280; | |
| height = 1280; | |
| resolve(); | |
| }; | |
| img.src = imageSrc; | |
| }); | |
| } | |
| const imageObj = { | |
| src: imageSrc, | |
| width: width, | |
| height: height, | |
| id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` | |
| }; | |
| uploadedImages.push(imageObj); | |
| renderImagePreviews(); | |
| const totalImages = uploadedImages.length; | |
| showStatus(`图像已添加为输入 (已使用 ${totalImages}/10 个位置)`, 'success'); | |
| addLog(`已添加图像作为输入 (${totalImages}/10 张图像)`); | |
| // Flash animation | |
| imagePreview.style.animation = 'flash 0.5s'; | |
| setTimeout(() => { | |
| imagePreview.style.animation = ''; | |
| }, 500); | |
| } catch (error) { | |
| console.error('Error using image as input:', error); | |
| showStatus('添加图像作为输入失败', 'error'); | |
| } | |
| } | |
| // Clear all input images | |
| function clearAllInputImages() { | |
| uploadedImages = []; | |
| renderImagePreviews(); | |
| showStatus('所有输入图像已清除', 'info'); | |
| } | |
| // Clear history | |
| function clearHistory() { | |
| if (confirm('确定要清除所有生成历史吗?此操作无法撤销。')) { | |
| generationHistory = []; | |
| localStorage.removeItem(HISTORY_KEY); | |
| displayHistory(); | |
| updateHistoryCount(); | |
| showStatus('历史已清除', 'info'); | |
| } | |
| } | |
| // Download all history | |
| function downloadAllHistory() { | |
| if (generationHistory.length === 0) { | |
| showStatus('无历史可下载', 'error'); | |
| return; | |
| } | |
| // Create a zip file or download each image | |
| generationHistory.forEach((generation, genIndex) => { | |
| if (generation.results && 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); | |
| } | |
| }); | |
| } | |
| }); | |
| showStatus('正在下载所有图像...', 'info'); | |
| } | |
| // Update history count | |
| function updateHistoryCount() { | |
| const countElement = document.getElementById('historyCount'); | |
| if (countElement) { | |
| let totalImages = 0; | |
| generationHistory.forEach(gen => { | |
| if (gen.results && gen.results.images) { | |
| totalImages += gen.results.images.length; | |
| } | |
| }); | |
| countElement.textContent = totalImages; | |
| } | |
| } | |
| // 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" and trigger element for focus restoration | |
| const triggerElement = document.activeElement; | |
| currentModalImage = { id: imageId, src: imageSrc, triggerElement }; | |
| // Reset image transform | |
| modalImg.style.transform = 'scale(1)'; | |
| modalImg.style.transformOrigin = 'center'; | |
| // Add zoom functionality | |
| setupImageZoom(modalImg); | |
| // Set modal content | |
| modalImg.src = imageSrc; | |
| modalCaption.innerHTML = ` | |
| <strong>生成时间:</strong> ${timestamp}<br> | |
| <strong>提示词:</strong> ${prompt} | |
| `; | |
| // Show modal | |
| modal.classList.add('show'); | |
| // Prevent body scroll when modal is open | |
| document.body.style.overflow = 'hidden'; | |
| // Focus the close button for keyboard navigation | |
| setTimeout(() => { | |
| const closeBtn = modal.querySelector('.modal-close'); | |
| if (closeBtn) closeBtn.focus(); | |
| }, 100); | |
| // 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 setupImageZoom(img) { | |
| let scale = 1; | |
| let isDragging = false; | |
| let startX, startY, initialX = 0, initialY = 0; | |
| // Double-click to zoom | |
| img.addEventListener('dblclick', () => { | |
| scale = scale > 1 ? 1 : 2; | |
| img.style.transform = `scale(${scale}) translate(${initialX}px, ${initialY}px)`; | |
| if (scale === 1) { | |
| initialX = 0; | |
| initialY = 0; | |
| } | |
| }); | |
| // Wheel zoom (with Ctrl for desktop) | |
| img.addEventListener('wheel', (e) => { | |
| if (e.ctrlKey) { | |
| e.preventDefault(); | |
| const delta = e.deltaY > 0 ? -0.1 : 0.1; | |
| scale = Math.max(1, Math.min(4, scale + delta)); | |
| img.style.transform = `scale(${scale}) translate(${initialX}px, ${initialY}px)`; | |
| if (scale === 1) { | |
| initialX = 0; | |
| initialY = 0; | |
| } | |
| } | |
| }, { passive: false }); | |
| // Touch gestures for pinch-to-zoom | |
| let initialDistance = 0; | |
| let initialScale = 1; | |
| img.addEventListener('touchstart', (e) => { | |
| if (e.touches.length === 2) { | |
| // Pinch gesture | |
| const touch1 = e.touches[0]; | |
| const touch2 = e.touches[1]; | |
| initialDistance = Math.hypot( | |
| touch2.clientX - touch1.clientX, | |
| touch2.clientY - touch1.clientY | |
| ); | |
| initialScale = scale; | |
| } else if (e.touches.length === 1 && scale > 1) { | |
| // Pan gesture when zoomed | |
| isDragging = true; | |
| startX = e.touches[0].clientX - initialX; | |
| startY = e.touches[0].clientY - initialY; | |
| } | |
| }); | |
| img.addEventListener('touchmove', (e) => { | |
| e.preventDefault(); | |
| if (e.touches.length === 2) { | |
| // Pinch zoom | |
| const touch1 = e.touches[0]; | |
| const touch2 = e.touches[1]; | |
| const currentDistance = Math.hypot( | |
| touch2.clientX - touch1.clientX, | |
| touch2.clientY - touch1.clientY | |
| ); | |
| scale = Math.max(1, Math.min(4, initialScale * (currentDistance / initialDistance))); | |
| img.style.transform = `scale(${scale}) translate(${initialX}px, ${initialY}px)`; | |
| if (scale === 1) { | |
| initialX = 0; | |
| initialY = 0; | |
| } | |
| } else if (e.touches.length === 1 && isDragging && scale > 1) { | |
| // Pan | |
| initialX = e.touches[0].clientX - startX; | |
| initialY = e.touches[0].clientY - startY; | |
| img.style.transform = `scale(${scale}) translate(${initialX}px, ${initialY}px)`; | |
| } | |
| }, { passive: false }); | |
| img.addEventListener('touchend', () => { | |
| isDragging = false; | |
| }); | |
| // Mouse drag for desktop | |
| img.addEventListener('mousedown', (e) => { | |
| if (scale > 1) { | |
| isDragging = true; | |
| startX = e.clientX - initialX; | |
| startY = e.clientY - initialY; | |
| img.style.cursor = 'grabbing'; | |
| e.preventDefault(); | |
| } | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (isDragging && scale > 1) { | |
| initialX = e.clientX - startX; | |
| initialY = e.clientY - startY; | |
| img.style.transform = `scale(${scale}) translate(${initialX}px, ${initialY}px)`; | |
| } | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (isDragging) { | |
| isDragging = false; | |
| img.style.cursor = 'grab'; | |
| } | |
| }); | |
| } | |
| function closeImageModal() { | |
| const modal = document.getElementById('imageModal'); | |
| modal.classList.remove('show'); | |
| document.body.style.overflow = ''; | |
| document.removeEventListener('keydown', handleModalEscape); | |
| // Restore focus to the trigger element | |
| if (currentModalImage?.triggerElement) { | |
| setTimeout(() => { | |
| currentModalImage.triggerElement.focus(); | |
| }, 100); | |
| } | |
| currentModalImage = null; | |
| } | |
| function handleModalEscape(event) { | |
| if (event.key === 'Escape') { | |
| closeImageModal(); | |
| } | |
| } | |
| function useModalImageAsInput() { | |
| if (currentModalImage) { | |
| useAsInput(currentModalImage.id, currentModalImage.src); | |
| closeImageModal(); | |
| } | |
| } | |
| // Toggle API Key visibility | |
| function toggleApiKeyVisibility() { | |
| const apiKeyInput = document.getElementById('apiKey'); | |
| const toggleBtn = document.getElementById('toggleApiKey'); | |
| if (apiKeyInput.type === 'password') { | |
| apiKeyInput.type = 'text'; | |
| toggleBtn.textContent = '🙈'; | |
| toggleBtn.title = '隐藏密钥'; | |
| toggleBtn.setAttribute('aria-label', '隐藏密钥'); | |
| } else { | |
| apiKeyInput.type = 'password'; | |
| toggleBtn.textContent = '👁'; | |
| toggleBtn.title = '显示密钥'; | |
| toggleBtn.setAttribute('aria-label', '显示密钥'); | |
| } | |
| } | |
| // Test API Key connection | |
| async function testApiKeyConnection() { | |
| const apiKey = getAPIKey(); | |
| const statusDiv = document.getElementById('apiKeyStatus'); | |
| const testBtn = document.getElementById('testApiKey'); | |
| if (!apiKey) { | |
| showApiKeyStatus('请先输入API密钥', 'error'); | |
| return; | |
| } | |
| testBtn.disabled = true; | |
| testBtn.textContent = '测试中...'; | |
| showApiKeyStatus('正在测试连接...', 'testing'); | |
| try { | |
| // Test with a minimal request to check API key validity | |
| const response = await fetch('/api/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${apiKey}`, | |
| 'X-Model-Endpoint': 'fal-ai/bytedance/seedream/v4/text-to-image' | |
| }, | |
| body: JSON.stringify({ | |
| prompt: 'test', | |
| image_size: 'square', | |
| num_images: 1, | |
| enable_safety_checker: false | |
| }) | |
| }); | |
| if (response.status === 401) { | |
| showApiKeyStatus('API密钥无效 - 请检查密钥是否正确', 'error'); | |
| } else if (response.status === 403) { | |
| showApiKeyStatus('权限不足 - 请检查密钥权限', 'error'); | |
| } else if (response.status === 429) { | |
| showApiKeyStatus('API密钥有效,但已达到速率限制', 'success'); | |
| } else if (response.ok) { | |
| const data = await response.json(); | |
| if (data.request_id) { | |
| showApiKeyStatus('✓ API密钥有效,连接正常', 'success'); | |
| } else { | |
| showApiKeyStatus('API密钥有效,但响应异常', 'error'); | |
| } | |
| } else { | |
| const errorText = await response.text(); | |
| if (errorText.includes('quota') || errorText.includes('credit') || errorText.includes('balance')) { | |
| showApiKeyStatus('API密钥有效,但账户余额不足', 'error'); | |
| } else { | |
| showApiKeyStatus(`连接失败 - ${errorText}`, 'error'); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('API Key test error:', error); | |
| showApiKeyStatus(`网络错误 - ${error.message}`, 'error'); | |
| } finally { | |
| testBtn.disabled = false; | |
| testBtn.textContent = '测试'; | |
| // Hide status after 5 seconds for success, keep error visible | |
| setTimeout(() => { | |
| if (statusDiv.classList.contains('success')) { | |
| statusDiv.style.display = 'none'; | |
| statusDiv.className = 'api-key-status'; | |
| } | |
| }, 5000); | |
| } | |
| } | |
| // Show API Key status | |
| function showApiKeyStatus(message, type) { | |
| const statusDiv = document.getElementById('apiKeyStatus'); | |
| statusDiv.className = `api-key-status ${type}`; | |
| statusDiv.textContent = message; | |
| statusDiv.style.display = 'block'; | |
| } | |
| // iOS 键盘遮挡处理 | |
| function setupIOSKeyboardHandling() { | |
| const prompt = document.getElementById('prompt'); | |
| const appContainer = document.querySelector('.app-container'); | |
| if (!prompt || !appContainer) return; | |
| // 检测是否支持 visualViewport API | |
| const vv = window.visualViewport; | |
| if (!vv) return; | |
| function adjustForKeyboard() { | |
| const viewportHeight = vv.height; | |
| const windowHeight = window.innerHeight; | |
| const diff = windowHeight - viewportHeight; | |
| if (diff > 0) { | |
| // 键盘弹出,调整底部内边距 | |
| appContainer.style.paddingBottom = `${Math.max(16, diff + 16)}px`; | |
| } | |
| } | |
| function resetKeyboard() { | |
| // 重置内边距 | |
| appContainer.style.paddingBottom = ''; | |
| } | |
| // 输入框获得焦点时开始监听 | |
| prompt.addEventListener('focus', () => { | |
| vv?.addEventListener('resize', adjustForKeyboard); | |
| }); | |
| // 输入框失去焦点时停止监听并重置 | |
| prompt.addEventListener('blur', () => { | |
| vv?.removeEventListener('resize', adjustForKeyboard); | |
| resetKeyboard(); | |
| }); | |
| // 页面可见性变化时也重置(用户切换应用) | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.hidden) { | |
| resetKeyboard(); | |
| } | |
| }); | |
| } | |
| // 在页面加载完成后设置键盘处理 | |
| document.addEventListener('DOMContentLoaded', setupIOSKeyboardHandling); | |
| // Drawer functionality for mobile sidebar | |
| function toggleDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| const isOpen = drawer.classList.contains('open'); | |
| if (isOpen) { | |
| closeDrawer(); | |
| } else { | |
| openDrawer(); | |
| } | |
| } | |
| function openDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| drawer.classList.add('open'); | |
| overlay.classList.add('show'); | |
| // Prevent body scroll when drawer is open | |
| document.body.style.overflow = 'hidden'; | |
| // Add escape key listener | |
| document.addEventListener('keydown', handleDrawerEscape); | |
| // Add swipe-to-close gesture support | |
| addSwipeGestures(); | |
| } | |
| function closeDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| drawer.classList.remove('open'); | |
| overlay.classList.remove('show'); | |
| // Restore body scroll | |
| document.body.classList.remove('drawer-open'); | |
| // Remove escape key listener | |
| document.removeEventListener('keydown', handleDrawerEscape); | |
| // Remove swipe gestures | |
| removeSwipeGestures(); | |
| } | |
| function handleDrawerEscape(event) { | |
| if (event.key === 'Escape') { | |
| closeDrawer(); | |
| } | |
| } | |
| // Swipe gesture support for drawer | |
| let startX = 0; | |
| let currentX = 0; | |
| let isDragging = false; | |
| function addSwipeGestures() { | |
| const drawer = document.getElementById('drawer'); | |
| drawer.addEventListener('touchstart', handleTouchStart, { passive: true }); | |
| drawer.addEventListener('touchmove', handleTouchMove, { passive: false }); | |
| drawer.addEventListener('touchend', handleTouchEnd, { passive: true }); | |
| } | |
| function removeSwipeGestures() { | |
| const drawer = document.getElementById('drawer'); | |
| drawer.removeEventListener('touchstart', handleTouchStart); | |
| drawer.removeEventListener('touchmove', handleTouchMove); | |
| drawer.removeEventListener('touchend', handleTouchEnd); | |
| } | |
| function handleTouchStart(event) { | |
| startX = event.touches[0].clientX; | |
| isDragging = false; | |
| } | |
| function handleTouchMove(event) { | |
| if (!isDragging) { | |
| isDragging = true; | |
| } | |
| currentX = event.touches[0].clientX; | |
| const deltaX = currentX - startX; | |
| // Only allow swiping left (to close) | |
| if (deltaX < 0) { | |
| const drawer = document.getElementById('drawer'); | |
| const percentage = Math.abs(deltaX) / drawer.offsetWidth; | |
| const translateX = Math.min(0, deltaX); | |
| drawer.style.transform = `translateX(${translateX}px)`; | |
| // Prevent default to avoid page scroll | |
| event.preventDefault(); | |
| } | |
| } | |
| function handleTouchEnd(event) { | |
| if (!isDragging) return; | |
| const drawer = document.getElementById('drawer'); | |
| const deltaX = currentX - startX; | |
| const threshold = drawer.offsetWidth * 0.3; // 30% swipe threshold | |
| // Reset transform | |
| drawer.style.transform = ''; | |
| // Close drawer if swiped left beyond threshold | |
| if (deltaX < -threshold) { | |
| closeDrawer(); | |
| } | |
| isDragging = false; | |
| } | |
| // Keyboard shortcuts | |
| function initializeKeyboardShortcuts() { | |
| document.addEventListener('keydown', (e) => { | |
| // Cmd/Ctrl + Enter: Generate | |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| const generateBtn = document.getElementById('generateBtn'); | |
| if (!generateBtn.disabled && !generateBtn.classList.contains('loading')) { | |
| generate(); | |
| } | |
| } | |
| // Escape: Close modal or drawer | |
| if (e.key === 'Escape') { | |
| const modal = document.getElementById('imageModal'); | |
| const drawer = document.getElementById('drawer'); | |
| if (modal.classList.contains('show')) { | |
| closeImageModal(); | |
| } else if (drawer.classList.contains('open')) { | |
| closeDrawer(); | |
| } | |
| } | |
| // Cmd/Ctrl + K: Focus prompt input | |
| if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | |
| e.preventDefault(); | |
| const promptInput = document.getElementById('prompt'); | |
| promptInput.focus(); | |
| promptInput.select(); | |
| } | |
| // Tab navigation enhancement | |
| if (e.key === 'Tab') { | |
| document.body.classList.add('keyboard-nav'); | |
| } | |
| }); | |
| // Remove keyboard navigation class on mouse use | |
| document.addEventListener('mousedown', () => { | |
| document.body.classList.remove('keyboard-nav'); | |
| }); | |
| } | |
| // Accessibility enhancements | |
| function initializeAccessibility() { | |
| // Add aria-live regions for dynamic content | |
| const statusMessage = document.getElementById('statusMessage'); | |
| if (statusMessage) { | |
| statusMessage.setAttribute('aria-live', 'polite'); | |
| statusMessage.setAttribute('aria-atomic', 'true'); | |
| } | |
| // Enhance form labels and descriptions | |
| const inputs = document.querySelectorAll('input, textarea, select'); | |
| inputs.forEach(input => { | |
| const label = document.querySelector(`label[for="${input.id}"]`); | |
| if (label && !label.id) { | |
| label.id = `label-${input.id}`; | |
| input.setAttribute('aria-labelledby', label.id); | |
| } | |
| // Add aria-describedby for help text | |
| const helpText = input.parentElement.querySelector('.help-text'); | |
| if (helpText && !helpText.id) { | |
| const helpId = `help-${input.id}`; | |
| helpText.id = helpId; | |
| input.setAttribute('aria-describedby', helpId); | |
| } | |
| }); | |
| // Add keyboard navigation to image gallery | |
| const updateImageAccessibility = () => { | |
| const images = document.querySelectorAll('.result-item img, .history-item img'); | |
| images.forEach((img, index) => { | |
| if (!img.hasAttribute('tabindex')) { | |
| img.setAttribute('tabindex', '0'); | |
| img.setAttribute('role', 'button'); | |
| img.setAttribute('aria-label', `查看图像 ${index + 1}`); | |
| img.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| img.click(); | |
| } | |
| }); | |
| } | |
| }); | |
| }; | |
| // Initial setup and observe for new images | |
| updateImageAccessibility(); | |
| const observer = new MutationObserver(updateImageAccessibility); | |
| const currentResults = document.getElementById('currentResults'); | |
| const historyGrid = document.getElementById('historyGrid'); | |
| if (currentResults) observer.observe(currentResults, { childList: true, subtree: true }); | |
| if (historyGrid) observer.observe(historyGrid, { childList: true, subtree: true }); | |
| // Announce loading states | |
| const generateBtn = document.getElementById('generateBtn'); | |
| if (generateBtn) { | |
| const buttonObserver = new MutationObserver((mutations) => { | |
| mutations.forEach((mutation) => { | |
| if (mutation.attributeName === 'aria-busy') { | |
| const isBusy = generateBtn.getAttribute('aria-busy') === 'true'; | |
| generateBtn.setAttribute('aria-label', | |
| isBusy ? '正在生成图像,请稍候...' : '生成图像'); | |
| } | |
| }); | |
| }); | |
| buttonObserver.observe(generateBtn, { attributes: true }); | |
| } | |
| } | |
| // Sync quick prompt to main prompt | |
| function syncToMainPrompt() { | |
| const quickPrompt = document.getElementById('quickPrompt'); | |
| const mainPrompt = document.getElementById('prompt'); | |
| if (quickPrompt && mainPrompt) { | |
| mainPrompt.value = quickPrompt.value; | |
| } | |
| } | |
| // Quick Dock functionality for narrow screens | |
| function generateFromDock() { | |
| const dockPrompt = document.getElementById('dockPrompt'); | |
| const mainPrompt = document.getElementById('prompt'); | |
| if (!dockPrompt.value.trim()) { | |
| showToast('请输入提示词', 'error'); | |
| return; | |
| } | |
| // Copy dock prompt to main prompt | |
| mainPrompt.value = dockPrompt.value; | |
| // Switch to current results tab | |
| switchTab('current'); | |
| // Trigger generation | |
| generateEdit(); | |
| // Clear dock input after generation starts | |
| setTimeout(() => { | |
| dockPrompt.value = ''; | |
| }, 500); | |
| } | |
| // Enhanced drawer functions with dock visibility control | |
| function toggleDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| const quickDock = document.querySelector('.quick-dock'); | |
| if (drawer.classList.contains('open')) { | |
| closeDrawer(); | |
| } else { | |
| openDrawer(); | |
| } | |
| } | |
| function openDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| const quickDock = document.querySelector('.quick-dock'); | |
| drawer.classList.add('open'); | |
| overlay.classList.add('show'); | |
| document.body.classList.add('drawer-open'); | |
| // Hide quick dock when drawer is open | |
| if (quickDock) { | |
| quickDock.style.display = 'none'; | |
| } | |
| } | |
| function closeDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| const quickDock = document.querySelector('.quick-dock'); | |
| drawer.classList.remove('open'); | |
| overlay.classList.remove('show'); | |
| document.body.classList.remove('drawer-open'); | |
| // Restore quick dock visibility on narrow screens only | |
| if (quickDock && window.innerWidth <= 767) { | |
| quickDock.style.display = 'flex'; | |
| } | |
| } | |
| // Auto-show drawer on first visit for narrow screens only | |
| document.addEventListener('DOMContentLoaded', () => { | |
| if (window.innerWidth <= 767 && !localStorage.getItem('seenDrawer')) { | |
| setTimeout(() => { | |
| openDrawer(); | |
| localStorage.setItem('seenDrawer', '1'); | |
| showToast('👈 在侧栏中可以调整更多参数', 'info', 3000); | |
| }, 1000); | |
| } | |
| }); | |
| // Handle Enter key in dock input | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const dockPrompt = document.getElementById('dockPrompt'); | |
| if (dockPrompt) { | |
| dockPrompt.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| generateFromDock(); | |
| } | |
| }); | |
| } | |
| }); | |
| // Drawer functionality for complete mobile experience | |
| function toggleDrawerApiConfig() { | |
| const content = document.getElementById('drawerApiContent'); | |
| const toggle = document.querySelector('#drawerApiCard .toggle-icon'); | |
| if (content.style.display === 'none') { | |
| content.style.display = 'block'; | |
| toggle.textContent = '▲'; | |
| } else { | |
| content.style.display = 'none'; | |
| toggle.textContent = '▼'; | |
| } | |
| } | |
| function syncAndTestApiKey() { | |
| const drawerKey = document.getElementById('drawerApiKey').value; | |
| const mainKey = document.getElementById('apiKey'); | |
| // Sync to main interface | |
| mainKey.value = drawerKey; | |
| // Trigger test on main interface | |
| const testBtn = document.getElementById('testApiKey'); | |
| if (testBtn) testBtn.click(); | |
| } | |
| function syncModelSelection() { | |
| const drawerModel = document.getElementById('drawerModelSelect').value; | |
| const mainModel = document.getElementById('modelSelect'); | |
| // Sync to main interface | |
| mainModel.value = drawerModel; | |
| // Trigger model change event | |
| const event = new Event('change'); | |
| mainModel.dispatchEvent(event); | |
| } | |
| function handleDrawerFileInput() { | |
| const drawerInput = document.getElementById('drawerFileInput'); | |
| const mainInput = document.getElementById('fileInput'); | |
| // Copy files to main input | |
| mainInput.files = drawerInput.files; | |
| // Trigger main file input change event | |
| const event = new Event('change'); | |
| mainInput.dispatchEvent(event); | |
| } | |
| function generateFromDrawer() { | |
| // Sync SDE data from drawer if in SDE mode | |
| if (document.getElementById('enableSDE').checked) { | |
| syncSDEFromDrawer(); | |
| } else { | |
| // Sync traditional prompt | |
| const drawerPrompt = document.getElementById('drawerPrompt').value; | |
| const mainPrompt = document.getElementById('prompt'); | |
| mainPrompt.value = drawerPrompt; | |
| } | |
| // Close drawer and switch to results tab | |
| closeDrawer(); | |
| switchTab('current'); | |
| // Trigger generation | |
| generateEdit(); | |
| } | |
| // Sync data between drawer and main interface when opening/closing | |
| function openDrawer() { | |
| const drawer = document.getElementById('drawer'); | |
| const overlay = document.querySelector('.drawer-overlay'); | |
| const quickDock = document.querySelector('.quick-dock'); | |
| // Sync current data to drawer before opening | |
| syncMainToDrawer(); | |
| drawer.classList.add('open'); | |
| overlay.classList.add('show'); | |
| document.body.classList.add('drawer-open'); | |
| // Hide quick dock when drawer is open | |
| if (quickDock) { | |
| quickDock.style.display = 'none'; | |
| } | |
| } | |
| function syncMainToDrawer() { | |
| // Sync API key | |
| const mainKey = document.getElementById('apiKey').value; | |
| const drawerKey = document.getElementById('drawerApiKey'); | |
| if (drawerKey) drawerKey.value = mainKey; | |
| // Sync model selection | |
| const mainModel = document.getElementById('modelSelect').value; | |
| const drawerModel = document.getElementById('drawerModelSelect'); | |
| if (drawerModel) drawerModel.value = mainModel; | |
| // Sync prompt | |
| const mainPrompt = document.getElementById('prompt').value; | |
| const drawerPrompt = document.getElementById('drawerPrompt'); | |
| if (drawerPrompt) drawerPrompt.value = mainPrompt; | |
| } | |
| // Settings Modal Functions | |
| function toggleSettingsModal() { | |
| const modal = document.getElementById('settingsModal'); | |
| if (modal) { | |
| modal.classList.toggle('show'); | |
| } | |
| } | |
| function closeSettingsModal(event) { | |
| const modal = document.getElementById('settingsModal'); | |
| if (modal && (event === undefined || event.target === modal)) { | |
| modal.classList.remove('show'); | |
| } | |
| } | |
| // Close modal on Escape key | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape') { | |
| closeSettingsModal(); | |
| } | |
| }); | |
| // ============================= // | |
| // Video Generation Support (WAN 2.2 / 2.5) // | |
| // ============================= // | |
| function updateVideoPriceEstimate() { | |
| const modelValue = modelSelect.value; | |
| const priceValueEl = document.getElementById('videoPriceValue'); | |
| if (!priceValueEl) return; | |
| const isWan25 = modelValue.includes('wan-25-preview'); | |
| const isWan22 = modelValue.includes('wan/v2.2'); | |
| if (isWan25) { | |
| // WAN 2.5 pricing: based on resolution and duration | |
| const resolution = document.getElementById('videoResolution')?.value || '1080p'; | |
| const duration = parseInt(document.getElementById('videoDuration')?.value || '5'); | |
| const resolutionPrices = { | |
| '480p': 0.05, | |
| '720p': 0.10, | |
| '1080p': 0.15 | |
| }; | |
| const pricePerSecond = resolutionPrices[resolution] || 0.10; | |
| const totalPrice = (pricePerSecond * duration).toFixed(2); | |
| priceValueEl.textContent = `$${totalPrice} (${resolution} × ${duration}s)`; | |
| } else if (isWan22) { | |
| // WAN 2.2 pricing: based on video seconds calculated from frames/fps | |
| // FAL uses 16 FPS for billing calculation | |
| const numFrames = parseInt(document.getElementById('videoNumFrames')?.value || 81); | |
| const fps = parseInt(document.getElementById('videoFPS')?.value || 16); | |
| const resolution = document.getElementById('videoResolution')?.value || '720p'; | |
| // Prevent division by zero and handle invalid values | |
| if (isNaN(numFrames) || isNaN(fps) || numFrames <= 0 || fps <= 0) { | |
| priceValueEl.textContent = '--'; | |
| return; | |
| } | |
| const videoSeconds = numFrames / fps; | |
| const billingSeconds = numFrames / 16; // FAL uses 16 FPS for billing | |
| const resolutionRates = { | |
| '480p': 0.04, | |
| '580p': 0.06, | |
| '720p': 0.08 | |
| }; | |
| const rate = resolutionRates[resolution] || 0.06; | |
| const totalPrice = (rate * billingSeconds).toFixed(2); | |
| priceValueEl.textContent = `$${totalPrice} (${resolution}, ~${videoSeconds.toFixed(1)}s 实际 / ${billingSeconds.toFixed(1)}s 计费@16FPS)`; | |
| } | |
| } | |
| function isVideoModel(modelValue) { | |
| return modelValue.includes('wan-25-preview') || modelValue.includes('wan/v2.2'); | |
| } | |
| function buildVideoParams() { | |
| const modelValue = modelSelect.value; | |
| const isWan25 = modelValue.includes('wan-25-preview'); | |
| const isWan22 = modelValue.includes('wan/v2.2'); | |
| const params = {}; | |
| // Common video params | |
| const resolution = document.getElementById('videoResolution')?.value; | |
| if (resolution) params.resolution = resolution; | |
| const negativePrompt = document.getElementById('videoNegativePrompt')?.value; | |
| if (negativePrompt) params.negative_prompt = negativePrompt; | |
| if (isWan25) { | |
| // WAN 2.5 specific params | |
| const duration = document.getElementById('videoDuration')?.value; | |
| if (duration) params.duration = duration; | |
| const audioUrl = document.getElementById('videoAudioUrl')?.value; | |
| if (audioUrl) params.audio_url = audioUrl; | |
| params.enable_prompt_expansion = true; // WAN 2.5 defaults to true | |
| } else if (isWan22) { | |
| // WAN 2.2 specific params | |
| const fps = document.getElementById('videoFPS')?.value; | |
| if (fps) params.frames_per_second = parseInt(fps); | |
| const numFrames = document.getElementById('videoNumFrames')?.value; | |
| if (numFrames) params.num_frames = parseInt(numFrames); | |
| const safetyCheckerEl = document.getElementById('videoSafetyChecker'); | |
| params.enable_safety_checker = safetyCheckerEl ? safetyCheckerEl.checked : true; | |
| } | |
| return params; | |
| } | |
| function downloadVideo(videoUrl, filename) { | |
| const link = document.createElement('a'); | |
| link.href = videoUrl; | |
| link.download = `${filename || 'video'}-${Date.now()}.mp4`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| showToast('视频下载已开始', 'success', 2000); | |
| } | |