"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.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 */}
setSelectedProvider("all")}
className={`w-8.5 h-8.5 rounded-full flex items-center justify-center border transition-all flex-shrink-0 cursor-pointer ${
selectedProvider === "all"
? "bg-white/10 text-yellow-400 border-yellow-500/30 shadow-md scale-105"
: "bg-white/[0.02] text-white/50 border-white/[0.03] hover:bg-white/5 hover:text-white"
}`}
title="All Providers"
>
{availableProviders.map(p => {
const style = getProviderStyle(p.id);
const isSelected = selectedProvider === p.id;
return (
setSelectedProvider(p.id)}
className={`w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center font-black text-[10px] border transition-all flex-shrink-0 cursor-pointer overflow-hidden ${
isSelected
? `${style.bg} border-white/25 scale-105 shadow-md`
: "bg-white/[0.02] text-white/40 border-white/[0.02] hover:bg-white/5 hover:text-white/80"
}`}
title={p.name}
>
{PROVIDER_LOGOS[p.id] ? (
) : (
style.text
)}
);
})}
{/* Right Pane: Search + Lists */}
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 (
{icon}
{label}
);
}
// ── 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 (
setFullscreenUrl(entry.url)}
controls={false}
loop
muted
playsInline
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
{
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
{
e.stopPropagation();
downloadFile(entry.url, `video-${entry.id || idx}.mp4`);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
{isSeedance2 && (
{
e.stopPropagation();
setLastGenerationId(entry.id);
handleExtend();
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
)}
{
e.stopPropagation();
if (confirm("Are you sure you want to delete this generated item?")) {
setLocalHistory(prev => prev.filter((_, i) => i !== idx));
}
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-red-400 hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
{/* Prompt & Details */}
{entry.prompt || "No prompt provided"}
{entry.model?.replace("-", " ")}
{entry.resolution && (
{entry.resolution}
)}
{entry.duration && (
{entry.duration}s
)}
);
})}
) : (
{/* Overlapping floating cards */}
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) => (
removeImageAtIndex(idx)}
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/60 hover:bg-black rounded-full flex items-center justify-center text-white/85 hover:text-white text-[8px] border border-white/5"
>
×
{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 */}
{/* Extend banner */}
{isExtendMode && (
Extending previous Seedance 2.0 generation
)}
{/* Bottom row: controls + generate */}
{/* Model btn */}
{(() => {
const allCurrentModels = [...t2vModels, ...i2vModels, ...v2vModels];
const selectedModelObj = allCurrentModels.find(m => m.id === selectedModel);
const selectedModelProvider = selectedModelObj?.provider || 'muapi';
return PROVIDER_LOGOS[selectedModelProvider] ? (
) : (
V
);
})()}
{selectedModelName}
{openDropdown === "model" && (
e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-[1.5rem] p-3.5 shadow-2xl border border-white/[0.05] w-[calc(100vw-2rem)] md:w-[480px] max-w-md md:max-w-none"
>
setOpenDropdown(null)}
/>
)}
{/* Aspect ratio btn */}
{showAr && (
{selectedAr}
{openDropdown === "ar" && (
e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-80 overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]"
>
Aspect Ratio
{getCurrentAspectRatios(selectedModel).map((r) => (
{
e.stopPropagation();
setSelectedAr(r);
setOpenDropdown(null);
}}
>
{r}
{selectedAr === r && }
))}
)}
)}
{/* Effect btn */}
{showEffect && (
{selectedEffect || "Effect"}
{openDropdown === "effect" && (
e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-80 overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[200px]"
>
Effect Type
{getEffectsForI2VModel(selectedModel).map((eff) => (
{
e.stopPropagation();
setSelectedEffect(eff);
setOpenDropdown(null);
}}
>
{eff}
{selectedEffect === eff && }
))}
)}
)}
{/* Duration btn */}
{showDuration && (
{selectedDuration}s
{openDropdown === "duration" && (
e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[140px]"
>
Duration
{getCurrentDurations(selectedModel).map((d) => (
{
e.stopPropagation();
setSelectedDuration(d);
setOpenDropdown(null);
}}
>
{d}s
{selectedDuration === d && }
))}
)}
)}
{/* Resolution btn */}
{showResolution && (
{selectedResolution || "720p"}
{openDropdown === "resolution" && (
e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[140px]"
>
Resolution
{getCurrentResolutions(selectedModel).map((r) => (
{
e.stopPropagation();
setSelectedResolution(r);
setOpenDropdown(null);
}}
>
{r}
{selectedResolution === r && }
))}
)}
)}
{/* Generate button */}
{generating ? (
<>
◌
{" "}
Generating...
>
) : generateError ? (
`Error: ${generateError}`
) : (
<>
Generate
>
)}
{/* ── FULLSCREEN VIDEO MODAL ── */}
{fullscreenUrl && (
setFullscreenUrl(null)}
>
{
e.stopPropagation();
setFullscreenUrl(null);
}}
>
e.stopPropagation()}
/>
)}
);
}