| |
| let uploadedImages = []; |
| let generationHistory = []; |
| let currentGeneration = null; |
| let activeTab = 'current'; |
| let currentGenerationAbort = null; |
| let lastGenerationTime = 0; |
| let generationInProgress = false; |
|
|
| |
| class StatusManager { |
| static show(message, type = 'info', persistent = false) { |
| |
| this.hideAll(); |
|
|
| const statusEl = document.getElementById('statusMessage'); |
| statusEl.textContent = message; |
| statusEl.className = `status-message ${type}`; |
| statusEl.style.display = 'block'; |
|
|
| |
| if (!persistent && type !== 'error') { |
| setTimeout(() => { |
| statusEl.style.display = 'none'; |
| }, type === 'success' ? 2000 : 3000); |
| } |
| } |
|
|
| static hideAll() { |
| |
| const statusEl = document.getElementById('statusMessage'); |
| if (statusEl) statusEl.style.display = 'none'; |
|
|
| |
| document.querySelectorAll('.toast, .banner').forEach(el => el.remove()); |
| } |
|
|
| static showProgress(message) { |
| this.show(message, 'info', true); |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| 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); |
|
|
| |
| requestAnimationFrame(() => { |
| toast.classList.add('show'); |
| }); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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 = '生成图像'; |
| } |
| } |
|
|
| |
| function showStatus(message, type = 'info', persistent = false) { |
| StatusManager.show(message, type, persistent); |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| const HISTORY_KEY = 'seedream_generation_history'; |
|
|
| |
| 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 = []; |
| } |
| } |
|
|
| |
| function saveHistory() { |
| try { |
| |
| if (generationHistory.length > 100) { |
| generationHistory = generationHistory.slice(-100); |
| } |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(generationHistory)); |
| updateHistoryCount(); |
| } catch (error) { |
| console.error('Error saving history:', error); |
| } |
| } |
|
|
| |
| 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'); |
|
|
| |
| 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); |
|
|
| |
| function switchTab(tabName) { |
| activeTab = tabName; |
| |
| |
| document.querySelectorAll('.tab-btn').forEach(btn => { |
| btn.classList.remove('active'); |
| }); |
| event.target.classList.add('active'); |
| |
| |
| 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(); |
| } |
| } |
|
|
| |
| function toggleSettings() { |
| const isCollapsed = settingsCard.classList.contains('collapsed'); |
| const toggleBtn = document.querySelector('.settings-toggle-btn'); |
|
|
| settingsCard.classList.toggle('collapsed'); |
|
|
| |
| 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'); |
|
|
| |
| 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'); |
|
|
| |
| toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'true' : 'false'); |
| } |
|
|
| |
| function handleImageSizeChange() { |
| if (imageSizeSelect.value === 'custom') { |
| customSizeElements.forEach(el => el.style.display = 'block'); |
| } else { |
| customSizeElements.forEach(el => el.style.display = 'none'); |
| } |
| } |
|
|
| |
| 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'); |
|
|
| |
| 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'; |
| } |
|
|
| |
| 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'); |
|
|
| |
| 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 = '生成图像'; |
| } |
|
|
| |
| 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); |
| } |
| }); |
| } |
| } |
|
|
| |
| function setupPasteUpload() { |
| document.addEventListener('paste', async (e) => { |
| |
| const activeElement = document.activeElement; |
| if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { |
| return; |
| } |
|
|
| const items = e.clipboardData?.items; |
| if (!items) return; |
|
|
| const imageFiles = []; |
| const textItems = []; |
|
|
| |
| for (let i = 0; i < items.length; i++) { |
| const item = items[i]; |
|
|
| if (item.type.indexOf('image') === 0) { |
| |
| const file = item.getAsFile(); |
| if (file) { |
| imageFiles.push(file); |
| } |
| } else if (item.type === 'text/plain') { |
| |
| item.getAsString((text) => { |
| textItems.push(text); |
| }); |
| } |
| } |
|
|
| |
| 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'); |
| } |
| } |
|
|
| |
| 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); |
| }); |
| } |
|
|
| |
| function setupExtendedDragDrop() { |
| const dropZones = [ |
| document.getElementById('historyGrid'), |
| document.getElementById('currentResults'), |
| document.querySelector('.right-panel'), |
| document.querySelector('.empty-state') |
| ].filter(Boolean); |
|
|
| dropZones.forEach(zone => { |
| zone.addEventListener('dragover', handleDragOver); |
| zone.addEventListener('dragenter', handleDragEnter); |
| zone.addEventListener('dragleave', handleDragLeave); |
| zone.addEventListener('drop', handleDrop); |
| }); |
|
|
| |
| 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(); |
|
|
| |
| 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'); |
| } |
| } |
|
|
| |
| 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'); |
| } |
| } |
| } |
|
|
| |
| window.addEventListener('DOMContentLoaded', () => { |
| |
| const savedKey = localStorage.getItem('fal_api_key'); |
| if (savedKey) { |
| document.getElementById('apiKey').value = savedKey; |
| } |
|
|
| |
| loadTextareaSizes(); |
|
|
| |
| loadSDEPreferences(); |
|
|
| |
| const enableSDECheckbox = document.getElementById('enableSDE'); |
| if (enableSDECheckbox) { |
| enableSDECheckbox.addEventListener('change', syncSDEMode); |
| } |
|
|
| |
| handleImageSizeChange(); |
| handleModelChange(); |
| loadHistory(); |
| setupPasteUpload(); |
| setupExtendedDragDrop(); |
| initializeKeyboardShortcuts(); |
| initializeAccessibility(); |
| displayHistory(); |
|
|
| |
| if (!savedKey && !localStorage.getItem('hasSeenApiKeyGuide')) { |
| |
| settingsCard.classList.remove('collapsed'); |
|
|
| |
| const apiKeyInput = document.getElementById('apiKey'); |
| apiKeyInput.style.animation = 'pulse-highlight 2s ease-in-out 3'; |
|
|
| |
| showToast('👋 欢迎使用SeedDream!请先配置FAL API密钥以开始生成图像', 'info', 0); |
|
|
| |
| setTimeout(() => { |
| apiKeyInput.focus(); |
| apiKeyInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| }, 500); |
|
|
| |
| apiKeyInput.addEventListener('blur', () => { |
| if (apiKeyInput.value.trim()) { |
| localStorage.setItem('hasSeenApiKeyGuide', '1'); |
| } |
| }); |
| } else { |
| |
| settingsCard.classList.add('collapsed'); |
| } |
| }); |
|
|
| |
| 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}`; |
| |
| |
| 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(); |
|
|
| |
| 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 = ''; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| function removeImage(index) { |
| uploadedImages.splice(index, 1); |
| renderImagePreviews(); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| function updateCustomSizeFromLastImage() { |
| if (uploadedImages.length > 0) { |
| const lastImage = uploadedImages[uploadedImages.length - 1]; |
| let width = lastImage.width; |
| let height = lastImage.height; |
| |
| |
| const aspectRatio = width / height; |
| |
| |
| if (width < 1024 && height < 1024) { |
| |
| if (width < height) { |
| |
| width = 1024; |
| height = Math.round(1024 / aspectRatio); |
| } else { |
| |
| height = 1024; |
| width = Math.round(1024 * aspectRatio); |
| } |
| } |
| |
| |
| if (width > 4096 || height > 4096) { |
| if (width > height) { |
| |
| const scaleFactor = 4096 / width; |
| width = 4096; |
| height = Math.round(height * scaleFactor); |
| } else { |
| |
| const scaleFactor = 4096 / height; |
| height = 4096; |
| width = Math.round(width * scaleFactor); |
| } |
| } |
| |
| |
| |
| if (width < 1024) { |
| const scaleFactor = 1024 / width; |
| width = 1024; |
| height = Math.round(height * scaleFactor); |
| } |
| |
| |
| if (height < 1024) { |
| const scaleFactor = 1024 / height; |
| height = 1024; |
| width = Math.round(width * scaleFactor); |
| } |
| |
| |
| 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(); |
| } |
| } |
| } |
|
|
| |
| function renderImagePreviews() { |
| imagePreview.innerHTML = ''; |
| uploadedImages.forEach((image, index) => { |
| addImagePreview(image.src, index); |
| }); |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| function clearLogs() { |
| progressLogs.innerHTML = ''; |
| progressLogs.classList.remove('active'); |
| } |
|
|
| |
| function getImageSize() { |
| const size = imageSizeSelect.value; |
| if (size === 'custom') { |
| return { |
| width: parseInt(document.getElementById('customWidth').value), |
| height: parseInt(document.getElementById('customHeight').value) |
| }; |
| } |
| return size; |
| } |
|
|
| |
| async function uploadImageToFal(imageData, apiKey, imageIndex, totalImages, actualIndex) { |
| try { |
| |
| 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'); |
| } |
| } |
| |
| |
| updateUploadProgress(imageIndex - 1, totalImages, `Uploading image ${imageIndex}/${totalImages}...`); |
| |
| |
| addLog(`正在上传图像 ${imageIndex}/${totalImages} 到FAL存储...`); |
| |
| |
| 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} 上传成功`); |
| |
| |
| updateUploadProgress(imageIndex, totalImages, `Completed ${imageIndex}/${totalImages}`); |
| |
| |
| 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'); |
| |
| |
| 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}`); |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| 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}%)`; |
| |
| |
| if (percentage === 100) { |
| progressFill.classList.add('complete'); |
| |
| |
| setTimeout(() => { |
| |
| container.style.transition = 'opacity 0.5s ease'; |
| container.style.opacity = '0'; |
| |
| |
| setTimeout(() => { |
| if (container.parentNode) { |
| container.parentNode.removeChild(container); |
| } |
| }, 500); |
| }, 1500); |
| } else { |
| |
| container.style.opacity = '1'; |
| } |
| } |
| } |
|
|
| |
| async function getImageUrlsForAPI() { |
| const urls = []; |
| const apiKey = getAPIKey(); |
|
|
| |
| const base64Images = uploadedImages.filter(img => img.src.startsWith('data:')); |
| const urlImages = uploadedImages.filter(img => !img.src.startsWith('data:')); |
|
|
| |
| 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'); |
| } |
|
|
| |
| const uploadPromises = uploadedImages.map(async (image, index) => { |
| if (image.src.startsWith('data:')) { |
| |
| 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 { |
| |
| addLog(`使用现有URL作为图像 ${index + 1}`); |
| return { success: true, url: image.src, index }; |
| } |
| }); |
|
|
| |
| const results = await Promise.allSettled(uploadPromises); |
|
|
| |
| 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}`); |
| } |
| } |
| }); |
|
|
| |
| 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} 张图像已准备好生成`); |
| } |
| |
| |
| 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); |
| } |
|
|
|
|
| |
| async function generateEdit() { |
| |
| const now = Date.now(); |
| if (now - lastGenerationTime < 500) { |
| showToast('请勿频繁点击生成按钮', 'warning', 2000); |
| return; |
| } |
| lastGenerationTime = now; |
|
|
| |
| 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'); |
|
|
| |
| const base64Images = uploadedImages.filter(img => img.src.startsWith('data:')); |
| const totalUploads = base64Images.length; |
| |
| |
| 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> |
| `; |
| |
| if (statusMessage.parentNode) { |
| statusMessage.parentNode.insertBefore(progressContainer, statusMessage.nextSibling); |
| } |
| } |
| |
| |
| let imageUrlsArray; |
| try { |
| imageUrlsArray = await getImageUrlsForAPI(); |
| } catch (error) { |
| |
| const pc = document.getElementById('uploadProgressContainer'); |
| if (pc && pc.parentNode) { |
| pc.parentNode.removeChild(pc); |
| } |
| addLog(`上传错误: ${error.message || error}`); |
| showStatus(`上传错误: ${error.message || error}`, 'error'); |
| return; |
| } |
| |
| 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'; |
|
|
| |
| 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'; |
| } |
| |
| |
| currentResults.innerHTML = '<div class="empty-state"><p>准备生成...</p></div>'; |
| currentInfo.innerHTML = ''; |
| clearLogs(); |
| |
| showStatus('开始生成进程...', 'info'); |
| progressLogs.classList.add('active'); |
| |
| |
| if (!isTextToImage && imageUrlsArray.length > 0) { |
| addLog(`正在处理 ${imageUrlsArray.length} 张输入图像...`); |
| } |
| |
| const requestData = { |
| prompt: prompt |
| }; |
|
|
| if (isVideo) { |
| |
| if (isVideoI2V && imageUrlsArray.length > 0) { |
| requestData.image_url = imageUrlsArray[0]; |
| } |
| |
| Object.assign(requestData, buildVideoParams()); |
| } else { |
| |
| 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); |
| } |
| |
| |
| 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 { |
| |
| if (currentGenerationAbort) { |
| currentGenerationAbort.abort(); |
| addLog('已取消前一次生成请求'); |
| } |
|
|
| |
| 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); |
| |
| |
| currentGeneration.results = response; |
| |
| |
| displayCurrentResults(response); |
| |
| |
| 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'; |
|
|
| |
| 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'; |
| } |
|
|
| |
| currentGenerationAbort = null; |
| |
| const pc2 = document.getElementById('uploadProgressContainer'); |
| if (pc2 && pc2.parentNode) { |
| pc2.parentNode.removeChild(pc2); |
| } |
| } |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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}`); |
| |
| |
| let attempts = 0; |
| const maxAttempts = 120; |
| let delay = 800; |
| let previousLogCount = 0; |
|
|
| while (attempts < maxAttempts) { |
| |
| 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, |
| keepalive: true |
| }); |
|
|
| let statusData; |
| try { |
| statusData = await statusResponse.json(); |
| } catch (_) { |
| statusData = {}; |
| } |
|
|
| if (!statusResponse.ok) { |
| const errorMsg = statusData?.error || `HTTP ${statusResponse.status}`; |
|
|
| |
| if (statusResponse.status >= 500 || statusResponse.status === 429) { |
| addLog(`服务器暂时不可用 (${statusResponse.status}),将重试...`); |
| attempts++; |
| delay = Math.min(delay * 1.5, 4000); |
| continue; |
| } |
|
|
| addLog(`状态查询失败: ${errorMsg}`, 'error'); |
| throw new Error('Failed to check request status'); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| const status = (statusData.status || '').toUpperCase(); |
|
|
| if (status === 'COMPLETED') { |
| return statusData.result; |
| } else if (status === 'ERROR') { |
| throw new Error(statusData.error || 'Generation failed'); |
| } |
|
|
| attempts++; |
|
|
| |
| delay = Math.min(delay * 1.35, 4000); |
|
|
| if (attempts % 5 === 0) { |
| addLog(`处理中... (已轮询 ${attempts} 次,下次等待 ${Math.round(delay/1000)}s)`); |
| } |
| } |
|
|
| throw new Error('Request timed out after maximum attempts'); |
| } |
|
|
| |
| function displayCurrentResults(response) { |
| |
| 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; |
| } |
|
|
| |
| 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); |
| }); |
|
|
| |
| if (response.seed) { |
| currentInfo.innerHTML = `<strong>随机种子:</strong> ${response.seed}`; |
| addLog(`使用的随机种子: ${response.seed}`); |
| } |
|
|
| addLog(`已生成 ${response.images.length} 张图像`); |
| } |
|
|
| |
| 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 = ''; |
|
|
| |
| [...generationHistory].reverse().forEach((generation) => { |
| if (!generation.results) return; |
|
|
| |
| 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); |
| }); |
| } |
| }); |
| } |
|
|
| |
| 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'); |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
| }); |
| } |
|
|
| |
| |
| |
|
|
| |
| const SDE_TEMPLATES = { |
| single_female: `使用我提供的参考照片,生成一张包含其中真人女性的图像。她的相貌、体型和特征必须与参考照片中的人物完全一致。接下来的描述将把这位女性置于一个新的、详细的的场景中:`, |
| multiple_female: `使用我提供的多张参考照片,生成一张包含其中多位真人女性的图像。她们各自的相貌、体型和特征必须与对应的参考照片完全一致。接下来的描述将把她们置于一个新的、详细的的场景中:`, |
| mixed_gender: `使用我提供的多张参考照片,生成一张包含其中多位真人的图像。其中,女性参考照片对应场景中的女主角,男性参考照片对应场景中的男性。他们各自的相貌、体型和特征必须与对应的参考照片完全一致。接下来的描述将把他们置于一个新的、详细的的场景中:`, |
| multi_character: `使用我提供的多张参考照片,生成一张包含其中多位真人的图像。他们各自的相貌、体型和特征必须与对应的参考照片完全一致。接下来的描述将把他们置于一个新的、详细的(动作和姿势参考动漫插图)的场景中:` |
| }; |
|
|
| const GSP_STYLE_TEXT = `由于是随意抓拍,构图混乱且角度尴尬,没有明确的主体。房间里不均匀的灯光导致画面部分区域曝光不均,同时带有轻微的运动模糊和强烈的数字噪点。整体呈现出一种刻意平庸的、混乱且私密的纪实感。`; |
|
|
| |
| 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); |
| } |
|
|
| |
| function toggleSDEMode(enabled) { |
| const traditionalMode = document.getElementById('traditionalPromptMode'); |
| const structuredMode = document.getElementById('structuredPromptMode'); |
| const drawerTraditionalMode = document.getElementById('drawerTraditionalMode'); |
| const drawerStructuredMode = document.getElementById('drawerStructuredMode'); |
|
|
| |
| if (enabled) { |
| |
| const traditionalPrompt = document.getElementById('prompt').value; |
| if (traditionalPrompt.trim().length > 0) { |
| const confirmed = confirm( |
| '切换到结构化编辑器将替换当前提示词。是否继续?\n\n' + |
| '当前内容将保存在草稿中,可通过"恢复草稿"找回。' |
| ); |
|
|
| if (!confirmed) { |
| |
| const mainCheckbox = document.getElementById('enableSDE'); |
| const drawerCheckbox = document.getElementById('drawerEnableSDE'); |
| if (mainCheckbox) mainCheckbox.checked = false; |
| if (drawerCheckbox) drawerCheckbox.checked = false; |
| return; |
| } |
|
|
| |
| 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 { |
| |
| const sceneDescription = document.getElementById('sceneDescription').value; |
| if (sceneDescription.trim().length > 0) { |
| const confirmed = confirm( |
| '切换到传统模式将清空结构化编辑器内容。是否继续?\n\n' + |
| '当前内容将保存在草稿中,可通过"恢复草稿"找回。' |
| ); |
|
|
| if (!confirmed) { |
| |
| const mainCheckbox = document.getElementById('enableSDE'); |
| const drawerCheckbox = document.getElementById('drawerEnableSDE'); |
| if (mainCheckbox) mainCheckbox.checked = true; |
| if (drawerCheckbox) drawerCheckbox.checked = true; |
| return; |
| } |
|
|
| |
| 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()); |
| } |
|
|
| |
| function restoreDraft() { |
| const enableSDE = document.getElementById('enableSDE').checked; |
|
|
| if (enableSDE) { |
| |
| 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 { |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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 : ''; |
| } |
| } |
|
|
| |
| function loadSDEPreferences() { |
| const savedEnabled = localStorage.getItem('sde-enabled'); |
| if (savedEnabled === 'true') { |
| document.getElementById('enableSDE').checked = true; |
| document.getElementById('drawerEnableSDE').checked = true; |
| toggleSDEMode(true); |
| } |
| } |
|
|
| |
| function copyPromptFromHistory(prompt, event) { |
| event.stopPropagation(); |
|
|
| |
| const decodedPrompt = prompt.replace(/\\'/g, "'").replace(/"/g, '"').replace(/&/g, '&'); |
|
|
| navigator.clipboard.writeText(decodedPrompt).then(() => { |
| |
| StatusManager.show('提示词已复制到剪贴板', 'success'); |
|
|
| |
| 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'); |
| }); |
| } |
|
|
| |
| async function useAsInput(imageId, imageSrc) { |
| try { |
| |
| 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; |
| } |
| |
| |
| 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 { |
| |
| 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 张图像)`); |
| |
| |
| imagePreview.style.animation = 'flash 0.5s'; |
| setTimeout(() => { |
| imagePreview.style.animation = ''; |
| }, 500); |
| |
| } catch (error) { |
| console.error('Error using image as input:', error); |
| showStatus('添加图像作为输入失败', 'error'); |
| } |
| } |
|
|
| |
| function clearAllInputImages() { |
| uploadedImages = []; |
| renderImagePreviews(); |
| showStatus('所有输入图像已清除', 'info'); |
| } |
|
|
| |
| function clearHistory() { |
| if (confirm('确定要清除所有生成历史吗?此操作无法撤销。')) { |
| generationHistory = []; |
| localStorage.removeItem(HISTORY_KEY); |
| displayHistory(); |
| updateHistoryCount(); |
| showStatus('历史已清除', 'info'); |
| } |
| } |
|
|
| |
| function downloadAllHistory() { |
| if (generationHistory.length === 0) { |
| showStatus('无历史可下载', 'error'); |
| return; |
| } |
| |
| |
| 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'); |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| let currentModalImage = null; |
|
|
| function openImageModal(imageId, imageSrc, prompt, timestamp) { |
| const modal = document.getElementById('imageModal'); |
| const modalImg = document.getElementById('modalImage'); |
| const modalCaption = document.getElementById('modalCaption'); |
|
|
| |
| const triggerElement = document.activeElement; |
| currentModalImage = { id: imageId, src: imageSrc, triggerElement }; |
|
|
| |
| modalImg.style.transform = 'scale(1)'; |
| modalImg.style.transformOrigin = 'center'; |
|
|
| |
| setupImageZoom(modalImg); |
|
|
| |
| modalImg.src = imageSrc; |
| modalCaption.innerHTML = ` |
| <strong>生成时间:</strong> ${timestamp}<br> |
| <strong>提示词:</strong> ${prompt} |
| `; |
| |
| |
| modal.classList.add('show'); |
|
|
| |
| document.body.style.overflow = 'hidden'; |
|
|
| |
| setTimeout(() => { |
| const closeBtn = modal.querySelector('.modal-close'); |
| if (closeBtn) closeBtn.focus(); |
| }, 100); |
| |
| |
| document.addEventListener('keydown', handleModalEscape); |
| |
| |
| 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; |
|
|
| |
| 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; |
| } |
| }); |
|
|
| |
| 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 }); |
|
|
| |
| let initialDistance = 0; |
| let initialScale = 1; |
|
|
| img.addEventListener('touchstart', (e) => { |
| if (e.touches.length === 2) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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; |
| }); |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| 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', '显示密钥'); |
| } |
| } |
|
|
| |
| 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 { |
| |
| 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 = '测试'; |
|
|
| |
| setTimeout(() => { |
| if (statusDiv.classList.contains('success')) { |
| statusDiv.style.display = 'none'; |
| statusDiv.className = 'api-key-status'; |
| } |
| }, 5000); |
| } |
| } |
|
|
| |
| function showApiKeyStatus(message, type) { |
| const statusDiv = document.getElementById('apiKeyStatus'); |
| statusDiv.className = `api-key-status ${type}`; |
| statusDiv.textContent = message; |
| statusDiv.style.display = 'block'; |
| } |
|
|
| |
| function setupIOSKeyboardHandling() { |
| const prompt = document.getElementById('prompt'); |
| const appContainer = document.querySelector('.app-container'); |
|
|
| if (!prompt || !appContainer) return; |
|
|
| |
| 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); |
|
|
| |
| 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'); |
|
|
| |
| document.body.style.overflow = 'hidden'; |
|
|
| |
| document.addEventListener('keydown', handleDrawerEscape); |
|
|
| |
| addSwipeGestures(); |
| } |
|
|
| function closeDrawer() { |
| const drawer = document.getElementById('drawer'); |
| const overlay = document.querySelector('.drawer-overlay'); |
|
|
| drawer.classList.remove('open'); |
| overlay.classList.remove('show'); |
|
|
| |
| document.body.classList.remove('drawer-open'); |
|
|
| |
| document.removeEventListener('keydown', handleDrawerEscape); |
|
|
| |
| removeSwipeGestures(); |
| } |
|
|
| function handleDrawerEscape(event) { |
| if (event.key === 'Escape') { |
| closeDrawer(); |
| } |
| } |
|
|
| |
| 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; |
|
|
| |
| 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)`; |
|
|
| |
| event.preventDefault(); |
| } |
| } |
|
|
| function handleTouchEnd(event) { |
| if (!isDragging) return; |
|
|
| const drawer = document.getElementById('drawer'); |
| const deltaX = currentX - startX; |
| const threshold = drawer.offsetWidth * 0.3; |
|
|
| |
| drawer.style.transform = ''; |
|
|
| |
| if (deltaX < -threshold) { |
| closeDrawer(); |
| } |
|
|
| isDragging = false; |
| } |
|
|
| |
| function initializeKeyboardShortcuts() { |
| document.addEventListener('keydown', (e) => { |
| |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| const generateBtn = document.getElementById('generateBtn'); |
| if (!generateBtn.disabled && !generateBtn.classList.contains('loading')) { |
| generate(); |
| } |
| } |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| if ((e.metaKey || e.ctrlKey) && e.key === 'k') { |
| e.preventDefault(); |
| const promptInput = document.getElementById('prompt'); |
| promptInput.focus(); |
| promptInput.select(); |
| } |
|
|
| |
| if (e.key === 'Tab') { |
| document.body.classList.add('keyboard-nav'); |
| } |
| }); |
|
|
| |
| document.addEventListener('mousedown', () => { |
| document.body.classList.remove('keyboard-nav'); |
| }); |
| } |
|
|
| |
| function initializeAccessibility() { |
| |
| const statusMessage = document.getElementById('statusMessage'); |
| if (statusMessage) { |
| statusMessage.setAttribute('aria-live', 'polite'); |
| statusMessage.setAttribute('aria-atomic', 'true'); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| const helpText = input.parentElement.querySelector('.help-text'); |
| if (helpText && !helpText.id) { |
| const helpId = `help-${input.id}`; |
| helpText.id = helpId; |
| input.setAttribute('aria-describedby', helpId); |
| } |
| }); |
|
|
| |
| 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(); |
| } |
| }); |
| } |
| }); |
| }; |
|
|
| |
| 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 }); |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| function syncToMainPrompt() { |
| const quickPrompt = document.getElementById('quickPrompt'); |
| const mainPrompt = document.getElementById('prompt'); |
| if (quickPrompt && mainPrompt) { |
| mainPrompt.value = quickPrompt.value; |
| } |
| } |
|
|
| |
| function generateFromDock() { |
| const dockPrompt = document.getElementById('dockPrompt'); |
| const mainPrompt = document.getElementById('prompt'); |
|
|
| if (!dockPrompt.value.trim()) { |
| showToast('请输入提示词', 'error'); |
| return; |
| } |
|
|
| |
| mainPrompt.value = dockPrompt.value; |
|
|
| |
| switchTab('current'); |
|
|
| |
| generateEdit(); |
|
|
| |
| setTimeout(() => { |
| dockPrompt.value = ''; |
| }, 500); |
| } |
|
|
| |
| 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'); |
|
|
| |
| 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'); |
|
|
| |
| if (quickDock && window.innerWidth <= 767) { |
| quickDock.style.display = 'flex'; |
| } |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| if (window.innerWidth <= 767 && !localStorage.getItem('seenDrawer')) { |
| setTimeout(() => { |
| openDrawer(); |
| localStorage.setItem('seenDrawer', '1'); |
| showToast('👈 在侧栏中可以调整更多参数', 'info', 3000); |
| }, 1000); |
| } |
| }); |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| const dockPrompt = document.getElementById('dockPrompt'); |
| if (dockPrompt) { |
| dockPrompt.addEventListener('keypress', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| generateFromDock(); |
| } |
| }); |
| } |
| }); |
|
|
| |
| 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'); |
|
|
| |
| mainKey.value = drawerKey; |
|
|
| |
| const testBtn = document.getElementById('testApiKey'); |
| if (testBtn) testBtn.click(); |
| } |
|
|
| function syncModelSelection() { |
| const drawerModel = document.getElementById('drawerModelSelect').value; |
| const mainModel = document.getElementById('modelSelect'); |
|
|
| |
| mainModel.value = drawerModel; |
|
|
| |
| const event = new Event('change'); |
| mainModel.dispatchEvent(event); |
| } |
|
|
| function handleDrawerFileInput() { |
| const drawerInput = document.getElementById('drawerFileInput'); |
| const mainInput = document.getElementById('fileInput'); |
|
|
| |
| mainInput.files = drawerInput.files; |
|
|
| |
| const event = new Event('change'); |
| mainInput.dispatchEvent(event); |
| } |
|
|
| function generateFromDrawer() { |
| |
| if (document.getElementById('enableSDE').checked) { |
| syncSDEFromDrawer(); |
| } else { |
| |
| const drawerPrompt = document.getElementById('drawerPrompt').value; |
| const mainPrompt = document.getElementById('prompt'); |
| mainPrompt.value = drawerPrompt; |
| } |
|
|
| |
| closeDrawer(); |
| switchTab('current'); |
|
|
| |
| generateEdit(); |
| } |
|
|
| |
| function openDrawer() { |
| const drawer = document.getElementById('drawer'); |
| const overlay = document.querySelector('.drawer-overlay'); |
| const quickDock = document.querySelector('.quick-dock'); |
|
|
| |
| syncMainToDrawer(); |
|
|
| drawer.classList.add('open'); |
| overlay.classList.add('show'); |
| document.body.classList.add('drawer-open'); |
|
|
| |
| if (quickDock) { |
| quickDock.style.display = 'none'; |
| } |
| } |
|
|
| function syncMainToDrawer() { |
| |
| const mainKey = document.getElementById('apiKey').value; |
| const drawerKey = document.getElementById('drawerApiKey'); |
| if (drawerKey) drawerKey.value = mainKey; |
|
|
| |
| const mainModel = document.getElementById('modelSelect').value; |
| const drawerModel = document.getElementById('drawerModelSelect'); |
| if (drawerModel) drawerModel.value = mainModel; |
|
|
| |
| const mainPrompt = document.getElementById('prompt').value; |
| const drawerPrompt = document.getElementById('drawerPrompt'); |
| if (drawerPrompt) drawerPrompt.value = mainPrompt; |
| } |
|
|
| |
| 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'); |
| } |
| } |
|
|
| |
| document.addEventListener('keydown', function(e) { |
| if (e.key === 'Escape') { |
| closeSettingsModal(); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| 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) { |
| |
| 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) { |
| |
| |
| const numFrames = parseInt(document.getElementById('videoNumFrames')?.value || 81); |
| const fps = parseInt(document.getElementById('videoFPS')?.value || 16); |
| const resolution = document.getElementById('videoResolution')?.value || '720p'; |
|
|
| |
| if (isNaN(numFrames) || isNaN(fps) || numFrames <= 0 || fps <= 0) { |
| priceValueEl.textContent = '--'; |
| return; |
| } |
|
|
| const videoSeconds = numFrames / fps; |
| const billingSeconds = numFrames / 16; |
|
|
| 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 = {}; |
|
|
| |
| 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) { |
| |
| 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; |
| } else if (isWan22) { |
| |
| 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); |
| } |
|
|