// 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 => `` ).join(''); } toast.innerHTML = `
${getToastIcon(type)} ${getToastTitle(type)}
${message} ${actionButtons ? `
${actionButtons}
` : ''}
`; 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 = `

${file.name}

`; 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 = ` Upload ${index + 1} `; 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 = `
Initializing...
`; // 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 = '

准备生成...

'; 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 = ` `; currentResults.appendChild(item); if (response.seed) { currentInfo.innerHTML = `随机种子: ${response.seed}`; addLog(`使用的随机种子: ${response.seed}`); } addLog('视频生成完成'); return; } // Handle image results if (!response || !response.images || response.images.length === 0) { currentResults.innerHTML = '

未生成图像

'; 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 = ` Result ${index + 1} `; currentResults.appendChild(item); }); // Display generation info if (response.seed) { currentInfo.innerHTML = `随机种子: ${response.seed}`; addLog(`使用的随机种子: ${response.seed}`); } addLog(`已生成 ${response.images.length} 张图像`); } // Display history function displayHistory() { if (generationHistory.length === 0) { historyGrid.innerHTML = `

No generation history

Your generated images will be saved here
`; 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 = ` `; 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 = ` Generation `; 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 = ` 生成时间: ${timestamp}
提示词: ${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); }