"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { generateVideo, generateI2V, uploadFile } from "../muapi.js"; import { t2vModels, i2vModels, v2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel, getAspectRatiosForI2VModel, getDurationsForI2VModel, getResolutionsForI2VModel, getModesForModel, } 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 && }
); } function ModelDropdown({ imageMode, selectedModel, onSelect, onClose }) { const [search, setSearch] = useState(""); const generationModels = imageMode ? i2vModels : t2vModels; const lf = search.toLowerCase(); const filteredMain = generationModels.filter( (m) => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf), ); const filteredV2V = v2vModels.filter( (m) => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf), ); const getIconColor = (m, isV2V) => { if (isV2V) return "bg-orange-500/10 text-orange-400"; if (m.id.includes("kling")) return "bg-blue-500/10 text-blue-400"; if (m.id.includes("veo")) return "bg-purple-500/10 text-purple-400"; if (m.id.includes("sora")) return "bg-rose-500/10 text-rose-400"; return "bg-primary/10 text-primary"; }; const renderItem = (m, isV2V = false) => (
{ e.stopPropagation(); onSelect(m, isV2V); onClose(); }} >
{m.name.charAt(0)}
{m.name} {isV2V && ( Upload a video to use )}
{selectedModel === m.id && }
); return (
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
{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, }) { 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(""); // ── 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); // ── uploads ── const [uploadedImageUrl, setUploadedImageUrl] = useState(null); const [imageUploading, setImageUploading] = useState(false); 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 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], ); // ── update controls when model/mode changes ────────────────────────────── const applyControlsForModel = useCallback( (modelId, isImageMode, isV2vMode) => { if (isV2vMode) { setShowAr(false); setShowDuration(false); setShowResolution(false); setShowQuality(false); setShowMode(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); } }, [], ); // ── 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.uploadedImageUrl) setUploadedImageUrl(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]); // ── Persistence: Save ──────────────────────────────────────────────────── useEffect(() => { const timer = setTimeout(() => { try { const state = { imageMode, v2vMode, selectedModel, selectedModelName, selectedAr, selectedDuration, selectedResolution, selectedQuality, selectedMode, uploadedImageUrl, 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, uploadedImageUrl, uploadedVideoUrl, uploadedVideoName, prompt, localHistory, ]); // 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); // Clear v2v if active setUploadedVideoUrl(null); setUploadedVideoName(null); setV2vMode(false); if (!imageMode) { const firstI2V = i2vModels[0]; setImageMode(true); setSelectedModel(firstI2V.id); setSelectedModelName(firstI2V.name); applyControlsForModel(firstI2V.id, true, false); } 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); setImageMode(false); const first = t2vModels[0]; setSelectedModel(first.id); setSelectedModelName(first.name); applyControlsForModel(first.id, false, false); setPromptDisabled(false); }; // ── 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); // Clear image mode if active 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); setUploadedImageUrl(null); setUploadedImagePreview(null); setSelectedModel(m.id); setSelectedModelName(m.name); applyControlsForModel(m.id, false, true); 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; } } else if (isExtendMode) { if (!lastGenerationId) { alert( "No Seedance 2.0 generation found to extend. Generate a video first.", ); return; } } else if (imageMode) { 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: use generateVideo with video_url (the v2v models use the video endpoint) res = await generateVideo(apiKey, { model: selectedModel, video_url: uploadedVideoUrl, }); 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: "", model: selectedModel, timestamp: new Date().toISOString(), }; addToLocalHistory(entry); showVideoInCanvas(res.url, selectedModel); if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: "", type: "video", }); } else if (imageMode) { const i2vParams = { model: selectedModel, image_url: uploadedImageUrl }; if (trimmedPrompt) i2vParams.prompt = trimmedPrompt; i2vParams.aspect_ratio = selectedAr; 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; 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; } 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, uploadedImageUrl, uploadedVideoUrl, lastGenerationId, getCurrentModel, addToLocalHistory, showVideoInCanvas, onGenerationComplete, ]); // ── reset to prompt bar ─────────────────────────────────────────────────── const resetToPromptBar = useCallback(() => { setShowCanvas(false); }, []); const handleNewPrompt = useCallback(() => { resetToPromptBar(); setPrompt(""); setUploadedImageUrl(null); setUploadedImagePreview(null); 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); setUploadedImagePreview(null); 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 ? "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 (
); })}
) : (

START CREATING WITH
VIDEO STUDIO

Animate images into stunning AI videos with motion effects

)}
{/* ── BOTTOM PROMPT BAR ── */}
{/* Image upload button */}
{/* Video upload button */}
{/* Prompt textarea */}