import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { ChevronDown, Dice5, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Sparkles, Video, Workflow as WorkflowIcon } from "lucide-react"; import { Button, Input, InputNumber, Segmented, Select, Slider, Switch, Tooltip } from "antd"; import { configuredModelMatchesCapability, defaultConfig, modelOptionName, normalizeRunningHubCapability, resolveModelChannel, useEffectiveConfig, type AiConfig, type RunningHubCapability, type RunningHubWorkflow, type RunningHubWorkflowKind } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities"; import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection"; import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { useThemeStore } from "@/stores/use-theme-store"; import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; import { usePluginStore } from "@/stores/use-plugin-store"; import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasVideoEditOperation, CanvasWorkspaceMode } from "@/types/canvas"; type CanvasConfigNodePanelProps = { node: CanvasNodeData; isRunning: boolean; inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number; characterCount: number }; onConfigChange: (nodeId: string, patch: Partial) => void; onGenerate: (nodeId: string) => void; onComposerToggle: () => void; workspaceMode?: CanvasWorkspaceMode; }; type WorkflowSelectOption = { label: ReactNode; value?: string; workflowId?: string; kind?: RunningHubWorkflowKind; title?: string; options?: WorkflowSelectOption[]; }; const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperation }> = [ { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "全模态参考", value: "reference_to_video" }, { label: "视频续写", value: "extend" }, { label: "局部修改", value: "inpaint" }, { label: "元素替换", value: "replace_element" }, { label: "运镜调整", value: "camera_motion" }, { label: "风格迁移", value: "style_transfer" }, { label: "音频生视频", value: "audio_to_video" }, { label: "版本对比", value: "compare_versions" }, ]; function capabilityLabel(capability: RunningHubCapability) { return capability === "video" ? "视频" : capability === "audio" ? "音频" : "图片"; } function runningHubWorkflowKind(workflow: RunningHubWorkflow): RunningHubWorkflowKind { return workflow.kind === "app" ? "app" : "workflow"; } function runningHubWorkflowEntryKey(workflow: RunningHubWorkflow): string { return `${runningHubWorkflowKind(workflow)}:${workflow.workflowId.trim()}`; } export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) { const globalConfig = useEffectiveConfig(); const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const mode = node.metadata?.generationMode === "video" || node.metadata?.generationMode === "audio" ? node.metadata.generationMode : "image"; const simpleMode = workspaceMode === "simple"; const resolvedProvider = resolveCanvasWorkflowProvider(node.metadata); const workflowProvider = resolvedProvider; const workflowCapability: RunningHubCapability = mode === "video" ? "video" : mode === "audio" ? "audio" : "image"; const requirements: ModelRequirements = { capability: mode, input: inputSummary, videoOperation: node.metadata?.videoEditOperation, videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, }, mode), }; const config = buildNodeConfig(globalConfig, node, mode, requirements); const defaultWorkflowCapability = normalizeRunningHubCapability(globalConfig.runningHub.capability); const selectedRunningHubWorkflow = globalConfig.runningHub.workflows.find((item) => ( item.workflowId.trim() === node.metadata?.runningHubWorkflowId?.trim() && (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind) )); const selectedRunningHubCapability = selectedRunningHubWorkflow ? normalizeRunningHubCapability(selectedRunningHubWorkflow.capability, defaultWorkflowCapability) : undefined; const selectedComfyBridgeWorkflow = globalConfig.comfyBridge.workflows.find((item) => item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim()); const selectedWorkflowFields: WorkflowVideoFieldLike[] = workflowProvider === "runninghub" ? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson) : workflowProvider === "comfyui" ? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson) : []; const dynamicWorkflowFields = workflowParameterFields(selectedWorkflowFields); useEffect(() => { if (workflowProvider === "runninghub" && !node.metadata?.runningHubWorkflowId?.trim()) { const capability = normalizeRunningHubCapability(globalConfig.runningHub.capability); // 模式切换后优先按当前能力选择工作流,不能沿用全局默认的视频工作流, // 否则点击“生图”会在下一次渲染时被自动改回“视频”。 const workflow = globalConfig.runningHub.workflows.find((item) => ( normalizeRunningHubCapability(item.capability, capability) === workflowCapability && item.workflowId.trim() === globalConfig.runningHub.workflowId.trim() && runningHubWorkflowKind(item) === globalConfig.runningHub.selectedKind )) || globalConfig.runningHub.workflows.find((item) => normalizeRunningHubCapability(item.capability, capability) === workflowCapability); if (workflow) { const workflowMode = normalizeRunningHubCapability(workflow.capability, capability); onConfigChange(node.id, { generationMode: workflowMode, runningHubWorkflowId: workflow.workflowId, runningHubWorkflowKind: runningHubWorkflowKind(workflow), workflowParameters: {} }); } return; } if (workflowProvider === "comfyui" && !node.metadata?.comfyBridgeWorkflowId?.trim()) { const workflow = globalConfig.comfyBridge.workflows.find((item) => item.capability === workflowCapability); if (workflow) onConfigChange(node.id, { generationMode: workflow.capability, comfyBridgeWorkflowId: workflow.workflowId, workflowParameters: {} }); } }, [globalConfig.comfyBridge.workflows, globalConfig.comfyBridge.workflowId, globalConfig.runningHub.capability, globalConfig.runningHub.selectedKind, globalConfig.runningHub.workflowId, globalConfig.runningHub.workflows, mode, node.id, node.metadata?.comfyBridgeWorkflowId, node.metadata?.runningHubWorkflowId, onConfigChange, workflowCapability, workflowProvider]); const runningHubEntries = globalConfig.runningHub.workflows // 正常情况下只显示当前模式的条目;旧画布已引用的错配条目保留在列表中, // 这样用户可以看到并重新选择,而不是出现“选项消失”的死路。 .filter((item) => normalizeRunningHubCapability(item.capability, defaultWorkflowCapability) === workflowCapability || ( item.workflowId.trim() === node.metadata?.runningHubWorkflowId?.trim() && (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind) )) .map((item) => ({ label: item.title || item.workflowId, value: runningHubWorkflowEntryKey(item), workflowId: item.workflowId, kind: runningHubWorkflowKind(item), title: `${item.title || item.workflowId} · ${capabilityLabel(normalizeRunningHubCapability(item.capability, defaultWorkflowCapability))}`, })); const runningHubApps = runningHubEntries.filter((item) => item.kind === "app"); const runningHubWorkflows = runningHubEntries.filter((item) => item.kind === "workflow"); const runningHubOptions: WorkflowSelectOption[] = [ ...(runningHubApps.length ? [{ label: , options: runningHubApps }] : []), ...(runningHubWorkflows.length ? [{ label: , options: runningHubWorkflows }] : []), ]; const comfyBridgeEntries = globalConfig.comfyBridge.workflows .filter((item) => item.capability === workflowCapability || item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim()) .map((item) => ({ label: item.title || item.workflowId, value: item.workflowId, kind: "workflow" as const, title: `${item.title || item.workflowId} · ${capabilityLabel(item.capability)}` })); const comfyBridgeOptions: WorkflowSelectOption[] = comfyBridgeEntries.length ? [{ label: , options: comfyBridgeEntries }] : []; const chipStyle = { background: theme.node.fill, color: theme.node.text }; const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount); const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); const workflowParameterError = firstWorkflowParameterError(dynamicWorkflowFields, node.metadata?.workflowParameters || {}); const capabilityError = workflowParameterError || (workflowProvider === "runninghub" ? (!workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "RunningHub 工作流插件未启用" : !globalConfig.runningHub.enabled ? "请先在设置中启用 RunningHub" : !node.metadata?.runningHubWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流或 App` : !selectedRunningHubWorkflow ? "当前画布引用的 RunningHub 条目已不存在,请重新选择" : selectedRunningHubCapability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedRunningHubCapability || "image")},请切换画布模式或重新选择条目` : undefined) : workflowProvider === "comfyui" ? (!workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "ComfyUI Bridge 工作流插件未启用" : !globalConfig.comfyBridge.enabled ? "请先在设置中启用 ComfyUI Bridge" : !globalConfig.comfyBridge.bridgeId ? "请选择在线 Bridge" : !node.metadata?.comfyBridgeWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流` : !selectedComfyBridgeWorkflow ? "当前画布引用的 ComfyUI 条目已不存在,请重新选择" : selectedComfyBridgeWorkflow.capability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedComfyBridgeWorkflow.capability)},请切换画布模式或重新选择条目` : undefined) : undefined); const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError; return (
event.stopPropagation()}>
{simpleMode ? "快速生成" : workflowProvider === "model" ? "生成配置" : "工作流生成"}
{simpleMode ? 自动配置 :
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> onConfigChange(node.id, { generationMode: value as CanvasGenerationMode, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} })} options={[ { value: "image", label: ( 生图 ), }, { value: "video", label: ( ), }, { value: "audio", label: ( 音频 ), }, ]} />
}
{inputSummary.characterCount ? : null}
{mode === "video" && !simpleMode ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
全能参考 已连接媒体自动映射
) : null} {simpleMode ? (
将使用当前默认模型与生成参数
) : (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> 生成来源 { const nextProvider = value as "model" | "runninghub" | "comfyui"; if (nextProvider === "model") { onConfigChange(node.id, { workflowProvider: "model", workflowTitle: undefined, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} }); return; } if (nextProvider === "runninghub") { onConfigChange(node.id, { workflowProvider: "runninghub", workflowTitle: "RunningHub 工作流", comfyBridgeWorkflowId: undefined, workflowParameters: {} }); } else { onConfigChange(node.id, { workflowProvider: "comfyui", workflowTitle: "ComfyUI Bridge", runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, workflowParameters: {} }); } }} />
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> {workflowProvider === "runninghub" ? ( className="canvas-compact-control !h-9 w-full" value={selectedRunningHubWorkflow ? runningHubWorkflowEntryKey(selectedRunningHubWorkflow) : undefined} title={selectedRunningHubWorkflow ? `${selectedRunningHubWorkflow.kind === "app" ? "App" : "工作流"} · ${selectedRunningHubWorkflow.title || selectedRunningHubWorkflow.workflowId}` : undefined} placeholder={`选择${capabilityLabel(workflowCapability)}工作流或 App`} showSearch options={runningHubOptions} optionFilterProp="label" optionLabelProp="label" labelRender={(selected) => { const workflow = runningHubEntries.find((item) => item.value === selected.value); return ; }} notFoundContent={`暂无${capabilityLabel(workflowCapability)}工作流`} popupMatchSelectWidth={false} virtual={false} listHeight={320} styles={{ popup: { root: { minWidth: 320, maxWidth: "min(420px, calc(100vw - 32px))" } } }} optionRender={(option) => { if (option.data.options) return option.label; return ; }} onChange={(value) => { const workflow = runningHubEntries.find((item) => item.value === value); onConfigChange(node.id, { runningHubWorkflowId: workflow?.workflowId, runningHubWorkflowKind: workflow?.kind, workflowParameters: {} }); }} /> ) : workflowProvider === "comfyui" ? ( className="canvas-compact-control !h-9 w-full" value={node.metadata?.comfyBridgeWorkflowId} title={selectedComfyBridgeWorkflow?.title || selectedComfyBridgeWorkflow?.workflowId} placeholder={`选择${capabilityLabel(workflowCapability)}工作流`} showSearch options={comfyBridgeOptions} optionFilterProp="label" optionLabelProp="label" labelRender={(selected) => item.value === selected.value)?.label || String(selected.label || "")} />} notFoundContent={`暂无${capabilityLabel(workflowCapability)}工作流`} popupMatchSelectWidth={false} virtual={false} listHeight={320} styles={{ popup: { root: { minWidth: 320, maxWidth: "min(420px, calc(100vw - 32px))" } } }} optionRender={(option) => { if (option.data.options) return option.label; return ; }} onChange={(value) => onConfigChange(node.id, { comfyBridgeWorkflowId: value, workflowParameters: {} })} /> ) : null}
)} {dynamicWorkflowFields.length ? : null} {capabilityError ?
{capabilityError}
: null}
); } function defaultVideoOperation(inputSummary: CanvasConfigNodePanelProps["inputSummary"]): CanvasVideoEditOperation { const visualInputCount = inputSummary.imageCount + inputSummary.characterCount; if (inputSummary.audioCount > 0 && visualInputCount === 0 && inputSummary.videoCount === 0) return "audio_to_video"; if (inputSummary.videoCount > 0) return "extend"; if (visualInputCount > 0) return "image_to_video"; return "text_to_video"; } export function WorkflowParameterControls({ fields, node, theme, onConfigChange, defaultExpanded = true }: { fields: WorkflowVideoFieldLike[]; node: CanvasNodeData; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onConfigChange: (nodeId: string, patch: Partial) => void; defaultExpanded?: boolean }) { const [expanded, setExpanded] = useState(defaultExpanded); const values = node.metadata?.workflowParameters || {}; const update = (field: WorkflowVideoFieldLike, value: unknown) => { const key = workflowFieldKey(field); onConfigChange(node.id, { workflowParameters: { ...values, [key]: value } }); }; const updateRandom = (field: WorkflowVideoFieldLike, randomEnabled: boolean) => { const key = workflowFieldRandomKey(field); onConfigChange(node.id, { workflowParameters: { ...values, [key]: randomEnabled } }); }; return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
工作流参数 {fields.length} 项
{expanded ? fields.map((field) => { const key = workflowFieldKey(field); const randomKey = workflowFieldRandomKey(field); const value = workflowFieldSubmissionValue(field, workflowFieldCurrentValue(field, values) ?? ""); const randomEnabled = typeof values[randomKey] === "boolean" ? values[randomKey] === true : field.randomEnabled === true; const rawFieldType = String(field.fieldType || "").toUpperCase(); const fieldType = ["FLOAT", "INT", "INTEGER"].includes(rawFieldType) ? "NUMBER" : rawFieldType; const options = workflowFieldChoiceValues(field); const selectOptions = options.length ? options : fieldType === "SELECT" && value !== "" ? [value] : []; const bounds = workflowFieldNumberBounds(field); const numeric = fieldType === "NUMBER" || fieldType === "SLIDER" || typeof value === "number" || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined; const numericValue = value === "" || !Number.isFinite(Number(value)) ? undefined : Number(value); const valueError = randomEnabled ? "" : workflowFieldValueError(field, value); const control = fieldType === "SELECT" || selectOptions.length ? ( update(field, event.target.value)} /> ); return ( ); }) : null}
); } export function firstWorkflowParameterError(fields: WorkflowVideoFieldLike[], values: Record) { for (const field of fields) { const randomKey = workflowFieldRandomKey(field); const randomEnabled = typeof values[randomKey] === "boolean" ? values[randomKey] === true : field.randomEnabled === true; if (randomEnabled) continue; const key = workflowFieldKey(field); const value = workflowFieldCurrentValue(field, values) ?? ""; const error = workflowFieldValueError(field, value); if (error) return `${field.label || field.fieldName}:${error}`; } return ""; } function workflowParameterOptionValue(value: unknown): string | number { if (value && typeof value === "object" && !Array.isArray(value)) { const item = value as Record; for (const key of ["value", "id", "key", "name", "label"]) { if (item[key] !== undefined && item[key] !== null) return typeof item[key] === "number" ? item[key] : String(item[key]); } } return typeof value === "number" ? value : String(value ?? ""); } function workflowParameterOptionLabel(value: unknown) { return String(workflowParameterOptionValue(value)); } function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) { return (
{label} {value}
); } function WorkflowOptionGroupLabel({ label, count }: { label: string; count: number }) { return {label}{count}; } function WorkflowOptionLabel({ kind, label, title }: { kind: "app" | "workflow"; label: string; title: string }) { return
{kind === "app" ? : } {label}