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),

280 lines • 10.8 kB
import { join } from "node:path"; import { config } from "../config.js"; import { logger } from "../utils/logger.js"; /** * Node class types we recognize for extracting generation settings. */ const SAMPLER_NODES = new Set([ "KSampler", "KSamplerAdvanced", "SamplerCustom", "QwenImageIntegratedKSampler", ]); const MODEL_LOADER_NODES = new Set([ "CheckpointLoaderSimple", "CheckpointLoader", "UNETLoader", "unCLIPCheckpointLoader", ]); const LORA_NODES = new Set([ "LoraLoader", "LoraLoaderModelOnly", ]); const VAE_DECODE_NODES = new Set([ "VAEDecode", "VAEDecodeTiled", ]); /** * Resolve a node input reference. ComfyUI links are [nodeId, outputIndex]. */ function resolveRef(workflow, value) { if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string") { return workflow[value[0]] ?? null; } return null; } /** * Walk upward from a sampler node through intermediate model nodes * (LoRA loaders, ModelSamplingAuraFlow, etc.) to find the model loader. * Stops after 10 hops to prevent infinite loops. */ function findModelLoader(workflow, samplerNode) { let current = samplerNode.inputs.model; for (let depth = 0; depth < 10; depth++) { const node = resolveRef(workflow, current); if (!node) return null; if (MODEL_LOADER_NODES.has(node.class_type)) return node; // Follow the "model" input through intermediate nodes (LoRA, ModelSampling*, etc.) current = node.inputs.model; if (!current) return null; } return null; } /** * Walk upward from a sampler node through intermediate model nodes to find a LoRA loader. */ function findLoraLoader(workflow, samplerNode) { let current = samplerNode.inputs.model; for (let depth = 0; depth < 10; depth++) { const node = resolveRef(workflow, current); if (!node) return null; if (LORA_NODES.has(node.class_type)) return node; if (MODEL_LOADER_NODES.has(node.class_type)) return null; // Past the loaders, no LoRA found current = node.inputs.model; if (!current) return null; } return null; } /** * Determine model family from checkpoint name and node types. * * Base versions confirmed via CivitAI hash lookup + HuggingFace: * - copaxTimeless_qwenUltraRealistic → Qwen-Image-2512 (CivitAI #118111, user confirmed) * - qwenUltimateRealism_v11 (Imageized) → original Qwen-Image (CivitAI #2027494, published Oct 2025) * - qwenImageEditRemix_v10 → Qwen-Image-Edit-2511 (HuggingFace Phr00t/Qwen-Image-Edit-Rapid-AIO) * - redcraftRedzimage → Z-Image family (NOT Qwen) */ function inferModelFamily(checkpointName, loaderClassType, samplerInputs) { const lower = checkpointName.toLowerCase(); // --- Z-Image family (check BEFORE Qwen — redcraftRedzimage contains both "redcraft" and "redzim") --- if (lower.includes("z-image") || lower.includes("zimage") || lower.includes("redzim")) return "z_image"; // --- Qwen Image versioned models (order: version in filename → known finetuned → generic) --- // Extract Qwen version if present: matches "2512", "2511", "2509", etc. const qwenVersionMatch = lower.match(/(?:qwen[_-]?(?:image)?[_-]?)?(25\d{2})/); const qwenVersion = qwenVersionMatch?.[1]; // e.g. "2512", "2511", "2509" // Qwen versioned base models (official releases with version in filename) if (qwenVersion) { const isEdit = lower.includes("edit") || qwenVersion === "2511" || qwenVersion === "2509"; return isEdit ? `qwen_image_${qwenVersion}_edit` : `qwen_image_${qwenVersion}`; } // Qwen finetuned models with CONFIRMED base versions if (lower.includes("copax") && lower.includes("qwen")) return "qwen_image_2512_finetuned"; if (lower.includes("ultimaterealism")) return "qwen_image_finetuned"; if (lower.includes("qwen") && lower.includes("editremix")) return "qwen_image_2511_edit_finetuned"; // Qwen generic (has "qwen" but no version number and not a known finetune) if (lower.includes("qwen") && lower.includes("edit")) return "qwen_image_edit"; if (lower.includes("qwen") && lower.includes("image")) return "qwen_image"; // --- Non-Qwen architectures --- if (lower.includes("flux") || lower.includes("klein")) return "flux"; if (lower.includes("wan2")) return "wan"; if (lower.includes("ltx")) return "ltx"; if (lower.includes("sdxl") || lower.includes("sd_xl")) return "sdxl"; if (lower.includes("illustrious")) return "illustrious"; if (lower.includes("pony")) return "pony"; if (lower.includes("sd3") || lower.includes("sd_3")) return "sd35"; // Fallback: infer from loader type + sampler hints if (loaderClassType === "UNETLoader") { if (samplerInputs.auraflow_shift != null) return "qwen_image"; } return "unknown"; } /** * Extract generation settings from a ComfyUI API-format workflow. * Returns null if no sampler node is found. */ export async function extractSettings(workflow, hasher) { // Find the primary sampler node let samplerNode = null; let samplerNodeId = ""; for (const [id, node] of Object.entries(workflow)) { if (SAMPLER_NODES.has(node.class_type)) { samplerNode = node; samplerNodeId = id; break; } } if (!samplerNode) { logger.debug("No sampler node found in workflow — skipping tracking"); return null; } const inputs = samplerNode.inputs; // Extract sampler params const sampler = String(inputs.sampler_name ?? inputs.sampler ?? "unknown"); const scheduler = String(inputs.scheduler ?? "unknown"); const steps = Number(inputs.steps ?? 0); const cfg = Number(inputs.cfg ?? inputs.cfg_scale ?? 0); const denoise = Number(inputs.denoise ?? 1.0); // Extract shift from ModelSamplingAuraFlow if present in the model chain let shift = inputs.auraflow_shift != null ? Number(inputs.auraflow_shift) : null; if (shift == null) { let ref = inputs.model; for (let depth = 0; depth < 10 && shift == null; depth++) { const node = resolveRef(workflow, ref); if (!node) break; if (node.class_type === "ModelSamplingAuraFlow") { shift = node.inputs.shift != null ? Number(node.inputs.shift) : null; break; } ref = node.inputs.model; } } // Extract resolution from latent_image input or direct width/height let width = Number(inputs.width ?? 0); let height = Number(inputs.height ?? 0); if (width === 0 || height === 0) { // Try to find EmptyLatentImage node connected to latent_image input const latentRef = inputs.latent_image; const latentNode = resolveRef(workflow, latentRef); if (latentNode) { width = Number(latentNode.inputs.width ?? 0); height = Number(latentNode.inputs.height ?? 0); } } // Find model loader let modelName = null; let modelHash = "UNKNOWN"; let modelFamily = "unknown"; const isIntegrated = samplerNode.class_type === "QwenImageIntegratedKSampler"; let loaderClassType = "CheckpointLoaderSimple"; if (isIntegrated) { // QwenImageIntegratedKSampler: v1 uses "unet" input, v2 uses "model" input const modelInput = inputs.model ?? inputs.unet; const unetRef = resolveRef(workflow, modelInput); if (unetRef && MODEL_LOADER_NODES.has(unetRef.class_type)) { loaderClassType = unetRef.class_type; modelName = String(unetRef.inputs.unet_name ?? unetRef.inputs.ckpt_name ?? ""); } } else { const loader = findModelLoader(workflow, samplerNode); if (loader) { loaderClassType = loader.class_type; modelName = String(loader.inputs.ckpt_name ?? loader.inputs.unet_name ?? ""); } } if (modelName) { modelFamily = inferModelFamily(modelName, loaderClassType, inputs); // Hash the model file — try multiple directories since ComfyUI maps // UNETLoader to both unet/ and diffusion_models/, and CheckpointLoader // to checkpoints/ if (config.comfyuiPath) { const candidateDirs = loaderClassType === "UNETLoader" ? ["unet", "diffusion_models", "checkpoints"] : ["checkpoints", "unet", "diffusion_models"]; const fileType = loaderClassType === "UNETLoader" ? "unet" : "checkpoint"; for (const dir of candidateDirs) { try { const hashResult = await hasher.getHash(join(config.comfyuiPath, "models", dir, modelName), fileType); modelHash = hashResult.autov2; break; // Found it } catch { // Not in this directory, try next } } if (modelHash === "UNKNOWN") { logger.warn(`Model file not found in any directory: ${modelName}`); } } } // Find LoRA let loraName = null; let loraHash = null; let loraStrength = null; let loraCivitaiId = null; const loraLoader = isIntegrated ? null : findLoraLoader(workflow, samplerNode); if (loraLoader) { loraName = String(loraLoader.inputs.lora_name ?? ""); loraStrength = Number(loraLoader.inputs.strength_model ?? 1.0); // Hash the LoRA file try { const loraPath = config.comfyuiPath ? join(config.comfyuiPath, "models", "loras", loraName) : ""; if (loraPath) { const hashResult = await hasher.getHash(loraPath, "lora"); loraHash = hashResult.autov2; loraCivitaiId = hashResult.civitaiId; } } catch (err) { logger.warn(`Could not hash LoRA file: ${loraName}`, { error: err instanceof Error ? err.message : err, }); } } return { modelFamily, modelHash, modelName, presetName: null, // Reserved; nothing assigns preset names today sampler, scheduler, steps, cfg, denoise, shift, width, height, loraHash, loraName, loraStrength, loraCivitaiId, negPromptHash: null, // Could hash negative prompt text in the future }; } //# sourceMappingURL=workflow-settings-extractor.js.map