"use client"; import { useCallback, useMemo, useState } from "react"; import { Position, useReactFlow, useStore, type Node, type NodeProps } from "@xyflow/react"; import { useAction } from "convex/react"; import type { FunctionReference } from "convex/server"; import { useRouter } from "next/navigation"; import { Coins, Loader2, Sparkles, Video } from "lucide-react"; import { useTranslations } from "next-intl"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { DEFAULT_VIDEO_MODEL_ID, getAvailableVideoModels, getVideoModel, isVideoModelId, type VideoModelDurationSeconds, type VideoModelId, } from "@/lib/ai-video-models"; import type { Id } from "@/convex/_generated/dataModel"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useAuthQuery } from "@/hooks/use-auth-query"; import { api } from "@/convex/_generated/api"; import { toast } from "@/lib/toast"; import { classifyError } from "@/lib/ai-errors"; import BaseNodeWrapper from "./base-node-wrapper"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import CanvasHandle from "@/components/canvas/canvas-handle"; type VideoPromptNodeData = { prompt?: string; modelId?: string; durationSeconds?: number; hasAudio?: boolean; canvasId?: string; _status?: string; _statusMessage?: string; }; export type VideoPromptNodeType = Node; function normalizeDuration(value: number | undefined): VideoModelDurationSeconds { return value === 10 ? 10 : 5; } export default function VideoPromptNode({ id, data, selected, }: NodeProps) { const t = useTranslations("videoPromptNode"); const tToast = useTranslations("toasts"); const nodeData = data as VideoPromptNodeData; const router = useRouter(); const { getNode } = useReactFlow(); const { queueNodeDataUpdate, status } = useCanvasSync(); const { createNodeConnectedFromSource } = useCanvasPlacement(); const balance = useAuthQuery(api.credits.getBalance); const edges = useStore((store) => store.edges); const nodes = useStore((store) => store.nodes); const generateVideo = useAction( (api as unknown as { ai: { generateVideo: FunctionReference< "action", "public", { canvasId: Id<"canvases">; sourceNodeId: Id<"nodes">; outputNodeId: Id<"nodes">; prompt: string; modelId: string; durationSeconds: 5 | 10; }, { queued: true; outputNodeId: Id<"nodes"> } >; }; }).ai.generateVideo, ); const [prompt, setPrompt] = useState(nodeData.prompt ?? ""); const [modelId, setModelId] = useState( isVideoModelId(nodeData.modelId ?? "") ? (nodeData.modelId as VideoModelId) : DEFAULT_VIDEO_MODEL_ID, ); const [durationSeconds, setDurationSeconds] = useState( normalizeDuration(nodeData.durationSeconds), ); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); const inputMeta = useMemo(() => { const incomingEdges = edges.filter((edge) => edge.target === id); let textPrompt: string | undefined; let hasTextInput = false; for (const edge of incomingEdges) { const sourceNode = nodes.find((node) => node.id === edge.source); if (sourceNode?.type !== "text") continue; hasTextInput = true; const sourceData = sourceNode.data as { content?: string }; if (typeof sourceData.content === "string") { textPrompt = sourceData.content; break; } } return { hasTextInput, textPrompt: textPrompt ?? "", }; }, [edges, id, nodes]); const effectivePrompt = inputMeta.hasTextInput ? inputMeta.textPrompt : prompt; const selectedModel = getVideoModel(modelId) ?? getVideoModel(DEFAULT_VIDEO_MODEL_ID); const creditCost = selectedModel?.creditCost[durationSeconds] ?? 0; const availableCredits = balance !== undefined ? balance.balance - balance.reserved : null; const hasEnoughCredits = availableCredits === null ? true : availableCredits >= creditCost; const debouncedSave = useDebouncedCallback( ( nextPrompt: string, nextModelId: VideoModelId, nextDurationSeconds: VideoModelDurationSeconds, ) => { const raw = data as Record; const { _status, _statusMessage, ...rest } = raw; void _status; void _statusMessage; void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...rest, prompt: nextPrompt, modelId: nextModelId, durationSeconds: nextDurationSeconds, }, }); }, 500, ); const handlePromptChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.value; setPrompt(value); debouncedSave(value, modelId, durationSeconds); }, [debouncedSave, durationSeconds, modelId], ); const handleModelChange = useCallback( (value: string) => { if (!isVideoModelId(value)) return; setModelId(value); debouncedSave(prompt, value, durationSeconds); }, [debouncedSave, durationSeconds, prompt], ); const handleDurationChange = useCallback( (value: VideoModelDurationSeconds) => { setDurationSeconds(value); debouncedSave(prompt, modelId, value); }, [debouncedSave, modelId, prompt], ); const generateDisabled = !effectivePrompt.trim() || balance === undefined || !hasEnoughCredits || isGenerating; const handleGenerate = useCallback(async () => { if (!effectivePrompt.trim() || isGenerating) return; if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstuetzt", "KI-Generierung benoetigt eine aktive Verbindung.", ); return; } if (availableCredits !== null && !hasEnoughCredits) { toast.action(tToast("ai.insufficientCreditsTitle"), { description: tToast("ai.insufficientCreditsDesc", { needed: creditCost, available: availableCredits, }), label: tToast("billing.topUp"), onClick: () => router.push("/settings/billing"), type: "warning", }); return; } setError(null); setIsGenerating(true); try { const canvasId = nodeData.canvasId as Id<"canvases">; if (!canvasId) { throw new Error("Canvas-ID fehlt in der Node"); } const promptToUse = effectivePrompt.trim(); if (!promptToUse) return; const currentNode = getNode(id); const offsetX = (currentNode?.measured?.width ?? 260) + 32; const position = { x: (currentNode?.position?.x ?? 0) + offsetX, y: currentNode?.position?.y ?? 0, }; const clientRequestId = crypto.randomUUID(); const outputNodeId = await createNodeConnectedFromSource({ type: "ai-video", position, data: { prompt: promptToUse, modelId, durationSeconds, creditCost, canvasId, }, clientRequestId, sourceNodeId: id as Id<"nodes">, sourceHandle: "video-prompt-out", targetHandle: "video-in", }); await toast.promise( generateVideo({ canvasId, sourceNodeId: id as Id<"nodes">, outputNodeId, prompt: promptToUse, modelId, durationSeconds, }), { loading: tToast("ai.generating"), success: tToast("ai.generationQueued"), error: tToast("ai.generationFailed"), }, ); } catch (err) { const classified = classifyError(err); if (classified.type === "dailyCap") { toast.error( tToast("billing.dailyLimitReachedTitle"), "Morgen stehen wieder Generierungen zur Verfuegung.", ); } else if (classified.type === "concurrency") { toast.warning( tToast("ai.concurrentLimitReachedTitle"), tToast("ai.concurrentLimitReachedDesc"), ); } else { setError(classified.rawMessage || tToast("ai.generationFailed")); } } finally { setIsGenerating(false); } }, [ availableCredits, createNodeConnectedFromSource, creditCost, durationSeconds, effectivePrompt, generateVideo, getNode, hasEnoughCredits, id, isGenerating, modelId, nodeData.canvasId, router, status.isOffline, tToast, ]); return (
{inputMeta.hasTextInput ? (

{t("promptFromTextNode")}

{inputMeta.textPrompt.trim() || t("noPromptHint")}

) : (