// 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 = `
${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 = `
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 = '