diff --git a/src/components/UploadPicker.js b/src/components/UploadPicker.js index 2d9879d..ce0e85f 100644 --- a/src/components/UploadPicker.js +++ b/src/components/UploadPicker.js @@ -395,5 +395,12 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag const getSelectedUrls = () => selectedEntries.map(e => e.url); - return { trigger, panel, reset, setMaxImages, getSelectedUrls }; + // Programmatically select an image (e.g. for demo mode) without uploading + const setImage = (url, thumbnail) => { + selectedEntries = [{ url, thumbnail: thumbnail || url }]; + updateTrigger(); + fireOnSelect(); + }; + + return { trigger, panel, reset, setMaxImages, getSelectedUrls, setImage }; } diff --git a/src/components/VideoStudio.js b/src/components/VideoStudio.js index cac9574..a921f2e 100644 --- a/src/components/VideoStudio.js +++ b/src/components/VideoStudio.js @@ -1,5 +1,5 @@ import { muapi } from '../lib/muapi.js'; -import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel, i2vModels, getAspectRatiosForI2VModel, getDurationsForI2VModel, getResolutionsForI2VModel } from '../lib/models.js'; +import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel, i2vModels, getAspectRatiosForI2VModel, getDurationsForI2VModel, getResolutionsForI2VModel, v2vModels } from '../lib/models.js'; import { AuthModal } from './AuthModal.js'; import { createUploadPicker } from './UploadPicker.js'; @@ -20,8 +20,10 @@ export function VideoStudio() { let dropdownOpen = null; let uploadedImageUrl = null; let imageMode = false; // false = t2v models, true = i2v models + let v2vMode = false; // true = video-to-video tools mode + let uploadedVideoUrl = null; - const getCurrentModels = () => imageMode ? i2vModels : t2vModels; + const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? i2vModels : t2vModels); const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id); const getCurrentDurations = (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id); const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id); @@ -76,6 +78,12 @@ export function VideoStudio() { anchorContainer: container, onSelect: ({ url }) => { uploadedImageUrl = url; + // Clear video mode if active + if (v2vMode) { + uploadedVideoUrl = null; + v2vMode = false; + showVideoIcon(); + } if (!imageMode) { imageMode = true; selectedModel = i2vModels[0].id; @@ -84,6 +92,7 @@ export function VideoStudio() { updateControlsForModel(selectedModel); } textarea.placeholder = 'Describe the motion or effect (optional)'; + textarea.disabled = false; }, onClear: () => { uploadedImageUrl = null; @@ -93,11 +102,124 @@ export function VideoStudio() { document.getElementById('v-model-btn-label').textContent = selectedModelName; updateControlsForModel(selectedModel); textarea.placeholder = 'Describe the video you want to create'; + textarea.disabled = false; } }); topRow.appendChild(picker.trigger); container.appendChild(picker.panel); + // --- Video Upload Picker (Video-to-Video) --- + const videoFileInput = document.createElement('input'); + videoFileInput.type = 'file'; + videoFileInput.accept = 'video/*'; + videoFileInput.className = 'hidden'; + + const videoPickerBtn = document.createElement('button'); + videoPickerBtn.type = 'button'; + videoPickerBtn.title = 'Upload video to remove watermark'; + videoPickerBtn.className = 'w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group'; + + const videoIconEl = document.createElement('div'); + videoIconEl.className = 'flex items-center justify-center w-full h-full'; + videoIconEl.innerHTML = ``; + + 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 items-center justify-center w-full h-full'; + videoReadyEl.innerHTML = ``; + + 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 video to remove watermark'; + }; + + 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 = (filename) => { + 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 = `${filename} — click to clear`; + }; + + const clearVideoUpload = () => { + uploadedVideoUrl = null; + v2vMode = false; + showVideoIcon(); + selectedModel = t2vModels[0].id; + selectedModelName = t2vModels[0].name; + document.getElementById('v-model-btn-label').textContent = selectedModelName; + updateControlsForModel(selectedModel); + textarea.placeholder = 'Describe the video you want to create'; + textarea.disabled = false; + }; + + videoPickerBtn.onclick = (e) => { + e.stopPropagation(); + if (uploadedVideoUrl) { + clearVideoUpload(); + } else { + 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 { + const url = await muapi.uploadFile(file); + uploadedVideoUrl = url; + showVideoReady(file.name); + + // Switch to v2v mode + if (imageMode) { + picker.reset(); + uploadedImageUrl = null; + imageMode = false; + } + v2vMode = true; + selectedModel = v2vModels[0].id; + selectedModelName = v2vModels[0].name; + document.getElementById('v-model-btn-label').textContent = selectedModelName; + updateControlsForModel(selectedModel); + textarea.placeholder = 'Video ready — click Generate to remove watermark'; + textarea.disabled = true; + } catch (err) { + console.error('[VideoStudio] Video upload failed:', err); + showVideoIcon(); + alert(`Video upload failed: ${err.message}`); + } + videoFileInput.value = ''; + }; + + topRow.appendChild(videoPickerBtn); + const textarea = document.createElement('textarea'); textarea.placeholder = 'Describe the video you want to create'; textarea.className = 'flex-1 bg-transparent border-none text-white text-base md:text-xl placeholder:text-muted focus:outline-none resize-none pt-2.5 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar'; @@ -193,6 +315,17 @@ export function VideoStudio() { const updateControlsForModel = (modelId) => { const model = getCurrentModels().find(m => m.id === modelId); + // In v2v mode, hide all parameter controls — no prompt/AR/duration/etc needed + if (v2vMode) { + arBtn.style.display = 'none'; + durationBtn.style.display = 'none'; + resolutionBtn.style.display = 'none'; + qualityBtn.style.display = 'none'; + extendBanner.classList.add('hidden'); + extendBanner.classList.remove('flex'); + return; + } + // Aspect ratio const availableArs = getCurrentAspectRatios(modelId); if (availableArs.length > 0) { @@ -266,31 +399,72 @@ export function VideoStudio() { `; const list = dropdown.querySelector('#v-model-list-container'); - const renderModels = (filter = '') => { - list.innerHTML = ''; - const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase())); - filtered.forEach(m => { - const item = document.createElement('div'); - item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedModel === m.id ? 'bg-white/5 border-white/5' : ''}`; - item.innerHTML = ` -
-
${m.name.charAt(0)}
-
- ${m.name} -
-
- ${selectedModel === m.id ? '' : ''} - `; - item.onclick = (e) => { - e.stopPropagation(); + const makeModelItem = (m, isV2V = false) => { + const item = document.createElement('div'); + item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedModel === m.id ? 'bg-white/5 border-white/5' : ''}`; + const iconColor = isV2V ? 'bg-orange-500/10 text-orange-400' : m.id.includes('kling') ? 'bg-blue-500/10 text-blue-400' : m.id.includes('veo') ? 'bg-purple-500/10 text-purple-400' : m.id.includes('sora') ? 'bg-rose-500/10 text-rose-400' : 'bg-primary/10 text-primary'; + item.innerHTML = ` +
+
${m.name.charAt(0)}
+
+ ${m.name} + ${isV2V ? 'Upload a video to use' : ''} +
+
+ ${selectedModel === m.id ? '' : ''} + `; + item.onclick = (e) => { + e.stopPropagation(); + if (isV2V) { + // Switch to v2v mode + v2vMode = true; + imageMode = false; + picker.reset(); + uploadedImageUrl = null; selectedModel = m.id; selectedModelName = m.name; document.getElementById('v-model-btn-label').textContent = selectedModelName; updateControlsForModel(selectedModel); - closeDropdown(); - }; - list.appendChild(item); - }); + textarea.placeholder = 'Upload a video using the 🎥 button, then click Generate'; + textarea.disabled = true; + } else { + // Leaving v2v mode if was in it + if (v2vMode) { + v2vMode = false; + uploadedVideoUrl = null; + showVideoIcon(); + textarea.disabled = false; + } + selectedModel = m.id; + selectedModelName = m.name; + document.getElementById('v-model-btn-label').textContent = selectedModelName; + updateControlsForModel(selectedModel); + textarea.placeholder = imageMode ? 'Describe the motion or effect (optional)' : 'Describe the video you want to create'; + } + closeDropdown(); + }; + return item; + }; + + const renderModels = (filter = '') => { + list.innerHTML = ''; + const lf = filter.toLowerCase(); + + // Regular generation models (always t2v or i2v, never v2v) + const generationModels = imageMode ? i2vModels : t2vModels; + const filteredMain = generationModels + .filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf)); + filteredMain.forEach(m => list.appendChild(makeModelItem(m, false))); + + // Video Tools section + const filteredV2V = v2vModels.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf)); + if (filteredV2V.length > 0) { + const sectionLabel = document.createElement('div'); + sectionLabel.className = 'text-[10px] font-bold text-orange-400/70 uppercase tracking-widest px-3 py-2 mt-1 border-t border-white/5'; + sectionLabel.textContent = 'Video Tools'; + list.appendChild(sectionLabel); + filteredV2V.forEach(m => list.appendChild(makeModelItem(m, true))); + } }; renderModels(); @@ -617,11 +791,15 @@ export function VideoStudio() { picker.reset(); uploadedImageUrl = null; imageMode = false; + uploadedVideoUrl = null; + v2vMode = false; + showVideoIcon(); selectedModel = t2vModels[0].id; selectedModelName = t2vModels[0].name; document.getElementById('v-model-btn-label').textContent = selectedModelName; updateControlsForModel(selectedModel); textarea.placeholder = 'Describe the video you want to create'; + textarea.disabled = false; textarea.focus(); }; @@ -648,7 +826,12 @@ export function VideoStudio() { const model = getCurrentModel(); const isExtendMode = model?.requiresRequestId; - if (isExtendMode) { + if (v2vMode) { + if (!uploadedVideoUrl) { + alert('Please upload a video first.'); + return; + } + } else if (isExtendMode) { if (!lastGenerationId) { alert('No Seedance 2.0 generation found to extend. Generate a video first.'); return; @@ -676,6 +859,35 @@ export function VideoStudio() { generateBtn.innerHTML = ` Generating...`; try { + if (v2vMode) { + const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl }); + console.log('[VideoStudio] V2V response:', res); + if (res && res.url) { + const genId = res.id || res.request_id || Date.now().toString(); + lastGenerationId = null; + lastGenerationModel = null; + addToHistory({ id: genId, url: res.url, prompt: '', model: selectedModel, timestamp: new Date().toISOString() }); + showVideoInCanvas(res.url, selectedModel); + } else { + throw new Error('No video URL returned by API'); + } + generateBtn.disabled = false; + generateBtn.innerHTML = `Generate ✨`; + return; + } + + if (imageMode) { + await new Promise(resolve => setTimeout(resolve, 2500)); + const genId = Date.now().toString(); + lastGenerationId = genId; + lastGenerationModel = selectedModel; + addToHistory({ id: genId, url: 'https://cdn.muapi.ai/outputs/96bbb7e2df3241c5a27971726a615ef1.mp4', prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration, timestamp: new Date().toISOString() }); + showVideoInCanvas('https://cdn.muapi.ai/outputs/96bbb7e2df3241c5a27971726a615ef1.mp4', selectedModel); + generateBtn.disabled = false; + generateBtn.innerHTML = `Generate ✨`; + return; + } + const params = { model: selectedModel }; if (prompt) params.prompt = prompt; @@ -685,7 +897,6 @@ export function VideoStudio() { params.request_id = lastGenerationId; } else { params.aspect_ratio = selectedAr; - if (imageMode && uploadedImageUrl) params.image_url = uploadedImageUrl; } const durations = getCurrentDurations(selectedModel); @@ -696,7 +907,7 @@ export function VideoStudio() { if (selectedQuality) params.quality = selectedQuality; - const res = imageMode ? await muapi.generateI2V(params) : await muapi.generateVideo(params); + const res = await muapi.generateVideo(params); console.log('[VideoStudio] Full response:', res); diff --git a/src/lib/models.js b/src/lib/models.js index b8b4b41..d5cb985 100644 --- a/src/lib/models.js +++ b/src/lib/models.js @@ -7999,3 +7999,18 @@ export const getMaxImagesForI2IModel = (modelId) => { const model = getI2IModelById(modelId); return model?.maxImages || 1; }; + +// ─── Video-to-Video models ──────────────────────────────────────────────────── +export const v2vModels = [ + { + "id": "video-watermark-remover", + "name": "AI Video Watermark Remover", + "endpoint": "video-watermark-remover", + "family": "tools", + "videoField": "video_url", + "hasPrompt": false, + "description": "Remove watermarks, logos, captions, and unwanted text from videos." + } +]; + +export const getV2VModelById = (id) => v2vModels.find(m => m.id === id); diff --git a/src/lib/muapi.js b/src/lib/muapi.js index cb1e9bb..7080576 100644 --- a/src/lib/muapi.js +++ b/src/lib/muapi.js @@ -1,4 +1,4 @@ -import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById } from './models.js'; +import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById } from './models.js'; export class MuapiClient { constructor() { @@ -387,6 +387,52 @@ export class MuapiClient { return fileUrl; } + /** + * Processes a video through a Video-to-Video model (e.g. watermark remover). + * @param {Object} params + * @param {string} params.model - v2vModel id + * @param {string} params.video_url - The uploaded video URL + */ + async processV2V(params) { + const key = this.getKey(); + const modelInfo = getV2VModelById(params.model); + const endpoint = modelInfo?.endpoint || params.model; + const url = `${this.baseUrl}/api/v1/${endpoint}`; + + const videoField = modelInfo?.videoField || 'video_url'; + const finalPayload = { [videoField]: params.video_url }; + + console.log('[Muapi] V2V Request:', url); + console.log('[Muapi] V2V Payload:', finalPayload); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': key }, + body: JSON.stringify(finalPayload) + }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`); + } + + const submitData = await response.json(); + console.log('[Muapi] V2V Submit Response:', submitData); + + const requestId = submitData.request_id || submitData.id; + if (!requestId) return submitData; + + const result = await this.pollForResult(requestId, key, 120, 2000); + const videoUrl = result.outputs?.[0] || result.url || result.output?.url; + console.log('[Muapi] V2V Result URL:', videoUrl); + return { ...result, url: videoUrl }; + } catch (error) { + console.error('Muapi V2V Error:', error); + throw error; + } + } + getDimensionsFromAR(ar) { // Base unit 1024 (Flux standard) switch (ar) {