"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type MouseEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { AspectRatio, EaseCurveNodeData, Generate3DNodeData, GenerateAudioNodeData, GenerateVideoNodeData, ImageInputNodeData, ImageGenerationCount, ModelType, NanoBananaNodeData, NodeType, Resolution, SelectedModel, VideoStitchNodeData, WorkflowNode, WorkflowNodeData, } from "@/types"; import { createDefaultAudioModel, createNewApiWGDefaultImageModel, createNewApiWGDefaultVideoModel, createPopiserverDefaultImageModel, createPopiserverDefaultVideoModel, } from "@/store/utils/defaultImageModel"; import { isPopiProviderMode } from "@/lib/providerMode"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ProviderModel } from "@/lib/providers/types"; import { useI18n } from "@/i18n"; import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS } from "@/lib/llmModels"; import { DEFAULT_VIDEO_DURATION_SECONDS, DEFAULT_VIDEO_RESOLUTION, getDefaultVideoSoundEnabled, modelSupportsVideoSound, normalizeVideoDurationSeconds, VIDEO_RESOLUTION_OPTIONS, } from "@/utils/videoGenerationSettings"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; type ComposerMode = "empty-create" | "node-edit" | "process-node" | "unsupported-node" | "multi-select"; type ComposerCapability = "all" | "image" | "video" | "3d" | "audio"; type EditableNodeType = "nanoBanana" | "generateVideo" | "generateAudio"; type DraftField = "prompt" | "aspectRatio" | "resolution" | "selectedModel" | "inputImages" | "imageCount" | "parameters" | "durationSeconds" | "soundEnabled"; type VideoStitchLoopCount = 1 | 2 | 3; interface ComposerContext { mode: ComposerMode; node: WorkflowNode | null; capability: ComposerCapability; nodeType: NodeType | null; selectedCount: number; } interface ComposerDraft { prompt: string; aspectRatio: AspectRatio; resolution: string; selectedModel: SelectedModel; inputImages: string[]; imageCount: ImageGenerationCount; durationSeconds: number; soundEnabled?: boolean; parameters?: Record; } interface ComposerAdapter { nodeType: EditableNodeType; capability: Exclude; defaultModel: () => SelectedModel; readDraft: (node: WorkflowNode) => ComposerDraft; buildPatch: (draft: ComposerDraft, dirtyFields: Set) => Partial; } const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; const VIDEO_DURATION_QUICK_OPTIONS = [4, 5, 8, 10, 15]; const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4]; const MAX_REFERENCE_IMAGES = 4; const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024; const COMPOSER_VIEWPORT_MARGIN = 16; const COMPOSER_EXPANDED_WIDTH = 640; const COMPOSER_COLLAPSED_WIDTH = 560; const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300; const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80; const DEFAULT_IMAGE_MODEL = isPopiProviderMode() ? createPopiserverDefaultImageModel() : createNewApiWGDefaultImageModel(); const DEFAULT_VIDEO_MODEL = isPopiProviderMode() ? createPopiserverDefaultVideoModel() : createNewApiWGDefaultVideoModel(); const DEFAULT_AUDIO_MODEL = createDefaultAudioModel(); const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = { provider: isPopiProviderMode() ? "popiserver" : "newapiwg", modelId: DEFAULT_NEWAPIWG_LLM_MODEL_ID, displayName: (isPopiProviderMode() ? LLM_MODELS.popiserver : LLM_MODELS.newapiwg) .find((model) => model.value === DEFAULT_NEWAPIWG_LLM_MODEL_ID)?.label || DEFAULT_NEWAPIWG_LLM_MODEL_ID, capabilities: ["text-to-text"], }; function createEmptyDraft(): ComposerDraft { return { prompt: "", aspectRatio: "16:9", resolution: "2K", selectedModel: DEFAULT_IMAGE_MODEL, inputImages: [], imageCount: 1, durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS, soundEnabled: getDefaultVideoSoundEnabled(DEFAULT_VIDEO_MODEL), parameters: {}, }; } function buildReferenceOnlyPrompt(): string { return "基于输入图片生成,保持主体一致、画风一致、构图自然。"; } function createPromptDraft(node: WorkflowNode): ComposerDraft { const data = node.data as { prompt?: unknown }; return { ...createEmptyDraft(), prompt: typeof data.prompt === "string" ? data.prompt : "", selectedModel: DEFAULT_PROMPT_TEXT_MODEL, }; } function getComposerModelLabel(model?: SelectedModel): string { if (!model) return "Select model"; return model.displayName || model.modelId; } function getLegacyImageModel(model: SelectedModel): ModelType { if ( model.provider === "gemini" && (model.modelId === "nano-banana" || model.modelId === "nano-banana-pro" || model.modelId === "nano-banana-2") ) { return model.modelId; } return "nano-banana-pro"; } function selectedModelFromProvider(model: ProviderModel): SelectedModel { return { provider: model.provider, modelId: model.id, displayName: model.name, capabilities: model.capabilities, pricing: model.pricing ? { type: model.pricing.type, amount: model.pricing.amount, } : undefined, metadata: model.metadata, }; } function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean { return Boolean(model.capabilities?.some((capability) => capabilities.includes(capability))); } function modelSupportsCapability(model: SelectedModel, capability: ComposerCapability): boolean { if (capability === "all") return true; if (capability === "image") return hasAnyCapability(model, ["text-to-image", "image-to-image"]); if (capability === "video") return hasAnyCapability(model, ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"]); if (capability === "3d") return hasAnyCapability(model, ["text-to-3d", "image-to-3d"]); return hasAnyCapability(model, ["text-to-audio"]); } function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean { if (capability === "all") return true; if (!model.capabilities || model.capabilities.length === 0) return true; return modelSupportsCapability(model, capability); } function inferCapabilityFromModel(model: SelectedModel): Exclude | null { if (modelSupportsCapability(model, "video")) return "video"; if (modelSupportsCapability(model, "3d")) return "3d"; if (modelSupportsCapability(model, "audio")) return "audio"; if (modelSupportsCapability(model, "image")) return "image"; return null; } function inferNodeTypeFromModel(model: SelectedModel): NodeType | null { const capability = inferCapabilityFromModel(model); if (capability === "video") return "generateVideo"; if (capability === "3d") return "generate3d"; if (capability === "audio") return "generateAudio"; if (capability === "image") return "nanoBanana"; return null; } function hasTextValue(value: string | null | undefined): value is string { return typeof value === "string" && value.trim().length > 0; } function getNodeDisplayTitle(node: WorkflowNode | undefined): string { if (!node) return "Unknown"; const data = node.data as { customTitle?: unknown; label?: unknown; inputPrompt?: unknown }; if (typeof data.customTitle === "string" && data.customTitle.trim()) return data.customTitle.trim(); if (typeof data.label === "string" && data.label.trim()) return data.label.trim(); if (typeof data.inputPrompt === "string" && data.inputPrompt.trim()) return data.inputPrompt.trim().slice(0, 32); return node.type; } function nodeHasVideoOutput(node: WorkflowNode | undefined): boolean { if (!node) return false; const data = node.data as { outputVideo?: unknown; video?: unknown }; return Boolean(data.outputVideo || data.video); } function formatBezierHandles(handles: [number, number, number, number]): string { return handles.map((value) => value.toFixed(2)).join(", "); } function buildBezierPreviewPath(handles: [number, number, number, number]): string { const [x1, y1, x2, y2] = handles; return `M 8 92 C ${8 + x1 * 84} ${92 - y1 * 84}, ${8 + x2 * 84} ${92 - y2 * 84}, 92 8`; } function getNumericSize(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function getComposerNodeDimensions(node: WorkflowNode): { width: number; height: number } { const defaults = defaultNodeDimensions[node.type as NodeType] ?? { width: 300, height: 220 }; return { width: node.measured?.width ?? getNumericSize(node.style?.width) ?? defaults.width, height: node.measured?.height ?? getNumericSize(node.style?.height) ?? defaults.height, }; } function readImageFileAsDataUrl(file: File): Promise { if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null); if (file.size > MAX_REFERENCE_IMAGE_BYTES) return Promise.resolve(null); return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (event) => resolve((event.target?.result as string | undefined) ?? null); reader.onerror = () => resolve(null); reader.readAsDataURL(file); }); } const imageComposerAdapter: ComposerAdapter = { nodeType: "nanoBanana", capability: "image", defaultModel: () => DEFAULT_IMAGE_MODEL, readDraft: (node) => { const data = node.data as NanoBananaNodeData; return { prompt: data.inputPrompt ?? "", aspectRatio: data.aspectRatio ?? "16:9", resolution: data.resolution ?? "2K", selectedModel: data.selectedModel ?? DEFAULT_IMAGE_MODEL, inputImages: data.inputImages ?? [], imageCount: data.imageCount ?? 1, durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS, soundEnabled: undefined, parameters: data.parameters ?? {}, }; }, buildPatch: (draft, dirtyFields) => { const patch: Partial = {}; if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim(); if (dirtyFields.has("aspectRatio")) patch.aspectRatio = draft.aspectRatio; if (dirtyFields.has("resolution")) patch.resolution = draft.resolution as Resolution; if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages; if (dirtyFields.has("imageCount")) patch.imageCount = draft.imageCount; if (dirtyFields.has("selectedModel")) { patch.selectedModel = draft.selectedModel; patch.model = getLegacyImageModel(draft.selectedModel); patch.parameters = {}; patch.fallbackParameters = {}; patch.inputSchema = undefined; } return patch; }, }; const videoComposerAdapter: ComposerAdapter = { nodeType: "generateVideo", capability: "video", defaultModel: () => DEFAULT_VIDEO_MODEL, readDraft: (node) => { const data = node.data as GenerateVideoNodeData; return { prompt: data.inputPrompt ?? "", aspectRatio: "16:9", resolution: data.resolution ?? DEFAULT_VIDEO_RESOLUTION, selectedModel: data.selectedModel ?? DEFAULT_VIDEO_MODEL, inputImages: data.inputImages ?? [], imageCount: 1, durationSeconds: data.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS, soundEnabled: data.soundEnabled ?? getDefaultVideoSoundEnabled(data.selectedModel ?? DEFAULT_VIDEO_MODEL), parameters: data.parameters ?? {}, }; }, buildPatch: (draft, dirtyFields) => { const patch: Partial = {}; if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim(); if (dirtyFields.has("resolution")) patch.resolution = draft.resolution; if (dirtyFields.has("durationSeconds")) patch.durationSeconds = normalizeVideoDurationSeconds(draft.durationSeconds); if (dirtyFields.has("soundEnabled")) patch.soundEnabled = draft.soundEnabled; if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages; if (dirtyFields.has("selectedModel")) { patch.selectedModel = draft.selectedModel; patch.resolution = draft.resolution; patch.durationSeconds = normalizeVideoDurationSeconds(draft.durationSeconds); patch.soundEnabled = getDefaultVideoSoundEnabled(draft.selectedModel); patch.parameters = {}; patch.fallbackParameters = {}; patch.inputSchema = undefined; } return patch; }, }; const audioComposerAdapter: ComposerAdapter = { nodeType: "generateAudio", capability: "audio", defaultModel: () => DEFAULT_AUDIO_MODEL, readDraft: (node) => { const data = node.data as GenerateAudioNodeData; return { prompt: data.inputPrompt ?? "", aspectRatio: "16:9", resolution: "2K", selectedModel: data.selectedModel ?? DEFAULT_AUDIO_MODEL, inputImages: [], imageCount: 1, durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS, soundEnabled: undefined, parameters: data.parameters ?? {}, }; }, buildPatch: (draft, dirtyFields) => { const patch: Partial = {}; if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim(); if (dirtyFields.has("selectedModel")) { patch.selectedModel = draft.selectedModel; patch.parameters = {}; patch.fallbackParameters = {}; patch.inputSchema = undefined; } return patch; }, }; function getAdapter(nodeType: NodeType | null): ComposerAdapter | null { if (!nodeType) return null; if (nodeType === "nanoBanana") return imageComposerAdapter as ComposerAdapter; if (nodeType === "generateVideo") return videoComposerAdapter as ComposerAdapter; if (nodeType === "generateAudio") return audioComposerAdapter as ComposerAdapter; return null; } function deriveComposerContext(nodes: WorkflowNode[]): ComposerContext { const selectedNodes = nodes.filter((node) => node.selected); if (selectedNodes.length === 0) { return { mode: "empty-create", node: null, capability: "all", nodeType: null, selectedCount: 0 }; } if (selectedNodes.length > 1) { return { mode: "multi-select", node: null, capability: "all", nodeType: null, selectedCount: selectedNodes.length }; } const node = selectedNodes[0]; const adapter = getAdapter(node.type); if (node.type === "videoStitch" || node.type === "easeCurve") { return { mode: "process-node", node, capability: "video", nodeType: node.type, selectedCount: 1 }; } if (!adapter) { return { mode: "unsupported-node", node, capability: "all", nodeType: node.type, selectedCount: 1 }; } return { mode: "node-edit", node, capability: adapter.capability, nodeType: adapter.nodeType, selectedCount: 1, }; } function contextIdentity(context: ComposerContext): string { return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}`; } function readDraftFromContext(context: ComposerContext): ComposerDraft { if (context.mode === "unsupported-node" && context.nodeType === "prompt" && context.node) { return createPromptDraft(context.node); } if (context.mode !== "node-edit") return createEmptyDraft(); const adapter = getAdapter(context.nodeType); return adapter && context.node ? adapter.readDraft(context.node) : createEmptyDraft(); } function nodeDraftFingerprint(context: ComposerContext): string { if (context.mode !== "node-edit" || !context.node) return contextIdentity(context); const data = context.node.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData; return JSON.stringify({ identity: contextIdentity(context), inputPrompt: data.inputPrompt, inputImages: "inputImages" in data ? data.inputImages : undefined, aspectRatio: "aspectRatio" in data ? data.aspectRatio : undefined, resolution: "resolution" in data ? data.resolution : undefined, durationSeconds: "durationSeconds" in data ? data.durationSeconds : undefined, soundEnabled: "soundEnabled" in data ? data.soundEnabled : undefined, imageCount: "imageCount" in data ? data.imageCount : undefined, selectedModel: data.selectedModel ? { provider: data.selectedModel.provider, modelId: data.selectedModel.modelId, displayName: data.selectedModel.displayName, capabilities: data.selectedModel.capabilities, } : null, parameters: data.parameters, }); } function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial { if (nodeType === "nanoBanana") { return { inputImages: draft.inputImages, inputPrompt: draft.prompt.trim(), aspectRatio: draft.aspectRatio, resolution: draft.resolution as Resolution, model: getLegacyImageModel(draft.selectedModel), selectedModel: draft.selectedModel, imageCount: draft.imageCount, parameters: {}, inputSchema: undefined, useGoogleSearch: false, useImageSearch: false, status: "idle", error: null, } satisfies Partial; } if (nodeType === "generateVideo") { return { inputImages: draft.inputImages, inputPrompt: draft.prompt.trim(), selectedModel: draft.selectedModel, resolution: draft.resolution, durationSeconds: normalizeVideoDurationSeconds(draft.durationSeconds), soundEnabled: modelSupportsVideoSound(draft.selectedModel) ? draft.soundEnabled ?? true : undefined, parameters: {}, inputSchema: undefined, status: "idle", error: null, } satisfies Partial; } if (nodeType === "generate3d") { return { inputImages: draft.inputImages, inputPrompt: draft.prompt.trim(), selectedModel: draft.selectedModel, parameters: {}, inputSchema: undefined, status: "idle", error: null, } satisfies Partial; } if (nodeType === "generateAudio") { return { inputPrompt: draft.prompt.trim(), selectedModel: draft.selectedModel, parameters: {}, inputSchema: undefined, status: "idle", error: null, } satisfies Partial; } return {}; } function IconButton({ label, children, onClick, }: { label: string; children: ReactNode; onClick?: () => void; }) { return ( ); } export function GenerationComposer() { const { t } = useI18n(); const nodes = useWorkflowStore((state) => state.nodes); const edges = useWorkflowStore((state) => state.edges); const dimmedNodeIds = useWorkflowStore((state) => state.dimmedNodeIds); const isRunning = useWorkflowStore((state) => state.isRunning); const isModalOpen = useWorkflowStore((state) => state.isModalOpen); const addNode = useWorkflowStore((state) => state.addNode); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); const { screenToFlowPosition } = useReactFlow(); const { x: viewportX, y: viewportY, zoom } = useViewport(); const context = useMemo(() => deriveComposerContext(nodes), [nodes]); const identity = contextIdentity(context); const fingerprint = nodeDraftFingerprint(context); const [draft, setDraft] = useState(() => readDraftFromContext(context)); const [dirtyFields, setDirtyFields] = useState>(() => new Set()); const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null); const [modelDialogOpen, setModelDialogOpen] = useState(false); const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); const [referencePreviewIndex, setReferencePreviewIndex] = useState(null); const referenceImageInputRef = useRef(null); const contextRef = useRef(context); const draftRef = useRef(draft); const dirtyFieldsRef = useRef(dirtyFields); useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { dirtyFieldsRef.current = dirtyFields; }, [dirtyFields]); const clearDirtyFields = useCallback(() => { const empty = new Set(); dirtyFieldsRef.current = empty; setDirtyFields(empty); }, []); const flushDraftForContext = useCallback( (targetContext: ComposerContext) => { const targetAdapter = getAdapter(targetContext.nodeType); const dirty = dirtyFieldsRef.current; if (targetContext.mode !== "node-edit" || !targetContext.node || !targetAdapter || dirty.size === 0) { return; } const nodeStillExists = useWorkflowStore.getState().nodes.some((node) => node.id === targetContext.node?.id); if (!nodeStillExists) { clearDirtyFields(); return; } const patch = targetAdapter.buildPatch(draftRef.current, dirty); if (Object.keys(patch).length > 0) { updateNodeData(targetContext.node.id, patch); } clearDirtyFields(); }, [clearDirtyFields, updateNodeData] ); const flushCurrentDraft = useCallback(() => { flushDraftForContext(contextRef.current); }, [flushDraftForContext]); useEffect(() => { const previousContext = contextRef.current; if (contextIdentity(previousContext) === identity) return; flushDraftForContext(previousContext); contextRef.current = context; setDraft(readDraftFromContext(context)); clearDirtyFields(); }, [clearDirtyFields, context, flushDraftForContext, identity]); useEffect(() => { if (context.mode !== "node-edit") return; const nextDraft = readDraftFromContext(context); setDraft((current) => { const dirty = dirtyFieldsRef.current; return { prompt: dirty.has("prompt") ? current.prompt : nextDraft.prompt, aspectRatio: dirty.has("aspectRatio") ? current.aspectRatio : nextDraft.aspectRatio, resolution: dirty.has("resolution") ? current.resolution : nextDraft.resolution, selectedModel: dirty.has("selectedModel") ? current.selectedModel : nextDraft.selectedModel, inputImages: dirty.has("inputImages") ? current.inputImages : nextDraft.inputImages, imageCount: dirty.has("imageCount") ? current.imageCount : nextDraft.imageCount, durationSeconds: dirty.has("durationSeconds") ? current.durationSeconds : nextDraft.durationSeconds, soundEnabled: dirty.has("soundEnabled") ? current.soundEnabled : nextDraft.soundEnabled, parameters: dirty.has("parameters") ? current.parameters : nextDraft.parameters, }; }); }, [context, fingerprint]); const connectedInputs = useMemo(() => { if ((context.mode !== "node-edit" && context.mode !== "process-node") || !context.node) return null; return getConnectedInputs(context.node.id); }, [context, getConnectedInputs, nodes, edges, dimmedNodeIds]); const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch"; const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve"; const isProcessNode = isVideoStitchNode || isEaseCurveNode; const videoStitchData = isVideoStitchNode && context.node ? (context.node.data as VideoStitchNodeData) : null; const easeCurveData = isEaseCurveNode && context.node ? (context.node.data as EaseCurveNodeData) : null; const processVideoInput = useMemo(() => { if (!isEaseCurveNode || !context.node) return null; const videoEdge = edges.find((edge) => edge.target === context.node?.id && edge.targetHandle === "video"); const sourceNode = videoEdge ? nodes.find((node) => node.id === videoEdge.source) : undefined; return videoEdge ? { edgeId: videoEdge.id, title: getNodeDisplayTitle(sourceNode), hasVideo: nodeHasVideoOutput(sourceNode), } : null; }, [context.node, edges, isEaseCurveNode, nodes]); const videoStitchClipSummaries = useMemo(() => { if (!isVideoStitchNode || !context.node) return []; const stitchData = context.node.data as VideoStitchNodeData; const stitchEdges = edges.filter( (edge) => edge.target === context.node?.id && edge.targetHandle?.startsWith("video-") ); const clipOrder = stitchData.clipOrder ?? []; const orderedEdges = [...stitchEdges].sort((a, b) => { const orderA = clipOrder.indexOf(a.id); const orderB = clipOrder.indexOf(b.id); if (orderA !== -1 && orderB !== -1) return orderA - orderB; if (orderA !== -1) return -1; if (orderB !== -1) return 1; return ((a.data as { createdAt?: number } | undefined)?.createdAt ?? 0) - ((b.data as { createdAt?: number } | undefined)?.createdAt ?? 0); }); return orderedEdges.map((edge, index) => { const sourceNode = nodes.find((node) => node.id === edge.source); return { edgeId: edge.id, index: index + 1, title: getNodeDisplayTitle(sourceNode), handle: edge.targetHandle ?? `video-${index}`, hasVideo: nodeHasVideoOutput(sourceNode), }; }); }, [context.node, edges, isVideoStitchNode, nodes]); const connectedPrompt = hasTextValue(connectedInputs?.text) ? connectedInputs.text : null; const promptValue = connectedPrompt ?? draft.prompt; const promptReadOnly = Boolean(connectedPrompt); const activeCapability = context.mode === "empty-create" ? inferCapabilityFromModel(draft.selectedModel) ?? "image" : context.capability; const videoSoundSupported = activeCapability === "video" && modelSupportsVideoSound(draft.selectedModel); const videoDurationOptions = useMemo(() => { const duration = normalizeVideoDurationSeconds(draft.durationSeconds); return VIDEO_DURATION_QUICK_OPTIONS.includes(duration) ? VIDEO_DURATION_QUICK_OPTIONS : [...VIDEO_DURATION_QUICK_OPTIONS, duration].sort((a, b) => a - b); }, [draft.durationSeconds]); const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit"; const modelIsValid = context.mode === "empty-create" ? Boolean(inferNodeTypeFromModel(draft.selectedModel)) : storedModelFitsCapability(draft.selectedModel, context.capability); const promptForSubmit = promptValue.trim(); const selectedImageInputImage = context.node?.type === "imageInput" ? (context.node.data as ImageInputNodeData).image : null; const hasReferenceImageForSubmit = (context.mode === "empty-create" || context.mode === "node-edit") && ((connectedInputs?.images.length ?? 0) > 0 || draft.inputImages.length > 0); const submitNodeType = context.mode === "empty-create" ? inferNodeTypeFromModel(draft.selectedModel) : context.nodeType; const hasRequiredPromptOrReference = submitNodeType === "generateAudio" ? promptForSubmit.length > 0 : (promptForSubmit.length > 0 || hasReferenceImageForSubmit); const canCreateFromSelectedImageInput = context.mode === "unsupported-node" && Boolean(selectedImageInputImage) && !isRunning && modelSupportsCapability(draft.selectedModel, "image"); const canRunVideoStitch = isVideoStitchNode && !isRunning && videoStitchData?.encoderSupported !== false && (connectedInputs?.videos.length ?? 0) >= 2; const canRunEaseCurve = isEaseCurveNode && !isRunning && easeCurveData?.encoderSupported !== false && (connectedInputs?.videos.length ?? 0) >= 1; const canSubmit = canRunVideoStitch || canRunEaseCurve || ( !isRunning && (context.mode === "empty-create" || context.mode === "node-edit") && hasRequiredPromptOrReference && modelIsValid ) || canCreateFromSelectedImageInput; const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : []; const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages; const primaryReference = referenceImages[0]; const previewReferenceImage = referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null; const canEditReferenceImages = (context.mode === "empty-create" || context.mode === "node-edit") && connectedReferenceImages.length === 0; const nodeData = context.node?.data as (NanoBananaNodeData | GenerateVideoNodeData | VideoStitchNodeData | EaseCurveNodeData | undefined); const errorMessage = nodeData?.status === "error" ? nodeData.error : null; const nodeEditModeLabel = context.nodeType === "nanoBanana" ? t("composer.imageNode") : context.nodeType === "generateVideo" ? t("composer.videoNode") : context.nodeType === "generateAudio" ? t("composer.audioNode") : t("composer.currentNode"); const nodeEditGenerateTitle = context.nodeType === "nanoBanana" ? t("composer.regenerateImageNode") : context.nodeType === "generateVideo" ? t("composer.regenerateVideoNode") : context.nodeType === "generateAudio" ? t("composer.regenerateAudioNode") : t("composer.generate"); const modelDialogTitle = context.nodeType === "nanoBanana" ? t("composer.chooseImageModel") : context.nodeType === "generateVideo" ? t("composer.chooseVideoModel") : context.nodeType === "generateAudio" ? t("composer.chooseAudioModel") : t("composer.chooseModelTitle"); const modeLabel = context.mode === "empty-create" ? t("composer.newGeneration") : context.mode === "multi-select" ? t("composer.selectedNodes", { count: context.selectedCount }) : context.mode === "unsupported-node" ? t("composer.currentNode") : context.mode === "process-node" ? t(context.nodeType === "easeCurve" ? "node.easeCurve" : "node.videoStitch") : nodeEditModeLabel; const generateTitle = context.mode === "empty-create" ? t("composer.createAndGenerate") : context.mode === "unsupported-node" ? t("composer.dragFromPort") : context.mode === "multi-select" ? t("composer.multiSelectNoSingleGenerate") : isVideoStitchNode ? t("composer.videoStitchRun") : isEaseCurveNode ? t("composer.easeCurveRun") : errorMessage ? t("composer.retryCurrentNode") : nodeEditGenerateTitle; const selectedNodeDimensions = useMemo( () => context.node ? getComposerNodeDimensions(context.node) : null, [context.node] ); const safeZoom = zoom > 0 ? zoom : 1; const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth; const viewportHeight = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerHeight; const visibleLeft = -viewportX / safeZoom; const visibleRight = (viewportWidth - viewportX) / safeZoom; const visibleTop = -viewportY / safeZoom; const visibleBottom = (viewportHeight - viewportY) / safeZoom; const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH; const composerFlowWidth = composerWidth / safeZoom; const composerHeightEstimate = isComposerCollapsed ? COMPOSER_COLLAPSED_HEIGHT_ESTIMATE : COMPOSER_EXPANDED_HEIGHT_ESTIMATE; const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom; const inlineComposerPosition = context.node && selectedNodeDimensions ? (() => { const centeredLeft = context.node.position.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2; const minLeft = visibleLeft + viewportMargin; const maxLeft = visibleRight - composerFlowWidth - viewportMargin; const left = maxLeft >= minLeft ? Math.min(Math.max(centeredLeft, minLeft), maxLeft) : minLeft; const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin; const aboveTop = context.node.position.y - composerHeightEstimate / safeZoom - viewportMargin; const belowFits = belowTop + composerHeightEstimate / safeZoom <= visibleBottom - viewportMargin; const aboveFits = aboveTop >= visibleTop + viewportMargin; return { left, top: belowFits || !aboveFits ? belowTop : aboveTop, }; })() : null; const shouldRenderInlineComposer = (context.mode === "node-edit" || context.mode === "process-node") && Boolean(context.node && inlineComposerPosition); const markDraft = useCallback((patch: Partial, fields: DraftField[]) => { setDraft((current) => { const next = { ...current, ...patch }; draftRef.current = next; return next; }); setDirtyFields((current) => { const next = new Set(current); fields.forEach((field) => next.add(field)); dirtyFieldsRef.current = next; return next; }); }, []); const updateVideoQuickDraft = useCallback( (patch: Partial, fields: DraftField[]) => { const activeContext = contextRef.current; setDraft((current) => { const next = { ...current, ...patch }; draftRef.current = next; return next; }); if (activeContext.mode === "node-edit" && activeContext.node?.type === "generateVideo") { const nodePatch: Partial = {}; if (fields.includes("durationSeconds") && patch.durationSeconds !== undefined) { nodePatch.durationSeconds = normalizeVideoDurationSeconds(patch.durationSeconds); } if (fields.includes("resolution") && patch.resolution !== undefined) { nodePatch.resolution = patch.resolution; } if (fields.includes("soundEnabled") && Object.prototype.hasOwnProperty.call(patch, "soundEnabled")) { nodePatch.soundEnabled = patch.soundEnabled; } if (Object.keys(nodePatch).length > 0) { updateNodeData(activeContext.node.id, nodePatch); } setDirtyFields((current) => { const next = new Set(current); fields.forEach((field) => next.delete(field)); dirtyFieldsRef.current = next; return next; }); return; } setDirtyFields((current) => { const next = new Set(current); fields.forEach((field) => next.add(field)); dirtyFieldsRef.current = next; return next; }); }, [updateNodeData] ); const handleComposerBlur = useCallback( (event: FocusEvent) => { const nextTarget = event.relatedTarget as Node | null; if (nextTarget && event.currentTarget.contains(nextTarget)) return; flushCurrentDraft(); }, [flushCurrentDraft] ); const stopCanvasEvent = useCallback((event: { stopPropagation: () => void; target?: EventTarget | null }) => { const target = event.target instanceof Element ? event.target : null; if (target?.closest("[data-composer-local-pointer='true']")) return; event.stopPropagation(); }, []); const buildTargetPosition = useCallback(() => { return screenToFlowPosition({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 140, }); }, [screenToFlowPosition]); const handleModelSelected = useCallback( (model: ProviderModel) => { const nextModel = selectedModelFromProvider(model); const activeContext = contextRef.current; if (activeContext.mode === "node-edit") { const activeAdapter = getAdapter(activeContext.nodeType); if (!activeContext.node || !activeAdapter || !modelSupportsCapability(nextModel, activeAdapter.capability)) { return; } const modelPatch = activeAdapter.buildPatch( { ...draftRef.current, selectedModel: nextModel, resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : draftRef.current.resolution, durationSeconds: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_DURATION_SECONDS : draftRef.current.durationSeconds, soundEnabled: getDefaultVideoSoundEnabled(nextModel), }, new Set(["selectedModel"]) ); updateNodeData(activeContext.node.id, modelPatch); setDraft((current) => ({ ...current, selectedModel: nextModel, resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution, durationSeconds: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_DURATION_SECONDS : current.durationSeconds, soundEnabled: getDefaultVideoSoundEnabled(nextModel), parameters: {}, })); clearDirtyFields(); return; } if (activeContext.mode === "empty-create") { setDraft((current) => ({ ...current, selectedModel: nextModel, resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution, durationSeconds: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_DURATION_SECONDS : current.durationSeconds, soundEnabled: getDefaultVideoSoundEnabled(nextModel), parameters: {}, })); } }, [clearDirtyFields, updateNodeData] ); const handleReferenceImageChange = useCallback( async (event: ChangeEvent) => { if (!canEditReferenceImages) return; const files = Array.from(event.target.files ?? []); event.target.value = ""; if (files.length === 0) return; const loadedImages = (await Promise.all(files.map(readImageFileAsDataUrl))).filter( (image): image is string => Boolean(image) ); if (loadedImages.length === 0) return; markDraft( { inputImages: [...draftRef.current.inputImages, ...loadedImages].slice(0, MAX_REFERENCE_IMAGES), }, ["inputImages"] ); }, [canEditReferenceImages, markDraft] ); const removeReferenceImage = useCallback( (index: number) => { if (!canEditReferenceImages) return; markDraft( { inputImages: draftRef.current.inputImages.filter((_, imageIndex) => imageIndex !== index), }, ["inputImages"] ); }, [canEditReferenceImages, markDraft] ); useEffect(() => { if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) { setReferencePreviewIndex(null); } }, [referenceImages.length, referencePreviewIndex]); useEffect(() => { if (!previewReferenceImage) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setReferencePreviewIndex(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [previewReferenceImage]); useEffect(() => { if (promptReadOnly) { setAssistMenu(null); return; } const lastChar = draft.prompt.at(-1); if (lastChar === "/") { setAssistMenu("command"); } else if (lastChar === "@") { setAssistMenu("reference"); } else if (!draft.prompt.includes("/") && !draft.prompt.includes("@")) { setAssistMenu(null); } }, [draft.prompt, promptReadOnly]); const handleSubmit = useCallback(async () => { if (!canSubmit) return; const fallbackPrompt = buildReferenceOnlyPrompt(); const submitPrompt = promptForSubmit || fallbackPrompt; if (context.mode === "unsupported-node" && context.node?.type === "imageInput") { const imageData = context.node.data as ImageInputNodeData; const sourceImage = imageData.image; if (!sourceImage) return; const selectedModel = modelSupportsCapability(draftRef.current.selectedModel, "image") ? draftRef.current.selectedModel : DEFAULT_IMAGE_MODEL; const nextNodeId = addNode( "nanoBanana", buildTargetPosition(), buildInitialDataForNode("nanoBanana", { ...draftRef.current, selectedModel, inputImages: [sourceImage], prompt: submitPrompt, }) ); selectSingleNode(nextNodeId); setDraft(createEmptyDraft()); clearDirtyFields(); await regenerateNode(nextNodeId); return; } if (context.mode === "empty-create") { const nodeType = inferNodeTypeFromModel(draftRef.current.selectedModel); if (!nodeType) return; const nextNodeId = addNode( nodeType, buildTargetPosition(), buildInitialDataForNode(nodeType, { ...draftRef.current, prompt: submitPrompt, }) ); selectSingleNode(nextNodeId); setDraft(createEmptyDraft()); clearDirtyFields(); await regenerateNode(nextNodeId); return; } if (context.mode === "node-edit" && context.node) { if (!promptForSubmit && !promptReadOnly) { const nextDraft = { ...draftRef.current, prompt: submitPrompt }; const nextDirtyFields = new Set(dirtyFieldsRef.current); nextDirtyFields.add("prompt"); draftRef.current = nextDraft; dirtyFieldsRef.current = nextDirtyFields; setDraft(nextDraft); setDirtyFields(nextDirtyFields); } flushCurrentDraft(); await regenerateNode(context.node.id); return; } if ( context.mode === "process-node" && (context.node?.type === "videoStitch" || context.node?.type === "easeCurve") ) { await regenerateNode(context.node.id); } }, [ addNode, buildTargetPosition, canSubmit, clearDirtyFields, context, flushCurrentDraft, promptForSubmit, promptReadOnly, regenerateNode, selectSingleNode, ]); const insertCommand = useCallback((value: string) => { markDraft({ prompt: draftRef.current.prompt.replace(/\/$/, "") + value }, ["prompt"]); setAssistMenu(null); }, [markDraft]); const insertReference = useCallback((value: string) => { markDraft({ prompt: draftRef.current.prompt.replace(/@$/, "") + value }, ["prompt"]); setAssistMenu(null); }, [markDraft]); const toggleComposerCollapsed = useCallback(() => { if (!isComposerCollapsed) { flushCurrentDraft(); setAssistMenu(null); } setIsComposerCollapsed((current) => !current); }, [flushCurrentDraft, isComposerCollapsed]); const expandComposer = useCallback(() => { setIsComposerCollapsed(false); }, []); const handleCollapsedComposerClick = useCallback( (event: MouseEvent) => { const target = event.target instanceof Element ? event.target : null; if (target?.closest("button,a,input,textarea,select,[role='button']")) return; expandComposer(); }, [expandComposer] ); const collapsedStatus = useMemo(() => { if (isEaseCurveNode) { return (connectedInputs?.videos.length ?? 0) >= 1 ? t("composer.easeCurveReady") : t("composer.easeCurveNoVideo"); } if (isVideoStitchNode) { return (connectedInputs?.videos.length ?? 0) >= 2 ? t("composer.videoStitchReady") : t("composer.videoStitchNeedTwo"); } if (promptForSubmit) return promptForSubmit; if (context.mode === "unsupported-node") return t("composer.dragFromPort"); if (context.mode === "multi-select") return t("composer.selectedNodes", { count: context.selectedCount }); return t("composer.describeContent"); }, [connectedInputs, context.mode, context.selectedCount, isEaseCurveNode, isVideoStitchNode, promptForSubmit, t]); const hideComposerForModal = isModalOpen && !modelDialogOpen; return ( <> {!hideComposerForModal && shouldRenderInlineComposer && inlineComposerPosition && (
{isComposerCollapsed ? (
{isProcessNode ? ( ) : ( )} {!isProcessNode && ( )}
) : ( <>
{t("composer.console")} {modeLabel}
{isProcessNode ? ( isVideoStitchNode ? (
{t("composer.videoStitchClipCount", { count: connectedInputs?.videos.length ?? 0 })} {connectedInputs?.audio.length ? t("composer.videoStitchAudio") : t("composer.videoStitchNoAudio")}
) : (
{connectedInputs?.videos.length ? t("composer.easeCurveVideoConnected") : t("composer.easeCurveNoVideo")} {connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")}
) ) : ( <> {referenceImages.length > 0 && (
{referenceImages.map((referenceImage, imageIndex) => (
{imageIndex + 1} {canEditReferenceImages && ( )}
))}
)} )}
{assistMenu && (
{assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")}
{assistMenu === "command" ? (
{[ { label: t("composer.commandCharacter"), value: t("composer.commandCharacterValue") }, { label: t("composer.commandTurnaround"), value: t("composer.commandTurnaroundValue") }, { label: t("composer.commandVideoShot"), value: t("composer.commandVideoShotValue") }, ].map((item) => ( ))}
) : (
{primaryReference ? ( ) : ( )}
)}
)} {isVideoStitchNode ? (
{t("composer.videoStitchInputs")} {t("composer.videoStitchInputHint")}
{videoStitchClipSummaries.length === 0 ? (
{t("composer.videoStitchNeedTwo")}
) : (
{videoStitchClipSummaries.map((clip) => (
{t("composer.videoStitchClip", { index: clip.index })}
{clip.title} · {clip.handle}
))}
)}
{t("composer.videoStitchLoop")}
{([1, 2, 3] as VideoStitchLoopCount[]).map((count) => ( ))}
{videoStitchData?.outputVideo ? t("composer.videoStitchOutputReady") : t("composer.videoStitchNoOutput")}
) : isEaseCurveNode ? (
{t("composer.easeCurveSettings")} {connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")}
{t("composer.easeCurvePreset")}
{easeCurveData?.easingPreset ?? t("composer.easeCurveCustom")}
{t("composer.easeCurveHandles")}
{formatBezierHandles(easeCurveData?.bezierHandles ?? [0.42, 0, 0.58, 1])}
{processVideoInput?.hasVideo ? `${t("composer.easeCurveInput")}: ${processVideoInput.title}` : t("composer.easeCurveNoVideo")}
{ if (!context.node) return; const value = Number.parseFloat(event.target.value); updateNodeData(context.node.id, { outputDuration: Number.isFinite(value) ? Math.max(0.1, Math.min(30, value)) : 1.5, }); }} className="mt-2 w-full rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1.5 text-xs text-neutral-100 outline-none focus:border-blue-500" />
{easeCurveData?.outputVideo ? t("composer.easeCurveOutputReady") : t("composer.easeCurveNoOutput")}
) : context.mode === "unsupported-node" || context.mode === "multi-select" ? (
{context.mode === "multi-select" ? t("composer.multiSelectUnsupported", { count: context.selectedCount }) : t("composer.unsupportedNodeHint")}
) : ( <>