1446 lines
56 KiB
JavaScript
1446 lines
56 KiB
JavaScript
"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 = () => (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="#d9ff00"
|
|
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="#d9ff00" 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>
|
|
);
|
|
}
|
|
|
|
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) => (
|
|
<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">
|
|
<div
|
|
className={`w-10 h-10 ${getIconColor(m, isV2V)} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase`}
|
|
>
|
|
{m.name.charAt(0)}
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-bold text-white tracking-tight">
|
|
{m.name}
|
|
</span>
|
|
{isV2V && (
|
|
<span className="text-[9px] text-orange-400/70">
|
|
Upload a video to use
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{selectedModel === m.id && <CheckSvg />}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full max-h-[70vh]">
|
|
<div className="px-2 pb-3 mb-2 border-b border-white/5 shrink-0">
|
|
<div className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-2.5 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-3 py-2 shrink-0">
|
|
Video models
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1 pb-2">
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// ── 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,
|
|
}) {
|
|
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 (
|
|
<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-2xl 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>
|
|
)}
|
|
</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]">
|
|
<div className="mb-12 relative group">
|
|
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
|
|
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
|
|
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary opacity-80">
|
|
<polygon points="23 7 16 12 23 17 23 7" />
|
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
|
</svg>
|
|
</div>
|
|
<div className="absolute top-4 right-4 text-[10px] text-primary/40 animate-pulse">✨</div>
|
|
</div>
|
|
</div>
|
|
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
|
|
<span className="text-white/40 font-medium">START CREATING WITH</span><br />
|
|
<span className="text-white">VIDEO STUDIO</span>
|
|
</h1>
|
|
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed">
|
|
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-[#0a0a0a]/80 backdrop-blur-3xl rounded-md border border-white/10 p-4 flex flex-col gap-2 shadow-2xl">
|
|
<div className="flex items-center gap-2 px-1">
|
|
{/* Image upload button */}
|
|
<div className="relative">
|
|
<input
|
|
ref={imageFileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageFileChange}
|
|
/>
|
|
<button
|
|
type="button"
|
|
title={
|
|
uploadedImageUrl
|
|
? "Clear image"
|
|
: "Upload image for Image-to-Video"
|
|
}
|
|
onClick={() =>
|
|
uploadedImageUrl
|
|
? clearImageUpload()
|
|
: imageFileInputRef.current?.click()
|
|
}
|
|
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImageUrl ? "border-primary/60 bg-primary/5" : "bg-white/5 border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`}
|
|
>
|
|
{imageUploading ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
|
<span className="text-[8px] font-black text-primary">
|
|
{imageProgress}%
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{uploadedImageUrl ? (
|
|
<img
|
|
src={uploadedImageUrl}
|
|
alt=""
|
|
className={`w-full h-full object-cover rounded-full ${imageUploading ? "opacity-40 blur-[2px]" : "opacity-100"}`}
|
|
/>
|
|
) : (
|
|
!imageUploading && (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className="text-white/40 group-hover:text-primary transition-colors"
|
|
>
|
|
<rect
|
|
x="3"
|
|
y="3"
|
|
width="18"
|
|
height="18"
|
|
rx="2"
|
|
ry="2"
|
|
/>
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
)
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Video upload button */}
|
|
<div className="relative">
|
|
<input
|
|
ref={videoFileInputRef}
|
|
type="file"
|
|
accept="video/*"
|
|
className="hidden"
|
|
onChange={handleVideoFileChange}
|
|
/>
|
|
<button
|
|
type="button"
|
|
title={
|
|
uploadedVideoUrl
|
|
? `${uploadedVideoName} — click to clear`
|
|
: "Upload video to remove watermark"
|
|
}
|
|
onClick={() =>
|
|
uploadedVideoUrl
|
|
? clearVideoUpload()
|
|
: videoFileInputRef.current?.click()
|
|
}
|
|
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedVideoUrl ? "border-primary/60 bg-white/5" : "bg-white/[0.03] border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`}
|
|
>
|
|
{videoUploading ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
|
<span className="text-[8px] font-black text-primary">
|
|
{videoProgress}%
|
|
</span>
|
|
</div>
|
|
) : uploadedVideoUrl ? (
|
|
<video
|
|
src={uploadedVideoUrl}
|
|
className={`w-full h-full object-cover rounded-full ${videoUploading ? "opacity-40 blur-[2px]" : "opacity-100"}`}
|
|
muted
|
|
/>
|
|
) : (
|
|
<VideoIconSvg className="text-white/40 group-hover:text-primary transition-colors" />
|
|
)}
|
|
</button>
|
|
</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-2 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="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
|
|
>
|
|
<div className="w-4 h-4 bg-[#d9ff00] rounded flex items-center justify-center shadow-lg shadow-[#d9ff00]/10">
|
|
<span className="text-[9px] font-bold text-black uppercase">
|
|
V
|
|
</span>
|
|
</div>
|
|
<span className="text-xs font-semibold text-white/70 group-hover:text-[#d9ff00] 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 shadow-2xl border border-white/[0.05] w-[calc(100vw-3rem)] max-w-xs"
|
|
>
|
|
<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="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
|
|
>
|
|
<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-[#d9ff00] 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-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] max-h-80 overflow-y-auto custom-scrollbar min-w-[160px]"
|
|
>
|
|
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">
|
|
Aspect Ratio
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{getCurrentAspectRatios(selectedModel).map((r) => (
|
|
<div
|
|
key={r}
|
|
className="flex items-center justify-between p-3 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedAr(r);
|
|
setOpenDropdown(null);
|
|
}}
|
|
>
|
|
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white transition-opacity">
|
|
{r}
|
|
</span>
|
|
{selectedAr === r && <CheckSvg />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Duration btn */}
|
|
{showDuration && (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={toggleDropdown("duration")}
|
|
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
|
|
>
|
|
<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-[#d9ff00] 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-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/10 min-w-[140px]"
|
|
>
|
|
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">
|
|
Duration
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{getCurrentDurations(selectedModel).map((d) => (
|
|
<div
|
|
key={d}
|
|
className="flex items-center justify-between p-2 hover:bg-white/5 rounded-md 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-white">
|
|
{d}s
|
|
</span>
|
|
{selectedDuration === d && <CheckSvg />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Resolution btn */}
|
|
{showResolution && (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={toggleDropdown("resolution")}
|
|
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className="opacity-40 text-white"
|
|
>
|
|
<path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z" />
|
|
</svg>
|
|
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#d9ff00] 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-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/[0.05] min-w-[140px]"
|
|
>
|
|
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">
|
|
Resolution
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{getCurrentResolutions(selectedModel).map((r) => (
|
|
<div
|
|
key={r}
|
|
className="flex items-center justify-between p-3 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedResolution(r);
|
|
setOpenDropdown(null);
|
|
}}
|
|
>
|
|
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white">
|
|
{r}
|
|
</span>
|
|
{selectedResolution === r && <CheckSvg />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Generate button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleGenerate}
|
|
disabled={generating}
|
|
className="bg-[#d9ff00] text-black px-4 py-2 rounded-md font-medium text-sm hover:bg-[#e5ff33] 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-[#d9ff00]/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>
|
|
);
|
|
}
|