Files
DemonGatanjieuandClaude Opus 5.5 4ab08a36e4 Combos keep every picked node, not only model and text nodes
Save as combo now keeps all picked nodes but subgraphs, with the links
between them. Model drop-downs and text boxes stay the slots; nodes
without slots are listed in the editor under "Nodes put back as saved"
with their values and go back exactly as saved. Inputs fed by links from
unpicked nodes are stored in structure.open, and the save dialog, the
editor and the card say which ones to wire after putting it down. Cards
of combos without models name their nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:39:44 +08:00

125 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The rules of combo structures (combo_structure.js) that need no canvas: which drop-down lists
* model files and from which models folder, where a saved link end goes on a node's ports, which
* node types are missing, and how a structure is named and compared. No imports, so it is tested
* on its own (LiteGraph is read from globalThis when there).
*/
export const STRUCTURE_VERSION = 1;
export const STRUCTURED = 'nodes';
const MODEL_FILE = /\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$/i;
export const NONE = /^\s*(none)?\s*$/i;
// ComfyUI's older names for the same folders, and which folder wins when several list the files.
const FOLDER_ALIAS = { clip: 'text_encoders', unet: 'diffusion_models' };
const FOLDER_ORDER = ['checkpoints', 'diffusion_models', 'loras', 'vae', 'text_encoders', 'controlnet', 'clip_vision',
'upscale_models', 'embeddings', 'style_models', 'gligen', 'vae_approx'];
export const slashes = (value) => String(value ?? '').replace(/\\/g, '/');
export const optionValues = (widget) => {
const values = widget?.options?.values;
try {
return Array.isArray(values) ? values : typeof values === 'function' ? values() || [] : [];
} catch {
return [];
}
};
/** Whether a box is a drop-down of model files. */
export function isModelWidget(widget) {
if (widget?.type !== 'combo') return false;
return MODEL_FILE.test(String(widget.value ?? '')) || optionValues(widget).some(value => typeof value === 'string' && MODEL_FILE.test(value));
}
/** Whether the box's value comes from a link (a box turned into an input). */
export function fedByLink(node, widget) {
return (node.inputs || []).some(input => input.widget?.name === widget.name && input.link != null);
}
/**
* The models folder a drop-down lists: the one whose files hold all its model-file options,
* by `lists` (Map folder -> Set of '/' paths). Several: the usual folder first. '' when none does.
*/
export function folderFor(options, current, lists) {
// Built-in choices beside the files (a VAE loader's "pixel_space", "taesd") are no files.
const files = options.filter(value => typeof value === 'string' && MODEL_FILE.test(value)).map(slashes);
const wanted = files.length ? files : (current && !NONE.test(current) ? [slashes(current)] : []);
if (!wanted.length) return '';
const fits = [...new Set([...lists].filter(([, set]) => wanted.every(file => set.has(file))).map(([name]) => FOLDER_ALIAS[name] || name))];
const rank = (name) => (FOLDER_ORDER.includes(name) ? FOLDER_ORDER.indexOf(name) : FOLDER_ORDER.length);
return fits.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))[0] || '';
}
/** The node pack a node type comes from ('' for ComfyUI's own), for "missing node pack" messages. */
export function packOf(type) {
const module = String(globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData?.python_module || '');
return module.startsWith('custom_nodes.') ? module.slice('custom_nodes.'.length).split('.')[0] : '';
}
/** The node types of a structure this ComfyUI does not have: [{ type, pack }]. */
export function missingTypes(structure) {
const known = globalThis.LiteGraph?.registered_node_types || {};
const seen = new Set();
return (structure?.nodes || []).filter(item => !known[item.type] && !seen.has(item.type) && seen.add(item.type))
.map(item => ({ type: item.type, pack: item.pack || '' }));
}
/** The slot index on `ports` a saved link end names: same name and type, else its old place, else the one port of that type. */
export function findPort(ports, name, index, type) {
const fits = (port) => !type || !port?.type || String(port.type) === type || port.type === '*' || type === '*';
const byName = (ports || []).findIndex(port => port?.name === name && fits(port));
if (name && byName >= 0) return byName;
if (ports?.[index] && fits(ports[index]) && (!name || !ports.some(port => port?.name === name))) return index;
const ofType = (ports || []).flatMap((port, at) => (String(port?.type) === type ? [at] : []));
return ofType.length === 1 ? ofType[0] : -1;
}
/** A short line for a structure: its nodes by name, repeats counted, e.g. "UNet加载器 + CLIP文本编码 ×2 + 加载LoRA ×4". */
export function structureSummary(structure) {
const counts = new Map();
for (const item of structure?.nodes || []) {
const name = item.title || item.name || item.type;
counts.set(name, (counts.get(name) || 0) + 1);
}
return [...counts].map(([name, count]) => (count > 1 ? `${name} ×${count}` : name)).join(' + ');
}
/**
* `ids` in the order things flow through `edges` ([from, to]): a loader before what it feeds,
* LoRAs along their chain. Ties keep their given order; anything in a loop comes last as given.
*/
export function chainOrder(ids, edges) {
const known = new Set(ids);
const waiting = new Map(ids.map(id => [id, 0]));
const next = new Map(ids.map(id => [id, []]));
for (const [from, to] of edges) {
if (!known.has(from) || !known.has(to) || from === to) continue;
next.get(from).push(to);
waiting.set(to, waiting.get(to) + 1);
}
const order = [];
const done = new Set();
while (order.length < ids.length) {
const ready = ids.find(id => !done.has(id) && waiting.get(id) === 0);
if (ready === undefined) break;
done.add(ready);
order.push(ready);
for (const to of next.get(ready)) waiting.set(to, waiting.get(to) - 1);
}
return [...order, ...ids.filter(id => !done.has(id))];
}
/** The structure's nodes that hold no slot (put back exactly as saved), in the order things flow. */
export function fixedNodes(structure) {
const nodes = structure?.nodes || [];
const withSlots = new Set((structure?.slots || []).map(slot => slot.node));
const byKey = new Map(nodes.map(item => [item.key, item]));
return chainOrder(nodes.map(item => item.key), (structure?.links || []).map(link => [link.from, link.to]))
.filter(key => !withSlots.has(key)).map(key => byKey.get(key));
}
/** Two structures with the same nodes, boxes and links (for "a new combo like this one"). */
export function structureKey(structure) {
return JSON.stringify([(structure?.nodes || []).map(item => item.type), (structure?.links || []).map(link => [link.from, link.fromName, link.to, link.toName]),
(structure?.slots || []).map(slot => [slot.node, slot.widget])]);
}