"use client"; import React, { useCallback, useState, useEffect, useMemo, useRef } from "react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { ModelParameters } from "./ModelParameters"; import { useWorkflowStore } from "@/store/workflowStore"; import { GenerateVideoNodeData, ProviderType, SelectedModel, ModelInputDef } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { useToast } from "@/components/Toast"; // Video generation capabilities const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"]; type GenerateVideoNodeType = Node; export function GenerateVideoNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const providerSettings = useWorkflowStore((state) => state.providerSettings); const [externalModels, setExternalModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); const [modelsFetchError, setModelsFetchError] = useState(null); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); // Get the current selected provider (default to fal since Gemini doesn't do video) const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; // Get enabled providers (exclude Gemini since it doesn't do video) const enabledProviders = useMemo(() => { const providers: { id: ProviderType; name: string }[] = []; // fal.ai is always available (works without key but rate limited) providers.push({ id: "fal", name: "fal.ai" }); // Add Replicate if configured if (providerSettings.providers.replicate?.enabled && providerSettings.providers.replicate?.apiKey) { providers.push({ id: "replicate", name: "Replicate" }); } return providers; }, [providerSettings]); // Fetch models from external providers when provider changes const fetchModels = useCallback(async () => { setIsLoadingModels(true); setModelsFetchError(null); try { const capabilities = VIDEO_CAPABILITIES.join(","); const headers: HeadersInit = {}; if (providerSettings.providers.replicate?.apiKey) { headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey; } if (providerSettings.providers.fal?.apiKey) { headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey; } const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers }); if (response.ok) { const data = await response.json(); setExternalModels(data.models || []); setModelsFetchError(null); } else { const errorData = await response.json().catch(() => ({})); const errorMsg = errorData.error || `Failed to load models (${response.status})`; setExternalModels([]); setModelsFetchError( currentProvider === "replicate" && response.status === 401 ? "Invalid Replicate API key. Check your settings." : errorMsg ); } } catch (error) { console.error("Failed to fetch video models:", error); setExternalModels([]); setModelsFetchError("Failed to load models. Check your connection."); } finally { setIsLoadingModels(false); } }, [currentProvider, providerSettings]); useEffect(() => { fetchModels(); }, [fetchModels]); // Handle provider change const handleProviderChange = useCallback( (e: React.ChangeEvent) => { const provider = e.target.value as ProviderType; // Set placeholder for the provider const newSelectedModel: SelectedModel = { provider, modelId: "", displayName: "Select model...", }; // Clear parameters when switching providers (different providers have different schemas) updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} }); }, [id, updateNodeData] ); // Handle model change const handleModelChange = useCallback( (e: React.ChangeEvent) => { const modelId = e.target.value; const model = externalModels.find(m => m.id === modelId); if (model) { const newSelectedModel: SelectedModel = { provider: currentProvider, modelId: model.id, displayName: model.name, }; // Clear parameters when changing models (different models have different schemas) updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} }); } }, [id, currentProvider, externalModels, updateNodeData] ); const handleClearVideo = useCallback(() => { updateNodeData(id, { outputVideo: null, status: "idle", error: null }); }, [id, updateNodeData]); const handleParametersChange = useCallback( (parameters: Record) => { updateNodeData(id, { parameters }); }, [id, updateNodeData] ); // Handle inputs loaded from schema const handleInputsLoaded = useCallback( (inputs: ModelInputDef[]) => { updateNodeData(id, { inputSchema: inputs }); }, [id, updateNodeData] ); // Handle parameters expand/collapse - resize node height const { setNodes } = useReactFlow(); const handleParametersExpandChange = useCallback( (expanded: boolean, parameterCount: number) => { // Each parameter row is ~24px, plus some padding const parameterHeight = expanded ? Math.max(parameterCount * 28 + 16, 60) : 0; const baseHeight = 300; // Default node height const newHeight = baseHeight + parameterHeight; setNodes((nodes) => nodes.map((node) => node.id === id ? { ...node, style: { ...node.style, height: newHeight } } : node ) ); }, [id, setNodes] ); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const handleRegenerate = useCallback(() => { regenerateNode(id); }, [id, regenerateNode]); // Handle model selection from browse dialog const handleBrowseModelSelect = useCallback((model: ProviderModel) => { const newSelectedModel: SelectedModel = { provider: model.provider, modelId: model.id, displayName: model.name, }; updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} }); setIsBrowseDialogOpen(false); }, [id, updateNodeData]); // Dynamic title based on selected model const displayTitle = useMemo(() => { if (nodeData.selectedModel?.displayName && nodeData.selectedModel.modelId) { return `Video - ${nodeData.selectedModel.displayName}`; } return "Generate Video"; }, [nodeData.selectedModel?.displayName, nodeData.selectedModel?.modelId]); // Header action element - browse button const headerAction = useMemo(() => ( ), []); // Track previous status to detect error transitions const prevStatusRef = useRef(nodeData.status); // Show toast when error occurs useEffect(() => { if (nodeData.status === "error" && prevStatusRef.current !== "error" && nodeData.error) { useToast.getState().show("Video generation failed", "error", true, nodeData.error); } prevStatusRef.current = nodeData.status; }, [nodeData.status, nodeData.error]); return ( <> updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} onRun={handleRegenerate} selected={selected} isExecuting={isRunning} hasError={nodeData.status === "error"} headerAction={headerAction} > {/* Dynamic input handles based on model schema */} {nodeData.inputSchema && nodeData.inputSchema.length > 0 ? ( // Render handles from schema, sorted by type (images first, text second) (() => { const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image"); const textInputs = nodeData.inputSchema!.filter(i => i.type === "text"); const sortedInputs = [...imageInputs, ...textInputs]; // Calculate positions with gap between image and text groups const imageCount = imageInputs.length; const textCount = textInputs.length; const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap return sortedInputs.map((input, index) => { // Add 1 to index for text inputs if there are image inputs (to account for gap) const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index; const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100; const isImage = input.type === "image"; return ( {/* Handle label - positioned outside node, above the connector */}
{input.label}
); }); })() ) : ( // Default handles when no schema <> {/* Default image label */}
Image
{/* Default text label */}
Prompt
)} {/* Video output */} {/* Output label */}
Video
{/* Preview area */} {nodeData.outputVideo ? (
) : (
{nodeData.status === "loading" ? ( ) : nodeData.status === "error" ? ( {nodeData.error || "Failed"} ) : ( Run to generate )}
)} {/* Model-specific parameters */} {nodeData.selectedModel?.modelId && ( )}
{/* Model browser dialog */} {isBrowseDialogOpen && ( setIsBrowseDialogOpen(false)} onModelSelected={handleBrowseModelSelect} initialCapabilityFilter="video" /> )} ); }