Inserting took a round per LoRA. The picker now gathers them: click to add, click again to drop, the number on a card is the order they go in. They go in as a chain of ComfyUI's loaders, or through one Power Lora Loader or Easy-Use stack where installed, all or nothing and as one undo step. Third-party nodes change height just after they appear, so they are placed, and the step taken, once they have settled. New nodes stand in one column and the view goes to them when a crowded canvas leaves them out of sight. Fixes the CLIP line going missing for a LoRA inserted before a sampler with a LoRA already there: that LoRA counted as a prompt node, so there seemed to be two CLIP sources. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
8.8 KiB
JavaScript
172 lines
8.8 KiB
JavaScript
// Several LoRAs into a workflow at once (web/modules/lora_insert.js), on a small stand-in graph.
|
|
import assert from 'node:assert/strict';
|
|
import { insertLoras } from '../web/modules/lora_insert.js';
|
|
import { loraNodeKind, POWER_LORA } from '../web/modules/lora_nodes.js';
|
|
|
|
const SLOTS = {
|
|
CheckpointLoaderSimple: [[], [['MODEL'], ['CLIP'], ['VAE']]],
|
|
UNETLoader: [[], [['MODEL']]],
|
|
CLIPTextEncode: [[['CLIP', 'clip']], [['CONDITIONING']]],
|
|
KSampler: [[['MODEL', 'model'], ['CONDITIONING', 'positive'], ['CONDITIONING', 'negative']], [['LATENT']]],
|
|
LoraLoader: [[['MODEL', 'model'], ['CLIP', 'clip']], [['MODEL'], ['CLIP']]],
|
|
LoraLoaderModelOnly: [[['MODEL', 'model']], [['MODEL']]],
|
|
[POWER_LORA]: [[['MODEL', 'model'], ['CLIP', 'clip']], [['MODEL'], ['CLIP']]],
|
|
'easy loraStack': [[['LORA_STACK', 'optional_lora_stack']], [['LORA_STACK']]],
|
|
'easy loraStackApply': [[['LORA_STACK', 'lora_stack'], ['MODEL', 'model'], ['CLIP', 'optional_clip']], [['MODEL'], ['CLIP']]],
|
|
};
|
|
|
|
class Graph {
|
|
constructor() { this._nodes = []; this.links = {}; this.nextLink = 1; this.nextNode = 1; }
|
|
getNodeById(id) { return this._nodes.find(node => node.id === id) || null; }
|
|
getLink(id) { return this.links[id] || null; }
|
|
add(node) { node.id ??= this.nextNode++; node.graph = this; this._nodes.push(node); }
|
|
remove(node) {
|
|
node.inputs.forEach(input => input.link != null && this.unlink(input.link));
|
|
node.outputs.forEach(output => [...output.links].forEach(id => this.unlink(id)));
|
|
this._nodes = this._nodes.filter(item => item !== node);
|
|
}
|
|
unlink(id) {
|
|
const link = this.links[id];
|
|
if (!link) return;
|
|
const origin = this.getNodeById(link.origin_id);
|
|
origin.outputs[link.origin_slot].links = origin.outputs[link.origin_slot].links.filter(item => item !== id);
|
|
this.getNodeById(link.target_id).inputs[link.target_slot].link = null;
|
|
delete this.links[id];
|
|
}
|
|
}
|
|
|
|
function node(type) {
|
|
const [inputs, outputs] = SLOTS[type];
|
|
const item = {
|
|
type, mode: 0, pos: [0, 0], size: [300, 120], widgets: [],
|
|
inputs: inputs.map(([slotType, name]) => ({ type: slotType, name, link: null })),
|
|
outputs: outputs.map(([slotType]) => ({ type: slotType, links: [] })),
|
|
connect(slot, target, targetSlot) {
|
|
const g = this.graph;
|
|
if (target.inputs[targetSlot].link != null) g.unlink(target.inputs[targetSlot].link);
|
|
const id = g.nextLink++;
|
|
g.links[id] = { id, origin_id: this.id, origin_slot: slot, target_id: target.id, target_slot: targetSlot };
|
|
this.outputs[slot].links.push(id);
|
|
target.inputs[targetSlot].link = id;
|
|
return g.links[id];
|
|
},
|
|
};
|
|
if (type === POWER_LORA) item.addNewLoraWidget = lora => item.widgets.push({ name: `lora_${item.widgets.length + 1}`, value: { on: true, lora } });
|
|
if (type === 'easy loraStack') item.widgets = ['toggle', 'mode', 'num_loras', ...Array.from({ length: 10 }, (_, row) => `lora_${row + 1}_name`)].map(name => ({ name, value: null }));
|
|
return item;
|
|
}
|
|
|
|
let failOn = null;
|
|
globalThis.LiteGraph = {
|
|
registered_node_types: {},
|
|
createNode: type => (type === failOn ? null : node(type)),
|
|
};
|
|
|
|
function workflow(loader = 'CheckpointLoaderSimple') {
|
|
const graph = new Graph();
|
|
const place = (type, x) => { const item = node(type); item.pos = [x, 0]; graph.add(item); return item; };
|
|
const model = place(loader, 0);
|
|
const positive = place('CLIPTextEncode', 900);
|
|
const negative = place('CLIPTextEncode', 900);
|
|
negative.pos = [900, 300];
|
|
const sampler = place('KSampler', 1400);
|
|
model.connect(0, sampler, 0);
|
|
if (loader === 'CheckpointLoaderSimple') {
|
|
model.connect(1, positive, 0);
|
|
model.connect(1, negative, 0);
|
|
}
|
|
positive.connect(0, sampler, 1);
|
|
negative.connect(0, sampler, 2);
|
|
return { graph, model, positive, negative, sampler };
|
|
}
|
|
|
|
const from = (graph, target, slot) => {
|
|
const link = graph.getLink(target.inputs[slot].link);
|
|
return link ? graph.getNodeById(link.origin_id) : null;
|
|
};
|
|
const setPath = (item, path) => { item.lora = path; };
|
|
const insert = (flow, options) => insertLoras({ graph: flow.graph, anchorNode: flow.model, direction: 'after', kind: loraNodeKind('core'), textEncoder: true, setPath, ...options });
|
|
|
|
// ComfyUI's own, after the loader: a chain in the order picked; the sampler and both prompts now hang off its end.
|
|
let flow = workflow();
|
|
let made = insert(flow, { loras: ['a', 'b', 'c'] });
|
|
assert.deepEqual(made.map(item => [item.type, item.lora]), [['LoraLoader', 'a'], ['LoraLoader', 'b'], ['LoraLoader', 'c']]);
|
|
assert.equal(from(flow.graph, made[0], 0), flow.model);
|
|
assert.equal(from(flow.graph, made[0], 1), flow.model);
|
|
assert.equal(from(flow.graph, made[1], 0), made[0]);
|
|
assert.equal(from(flow.graph, made[2], 1), made[1]);
|
|
assert.equal(from(flow.graph, flow.sampler, 0), made[2]);
|
|
assert.equal(from(flow.graph, flow.positive, 0), made[2]);
|
|
assert.equal(from(flow.graph, flow.negative, 0), made[2]);
|
|
|
|
// They stand in one column, in order, clear of each other and of what was there.
|
|
assert.equal(new Set(made.map(item => item.pos[0])).size, 1);
|
|
assert.ok(made[0].pos[1] + made[0].size[1] < made[1].pos[1]);
|
|
assert.ok(made[1].pos[1] + made[1].size[1] < made[2].pos[1]);
|
|
|
|
// No file trains the text encoder: model-only loaders, the prompts stay on the checkpoint.
|
|
flow = workflow();
|
|
made = insert(flow, { loras: ['a', 'b'], textEncoder: false });
|
|
assert.deepEqual(made.map(item => item.type), ['LoraLoaderModelOnly', 'LoraLoaderModelOnly']);
|
|
assert.equal(from(flow.graph, flow.sampler, 0), made[1]);
|
|
assert.equal(from(flow.graph, flow.positive, 0), flow.model);
|
|
|
|
// Before the sampler: still in the order picked, with the sampler at the end.
|
|
flow = workflow();
|
|
made = insertLoras({ graph: flow.graph, anchorNode: flow.sampler, direction: 'before', kind: loraNodeKind('core'), textEncoder: true, setPath, loras: ['a', 'b'] });
|
|
assert.equal(from(flow.graph, made[0], 0), flow.model);
|
|
assert.equal(from(flow.graph, made[1], 0), made[0]);
|
|
assert.equal(from(flow.graph, flow.sampler, 0), made[1]);
|
|
assert.equal(from(flow.graph, made[1], 1), made[0]);
|
|
assert.equal(from(flow.graph, flow.positive, 0), made[1]);
|
|
assert.equal(from(flow.graph, flow.negative, 0), made[1]);
|
|
|
|
// Power Lora Loader: one node, a row each.
|
|
flow = workflow();
|
|
made = insert(flow, { kind: loraNodeKind('rgthree'), loras: ['a', 'b', 'c'] });
|
|
assert.deepEqual(made.map(item => item.type), [POWER_LORA]);
|
|
assert.deepEqual(made[0].widgets.map(widget => widget.value.lora), ['a', 'b', 'c']);
|
|
assert.equal(from(flow.graph, flow.sampler, 0), made[0]);
|
|
assert.equal(from(flow.graph, flow.positive, 0), made[0]);
|
|
|
|
// A UNet with no CLIP on the canvas: the model goes through, the CLIP input stays empty.
|
|
flow = workflow('UNETLoader');
|
|
made = insert(flow, { kind: loraNodeKind('rgthree'), loras: ['a'] });
|
|
assert.equal(from(flow.graph, flow.sampler, 0), made[0]);
|
|
assert.equal(made[0].inputs[1].link, null);
|
|
|
|
// Easy-Use: twelve LoRAs fill two stacks; the stacks stand above Apply LoraStack, which the model goes through.
|
|
flow = workflow();
|
|
const twelve = Array.from({ length: 12 }, (_, index) => `lora${index + 1}`);
|
|
made = insert(flow, { kind: loraNodeKind('easy'), loras: twelve });
|
|
assert.deepEqual(made.map(item => item.type), ['easy loraStack', 'easy loraStack', 'easy loraStackApply']);
|
|
const row = (item, name) => item.widgets.find(widget => widget.name === name).value;
|
|
assert.equal(row(made[0], 'num_loras'), 10);
|
|
assert.equal(row(made[0], 'lora_10_name'), 'lora10');
|
|
assert.equal(row(made[1], 'num_loras'), 2);
|
|
assert.equal(row(made[1], 'lora_2_name'), 'lora12');
|
|
assert.equal(from(flow.graph, made[2], 0), made[0]);
|
|
assert.equal(from(flow.graph, made[0], 0), made[1]);
|
|
assert.equal(from(flow.graph, made[2], 1), flow.model);
|
|
assert.equal(from(flow.graph, made[2], 2), flow.model);
|
|
assert.equal(from(flow.graph, flow.sampler, 0), made[2]);
|
|
assert.ok(made[1].pos[1] < made[2].pos[1]);
|
|
|
|
// All or nothing: a failure part way leaves the workflow as it was.
|
|
const shape = graph => JSON.stringify([graph._nodes.map(item => item.id), Object.values(graph.links).map(link => [link.origin_id, link.origin_slot, link.target_id, link.target_slot]).sort()]);
|
|
flow = workflow();
|
|
const before = shape(flow.graph);
|
|
let calls = 0;
|
|
assert.throws(() => insert(flow, { loras: ['a', 'b', 'c'], setPath: () => { if (++calls === 3) throw new Error('no such file'); } }), /no such file/);
|
|
assert.equal(shape(flow.graph), before);
|
|
assert.equal(from(flow.graph, flow.sampler, 0), flow.model);
|
|
|
|
flow = workflow();
|
|
const untouched = shape(flow.graph);
|
|
failOn = 'easy loraStack';
|
|
assert.throws(() => insert(flow, { kind: loraNodeKind('easy'), loras: ['a'] }), /cannot make easy loraStack/);
|
|
failOn = null;
|
|
assert.equal(shape(flow.graph), untouched);
|
|
|
|
console.log('LoRA insert: chains, one-node stacks, placing and undoing a failed insert.');
|