"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { generateVideo, generateI2V, processV2V, uploadFile } from "../muapi.js"; import { t2vModels, i2vModels, v2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel, getAspectRatiosForI2VModel, getDurationsForI2VModel, getResolutionsForI2VModel, getEffectsForI2VModel, getDefaultEffectForI2VModel, getModesForModel, getMaxImagesForI2VModel, } from "../models.js"; // ── tiny helpers ────────────────────────────────────────────────────────────── function getQualitiesForModel(modelList, modelId) { const model = modelList.find((m) => m.id === modelId); return model?.inputs?.quality?.enum || []; } async function downloadFile(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"); } } // ── SVG icons (kept inline to avoid extra deps) ─────────────────────────────── const CheckSvg = () => ( ); const VideoIconSvg = ({ className }) => ( ); const VideoReadySvg = () => ( ); // ── Dropdown components ─────────────────────────────────────────────────────── function DropdownItem({ label, selected, onClick }) { return (
{label} {selected && }
); } const PROVIDER_LOGOS = { openai: "https://cdn.muapi.ai/models/openai.png", google: "https://cdn.muapi.ai/models/gemini.png", kling: "https://cdn.muapi.ai/models/kling.png", alibaba: "https://cdn.muapi.ai/models/alibaba.png", bytedance: "https://cdn.muapi.ai/models/bytedance.png", blackforest: "https://cdn.muapi.ai/models/bfl.png", minimax: "https://cdn.muapi.ai/models/minimax.png", suno: "https://cdn.muapi.ai/models/suno.png", anthropic: "https://cdn.muapi.ai/models/claude.png", meshy: "https://cdn.muapi.ai/models/meshy-3.png", tripo3d: "https://cdn.muapi.ai/models/tripo3d.png", grok: "https://cdn.muapi.ai/models/xai.png", muapi: "https://cdn.muapi.ai/models/muapi.png", midjourney: "https://cdn.muapi.ai/models/midjourney.png", vidu: "https://cdn.muapi.ai/models/vidu.png", runway: "https://cdn.muapi.ai/models/runway.png", luma: "https://cdn.muapi.ai/models/luma.png", ideogram: "https://cdn.muapi.ai/models/ideogram.png", leonardoai: "https://cdn.muapi.ai/models/leonardoai.png", hunyuan: "https://cdn.muapi.ai/models/hunyuan.png", hidream: "https://cdn.muapi.ai/models/hidream.png", lightricks: "https://cdn.muapi.ai/models/lightricks.png", pixverse: "https://cdn.muapi.ai/models/pixverse.png", reve: "https://cdn.muapi.ai/models/reve.png", stability: "https://cdn.muapi.ai/models/stability.png" }; const invertLogos = ['openai', 'blackforest', 'runway', 'ideogram', 'lightricks', 'grok']; function ModelDropdown({ imageMode, selectedModel, onSelect, onClose }) { const [search, setSearch] = useState(""); const [selectedProvider, setSelectedProvider] = useState("all"); const generationModels = imageMode ? i2vModels : t2vModels; const getProviderStyle = (provider) => { switch (provider) { case "grok": return { text: "xI", bg: "bg-orange-500/10 text-orange-400 border-orange-500/25" }; case "openai": return { text: "O", bg: "bg-emerald-500/10 text-emerald-400 border-emerald-500/25" }; case "google": return { text: "G", bg: "bg-blue-500/10 text-blue-400 border-blue-500/25" }; case "blackforest": return { text: "BF", bg: "bg-amber-500/10 text-amber-400 border-amber-500/25" }; case "bytedance": return { text: "BD", bg: "bg-purple-500/10 text-purple-400 border-purple-500/25" }; case "midjourney": return { text: "MJ", bg: "bg-indigo-500/10 text-indigo-400 border-indigo-500/25" }; case "kling": return { text: "KL", bg: "bg-rose-500/10 text-rose-400 border-rose-500/25" }; case "vidu": return { text: "VD", bg: "bg-cyan-500/10 text-cyan-400 border-cyan-500/25" }; case "minimax": return { text: "MX", bg: "bg-pink-500/10 text-pink-400 border-pink-500/25" }; case "ideogram": return { text: "ID", bg: "bg-yellow-500/10 text-yellow-400 border-yellow-500/25" }; case "luma": return { text: "LM", bg: "bg-teal-500/10 text-teal-400 border-teal-500/25" }; case "alibaba": return { text: "AL", bg: "bg-sky-500/10 text-sky-400 border-sky-500/25" }; case "leonardoai": return { text: "LE", bg: "bg-violet-500/10 text-violet-400 border-violet-500/25" }; case "stability": return { text: "SD", bg: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/25" }; default: const name = provider ? provider.toUpperCase() : "AI"; return { text: name.substring(0, 2), bg: "bg-primary/10 text-primary border-primary/25" }; } }; // Dynamically compute list of providers from the input models lists const availableProviders = []; const seenProviders = new Set(); const allCurrentModels = [...generationModels, ...v2vModels]; allCurrentModels.forEach(m => { const pId = m.provider || 'muapi'; const pName = m.provider_name || 'Muapi'; if (!seenProviders.has(pId)) { seenProviders.add(pId); availableProviders.push({ id: pId, name: pName }); } }); const lf = search.toLowerCase(); const filterFn = (m) => { // 1. Filter by provider tab if (selectedProvider !== "all") { const pId = m.provider || 'muapi'; if (pId !== selectedProvider) return false; } // 2. Filter by search query return ( m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf) ); }; const filteredMain = generationModels.filter(filterFn); const filteredV2V = v2vModels.filter(filterFn); const getIconColor = (m, isV2V) => { if (isV2V) return "bg-orange-500/10 text-orange-400 border-orange-500/10"; if (m.id.includes("kling")) return "bg-blue-500/10 text-blue-400 border-blue-500/10"; if (m.id.includes("veo")) return "bg-purple-500/10 text-purple-400 border-purple-500/10"; if (m.id.includes("sora")) return "bg-rose-500/10 text-rose-400 border-rose-500/10"; return "bg-primary/10 text-primary border-primary/10"; }; const renderItem = (m, isV2V = false) => (
{ e.stopPropagation(); onSelect(m, isV2V); onClose(); }} >
{PROVIDER_LOGOS[m.provider] ? (
{m.provider_name}
) : (
{m.name.charAt(0)}
)}
{m.name} {isV2V ? ( {m.imageField ? "Upload a video and image" : "Upload a video to use"} ) : ( selectedProvider === "all" && m.provider_name && ( {m.provider_name} ) )}
{selectedModel === m.id && }
); const invertLogos = ['openai', 'blackforest', 'runway', 'ideogram', 'lightricks', 'grok']; return (
{/* Left Sidebar: Provider tabs */}
{availableProviders.map(p => { const style = getProviderStyle(p.id); const isSelected = selectedProvider === p.id; return ( ); })}
{/* Right Pane: Search + Lists */}
setSearch(e.target.value)} onClick={(e) => e.stopPropagation()} className="bg-transparent border-none text-xs text-white focus:ring-0 w-full p-0 outline-none" />
Video models {selectedProvider !== "all" && ( {availableProviders.find(p => p.id === selectedProvider)?.name || selectedProvider} )}
{filteredMain.length === 0 && filteredV2V.length === 0 ? (
No models found
) : ( <> {filteredMain.map((m) => renderItem(m, false))} {filteredV2V.length > 0 && ( <>
Video Tools
{filteredV2V.map((m) => renderItem(m, true))} )} )}
); } // ── Control button ──────────────────────────────────────────────────────────── function ControlBtn({ icon, label, onClick, style }) { return ( ); } // ── Dropdown panel ───────────────────────────────────────────────────────────── // Rendered inside a `relative` wrapper div; floats above the anchor button. // ── Main component ──────────────────────────────────────────────────────────── export default function VideoStudio({ apiKey, onGenerationComplete, historyItems, droppedFiles, onFilesHandled, }) { const PERSIST_KEY = "hg_video_studio_persistent"; // ── mode state ── const [imageMode, setImageMode] = useState(false); // i2v const [v2vMode, setV2vMode] = useState(false); // ── model / params ── const defaultModel = t2vModels[0]; const [selectedModel, setSelectedModel] = useState(defaultModel.id); const [selectedModelName, setSelectedModelName] = useState(defaultModel.name); const [selectedAr, setSelectedAr] = useState( defaultModel.inputs?.aspect_ratio?.default || "16:9", ); const [selectedDuration, setSelectedDuration] = useState( defaultModel.inputs?.duration?.default || 5, ); const [selectedResolution, setSelectedResolution] = useState( defaultModel.inputs?.resolution?.default || "", ); const [selectedQuality, setSelectedQuality] = useState( defaultModel.inputs?.quality?.default || "", ); const [selectedMode, setSelectedMode] = useState(""); const [selectedEffect, setSelectedEffect] = useState(""); // ── upload progress ── const [imageProgress, setImageProgress] = useState(0); const [videoProgress, setVideoProgress] = useState(0); // ── control visibility ── const [showAr, setShowAr] = useState(true); const [showDuration, setShowDuration] = useState(true); const [showResolution, setShowResolution] = useState(false); const [showQuality, setShowQuality] = useState(false); const [showMode, setShowMode] = useState(false); const [showEffect, setShowEffect] = useState(false); // ── uploads ── const [uploadedImageUrl, setUploadedImageUrl] = useState(null); const [uploadedImageUrls, setUploadedImageUrls] = useState([]); const [imageUploading, setImageUploading] = useState(false); const [uploadedEndImageUrl, setUploadedEndImageUrl] = useState(null); const [endImageUploading, setEndImageUploading] = useState(false); const [endImageProgress, setEndImageProgress] = useState(0); const [uploadedVideoUrl, setUploadedVideoUrl] = useState(null); const [videoUploading, setVideoUploading] = useState(false); const [uploadedVideoName, setUploadedVideoName] = useState(null); // ── generation / canvas ── const [generating, setGenerating] = useState(false); const [generateError, setGenerateError] = useState(null); const [fullscreenUrl, setFullscreenUrl] = useState(null); const [canvasUrl, setCanvasUrl] = useState(null); const [canvasModel, setCanvasModel] = useState(null); const [showCanvas, setShowCanvas] = useState(false); const [lastGenerationId, setLastGenerationId] = useState(null); const [lastGenerationModel, setLastGenerationModel] = useState(null); // ── history ── const [localHistory, setLocalHistory] = useState([]); const [activeHistoryIdx, setActiveHistoryIdx] = useState(0); // ── dropdown ── const [openDropdown, setOpenDropdown] = useState(null); // 'model'|'ar'|'duration'|'resolution'|'quality'|'mode'|null // ── prompt ── const [prompt, setPrompt] = useState(""); const [promptDisabled, setPromptDisabled] = useState(false); // ── refs ── const containerRef = useRef(null); const textareaRef = useRef(null); const dropdownRef = useRef(null); const imageFileInputRef = useRef(null); const endImageFileInputRef = useRef(null); const videoFileInputRef = useRef(null); const resultVideoRef = useRef(null); const hasRestored = useRef(false); // ── derived data ── const history = historyItems ?? localHistory; const getCurrentModels = useCallback(() => { if (v2vMode) return v2vModels; return imageMode ? i2vModels : t2vModels; }, [imageMode, v2vMode]); const getCurrentAspectRatios = useCallback( (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id), [imageMode], ); const getCurrentDurations = useCallback( (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id), [imageMode], ); const getCurrentResolutions = useCallback( (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id), [imageMode], ); const getCurrentModel = useCallback( () => getCurrentModels().find((m) => m.id === selectedModel), [getCurrentModels, selectedModel], ); const isMotionControlSelection = useCallback( (modelId, isV2v) => { if (!isV2v) return false; const m = v2vModels.find((x) => x.id === modelId); return !!m?.imageField; }, [], ); // ── update controls when model/mode changes ────────────────────────────── const applyControlsForModel = useCallback( (modelId, isImageMode, isV2vMode) => { if (isV2vMode) { setShowAr(false); setShowDuration(false); setShowResolution(false); setShowQuality(false); setShowMode(false); setShowEffect(false); return; } const modelList = isImageMode ? i2vModels : t2vModels; const model = modelList.find((m) => m.id === modelId); const ars = isImageMode ? getAspectRatiosForI2VModel(modelId) : getAspectRatiosForVideoModel(modelId); if (ars.length > 0) { setSelectedAr(ars[0]); setShowAr(true); } else { setShowAr(false); } const durations = isImageMode ? getDurationsForI2VModel(modelId) : getDurationsForModel(modelId); if (durations.length > 0) { setSelectedDuration(durations[0]); setShowDuration(true); } else { setShowDuration(false); } const resolutions = isImageMode ? getResolutionsForI2VModel(modelId) : getResolutionsForVideoModel(modelId); if (resolutions.length > 0) { setSelectedResolution(resolutions[0]); setShowResolution(true); } else { setShowResolution(false); } const qualities = getQualitiesForModel(modelList, modelId); if (qualities.length > 0) { setSelectedQuality(model?.inputs?.quality?.default || qualities[0]); setShowQuality(true); } else { setSelectedQuality(""); setShowQuality(false); } const modes = getModesForModel(modelId); if (modes.length > 0) { setSelectedMode(model?.inputs?.mode?.default || modes[0]); setShowMode(true); } else { setSelectedMode(""); setShowMode(false); } const effects = isImageMode ? getEffectsForI2VModel(modelId) : []; if (effects.length > 0) { setSelectedEffect(getDefaultEffectForI2VModel(modelId) || effects[0]); setShowEffect(true); } else { setSelectedEffect(""); setShowEffect(false); } }, [], ); // ── Persistence: Load ──────────────────────────────────────────────────── useEffect(() => { try { const stored = localStorage.getItem(PERSIST_KEY); if (stored) { const data = JSON.parse(stored); if (data.imageMode !== undefined) setImageMode(data.imageMode); if (data.v2vMode !== undefined) setV2vMode(data.v2vMode); if (data.selectedModel) setSelectedModel(data.selectedModel); if (data.selectedModelName) setSelectedModelName(data.selectedModelName); if (data.selectedAr) setSelectedAr(data.selectedAr); if (data.selectedDuration) setSelectedDuration(data.selectedDuration); if (data.selectedResolution) setSelectedResolution(data.selectedResolution); if (data.selectedQuality) setSelectedQuality(data.selectedQuality); if (data.selectedMode) setSelectedMode(data.selectedMode); if (data.selectedEffect) setSelectedEffect(data.selectedEffect); if (data.uploadedImageUrl) setUploadedImageUrl(data.uploadedImageUrl); if (data.uploadedImageUrls) { setUploadedImageUrls(data.uploadedImageUrls); } else if (data.uploadedImageUrl) { setUploadedImageUrls([data.uploadedImageUrl]); } if (data.uploadedVideoUrl) setUploadedVideoUrl(data.uploadedVideoUrl); if (data.uploadedVideoName) setUploadedVideoName(data.uploadedVideoName); if (data.prompt) setPrompt(data.prompt); if (data.localHistory) setLocalHistory(data.localHistory); // Update control visibility based on restored model/mode applyControlsForModel( data.selectedModel || defaultModel.id, !!data.imageMode, !!data.v2vMode ); } } catch (err) { console.warn("Failed to load VideoStudio persistence:", err); } finally { hasRestored.current = true; } }, [applyControlsForModel, defaultModel.id]); // ── Adjust height on load ──────────────────────────────────────────────── useEffect(() => { const timer = setTimeout(() => { if (textareaRef.current) { const el = textareaRef.current; el.style.height = "auto"; const maxH = window.innerWidth < 768 ? 150 : 250; el.style.height = Math.min(el.scrollHeight, maxH) + "px"; } }, 150); return () => clearTimeout(timer); }, []); // ── Persistence: Save ──────────────────────────────────────────────────── useEffect(() => { const timer = setTimeout(() => { try { const state = { imageMode, v2vMode, selectedModel, selectedModelName, selectedAr, selectedDuration, selectedResolution, selectedQuality, selectedMode, selectedEffect, uploadedImageUrl, uploadedImageUrls, uploadedVideoUrl, uploadedVideoName, prompt, localHistory, }; localStorage.setItem(PERSIST_KEY, JSON.stringify(state)); } catch (err) { console.warn("Failed to save VideoStudio persistence:", err); } }, 500); // 500ms debounce return () => clearTimeout(timer); }, [ imageMode, v2vMode, selectedModel, selectedModelName, selectedAr, selectedDuration, selectedResolution, selectedQuality, selectedMode, selectedEffect, uploadedImageUrl, uploadedImageUrls, uploadedVideoUrl, uploadedVideoName, prompt, localHistory, ]); // ── Derived UI values ──────────────────────────────────────────────────── const processDroppedImage = async (file) => { if (file.size > 10 * 1024 * 1024) { alert("Image exceeds 10MB limit."); return; } setImageUploading(true); setImageProgress(0); try { const url = await uploadFile(apiKey, file, (pct) => { setImageProgress(pct); }); setUploadedImageUrl(url); setUploadedVideoUrl(null); setUploadedVideoName(null); setV2vMode(false); let targetModelId = selectedModel; if (!imageMode) { const currentT2V = t2vModels.find((m) => m.id === selectedModel); const sibling = currentT2V?.family ? i2vModels.find((m) => m.family === currentT2V.family) : null; const target = sibling || i2vModels[0]; targetModelId = target.id; setImageMode(true); setSelectedModel(target.id); setSelectedModelName(target.name); applyControlsForModel(target.id, true, false); } const maxImgs = getMaxImagesForI2VModel(targetModelId); if (maxImgs > 2) { setUploadedImageUrls((prev) => { if (prev.includes(url)) return prev; return [...prev, url].slice(0, maxImgs); }); } else { setUploadedImageUrls([url]); } setPromptDisabled(false); } catch (err) { alert(`Image upload failed: ${err.message}`); } finally { setImageUploading(false); setImageProgress(0); } }; const processDroppedVideo = async (file) => { if (file.size > 50 * 1024 * 1024) { alert("Video exceeds 50MB limit."); return; } setVideoUploading(true); setVideoProgress(0); try { const url = await uploadFile(apiKey, file, (pct) => { setVideoProgress(pct); }); setUploadedVideoUrl(url); setUploadedVideoName(file.name); if (imageMode) { setUploadedImageUrl(null); setImageMode(false); } setV2vMode(true); const firstV2V = v2vModels[0]; setSelectedModel(firstV2V.id); setSelectedModelName(firstV2V.name); applyControlsForModel(firstV2V.id, false, true); setPrompt(""); setPromptDisabled(true); } catch (err) { alert(`Video upload failed: ${err.message}`); } finally { setVideoUploading(false); setVideoProgress(0); } }; // ── Handle Dropped Files ──────────────────────────────────────────────── useEffect(() => { if (droppedFiles && droppedFiles.length > 0) { const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/')); const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/')); if (videoFiles.length > 0) { processDroppedVideo(videoFiles[0]); } else if (imageFiles.length > 0) { processDroppedImage(imageFiles[0]); } onFilesHandled?.(); } }, [droppedFiles, onFilesHandled, processDroppedImage, processDroppedVideo]); // Initialise controls for default model on mount useEffect(() => { if (hasRestored.current) return; applyControlsForModel(defaultModel.id, false, false); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── close dropdown on outside click ───────────────────────────────────── useEffect(() => { if (!openDropdown) return; const handler = (e) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { setOpenDropdown(null); } }; window.addEventListener("click", handler); return () => window.removeEventListener("click", handler); }, [openDropdown]); // ── textarea auto-resize ────────────────────────────────────────────────── const handlePromptInput = (e) => { setPrompt(e.target.value); const el = e.target; el.style.height = "auto"; const maxH = window.innerWidth < 768 ? 150 : 250; el.style.height = Math.min(el.scrollHeight, maxH) + "px"; }; // ── image upload ───────────────────────────────────────────────────────── const handleImageFileChange = async (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > 10 * 1024 * 1024) { alert("Image exceeds 10MB limit."); return; } setImageUploading(true); setImageProgress(0); try { const url = await uploadFile(apiKey, file, (pct) => { setImageProgress(pct); }); setUploadedImageUrl(url); // Motion-control v2v: image is a second input, not a mode switch if (isMotionControlSelection(selectedModel, v2vMode)) { setPromptDisabled(false); setUploadedImageUrls([url]); } else { // Model-native image reference (e.g. Seedance 2.0 Extend with inputs.images_list): // keep the current model & mode; just accumulate the image URL const currentT2VOrExtend = t2vModels.find((m) => m.id === selectedModel); if (currentT2VOrExtend?.inputs?.images_list) { const maxImgs = currentT2VOrExtend.inputs?.images_list?.maxItems || 8; setUploadedImageUrls((prev) => { if (prev.includes(url)) return prev; return [...prev, url].slice(0, maxImgs); }); setPromptDisabled(false); } else { // Standard flow: clear v2v and switch to an I2V sibling model setUploadedVideoUrl(null); setUploadedVideoName(null); setV2vMode(false); let targetModelId = selectedModel; if (!imageMode) { const sibling = currentT2VOrExtend?.family ? i2vModels.find((m) => m.family === currentT2VOrExtend.family) : null; const target = sibling || i2vModels[0]; targetModelId = target.id; setImageMode(true); setSelectedModel(target.id); setSelectedModelName(target.name); applyControlsForModel(target.id, true, false); } const maxImgs = getMaxImagesForI2VModel(targetModelId); if (maxImgs > 2) { setUploadedImageUrls((prev) => { if (prev.includes(url)) return prev; return [...prev, url].slice(0, maxImgs); }); } else { setUploadedImageUrls([url]); } setPromptDisabled(false); } } } catch (err) { console.error("[VideoStudio] Image upload failed:", err); alert(`Image upload failed: ${err.message}`); } finally { setImageUploading(false); setImageProgress(0); if (imageFileInputRef.current) imageFileInputRef.current.value = ""; } }; const clearImageUpload = () => { setUploadedImageUrl(null); setUploadedImageUrls([]); setUploadedEndImageUrl(null); // Motion-control v2v or model with inputs.images_list: keep model, just drop the image if (isMotionControlSelection(selectedModel, v2vMode)) return; const currentT2V = t2vModels.find((m) => m.id === selectedModel); if (currentT2V?.inputs?.images_list) return; setImageMode(false); const first = t2vModels[0]; setSelectedModel(first.id); setSelectedModelName(first.name); applyControlsForModel(first.id, false, false); setPromptDisabled(false); }; const removeImageAtIndex = (idx) => { const nextUrls = uploadedImageUrls.filter((_, i) => i !== idx); setUploadedImageUrls(nextUrls); if (nextUrls.length === 0) { setUploadedImageUrl(null); // Reset to text-to-video if empty list if (isMotionControlSelection(selectedModel, v2vMode)) return; setImageMode(false); const first = t2vModels[0]; setSelectedModel(first.id); setSelectedModelName(first.name); applyControlsForModel(first.id, false, false); setPromptDisabled(false); } else { setUploadedImageUrl(nextUrls[0]); } }; // ── end-frame upload (FLF i2v models) ────────────────────────────────────── const handleEndImageFileChange = async (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > 10 * 1024 * 1024) { alert("Image exceeds 10MB limit."); return; } setEndImageUploading(true); setEndImageProgress(0); try { const url = await uploadFile(apiKey, file, (pct) => { setEndImageProgress(pct); }); setUploadedEndImageUrl(url); } catch (err) { alert(`End frame upload failed: ${err.message}`); } finally { setEndImageUploading(false); setEndImageProgress(0); if (endImageFileInputRef.current) endImageFileInputRef.current.value = ""; } }; const clearEndImage = () => setUploadedEndImageUrl(null); // ── video upload ───────────────────────────────────────────────────────── const handleVideoFileChange = async (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > 50 * 1024 * 1024) { alert("Video exceeds 50MB limit."); return; } setVideoUploading(true); setVideoProgress(0); try { const url = await uploadFile(apiKey, file, (pct) => { setVideoProgress(pct); }); setUploadedVideoUrl(url); setUploadedVideoName(file.name); if (isMotionControlSelection(selectedModel, v2vMode)) { // Already in motion-control mode — keep model and image, allow prompt setPromptDisabled(false); } else { // Model-native video reference (e.g. Seedance 2.0 Extend with inputs.video_files): // keep the current model & mode; just store the video URL as a reference const currentT2VOrExtend = t2vModels.find((m) => m.id === selectedModel); if (currentT2VOrExtend?.inputs?.video_files) { setPromptDisabled(false); } else { // Default v2v flow (e.g. watermark remover) — auto-pick the first v2v model if (imageMode) { setUploadedImageUrl(null); setImageMode(false); } setV2vMode(true); const firstV2V = v2vModels[0]; setSelectedModel(firstV2V.id); setSelectedModelName(firstV2V.name); applyControlsForModel(firstV2V.id, false, true); setPrompt(""); setPromptDisabled(true); } } } catch (err) { console.error("[VideoStudio] Video upload failed:", err); alert(`Video upload failed: ${err.message}`); } finally { setVideoUploading(false); setVideoProgress(0); if (videoFileInputRef.current) videoFileInputRef.current.value = ""; } }; const clearVideoUpload = () => { setUploadedVideoUrl(null); setUploadedVideoName(null); setV2vMode(false); const first = t2vModels[0]; setSelectedModel(first.id); setSelectedModelName(first.name); applyControlsForModel(first.id, false, false); setPromptDisabled(false); }; // ── model selection from dropdown ───────────────────────────────────────── const handleModelSelect = useCallback( (m, isV2V) => { if (isV2V) { setV2vMode(true); setImageMode(false); const isMC = !!m.imageField; if (!isMC) { // Single-input v2v (watermark remover etc.) — drop any image setUploadedImageUrl(null); } setSelectedModel(m.id); setSelectedModelName(m.name); applyControlsForModel(m.id, false, true); if (isMC) { // Motion-control: prompt is editable, video+image are needed setPromptDisabled(false); } else { setPrompt(""); setPromptDisabled(true); } } else { if (v2vMode) { setV2vMode(false); setUploadedVideoUrl(null); setUploadedVideoName(null); setPromptDisabled(false); } setSelectedModel(m.id); setSelectedModelName(m.name); applyControlsForModel(m.id, imageMode, false); } }, [v2vMode, imageMode, applyControlsForModel], ); // ── add to local history ────────────────────────────────────────────────── const addToLocalHistory = useCallback((entry) => { setLocalHistory((prev) => [entry, ...prev].slice(0, 30)); setActiveHistoryIdx(0); }, []); // ── show result in canvas ───────────────────────────────────────────────── const showVideoInCanvas = useCallback((url, model) => { setCanvasUrl(url); setCanvasModel(model); setShowCanvas(true); }, []); // ── generate ────────────────────────────────────────────────────────────── const handleGenerate = useCallback(async () => { const currentModel = getCurrentModel(); const isExtendMode = currentModel?.requiresRequestId; const trimmedPrompt = prompt.trim(); if (v2vMode) { if (!uploadedVideoUrl) { alert("Please upload a video first."); return; } if (currentModel?.imageField && !uploadedImageUrl) { alert("Please upload a reference image for motion control."); return; } if (currentModel?.promptRequired && !trimmedPrompt) { alert("Please describe the motion you want."); return; } } else if (isExtendMode) { if (!lastGenerationId) { alert( "No Seedance 2.0 generation found to extend. Generate a video first.", ); return; } } else if (imageMode) { const maxImgs = getMaxImagesForI2VModel(selectedModel); if (maxImgs > 2) { if (uploadedImageUrls.length === 0) { alert("Please upload at least one reference image first."); return; } } else { if (!uploadedImageUrl) { alert("Please upload a start frame image first."); return; } } } else { if (!trimmedPrompt) { alert("Please enter a prompt to generate a video."); return; } } setGenerating(true); setGenerateError(null); let hadError = false; try { let res; if (v2vMode) { // V2V: dedicated processV2V handles single-input tools (e.g. watermark // remover) and motion-control models (which take video + image + prompt) const v2vParams = { model: selectedModel, video_url: uploadedVideoUrl, }; if (currentModel?.imageField && uploadedImageUrl) { v2vParams.image_url = uploadedImageUrl; } if (currentModel?.hasPrompt && trimmedPrompt) { v2vParams.prompt = trimmedPrompt; } res = await processV2V(apiKey, v2vParams); if (!res?.url) throw new Error("No video URL returned by API"); const genId = res.id || Date.now().toString(); setLastGenerationId(null); setLastGenerationModel(null); const entry = { id: genId, url: res.url, prompt: currentModel?.hasPrompt ? trimmedPrompt : "", model: selectedModel, timestamp: new Date().toISOString(), }; addToLocalHistory(entry); showVideoInCanvas(res.url, selectedModel); if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: currentModel?.hasPrompt ? trimmedPrompt : "", type: "video", }); } else if (imageMode) { const maxImgs = getMaxImagesForI2VModel(selectedModel); const i2vParams = { model: selectedModel }; if (maxImgs > 2) { i2vParams.images_list = uploadedImageUrls; } else { i2vParams.image_url = uploadedImageUrl; } if (trimmedPrompt) i2vParams.prompt = trimmedPrompt; i2vParams.aspect_ratio = selectedAr; const i2vModel = i2vModels.find((m) => m.id === selectedModel); if (uploadedEndImageUrl && i2vModel?.lastImageField) { i2vParams.last_image = uploadedEndImageUrl; } const durations = getDurationsForI2VModel(selectedModel); if (durations.length > 0) i2vParams.duration = selectedDuration; const resolutions = getResolutionsForI2VModel(selectedModel); if (resolutions.length > 0) i2vParams.resolution = selectedResolution; if (selectedQuality) i2vParams.quality = selectedQuality; if (selectedMode) i2vParams.mode = selectedMode; if (showEffect && selectedEffect) i2vParams.name = selectedEffect; res = await generateI2V(apiKey, i2vParams); if (!res?.url) throw new Error("No video URL returned by API"); const genId = res.id || Date.now().toString(); if (selectedModel === "seedance-v2.0-i2v") { setLastGenerationId(genId); setLastGenerationModel(selectedModel); } else { setLastGenerationId(null); setLastGenerationModel(null); } const entry = { id: genId, url: res.url, prompt: trimmedPrompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration, timestamp: new Date().toISOString(), }; addToLocalHistory(entry); showVideoInCanvas(res.url, selectedModel); if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: trimmedPrompt, type: "video", }); } else { // T2V (including extend mode) const params = { model: selectedModel }; if (trimmedPrompt) params.prompt = trimmedPrompt; if (isExtendMode) { params.request_id = lastGenerationId; // Optional reference media for Seedance 2.0 Extend: // images map to @image2…@image9 and videos map to @video1…@video3 in the prompt if (uploadedImageUrls.length > 0) { params.images_list = uploadedImageUrls; } if (uploadedVideoUrl) { params.videos_list = [uploadedVideoUrl]; } } else { params.aspect_ratio = selectedAr; } const durations = getDurationsForModel(selectedModel); if (durations.length > 0) params.duration = selectedDuration; const resolutions = getResolutionsForVideoModel(selectedModel); if (resolutions.length > 0) params.resolution = selectedResolution; if (selectedQuality) params.quality = selectedQuality; if (selectedMode) params.mode = selectedMode; res = await generateVideo(apiKey, params); if (!res?.url) throw new Error("No video URL returned by API"); const genId = res.id || Date.now().toString(); if ( selectedModel === "seedance-v2.0-t2v" || selectedModel === "seedance-v2.0-i2v" ) { setLastGenerationId(genId); setLastGenerationModel(selectedModel); } else { setLastGenerationId(null); setLastGenerationModel(null); } const entry = { id: genId, url: res.url, prompt: trimmedPrompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration, timestamp: new Date().toISOString(), }; addToLocalHistory(entry); showVideoInCanvas(res.url, selectedModel); if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: trimmedPrompt, type: "video", }); } } catch (e) { hadError = true; console.error("[VideoStudio]", e); setGenerateError(e.message?.slice(0, 80) || "Generation failed"); setTimeout(() => setGenerateError(null), 4000); } finally { setGenerating(false); } }, [ apiKey, prompt, v2vMode, imageMode, selectedModel, selectedAr, selectedDuration, selectedResolution, selectedQuality, selectedMode, selectedEffect, showEffect, uploadedImageUrl, uploadedImageUrls, uploadedVideoUrl, lastGenerationId, getCurrentModel, addToLocalHistory, showVideoInCanvas, onGenerationComplete, ]); // ── reset to prompt bar ─────────────────────────────────────────────────── const resetToPromptBar = useCallback(() => { setShowCanvas(false); }, []); const handleNewPrompt = useCallback(() => { resetToPromptBar(); setPrompt(""); setUploadedImageUrl(null); setUploadedImageUrls([]); setImageMode(false); setUploadedVideoUrl(null); setUploadedVideoName(null); setV2vMode(false); const first = t2vModels[0]; setSelectedModel(first.id); setSelectedModelName(first.name); applyControlsForModel(first.id, false, false); setPromptDisabled(false); setTimeout(() => textareaRef.current?.focus(), 50); }, [resetToPromptBar, applyControlsForModel]); const handleExtend = useCallback(() => { if (!lastGenerationId) return; resetToPromptBar(); setPrompt(""); setUploadedImageUrl(null); setUploadedImageUrls([]); setImageMode(false); setSelectedModel("seedance-v2.0-extend"); setSelectedModelName("Seedance 2.0 Extend"); applyControlsForModel("seedance-v2.0-extend", false, false); setPromptDisabled(false); setTimeout(() => textareaRef.current?.focus(), 50); }, [lastGenerationId, resetToPromptBar, applyControlsForModel]); // ── derived UI values ──────────────────────────────────────────────────── const isSeedance2Canvas = canvasModel === "seedance-v2.0-t2v" || canvasModel === "seedance-v2.0-i2v"; const currentModelObj = getCurrentModel(); const isExtendMode = currentModelObj?.requiresRequestId; const promptPlaceholder = v2vMode ? currentModelObj?.imageField ? currentModelObj?.promptRequired ? "Describe the motion" : "Describe the motion (optional)" : "Video ready — click Generate to remove watermark" : imageMode ? "Describe the motion or effect (optional)" : isExtendMode ? "Optional: describe how to continue the video..." : "Describe the video you want to create"; const toggleDropdown = (type) => (e) => { e.stopPropagation(); setOpenDropdown((prev) => (prev === type ? null : type)); }; // ── render ──────────────────────────────────────────────────────────────── return (
{/* ── CENTRAL GALLERY AREA ── */}
{history.length > 0 ? (
{history.map((entry, idx) => { const isSeedance2 = entry.model === "seedance-v2.0-t2v" || entry.model === "seedance-v2.0-i2v"; return (
); })}
) : (
{/* Overlapping floating cards */}
Creative asset 1
Creative asset 2
Creative asset 3
Creative asset 4

START CREATING WITH {selectedModelName}

Animate images into stunning AI videos with motion effects

)}
{/* ── BOTTOM PROMPT BAR ── */}
{/* Inline list of uploaded media files */}
{/* Main image preview */} {uploadedImageUrl && (
)} {/* End frame image preview */} {uploadedEndImageUrl && (
END
)} {/* Video preview */} {uploadedVideoUrl && (
)} {/* Multiple images layout if supported */} {imageMode && getMaxImagesForI2VModel(selectedModel) > 2 && ( <> {uploadedImageUrls.map((url, idx) => (
{idx + 1}
))} )} {/* Upload trigger buttons */} {/* Image upload button — shown when the model accepts image input: • T2V mode: uploading an image auto-switches to the sibling I2V model • I2V mode: uploading the start-frame (multi-image logic applies) • V2V motion-control: reference image is required alongside the video • T2V with inputs.images_list: optional reference images (e.g. Seedance 2.0 Extend) • Hidden in regular V2V mode (watermark remover etc. needs no image) • Hidden for extend-type models without inputs.images_list */} {((!v2vMode || isMotionControlSelection(selectedModel, v2vMode)) && (!isExtendMode || currentModelObj?.inputs?.images_list)) && ( getMaxImagesForI2VModel(selectedModel) > 2 ? ( uploadedImageUrls.length < getMaxImagesForI2VModel(selectedModel) && (
) ) : ( !uploadedImageUrl && (
) ) )} {/* End frame image button */} {imageMode && i2vModels.find((m) => m.id === selectedModel)?.lastImageField && !uploadedEndImageUrl && (
)} {/* Video upload button — shown when a V2V model is active, OR when the current model has inputs.video_files (e.g. Seedance 2.0 Extend). */} {!uploadedVideoUrl && (v2vMode || currentModelObj?.inputs?.video_files) && (
)}
{/* Prompt textarea */}