2187 lines
92 KiB
React
2187 lines
92 KiB
React
"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 = () => (
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="#22d3ee"
|
||
strokeWidth="4"
|
||
>
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
);
|
||
|
||
const VideoIconSvg = ({ className }) => (
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
className={className}
|
||
>
|
||
<polygon points="23 7 16 12 23 17 23 7" />
|
||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||
</svg>
|
||
);
|
||
|
||
const VideoReadySvg = () => (
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
className="text-primary"
|
||
>
|
||
<polygon points="23 7 16 12 23 17 23 7" />
|
||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||
<polyline points="7 10 10 13 15 8" stroke="#22d3ee" strokeWidth="2.5" />
|
||
</svg>
|
||
);
|
||
|
||
// ── Dropdown components ───────────────────────────────────────────────────────
|
||
|
||
function DropdownItem({ label, selected, onClick }) {
|
||
return (
|
||
<div
|
||
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group"
|
||
onClick={onClick}
|
||
>
|
||
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100 capitalize">
|
||
{label}
|
||
</span>
|
||
{selected && <CheckSvg />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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) => (
|
||
<div
|
||
key={m.id}
|
||
className={`flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedModel === m.id ? "bg-white/5 border-white/5" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onSelect(m, isV2V);
|
||
onClose();
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-3.5">
|
||
{PROVIDER_LOGOS[m.provider] ? (
|
||
<div className="w-8 h-8 rounded-xl border border-white/5 overflow-hidden shrink-0 flex items-center justify-center bg-white/[0.02]">
|
||
<img
|
||
src={PROVIDER_LOGOS[m.provider]}
|
||
alt={m.provider_name}
|
||
className={`w-full h-full object-contain p-1 ${invertLogos.includes(m.provider) ? "invert" : ""}`}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`w-9 h-9 ${getIconColor(m, isV2V)} border rounded-xl flex items-center justify-center font-black text-xs shadow-inner uppercase`}
|
||
>
|
||
{m.name.charAt(0)}
|
||
</div>
|
||
)}
|
||
<div className="flex flex-col gap-0.5 min-w-0">
|
||
<span className="text-xs font-bold text-white tracking-tight truncate">
|
||
{m.name}
|
||
</span>
|
||
{isV2V ? (
|
||
<span className="text-[9px] text-orange-400/70">
|
||
{m.imageField ? "Upload a video and image" : "Upload a video to use"}
|
||
</span>
|
||
) : (
|
||
selectedProvider === "all" && m.provider_name && (
|
||
<span className="text-[9px] text-white/40">
|
||
{m.provider_name}
|
||
</span>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
{selectedModel === m.id && <CheckSvg />}
|
||
</div>
|
||
);
|
||
|
||
const invertLogos = ['openai', 'blackforest', 'runway', 'ideogram', 'lightricks', 'grok'];
|
||
|
||
return (
|
||
<div className="flex gap-4 h-full max-h-[70vh] min-h-[350px]">
|
||
{/* Left Sidebar: Provider tabs */}
|
||
<div className="flex flex-col gap-2.5 items-center pr-3 border-r border-white/5 shrink-0 select-none overflow-y-auto custom-scrollbar w-12 pt-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => 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"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill={selectedProvider === "all" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||
</svg>
|
||
</button>
|
||
|
||
{availableProviders.map(p => {
|
||
const style = getProviderStyle(p.id);
|
||
const isSelected = selectedProvider === p.id;
|
||
return (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
onClick={() => 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] ? (
|
||
<img
|
||
src={PROVIDER_LOGOS[p.id]}
|
||
alt={p.name}
|
||
className={`w-full h-full rounded-full object-contain ${invertLogos.includes(p.id) ? "invert" : ""}`}
|
||
/>
|
||
) : (
|
||
style.text
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Right Pane: Search + Lists */}
|
||
<div className="flex-1 flex flex-col gap-2 min-w-0">
|
||
<div className="px-1 pb-2 border-b border-white/5 shrink-0">
|
||
<div className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-2 border border-white/5 focus-within:border-primary/50 transition-colors">
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="3"
|
||
className="text-muted"
|
||
>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
<input
|
||
type="text"
|
||
placeholder="Search models..."
|
||
value={search}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-xs font-bold text-secondary px-2 py-1 shrink-0 flex items-center justify-between">
|
||
<span>Video models</span>
|
||
{selectedProvider !== "all" && (
|
||
<span className="text-[10px] bg-white/5 px-2 py-0.5 rounded text-white/60">
|
||
{availableProviders.find(p => p.id === selectedProvider)?.name || selectedProvider}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1 pb-2 flex-1">
|
||
{filteredMain.length === 0 && filteredV2V.length === 0 ? (
|
||
<div className="text-xs text-white/30 text-center py-6">
|
||
No models found
|
||
</div>
|
||
) : (
|
||
<>
|
||
{filteredMain.map((m) => renderItem(m, false))}
|
||
{filteredV2V.length > 0 && (
|
||
<>
|
||
<div className="text-xs font-bold text-orange-400/70 px-3 py-2 mt-1 border-t border-white/5">
|
||
Video Tools
|
||
</div>
|
||
{filteredV2V.map((m) => renderItem(m, true))}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Control button ────────────────────────────────────────────────────────────
|
||
|
||
function ControlBtn({ icon, label, onClick, style }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
style={style}
|
||
className="flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap"
|
||
>
|
||
{icon}
|
||
<span className="text-xs font-bold text-white group-hover:text-primary transition-colors">
|
||
{label}
|
||
</span>
|
||
<svg
|
||
width="10"
|
||
height="10"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
className="opacity-20 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<path d="M6 9l6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div
|
||
ref={containerRef}
|
||
className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden"
|
||
>
|
||
{/* ── CENTRAL GALLERY AREA ── */}
|
||
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
|
||
{history.length > 0 ? (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
|
||
{history.map((entry, idx) => {
|
||
const isSeedance2 = entry.model === "seedance-v2.0-t2v" || entry.model === "seedance-v2.0-i2v";
|
||
return (
|
||
<div
|
||
key={entry.id || idx}
|
||
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
|
||
>
|
||
<video
|
||
src={entry.url}
|
||
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
|
||
onClick={() => setFullscreenUrl(entry.url)}
|
||
controls={false}
|
||
loop
|
||
muted
|
||
playsInline
|
||
onMouseOver={(e) => e.target.play()}
|
||
onMouseOut={(e) => {
|
||
e.target.pause();
|
||
e.target.currentTime = 0;
|
||
}}
|
||
/>
|
||
|
||
{/* Overlay actions */}
|
||
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
type="button"
|
||
title="Fullscreen"
|
||
onClick={(e) => {
|
||
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"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||
<polyline points="15 3 21 3 21 9" />
|
||
<polyline points="9 21 3 21 3 15" />
|
||
<line x1="21" y1="3" x2="14" y2="10" />
|
||
<line x1="3" y1="21" x2="10" y2="14" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="Download"
|
||
onClick={(e) => {
|
||
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"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||
</svg>
|
||
</button>
|
||
{isSeedance2 && (
|
||
<button
|
||
type="button"
|
||
title="Extend this video using Seedance 2.0 Extend"
|
||
onClick={(e) => {
|
||
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"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
title="Delete"
|
||
onClick={(e) => {
|
||
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"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||
<polyline points="3 6 5 6 21 6" />
|
||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||
<line x1="10" y1="11" x2="10" y2="17" />
|
||
<line x1="14" y1="11" x2="14" y2="17" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Prompt & Details */}
|
||
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
|
||
<p className="text-white/70 text-xs line-clamp-3 leading-relaxed" title={entry.prompt}>
|
||
{entry.prompt || "No prompt provided"}
|
||
</p>
|
||
<div className="flex items-center justify-between mt-1 flex-wrap gap-1">
|
||
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 whitespace-nowrap">
|
||
{entry.model?.replace("-", " ")}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
{entry.resolution && (
|
||
<span className="text-[10px] text-white/40">{entry.resolution}</span>
|
||
)}
|
||
{entry.duration && (
|
||
<span className="text-[10px] text-white/40">{entry.duration}s</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
|
||
{/* Overlapping floating cards */}
|
||
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
|
||
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
|
||
<img
|
||
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/sdxl-image.avif"
|
||
alt="Creative asset 1"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[4deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
|
||
<img
|
||
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/chroma-image.avif"
|
||
alt="Creative asset 2"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="w-18 h-18 sm:w-24 sm:h-24 rounded-full border border-white/10 shadow-2xl rotate-[6deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
|
||
<img
|
||
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/neta-lumina.avif"
|
||
alt="Creative asset 3"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
|
||
<img
|
||
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/perfect-pony-xl.avif"
|
||
alt="Creative asset 4"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<h1 className="text-2xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-center px-4 flex flex-col items-center">
|
||
<span className="text-white font-black uppercase text-xl sm:text-3xl tracking-wide mb-1 opacity-90">START CREATING WITH</span>
|
||
<span className="text-[#22d3ee] font-black uppercase text-2xl sm:text-4xl sm:mt-1 tracking-tight">
|
||
{selectedModelName}
|
||
</span>
|
||
</h1>
|
||
<p className="text-white/40 text-xs sm:text-sm font-medium tracking-wide text-center max-w-lg leading-relaxed px-4">
|
||
Animate images into stunning AI videos with motion effects
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── BOTTOM PROMPT BAR ── */}
|
||
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
|
||
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
|
||
<div className="flex flex-col gap-3">
|
||
{/* Inline list of uploaded media files */}
|
||
<div className="flex items-center gap-2.5 flex-wrap">
|
||
{/* Main image preview */}
|
||
{uploadedImageUrl && (
|
||
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
|
||
<img src={uploadedImageUrl} alt="" className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={clearImageUpload}
|
||
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"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* End frame image preview */}
|
||
{uploadedEndImageUrl && (
|
||
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
|
||
<img src={uploadedEndImageUrl} alt="" className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={clearEndImage}
|
||
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"
|
||
>
|
||
×
|
||
</button>
|
||
<span className="absolute bottom-0.5 left-0.5 px-1 h-3.5 bg-black/60 rounded-md text-[7px] font-black text-[#22d3ee] leading-none flex items-center justify-center pointer-events-none">
|
||
END
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Video preview */}
|
||
{uploadedVideoUrl && (
|
||
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
|
||
<video src={uploadedVideoUrl} className="w-full h-full object-cover" muted />
|
||
<button
|
||
type="button"
|
||
onClick={clearVideoUpload}
|
||
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"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Multiple images layout if supported */}
|
||
{imageMode && getMaxImagesForI2VModel(selectedModel) > 2 && (
|
||
<>
|
||
{uploadedImageUrls.map((url, idx) => (
|
||
<div key={idx} className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
|
||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={() => 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"
|
||
>
|
||
×
|
||
</button>
|
||
<span className="absolute bottom-0.5 right-0.5 px-1 h-3.5 bg-black/60 rounded-full text-[8px] font-black text-[#22d3ee] leading-none flex items-center justify-center pointer-events-none">
|
||
{idx + 1}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* 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) && (
|
||
<div className="relative">
|
||
<input
|
||
ref={imageFileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleImageFileChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
title="Upload reference image"
|
||
onClick={() => imageFileInputRef.current?.click()}
|
||
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
|
||
>
|
||
{imageUploading ? (
|
||
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||
<svg className="w-8 h-8 -rotate-90">
|
||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" fill="transparent" className="text-white/10" />
|
||
<circle
|
||
cx="16"
|
||
cy="16"
|
||
r="14"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
fill="transparent"
|
||
strokeDasharray={88}
|
||
strokeDashoffset={88 - (88 * imageProgress) / 100}
|
||
className="text-[#22d3ee] transition-all duration-300"
|
||
/>
|
||
</svg>
|
||
<span className="absolute text-[9px] font-black text-[#22d3ee] leading-none">{imageProgress}%</span>
|
||
</div>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-white/40 group-hover:text-[#22d3ee] transition-colors">
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)
|
||
) : (
|
||
!uploadedImageUrl && (
|
||
<div className="relative">
|
||
<input
|
||
ref={imageFileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleImageFileChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
title="Upload reference image"
|
||
onClick={() => imageFileInputRef.current?.click()}
|
||
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
|
||
>
|
||
{imageUploading ? (
|
||
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||
<svg className="w-8 h-8 -rotate-90">
|
||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" fill="transparent" className="text-white/10" />
|
||
<circle
|
||
cx="16"
|
||
cy="16"
|
||
r="14"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
fill="transparent"
|
||
strokeDasharray={88}
|
||
strokeDashoffset={88 - (88 * imageProgress) / 100}
|
||
className="text-[#22d3ee] transition-all duration-300"
|
||
/>
|
||
</svg>
|
||
<span className="absolute text-[9px] font-black text-[#22d3ee] leading-none">{imageProgress}%</span>
|
||
</div>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-white/40 group-hover:text-[#22d3ee] transition-colors">
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)
|
||
)
|
||
)}
|
||
|
||
{/* End frame image button */}
|
||
{imageMode && i2vModels.find((m) => m.id === selectedModel)?.lastImageField && !uploadedEndImageUrl && (
|
||
<div className="relative">
|
||
<input
|
||
ref={endImageFileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleEndImageFileChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
title="Upload end frame (optional)"
|
||
onClick={() => endImageFileInputRef.current?.click()}
|
||
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
|
||
>
|
||
{endImageUploading ? (
|
||
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||
<svg className="w-8 h-8 -rotate-90">
|
||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" fill="transparent" className="text-white/10" />
|
||
<circle
|
||
cx="16"
|
||
cy="16"
|
||
r="14"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
fill="transparent"
|
||
strokeDasharray={88}
|
||
strokeDashoffset={88 - (88 * endImageProgress) / 100}
|
||
className="text-[#22d3ee] transition-all duration-300"
|
||
/>
|
||
</svg>
|
||
<span className="absolute text-[9px] font-black text-[#22d3ee] leading-none">{endImageProgress}%</span>
|
||
</div>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-white/40 group-hover:text-[#22d3ee] transition-colors">
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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) && (
|
||
<div className="relative">
|
||
<input
|
||
ref={videoFileInputRef}
|
||
type="file"
|
||
accept="video/*"
|
||
className="hidden"
|
||
onChange={handleVideoFileChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
title="Upload video to remove watermark"
|
||
onClick={() => videoFileInputRef.current?.click()}
|
||
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
|
||
>
|
||
{videoUploading ? (
|
||
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||
<svg className="w-8 h-8 -rotate-90">
|
||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" fill="transparent" className="text-white/10" />
|
||
<circle
|
||
cx="16"
|
||
cy="16"
|
||
r="14"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
fill="transparent"
|
||
strokeDasharray={88}
|
||
strokeDashoffset={88 - (88 * videoProgress) / 100}
|
||
className="text-[#22d3ee] transition-all duration-300"
|
||
/>
|
||
</svg>
|
||
<span className="absolute text-[9px] font-black text-[#22d3ee] leading-none">{videoProgress}%</span>
|
||
</div>
|
||
) : (
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
className="text-white/40 group-hover:text-[#22d3ee] transition-colors"
|
||
>
|
||
<polygon points="23 7 16 12 23 17 23 7" fill="currentColor" />
|
||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" fill="currentColor" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Prompt textarea */}
|
||
<div className="flex-1 flex flex-col gap-1">
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={prompt}
|
||
onChange={handlePromptInput}
|
||
placeholder={promptPlaceholder}
|
||
disabled={promptDisabled}
|
||
rows={1}
|
||
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Extend banner */}
|
||
{isExtendMode && (
|
||
<div className="flex items-center gap-2 px-3 py-1.5 mx-3 bg-primary/5 border border-primary/10 rounded-lg text-[10px] text-primary/80 font-medium tracking-tight">
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
>
|
||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||
</svg>
|
||
<span>Extending previous Seedance 2.0 generation</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom row: controls + generate */}
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
|
||
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
|
||
{/* Model btn */}
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={toggleDropdown("model")}
|
||
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
|
||
>
|
||
<div className="w-4 h-4 rounded overflow-hidden shrink-0 flex items-center justify-center bg-white/5">
|
||
{(() => {
|
||
const allCurrentModels = [...t2vModels, ...i2vModels, ...v2vModels];
|
||
const selectedModelObj = allCurrentModels.find(m => m.id === selectedModel);
|
||
const selectedModelProvider = selectedModelObj?.provider || 'muapi';
|
||
return PROVIDER_LOGOS[selectedModelProvider] ? (
|
||
<img
|
||
src={PROVIDER_LOGOS[selectedModelProvider]}
|
||
alt=""
|
||
className={`w-full h-full object-contain ${invertLogos.includes(selectedModelProvider) ? "invert" : ""}`}
|
||
/>
|
||
) : (
|
||
<span className="text-[9px] font-bold text-black uppercase">V</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
|
||
{selectedModelName}
|
||
</span>
|
||
<svg
|
||
width="8"
|
||
height="8"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
className="opacity-20 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<path d="M6 9l6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
{openDropdown === "model" && (
|
||
<div
|
||
ref={dropdownRef}
|
||
onClick={(e) => 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"
|
||
>
|
||
<ModelDropdown
|
||
imageMode={imageMode}
|
||
selectedModel={selectedModel}
|
||
onSelect={handleModelSelect}
|
||
onClose={() => setOpenDropdown(null)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Aspect ratio btn */}
|
||
{showAr && (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={toggleDropdown("ar")}
|
||
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
className="opacity-40 text-white"
|
||
>
|
||
<rect
|
||
x="3"
|
||
y="3"
|
||
width="18"
|
||
height="18"
|
||
rx="2"
|
||
ry="2"
|
||
/>
|
||
</svg>
|
||
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
|
||
{selectedAr}
|
||
</span>
|
||
</button>
|
||
{openDropdown === "ar" && (
|
||
<div
|
||
ref={dropdownRef}
|
||
onClick={(e) => 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]"
|
||
>
|
||
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
|
||
Aspect Ratio
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{getCurrentAspectRatios(selectedModel).map((r) => (
|
||
<div
|
||
key={r}
|
||
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedAr(r);
|
||
setOpenDropdown(null);
|
||
}}
|
||
>
|
||
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
|
||
{r}
|
||
</span>
|
||
{selectedAr === r && <CheckSvg />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Effect btn */}
|
||
{showEffect && (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={toggleDropdown("effect")}
|
||
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
className="opacity-40 text-white"
|
||
>
|
||
<path d="M5 3l14 9-14 9V3z" />
|
||
</svg>
|
||
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors max-w-[140px] truncate">
|
||
{selectedEffect || "Effect"}
|
||
</span>
|
||
</button>
|
||
{openDropdown === "effect" && (
|
||
<div
|
||
ref={dropdownRef}
|
||
onClick={(e) => 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]"
|
||
>
|
||
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
|
||
Effect Type
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{getEffectsForI2VModel(selectedModel).map((eff) => (
|
||
<div
|
||
key={eff}
|
||
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedEffect(eff);
|
||
setOpenDropdown(null);
|
||
}}
|
||
>
|
||
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
|
||
{eff}
|
||
</span>
|
||
{selectedEffect === eff && <CheckSvg />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Duration btn */}
|
||
{showDuration && (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={toggleDropdown("duration")}
|
||
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
className="opacity-40 text-white"
|
||
>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<polyline points="12 6 12 12 16 14" />
|
||
</svg>
|
||
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
|
||
{selectedDuration}s
|
||
</span>
|
||
</button>
|
||
{openDropdown === "duration" && (
|
||
<div
|
||
ref={dropdownRef}
|
||
onClick={(e) => 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]"
|
||
>
|
||
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
|
||
Duration
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{getCurrentDurations(selectedModel).map((d) => (
|
||
<div
|
||
key={d}
|
||
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedDuration(d);
|
||
setOpenDropdown(null);
|
||
}}
|
||
>
|
||
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
|
||
{d}s
|
||
</span>
|
||
{selectedDuration === d && <CheckSvg />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Resolution btn */}
|
||
{showResolution && (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={toggleDropdown("resolution")}
|
||
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
|
||
>
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
className="opacity-40 text-white"
|
||
>
|
||
<polygon points="12 2 22 12 12 22 2 12" />
|
||
</svg>
|
||
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
|
||
{selectedResolution || "720p"}
|
||
</span>
|
||
</button>
|
||
{openDropdown === "resolution" && (
|
||
<div
|
||
ref={dropdownRef}
|
||
onClick={(e) => 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]"
|
||
>
|
||
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
|
||
Resolution
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{getCurrentResolutions(selectedModel).map((r) => (
|
||
<div
|
||
key={r}
|
||
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedResolution(r);
|
||
setOpenDropdown(null);
|
||
}}
|
||
>
|
||
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
|
||
{r}
|
||
</span>
|
||
{selectedResolution === r && <CheckSvg />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Generate button */}
|
||
<button
|
||
type="button"
|
||
onClick={handleGenerate}
|
||
disabled={generating}
|
||
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-black text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{generating ? (
|
||
<>
|
||
<span className="animate-spin inline-block text-black">
|
||
◌
|
||
</span>{" "}
|
||
Generating...
|
||
</>
|
||
) : generateError ? (
|
||
`Error: ${generateError}`
|
||
) : (
|
||
<>
|
||
<span>Generate</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── FULLSCREEN VIDEO MODAL ── */}
|
||
{fullscreenUrl && (
|
||
<div
|
||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in"
|
||
onClick={() => setFullscreenUrl(null)}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors border border-white/10"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setFullscreenUrl(null);
|
||
}}
|
||
>
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18" />
|
||
<line x1="6" y1="6" x2="18" y2="18" />
|
||
</svg>
|
||
</button>
|
||
<video
|
||
src={fullscreenUrl}
|
||
controls
|
||
autoPlay
|
||
loop
|
||
className="max-w-[95vw] max-h-[95vh] rounded-2xl shadow-2xl object-contain animate-scale-up"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|