Spaces:
Sleeping
Sleeping
| // 全局变量 | |
| let currentTheme = 'light'; | |
| let isGenerating = false; | |
| let currentApiKey = null; | |
| // 初始化 | |
| document.addEventListener('DOMContentLoaded', function() { | |
| console.log('页面加载完成,开始初始化...'); | |
| try { | |
| initializeTheme(); | |
| initializeEventListeners(); | |
| initializeRangeSliders(); | |
| // 先加载本地缓存的 API 密钥,避免未传 key 导致 401 | |
| loadLocalApiKey(); | |
| // 再检查后端密钥状态(环境变量/服务端存储) | |
| checkApiKeyStatus(); | |
| loadVideoLibrary(); | |
| console.log('初始化完成'); | |
| } catch (error) { | |
| console.error('初始化失败:', error); | |
| } | |
| }); | |
| // 主题切换功能 | |
| function initializeTheme() { | |
| const savedTheme = localStorage.getItem('theme') || 'light'; | |
| setTheme(savedTheme); | |
| } | |
| function setTheme(theme) { | |
| currentTheme = theme; | |
| document.documentElement.setAttribute('data-theme', theme); | |
| localStorage.setItem('theme', theme); | |
| const themeToggle = document.getElementById('themeToggle'); | |
| if (themeToggle) { | |
| const icon = themeToggle.querySelector('i'); | |
| if (icon) { | |
| if (theme === 'dark') { | |
| icon.className = 'fas fa-sun'; | |
| themeToggle.title = '切换到浅色模式'; | |
| } else { | |
| icon.className = 'fas fa-moon'; | |
| themeToggle.title = '切换到深色模式'; | |
| } | |
| } | |
| } | |
| } | |
| function toggleTheme() { | |
| const newTheme = currentTheme === 'light' ? 'dark' : 'light'; | |
| setTheme(newTheme); | |
| } | |
| // 从本地缓存读取 API 密钥并启用(不改后端) | |
| function loadLocalApiKey() { | |
| try { | |
| const cached = localStorage.getItem('fal_api_key'); | |
| if (cached && cached.length >= 10) { | |
| currentApiKey = cached; | |
| const apiKeyStatus = document.getElementById('apiKeyStatus'); | |
| const apiKeyButton = document.getElementById('apiKeyButton'); | |
| const modalApiKeyStatus = document.getElementById('modalApiKeyStatus'); | |
| if (apiKeyStatus) apiKeyStatus.textContent = '本地缓存'; | |
| if (apiKeyButton) apiKeyButton.classList.add('configured'); | |
| if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:本地缓存)'; | |
| } | |
| } catch (e) { | |
| console.warn('读取本地缓存的 API key 失败:', e); | |
| } | |
| } | |
| // API Key 管理功能 | |
| async function checkApiKeyStatus() { | |
| try { | |
| const response = await fetch('/api/check-key'); | |
| const data = await response.json(); | |
| updateApiKeyStatus(data); | |
| } catch (error) { | |
| console.error('检查 API key 状态失败:', error); | |
| updateApiKeyStatus({ hasStoredKey: false, hasEnvKey: false, keySource: 'none' }); | |
| } | |
| } | |
| function updateApiKeyStatus(status) { | |
| const { hasStoredKey, hasEnvKey, keySource } = status; | |
| const apiKeyStatus = document.getElementById('apiKeyStatus'); | |
| const apiKeyButton = document.getElementById('apiKeyButton'); | |
| const modalApiKeyStatus = document.getElementById('modalApiKeyStatus'); | |
| if (apiKeyStatus) { | |
| if (keySource === 'environment') { | |
| apiKeyStatus.textContent = '环境变量'; | |
| if (apiKeyButton) apiKeyButton.classList.add('configured'); | |
| if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:环境变量)'; | |
| } else if (keySource === 'stored') { | |
| apiKeyStatus.textContent = '已保存'; | |
| if (apiKeyButton) apiKeyButton.classList.add('configured'); | |
| if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:本地存储)'; | |
| } else { | |
| apiKeyStatus.textContent = '未配置'; | |
| if (apiKeyButton) apiKeyButton.classList.remove('configured'); | |
| if (modalApiKeyStatus) modalApiKeyStatus.textContent = '未配置 - 请输入 API 密钥'; | |
| } | |
| } | |
| } | |
| // 事件监听器初始化 | |
| function initializeEventListeners() { | |
| console.log('初始化事件监听器...'); | |
| // 主题切换 | |
| const themeToggle = document.getElementById('themeToggle'); | |
| if (themeToggle) { | |
| themeToggle.addEventListener('click', toggleTheme); | |
| console.log('主题切换按钮已绑定'); | |
| } | |
| // API Key 管理 | |
| const apiKeyButton = document.getElementById('apiKeyButton'); | |
| const apiKeyModal = document.getElementById('apiKeyModal'); | |
| const closeModal = document.getElementById('closeModal'); | |
| if (apiKeyButton && apiKeyModal) { | |
| apiKeyButton.addEventListener('click', () => { | |
| apiKeyModal.style.display = 'flex'; | |
| checkApiKeyStatus(); | |
| }); | |
| console.log('API Key 按钮已绑定'); | |
| } | |
| if (closeModal && apiKeyModal) { | |
| closeModal.addEventListener('click', () => { | |
| apiKeyModal.style.display = 'none'; | |
| }); | |
| // 点击模态框背景关闭 | |
| apiKeyModal.addEventListener('click', (e) => { | |
| if (e.target === apiKeyModal) { | |
| apiKeyModal.style.display = 'none'; | |
| } | |
| }); | |
| console.log('模态框关闭事件已绑定'); | |
| } | |
| // API Key 相关按钮 | |
| const toggleApiKeyVisibility = document.getElementById('toggleApiKeyVisibility'); | |
| const saveApiKeyButton = document.getElementById('saveApiKeyButton'); | |
| const testApiKeyButton = document.getElementById('testApiKeyButton'); | |
| if (toggleApiKeyVisibility) { | |
| toggleApiKeyVisibility.addEventListener('click', toggleApiKeyVisibilityFunc); | |
| } | |
| if (saveApiKeyButton) { | |
| saveApiKeyButton.addEventListener('click', saveApiKey); | |
| } | |
| if (testApiKeyButton) { | |
| testApiKeyButton.addEventListener('click', testApiKey); | |
| } | |
| // ESC 键关闭模态框 | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && apiKeyModal && apiKeyModal.style.display === 'flex') { | |
| apiKeyModal.style.display = 'none'; | |
| } | |
| }); | |
| // 标签页切换 | |
| const tabButtons = document.querySelectorAll('.tab-button'); | |
| tabButtons.forEach(button => { | |
| button.addEventListener('click', () => switchTab(button.dataset.tab)); | |
| }); | |
| console.log(`${tabButtons.length} 个标签按钮已绑定`); | |
| // 图片上传相关 | |
| const imageUploadArea = document.getElementById('imageUploadArea'); | |
| const imageFile = document.getElementById('imageFile'); | |
| const removeImage = document.getElementById('removeImage'); | |
| if (imageUploadArea && imageFile) { | |
| imageUploadArea.addEventListener('click', () => imageFile.click()); | |
| imageUploadArea.addEventListener('dragover', handleDragOver); | |
| imageUploadArea.addEventListener('drop', handleDrop); | |
| imageUploadArea.addEventListener('dragleave', handleDragLeave); | |
| imageFile.addEventListener('change', handleImageSelect); | |
| console.log('图片上传事件已绑定'); | |
| } | |
| if (removeImage) { | |
| removeImage.addEventListener('click', clearImagePreview); | |
| } | |
| // 表单提交 | |
| const imageToVideoForm = document.getElementById('imageToVideoForm'); | |
| const textToVideoForm = document.getElementById('textToVideoForm'); | |
| if (imageToVideoForm) { | |
| imageToVideoForm.addEventListener('submit', handleImageToVideoSubmit); | |
| console.log('图片转视频表单已绑定'); | |
| } | |
| if (textToVideoForm) { | |
| textToVideoForm.addEventListener('submit', handleTextToVideoSubmit); | |
| console.log('文本转视频表单已绑定'); | |
| } | |
| // 结果清除 | |
| const clearResult = document.getElementById('clearResult'); | |
| if (clearResult) { | |
| clearResult.addEventListener('click', clearResults); | |
| } | |
| // 视频库相关 | |
| const refreshLibrary = document.getElementById('refreshLibrary'); | |
| if (refreshLibrary) { | |
| refreshLibrary.addEventListener('click', loadVideoLibrary); | |
| } | |
| // 模型选择(文本转视频) | |
| const t2vModelSelect = document.getElementById('t2vModel'); | |
| if (t2vModelSelect) { | |
| t2vModelSelect.addEventListener('change', updateModelSettingsVisibility); | |
| // 初始化时根据默认选择显示对应参数 | |
| updateModelSettingsVisibility(); | |
| console.log('模型选择切换事件已绑定'); | |
| } | |
| // 模型选择(图片转视频) | |
| const i2vModelSelect = document.getElementById('i2vModel'); | |
| if (i2vModelSelect) { | |
| i2vModelSelect.addEventListener('change', updateI2VModelSettingsVisibility); | |
| // 初始化时根据默认选择显示对应参数 | |
| updateI2VModelSettingsVisibility(); | |
| console.log('图片转视频模型选择切换事件已绑定'); | |
| } | |
| console.log('事件监听器初始化完成'); | |
| } | |
| // 范围滑块初始化 | |
| function initializeRangeSliders() { | |
| const ranges = document.querySelectorAll('.form-range'); | |
| ranges.forEach(range => { | |
| const valueSpan = document.getElementById(range.id + 'Value'); | |
| if (valueSpan) { | |
| valueSpan.textContent = range.value; | |
| range.addEventListener('input', () => { | |
| valueSpan.textContent = range.value; | |
| }); | |
| } | |
| }); | |
| console.log(`${ranges.length} 个滑块已初始化`); | |
| } | |
| // 根据模型选择显示/隐藏对应高级设置 | |
| function updateModelSettingsVisibility() { | |
| const model = document.getElementById('t2vModel')?.value || 'seedance-pro-fast'; | |
| const wanSettings = document.getElementById('wanSettings'); | |
| const seedSettings = document.getElementById('seedanceSettings'); | |
| if (wanSettings && seedSettings) { | |
| if (model === 'wan-v2.2-a14b') { | |
| wanSettings.style.display = 'block'; | |
| seedSettings.style.display = 'none'; | |
| } else { | |
| wanSettings.style.display = 'none'; | |
| seedSettings.style.display = 'block'; | |
| } | |
| } | |
| } | |
| // 根据图片转视频模型选择显示/隐藏对应高级设置 | |
| function updateI2VModelSettingsVisibility() { | |
| const model = document.getElementById('i2vModel')?.value || 'seedance-pro-fast'; | |
| const wanSettings = document.getElementById('i2vWanSettings'); | |
| const seedSettings = document.getElementById('i2vSeedanceSettings'); | |
| if (wanSettings && seedSettings) { | |
| if (model === 'wan-v2.2-a14b') { | |
| wanSettings.style.display = 'block'; | |
| seedSettings.style.display = 'none'; | |
| } else { | |
| wanSettings.style.display = 'none'; | |
| seedSettings.style.display = 'block'; | |
| } | |
| } | |
| } | |
| // API Key 相关函数 | |
| function toggleApiKeyVisibilityFunc() { | |
| const apiKeyInput = document.getElementById('apiKeyInput'); | |
| const toggleButton = document.getElementById('toggleApiKeyVisibility'); | |
| if (apiKeyInput && toggleButton) { | |
| const icon = toggleButton.querySelector('i'); | |
| if (apiKeyInput.type === 'password') { | |
| apiKeyInput.type = 'text'; | |
| if (icon) icon.className = 'fas fa-eye-slash'; | |
| } else { | |
| apiKeyInput.type = 'password'; | |
| if (icon) icon.className = 'fas fa-eye'; | |
| } | |
| } | |
| } | |
| async function saveApiKey() { | |
| const apiKeyInput = document.getElementById('apiKeyInput'); | |
| const saveApiKeyCheckbox = document.getElementById('saveApiKey'); | |
| const saveApiKeyButton = document.getElementById('saveApiKeyButton'); | |
| if (!apiKeyInput) return; | |
| const apiKey = apiKeyInput.value.trim(); | |
| const shouldSave = saveApiKeyCheckbox ? saveApiKeyCheckbox.checked : true; | |
| if (!apiKey) { | |
| showNotification('请输入 API 密钥', 'error'); | |
| return; | |
| } | |
| // 基本检查 | |
| if (apiKey.length < 10) { | |
| showNotification('API 密钥长度不足', 'error'); | |
| return; | |
| } | |
| // 显示保存状态 | |
| if (saveApiKeyButton) { | |
| saveApiKeyButton.disabled = true; | |
| saveApiKeyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 保存中...'; | |
| } | |
| if (shouldSave) { | |
| try { | |
| console.log('正在保存 API 密钥到服务器...'); | |
| const response = await fetch('/api/save-key', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ apiKey }) | |
| }); | |
| const result = await response.json(); | |
| console.log('保存结果:', result); | |
| if (result.success) { | |
| showNotification('API 密钥保存成功', 'success'); | |
| // 同步前端使用,避免未传 key 导致 401 | |
| currentApiKey = apiKey; | |
| try { localStorage.setItem('fal_api_key', apiKey); } catch (e) {} | |
| checkApiKeyStatus(); | |
| const apiKeyModal = document.getElementById('apiKeyModal'); | |
| if (apiKeyModal) apiKeyModal.style.display = 'none'; | |
| } else { | |
| showNotification(result.error || '保存失败', 'error'); | |
| console.error('保存失败:', result); | |
| } | |
| } catch (error) { | |
| console.error('保存 API key 失败:', error); | |
| showNotification('保存失败,请检查网络连接', 'error'); | |
| } | |
| } else { | |
| // 临时设置 | |
| currentApiKey = apiKey; | |
| showNotification('API 密钥已设置(临时)', 'success'); | |
| const apiKeyModal = document.getElementById('apiKeyModal'); | |
| if (apiKeyModal) apiKeyModal.style.display = 'none'; | |
| const apiKeyStatus = document.getElementById('apiKeyStatus'); | |
| const apiKeyButton = document.getElementById('apiKeyButton'); | |
| if (apiKeyStatus) apiKeyStatus.textContent = '临时设置'; | |
| if (apiKeyButton) apiKeyButton.classList.add('configured'); | |
| } | |
| // 恢复按钮状态 | |
| if (saveApiKeyButton) { | |
| saveApiKeyButton.disabled = false; | |
| saveApiKeyButton.innerHTML = '<i class="fas fa-save"></i> 保存设置'; | |
| } | |
| } | |
| async function testApiKey() { | |
| const testApiKeyButton = document.getElementById('testApiKeyButton'); | |
| const apiKeyInput = document.getElementById('apiKeyInput'); | |
| if (!testApiKeyButton) return; | |
| const apiKey = apiKeyInput ? apiKeyInput.value.trim() : null; | |
| testApiKeyButton.disabled = true; | |
| testApiKeyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 连接中...'; | |
| try { | |
| const response = await fetch('/api/test-key', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ apiKey }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| showNotification(`✅ ${result.message}`, 'success'); | |
| if (result.note) { | |
| setTimeout(() => { | |
| showNotification(result.note, 'info'); | |
| }, 1500); | |
| } | |
| } else { | |
| showNotification(result.error || 'API 密钥格式验证失败', 'error'); | |
| // 显示具体的格式问题 | |
| if (result.tips && result.tips.length > 0) { | |
| console.error('格式问题:', result.tips); | |
| setTimeout(() => { | |
| result.tips.forEach((tip, index) => { | |
| setTimeout(() => { | |
| showNotification(tip, 'warning'); | |
| }, index * 1000); | |
| }); | |
| }, 1000); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('测试 API key 失败:', error); | |
| showNotification('网络连接失败,请重试', 'error'); | |
| } finally { | |
| testApiKeyButton.disabled = false; | |
| testApiKeyButton.innerHTML = '<i class="fas fa-plug"></i> 测试连接'; | |
| } | |
| } | |
| // 标签页切换 | |
| function switchTab(tabId) { | |
| const tabButtons = document.querySelectorAll('.tab-button'); | |
| const tabContents = document.querySelectorAll('.tab-content'); | |
| tabButtons.forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.tab === tabId); | |
| }); | |
| tabContents.forEach(content => { | |
| content.classList.toggle('active', content.id === tabId); | |
| }); | |
| clearResults(); | |
| } | |
| // 图片处理函数 | |
| function handleDragOver(e) { | |
| e.preventDefault(); | |
| const imageUploadArea = document.getElementById('imageUploadArea'); | |
| if (imageUploadArea) imageUploadArea.classList.add('dragover'); | |
| } | |
| function handleDragLeave(e) { | |
| e.preventDefault(); | |
| const imageUploadArea = document.getElementById('imageUploadArea'); | |
| if (imageUploadArea) imageUploadArea.classList.remove('dragover'); | |
| } | |
| function handleDrop(e) { | |
| e.preventDefault(); | |
| const imageUploadArea = document.getElementById('imageUploadArea'); | |
| if (imageUploadArea) imageUploadArea.classList.remove('dragover'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0 && files[0].type.startsWith('image/')) { | |
| handleImageFile(files[0]); | |
| } | |
| } | |
| function handleImageSelect(e) { | |
| const file = e.target.files[0]; | |
| if (file && file.type.startsWith('image/')) { | |
| handleImageFile(file); | |
| } | |
| } | |
| function handleImageFile(file) { | |
| if (file.size > 50 * 1024 * 1024) { | |
| showNotification('图片文件大小不能超过 50MB', 'error'); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const previewImg = document.getElementById('previewImg'); | |
| const imagePreview = document.getElementById('imagePreview'); | |
| const uploadPlaceholder = document.querySelector('.upload-placeholder'); | |
| const imageUrl = document.getElementById('imageUrl'); | |
| if (previewImg) previewImg.src = e.target.result; | |
| if (imagePreview) imagePreview.style.display = 'block'; | |
| if (uploadPlaceholder) uploadPlaceholder.style.display = 'none'; | |
| if (imageUrl) imageUrl.value = ''; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function clearImagePreview() { | |
| const imagePreview = document.getElementById('imagePreview'); | |
| const uploadPlaceholder = document.querySelector('.upload-placeholder'); | |
| const imageFile = document.getElementById('imageFile'); | |
| const imageUrl = document.getElementById('imageUrl'); | |
| if (imagePreview) imagePreview.style.display = 'none'; | |
| if (uploadPlaceholder) uploadPlaceholder.style.display = 'block'; | |
| if (imageFile) imageFile.value = ''; | |
| if (imageUrl) imageUrl.value = ''; | |
| } | |
| // 表单提交处理 | |
| async function handleImageToVideoSubmit(e) { | |
| e.preventDefault(); | |
| // 并发生成允许,不阻塞生成按钮 | |
| const prompt = document.getElementById('i2vPrompt')?.value.trim(); | |
| const imageUrl = document.getElementById('imageUrl')?.value.trim(); | |
| const imageFile = document.getElementById('imageFile'); | |
| const model = document.getElementById('i2vModel')?.value || 'seedance-pro-fast'; | |
| if (!prompt) { | |
| showNotification('请输入文本描述', 'error'); | |
| return; | |
| } | |
| if (!imageFile?.files[0] && !imageUrl) { | |
| showNotification('请选择图片或输入图片URL', 'error'); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| if (imageFile?.files[0]) { | |
| formData.append('image', imageFile.files[0]); | |
| } else { | |
| formData.append('image_url', imageUrl); | |
| } | |
| const apiKey = currentApiKey; | |
| if (apiKey) { | |
| formData.append('userApiKey', apiKey); | |
| } | |
| formData.append('prompt', prompt); | |
| formData.append('model', model); | |
| if (model === 'seedance-pro-fast') { | |
| // Seedance 1.0 Pro Fast 所需参数 | |
| formData.append('aspect_ratio', document.getElementById('i2vSeedAspectRatio')?.value || 'auto'); | |
| formData.append('resolution', document.getElementById('i2vSeedResolution')?.value || '1080p'); | |
| formData.append('duration', document.getElementById('i2vSeedDuration')?.value || '5'); | |
| formData.append('camera_fixed', document.getElementById('i2vCameraFixed')?.checked ? 'true' : 'false'); | |
| formData.append('seed', (document.getElementById('i2vSeedValue')?.value ?? '-1').toString()); | |
| formData.append('enable_safety_checker', document.getElementById('i2vEnableSafety')?.checked ? 'true' : 'false'); | |
| } else { | |
| // WAN v2.2-a14b 参数(保持原逻辑) | |
| formData.append('negative_prompt', document.getElementById('i2vNegativePrompt')?.value || ''); | |
| formData.append('num_frames', document.getElementById('i2vFrames')?.value || '81'); | |
| formData.append('frames_per_second', document.getElementById('i2vFps')?.value || '16'); | |
| formData.append('resolution', document.getElementById('i2vResolution')?.value || '720p'); | |
| formData.append('aspect_ratio', document.getElementById('i2vAspectRatio')?.value || 'auto'); | |
| formData.append('video_quality', document.getElementById('i2vQuality')?.value || 'high'); | |
| formData.append('enable_safety_checker', document.getElementById('i2vDisableSafety')?.checked ? 'false' : 'true'); | |
| } | |
| await generateVideo('/api/image-to-video', formData); | |
| } | |
| async function handleTextToVideoSubmit(e) { | |
| e.preventDefault(); | |
| // 并发生成允许,不阻塞生成按钮 | |
| const prompt = document.getElementById('t2vPrompt')?.value.trim(); | |
| if (!prompt) { | |
| showNotification('请输入文本描述', 'error'); | |
| return; | |
| } | |
| const model = document.getElementById('t2vModel')?.value || 'seedance-pro-fast'; | |
| const apiKey = currentApiKey; | |
| if (model === 'seedance-pro-fast') { | |
| // Bytedance Seedance 1.0 Pro Fast 入参 | |
| const requestData = { | |
| prompt: prompt, | |
| aspect_ratio: document.getElementById('seedAspectRatio')?.value || '16:9', | |
| resolution: document.getElementById('seedResolution')?.value || '1080p', | |
| duration: document.getElementById('seedDuration')?.value || '5', | |
| camera_fixed: !!document.getElementById('t2vCameraFixed')?.checked, | |
| seed: parseInt(document.getElementById('t2vSeed')?.value ?? '-1', 10), | |
| enable_safety_checker: !!document.getElementById('t2vEnableSafety')?.checked, | |
| model: 'seedance-pro-fast' | |
| }; | |
| if (apiKey) { | |
| requestData.userApiKey = apiKey; | |
| } | |
| await generateVideo('/api/text-to-video', requestData, 'json'); | |
| return; | |
| } | |
| // WAN v2.2-a14b 入参(保持原逻辑) | |
| const requestData = { | |
| prompt: prompt, | |
| negative_prompt: document.getElementById('t2vNegativePrompt')?.value || '', | |
| num_frames: parseInt(document.getElementById('t2vFrames')?.value || '81', 10), | |
| frames_per_second: parseInt(document.getElementById('t2vFps')?.value || '16', 10), | |
| resolution: document.getElementById('t2vResolution')?.value || '720p', | |
| aspect_ratio: document.getElementById('t2vAspectRatio')?.value || '16:9', | |
| video_quality: document.getElementById('t2vQuality')?.value || 'high', | |
| enable_safety_checker: document.getElementById('t2vDisableSafety')?.checked ? false : true, | |
| model: 'wan-v2.2-a14b' | |
| }; | |
| if (apiKey) { | |
| requestData.userApiKey = apiKey; | |
| } | |
| await generateVideo('/api/text-to-video', requestData, 'json'); | |
| } | |
| // 视频生成 | |
| async function generateVideo(endpoint, data, contentType = 'form') { | |
| // 在生成栏中创建队列项并显示进度,允许并发生成 | |
| const queueList = document.getElementById('queueList'); | |
| const queueCount = document.getElementById('queueCount'); | |
| if (!queueList) { | |
| // 兼容旧页面:如果没有队列容器,直接执行旧逻辑的通知与结果展示 | |
| try { | |
| const options = { method: 'POST' }; | |
| if (contentType === 'json') { | |
| options.headers = { 'Content-Type': 'application/json' }; | |
| options.body = JSON.stringify(data); | |
| } else { | |
| options.body = data; | |
| } | |
| const response = await fetch(endpoint, options); | |
| const result = await response.json(); | |
| if (result.success && result.data && result.data.video) { | |
| showResult(result.data.video.url, result.data.prompt); | |
| showNotification('视频生成成功!', 'success'); | |
| } else { | |
| throw new Error(result.error || '视频生成失败'); | |
| } | |
| } catch (error) { | |
| console.error('生成错误:', error); | |
| showNotification(error.message || '生成失败,请重试', 'error'); | |
| } | |
| return; | |
| } | |
| // 若为空队列占位,先移除 | |
| if (queueList.classList.contains('empty')) { | |
| queueList.classList.remove('empty'); | |
| queueList.innerHTML = ''; | |
| } | |
| const taskId = `task-${Date.now()}-${Math.floor(Math.random() * 1000)}`; | |
| const title = endpoint.includes('image') ? '图片转视频' : '文本转视频'; | |
| const promptText = (() => { | |
| if (typeof data === 'object' && contentType === 'json') { | |
| return data.prompt ?? ''; | |
| } | |
| // FormData 场景:直接从页面读取 | |
| const domId = endpoint.includes('image') ? 'i2vPrompt' : 't2vPrompt'; | |
| return document.getElementById(domId)?.value?.trim() ?? ''; | |
| })(); | |
| const item = document.createElement('div'); | |
| item.className = 'queue-item'; | |
| item.id = taskId; | |
| item.innerHTML = ` | |
| <div class="info"> | |
| <div class="title">${title}</div> | |
| <div class="prompt">${escapeHtml(promptText)}</div> | |
| <div class="meta">${new Date().toLocaleString('zh-CN')}</div> | |
| </div> | |
| <div class="queue-progress"> | |
| <div class="progress-bar"><div class="progress-fill" id="${taskId}-progress"></div></div> | |
| <div class="queue-status running" id="${taskId}-status">运行中</div> | |
| </div> | |
| `; | |
| queueList.appendChild(item); | |
| // 更新队列计数 | |
| if (queueCount) { | |
| const count = queueList.querySelectorAll('.queue-item').length; | |
| queueCount.textContent = `${count} 任务`; | |
| } | |
| // 每个任务独立的进度模拟(服务端暂不提供实时进度回传) | |
| let progress = 0; | |
| const progressEl = () => document.getElementById(`${taskId}-progress`); | |
| const statusEl = () => document.getElementById(`${taskId}-status`); | |
| const progressInterval = setInterval(() => { | |
| progress += Math.random() * 15; | |
| if (progress > 90) progress = 90; | |
| if (progressEl()) progressEl().style.width = progress + '%'; | |
| }, 900); | |
| try { | |
| const options = { method: 'POST' }; | |
| if (contentType === 'json') { | |
| options.headers = { 'Content-Type': 'application/json' }; | |
| options.body = JSON.stringify(data); | |
| } else { | |
| options.body = data; | |
| } | |
| const response = await fetch(endpoint, options); | |
| const result = await response.json(); | |
| if (result.success && result.data && result.data.video) { | |
| clearInterval(progressInterval); | |
| if (progressEl()) progressEl().style.width = '100%'; | |
| if (statusEl()) { | |
| statusEl().className = 'queue-status done'; | |
| statusEl().textContent = '完成'; | |
| } | |
| showResult(result.data.video.url, result.data.prompt); | |
| showNotification('视频生成成功!', 'success'); | |
| } else { | |
| throw new Error(result.error || '视频生成失败'); | |
| } | |
| } catch (error) { | |
| clearInterval(progressInterval); | |
| console.error('生成错误:', error); | |
| if (statusEl()) { | |
| statusEl().className = 'queue-status error'; | |
| statusEl().textContent = '错误'; | |
| } | |
| if (error.message.includes('API') || error.message.includes('密钥')) { | |
| showNotification('请先配置 API 密钥', 'error'); | |
| setTimeout(() => { | |
| const apiKeyModal = document.getElementById('apiKeyModal'); | |
| if (apiKeyModal) apiKeyModal.style.display = 'flex'; | |
| }, 1000); | |
| } else { | |
| showNotification(error.message || '生成失败,请重试', 'error'); | |
| } | |
| } | |
| function escapeHtml(str) { | |
| const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; | |
| return String(str || '').replace(/[&<>"']/g, s => map[s]); | |
| } | |
| } | |
| // 加载状态 | |
| function showLoading() { | |
| // 不再显示全屏遮罩,也不禁用按钮,避免占用全屏和阻塞生成键 | |
| const loadingContainer = document.getElementById('loadingContainer'); | |
| if (loadingContainer) loadingContainer.style.display = 'none'; | |
| } | |
| function hideLoading() { | |
| // 保持按钮可用,不做处理 | |
| const loadingContainer = document.getElementById('loadingContainer'); | |
| if (loadingContainer) loadingContainer.style.display = 'none'; | |
| } | |
| function simulateProgress() { | |
| // 已改为每个任务独立的进度显示,此处不再使用全局模拟 | |
| return; | |
| } | |
| // 结果显示 | |
| function showResult(videoUrl, prompt) { | |
| const resultVideo = document.getElementById('resultVideo'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| const resultContainer = document.getElementById('resultContainer'); | |
| if (resultVideo) resultVideo.src = videoUrl; | |
| if (downloadLink) { | |
| downloadLink.href = videoUrl; | |
| downloadLink.download = `generated-video-${Date.now()}.mp4`; | |
| } | |
| if (resultContainer) { | |
| resultContainer.style.display = 'block'; | |
| resultContainer.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| } | |
| function clearResults() { | |
| const resultContainer = document.getElementById('resultContainer'); | |
| const resultVideo = document.getElementById('resultVideo'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| if (resultContainer) resultContainer.style.display = 'none'; | |
| if (resultVideo) resultVideo.src = ''; | |
| if (downloadLink) downloadLink.href = ''; | |
| } | |
| // 通知系统 | |
| function showNotification(message, type = 'info') { | |
| const notification = document.createElement('div'); | |
| notification.className = `notification notification-${type}`; | |
| notification.innerHTML = ` | |
| <div class="notification-content"> | |
| <i class="fas ${getNotificationIcon(type)}"></i> | |
| <span>${message}</span> | |
| </div> | |
| `; | |
| notification.style.cssText = ` | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: ${getNotificationColor(type)}; | |
| color: white; | |
| padding: 1rem 1.5rem; | |
| border-radius: 0.5rem; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
| z-index: 1001; | |
| transform: translateX(100%); | |
| transition: transform 0.3s ease; | |
| max-width: 400px; | |
| `; | |
| document.body.appendChild(notification); | |
| setTimeout(() => { | |
| notification.style.transform = 'translateX(0)'; | |
| }, 100); | |
| setTimeout(() => { | |
| notification.style.transform = 'translateX(100%)'; | |
| setTimeout(() => { | |
| if (document.body.contains(notification)) { | |
| document.body.removeChild(notification); | |
| } | |
| }, 300); | |
| }, 4000); | |
| } | |
| function getNotificationIcon(type) { | |
| switch (type) { | |
| case 'success': return 'fa-check-circle'; | |
| case 'error': return 'fa-exclamation-circle'; | |
| case 'warning': return 'fa-exclamation-triangle'; | |
| default: return 'fa-info-circle'; | |
| } | |
| } | |
| function getNotificationColor(type) { | |
| switch (type) { | |
| case 'success': return '#3b82f6'; | |
| case 'error': return '#ef4444'; | |
| case 'warning': return '#f59e0b'; | |
| default: return '#3b82f6'; | |
| } | |
| } | |
| // 视频库功能 | |
| async function loadVideoLibrary() { | |
| const videoLibraryContent = document.getElementById('videoLibraryContent'); | |
| const videoCount = document.getElementById('videoCount'); | |
| if (!videoLibraryContent) return; | |
| // 显示加载状态 | |
| videoLibraryContent.innerHTML = ` | |
| <div class="loading-library"> | |
| <i class="fas fa-spinner fa-spin"></i> | |
| 正在加载视频库... | |
| </div> | |
| `; | |
| try { | |
| const response = await fetch('/api/videos'); | |
| const data = await response.json(); | |
| if (data.videos && data.videos.length > 0) { | |
| displayVideoLibrary(data.videos); | |
| if (videoCount) { | |
| videoCount.textContent = `共 ${data.videos.length} 个视频`; | |
| } | |
| } else { | |
| displayEmptyLibrary(); | |
| if (videoCount) { | |
| videoCount.textContent = '共 0 个视频'; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('加载视频库失败:', error); | |
| videoLibraryContent.innerHTML = ` | |
| <div class="empty-library"> | |
| <i class="fas fa-exclamation-triangle"></i> | |
| <p>加载视频库失败</p> | |
| <button onclick="loadVideoLibrary()" class="refresh-button">重试</button> | |
| </div> | |
| `; | |
| } | |
| } | |
| function displayVideoLibrary(videos) { | |
| const videoLibraryContent = document.getElementById('videoLibraryContent'); | |
| const videoGrid = document.createElement('div'); | |
| videoGrid.className = 'video-grid'; | |
| videos.forEach(video => { | |
| const videoItem = createVideoItem(video); | |
| videoGrid.appendChild(videoItem); | |
| }); | |
| videoLibraryContent.innerHTML = ''; | |
| videoLibraryContent.appendChild(videoGrid); | |
| } | |
| function createVideoItem(video) { | |
| const item = document.createElement('div'); | |
| item.className = 'video-item'; | |
| const fileSize = formatFileSize(video.size); | |
| const createdDate = new Date(video.created).toLocaleString('zh-CN'); | |
| item.innerHTML = ` | |
| <video class="video-preview" controls preload="metadata"> | |
| <source src="/${video.path}" type="video/mp4"> | |
| 您的浏览器不支持视频播放。 | |
| </video> | |
| <div class="video-info"> | |
| <div class="video-filename">${video.filename}</div> | |
| <div class="video-meta"> | |
| <span>${createdDate}</span> | |
| <span class="video-size">${fileSize}</span> | |
| </div> | |
| <div class="video-actions"> | |
| <button class="video-action-btn play-video-btn" onclick="playVideoInModal('/${video.path}')"> | |
| <i class="fas fa-play"></i> | |
| 播放 | |
| </button> | |
| <button class="video-action-btn download-video-btn" onclick="downloadVideo('/${video.path}', '${video.filename}')"> | |
| <i class="fas fa-download"></i> | |
| 下载 | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| return item; | |
| } | |
| function displayEmptyLibrary() { | |
| const videoLibraryContent = document.getElementById('videoLibraryContent'); | |
| videoLibraryContent.innerHTML = ` | |
| <div class="empty-library"> | |
| <i class="fas fa-video"></i> | |
| <p>暂无视频</p> | |
| <p class="upload-hint">生成的视频会自动保存到这里</p> | |
| </div> | |
| `; | |
| } | |
| function formatFileSize(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| function playVideoInModal(videoPath) { | |
| // 在结果容器中播放视频 | |
| const resultVideo = document.getElementById('resultVideo'); | |
| const resultContainer = document.getElementById('resultContainer'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| if (resultVideo && resultContainer && downloadLink) { | |
| resultVideo.src = videoPath; | |
| downloadLink.href = videoPath; | |
| downloadLink.download = videoPath.split('/').pop(); | |
| resultContainer.style.display = 'block'; | |
| resultContainer.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| } | |
| function downloadVideo(videoPath, filename) { | |
| const link = document.createElement('a'); | |
| link.href = videoPath; | |
| link.download = filename; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| showNotification('开始下载视频', 'success'); | |
| } | |
| // 修改 showResult 函数,生成视频后自动刷新视频库 | |
| function showResult(videoUrl, prompt) { | |
| const resultVideo = document.getElementById('resultVideo'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| const resultContainer = document.getElementById('resultContainer'); | |
| if (resultVideo) resultVideo.src = videoUrl; | |
| if (downloadLink) { | |
| downloadLink.href = videoUrl; | |
| downloadLink.download = `generated-video-${Date.now()}.mp4`; | |
| } | |
| if (resultContainer) { | |
| resultContainer.style.display = 'block'; | |
| resultContainer.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| // 刷新视频库 | |
| setTimeout(() => { | |
| loadVideoLibrary(); | |
| }, 2000); | |
| } | |
| // 错误处理 | |
| window.addEventListener('error', function(e) { | |
| console.error('全局错误:', e.error); | |
| if (isGenerating) { | |
| hideLoading(); | |
| showNotification('发生未知错误,请重试', 'error'); | |
| isGenerating = false; | |
| } | |
| }); | |
| window.addEventListener('online', () => { | |
| showNotification('网络连接已恢复', 'success'); | |
| }); | |
| window.addEventListener('offline', () => { | |
| showNotification('网络连接已断开', 'warning'); | |
| }); | |