Spaces:
Running
Running
| document.addEventListener("DOMContentLoaded", () => { | |
| // --- DOM Element Selection --- | |
| const generateBtn = document.getElementById("generate-btn"); | |
| const saveVideoBtn = document.getElementById("save-video-btn"); | |
| const soundToggleBtn = document.getElementById("sound-toggle-btn"); | |
| const loader = document.getElementById("loader"); | |
| const errorContainer = document.getElementById("error-container"); | |
| const placeholder = document.querySelector(".video-container .placeholder"); | |
| // Form Inputs | |
| const apiKeyInput = document.getElementById("api-key"); | |
| const promptInput = document.getElementById("prompt"); | |
| const negativePromptInput = document.getElementById("negative-prompt"); | |
| const modelSelect = document.getElementById("model"); | |
| const aspectRatioSelect = document.getElementById("aspect-ratio"); | |
| const resolutionSelect = document.getElementById("resolution"); | |
| const personGenSelect = document.getElementById("person-generation"); | |
| const seedInput = document.getElementById("seed"); | |
| const sampleCountInput = document.getElementById("sample-count"); | |
| const durationInput = document.getElementById("duration"); | |
| const generateAudioSwitch = document.getElementById("generate-audio"); | |
| const threadCountInput = document.getElementById("thread-count"); | |
| const enhancePromptSwitch = document.getElementById("enhance-prompt"); | |
| const imagePromptInput = document.getElementById("image-prompt"); | |
| const videoPromptInput = document.getElementById("video-prompt"); | |
| const imageProcessModeSelect = document.getElementById("image-process-mode"); | |
| const imageFillColorGroup = document.getElementById("image-fill-color-group"); | |
| const imageFillColorPicker = document.getElementById("image-fill-color-picker"); | |
| const imageFillColorInput = document.getElementById("image-fill-color"); | |
| // Carousel Elements | |
| const videoCarousel = document.getElementById("video-carousel"); | |
| const prevBtn = document.getElementById("prev-btn"); | |
| const nextBtn = document.getElementById("next-btn"); | |
| const carouselDots = document.getElementById("carousel-dots"); | |
| // Prompt Saving Elements | |
| const savePromptBtn = document.getElementById("save-prompt-btn"); | |
| const savedPromptsToggle = document.getElementById("saved-prompts-toggle"); | |
| const savedPromptsList = document.getElementById("saved-prompts-list"); | |
| // Key Saving Elements | |
| const saveKeyBtn = document.getElementById("save-key-btn"); | |
| const savedKeysToggle = document.getElementById("saved-keys-toggle"); | |
| const savedKeysList = document.getElementById("saved-keys-list"); | |
| // --- State --- | |
| let currentIndex = 0; | |
| let videoItems = []; | |
| const SETTINGS_KEY = 'veoGeneratorSettings'; | |
| let currentPasteTarget = null; // 'image' or 'video' | |
| let savedVideos = []; // To track URLs of saved videos | |
| const SAVED_PROMPTS_KEY = 'veoGeneratorSavedPrompts'; | |
| const SAVED_KEYS_KEY = 'veoGeneratorSavedKeys'; | |
| const SOUND_SETTINGS_KEY = 'veoGeneratorSoundEnabled'; | |
| let soundEnabled = false; // Default to disabled | |
| const PRESET_KEYS = []; | |
| // --- Notification Sound --- | |
| function playNotificationSound() { | |
| if (!soundEnabled) return; // 如果声音被禁用则直接返回 | |
| try { | |
| // 完成音效 - 上升琶音 (C5→E5→G5→C6) | |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6 | |
| notes.forEach((frequency, index) => { | |
| setTimeout(() => { | |
| const oscillator = audioContext.createOscillator(); | |
| const gainNode = audioContext.createGain(); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); | |
| oscillator.type = 'sine'; | |
| // 最大音量设置 | |
| gainNode.gain.setValueAtTime(0, audioContext.currentTime); | |
| gainNode.gain.linearRampToValueAtTime(1.0, audioContext.currentTime + 0.01); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); | |
| oscillator.start(audioContext.currentTime); | |
| oscillator.stop(audioContext.currentTime + 0.3); | |
| }, index * 100); | |
| }); | |
| } catch (error) { | |
| console.log('无法播放提示音:', error); | |
| } | |
| } | |
| // --- Sound Toggle Logic --- | |
| function updateSoundButtonState() { | |
| if (soundEnabled) { | |
| soundToggleBtn.classList.remove('disabled'); | |
| soundToggleBtn.innerHTML = feather.icons['volume-2'].toSvg(); | |
| soundToggleBtn.title = "提示音已开启,点击关闭"; | |
| } else { | |
| soundToggleBtn.classList.add('disabled'); | |
| soundToggleBtn.innerHTML = feather.icons['volume-x'].toSvg(); | |
| soundToggleBtn.title = "提示音已关闭,点击开启"; | |
| } | |
| } | |
| function toggleSound() { | |
| soundEnabled = !soundEnabled; | |
| localStorage.setItem(SOUND_SETTINGS_KEY, JSON.stringify(soundEnabled)); | |
| updateSoundButtonState(); | |
| // 如果开启了声音,播放一次提示音作为反馈 | |
| if (soundEnabled) { | |
| playNotificationSound(); | |
| } | |
| } | |
| function loadSoundSettings() { | |
| const savedSoundSettings = localStorage.getItem(SOUND_SETTINGS_KEY); | |
| if (savedSoundSettings !== null) { | |
| soundEnabled = JSON.parse(savedSoundSettings); | |
| } | |
| updateSoundButtonState(); | |
| } | |
| soundToggleBtn.addEventListener('click', toggleSound); | |
| // --- Functions --- | |
| // --- Saved Prompts Logic --- | |
| function updateSaveButtonState() { | |
| const currentPrompt = promptInput.value.trim(); | |
| const prompts = getSavedPrompts(); | |
| if (currentPrompt && prompts.includes(currentPrompt)) { | |
| savePromptBtn.innerHTML = feather.icons['check-circle'].toSvg(); | |
| savePromptBtn.title = "提示词已保存"; | |
| savePromptBtn.disabled = true; | |
| } else { | |
| savePromptBtn.innerHTML = feather.icons['plus-circle'].toSvg(); | |
| savePromptBtn.title = "保存当前提示词"; | |
| savePromptBtn.disabled = false; | |
| } | |
| } | |
| function getSavedPrompts() { | |
| return JSON.parse(localStorage.getItem(SAVED_PROMPTS_KEY)) || []; | |
| } | |
| function savePrompts(prompts) { | |
| localStorage.setItem(SAVED_PROMPTS_KEY, JSON.stringify(prompts)); | |
| } | |
| function renderSavedPrompts() { | |
| const prompts = getSavedPrompts(); | |
| savedPromptsList.innerHTML = ''; // Clear the list first | |
| if (prompts.length === 0) { | |
| savedPromptsList.innerHTML = `<div class="saved-prompts-list-empty">没有已保存的提示词</div>`; | |
| return; | |
| } | |
| prompts.forEach((promptText, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'saved-prompt-item'; | |
| item.dataset.index = index; | |
| const text = document.createElement('span'); | |
| text.className = 'saved-prompt-text'; | |
| text.textContent = promptText; | |
| text.title = promptText; // Show full prompt on hover | |
| text.addEventListener('click', () => { | |
| promptInput.value = promptText; | |
| saveSettings(); // Also save to main settings | |
| savedPromptsList.classList.add('hidden'); | |
| updateSaveButtonState(); | |
| }); | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'delete-prompt-btn'; | |
| deleteBtn.innerHTML = feather.icons['trash-2'].toSvg({ width: 14, height: 14 }); | |
| deleteBtn.title = '删除此提示词'; | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); // Prevent item click event | |
| deletePrompt(index); | |
| }); | |
| item.appendChild(text); | |
| item.appendChild(deleteBtn); | |
| savedPromptsList.appendChild(item); | |
| }); | |
| } | |
| function addNewPrompt() { | |
| const currentPrompt = promptInput.value.trim(); | |
| if (!currentPrompt) { | |
| // Silently return if prompt is empty | |
| return; | |
| } | |
| let prompts = getSavedPrompts(); | |
| if (prompts.includes(currentPrompt)) { | |
| // Silently return if prompt already exists | |
| return; | |
| } | |
| prompts.unshift(currentPrompt); // Add to the beginning | |
| savePrompts(prompts); | |
| renderSavedPrompts(); | |
| updateSaveButtonState(); | |
| } | |
| function deletePrompt(indexToDelete) { | |
| let prompts = getSavedPrompts(); | |
| prompts.splice(indexToDelete, 1); | |
| savePrompts(prompts); | |
| renderSavedPrompts(); | |
| updateSaveButtonState(); | |
| } | |
| savePromptBtn.addEventListener('click', addNewPrompt); | |
| savedPromptsToggle.addEventListener('click', () => { | |
| const isHidden = savedPromptsList.classList.toggle('hidden'); | |
| if (!isHidden) { | |
| renderSavedPrompts(); // Re-render when opening | |
| } | |
| }); | |
| // Hide dropdown if clicked outside | |
| document.addEventListener('click', (event) => { | |
| if (!savedPromptsToggle.contains(event.target) && !savedPromptsList.contains(event.target)) { | |
| savedPromptsList.classList.add('hidden'); | |
| } | |
| }); | |
| // --- Saved Keys Logic --- | |
| function getSavedKeys() { | |
| return JSON.parse(localStorage.getItem(SAVED_KEYS_KEY)) || []; | |
| } | |
| function saveKeys(keys) { | |
| localStorage.setItem(SAVED_KEYS_KEY, JSON.stringify(keys)); | |
| } | |
| function getAllKeys() { | |
| const customKeys = getSavedKeys(); | |
| // Add a 'preset' flag to distinguish them | |
| const presetKeys = PRESET_KEYS.map(k => ({ ...k, preset: true })); | |
| return [...presetKeys, ...customKeys]; | |
| } | |
| function renderSavedKeys() { | |
| const keys = getAllKeys(); | |
| savedKeysList.innerHTML = ''; // Clear the list | |
| if (keys.length === 0) { | |
| savedKeysList.innerHTML = `<div class="saved-keys-list-empty">没有已保存的 Key</div>`; | |
| return; | |
| } | |
| keys.forEach((keyData, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'saved-key-item'; | |
| if (keyData.preset) { | |
| item.classList.add('preset'); | |
| } | |
| const alias = document.createElement('span'); | |
| alias.className = 'saved-key-alias'; | |
| alias.textContent = keyData.alias; | |
| alias.title = keyData.alias; | |
| const value = document.createElement('span'); | |
| value.className = 'saved-key-value'; | |
| // Show partial key for preview | |
| value.textContent = `...${keyData.value.slice(-6)}`; | |
| value.title = keyData.value; | |
| const textContainer = document.createElement('div'); | |
| textContainer.style.display = 'flex'; | |
| textContainer.style.alignItems = 'center'; | |
| textContainer.style.overflow = 'hidden'; | |
| textContainer.style.flexGrow = '1'; | |
| textContainer.appendChild(alias); | |
| textContainer.appendChild(value); | |
| item.addEventListener('click', () => { | |
| apiKeyInput.value = keyData.value; | |
| saveSettings(); | |
| savedKeysList.classList.add('hidden'); | |
| updateSaveKeyButtonState(); | |
| }); | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'delete-key-btn'; | |
| deleteBtn.innerHTML = feather.icons['trash-2'].toSvg({ width: 14, height: 14 }); | |
| deleteBtn.title = '删除此 Key'; | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| // We need to find the index in the *custom* keys array | |
| const customKeys = getSavedKeys(); | |
| const customIndexToDelete = customKeys.findIndex(k => k.value === keyData.value); | |
| if (customIndexToDelete > -1) { | |
| deleteKey(customIndexToDelete); | |
| } | |
| }); | |
| item.appendChild(textContainer); | |
| item.appendChild(deleteBtn); | |
| savedKeysList.appendChild(item); | |
| }); | |
| } | |
| function addNewKey() { | |
| const currentKey = apiKeyInput.value.trim(); | |
| if (!currentKey) return; | |
| const allKeys = getAllKeys(); | |
| if (allKeys.some(k => k.value === currentKey)) { | |
| return; // Key already exists | |
| } | |
| const alias = prompt("为这个新的 Key 输入一个别名:", ""); | |
| if (alias === null || alias.trim() === "") { | |
| return; // User cancelled or entered empty alias | |
| } | |
| let customKeys = getSavedKeys(); | |
| customKeys.unshift({ alias: alias.trim(), value: currentKey }); | |
| saveKeys(customKeys); | |
| renderSavedKeys(); | |
| updateSaveKeyButtonState(); | |
| } | |
| function deleteKey(indexToDelete) { | |
| let customKeys = getSavedKeys(); | |
| customKeys.splice(indexToDelete, 1); | |
| saveKeys(customKeys); | |
| renderSavedKeys(); | |
| updateSaveKeyButtonState(); | |
| } | |
| function updateSaveKeyButtonState() { | |
| const currentKey = apiKeyInput.value.trim(); | |
| const allKeys = getAllKeys(); | |
| if (currentKey && allKeys.some(k => k.value === currentKey)) { | |
| saveKeyBtn.innerHTML = feather.icons['check-circle'].toSvg(); | |
| saveKeyBtn.title = "Key 已保存"; | |
| saveKeyBtn.disabled = true; | |
| } else { | |
| saveKeyBtn.innerHTML = feather.icons['plus-circle'].toSvg(); | |
| saveKeyBtn.title = "保存当前 Key"; | |
| saveKeyBtn.disabled = false; | |
| } | |
| } | |
| saveKeyBtn.addEventListener('click', addNewKey); | |
| savedKeysToggle.addEventListener('click', () => { | |
| const isHidden = savedKeysList.classList.toggle('hidden'); | |
| if (!isHidden) { | |
| renderSavedKeys(); | |
| } | |
| }); | |
| document.addEventListener('click', (event) => { | |
| if (!savedKeysToggle.contains(event.target) && !savedKeysList.contains(event.target)) { | |
| savedKeysList.classList.add('hidden'); | |
| } | |
| }); | |
| // --- Settings Persistence --- | |
| function saveSettings() { | |
| const settings = { | |
| apiKey: apiKeyInput.value, | |
| prompt: promptInput.value, | |
| negativePrompt: negativePromptInput.value, | |
| model: modelSelect.value, | |
| aspectRatio: aspectRatioSelect.value, | |
| resolution: resolutionSelect.value, | |
| personGeneration: personGenSelect.value, | |
| seed: seedInput.value, | |
| sampleCount: sampleCountInput.value, | |
| duration: durationInput.value, | |
| generateAudio: generateAudioSwitch.checked, | |
| enhancePrompt: enhancePromptSwitch.checked, | |
| threadCount: threadCountInput.value, | |
| imageProcessMode: imageProcessModeSelect.value, | |
| imageFillColor: imageFillColorInput.value, | |
| }; | |
| localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); | |
| } | |
| function loadSettings() { | |
| const savedSettings = localStorage.getItem(SETTINGS_KEY); | |
| if (savedSettings) { | |
| const settings = JSON.parse(savedSettings); | |
| apiKeyInput.value = settings.apiKey || ''; | |
| promptInput.value = settings.prompt || ''; | |
| negativePromptInput.value = settings.negativePrompt || ''; | |
| modelSelect.value = settings.model || 'veo-3.0-generate-preview'; | |
| aspectRatioSelect.value = settings.aspectRatio || '16:9'; | |
| resolutionSelect.value = settings.resolution || '1080p'; | |
| personGenSelect.value = settings.personGeneration || 'allow_all'; | |
| seedInput.value = settings.seed || ''; | |
| sampleCountInput.value = settings.sampleCount || '1'; | |
| durationInput.value = settings.duration || '8'; | |
| generateAudioSwitch.checked = settings.generateAudio !== false; // default to true if undefined | |
| enhancePromptSwitch.checked = settings.enhancePrompt !== false; // default to true if undefined | |
| threadCountInput.value = settings.threadCount || '1'; | |
| imageProcessModeSelect.value = settings.imageProcessMode || 'none'; | |
| imageFillColorInput.value = settings.imageFillColor || '#000000'; | |
| imageFillColorPicker.value = settings.imageFillColor || '#000000'; | |
| // Update UI state based on loaded settings | |
| updateImageProcessingState(); | |
| updateSaveButtonState(); | |
| } else { | |
| // If no settings, load the default preset key | |
| if (PRESET_KEYS.length > 0) { | |
| apiKeyInput.value = PRESET_KEYS[0].value; | |
| saveSettings(); // Save this default state | |
| } | |
| } | |
| updateSaveKeyButtonState(); // Also update key button state on load | |
| } | |
| // Attach listeners to save settings on change | |
| const inputsToSave = [ | |
| apiKeyInput, promptInput, negativePromptInput, modelSelect, aspectRatioSelect, | |
| resolutionSelect, personGenSelect, seedInput, sampleCountInput, durationInput, | |
| generateAudioSwitch, threadCountInput, enhancePromptSwitch, | |
| imageProcessModeSelect, imageFillColorInput | |
| ]; | |
| inputsToSave.forEach(input => { | |
| input.addEventListener('change', saveSettings); | |
| // For text inputs, 'input' event is more responsive | |
| if (input.type === 'textarea' || input.type === 'text' || input.type === 'password' || input.type === 'number') { | |
| input.addEventListener('input', saveSettings); | |
| } | |
| }); | |
| promptInput.addEventListener('input', updateSaveButtonState); | |
| apiKeyInput.addEventListener('input', updateSaveKeyButtonState); | |
| // File Drop Zone Logic V2: With Preview and Delete | |
| function setupFileDropZone(zoneId, inputId, previewId) { | |
| const dropZone = document.getElementById(zoneId); | |
| const fileInput = document.getElementById(inputId); | |
| const previewContainer = document.getElementById(previewId); | |
| let currentObjectURL = null; | |
| const handleFile = (file) => { | |
| if (currentObjectURL) { | |
| URL.revokeObjectURL(currentObjectURL); | |
| } | |
| if (!file) { | |
| previewContainer.innerHTML = ''; | |
| dropZone.classList.remove('has-file'); | |
| fileInput.value = ''; // Ensure file is cleared | |
| return; | |
| } | |
| currentObjectURL = URL.createObjectURL(file); | |
| previewContainer.innerHTML = ''; // Clear previous preview | |
| let previewElement; | |
| if (file.type.startsWith('image/')) { | |
| previewElement = document.createElement('img'); | |
| previewElement.src = currentObjectURL; | |
| } else if (file.type.startsWith('video/')) { | |
| previewElement = document.createElement('video'); | |
| previewElement.src = currentObjectURL; | |
| previewElement.muted = true; | |
| previewElement.playsInline = true; | |
| previewElement.setAttribute('preload', 'metadata'); // For poster frame | |
| } | |
| if(previewElement) { | |
| previewContainer.appendChild(previewElement); | |
| } | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'delete-file-btn'; | |
| deleteBtn.innerHTML = feather.icons['x'].toSvg({ width: 16, height: 16 }); | |
| deleteBtn.setAttribute('aria-label', 'Remove file'); | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); // VERY IMPORTANT: Prevents the click from bubbling up to the dropZone | |
| e.preventDefault(); | |
| handleFile(null); // Clear the file | |
| }); | |
| previewContainer.appendChild(deleteBtn); | |
| dropZone.classList.add('has-file'); | |
| }; | |
| dropZone.addEventListener("click", (e) => { | |
| // If the click is on the delete button, do nothing. | |
| if (e.target.closest('.delete-file-btn')) { | |
| return; | |
| } | |
| // If the click is on the file input itself, let the browser handle it. | |
| if (e.target === fileInput) { | |
| return; | |
| } | |
| // Otherwise, for any other part of the zone, trigger the file input. | |
| fileInput.click(); | |
| }); | |
| dropZone.addEventListener("dragover", (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add("dragover"); | |
| }); | |
| dropZone.addEventListener("dragleave", () => { | |
| dropZone.classList.remove("dragover"); | |
| }); | |
| dropZone.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove("dragover"); | |
| if (e.dataTransfer.files.length) { | |
| fileInput.files = e.dataTransfer.files; | |
| handleFile(fileInput.files[0]); | |
| } | |
| }); | |
| fileInput.addEventListener("change", () => { | |
| if (fileInput.files.length > 0) { | |
| handleFile(fileInput.files[0]); | |
| } else { | |
| handleFile(null); | |
| } | |
| }); | |
| } | |
| setupFileDropZone("image-drop-zone", "image-prompt", "image-preview"); | |
| setupFileDropZone("video-drop-zone", "video-prompt", "video-preview"); | |
| // --- Image Processing UI Logic --- | |
| function updateImageProcessingState() { | |
| const isFillMode = imageProcessModeSelect.value === 'fill'; | |
| const processGroup = imageProcessModeSelect.closest('.form-group'); | |
| // 移除了基于宽高比的禁用限制,现在所有宽高比都可以使用图片处理功能 | |
| processGroup.classList.remove('disabled'); | |
| imageProcessModeSelect.disabled = false; | |
| // Color picker is enabled only in fill mode | |
| if (isFillMode) { | |
| imageFillColorGroup.classList.remove('disabled'); | |
| imageFillColorPicker.disabled = false; | |
| imageFillColorInput.disabled = false; | |
| } else { | |
| imageFillColorGroup.classList.add('disabled'); | |
| imageFillColorPicker.disabled = true; | |
| imageFillColorInput.disabled = true; | |
| } | |
| } | |
| aspectRatioSelect.addEventListener('change', updateImageProcessingState); | |
| imageProcessModeSelect.addEventListener('change', updateImageProcessingState); | |
| imageFillColorPicker.addEventListener('input', () => { | |
| imageFillColorInput.value = imageFillColorPicker.value; | |
| saveSettings(); // Save on color change | |
| }); | |
| imageFillColorInput.addEventListener('input', () => { | |
| const value = imageFillColorInput.value; | |
| // A simple regex to check for valid hex color format | |
| if (/^#[0-9a-fA-F]{6}$/.test(value)) { | |
| imageFillColorPicker.value = value; | |
| saveSettings(); // Save on text input change | |
| } | |
| }); | |
| // Carousel Logic | |
| function updateCarousel() { | |
| videoCarousel.style.transform = `translateX(-${currentIndex * 100}%)`; | |
| updateSaveVideoButtonState(); // Update save button state when carousel changes | |
| const dots = carouselDots.querySelectorAll('.carousel-dot'); | |
| dots.forEach((dot, index) => { | |
| dot.classList.toggle('active', index === currentIndex); | |
| }); | |
| const videos = videoCarousel.querySelectorAll('video'); | |
| videos.forEach((video, index) => { | |
| if (index !== currentIndex) { | |
| video.pause(); | |
| } | |
| }); | |
| } | |
| function resetCarousel() { | |
| videoCarousel.innerHTML = ''; | |
| carouselDots.innerHTML = ''; | |
| videoItems = []; | |
| currentIndex = 0; | |
| prevBtn.classList.add('hidden'); | |
| nextBtn.classList.add('hidden'); | |
| placeholder.style.display = 'flex'; | |
| saveVideoBtn.disabled = true; // Disable button when there are no videos | |
| savedVideos = []; // Reset saved videos tracker | |
| } | |
| prevBtn.addEventListener('click', () => { | |
| currentIndex = (currentIndex > 0) ? currentIndex - 1 : videoItems.length - 1; | |
| updateCarousel(); | |
| }); | |
| nextBtn.addEventListener('click', () => { | |
| currentIndex = (currentIndex < videoItems.length - 1) ? currentIndex + 1 : 0; | |
| updateCarousel(); | |
| }); | |
| // --- Video Handling --- | |
| function addVideoToCarousel(videoUrl) { | |
| const index = videoItems.length; | |
| videoItems.push(videoUrl); | |
| const item = document.createElement('div'); | |
| item.className = 'carousel-item'; | |
| const video = document.createElement('video'); | |
| video.src = videoUrl; | |
| video.controls = true; | |
| video.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| if (video.paused) { | |
| video.play(); | |
| } else { | |
| video.pause(); | |
| } | |
| }); | |
| item.appendChild(video); | |
| videoCarousel.appendChild(item); | |
| const dot = document.createElement('div'); | |
| dot.className = 'carousel-dot'; | |
| dot.addEventListener('click', () => { | |
| currentIndex = index; | |
| updateCarousel(); | |
| }); | |
| carouselDots.appendChild(dot); | |
| if (videoItems.length > 1) { | |
| prevBtn.classList.remove('hidden'); | |
| nextBtn.classList.remove('hidden'); | |
| } | |
| // If it's the first video, hide loader and update display | |
| if (videoItems.length === 1) { | |
| loader.classList.add("hidden"); | |
| placeholder.style.display = "none"; | |
| updateCarousel(); | |
| saveVideoBtn.disabled = false; // Enable the save button | |
| } | |
| } | |
| // --- Form Submission --- | |
| generateBtn.addEventListener("click", async () => { | |
| resetCarousel(); | |
| loader.classList.remove("hidden"); | |
| placeholder.style.display = "none"; | |
| errorContainer.classList.add("hidden"); | |
| const threadCount = parseInt(threadCountInput.value, 10) || 1; | |
| const sampleCount = parseInt(sampleCountInput.value, 10) || 1; | |
| const baseFormData = () => { | |
| const formData = new FormData(); | |
| formData.append("api_key", apiKeyInput.value); | |
| formData.append("prompt", promptInput.value); | |
| formData.append("negative_prompt", negativePromptInput.value); | |
| formData.append("model", modelSelect.value); | |
| formData.append("aspect_ratio", aspectRatioSelect.value); | |
| formData.append("duration", durationInput.value); | |
| formData.append("resolution", resolutionSelect.value); | |
| if (seedInput.value) { | |
| formData.append("seed", seedInput.value); | |
| } | |
| formData.append("person_generation", personGenSelect.value); | |
| formData.append("generate_audio", generateAudioSwitch.checked); | |
| formData.append("enhance_prompt", enhancePromptSwitch.checked); | |
| if (imagePromptInput.files[0]) { | |
| formData.append("image", imagePromptInput.files[0]); | |
| } | |
| if (videoPromptInput.files[0]) { | |
| formData.append("video", videoPromptInput.files[0]); | |
| } | |
| // 现在所有宽高比都支持图片处理参数 | |
| formData.append("image_process_mode", imageProcessModeSelect.value); | |
| formData.append("image_fill_color", imageFillColorInput.value); | |
| return formData; | |
| }; | |
| if (threadCount > 1) { | |
| const requests = []; | |
| for (let i = 0; i < threadCount; i++) { | |
| const formData = baseFormData(); | |
| formData.append("sample_count", String(sampleCount)); // 每个线程生成 sampleCount 个视频 | |
| const request = fetch("/api/generate_video", { | |
| method: "POST", | |
| body: formData, | |
| }) | |
| .then(response => { | |
| if (!response.ok) { | |
| return response.json().then(err => Promise.reject(err)); | |
| } | |
| return response.json(); | |
| }) | |
| .then(data => { | |
| if (data.video_urls && data.video_urls.length > 0) { | |
| // 将该线程返回的全部视频加入轮播 | |
| data.video_urls.forEach(addVideoToCarousel); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error(`Thread ${i + 1} failed:`, error.detail || error.message); | |
| }); | |
| requests.push(request); | |
| } | |
| Promise.allSettled(requests).then(() => { | |
| if (videoItems.length === 0) { | |
| // All requests failed | |
| errorContainer.textContent = "所有视频生成请求均失败。"; | |
| errorContainer.classList.remove("hidden"); | |
| resetCarousel(); | |
| } else { | |
| // At least one video was generated successfully | |
| playNotificationSound(); | |
| } | |
| loader.classList.add("hidden"); | |
| }); | |
| } else { | |
| // --- Single Request Logic (Original Flow) --- | |
| const formData = baseFormData(); | |
| formData.append("sample_count", sampleCountInput.value); | |
| try { | |
| const response = await fetch("/api/generate_video", { | |
| method: "POST", | |
| body: formData, | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.detail || "发生未知错误。"); | |
| } | |
| const urls = data.video_urls; | |
| if (!urls || !Array.isArray(urls) || urls.length === 0) { | |
| throw new Error("API响应格式不正确或未返回视频。"); | |
| } | |
| urls.forEach(addVideoToCarousel); | |
| if (videoItems.length > 0) { | |
| currentIndex = 0; | |
| updateCarousel(); | |
| playNotificationSound(); // Play notification sound for successful generation | |
| } else { | |
| resetCarousel(); // No videos were successfully added | |
| } | |
| } catch (error) { | |
| errorContainer.textContent = error.message; | |
| errorContainer.classList.remove("hidden"); | |
| resetCarousel(); | |
| } finally { | |
| loader.classList.add("hidden"); | |
| } | |
| } | |
| }); | |
| // --- Save Video Logic --- | |
| function updateSaveVideoButtonState() { | |
| if (videoItems.length === 0) { | |
| saveVideoBtn.disabled = true; | |
| saveVideoBtn.classList.remove('saved'); | |
| saveVideoBtn.querySelector('span').textContent = '保存此视频到服务器'; | |
| return; | |
| } | |
| const currentVideoUrl = videoItems[currentIndex]; | |
| if (savedVideos.includes(currentVideoUrl)) { | |
| saveVideoBtn.disabled = true; | |
| saveVideoBtn.classList.add('saved'); | |
| saveVideoBtn.querySelector('span').textContent = '已保存至服务器'; | |
| } else { | |
| saveVideoBtn.disabled = false; | |
| saveVideoBtn.classList.remove('saved'); | |
| saveVideoBtn.querySelector('span').textContent = '保存此视频到服务器'; | |
| } | |
| } | |
| async function saveCurrentVideo() { | |
| if (videoItems.length === 0) return; | |
| const videoUrlToSave = videoItems[currentIndex]; | |
| if (savedVideos.includes(videoUrlToSave)) return; | |
| // Temporarily disable to prevent double-clicking | |
| saveVideoBtn.disabled = true; | |
| const originalText = saveVideoBtn.querySelector('span').textContent; | |
| saveVideoBtn.querySelector('span').textContent = '保存中...'; | |
| try { | |
| const formData = new FormData(); | |
| formData.append('video_url', videoUrlToSave); | |
| const response = await fetch('/api/save_video', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.detail || '保存失败'); | |
| } | |
| // Success | |
| savedVideos.push(videoUrlToSave); | |
| updateSaveVideoButtonState(); | |
| } catch (error) { | |
| // Show a temporary error message on the button | |
| saveVideoBtn.querySelector('span').textContent = '保存失败!'; | |
| console.error('Save video error:', error); | |
| // Revert button state after a delay | |
| setTimeout(() => { | |
| saveVideoBtn.querySelector('span').textContent = originalText; | |
| updateSaveVideoButtonState(); // Re-evaluates the correct state | |
| }, 2000); | |
| } | |
| } | |
| saveVideoBtn.addEventListener('click', saveCurrentVideo); | |
| // --- Paste to Upload Logic --- | |
| const imageDropZone = document.getElementById("image-drop-zone"); | |
| const videoDropZone = document.getElementById("video-drop-zone"); | |
| imageDropZone.addEventListener('mouseenter', () => currentPasteTarget = 'image'); | |
| imageDropZone.addEventListener('mouseleave', () => currentPasteTarget = null); | |
| videoDropZone.addEventListener('mouseenter', () => currentPasteTarget = 'video'); | |
| videoDropZone.addEventListener('mouseleave', () => currentPasteTarget = null); | |
| document.addEventListener('paste', (event) => { | |
| if (!currentPasteTarget) return; | |
| // Don't interfere with text inputs | |
| const activeElement = document.activeElement; | |
| if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { | |
| return; | |
| } | |
| const items = event.clipboardData.items; | |
| if (!items) return; | |
| for (let i = 0; i < items.length; i++) { | |
| if (items[i].kind === 'file') { | |
| const file = items[i].getAsFile(); | |
| if (!file) continue; | |
| event.preventDefault(); // Prevent default paste behavior | |
| if (currentPasteTarget === 'image' && file.type.startsWith('image/')) { | |
| // Create a new FileList and assign it to the input | |
| const dataTransfer = new DataTransfer(); | |
| dataTransfer.items.add(file); | |
| imagePromptInput.files = dataTransfer.files; | |
| // Manually trigger the change event to update the UI | |
| imagePromptInput.dispatchEvent(new Event('change')); | |
| break; // Handle only the first valid file | |
| } | |
| if (currentPasteTarget === 'video' && file.type.startsWith('video/')) { | |
| const dataTransfer = new DataTransfer(); | |
| dataTransfer.items.add(file); | |
| videoPromptInput.files = dataTransfer.files; | |
| videoPromptInput.dispatchEvent(new Event('change')); | |
| break; // Handle only the first valid file | |
| } | |
| } | |
| } | |
| }); | |
| // --- Initial Load --- | |
| loadSettings(); | |
| loadSoundSettings(); // Load sound settings | |
| // Set initial state for image processing UI | |
| updateImageProcessingState(); | |
| updateSaveButtonState(); | |
| renderSavedKeys(); // Initial render of the key list | |
| }); |