diff --git a/src/components/LipSyncStudio.js b/src/components/LipSyncStudio.js index 721edfd..d1fb429 100644 --- a/src/components/LipSyncStudio.js +++ b/src/components/LipSyncStudio.js @@ -1,6 +1,7 @@ import { muapi } from '../lib/muapi.js'; import { lipsyncModels, imageLipSyncModels, videoLipSyncModels, getLipSyncModelById, getResolutionsForLipSyncModel } from '../lib/models.js'; import { AuthModal } from './AuthModal.js'; +import { createUploadPicker } from './UploadPicker.js'; import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js'; export function LipSyncStudio() { @@ -89,74 +90,161 @@ export function LipSyncStudio() { 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'; + // ── Image Upload — uses createUploadPicker (same as VideoStudio) ── + const imagePicker = createUploadPicker({ + anchorContainer: container, + onSelect: ({ url }) => { + uploadedImageUrl = url; + imageStatusLabel.textContent = '✓ Image ready'; + imageStatusLabel.className = 'text-primary'; + }, + onClear: () => { + uploadedImageUrl = null; + imageStatusLabel.textContent = 'No image'; + imageStatusLabel.className = 'text-muted'; + } + }); + // Size the trigger to match our other buttons + imagePicker.trigger.className = imagePicker.trigger.className + .replace('w-10 h-10', 'w-14 h-14') + .replace('mt-1.5', ''); + container.appendChild(imagePicker.panel); - 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 = ` -
- - - `; - imageUploadBtn.appendChild(imageFileInput); - - // ── Video Upload ── + // ── Video Upload Button (VideoStudio pattern — separate state divs, file input inside btn) ── 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 = ` - - - - `; - videoUploadBtn.appendChild(videoFileInput); + const videoPickerBtn = document.createElement('button'); + videoPickerBtn.type = 'button'; + videoPickerBtn.title = 'Upload source video'; + videoPickerBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden hidden bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group'; - // ── Audio Upload ── + const videoIconEl = document.createElement('div'); + videoIconEl.className = 'flex flex-col items-center justify-center gap-1 w-full h-full'; + videoIconEl.innerHTML = `VIDEO`; + + const videoSpinnerEl = document.createElement('div'); + videoSpinnerEl.className = 'hidden items-center justify-center w-full h-full'; + videoSpinnerEl.innerHTML = `◌`; + + const videoReadyEl = document.createElement('div'); + videoReadyEl.className = 'hidden flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10'; + videoReadyEl.innerHTML = `READY`; + + videoPickerBtn.appendChild(videoFileInput); + videoPickerBtn.appendChild(videoIconEl); + videoPickerBtn.appendChild(videoSpinnerEl); + videoPickerBtn.appendChild(videoReadyEl); + + const showVideoIcon = () => { + videoIconEl.classList.replace('hidden', 'flex'); + videoSpinnerEl.classList.add('hidden'); videoSpinnerEl.classList.remove('flex'); + videoReadyEl.classList.add('hidden'); videoReadyEl.classList.remove('flex'); + videoPickerBtn.classList.remove('border-primary/60'); videoPickerBtn.classList.add('border-white/10'); + videoPickerBtn.title = 'Upload source video'; + mediaStatusLabel.textContent = 'No video'; mediaStatusLabel.className = 'text-muted'; + }; + const showVideoSpinner = () => { + videoIconEl.classList.add('hidden'); videoIconEl.classList.remove('flex'); + videoSpinnerEl.classList.replace('hidden', 'flex'); + videoReadyEl.classList.add('hidden'); videoReadyEl.classList.remove('flex'); + }; + const showVideoReady = (name) => { + videoIconEl.classList.add('hidden'); videoIconEl.classList.remove('flex'); + videoSpinnerEl.classList.add('hidden'); videoSpinnerEl.classList.remove('flex'); + videoReadyEl.classList.replace('hidden', 'flex'); + videoPickerBtn.classList.remove('border-white/10'); videoPickerBtn.classList.add('border-primary/60'); + videoPickerBtn.title = `${name} — click to clear`; + mediaStatusLabel.textContent = `✓ ${name}`; mediaStatusLabel.className = 'text-primary'; + }; + + videoPickerBtn.onclick = (e) => { + e.stopPropagation(); + if (uploadedVideoUrl) { uploadedVideoUrl = null; showVideoIcon(); 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; } + showVideoSpinner(); + try { + uploadedVideoUrl = await muapi.uploadFile(file); + showVideoReady(file.name); + } catch (err) { showVideoIcon(); alert(`Video upload failed: ${err.message}`); } + videoFileInput.value = ''; + }; + + // ── Audio Upload Button (same pattern as video) ── 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 = ` - - - - `; - audioUploadBtn.appendChild(audioFileInput); + const audioPickerBtn = document.createElement('button'); + audioPickerBtn.type = 'button'; + audioPickerBtn.title = 'Upload audio file'; + audioPickerBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group'; + + const audioIconEl = document.createElement('div'); + audioIconEl.className = 'flex flex-col items-center justify-center gap-1 w-full h-full'; + audioIconEl.innerHTML = `AUDIO`; + + const audioSpinnerEl = document.createElement('div'); + audioSpinnerEl.className = 'hidden items-center justify-center w-full h-full'; + audioSpinnerEl.innerHTML = `◌`; + + const audioReadyEl = document.createElement('div'); + audioReadyEl.className = 'hidden flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10'; + audioReadyEl.innerHTML = `READY`; + + audioPickerBtn.appendChild(audioFileInput); + audioPickerBtn.appendChild(audioIconEl); + audioPickerBtn.appendChild(audioSpinnerEl); + audioPickerBtn.appendChild(audioReadyEl); + + const showAudioIcon = () => { + audioIconEl.classList.replace('hidden', 'flex'); + audioSpinnerEl.classList.add('hidden'); audioSpinnerEl.classList.remove('flex'); + audioReadyEl.classList.add('hidden'); audioReadyEl.classList.remove('flex'); + audioPickerBtn.classList.remove('border-primary/60'); audioPickerBtn.classList.add('border-white/10'); + audioPickerBtn.title = 'Upload audio file'; + audioStatusLabel.textContent = 'No audio'; audioStatusLabel.className = 'text-muted'; + }; + const showAudioSpinner = () => { + audioIconEl.classList.add('hidden'); audioIconEl.classList.remove('flex'); + audioSpinnerEl.classList.replace('hidden', 'flex'); + audioReadyEl.classList.add('hidden'); audioReadyEl.classList.remove('flex'); + }; + const showAudioReady = (name) => { + audioIconEl.classList.add('hidden'); audioIconEl.classList.remove('flex'); + audioSpinnerEl.classList.add('hidden'); audioSpinnerEl.classList.remove('flex'); + audioReadyEl.classList.replace('hidden', 'flex'); + audioPickerBtn.classList.remove('border-white/10'); audioPickerBtn.classList.add('border-primary/60'); + audioPickerBtn.title = `${name} — click to clear`; + audioStatusLabel.textContent = `✓ ${name}`; audioStatusLabel.className = 'text-primary'; + }; + + audioPickerBtn.onclick = (e) => { + e.stopPropagation(); + if (uploadedAudioUrl) { uploadedAudioUrl = null; showAudioIcon(); 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; } + showAudioSpinner(); + try { + uploadedAudioUrl = await muapi.uploadFile(file); + showAudioReady(file.name); + } catch (err) { showAudioIcon(); alert(`Audio upload failed: ${err.message}`); } + audioFileInput.value = ''; + }; // ── Prompt Textarea ── const textarea = document.createElement('textarea'); @@ -164,9 +252,9 @@ export function LipSyncStudio() { 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(imagePicker.trigger); + uploadsRow.appendChild(videoPickerBtn); + uploadsRow.appendChild(audioPickerBtn); uploadsRow.appendChild(textarea); bar.appendChild(uploadsRow); @@ -174,15 +262,18 @@ export function LipSyncStudio() { 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'; + // mediaStatusLabel: shows image or video status depending on mode + const mediaStatusLabel = document.createElement('span'); + mediaStatusLabel.className = 'text-muted'; + mediaStatusLabel.textContent = 'No image'; + + const imageStatusLabel = mediaStatusLabel; // alias used in imagePicker callbacks const audioStatusLabel = document.createElement('span'); audioStatusLabel.className = 'text-muted'; audioStatusLabel.textContent = 'No audio'; - statusRow.appendChild(imageStatusLabel); + statusRow.appendChild(mediaStatusLabel); statusRow.appendChild(document.createTextNode(' · ')); statusRow.appendChild(audioStatusLabel); bar.appendChild(statusRow); @@ -314,13 +405,17 @@ export function LipSyncStudio() { 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'); + imagePicker.trigger.classList.remove('hidden'); + videoPickerBtn.classList.add('hidden'); + mediaStatusLabel.textContent = uploadedImageUrl ? '✓ Image ready' : 'No image'; + mediaStatusLabel.className = uploadedImageUrl ? 'text-primary' : 'text-muted'; } 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'); + videoPickerBtn.classList.remove('hidden'); + imagePicker.trigger.classList.add('hidden'); + mediaStatusLabel.textContent = uploadedVideoUrl ? '✓ Video ready' : 'No video'; + mediaStatusLabel.className = uploadedVideoUrl ? 'text-primary' : 'text-muted'; } // Switch to first model of new mode @@ -346,7 +441,7 @@ export function LipSyncStudio() { if (inputMode === 'image') return; inputMode = 'image'; uploadedVideoUrl = null; - updateVideoUploadState('idle'); + showVideoIcon(); updateUIForMode(); }; @@ -354,178 +449,10 @@ export function LipSyncStudio() { if (inputMode === 'video') return; inputMode = 'video'; uploadedImageUrl = null; - updateImageUploadState('idle'); + imagePicker.reset(); 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'); @@ -705,6 +632,17 @@ export function LipSyncStudio() { hero.classList.remove('hidden', 'opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none'); promptWrapper.classList.remove('hidden', 'opacity-40'); textarea.value = ''; + // Reset uploads + imagePicker.reset(); + uploadedImageUrl = null; + uploadedVideoUrl = null; + uploadedAudioUrl = null; + showVideoIcon(); + showAudioIcon(); + mediaStatusLabel.textContent = inputMode === 'image' ? 'No image' : 'No video'; + mediaStatusLabel.className = 'text-muted'; + audioStatusLabel.textContent = 'No audio'; + audioStatusLabel.className = 'text-muted'; textarea.focus(); };