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),
139 lines • 7.81 kB
JavaScript
// Live-canvas widget capture: the authoritative name→value overlay (#959).
//
// `graph_serialize` gives a faithful STRUCTURE (links, groups, properties,
// subgraph definitions) but stores widget values as a bare positional array. The
// converter has to name those positions, and the only order it can reconstruct is
// object_info's input order — which is NOT the frontend's widget order. Custom
// nodes that add, hide, or reorder widgets in JS make the two disagree, and every
// value from the first disagreement onward lands on the wrong widget.
//
// Reported three times against three different node packs before it was read as
// one bug: #961 (Eclipse wildcards — `wildcards` serialized before `seed`, so a
// wildcard STRING was emitted as the seed), #955 (the same node with numeric seeds
// 101/202 swapped out), #361 (a SteadyDancer canvas where five model/LoRA/VAE
// filenames were each replaced by a DIFFERENT model on disk). The last one is the
// severity argument: a shifted asset widget silently renders something the user
// never asked for, with no error anywhere.
//
// `graph_get_state` already reports each node's widgets BY NAME, straight off the
// live litegraph node objects — the frontend's own truth, with no order to
// disagree about. So capture both and hand the converter the named map.
//
// Why the named map can't be LESS complete than the array (the load-bearing
// point — if it could, this fix would trade a wrong value for a missing one):
// both are derived from the SAME list, `node.widgets`. litegraph builds
// `widgets_values` by walking that list and taking each `w.value`; the capture
// walks the same list and takes each `w.name`/`w.value` pair. The array is that
// list with the names thrown away, which is precisely why naming its positions
// requires a guess. Anything absent from the capture was absent from
// `node.widgets`, so it had no serialized value either. (The asymmetry runs the
// other way in some frontend versions, which skip `serialize:false` widgets when
// building the array — one more reason position is not a reliable key.)
//
// This module decides ONLY whether the two captures describe the same nodes. It
// refuses rather than guesses, and every refusal is disclosed to the caller: a
// positional mapping that could not be verified is reported as UNVERIFIED, never
// as correct and never as wrong.
/** The caveat that a positionally-mapped node carries. Deliberately says MAY: we
* did not verify the order, which is not the same as having found it wrong. */
const POSITIONAL_CAVEAT = "their widget values were mapped by POSITION against the server's input order, " +
"which is not guaranteed to match the frontend's widget order for custom nodes — " +
"so a value MAY be attributed to the wrong widget (this is unverified, not known wrong). " +
"Compare against panel_query_graph {fields:'detail'} before trusting a widget value that looks off.";
function isPlainObject(v) {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/**
* Overlay the panel's name-keyed widget capture onto a serialized live graph,
* IN PLACE, by setting each node's `capturedWidgetValues`.
*
* `widgets_values` is deliberately left untouched — a subgraph node's
* proxyWidgets, a PrimitiveNode's literal at [0], and a Set/Get node's bus name
* are all read from that array BY INDEX, and replacing it with an object would
* silently drop them. The converter prefers `capturedWidgetValues` where it
* exists and falls back to the array everywhere else.
*/
export function applyCapturedWidgetValues(ui, stateReply) {
const graphNodes = ui?.nodes;
if (!Array.isArray(graphNodes) || graphNodes.length === 0) {
return { applied: 0, uncovered: 0, notes: [] };
}
const refuse = (why) => ({
applied: 0,
uncovered: graphNodes.length,
refused: why,
notes: [`note: ${why} All ${graphNodes.length} node(s) kept the serialized values, so ${POSITIONAL_CAVEAT}`],
});
const state = stateReply;
if (!isPlainObject(state) || !Array.isArray(state.nodes)) {
return refuse("The panel returned no live widget capture to cross-check the serialized graph against.");
}
// THE catastrophic case, and the reason this refuses instead of best-efforting.
// `graph_serialize` always serializes the ROOT graph, but `graph_get_state`
// reports whichever graph the user is currently INSIDE. With a subgraph open,
// the two id spaces are different graphs' — and node 7 in the subgraph would
// overwrite node 7 of the root with a completely unrelated node's widget values.
// That is a worse bug than the one being fixed, so scope must be proven root.
const scope = isPlainObject(state.viewing) ? state.viewing.scope : undefined;
if (scope !== "root") {
return refuse(scope === undefined
? "The panel's widget capture did not say which graph it describes, so it can't be matched to the serialized root graph."
: `The panel is currently viewing a ${String(scope)}, so its widget capture describes that graph's nodes rather than the serialized root graph's.`);
}
// Build the id→widgets index. A duplicate id can't be attributed to either
// claimant, so drop BOTH rather than pick one (the same fail-closed rule the
// converter applies to an ambiguous widget name).
const widgetsById = new Map();
const duplicated = new Set();
for (const raw of state.nodes) {
const n = raw;
if (n?.id == null)
continue;
const key = String(n.id);
if (widgetsById.has(key) || duplicated.has(key)) {
widgetsById.delete(key);
duplicated.add(key);
continue;
}
// A node with no widgets at all reports `{}`. Indexing it is correct — it
// means "this node genuinely has no widget values", which is a fact worth
// honouring rather than a reason to fall back to a positional array.
if (isPlainObject(n.widgets))
widgetsById.set(key, n.widgets);
}
let applied = 0;
const uncoveredTypes = new Map();
for (const raw of graphNodes) {
const node = raw;
const captured = node?.id == null ? undefined : widgetsById.get(String(node.id));
if (captured) {
node.capturedWidgetValues = captured;
applied++;
}
else {
const t = typeof node?.type === "string" ? node.type : "?";
uncoveredTypes.set(t, (uncoveredTypes.get(t) ?? 0) + 1);
}
}
const uncovered = graphNodes.length - applied;
const notes = [];
if (uncovered > 0) {
// Name the cap explicitly when that is what happened, so the reader knows the
// remedy (strip a smaller selection) rather than suspecting a broken panel.
const capped = state.truncated === true ||
(typeof state.node_count === "number" && state.node_count > state.nodes.length);
const types = [...uncoveredTypes.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([t, c]) => `${c}× ${t}`)
.join(", ");
notes.push(`note: ${uncovered} of ${graphNodes.length} node(s) (${types}) were not covered by the panel's live widget capture` +
(capped ? ` (it reports at most ${state.nodes.length} nodes per read)` : "") +
`, so ${POSITIONAL_CAVEAT}`);
}
if (duplicated.size > 0) {
notes.push(`note: the live widget capture reported ${duplicated.size} node id(s) more than once, so those nodes' values could not be attributed and were left as serialized.`);
}
return { applied, uncovered, notes };
}
//# sourceMappingURL=live-widget-overlay.js.map