"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 { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { ModelParameters } from "./ModelParameters"; import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore"; import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; 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"; import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions"; // Provider badge component - shows provider icon for all providers function ProviderBadge({ provider }: { provider: ProviderType }) { const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : provider === "kie" ? "Kie.ai" : "fal.ai"; return ( {provider === "gemini" ? ( ) : provider === "replicate" ? ( ) : provider === "kie" ? ( ) : ( )} ); } // 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 commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); // Use stable selector for API keys to prevent unnecessary re-fetches const { replicateApiKey, falApiKey, kieApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys(); const generationsPath = useWorkflowStore((state) => state.generationsPath); const [externalModels, setExternalModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); const [modelsFetchError, setModelsFetchError] = useState(null); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isLoadingCarouselVideo, setIsLoadingCarouselVideo] = 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 (replicateEnabled && replicateApiKey) { providers.push({ id: "replicate", name: "Replicate" }); } // Add Kie.ai if configured if (kieEnabled && kieApiKey) { providers.push({ id: "kie", name: "Kie.ai" }); } return providers; }, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey]); // 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 (replicateApiKey) { headers["X-Replicate-Key"] = replicateApiKey; } if (falApiKey) { headers["X-Fal-Key"] = falApiKey; } if (kieApiKey) { headers["X-Kie-Key"] = kieApiKey; } const response = await deduplicatedFetch(`/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, replicateApiKey, falApiKey, kieApiKey]); 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]); // Load video by ID from generations folder const loadVideoById = useCallback(async (videoId: string) => { if (!generationsPath) { console.error("Generations path not configured"); return null; } try { const response = await fetch("/api/load-generation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directoryPath: generationsPath, imageId: videoId, }), }); const result = await response.json(); if (!result.success) { // Missing videos are expected when refs point to deleted/moved files console.log(`Video not found: ${videoId}`); return null; } return result.video || result.image; } catch (error) { console.warn("Error loading video:", error); return null; } }, [generationsPath]); // Carousel navigation handlers const handleCarouselPrevious = useCallback(async () => { const history = nodeData.videoHistory || []; if (history.length === 0 || isLoadingCarouselVideo) return; const currentIndex = nodeData.selectedVideoHistoryIndex || 0; const newIndex = currentIndex === 0 ? history.length - 1 : currentIndex - 1; const videoItem = history[newIndex]; setIsLoadingCarouselVideo(true); const video = await loadVideoById(videoItem.id); setIsLoadingCarouselVideo(false); if (video) { updateNodeData(id, { outputVideo: video, selectedVideoHistoryIndex: newIndex, }); } }, [id, nodeData.videoHistory, nodeData.selectedVideoHistoryIndex, isLoadingCarouselVideo, loadVideoById, updateNodeData]); const handleCarouselNext = useCallback(async () => { const history = nodeData.videoHistory || []; if (history.length === 0 || isLoadingCarouselVideo) return; const currentIndex = nodeData.selectedVideoHistoryIndex || 0; const newIndex = (currentIndex + 1) % history.length; const videoItem = history[newIndex]; setIsLoadingCarouselVideo(true); const video = await loadVideoById(videoItem.id); setIsLoadingCarouselVideo(false); if (video) { updateNodeData(id, { outputVideo: video, selectedVideoHistoryIndex: newIndex, }); } }, [id, nodeData.videoHistory, nodeData.selectedVideoHistoryIndex, isLoadingCarouselVideo, loadVideoById, updateNodeData]); // 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 - just the model name const displayTitle = useMemo(() => { if (nodeData.selectedModel?.displayName && nodeData.selectedModel.modelId) { return nodeData.selectedModel.displayName; } return "Select model..."; }, [nodeData.selectedModel?.displayName, nodeData.selectedModel?.modelId]); // Provider badge as title prefix const titlePrefix = useMemo(() => ( ), [currentProvider]); // Header action element - browse button const headerAction = useMemo(() => ( ), []); const hasCarouselVideos = (nodeData.videoHistory || []).length > 1; // 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]); // Auto-resize node when output video changes const prevOutputVideoRef = useRef(null); useEffect(() => { // Only resize when outputVideo transitions from null/different to a new value if (!nodeData.outputVideo || nodeData.outputVideo === prevOutputVideoRef.current) { prevOutputVideoRef.current = nodeData.outputVideo ?? null; return; } prevOutputVideoRef.current = nodeData.outputVideo; // Use requestAnimationFrame to avoid React Flow update conflicts requestAnimationFrame(() => { getVideoDimensions(nodeData.outputVideo!).then((dims) => { if (!dims) return; const aspectRatio = dims.width / dims.height; setNodes((nodes) => nodes.map((node) => { if (node.id !== id) return node; // Preserve user's manually set height if present const currentHeight = typeof node.style?.height === 'number' ? node.style.height : undefined; const newSize = calculateNodeSizePreservingHeight(aspectRatio, currentHeight); return { ...node, style: { ...node.style, width: newSize.width, height: newSize.height } }; }) ); }); }); }, [id, nodeData.outputVideo, setNodes]); 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} titlePrefix={titlePrefix} commentNavigation={commentNavigation ?? undefined} > {/* Dynamic input handles based on model schema */} {nodeData.inputSchema && nodeData.inputSchema.length > 0 ? ( // Render handles from schema, sorted by type (images first, text second) // IMPORTANT: Always render "image" and "text" handles to maintain connection // compatibility. Schema may only have text inputs (text-to-video models) but // we still need the image handle to preserve connections made before model selection. (() => { const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image"); const textInputs = nodeData.inputSchema!.filter(i => i.type === "text"); // Always include at least one image and one text handle for connection stability const hasImageInput = imageInputs.length > 0; const hasTextInput = textInputs.length > 0; // Build the handles array: schema inputs + fallback defaults if missing const handles: Array<{ id: string; type: "image" | "text"; label: string; schemaName: string | null; description: string | null; isPlaceholder: boolean; }> = []; // Add image handles from schema, or a placeholder if none exist if (hasImageInput) { imageInputs.forEach((input, index) => { handles.push({ // Always use indexed IDs for schema inputs for consistency id: `image-${index}`, type: "image", label: input.label, schemaName: input.name, description: input.description || null, isPlaceholder: false, }); }); } else { // No image inputs in schema - add placeholder to preserve connections handles.push({ id: "image", type: "image", label: "Image", schemaName: null, description: "Not used by this model", isPlaceholder: true, }); } // Add text handles from schema, or a placeholder if none exist if (hasTextInput) { textInputs.forEach((input, index) => { handles.push({ // Always use indexed IDs for schema inputs for consistency id: `text-${index}`, type: "text", label: input.label, schemaName: input.name, description: input.description || null, isPlaceholder: false, }); }); } else { // No text inputs in schema - add placeholder to preserve connections handles.push({ id: "text", type: "text", label: "Prompt", schemaName: null, description: "Not used by this model", isPlaceholder: true, }); } // Calculate positions const imageHandles = handles.filter(h => h.type === "image"); const textHandles = handles.filter(h => h.type === "text"); const totalSlots = imageHandles.length + textHandles.length + 1; // +1 for gap const renderedHandles = handles.map((handle, index) => { // Position: images first, then gap, then text const isImage = handle.type === "image"; const typeIndex = isImage ? imageHandles.findIndex(h => h.id === handle.id) : textHandles.findIndex(h => h.id === handle.id); const adjustedIndex = isImage ? typeIndex : imageHandles.length + 1 + typeIndex; const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100; return ( {/* Handle label - positioned outside node, above the connector */}
{handle.label}
); }); // Add hidden backward-compatibility handles for edges using non-indexed IDs // This ensures edges created with "image"/"text" still work when schema uses "image-0"/"text-0" // Note: No data-handletype to avoid being counted in tests - these are purely for edge routing return ( <> {renderedHandles} {hasImageInput && ( )} {hasTextInput && ( )} ); })() ) : ( // Default handles when no schema <> {/* Default image label */}
Image
{/* Default text label */}
Prompt
)} {/* Video output */} {/* Output label */}
Video
{/* Preview area */} {nodeData.outputVideo ? ( <>
{/* Carousel controls - only show if there are multiple videos */} {hasCarouselVideos && (
{(nodeData.selectedVideoHistoryIndex || 0) + 1} / {(nodeData.videoHistory || []).length}
)} ) : (
{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" /> )} ); }