import { muapi } from '../lib/muapi.js'; import { lipsyncModels, imageLipSyncModels, videoLipSyncModels, getLipSyncModelById, getResolutionsForLipSyncModel } from '../lib/models.js'; import { AuthModal } from './AuthModal.js'; import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js'; export function LipSyncStudio() { const container = document.createElement('div'); container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden'; // --- State --- // 'image' mode: portrait image + audio โ†’ video // 'video' mode: existing video + audio โ†’ lipsync video let inputMode = 'image'; let selectedModel = imageLipSyncModels[0].id; let selectedResolution = imageLipSyncModels[0].inputs?.resolution?.default || '480p'; let uploadedImageUrl = null; let uploadedVideoUrl = null; let uploadedAudioUrl = null; let dropdownOpen = null; const getCurrentModels = () => inputMode === 'image' ? imageLipSyncModels : videoLipSyncModels; const getCurrentModel = () => lipsyncModels.find(m => m.id === selectedModel); // ========================================== // 1. HERO SECTION // ========================================== const hero = document.createElement('div'); hero.className = 'flex flex-col items-center mb-10 md:mb-20 animate-fade-in-up transition-all duration-700'; hero.innerHTML = `
๐ŸŽ™

Lip Sync

Animate portraits or sync lips to audio with AI

`; container.appendChild(hero); // ========================================== // 2. INPUT BAR // ========================================== const promptWrapper = document.createElement('div'); promptWrapper.className = 'w-full max-w-4xl relative z-40 animate-fade-in-up'; promptWrapper.style.animationDelay = '0.2s'; const bar = document.createElement('div'); bar.className = 'w-full bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-5 flex flex-col gap-3 md:gap-5 shadow-3xl'; // --- Mode Toggle (Image vs Video) --- const modeToggleRow = document.createElement('div'); modeToggleRow.className = 'flex items-center gap-2 px-2'; const modeLabel = document.createElement('span'); modeLabel.className = 'text-xs text-muted font-bold uppercase tracking-widest mr-2'; modeLabel.textContent = 'Input:'; const imageModeBtn = document.createElement('button'); imageModeBtn.type = 'button'; imageModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-primary bg-primary/10 text-primary'; imageModeBtn.textContent = '๐Ÿ–ผ Portrait Image'; const videoModeBtn = document.createElement('button'); videoModeBtn.type = 'button'; videoModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-white/10 text-muted hover:border-white/30 hover:text-white'; videoModeBtn.textContent = '๐ŸŽฌ Video'; modeToggleRow.appendChild(modeLabel); modeToggleRow.appendChild(imageModeBtn); modeToggleRow.appendChild(videoModeBtn); bar.appendChild(modeToggleRow); // --- Uploads Row --- const uploadsRow = document.createElement('div'); uploadsRow.className = 'flex items-start gap-3 px-2'; // โ”€โ”€ Image Upload โ”€โ”€ const imageFileInput = document.createElement('input'); imageFileInput.type = 'file'; imageFileInput.accept = 'image/*'; imageFileInput.className = 'hidden'; const imageUploadBtn = document.createElement('button'); imageUploadBtn.type = 'button'; imageUploadBtn.title = 'Upload portrait image'; imageUploadBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex flex-col items-center justify-center gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group relative overflow-hidden'; imageUploadBtn.innerHTML = `
IMAGE
`; imageUploadBtn.appendChild(imageFileInput); // โ”€โ”€ Video Upload โ”€โ”€ const videoFileInput = document.createElement('input'); videoFileInput.type = 'file'; videoFileInput.accept = 'video/*'; videoFileInput.className = 'hidden'; const videoUploadBtn = document.createElement('button'); videoUploadBtn.type = 'button'; videoUploadBtn.title = 'Upload source video'; videoUploadBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex flex-col items-center justify-center gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group relative overflow-hidden hidden'; videoUploadBtn.innerHTML = `
VIDEO
`; videoUploadBtn.appendChild(videoFileInput); // โ”€โ”€ Audio Upload โ”€โ”€ const audioFileInput = document.createElement('input'); audioFileInput.type = 'file'; audioFileInput.accept = 'audio/*'; audioFileInput.className = 'hidden'; const audioUploadBtn = document.createElement('button'); audioUploadBtn.type = 'button'; audioUploadBtn.title = 'Upload audio file'; audioUploadBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex flex-col items-center justify-center gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group relative overflow-hidden'; audioUploadBtn.innerHTML = `
AUDIO
`; audioUploadBtn.appendChild(audioFileInput); // โ”€โ”€ Prompt Textarea โ”€โ”€ const textarea = document.createElement('textarea'); textarea.placeholder = 'Optional: describe the talking style or motion...'; textarea.className = 'flex-1 bg-transparent text-white placeholder-muted/50 text-sm resize-none outline-none min-h-[56px] leading-relaxed pt-1'; textarea.rows = 2; uploadsRow.appendChild(imageUploadBtn); uploadsRow.appendChild(videoUploadBtn); uploadsRow.appendChild(audioUploadBtn); uploadsRow.appendChild(textarea); bar.appendChild(uploadsRow); // โ”€โ”€ Status labels โ”€โ”€ const statusRow = document.createElement('div'); statusRow.className = 'flex items-center gap-3 px-2 text-xs text-muted'; const imageStatusLabel = document.createElement('span'); imageStatusLabel.className = 'text-muted'; imageStatusLabel.textContent = 'No image'; const audioStatusLabel = document.createElement('span'); audioStatusLabel.className = 'text-muted'; audioStatusLabel.textContent = 'No audio'; statusRow.appendChild(imageStatusLabel); statusRow.appendChild(document.createTextNode(' ยท ')); statusRow.appendChild(audioStatusLabel); bar.appendChild(statusRow); // โ”€โ”€ Bottom Controls Row โ”€โ”€ const bottomRow = document.createElement('div'); bottomRow.className = 'flex items-center gap-2 md:gap-3 flex-wrap px-2'; // Model selector const modelBtn = document.createElement('button'); modelBtn.id = 'ls-model-btn'; modelBtn.type = 'button'; modelBtn.className = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-primary/40 transition-all text-xs font-bold text-white group'; modelBtn.innerHTML = `${getCurrentModels()[0].name}`; // Resolution selector const resolutionBtn = document.createElement('button'); resolutionBtn.id = 'ls-resolution-btn'; resolutionBtn.type = 'button'; resolutionBtn.className = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-primary/40 transition-all text-xs font-bold text-white group'; resolutionBtn.innerHTML = `${selectedResolution}`; // Generate button const generateBtn = document.createElement('button'); generateBtn.id = 'ls-generate-btn'; generateBtn.type = 'button'; generateBtn.className = 'ml-auto px-6 py-2.5 bg-primary text-black font-black text-sm rounded-2xl hover:scale-105 active:scale-95 transition-all shadow-glow disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'; generateBtn.textContent = 'Generate โœจ'; bottomRow.appendChild(modelBtn); bottomRow.appendChild(resolutionBtn); bottomRow.appendChild(generateBtn); bar.appendChild(bottomRow); promptWrapper.appendChild(bar); container.appendChild(promptWrapper); // ========================================== // 3. DROPDOWN SYSTEM // ========================================== const dropdown = document.createElement('div'); dropdown.className = 'hidden fixed z-[100] bg-[#111] border border-white/10 rounded-2xl shadow-3xl p-2 min-w-[200px] max-h-[400px] overflow-y-auto custom-scrollbar'; dropdown.id = 'ls-dropdown'; const closeDropdown = (e) => { if (!e || (!dropdown.contains(e.target) && !e.target.closest('[id^="ls-"]'))) { dropdown.classList.add('hidden'); dropdownOpen = null; } }; const populateDropdown = (type) => { dropdown.innerHTML = ''; if (type === 'model') { const models = getCurrentModels(); models.forEach(m => { const item = document.createElement('button'); item.type = 'button'; item.className = `w-full text-left px-4 py-2.5 rounded-xl text-sm transition-all hover:bg-white/10 ${m.id === selectedModel ? 'text-primary font-bold bg-primary/5' : 'text-white font-medium'}`; item.innerHTML = `
${m.name}
${m.description?.slice(0, 60)}...
`; item.onclick = () => { selectedModel = m.id; document.getElementById('ls-model-btn-label').textContent = m.name; const resolutions = getResolutionsForLipSyncModel(selectedModel); if (resolutions.length > 0) { selectedResolution = m.inputs?.resolution?.default || resolutions[0]; document.getElementById('ls-resolution-btn-label').textContent = selectedResolution; resolutionBtn.classList.remove('hidden'); } else { resolutionBtn.classList.add('hidden'); } textarea.style.display = m.hasPrompt ? '' : 'none'; closeDropdown(); }; dropdown.appendChild(item); }); } else if (type === 'resolution') { const resolutions = getResolutionsForLipSyncModel(selectedModel); resolutions.forEach(r => { const item = document.createElement('button'); item.type = 'button'; item.className = `w-full text-left px-4 py-2.5 rounded-xl text-sm transition-all hover:bg-white/10 ${r === selectedResolution ? 'text-primary font-bold bg-primary/5' : 'text-white font-medium'}`; item.textContent = r; item.onclick = () => { selectedResolution = r; document.getElementById('ls-resolution-btn-label').textContent = r; closeDropdown(); }; dropdown.appendChild(item); }); } }; const openDropdown = (type, anchorBtn) => { dropdownOpen = type; // Populate and temporarily show off-screen to measure height populateDropdown(type); dropdown.style.top = '-9999px'; dropdown.style.bottom = 'auto'; dropdown.classList.remove('hidden'); const ddHeight = dropdown.offsetHeight; const rect = anchorBtn.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom - 8; const spaceAbove = rect.top - 8; if (spaceBelow >= ddHeight || spaceBelow >= spaceAbove) { dropdown.style.top = `${rect.bottom + 8}px`; dropdown.style.bottom = 'auto'; dropdown.style.maxHeight = `${Math.max(150, spaceBelow - 8)}px`; } else { dropdown.style.top = 'auto'; dropdown.style.bottom = `${window.innerHeight - rect.top + 8}px`; dropdown.style.maxHeight = `${Math.max(150, spaceAbove - 8)}px`; } dropdown.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`; }; modelBtn.onclick = (e) => { e.stopPropagation(); if (dropdownOpen === 'model') { closeDropdown(); } else { openDropdown('model', modelBtn); } }; resolutionBtn.onclick = (e) => { e.stopPropagation(); if (dropdownOpen === 'resolution') { closeDropdown(); } else { openDropdown('resolution', resolutionBtn); } }; window.addEventListener('click', closeDropdown); container.appendChild(dropdown); // ========================================== // 4. MODE SWITCHING LOGIC // ========================================== const updateUIForMode = () => { if (inputMode === 'image') { imageModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-primary bg-primary/10 text-primary'; videoModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-white/10 text-muted hover:border-white/30 hover:text-white'; imageUploadBtn.classList.remove('hidden'); videoUploadBtn.classList.add('hidden'); } else { videoModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-primary bg-primary/10 text-primary'; imageModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-white/10 text-muted hover:border-white/30 hover:text-white'; videoUploadBtn.classList.remove('hidden'); imageUploadBtn.classList.add('hidden'); } // Switch to first model of new mode const models = getCurrentModels(); selectedModel = models[0].id; document.getElementById('ls-model-btn-label').textContent = models[0].name; // Update resolution const resolutions = getResolutionsForLipSyncModel(selectedModel); if (resolutions.length > 0) { selectedResolution = models[0].inputs?.resolution?.default || resolutions[0]; document.getElementById('ls-resolution-btn-label').textContent = selectedResolution; resolutionBtn.classList.remove('hidden'); } else { resolutionBtn.classList.add('hidden'); } // Show/hide prompt textarea.style.display = models[0].hasPrompt ? '' : 'none'; }; imageModeBtn.onclick = () => { if (inputMode === 'image') return; inputMode = 'image'; uploadedVideoUrl = null; updateVideoUploadState('idle'); updateUIForMode(); }; videoModeBtn.onclick = () => { if (inputMode === 'video') return; inputMode = 'video'; uploadedImageUrl = null; updateImageUploadState('idle'); updateUIForMode(); }; // ========================================== // 5. UPLOAD HANDLERS // ========================================== const updateImageUploadState = (state, filename) => { const icon = imageUploadBtn.querySelector('.image-icon'); const spinner = imageUploadBtn.querySelector('.image-spinner'); const ready = imageUploadBtn.querySelector('.image-ready'); if (state === 'idle') { icon.classList.remove('hidden'); icon.classList.add('flex'); spinner.classList.add('hidden'); spinner.classList.remove('flex'); ready.classList.add('hidden'); ready.classList.remove('flex'); imageUploadBtn.classList.remove('border-primary/60'); imageUploadBtn.classList.add('border-white/10'); imageUploadBtn.title = 'Upload portrait image'; imageStatusLabel.textContent = 'No image'; imageStatusLabel.className = 'text-muted'; } else if (state === 'loading') { icon.classList.add('hidden'); icon.classList.remove('flex'); spinner.classList.remove('hidden'); spinner.classList.add('flex'); ready.classList.add('hidden'); ready.classList.remove('flex'); } else if (state === 'ready') { icon.classList.add('hidden'); icon.classList.remove('flex'); spinner.classList.add('hidden'); spinner.classList.remove('flex'); ready.classList.remove('hidden'); ready.classList.add('flex'); imageUploadBtn.classList.remove('border-white/10'); imageUploadBtn.classList.add('border-primary/60'); imageUploadBtn.title = `${filename} โ€” click to clear`; imageStatusLabel.textContent = `โœ“ ${filename}`; imageStatusLabel.className = 'text-primary'; } }; const updateVideoUploadState = (state, filename) => { const icon = videoUploadBtn.querySelector('.video-icon'); const spinner = videoUploadBtn.querySelector('.video-spinner'); const ready = videoUploadBtn.querySelector('.video-ready'); if (state === 'idle') { icon.classList.remove('hidden'); icon.classList.add('flex'); spinner.classList.add('hidden'); spinner.classList.remove('flex'); ready.classList.add('hidden'); ready.classList.remove('flex'); videoUploadBtn.classList.remove('border-primary/60'); videoUploadBtn.classList.add('border-white/10'); videoUploadBtn.title = 'Upload source video'; imageStatusLabel.textContent = 'No video'; imageStatusLabel.className = 'text-muted'; } else if (state === 'loading') { icon.classList.add('hidden'); icon.classList.remove('flex'); spinner.classList.remove('hidden'); spinner.classList.add('flex'); ready.classList.add('hidden'); ready.classList.remove('flex'); } else if (state === 'ready') { icon.classList.add('hidden'); icon.classList.remove('flex'); spinner.classList.add('hidden'); spinner.classList.remove('flex'); ready.classList.remove('hidden'); ready.classList.add('flex'); videoUploadBtn.classList.remove('border-white/10'); videoUploadBtn.classList.add('border-primary/60'); videoUploadBtn.title = `${filename} โ€” click to clear`; imageStatusLabel.textContent = `โœ“ ${filename}`; imageStatusLabel.className = 'text-primary'; } }; const updateAudioUploadState = (state, filename) => { const icon = audioUploadBtn.querySelector('.audio-icon'); const spinner = audioUploadBtn.querySelector('.audio-spinner'); const ready = audioUploadBtn.querySelector('.audio-ready'); if (state === 'idle') { icon.classList.remove('hidden'); icon.classList.add('flex'); spinner.classList.add('hidden'); spinner.classList.remove('flex'); ready.classList.add('hidden'); ready.classList.remove('flex'); audioUploadBtn.classList.remove('border-primary/60'); audioUploadBtn.classList.add('border-white/10'); audioUploadBtn.title = 'Upload audio file'; audioStatusLabel.textContent = 'No audio'; audioStatusLabel.className = 'text-muted'; } else if (state === 'loading') { icon.classList.add('hidden'); icon.classList.remove('flex'); spinner.classList.remove('hidden'); spinner.classList.add('flex'); ready.classList.add('hidden'); ready.classList.remove('flex'); } else if (state === 'ready') { icon.classList.add('hidden'); icon.classList.remove('flex'); spinner.classList.add('hidden'); spinner.classList.remove('flex'); ready.classList.remove('hidden'); ready.classList.add('flex'); audioUploadBtn.classList.remove('border-white/10'); audioUploadBtn.classList.add('border-primary/60'); audioUploadBtn.title = `${filename} โ€” click to clear`; audioStatusLabel.textContent = `โœ“ ${filename}`; audioStatusLabel.className = 'text-primary'; } }; imageUploadBtn.onclick = async (e) => { e.stopPropagation(); if (uploadedImageUrl) { uploadedImageUrl = null; updateImageUploadState('idle'); return; } imageFileInput.click(); }; imageFileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) { AuthModal(() => imageFileInput.click()); return; } updateImageUploadState('loading'); try { uploadedImageUrl = await muapi.uploadFile(file); updateImageUploadState('ready', file.name); } catch (err) { updateImageUploadState('idle'); alert(`Image upload failed: ${err.message}`); } imageFileInput.value = ''; }; videoUploadBtn.onclick = async (e) => { e.stopPropagation(); if (uploadedVideoUrl) { uploadedVideoUrl = null; updateVideoUploadState('idle'); return; } videoFileInput.click(); }; videoFileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) { AuthModal(() => videoFileInput.click()); return; } updateVideoUploadState('loading'); try { uploadedVideoUrl = await muapi.uploadFile(file); updateVideoUploadState('ready', file.name); } catch (err) { updateVideoUploadState('idle'); alert(`Video upload failed: ${err.message}`); } videoFileInput.value = ''; }; audioUploadBtn.onclick = async (e) => { e.stopPropagation(); if (uploadedAudioUrl) { uploadedAudioUrl = null; updateAudioUploadState('idle'); return; } audioFileInput.click(); }; audioFileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) { AuthModal(() => audioFileInput.click()); return; } updateAudioUploadState('loading'); try { uploadedAudioUrl = await muapi.uploadFile(file); updateAudioUploadState('ready', file.name); } catch (err) { updateAudioUploadState('idle'); alert(`Audio upload failed: ${err.message}`); } audioFileInput.value = ''; }; // Hide resolution if first model has none if (getResolutionsForLipSyncModel(selectedModel).length === 0) { resolutionBtn.classList.add('hidden'); } // ========================================== // 6. CANVAS AREA + HISTORY // ========================================== const generationHistory = []; const historySidebar = document.createElement('div'); historySidebar.className = 'fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500 translate-x-full opacity-0'; historySidebar.id = 'lipsync-history-sidebar'; const historyLabel = document.createElement('div'); historyLabel.className = 'text-[9px] font-bold text-muted uppercase tracking-widest mb-2'; historyLabel.textContent = 'History'; historySidebar.appendChild(historyLabel); const historyList = document.createElement('div'); historyList.className = 'flex flex-col gap-2 w-full px-2'; historySidebar.appendChild(historyList); container.appendChild(historySidebar); // Main canvas const canvas = document.createElement('div'); canvas.className = 'absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-10 opacity-0 pointer-events-none transition-all duration-1000 translate-y-10 scale-95'; const videoContainer = document.createElement('div'); videoContainer.className = 'relative group'; const resultVideo = document.createElement('video'); resultVideo.className = 'max-h-[60vh] max-w-[80vw] rounded-3xl shadow-3xl border border-white/10 interactive-glow object-contain'; resultVideo.controls = true; resultVideo.loop = true; resultVideo.autoplay = true; resultVideo.muted = false; resultVideo.playsInline = true; videoContainer.appendChild(resultVideo); const canvasControls = document.createElement('div'); canvasControls.className = 'mt-6 flex gap-3 opacity-0 transition-opacity delay-500 duration-500 justify-center'; const regenerateBtn = document.createElement('button'); regenerateBtn.className = 'bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white'; regenerateBtn.textContent = 'โ†ป Regenerate'; const downloadBtn = document.createElement('button'); downloadBtn.className = 'bg-primary text-black px-6 py-2.5 rounded-2xl text-xs font-bold transition-all shadow-glow active:scale-95'; downloadBtn.textContent = 'โ†“ Download'; const newBtn = document.createElement('button'); newBtn.className = 'bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white'; newBtn.textContent = '+ New'; canvasControls.appendChild(regenerateBtn); canvasControls.appendChild(downloadBtn); canvasControls.appendChild(newBtn); canvas.appendChild(videoContainer); canvas.appendChild(canvasControls); container.appendChild(canvas); const showVideoInCanvas = (videoUrl) => { hero.classList.add('hidden'); promptWrapper.classList.add('hidden'); resultVideo.src = videoUrl; resultVideo.onloadeddata = () => { canvas.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-10', 'scale-95'); canvas.classList.add('opacity-100', 'translate-y-0', 'scale-100'); canvasControls.classList.remove('opacity-0'); canvasControls.classList.add('opacity-100'); }; }; const addToHistory = (entry) => { generationHistory.unshift(entry); localStorage.setItem('lipsync_history', JSON.stringify(generationHistory.slice(0, 30))); historySidebar.classList.remove('translate-x-full', 'opacity-0'); historySidebar.classList.add('translate-x-0', 'opacity-100'); renderHistory(); }; const renderHistory = () => { historyList.innerHTML = ''; generationHistory.forEach((entry, idx) => { const thumb = document.createElement('div'); thumb.className = `relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${idx === 0 ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`; thumb.innerHTML = `
`; thumb.onclick = (e) => { if (e.target.closest('.hist-download')) { downloadFile(entry.url, `lipsync-${entry.id || idx}.mp4`); return; } showVideoInCanvas(entry.url); historyList.querySelectorAll('div').forEach(t => { t.classList.remove('border-primary', 'shadow-glow'); t.classList.add('border-white/10'); }); thumb.classList.remove('border-white/10'); thumb.classList.add('border-primary', 'shadow-glow'); }; historyList.appendChild(thumb); }); }; const downloadFile = async (url, filename) => { try { const response = await fetch(url); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch { window.open(url, '_blank'); } }; // Load history try { const saved = JSON.parse(localStorage.getItem('lipsync_history') || '[]'); if (saved.length > 0) { saved.forEach(e => generationHistory.push(e)); historySidebar.classList.remove('translate-x-full', 'opacity-0'); historySidebar.classList.add('translate-x-0', 'opacity-100'); renderHistory(); } } catch { /* ignore */ } // Resume pending jobs (async () => { const pending = getPendingJobs('lipsync'); if (!pending.length) return; const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) return; const banner = document.createElement('div'); banner.className = 'fixed top-4 left-1/2 -translate-x-1/2 z-[200] bg-[#111] border border-white/10 text-white text-sm px-5 py-3 rounded-2xl shadow-xl flex items-center gap-3'; banner.innerHTML = `โ—Œ `; document.body.appendChild(banner); let remaining = pending.length; pending.forEach(async (job) => { const elapsedAttempts = Math.floor((Date.now() - job.submittedAt) / job.interval); const attemptsLeft = Math.max(1, job.maxAttempts - elapsedAttempts); try { const result = await muapi.pollForResult(job.requestId, apiKey, attemptsLeft, job.interval); const url = result.outputs?.[0] || result.url || result.output?.url; if (url) addToHistory({ id: job.requestId, url, ...job.historyMeta, timestamp: new Date().toISOString() }); } catch (e) { console.warn('[LipSyncStudio] Pending job failed:', job.requestId, e.message); } finally { removePendingJob(job.requestId); remaining--; if (remaining === 0) banner.remove(); else banner.querySelector('.banner-text').textContent = `Resuming ${remaining} pending generation${remaining > 1 ? 's' : ''}โ€ฆ`; } }); })(); // ========================================== // 7. CANVAS BUTTON HANDLERS // ========================================== downloadBtn.onclick = () => { const current = resultVideo.src; if (current) { const entry = generationHistory.find(e => e.url === current); downloadFile(current, `lipsync-${entry?.id || 'clip'}.mp4`); } }; regenerateBtn.onclick = () => generateBtn.click(); newBtn.onclick = () => { canvas.classList.add('opacity-0', 'pointer-events-none', 'translate-y-10', 'scale-95'); canvas.classList.remove('opacity-100', 'translate-y-0', 'scale-100'); canvasControls.classList.add('opacity-0'); canvasControls.classList.remove('opacity-100'); hero.classList.remove('hidden', 'opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none'); promptWrapper.classList.remove('hidden', 'opacity-40'); textarea.value = ''; textarea.focus(); }; // ========================================== // 8. GENERATION LOGIC // ========================================== generateBtn.onclick = async () => { const model = getCurrentModel(); const prompt = textarea.value.trim(); // Validation if (!uploadedAudioUrl) { alert('Please upload an audio file first.'); return; } if (inputMode === 'image' && !uploadedImageUrl) { alert('Please upload a portrait image first.'); return; } if (inputMode === 'video' && !uploadedVideoUrl) { alert('Please upload a source video first.'); return; } const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) { AuthModal(() => generateBtn.click()); return; } hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none'); generateBtn.disabled = true; generateBtn.innerHTML = `โ—Œ Generating...`; let hadError = false; let capturedRequestId = null; const historyMeta = { prompt, model: selectedModel }; const onRequestId = (rid) => { capturedRequestId = rid; savePendingJob({ requestId: rid, studioType: 'lipsync', historyMeta, maxAttempts: 900, interval: 2000, submittedAt: Date.now() }); }; try { const lipsyncParams = { model: selectedModel, audio_url: uploadedAudioUrl, onRequestId }; if (inputMode === 'image') { lipsyncParams.image_url = uploadedImageUrl; } else { lipsyncParams.video_url = uploadedVideoUrl; } if (prompt && model?.hasPrompt) lipsyncParams.prompt = prompt; const resolutions = getResolutionsForLipSyncModel(selectedModel); if (resolutions.length > 0) lipsyncParams.resolution = selectedResolution; if (model?.hasSeed) lipsyncParams.seed = -1; const res = await muapi.processLipSync(lipsyncParams); console.log('[LipSyncStudio] Response:', res); if (res && res.url) { if (capturedRequestId) removePendingJob(capturedRequestId); const genId = res.id || capturedRequestId || Date.now().toString(); addToHistory({ id: genId, url: res.url, prompt, model: selectedModel, timestamp: new Date().toISOString() }); showVideoInCanvas(res.url); } else { throw new Error('No video URL returned by API'); } } catch (e) { hadError = true; if (capturedRequestId) removePendingJob(capturedRequestId); console.error(e); hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none'); generateBtn.innerHTML = `Error: ${e.message.slice(0, 60)}`; setTimeout(() => { generateBtn.innerHTML = `Generate โœจ`; }, 4000); } finally { generateBtn.disabled = false; if (!hadError) generateBtn.innerHTML = `Generate โœจ`; } }; return container; }