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