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),
169 lines • 8.13 kB
JavaScript
import { enqueuePrompt as clientEnqueuePrompt, getSystemStats as clientGetSystemStats, getObjectInfo as clientGetObjectInfo, } from "../comfyui/client.js";
import { logger } from "../utils/logger.js";
import { JobWatcher } from "./job-watcher.js";
import { convertApiToUi, isUiFormat } from "./workflow-converter.js";
const SEED_INPUT_NAMES = ["seed", "noise_seed"];
/**
* Fallback draw range when a node's declared seed bounds are unavailable
* (unknown class_type, /object_info unreachable): int32 `0..2147483647`.
* Every stock ComfyUI seed widget is int32 or int64, so int32 draws are valid
* for both — the previous fixed `2 ** 32` range overflowed int32-max nodes
* (e.g. core's ClaudeNode) and was rejected with a 400 (issue #865).
*/
const FALLBACK_SEED_MIN = 0;
const FALLBACK_SEED_MAX = 2147483647; // 2^31 - 1
/**
* Declared `[min, max]` for one node's seed input, from the /object_info
* snapshot. Falls back to the int32 range when the node or the input's bounds
* are unknown — an unreadable observation is never treated as a definite
* range. Returns null when bounds ARE declared but no safely-drawable value
* exists inside them (incoherent min > max, or a minimum above
* MAX_SAFE_INTEGER): drawing the int32 fallback there would land OUTSIDE the
* declared range and fail validation — the very bug this fixes — so the
* caller's value must be left untouched instead.
*/
export function seedInputRange(objectInfo, classType, inputName) {
const def = objectInfo?.[classType];
const spec = def?.input?.required?.[inputName] ?? def?.input?.optional?.[inputName];
const config = Array.isArray(spec) ? spec[1] : undefined;
const declaredMin = config && typeof config.min === "number" && Number.isFinite(config.min)
? config.min
: undefined;
const declaredMax = config && typeof config.max === "number" && Number.isFinite(config.max)
? config.max
: undefined;
const min = declaredMin ?? FALLBACK_SEED_MIN;
// Clamp the draw ceiling to MAX_SAFE_INTEGER: declared maxes above 2^53
// (KSampler declares 2^64-1) are inexact doubles, and a top-of-range draw
// could round UP past the true maximum and fail validation.
const max = Math.min(declaredMax ?? FALLBACK_SEED_MAX, Number.MAX_SAFE_INTEGER);
// ceil/floor fractional declared bounds inward so the draw is always an
// integer INSIDE the declared range (the input type is INT).
const safeMin = Math.ceil(min);
const safeMax = Math.floor(max);
if (safeMin > safeMax)
return null;
return [safeMin, safeMax];
}
function drawSeed(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
/**
* Randomize seed and noise_seed fields in a workflow.
* Replicates the behavior that the SDK's `enqueue()` does internally,
* since `_enqueue_prompt()` is the raw HTTP POST without seed randomization.
*
* Each draw respects the target node's DECLARED `[min, max]` for that input
* (from the memoized /object_info snapshot — the same one
* create_workflow (action:"node_info") serves), so nodes with a sub-2^32 seed
* max no longer fail validation with a
* 400. Inputs named in `preserveInputs` are left exactly as supplied.
*/
async function randomizeSeeds(workflow, preserveInputs) {
const copy = JSON.parse(JSON.stringify(workflow));
// Best-effort: without the snapshot every draw uses the int32 fallback,
// which is still in range for every stock seed widget.
let objectInfo;
try {
objectInfo = await clientGetObjectInfo();
}
catch (err) {
logger.debug("object_info unavailable for seed range lookup; using int32 fallback", { error: err instanceof Error ? err.message : String(err) });
}
for (const node of Object.values(copy)) {
if (node.inputs) {
for (const key of SEED_INPUT_NAMES) {
if (preserveInputs?.has(key))
continue;
if (key in node.inputs &&
typeof node.inputs[key] === "number") {
const range = seedInputRange(objectInfo, node.class_type, key);
// null: bounds are declared but hold no safely-drawable value —
// keep the caller's seed rather than draw out of range.
if (!range)
continue;
node.inputs[key] = drawSeed(range[0], range[1]);
}
}
}
}
return copy;
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function withWorkflowMetadata(workflow, extraData) {
// Present but not an object (an array, a string) — we cannot merge into it,
// and spreading it would DISCARD whatever the caller put there. Attaching this
// metadata is an enhancement; silently dropping a field the caller set would be
// a regression, so leave the request exactly as it came.
if (extraData?.extra_pnginfo !== undefined && !isRecord(extraData.extra_pnginfo)) {
logger.debug("extra_pnginfo is not an object; leaving it untouched (no UI metadata attached)", {
type: Array.isArray(extraData.extra_pnginfo) ? "array" : typeof extraData.extra_pnginfo,
});
return extraData;
}
const extraPngInfo = isRecord(extraData?.extra_pnginfo)
? extraData.extra_pnginfo
: {};
// A valid caller-provided canvas is authoritative. Do not spend time fetching
// object_info or replace layout/provenance the caller already supplied.
if (isUiFormat(extraPngInfo.workflow))
return extraData;
try {
// Reuse the client's existing TTL cache and single-flight request. Seed
// randomization may already have warmed this exact snapshot.
const objectInfo = await clientGetObjectInfo();
const { workflow: uiWorkflow, warnings } = convertApiToUi(workflow, objectInfo);
if (warnings.length > 0) {
logger.warn("Workflow UI metadata conversion completed with warnings", {
warnings,
});
}
return {
...(extraData ?? {}),
extra_pnginfo: {
...extraPngInfo,
workflow: uiWorkflow,
},
};
}
catch (err) {
logger.warn("Workflow UI metadata conversion failed; enqueueing without it", {
error: err instanceof Error ? err.message : String(err),
});
return extraData;
}
}
/**
* Fire-and-forget workflow enqueue. Returns prompt_id immediately
* without waiting for execution to complete.
*/
export async function enqueueWorkflow(workflowJson, options) {
const workflow = options?.disable_random_seed
? workflowJson
: await randomizeSeeds(workflowJson, new Set(options?.preserve_seed_inputs ?? []));
const extraData = await withWorkflowMetadata(workflow, options?.extra_data);
logger.info("Enqueueing workflow (fire-and-forget)");
const result = await clientEnqueuePrompt(workflow, extraData);
logger.info("Workflow enqueued", {
prompt_id: result.prompt_id,
queue_remaining: result.queue_remaining,
});
// A 200 from /prompt does not mean every output was accepted: ComfyUI queues
// the branches that validate and reports the rejected ones in node_errors. Log
// it unconditionally so the fact survives even if a caller renders only
// prompt_id — a rejected branch produces nothing while the run still completes.
if (result.rejectedOutputs) {
logger.warn(`[enqueue] ${result.rejectedOutputs}`, { prompt_id: result.prompt_id });
}
// Start background watcher for completion detection. Capture the workflow
// that was actually sent (post-seed-randomization) so the AssetRegistry can
// store an exact reproducible snapshot for generate_image (action:"regenerate").
JobWatcher.watch(result.prompt_id, workflow);
return result;
}
export async function getSystemInfo() {
return clientGetSystemStats();
}
//# sourceMappingURL=workflow-executor.js.map