UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

247 lines • 9.88 kB
import { getObjectInfo } from "../comfyui/client.js"; import { enqueueWorkflow } from "./workflow-executor.js"; import { isApiNode, generateWithApiNode, } from "./api-nodes.js"; import { ValidationError } from "../utils/errors.js"; /** * generate_image (action:"3d") — produce a 3D model (glb/obj/fbx/ply) from a * text prompt or an * input image. * * PATH CHOSEN (investigated 2026-07-24): current ComfyUI ships hosted partner * 3D nodes in core (`comfy_api_nodes`) — Tripo, Meshy, Rodin and Hunyuan3D * (Tencent), all under categories like "partner/3d/Tripo" with FILE_3D_* * outputs and OUTPUT_NODE=true. We therefore route through the SAME * generateWithApiNode machinery used by the list_api_nodes (action:"generate") tool: no * local 3D pack install is required, only a comfy.org API key/login on the * server (or COMFY_API_KEY here). Detection is dynamic against /object_info, * so the tool degrades honestly when the server has no 3D-capable API nodes * (old build, --disable-api-nodes) with an actionable error naming local-pack * alternatives. */ const defaultDeps = { getObjectInfo, enqueue: enqueueWorkflow, }; /** Node id used for the LoadImage feeder in image mode ("1"/"2" are reserved). */ const LOAD_IMAGE_NODE_ID = "10"; /** Widget-ish input types that don't need an upstream link. */ const WIDGET_TYPES = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"]); function inputType(spec) { if (Array.isArray(spec)) return spec[0]; return String(spec); } function isWidgetType(type) { if (Array.isArray(type)) return true; // inline combo options const t = type.toUpperCase(); return WIDGET_TYPES.has(t) || t.includes("COMBO") || t.includes("AUTOGROW"); } /** Does this node output a 3D model file? */ function has3dOutput(def) { return (Array.isArray(def.output) ? def.output : []).some((o) => String(o).toUpperCase().startsWith("FILE_3D")); } /** * A hosted partner/API node that produces a 3D model. Requires an actual * FILE_3D* output — a 3D-ish category alone is NOT enough, since 3D categories * also contain post-processing/utility nodes (UV unwrap, retopology, task-id * plumbing) that would be a false "backend" for generation. */ export function is3dApiNode(def) { return isApiNode(def) && has3dOutput(def); } /** Names of required link-type inputs (non-widget — need an upstream node). */ function requiredLinkInputs(def) { const out = []; for (const [name, spec] of Object.entries(def.input?.required ?? {})) { const type = inputType(spec); if (!isWidgetType(type)) out.push({ name, type: String(type).toUpperCase() }); } return out; } function findStringPromptInput(def) { for (const section of [def.input?.required, def.input?.optional]) { for (const [name, spec] of Object.entries(section ?? {})) { const type = inputType(spec); if (typeof type === "string" && type.toUpperCase() === "STRING" && /prompt/i.test(name)) { return name; } } } return undefined; } /** Can this 3D node run from just a text prompt (no required link inputs)? */ export function isTextCapable(def) { if (requiredLinkInputs(def).length > 0) return false; // Must actually accept a prompt. const req = def.input?.required ?? {}; return Object.entries(req).some(([name, spec]) => { const type = inputType(spec); return typeof type === "string" && type.toUpperCase() === "STRING" && /prompt/i.test(name); }); } /** Can this 3D node run from a single input image (exactly one required IMAGE link)? */ export function isImageCapable(def) { const links = requiredLinkInputs(def); const images = links.filter((l) => l.type === "IMAGE"); if (images.length !== 1 || links.length !== images.length) return { ok: false }; return { ok: true, imageInput: images[0].name }; } /** Preferred class_types per mode, best-supported first. */ const PREFERRED = { text: [ "TripoTextToModelNode", "MeshyTextToModelNode", "TencentTextToModelNode", "Rodin3D_Gen25_Text", "TripoP1TextToModelNode", ], image: [ "TripoImageToModelNode", "MeshyImageToModelNode", "TencentImageToModelNode", "Rodin3D_Gen25_Image", "TripoP1ImageToModelNode", ], }; function formatsOf(def) { const out = []; for (const o of Array.isArray(def.output) ? def.output : []) { const s = String(o).toUpperCase(); if (s === "FILE_3D") out.push("3D"); else if (s.startsWith("FILE_3D_")) out.push(s.slice("FILE_3D_".length)); } return [...new Set(out)]; } /** Discover 3D-capable API nodes on the connected server, for a given mode. */ export function find3dApiNodes(objectInfo, mode) { const out = []; for (const [classType, def] of Object.entries(objectInfo)) { if (!def || !is3dApiNode(def)) continue; if (mode === "text") { if (!isTextCapable(def)) continue; out.push({ class_type: classType, display_name: def.display_name || classType, category: def.category ?? "", formats: formatsOf(def), }); } else { const cap = isImageCapable(def); if (!cap.ok) continue; out.push({ class_type: classType, display_name: def.display_name || classType, category: def.category ?? "", formats: formatsOf(def), image_input: cap.imageInput, }); } } out.sort((a, b) => a.class_type.localeCompare(b.class_type)); return out; } function pickCandidate(candidates, mode) { for (const preferred of PREFERRED[mode]) { const hit = candidates.find((c) => c.class_type === preferred); if (hit) return hit; } return candidates[0]; } function noBackendError(mode) { return new ValidationError(`No 3D-capable API/partner nodes for ${mode}-to-3D were found on the connected ComfyUI, ` + `so 3D generation has no backend to run on. Recent ComfyUI builds ship hosted 3D partner nodes ` + `in core (Tripo, Meshy, Rodin, Hunyuan3D — categories like "partner/3d/..."); they may be ` + `missing because the ComfyUI build is old or API nodes are disabled (--disable-api-nodes). ` + `Fixes: (1) update ComfyUI (install_comfyui (action:"update")) and remove --disable-api-nodes, then retry; or ` + `(2) install a LOCAL 3D pack and build a workflow with it — e.g. install_custom_node with ` + `"ComfyUI-Hunyuan3DWrapper" (image-to-3D) or "ComfyUI-Flowty-TripoSR" (image-to-3D). ` + `Nothing was installed automatically.`); } /** * Generate a 3D model from text or an image by enqueueing a minimal workflow * around a hosted partner 3D node (Tripo/Meshy/Rodin/Hunyuan3D), reusing the * list_api_nodes (action:"generate") machinery. Returns immediately with the prompt_id. */ export async function generate3d(args, deps = defaultDeps) { if (args.mode === "text" && !args.prompt?.trim()) { throw new ValidationError('mode "text" requires a non-empty prompt.'); } if (args.mode === "image" && !args.image?.trim()) { throw new ValidationError('mode "image" requires an input image filename (upload one first with upload_image (action:"image")).'); } const objectInfo = await deps.getObjectInfo(); const candidates = find3dApiNodes(objectInfo, args.mode); if (candidates.length === 0) throw noBackendError(args.mode); let chosen; if (args.node) { const hit = candidates.find((c) => c.class_type === args.node); if (!hit) { throw new ValidationError(`Node "${args.node}" is not a ${args.mode}-capable 3D API node on this server. ` + `Available: ${candidates.map((c) => c.class_type).join(", ")}.`); } chosen = hit; } else { chosen = pickCandidate(candidates, args.mode); } const def = objectInfo[chosen.class_type]; const inputs = { ...(args.inputs ?? {}) }; let extra_nodes; const preNotes = []; if (args.mode === "text") { const promptName = findStringPromptInput(def) ?? "prompt"; if (!(promptName in inputs)) inputs[promptName] = args.prompt; } else { const imageInput = chosen.image_input ?? "image"; inputs[imageInput] = [LOAD_IMAGE_NODE_ID, 0]; extra_nodes = { [LOAD_IMAGE_NODE_ID]: { class_type: "LoadImage", inputs: { image: args.image }, _meta: { title: "Load Input Image" }, }, }; if (args.prompt?.trim()) { const promptName = findStringPromptInput(def); if (promptName) { if (!(promptName in inputs)) inputs[promptName] = args.prompt; } else { preNotes.push(`"${chosen.class_type}" takes no text prompt input — the prompt was ignored (image-only node).`); } } } const result = await generateWithApiNode({ class_type: chosen.class_type, inputs, disable_random_seed: args.disable_random_seed, extra_nodes, }, deps); return { ...result, notes: [...preNotes, ...result.notes], class_type: chosen.class_type, display_name: chosen.display_name, category: chosen.category, formats: chosen.formats, alternatives: candidates .map((c) => c.class_type) .filter((c) => c !== chosen.class_type), }; } //# sourceMappingURL=generate-3d.js.map