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),
289 lines • 11.6 kB
JavaScript
/**
* #1877 / #1921 — panel_create_group auto-bounds from live `pos`/`size` can
* miss a requested node whose origin is already inside the box.
*
* #1877: a collapsed node often has `size[1] === 0` (title-chip only), so the
* box is ~100px tall around the origin. Membership then tests the CENTRE of a
* boundingRect rebuilt with the panel's `finiteExtent` fallback (0 is not a
* usable height, so the body becomes 100px plus a 30px title). That centre
* sits a few pixels below the box, so the group is empty even though the
* node's coordinates are inside.
*
* #1921: SaveImage / other DOM-preview nodes start compact (`size[1] === 58`)
* and rehydrate to ~326px after the image widget mounts (save/reopen). The
* compact auto-box still reports them as members; after rehydrate their centre
* drops below the persisted bottom and they fall out.
*
* These helpers expand a created group's bounds just enough to contain every
* membership-plausible centre of a requested node whose origin is already in
* the box — including the stable full-height centre of a DOM-preview node —
* then the caller writes those bounds with graph_edit_group.
*
* Constants match comfyui-mcp-panel `web/js/lib/group-geometry.js`
* (`COLLAPSED_TITLE_HEIGHT`, `COLLAPSED_PILL_WIDTH`, `finiteExtent` fallbacks)
* and LiteGraph's containsCentre rule (min edge inclusive, max edge exclusive).
*/
export const NODE_TITLE_HEIGHT = 30;
export const COLLAPSED_PILL_WIDTH = 80;
export const DEFAULT_NODE_WIDTH = 200;
export const DEFAULT_NODE_BODY_HEIGHT = 100;
/** Compact SaveImage body is ~58px; after the image-preview DOM widget mounts it is 326px (#1921). */
export const DOM_PREVIEW_NODE_MIN_BODY_HEIGHT = 326;
/** Node types whose LiteGraph size grows when a DOM/preview widget rehydrates. */
export const DOM_PREVIEW_NODE_TYPES = new Set([
"SaveImage",
"PreviewImage",
"LoadImage",
"LoadImageMask",
"SaveVideo",
"VHS_LoadVideo",
"VHS_VideoCombine",
]);
function finiteExtent(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function finiteQuad(v) {
if (!Array.isArray(v) || v.length !== 4)
return null;
const q = [Number(v[0]), Number(v[1]), Number(v[2]), Number(v[3])];
return q.every(Number.isFinite) ? q : null;
}
export function nodePosInsideGroupBounds(pos, bounds) {
const [gx, gy, gw, gh] = bounds;
const [x, y] = pos;
// Inclusive on every edge: the reporter's "bounds cover the node" is a
// top-left origin sitting on or inside the box, not a centre test.
return x >= gx && x <= gx + gw && y >= gy && y <= gy + gh;
}
export function isDomPreviewNodeType(type) {
return typeof type === "string" && DOM_PREVIEW_NODE_TYPES.has(type);
}
/**
* Centres LiteGraph / the panel might use for containsCentre, given only the
* geometry panel_query_graph actually returns (pos, size, type, collapsed, full_height).
*/
export function membershipCandidateCenters(node) {
const x = node.pos[0];
const y = node.pos[1];
const pillW = finiteExtent(node.size[0], COLLAPSED_PILL_WIDTH);
const fullW = finiteExtent(node.size[0], DEFAULT_NODE_WIDTH);
const fullH = finiteExtent(node.size[1], DEFAULT_NODE_BODY_HEIGHT);
const chipH = finiteExtent(node.full_height, NODE_TITLE_HEIGHT);
const points = [
// Collapsed title-chip (syncNodeArea without forceCollapsed).
{ x: x + pillW / 2, y: y - NODE_TITLE_HEIGHT + chipH / 2 },
// forceCollapsed wantedNodeArea: size[1]===0 falls back to DEFAULT_NODE_BODY_HEIGHT.
{ x: x + fullW / 2, y: y - NODE_TITLE_HEIGHT + (fullH + NODE_TITLE_HEIGHT) / 2 },
// Raw pos + stored size (degenerate when size[1] is 0 — origin itself).
{ x: x + fullW / 2, y: y + (node.size[1] > 0 ? node.size[1] : 0) / 2 },
];
// #1921 — expanded DOM-preview body after widget rehydrate. Collapsed chips stay chips.
if (!node.collapsed && isDomPreviewNodeType(node.type)) {
const previewH = Math.max(fullH, DOM_PREVIEW_NODE_MIN_BODY_HEIGHT);
if (previewH > fullH) {
points.push({ x: x + fullW / 2, y: y + previewH / 2 });
points.push({ x: x + fullW / 2, y: y - NODE_TITLE_HEIGHT + (previewH + NODE_TITLE_HEIGHT) / 2 });
}
}
return points;
}
/** Grow `bounds` so every point is a containsCentre member (max edge exclusive). */
export function expandBoundsToContainCenters(bounds, points) {
let [x, y, w, h] = bounds;
let x2 = x + w;
let y2 = y + h;
for (const p of points) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y))
continue;
if (p.x < x)
x = p.x;
if (p.y < y)
y = p.y;
if (p.x >= x2)
x2 = p.x + 1;
if (p.y >= y2)
y2 = p.y + 1;
}
return [x, y, x2 - x, y2 - y];
}
export function expandGroupBoundsForMembership(bounds, nodes) {
const points = [];
for (const n of nodes) {
if (!nodePosInsideGroupBounds(n.pos, bounds))
continue;
points.push(...membershipCandidateCenters(n));
}
return points.length ? expandBoundsToContainCenters(bounds, points) : bounds;
}
function idKey(id) {
return String(id);
}
export function classifyRequestedMembership(requested, members) {
const reqKeys = new Set(requested.map(idKey));
const memberKeys = new Set(members.map(idKey));
return {
extra: members.filter((id) => !reqKeys.has(idKey(id))),
missing: requested.filter((id) => !memberKeys.has(idKey(id))),
};
}
function asIdList(v) {
if (!Array.isArray(v))
return [];
return v.filter((id) => typeof id === "number" || typeof id === "string");
}
function parseToolJson(res) {
if (!res || res.isError)
return null;
const text = res.content.find((c) => c.type === "text")?.text;
if (typeof text !== "string")
return null;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
}
catch {
return null;
}
}
function withPayload(res, payload) {
const first = res.content[0];
if (!first || first.type !== "text")
return res;
return {
...res,
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...res.content.slice(1)],
};
}
function groupFromCreateReply(payload) {
const inner = payload.group;
if (inner && typeof inner === "object" && !Array.isArray(inner))
return inner;
if ("bounding" in payload || "node_ids" in payload)
return payload;
return null;
}
export function parseDetailNodesFromQueryText(text) {
if (typeof text !== "string")
return [];
const out = [];
for (const line of text.split("\n")) {
const s = line.trim();
if (!s.startsWith("{"))
continue;
try {
const row = JSON.parse(s);
const id = row.id;
if (typeof id !== "number" && typeof id !== "string")
continue;
const pos = row.pos;
const size = row.size;
if (!Array.isArray(pos) || pos.length < 2 || !Array.isArray(size) || size.length < 2)
continue;
const x = Number(pos[0]);
const y = Number(pos[1]);
const w = Number(size[0]);
const h = Number(size[1]);
if (![x, y, w, h].every(Number.isFinite))
continue;
const geom = { id, pos: [x, y], size: [w, h] };
if (typeof row.type === "string" && row.type)
geom.type = row.type;
if (row.collapsed === true)
geom.collapsed = true;
if (typeof row.full_height === "number" && Number.isFinite(row.full_height)) {
geom.full_height = row.full_height;
}
out.push(geom);
}
catch {
/* skip a non-JSON detail line */
}
}
return out;
}
function honestyWarning(extra, missing) {
return ("group membership is geometric: the box that wraps the requested nodes " +
`also captures ${extra} unrelated node(s) (their centre falls inside)` +
(missing ? ` and misses ${missing} requested node(s)` : "") +
". Move the intended nodes into a contiguous region (panel_edit_node / " +
"panel_auto_layout) before grouping, or edit the group bounds, to get an exact set.");
}
function attachHonesty(group, requested) {
const live = asIdList(group.node_ids);
const { extra, missing } = classifyRequestedMembership(requested, live);
const out = { ...group, requested_node_ids: requested };
delete out.extra_node_ids;
delete out.missing_node_ids;
delete out.warning;
if (extra.length || missing.length) {
if (extra.length)
out.extra_node_ids = extra;
if (missing.length)
out.missing_node_ids = missing;
out.warning = honestyWarning(extra.length, missing.length);
}
return out;
}
function quadsEqual(a, b) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
}
/**
* After graph_create_group, query the requested nodes and expand the box so
* every origin-inside node stays a containsCentre member — including collapsed
* fallback centres (#1877) and DOM-preview full height after rehydrate (#1921).
* Never throws: a failed repair returns the original create result.
*/
export async function includeRequestedCreateGroupMembers(created, requestedIds, call) {
try {
if (created.isError)
return created;
const requested = asIdList(requestedIds);
if (!requested.length)
return created;
const payload = parseToolJson(created);
if (!payload)
return created;
const group = groupFromCreateReply(payload);
if (!group)
return created;
const bounds = finiteQuad(group.bounding);
if (!bounds)
return created;
const query = await call({
cmd: "graph_query",
ids: requested,
fields: "detail",
limit: Math.min(Math.max(requested.length, 1), 200),
}, 8000);
if (query.isError)
return created;
const qPayload = parseToolJson(query);
const nodes = parseDetailNodesFromQueryText(qPayload?.text).filter((n) => requested.some((id) => idKey(id) === idKey(n.id)));
const repairable = nodes.filter((n) => nodePosInsideGroupBounds(n.pos, bounds));
if (!repairable.length)
return created;
const expanded = expandGroupBoundsForMembership(bounds, repairable);
if (quadsEqual(expanded, bounds))
return created;
const groupId = group.id;
if (typeof groupId !== "number" && typeof groupId !== "string")
return created;
const edited = await call({ cmd: "graph_edit_group", group_id: groupId, bounds: expanded }, 15000);
if (edited.isError)
return created;
const ePayload = parseToolJson(edited);
if (!ePayload)
return created;
const editedGroup = groupFromCreateReply(ePayload);
if (!editedGroup)
return created;
return withPayload(created, { ...ePayload, group: attachHonesty(editedGroup, requested) });
}
catch {
return created;
}
}
//# sourceMappingURL=create-group-membership.js.map