Files
DemonGatanjieu-Anomalous_Mo…/web/modules/combo_structure.js
T
DemonGatanjieuandClaude Opus 5.5 06461c4f9a Combos put LoRAs in rgthree's Power Lora Loader or Easy-Use's LoRA stack
Stacking many LoRAs made a long chain of Load LoRA nodes. A default combo now says which LoRA
node it is laid out with (data.loraNode: ComfyUI's own chain, rgthree's Power Lora Loader, or
Easy-Use's LoRA stack + Apply LoraStack), chosen under New or in its editor; the third-party ones
are offered only when installed, and a missing one falls back to ComfyUI's own and says so.

Save as combo lost a Power Lora Loader's rows (object values were not stored): they are kept as
the node's rows, made again before the slots are filled, each row's LoRA a slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:36:08 +08:00

211 lines
11 KiB
JavaScript

/**
* A combo's node structure (搭配的节点结构): the nodes picked on the canvas and the links between
* them, with the boxes a combo fills in (slots). No DOM.
*
* Every picked node is kept but a subgraph (a graph of its own, left out with its links). Every
* box of a kept node is stored by its name, not its position, and put back untouched; the slots
* are its model drop-downs (a drop-down listing model files) and text boxes (a multiline text box
* by its own definition, prompt_boxes.js), one node may hold several, each model slot with the
* models folder its drop-down lists (not guessed from names). A box fed by a link is no slot, and
* a node without slots is put back exactly as saved. A link coming in from a node that was not
* picked cannot be kept; its input is remembered (`open`) to say it needs wiring. Putting a
* structure back makes the nodes first, restores the other boxes in their order (a count box adds
* its rows), then fills the slots by name, then links; whatever cannot be found is reported,
* never guessed.
*
* rgthree's Power Lora Loader keeps its LoRAs in rows, no drop-downs: a node's rows are stored
* as `rows` (lora_nodes.js), made again before its slots are filled, and each row's LoRA is a
* model slot with `field: 'lora'` (the row's value is an object).
*
* A combo with a structure: data = { kind: 'nodes', structure: { version, nodes, links, slots,
* open: [{ node, name, type }] }, values: { slotId: value }, covers: { slotId: url } }.
*/
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
import { promptBoxes } from './prompt_boxes.js';
import {
NONE, STRUCTURE_VERSION, chainOrder, findPort, folderFor, fedByLink, isModelWidget, missingTypes, optionValues, packOf, slashes,
} from './combo_slots.js';
import { addPowerLoraRows, nativeLora, powerLoraRows } from './lora_nodes.js';
let folderLists = null;
/** ComfyUI's model folders and their files (GET /models, /models/<folder>), read once per page. */
async function loadFolderLists() {
if (folderLists) return folderLists;
const names = await (await api.fetchApi('/models')).json();
const lists = new Map();
await Promise.all((Array.isArray(names) ? names : []).map(async (name) => {
try {
const files = await (await api.fetchApi(`/models/${encodeURIComponent(name)}`)).json();
if (Array.isArray(files)) lists.set(name, new Set(files.map(slashes)));
} catch { /* a folder that cannot be listed fits nothing */ }
}));
folderLists = lists;
return lists;
}
const storable = (value) => value === null || ['string', 'number', 'boolean'].includes(typeof value);
/**
* The structure of the picked canvas nodes: { structure, values, dropped } — `dropped` the
* picked subgraphs. Null structure when nothing else was picked.
*/
export async function captureStructure(picked, { labelFor } = {}) {
const lists = await loadFolderLists();
const kept = [];
const dropped = [];
for (const node of picked) {
if (node.isSubgraphNode?.()) dropped.push(node);
else kept.push({ node, models: (node.widgets || []).filter(isModelWidget), texts: promptBoxes(node) });
}
if (!kept.length) return { structure: null, values: {}, dropped };
const graph = picked[0]?.graph || app.graph;
const linkOf = (input) => (input.link != null ? (graph.getLink?.(input.link) ?? graph.links?.[input.link]) : null);
// In the order things flow (loaders, then LoRAs from the model outwards), so the numbers read along the chain.
const byId = new Map(kept.map(item => [item.node.id, item]));
const edges = kept.flatMap(({ node }) => (node.inputs || []).map(linkOf).filter(Boolean).map(link => [link.origin_id, node.id]));
kept.splice(0, kept.length, ...chainOrder([...byId.keys()], edges).map(id => byId.get(id)));
const left = Math.min(...kept.map(({ node }) => node.pos?.[0] || 0));
const top = Math.min(...kept.map(({ node }) => node.pos?.[1] || 0));
const keys = new Map(kept.map(({ node }, index) => [node.id, `n${index + 1}`]));
const nodes = [];
const slots = [];
const values = {};
const counts = {};
for (const { node, models, texts } of kept) {
const key = keys.get(node.id);
const definitionTitle = globalThis.LiteGraph?.registered_node_types?.[node.type]?.title;
const rows = powerLoraRows(node);
nodes.push({
key, type: node.type, pack: packOf(node.type), name: node.title || node.type, // as the canvas shows it
title: node.title && node.title !== definitionTitle ? node.title : '',
pos: [Math.round((node.pos?.[0] || 0) - left), Math.round((node.pos?.[1] || 0) - top)],
size: Array.isArray(node.size) ? [Math.round(node.size[0]), Math.round(node.size[1])] : null,
widgets: (node.widgets || []).filter(widget => widget.name && widget.type !== 'button' && storable(widget.value))
.map(widget => [widget.name, widget.value]),
...(rows.length ? { rows } : {}),
});
for (const widget of models) {
if (fedByLink(node, widget)) continue;
const folder = folderFor(optionValues(widget), widget.value, lists);
counts[folder] = (counts[folder] || 0) + 1;
const id = `s${slots.length + 1}`;
slots.push({ id, node: key, widget: widget.name, kind: 'model', folder,
optional: optionValues(widget).some(value => typeof value === 'string' && NONE.test(value)),
label: labelFor?.({ kind: 'model', folder, index: counts[folder], widget: widget.name }) || widget.name });
values[id] = String(widget.value ?? '');
}
rows.forEach((row, index) => {
if (row.lora === null) return;
counts.loras = (counts.loras || 0) + 1;
const id = `s${slots.length + 1}`;
const widget = `lora_${index + 1}`; // the name the row gets when the node is made again
slots.push({ id, node: key, widget, field: 'lora', kind: 'model', folder: 'loras', optional: false,
label: labelFor?.({ kind: 'model', folder: 'loras', index: counts.loras, widget }) || widget });
values[id] = row.lora;
});
for (const box of texts) {
if (fedByLink(node, box.widget)) continue;
const id = `s${slots.length + 1}`;
slots.push({ id, node: key, widget: box.name, kind: 'text', role: box.role === 'both' ? '' : box.role,
label: labelFor?.({ kind: 'text', role: box.role, widget: box.name }) || box.name });
values[id] = String(box.widget.value ?? '');
}
}
// Model folders appear more than once: number them all ("LoRA 1", "LoRA 2"); a single one keeps its plain name.
for (const slot of slots) {
if (slot.kind === 'model' && counts[slot.folder] === 1) {
slot.label = labelFor?.({ kind: 'model', folder: slot.folder, index: 0, widget: slot.widget }) || slot.label;
}
}
const links = [];
const open = [];
for (const { node } of kept) {
(node.inputs || []).forEach((input, slot) => {
const link = linkOf(input);
if (!link) return;
if (!keys.has(link.origin_id)) {
// A box turned into an input keeps its saved value; any other input waits for a wire.
if (!input.widget) open.push({ node: keys.get(node.id), name: input.name || '', type: String(link.type ?? input.type ?? '') });
return;
}
const origin = graph.getNodeById(link.origin_id);
const output = origin?.outputs?.[link.origin_slot];
links.push({
from: keys.get(link.origin_id), fromSlot: link.origin_slot, fromName: output?.name || '',
to: keys.get(node.id), toSlot: slot, toName: input.name || '', type: String(link.type ?? output?.type ?? ''),
});
});
}
return { structure: { version: STRUCTURE_VERSION, nodes, links, slots, open }, values, dropped };
}
/** The drop-down's own spelling of a model path (folders with \ on Windows), else the path as given. */
function nativeChoice(widget, value) {
const wanted = slashes(value);
return optionValues(widget).find(option => typeof option === 'string' && slashes(option) === wanted) ?? value;
}
function setBox(node, widget, value) {
widget.value = value;
widget.callback?.(value, app.canvas, node, app.canvas?.graph_mouse, null);
}
/**
* Makes the structure's nodes on the canvas with `values` in its slots: { made: [{ node, relX,
* relY }], problems: [{ kind, … }] }. Throws { code: 'missing_nodes', missing } when a node type
* is not installed, before making anything.
*/
export function buildStructure(structure, values = {}) {
const missing = missingTypes(structure);
if (missing.length) {
const error = new Error('missing_nodes');
error.code = 'missing_nodes';
error.missing = missing;
throw error;
}
const problems = [];
const made = new Map();
const placed = [];
for (const item of structure.nodes) {
const node = globalThis.LiteGraph.createNode(item.type);
app.graph.add(node);
if (item.title) node.title = item.title;
made.set(item.key, node);
placed.push({ node, relX: item.pos?.[0] || 0, relY: item.pos?.[1] || 0 });
const slotNames = new Set(structure.slots.filter(slot => slot.node === item.key).map(slot => slot.widget));
for (const [name, value] of item.widgets || []) {
if (slotNames.has(name)) continue;
const widget = node.widgets?.find(entry => entry.name === name);
if (widget) setBox(node, widget, value); // in order: a count box adds the boxes after it
}
if (item.rows?.length) addPowerLoraRows(node, item.rows);
if (Array.isArray(item.size)) node.size = [Math.max(node.size?.[0] || 0, item.size[0]), Math.max(node.size?.[1] || 0, item.size[1])];
}
for (const slot of structure.slots) {
const node = made.get(slot.node);
const widget = node?.widgets?.find(entry => entry.name === slot.widget);
if (!widget) {
problems.push({ kind: 'slot_missing', label: slot.label, widget: slot.widget, type: node?.title || node?.type || '' });
continue;
}
const value = Object.hasOwn(values, slot.id) ? values[slot.id] : slot.field ? widget.value?.[slot.field] : widget.value;
if (slot.field) widget.value = { ...widget.value, [slot.field]: nativeLora(value) };
else setBox(node, widget, slot.kind === 'model' ? nativeChoice(widget, value) : String(value ?? ''));
}
for (const link of structure.links) {
const from = made.get(link.from);
const to = made.get(link.to);
const out = findPort(from?.outputs, link.fromName, link.fromSlot, link.type);
const into = findPort(to?.inputs, link.toName, link.toSlot, link.type);
if (out < 0 || into < 0 || !from.connect(out, to, into)) {
problems.push({ kind: 'link_missing', from: from?.title || from?.type || link.from, to: to?.title || to?.type || link.to, type: link.type });
}
}
return { made: placed, problems };
}