Group nodes (#1776)
* setup ui unit tests * Refactoring, adding connections * Few tweaks * Fix type * Add general test * Refactored and extended test * move to describe * for groups * wip group nodes * Relink nodes Fixed widget values Convert to nodes * Reconnect on convert back * add via node menu + canvas refactor * Add ws event handling * fix using wrong node on widget serialize * allow reroute pipe fix control_after_generate configure * allow multiple images * Add test for converted widgets on missing nodes + fix crash * tidy * mores tests + refactor * throw earlier to get less confusing error * support outputs * more test * add ci action * use lts node * Fix? * Prevent connecting non matching combos * update * accidently removed npm i * Disable logging extension * fix naming allow control_after_generate custom name allow convert from reroutes * group node tests * Add executing info, custom node icon Tidy * internal reroute just works * Fix crash on virtual nodes e.g. note * Save group nodes to templates * Fix template nodes not being stored * Fix aborting convert * tidy * Fix reconnecting output links on convert to group * Fix links on convert to nodes * Handle missing internal nodes * Trigger callback on text change * Apply value on connect * Fix converted widgets not reconnecting * Group node updates - persist internal ids in current session - copy widget values when converting to nodes - fix issue serializing converted inputs * Resolve issue with sanitized node name * Fix internal id * allow outputs to be used internally and externally * order widgets on group node various fixes * fix imageupload widget requiring a specific name * groupnode imageupload test give widget unique name * Fix issue with external node links * Add VAE model * Fix internal node id check * fix potential crash * wip widget input support * more wip group widget inputs * Group node refactor Support for primitives/converted widgets * Fix convert to nodes with internal reroutes * fix applying primitive * Fix control widget values * fix test
This commit is contained in:
parent
d19de2753e
commit
7f469203b7
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"path-intellisense.mappings": {
|
||||||
|
"../": "${workspaceFolder}/web/extensions/core"
|
||||||
|
},
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "ms-python.autopep8"
|
||||||
|
},
|
||||||
|
"python.formatting.provider": "none"
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ async function setup() {
|
||||||
// Modify the response data to add some checkpoints
|
// Modify the response data to add some checkpoints
|
||||||
const objectInfo = JSON.parse(data);
|
const objectInfo = JSON.parse(data);
|
||||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"];
|
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"];
|
||||||
|
objectInfo.VAELoader.input.required.vae_name[0] = ["vae1.safetensors", "vae2.ckpt"];
|
||||||
|
|
||||||
data = JSON.stringify(objectInfo, undefined, "\t");
|
data = JSON.stringify(objectInfo, undefined, "\t");
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,818 @@
|
||||||
|
// @ts-check
|
||||||
|
/// <reference path="../node_modules/@types/jest/index.d.ts" />
|
||||||
|
|
||||||
|
const { start, createDefaultWorkflow } = require("../utils");
|
||||||
|
const lg = require("../utils/litegraph");
|
||||||
|
|
||||||
|
describe("group node", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
lg.setup(global);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
lg.teardown(global);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} app
|
||||||
|
* @param {*} graph
|
||||||
|
* @param {*} name
|
||||||
|
* @param {*} nodes
|
||||||
|
* @returns { Promise<InstanceType<import("../utils/ezgraph")["EzNode"]>> }
|
||||||
|
*/
|
||||||
|
async function convertToGroup(app, graph, name, nodes) {
|
||||||
|
// Select the nodes we are converting
|
||||||
|
for (const n of nodes) {
|
||||||
|
n.select(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b)).toEqual(
|
||||||
|
nodes.map((n) => n.id + "").sort((a, b) => +a - +b)
|
||||||
|
);
|
||||||
|
|
||||||
|
global.prompt = jest.fn().mockImplementation(() => name);
|
||||||
|
const groupNode = await nodes[0].menu["Convert to Group Node"].call(false);
|
||||||
|
|
||||||
|
// Check group name was requested
|
||||||
|
expect(window.prompt).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Ensure old nodes are removed
|
||||||
|
for (const n of nodes) {
|
||||||
|
expect(n.isRemoved).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(groupNode.type).toEqual("workflow/" + name);
|
||||||
|
|
||||||
|
return graph.find(groupNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { Record<string, string | number> | number[] } idMap
|
||||||
|
* @param { Record<string, Record<string, unknown>> } valueMap
|
||||||
|
*/
|
||||||
|
function getOutput(idMap = {}, valueMap = {}) {
|
||||||
|
if (idMap instanceof Array) {
|
||||||
|
idMap = idMap.reduce((p, n) => {
|
||||||
|
p[n] = n + "";
|
||||||
|
return p;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
const expected = {
|
||||||
|
1: { inputs: { ckpt_name: "model1.safetensors", ...valueMap?.[1] }, class_type: "CheckpointLoaderSimple" },
|
||||||
|
2: { inputs: { text: "positive", clip: ["1", 1], ...valueMap?.[2] }, class_type: "CLIPTextEncode" },
|
||||||
|
3: { inputs: { text: "negative", clip: ["1", 1], ...valueMap?.[3] }, class_type: "CLIPTextEncode" },
|
||||||
|
4: { inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] }, class_type: "EmptyLatentImage" },
|
||||||
|
5: {
|
||||||
|
inputs: {
|
||||||
|
seed: 0,
|
||||||
|
steps: 20,
|
||||||
|
cfg: 8,
|
||||||
|
sampler_name: "euler",
|
||||||
|
scheduler: "normal",
|
||||||
|
denoise: 1,
|
||||||
|
model: ["1", 0],
|
||||||
|
positive: ["2", 0],
|
||||||
|
negative: ["3", 0],
|
||||||
|
latent_image: ["4", 0],
|
||||||
|
...valueMap?.[5],
|
||||||
|
},
|
||||||
|
class_type: "KSampler",
|
||||||
|
},
|
||||||
|
6: { inputs: { samples: ["5", 0], vae: ["1", 2], ...valueMap?.[6] }, class_type: "VAEDecode" },
|
||||||
|
7: { inputs: { filename_prefix: "ComfyUI", images: ["6", 0], ...valueMap?.[7] }, class_type: "SaveImage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map old IDs to new at the top level
|
||||||
|
const mapped = {};
|
||||||
|
for (const oldId in idMap) {
|
||||||
|
mapped[idMap[oldId]] = expected[oldId];
|
||||||
|
delete expected[oldId];
|
||||||
|
}
|
||||||
|
Object.assign(mapped, expected);
|
||||||
|
|
||||||
|
// Map old IDs to new inside links
|
||||||
|
for (const k in mapped) {
|
||||||
|
for (const input in mapped[k].inputs) {
|
||||||
|
const v = mapped[k].inputs[input];
|
||||||
|
if (v instanceof Array) {
|
||||||
|
if (v[0] in idMap) {
|
||||||
|
v[0] = idMap[v[0]] + "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("can be created from selected nodes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty]);
|
||||||
|
|
||||||
|
// Ensure links are now to the group node
|
||||||
|
expect(group.inputs).toHaveLength(2);
|
||||||
|
expect(group.outputs).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(group.inputs.map((i) => i.input.name)).toEqual(["clip", "CLIPTextEncode clip"]);
|
||||||
|
expect(group.outputs.map((i) => i.output.name)).toEqual(["LATENT", "CONDITIONING", "CLIPTextEncode CONDITIONING"]);
|
||||||
|
|
||||||
|
// ckpt clip to both clip inputs on the group
|
||||||
|
expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[group.id, 0],
|
||||||
|
[group.id, 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// group conditioning to sampler
|
||||||
|
expect(group.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[nodes.sampler.id, 1],
|
||||||
|
]);
|
||||||
|
// group conditioning 2 to sampler
|
||||||
|
expect(
|
||||||
|
group.outputs["CLIPTextEncode CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])
|
||||||
|
).toEqual([[nodes.sampler.id, 2]]);
|
||||||
|
// group latent to sampler
|
||||||
|
expect(group.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[nodes.sampler.id, 3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maintains all output links on conversion", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const save2 = ez.SaveImage(...nodes.decode.outputs);
|
||||||
|
const save3 = ez.SaveImage(...nodes.decode.outputs);
|
||||||
|
// Ensure an output with multiple links maintains them on convert to group
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.sampler, nodes.decode]);
|
||||||
|
expect(group.outputs[0].connections.length).toBe(3);
|
||||||
|
expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id);
|
||||||
|
expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id);
|
||||||
|
expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id);
|
||||||
|
|
||||||
|
// and they're still linked when converting back to nodes
|
||||||
|
const newNodes = group.menu["Convert to nodes"].call();
|
||||||
|
const decode = graph.find(newNodes.find((n) => n.type === "VAEDecode"));
|
||||||
|
expect(decode.outputs[0].connections.length).toBe(3);
|
||||||
|
expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id);
|
||||||
|
expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id);
|
||||||
|
expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id);
|
||||||
|
});
|
||||||
|
test("can be be converted back to nodes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler];
|
||||||
|
const group = await convertToGroup(app, graph, "test", toConvert);
|
||||||
|
|
||||||
|
// Edit some values to ensure they are set back onto the converted nodes
|
||||||
|
expect(group.widgets["text"].value).toBe("positive");
|
||||||
|
group.widgets["text"].value = "pos";
|
||||||
|
expect(group.widgets["CLIPTextEncode text"].value).toBe("negative");
|
||||||
|
group.widgets["CLIPTextEncode text"].value = "neg";
|
||||||
|
expect(group.widgets["width"].value).toBe(512);
|
||||||
|
group.widgets["width"].value = 1024;
|
||||||
|
expect(group.widgets["sampler_name"].value).toBe("euler");
|
||||||
|
group.widgets["sampler_name"].value = "ddim";
|
||||||
|
expect(group.widgets["control_after_generate"].value).toBe("randomize");
|
||||||
|
group.widgets["control_after_generate"].value = "fixed";
|
||||||
|
|
||||||
|
/** @type { Array<any> } */
|
||||||
|
group.menu["Convert to nodes"].call();
|
||||||
|
|
||||||
|
// ensure widget values are set
|
||||||
|
const pos = graph.find(nodes.pos.id);
|
||||||
|
expect(pos.node.type).toBe("CLIPTextEncode");
|
||||||
|
expect(pos.widgets["text"].value).toBe("pos");
|
||||||
|
const neg = graph.find(nodes.neg.id);
|
||||||
|
expect(neg.node.type).toBe("CLIPTextEncode");
|
||||||
|
expect(neg.widgets["text"].value).toBe("neg");
|
||||||
|
const empty = graph.find(nodes.empty.id);
|
||||||
|
expect(empty.node.type).toBe("EmptyLatentImage");
|
||||||
|
expect(empty.widgets["width"].value).toBe(1024);
|
||||||
|
const sampler = graph.find(nodes.sampler.id);
|
||||||
|
expect(sampler.node.type).toBe("KSampler");
|
||||||
|
expect(sampler.widgets["sampler_name"].value).toBe("ddim");
|
||||||
|
expect(sampler.widgets["control_after_generate"].value).toBe("fixed");
|
||||||
|
|
||||||
|
// validate links
|
||||||
|
expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[pos.id, 0],
|
||||||
|
[neg.id, 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(pos.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[nodes.sampler.id, 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(neg.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[nodes.sampler.id, 2],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(empty.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
|
||||||
|
[nodes.sampler.id, 3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("it can embed reroutes as inputs", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
|
||||||
|
// Add and connect a reroute to the clip text encodes
|
||||||
|
const reroute = ez.Reroute();
|
||||||
|
nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]);
|
||||||
|
reroute.outputs[0].connectTo(nodes.pos.inputs[0]);
|
||||||
|
reroute.outputs[0].connectTo(nodes.neg.inputs[0]);
|
||||||
|
|
||||||
|
// Convert to group and ensure we only have 1 input of the correct type
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty, reroute]);
|
||||||
|
expect(group.inputs).toHaveLength(1);
|
||||||
|
expect(group.inputs[0].input.type).toEqual("CLIP");
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(getOutput());
|
||||||
|
});
|
||||||
|
test("it can embed reroutes as outputs", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
|
||||||
|
// Add a reroute with no output so we output IMAGE even though its used internally
|
||||||
|
const reroute = ez.Reroute();
|
||||||
|
nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0]);
|
||||||
|
|
||||||
|
// Convert to group and ensure there is an IMAGE output
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.decode, nodes.save, reroute]);
|
||||||
|
expect(group.outputs).toHaveLength(1);
|
||||||
|
expect(group.outputs[0].output.type).toEqual("IMAGE");
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.decode.id, nodes.save.id]));
|
||||||
|
});
|
||||||
|
test("it can embed reroutes as pipes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
|
||||||
|
// Use reroutes as a pipe
|
||||||
|
const rerouteModel = ez.Reroute();
|
||||||
|
const rerouteClip = ez.Reroute();
|
||||||
|
const rerouteVae = ez.Reroute();
|
||||||
|
nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0]);
|
||||||
|
nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0]);
|
||||||
|
nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0]);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [rerouteModel, rerouteClip, rerouteVae]);
|
||||||
|
|
||||||
|
expect(group.outputs).toHaveLength(3);
|
||||||
|
expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]);
|
||||||
|
|
||||||
|
expect(group.outputs).toHaveLength(3);
|
||||||
|
expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]);
|
||||||
|
|
||||||
|
group.outputs[0].connectTo(nodes.sampler.inputs.model);
|
||||||
|
group.outputs[1].connectTo(nodes.pos.inputs.clip);
|
||||||
|
group.outputs[1].connectTo(nodes.neg.inputs.clip);
|
||||||
|
});
|
||||||
|
test("can handle reroutes used internally", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
|
||||||
|
let reroutes = [];
|
||||||
|
let prevNode = nodes.ckpt;
|
||||||
|
for(let i = 0; i < 5; i++) {
|
||||||
|
const reroute = ez.Reroute();
|
||||||
|
prevNode.outputs[0].connectTo(reroute.inputs[0]);
|
||||||
|
prevNode = reroute;
|
||||||
|
reroutes.push(reroute);
|
||||||
|
}
|
||||||
|
prevNode.outputs[0].connectTo(nodes.sampler.inputs.model);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [...reroutes, ...Object.values(nodes)]);
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(getOutput());
|
||||||
|
|
||||||
|
group.menu["Convert to nodes"].call();
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(getOutput());
|
||||||
|
});
|
||||||
|
test("creates with widget values from inner nodes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
|
||||||
|
nodes.ckpt.widgets.ckpt_name.value = "model2.ckpt";
|
||||||
|
nodes.pos.widgets.text.value = "hello";
|
||||||
|
nodes.neg.widgets.text.value = "world";
|
||||||
|
nodes.empty.widgets.width.value = 256;
|
||||||
|
nodes.empty.widgets.height.value = 1024;
|
||||||
|
nodes.sampler.widgets.seed.value = 1;
|
||||||
|
nodes.sampler.widgets.control_after_generate.value = "increment";
|
||||||
|
nodes.sampler.widgets.steps.value = 8;
|
||||||
|
nodes.sampler.widgets.cfg.value = 4.5;
|
||||||
|
nodes.sampler.widgets.sampler_name.value = "uni_pc";
|
||||||
|
nodes.sampler.widgets.scheduler.value = "karras";
|
||||||
|
nodes.sampler.widgets.denoise.value = 0.9;
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [
|
||||||
|
nodes.ckpt,
|
||||||
|
nodes.pos,
|
||||||
|
nodes.neg,
|
||||||
|
nodes.empty,
|
||||||
|
nodes.sampler,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(group.widgets["ckpt_name"].value).toEqual("model2.ckpt");
|
||||||
|
expect(group.widgets["text"].value).toEqual("hello");
|
||||||
|
expect(group.widgets["CLIPTextEncode text"].value).toEqual("world");
|
||||||
|
expect(group.widgets["width"].value).toEqual(256);
|
||||||
|
expect(group.widgets["height"].value).toEqual(1024);
|
||||||
|
expect(group.widgets["seed"].value).toEqual(1);
|
||||||
|
expect(group.widgets["control_after_generate"].value).toEqual("increment");
|
||||||
|
expect(group.widgets["steps"].value).toEqual(8);
|
||||||
|
expect(group.widgets["cfg"].value).toEqual(4.5);
|
||||||
|
expect(group.widgets["sampler_name"].value).toEqual("uni_pc");
|
||||||
|
expect(group.widgets["scheduler"].value).toEqual("karras");
|
||||||
|
expect(group.widgets["denoise"].value).toEqual(0.9);
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(
|
||||||
|
getOutput([nodes.ckpt.id, nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id], {
|
||||||
|
[nodes.ckpt.id]: { ckpt_name: "model2.ckpt" },
|
||||||
|
[nodes.pos.id]: { text: "hello" },
|
||||||
|
[nodes.neg.id]: { text: "world" },
|
||||||
|
[nodes.empty.id]: { width: 256, height: 1024 },
|
||||||
|
[nodes.sampler.id]: {
|
||||||
|
seed: 1,
|
||||||
|
steps: 8,
|
||||||
|
cfg: 4.5,
|
||||||
|
sampler_name: "uni_pc",
|
||||||
|
scheduler: "karras",
|
||||||
|
denoise: 0.9,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("group inputs can be reroutes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
|
||||||
|
|
||||||
|
const reroute = ez.Reroute();
|
||||||
|
nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]);
|
||||||
|
|
||||||
|
reroute.outputs[0].connectTo(group.inputs[0]);
|
||||||
|
reroute.outputs[0].connectTo(group.inputs[1]);
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id]));
|
||||||
|
});
|
||||||
|
test("group outputs can be reroutes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
|
||||||
|
|
||||||
|
const reroute1 = ez.Reroute();
|
||||||
|
const reroute2 = ez.Reroute();
|
||||||
|
group.outputs[0].connectTo(reroute1.inputs[0]);
|
||||||
|
group.outputs[1].connectTo(reroute2.inputs[0]);
|
||||||
|
|
||||||
|
reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive);
|
||||||
|
reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative);
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id]));
|
||||||
|
});
|
||||||
|
test("groups can connect to each other", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const group1 = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
|
||||||
|
const group2 = await convertToGroup(app, graph, "test2", [nodes.empty, nodes.sampler]);
|
||||||
|
|
||||||
|
group1.outputs[0].connectTo(group2.inputs["positive"]);
|
||||||
|
group1.outputs[1].connectTo(group2.inputs["negative"]);
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(
|
||||||
|
getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("displays generated image on group node", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
let group = await convertToGroup(app, graph, "test", [
|
||||||
|
nodes.pos,
|
||||||
|
nodes.neg,
|
||||||
|
nodes.empty,
|
||||||
|
nodes.sampler,
|
||||||
|
nodes.decode,
|
||||||
|
nodes.save,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { api } = require("../../web/scripts/api");
|
||||||
|
|
||||||
|
api.dispatchEvent(new CustomEvent("execution_start", {}));
|
||||||
|
api.dispatchEvent(new CustomEvent("executing", { detail: `${nodes.save.id}` }));
|
||||||
|
// Event should be forwarded to group node id
|
||||||
|
expect(+app.runningNodeId).toEqual(group.id);
|
||||||
|
expect(group.node["imgs"]).toBeFalsy();
|
||||||
|
api.dispatchEvent(
|
||||||
|
new CustomEvent("executed", {
|
||||||
|
detail: {
|
||||||
|
node: `${nodes.save.id}`,
|
||||||
|
output: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
filename: "test.png",
|
||||||
|
type: "output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger paint
|
||||||
|
group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas);
|
||||||
|
|
||||||
|
expect(group.node["images"]).toEqual([
|
||||||
|
{
|
||||||
|
filename: "test.png",
|
||||||
|
type: "output",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
const workflow = JSON.stringify((await graph.toPrompt()).workflow);
|
||||||
|
await app.loadGraphData(JSON.parse(workflow));
|
||||||
|
group = graph.find(group);
|
||||||
|
|
||||||
|
// Trigger inner nodes to get created
|
||||||
|
group.node["getInnerNodes"]();
|
||||||
|
|
||||||
|
// Check it works for internal node ids
|
||||||
|
api.dispatchEvent(new CustomEvent("execution_start", {}));
|
||||||
|
api.dispatchEvent(new CustomEvent("executing", { detail: `${group.id}:5` }));
|
||||||
|
// Event should be forwarded to group node id
|
||||||
|
expect(+app.runningNodeId).toEqual(group.id);
|
||||||
|
expect(group.node["imgs"]).toBeFalsy();
|
||||||
|
api.dispatchEvent(
|
||||||
|
new CustomEvent("executed", {
|
||||||
|
detail: {
|
||||||
|
node: `${group.id}:5`,
|
||||||
|
output: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
filename: "test2.png",
|
||||||
|
type: "output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger paint
|
||||||
|
group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas);
|
||||||
|
|
||||||
|
expect(group.node["images"]).toEqual([
|
||||||
|
{
|
||||||
|
filename: "test2.png",
|
||||||
|
type: "output",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("allows widgets to be converted to inputs", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
|
||||||
|
group.widgets[0].convertToInput();
|
||||||
|
|
||||||
|
const primitive = ez.PrimitiveNode();
|
||||||
|
primitive.outputs[0].connectTo(group.inputs["text"]);
|
||||||
|
primitive.widgets[0].value = "hello";
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(
|
||||||
|
getOutput([nodes.pos.id, nodes.neg.id], {
|
||||||
|
[nodes.pos.id]: { text: "hello" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("can be copied", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
|
||||||
|
const group1 = await convertToGroup(app, graph, "test", [
|
||||||
|
nodes.pos,
|
||||||
|
nodes.neg,
|
||||||
|
nodes.empty,
|
||||||
|
nodes.sampler,
|
||||||
|
nodes.decode,
|
||||||
|
nodes.save,
|
||||||
|
]);
|
||||||
|
|
||||||
|
group1.widgets["text"].value = "hello";
|
||||||
|
group1.widgets["width"].value = 256;
|
||||||
|
group1.widgets["seed"].value = 1;
|
||||||
|
|
||||||
|
// Clone the node
|
||||||
|
group1.menu.Clone.call();
|
||||||
|
expect(app.graph._nodes).toHaveLength(3);
|
||||||
|
const group2 = graph.find(app.graph._nodes[2]);
|
||||||
|
expect(group2.node.type).toEqual("workflow/test");
|
||||||
|
expect(group2.id).not.toEqual(group1.id);
|
||||||
|
|
||||||
|
// Reconnect ckpt
|
||||||
|
nodes.ckpt.outputs.MODEL.connectTo(group2.inputs["model"]);
|
||||||
|
nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["clip"]);
|
||||||
|
nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["CLIPTextEncode clip"]);
|
||||||
|
nodes.ckpt.outputs.VAE.connectTo(group2.inputs["vae"]);
|
||||||
|
|
||||||
|
group2.widgets["text"].value = "world";
|
||||||
|
group2.widgets["width"].value = 1024;
|
||||||
|
group2.widgets["seed"].value = 100;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
expect((await graph.toPrompt()).output).toEqual({
|
||||||
|
...getOutput([nodes.empty.id, nodes.pos.id, nodes.neg.id, nodes.sampler.id, nodes.decode.id, nodes.save.id], {
|
||||||
|
[nodes.empty.id]: { width: 256 },
|
||||||
|
[nodes.pos.id]: { text: "hello" },
|
||||||
|
[nodes.sampler.id]: { seed: 1 },
|
||||||
|
}),
|
||||||
|
...getOutput(
|
||||||
|
{
|
||||||
|
[nodes.empty.id]: `${group2.id}:${i++}`,
|
||||||
|
[nodes.pos.id]: `${group2.id}:${i++}`,
|
||||||
|
[nodes.neg.id]: `${group2.id}:${i++}`,
|
||||||
|
[nodes.sampler.id]: `${group2.id}:${i++}`,
|
||||||
|
[nodes.decode.id]: `${group2.id}:${i++}`,
|
||||||
|
[nodes.save.id]: `${group2.id}:${i++}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[nodes.empty.id]: { width: 1024 },
|
||||||
|
[nodes.pos.id]: { text: "world" },
|
||||||
|
[nodes.sampler.id]: { seed: 100 },
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
graph.arrange();
|
||||||
|
});
|
||||||
|
test("is embedded in workflow", async () => {
|
||||||
|
let { ez, graph, app } = await start();
|
||||||
|
const nodes = createDefaultWorkflow(ez, graph);
|
||||||
|
let group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
|
||||||
|
const workflow = JSON.stringify((await graph.toPrompt()).workflow);
|
||||||
|
|
||||||
|
// Clear the environment
|
||||||
|
({ ez, graph, app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
}));
|
||||||
|
// Ensure the node isnt registered
|
||||||
|
expect(() => ez["workflow/test"]).toThrow();
|
||||||
|
|
||||||
|
// Reload the workflow
|
||||||
|
await app.loadGraphData(JSON.parse(workflow));
|
||||||
|
|
||||||
|
// Ensure the node is found
|
||||||
|
group = graph.find(group);
|
||||||
|
|
||||||
|
// Generate prompt and ensure it is as expected
|
||||||
|
expect((await graph.toPrompt()).output).toEqual(
|
||||||
|
getOutput({
|
||||||
|
[nodes.pos.id]: `${group.id}:0`,
|
||||||
|
[nodes.neg.id]: `${group.id}:1`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("shows missing node error on missing internal node when loading graph data", async () => {
|
||||||
|
const { graph } = await start();
|
||||||
|
|
||||||
|
const dialogShow = jest.spyOn(graph.app.ui.dialog, "show");
|
||||||
|
await graph.app.loadGraphData({
|
||||||
|
last_node_id: 3,
|
||||||
|
last_link_id: 1,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: "workflow/testerror",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [],
|
||||||
|
groups: [],
|
||||||
|
config: {},
|
||||||
|
extra: {
|
||||||
|
groupNodes: {
|
||||||
|
testerror: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "NotKSampler",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "NotVAEDecode",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dialogShow).toBeCalledTimes(1);
|
||||||
|
const call = dialogShow.mock.calls[0][0].innerHTML;
|
||||||
|
expect(call).toContain("the following node types were not found");
|
||||||
|
expect(call).toContain("NotKSampler");
|
||||||
|
expect(call).toContain("NotVAEDecode");
|
||||||
|
expect(call).toContain("workflow/testerror");
|
||||||
|
});
|
||||||
|
test("maintains widget inputs on conversion back to nodes", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
let pos = ez.CLIPTextEncode({ text: "positive" });
|
||||||
|
pos.node.title = "Positive";
|
||||||
|
let neg = ez.CLIPTextEncode({ text: "negative" });
|
||||||
|
neg.node.title = "Negative";
|
||||||
|
pos.widgets.text.convertToInput();
|
||||||
|
neg.widgets.text.convertToInput();
|
||||||
|
|
||||||
|
let primitive = ez.PrimitiveNode();
|
||||||
|
primitive.outputs[0].connectTo(pos.inputs.text);
|
||||||
|
primitive.outputs[0].connectTo(neg.inputs.text);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [pos, neg, primitive]);
|
||||||
|
// This will use a primitive widget named 'value'
|
||||||
|
expect(group.widgets.length).toBe(1);
|
||||||
|
expect(group.widgets["value"].value).toBe("positive");
|
||||||
|
|
||||||
|
const newNodes = group.menu["Convert to nodes"].call();
|
||||||
|
pos = graph.find(newNodes.find((n) => n.title === "Positive"));
|
||||||
|
neg = graph.find(newNodes.find((n) => n.title === "Negative"));
|
||||||
|
primitive = graph.find(newNodes.find((n) => n.type === "PrimitiveNode"));
|
||||||
|
|
||||||
|
expect(pos.inputs).toHaveLength(2);
|
||||||
|
expect(neg.inputs).toHaveLength(2);
|
||||||
|
expect(primitive.outputs[0].connections).toHaveLength(2);
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual({
|
||||||
|
1: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" },
|
||||||
|
2: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("adds widgets in node execution order", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const scale = ez.LatentUpscale();
|
||||||
|
const save = ez.SaveImage();
|
||||||
|
const empty = ez.EmptyLatentImage();
|
||||||
|
const decode = ez.VAEDecode();
|
||||||
|
|
||||||
|
scale.outputs.LATENT.connectTo(decode.inputs.samples);
|
||||||
|
decode.outputs.IMAGE.connectTo(save.inputs.images);
|
||||||
|
empty.outputs.LATENT.connectTo(scale.inputs.samples);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [scale, save, empty, decode]);
|
||||||
|
const widgets = group.widgets.map((w) => w.widget.name);
|
||||||
|
expect(widgets).toStrictEqual([
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"batch_size",
|
||||||
|
"upscale_method",
|
||||||
|
"LatentUpscale width",
|
||||||
|
"LatentUpscale height",
|
||||||
|
"crop",
|
||||||
|
"filename_prefix",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("adds output for external links when converting to group", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const img = ez.EmptyLatentImage();
|
||||||
|
let decode = ez.VAEDecode(...img.outputs);
|
||||||
|
const preview1 = ez.PreviewImage(...decode.outputs);
|
||||||
|
const preview2 = ez.PreviewImage(...decode.outputs);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [img, decode, preview1]);
|
||||||
|
|
||||||
|
// Ensure we have an output connected to the 2nd preview node
|
||||||
|
expect(group.outputs.length).toBe(1);
|
||||||
|
expect(group.outputs[0].connections.length).toBe(1);
|
||||||
|
expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id);
|
||||||
|
|
||||||
|
// Convert back and ensure bothe previews are still connected
|
||||||
|
group.menu["Convert to nodes"].call();
|
||||||
|
decode = graph.find(decode);
|
||||||
|
expect(decode.outputs[0].connections.length).toBe(2);
|
||||||
|
expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id);
|
||||||
|
expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id);
|
||||||
|
});
|
||||||
|
test("adds output for external links when converting to group when nodes are not in execution order", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const sampler = ez.KSampler();
|
||||||
|
const ckpt = ez.CheckpointLoaderSimple();
|
||||||
|
const empty = ez.EmptyLatentImage();
|
||||||
|
const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" });
|
||||||
|
const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" });
|
||||||
|
const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE);
|
||||||
|
const save = ez.SaveImage(decode1.outputs.IMAGE);
|
||||||
|
ckpt.outputs.MODEL.connectTo(sampler.inputs.model);
|
||||||
|
pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive);
|
||||||
|
neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative);
|
||||||
|
empty.outputs.LATENT.connectTo(sampler.inputs.latent_image);
|
||||||
|
|
||||||
|
const encode = ez.VAEEncode(decode1.outputs.IMAGE);
|
||||||
|
const vae = ez.VAELoader();
|
||||||
|
const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE);
|
||||||
|
const preview = ez.PreviewImage(decode2.outputs.IMAGE);
|
||||||
|
vae.outputs.VAE.connectTo(encode.inputs.vae);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [vae, decode1, encode, sampler]);
|
||||||
|
|
||||||
|
expect(group.outputs.length).toBe(3);
|
||||||
|
expect(group.outputs[0].output.name).toBe("VAE");
|
||||||
|
expect(group.outputs[0].output.type).toBe("VAE");
|
||||||
|
expect(group.outputs[1].output.name).toBe("IMAGE");
|
||||||
|
expect(group.outputs[1].output.type).toBe("IMAGE");
|
||||||
|
expect(group.outputs[2].output.name).toBe("LATENT");
|
||||||
|
expect(group.outputs[2].output.type).toBe("LATENT");
|
||||||
|
|
||||||
|
expect(group.outputs[0].connections.length).toBe(1);
|
||||||
|
expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id);
|
||||||
|
expect(group.outputs[0].connections[0].targetInput.index).toBe(1);
|
||||||
|
|
||||||
|
expect(group.outputs[1].connections.length).toBe(1);
|
||||||
|
expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id);
|
||||||
|
expect(group.outputs[1].connections[0].targetInput.index).toBe(0);
|
||||||
|
|
||||||
|
expect(group.outputs[2].connections.length).toBe(1);
|
||||||
|
expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id);
|
||||||
|
expect(group.outputs[2].connections[0].targetInput.index).toBe(0);
|
||||||
|
|
||||||
|
expect((await graph.toPrompt()).output).toEqual({
|
||||||
|
...getOutput({ 1: ckpt.id, 2: pos.id, 3: neg.id, 4: empty.id, 5: sampler.id, 6: decode1.id, 7: save.id }),
|
||||||
|
[vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: vae.node.type },
|
||||||
|
[encode.id]: { inputs: { pixels: ["6", 0], vae: [vae.id + "", 0] }, class_type: encode.node.type },
|
||||||
|
[decode2.id]: { inputs: { samples: [encode.id + "", 0], vae: [vae.id + "", 0] }, class_type: decode2.node.type },
|
||||||
|
[preview.id]: { inputs: { images: [decode2.id + "", 0] }, class_type: preview.node.type },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("works with IMAGEUPLOAD widget", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const img = ez.LoadImage();
|
||||||
|
const preview1 = ez.PreviewImage(img.outputs[0]);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [img, preview1]);
|
||||||
|
const widget = group.widgets["upload"];
|
||||||
|
expect(widget).toBeTruthy();
|
||||||
|
expect(widget.widget.type).toBe("button");
|
||||||
|
});
|
||||||
|
test("internal primitive populates widgets for all linked inputs", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const img = ez.LoadImage();
|
||||||
|
const scale1 = ez.ImageScale(img.outputs[0]);
|
||||||
|
const scale2 = ez.ImageScale(img.outputs[0]);
|
||||||
|
ez.PreviewImage(scale1.outputs[0]);
|
||||||
|
ez.PreviewImage(scale2.outputs[0]);
|
||||||
|
|
||||||
|
scale1.widgets.width.convertToInput();
|
||||||
|
scale2.widgets.height.convertToInput();
|
||||||
|
|
||||||
|
const primitive = ez.PrimitiveNode();
|
||||||
|
primitive.outputs[0].connectTo(scale1.inputs.width);
|
||||||
|
primitive.outputs[0].connectTo(scale2.inputs.height);
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [img, primitive, scale1, scale2]);
|
||||||
|
group.widgets.value.value = 100;
|
||||||
|
expect((await graph.toPrompt()).output).toEqual({
|
||||||
|
1: {
|
||||||
|
inputs: { image: img.widgets.image.value, upload: "image" },
|
||||||
|
class_type: "LoadImage",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
inputs: { upscale_method: "nearest-exact", width: 100, height: 512, crop: "disabled", image: ["1", 0] },
|
||||||
|
class_type: "ImageScale",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
inputs: { upscale_method: "nearest-exact", width: 512, height: 100, crop: "disabled", image: ["1", 0] },
|
||||||
|
class_type: "ImageScale",
|
||||||
|
},
|
||||||
|
4: { inputs: { images: ["2", 0] }, class_type: "PreviewImage" },
|
||||||
|
5: { inputs: { images: ["3", 0] }, class_type: "PreviewImage" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("primitive control widgets values are copied on convert", async () => {
|
||||||
|
const { ez, graph, app } = await start();
|
||||||
|
const sampler = ez.KSampler();
|
||||||
|
sampler.widgets.seed.convertToInput();
|
||||||
|
sampler.widgets.sampler_name.convertToInput();
|
||||||
|
|
||||||
|
let p1 = ez.PrimitiveNode();
|
||||||
|
let p2 = ez.PrimitiveNode();
|
||||||
|
p1.outputs[0].connectTo(sampler.inputs.seed);
|
||||||
|
p2.outputs[0].connectTo(sampler.inputs.sampler_name);
|
||||||
|
|
||||||
|
p1.widgets.control_after_generate.value = "increment";
|
||||||
|
p2.widgets.control_after_generate.value = "decrement";
|
||||||
|
p2.widgets.control_filter_list.value = "/.*/";
|
||||||
|
|
||||||
|
p2.node.title = "p2";
|
||||||
|
|
||||||
|
const group = await convertToGroup(app, graph, "test", [sampler, p1, p2]);
|
||||||
|
expect(group.widgets.control_after_generate.value).toBe("increment");
|
||||||
|
expect(group.widgets["p2 control_after_generate"].value).toBe("decrement");
|
||||||
|
expect(group.widgets["p2 control_filter_list"].value).toBe("/.*/");
|
||||||
|
|
||||||
|
group.widgets.control_after_generate.value = "fixed";
|
||||||
|
group.widgets["p2 control_after_generate"].value = "randomize";
|
||||||
|
group.widgets["p2 control_filter_list"].value = "/.+/";
|
||||||
|
|
||||||
|
group.menu["Convert to nodes"].call();
|
||||||
|
p1 = graph.find(p1);
|
||||||
|
p2 = graph.find(p2);
|
||||||
|
|
||||||
|
expect(p1.widgets.control_after_generate.value).toBe("fixed");
|
||||||
|
expect(p2.widgets.control_after_generate.value).toBe("randomize");
|
||||||
|
expect(p2.widgets.control_filter_list.value).toBe("/.+/");
|
||||||
|
});
|
||||||
|
});
|
|
@ -202,8 +202,8 @@ describe("widget inputs", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dialogShow).toBeCalledTimes(1);
|
expect(dialogShow).toBeCalledTimes(1);
|
||||||
expect(dialogShow.mock.calls[0][0]).toContain("the following node types were not found");
|
expect(dialogShow.mock.calls[0][0].innerHTML).toContain("the following node types were not found");
|
||||||
expect(dialogShow.mock.calls[0][0]).toContain("TestNode");
|
expect(dialogShow.mock.calls[0][0].innerHTML).toContain("TestNode");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defaultInput widgets can be converted back to inputs", async () => {
|
test("defaultInput widgets can be converted back to inputs", async () => {
|
||||||
|
|
|
@ -150,7 +150,7 @@ export class EzNodeMenuItem {
|
||||||
if (selectNode) {
|
if (selectNode) {
|
||||||
this.node.select();
|
this.node.select();
|
||||||
}
|
}
|
||||||
this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node);
|
return this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,8 +240,12 @@ export class EzNode {
|
||||||
return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem);
|
return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
select() {
|
get isRemoved() {
|
||||||
this.app.canvas.selectNode(this.node);
|
return !this.app.graph.getNodeById(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(addToSelection = false) {
|
||||||
|
this.app.canvas.selectNode(this.node, addToSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
|
@ -275,12 +279,17 @@ export class EzNode {
|
||||||
if (!s) return p;
|
if (!s) return p;
|
||||||
|
|
||||||
const name = s[nameProperty];
|
const name = s[nameProperty];
|
||||||
|
const item = new ctor(this, i, s);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!name || name in p) {
|
p.push(item);
|
||||||
throw new Error(`Unable to store ${nodeProperty} ${name} on array as name conflicts.`);
|
if (name) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (name in p) {
|
||||||
|
throw new Error(`Unable to store ${nodeProperty} ${name} on array as name conflicts.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
p.push((p[name] = new ctor(this, i, s)));
|
p[name] = item;
|
||||||
return p;
|
return p;
|
||||||
}, Object.assign([], { $: this }));
|
}, Object.assign([], { $: this }));
|
||||||
}
|
}
|
||||||
|
@ -348,6 +357,19 @@ export class EzGraph {
|
||||||
}, 10);
|
}, 10);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns { Promise<{
|
||||||
|
* workflow: {},
|
||||||
|
* output: Record<string, {
|
||||||
|
* class_name: string,
|
||||||
|
* inputs: Record<string, [string, number] | unknown>
|
||||||
|
* }>}> }
|
||||||
|
*/
|
||||||
|
toPrompt() {
|
||||||
|
// @ts-ignore
|
||||||
|
return this.app.graphToPrompt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Ez = {
|
export const Ez = {
|
||||||
|
@ -356,12 +378,12 @@ export const Ez = {
|
||||||
* @example
|
* @example
|
||||||
* const { ez, graph } = Ez.graph(app);
|
* const { ez, graph } = Ez.graph(app);
|
||||||
* graph.clear();
|
* graph.clear();
|
||||||
* const [model, clip, vae] = ez.CheckpointLoaderSimple();
|
* const [model, clip, vae] = ez.CheckpointLoaderSimple().outputs;
|
||||||
* const [pos] = ez.CLIPTextEncode(clip, { text: "positive" });
|
* const [pos] = ez.CLIPTextEncode(clip, { text: "positive" }).outputs;
|
||||||
* const [neg] = ez.CLIPTextEncode(clip, { text: "negative" });
|
* const [neg] = ez.CLIPTextEncode(clip, { text: "negative" }).outputs;
|
||||||
* const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage());
|
* const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage().outputs).outputs;
|
||||||
* const [image] = ez.VAEDecode(latent, vae);
|
* const [image] = ez.VAEDecode(latent, vae).outputs;
|
||||||
* const saveNode = ez.SaveImage(image).node;
|
* const saveNode = ez.SaveImage(image);
|
||||||
* console.log(saveNode);
|
* console.log(saveNode);
|
||||||
* graph.arrange();
|
* graph.arrange();
|
||||||
* @param { app } app
|
* @param { app } app
|
||||||
|
|
|
@ -1,21 +1,28 @@
|
||||||
const { mockApi } = require("./setup");
|
const { mockApi } = require("./setup");
|
||||||
const { Ez } = require("./ezgraph");
|
const { Ez } = require("./ezgraph");
|
||||||
|
const lg = require("./litegraph");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param { Parameters<mockApi>[0] } config
|
* @param { Parameters<mockApi>[0] & { resetEnv?: boolean } } config
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function start(config = undefined) {
|
export async function start(config = undefined) {
|
||||||
|
if(config?.resetEnv) {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.resetAllMocks();
|
||||||
|
lg.setup(global);
|
||||||
|
}
|
||||||
|
|
||||||
mockApi(config);
|
mockApi(config);
|
||||||
const { app } = require("../../web/scripts/app");
|
const { app } = require("../../web/scripts/app");
|
||||||
await app.setup();
|
await app.setup();
|
||||||
return Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]);
|
return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param { ReturnType<Ez["graph"]>["graph"] } graph
|
* @param { ReturnType<Ez["graph"]>["graph"] } graph
|
||||||
* @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
|
* @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
|
||||||
*/
|
*/
|
||||||
export async function checkBeforeAndAfterReload(graph, cb) {
|
export async function checkBeforeAndAfterReload(graph, cb) {
|
||||||
await cb(false);
|
await cb(false);
|
||||||
|
@ -24,10 +31,10 @@ export async function checkBeforeAndAfterReload(graph, cb) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param { string } name
|
* @param { string } name
|
||||||
* @param { Record<string, string | [string | string[], any]> } input
|
* @param { Record<string, string | [string | string[], any]> } input
|
||||||
* @param { (string | string[])[] | Record<string, string | string[]> } output
|
* @param { (string | string[])[] | Record<string, string | string[]> } output
|
||||||
* @returns { Record<string, import("../../web/types/comfy").ComfyObjectInfo> }
|
* @returns { Record<string, import("../../web/types/comfy").ComfyObjectInfo> }
|
||||||
*/
|
*/
|
||||||
export function makeNodeDef(name, input, output = {}) {
|
export function makeNodeDef(name, input, output = {}) {
|
||||||
const nodeDef = {
|
const nodeDef = {
|
||||||
|
@ -37,19 +44,19 @@ export function makeNodeDef(name, input, output = {}) {
|
||||||
output_name: [],
|
output_name: [],
|
||||||
output_is_list: [],
|
output_is_list: [],
|
||||||
input: {
|
input: {
|
||||||
required: {}
|
required: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
for(const k in input) {
|
for (const k in input) {
|
||||||
nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]];
|
nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]];
|
||||||
}
|
}
|
||||||
if(output instanceof Array) {
|
if (output instanceof Array) {
|
||||||
output = output.reduce((p, c) => {
|
output = output.reduce((p, c) => {
|
||||||
p[c] = c;
|
p[c] = c;
|
||||||
return p;
|
return p;
|
||||||
}, {})
|
}, {});
|
||||||
}
|
}
|
||||||
for(const k in output) {
|
for (const k in output) {
|
||||||
nodeDef.output.push(output[k]);
|
nodeDef.output.push(output[k]);
|
||||||
nodeDef.output_name.push(k);
|
nodeDef.output_name.push(k);
|
||||||
nodeDef.output_is_list.push(false);
|
nodeDef.output_is_list.push(false);
|
||||||
|
@ -68,4 +75,31 @@ export function assertNotNullOrUndefined(x) {
|
||||||
expect(x).not.toEqual(null);
|
expect(x).not.toEqual(null);
|
||||||
expect(x).not.toEqual(undefined);
|
expect(x).not.toEqual(undefined);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param { ReturnType<Ez["graph"]>["ez"] } ez
|
||||||
|
* @param { ReturnType<Ez["graph"]>["graph"] } graph
|
||||||
|
*/
|
||||||
|
export function createDefaultWorkflow(ez, graph) {
|
||||||
|
graph.clear();
|
||||||
|
const ckpt = ez.CheckpointLoaderSimple();
|
||||||
|
|
||||||
|
const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" });
|
||||||
|
const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" });
|
||||||
|
|
||||||
|
const empty = ez.EmptyLatentImage();
|
||||||
|
const sampler = ez.KSampler(
|
||||||
|
ckpt.outputs.MODEL,
|
||||||
|
pos.outputs.CONDITIONING,
|
||||||
|
neg.outputs.CONDITIONING,
|
||||||
|
empty.outputs.LATENT
|
||||||
|
);
|
||||||
|
|
||||||
|
const decode = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE);
|
||||||
|
const save = ez.SaveImage(decode.outputs.IMAGE);
|
||||||
|
graph.arrange();
|
||||||
|
|
||||||
|
return { ckpt, pos, neg, empty, sampler, decode, save };
|
||||||
|
}
|
||||||
|
|
|
@ -30,16 +30,20 @@ export function mockApi({ mockExtensions, mockNodeDefs } = {}) {
|
||||||
mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./data/object_info.json")));
|
mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./data/object_info.json")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const events = new EventTarget();
|
||||||
|
const mockApi = {
|
||||||
|
addEventListener: events.addEventListener.bind(events),
|
||||||
|
removeEventListener: events.removeEventListener.bind(events),
|
||||||
|
dispatchEvent: events.dispatchEvent.bind(events),
|
||||||
|
getSystemStats: jest.fn(),
|
||||||
|
getExtensions: jest.fn(() => mockExtensions),
|
||||||
|
getNodeDefs: jest.fn(() => mockNodeDefs),
|
||||||
|
init: jest.fn(),
|
||||||
|
apiURL: jest.fn((x) => "../../web/" + x),
|
||||||
|
};
|
||||||
jest.mock("../../web/scripts/api", () => ({
|
jest.mock("../../web/scripts/api", () => ({
|
||||||
get api() {
|
get api() {
|
||||||
return {
|
return mockApi;
|
||||||
addEventListener: jest.fn(),
|
|
||||||
getSystemStats: jest.fn(),
|
|
||||||
getExtensions: jest.fn(() => mockExtensions),
|
|
||||||
getNodeDefs: jest.fn(() => mockNodeDefs),
|
|
||||||
init: jest.fn(),
|
|
||||||
apiURL: jest.fn((x) => "../../web/" + x),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,5 +1,6 @@
|
||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||||
|
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
|
||||||
|
|
||||||
// Adds the ability to save and add multiple nodes as a template
|
// Adds the ability to save and add multiple nodes as a template
|
||||||
// To save:
|
// To save:
|
||||||
|
@ -34,7 +35,7 @@ class ManageTemplates extends ComfyDialog {
|
||||||
type: "file",
|
type: "file",
|
||||||
accept: ".json",
|
accept: ".json",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
style: {display: "none"},
|
style: { display: "none" },
|
||||||
parent: document.body,
|
parent: document.body,
|
||||||
onchange: () => this.importAll(),
|
onchange: () => this.importAll(),
|
||||||
});
|
});
|
||||||
|
@ -109,13 +110,13 @@ class ManageTemplates extends ComfyDialog {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = JSON.stringify({templates: this.templates}, null, 2); // convert the data to a JSON string
|
const json = JSON.stringify({ templates: this.templates }, null, 2); // convert the data to a JSON string
|
||||||
const blob = new Blob([json], {type: "application/json"});
|
const blob = new Blob([json], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = $el("a", {
|
const a = $el("a", {
|
||||||
href: url,
|
href: url,
|
||||||
download: "node_templates.json",
|
download: "node_templates.json",
|
||||||
style: {display: "none"},
|
style: { display: "none" },
|
||||||
parent: document.body,
|
parent: document.body,
|
||||||
});
|
});
|
||||||
a.click();
|
a.click();
|
||||||
|
@ -291,11 +292,11 @@ app.registerExtension({
|
||||||
setup() {
|
setup() {
|
||||||
const manage = new ManageTemplates();
|
const manage = new ManageTemplates();
|
||||||
|
|
||||||
const clipboardAction = (cb) => {
|
const clipboardAction = async (cb) => {
|
||||||
// We use the clipboard functions but dont want to overwrite the current user clipboard
|
// We use the clipboard functions but dont want to overwrite the current user clipboard
|
||||||
// Restore it after we've run our callback
|
// Restore it after we've run our callback
|
||||||
const old = localStorage.getItem("litegrapheditor_clipboard");
|
const old = localStorage.getItem("litegrapheditor_clipboard");
|
||||||
cb();
|
await cb();
|
||||||
localStorage.setItem("litegrapheditor_clipboard", old);
|
localStorage.setItem("litegrapheditor_clipboard", old);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -309,13 +310,31 @@ app.registerExtension({
|
||||||
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
|
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
const name = prompt("Enter name");
|
const name = prompt("Enter name");
|
||||||
if (!name || !name.trim()) return;
|
if (!name?.trim()) return;
|
||||||
|
|
||||||
clipboardAction(() => {
|
clipboardAction(() => {
|
||||||
app.canvas.copyToClipboard();
|
app.canvas.copyToClipboard();
|
||||||
|
let data = localStorage.getItem("litegrapheditor_clipboard");
|
||||||
|
data = JSON.parse(data);
|
||||||
|
const nodeIds = Object.keys(app.canvas.selected_nodes);
|
||||||
|
for (let i = 0; i < nodeIds.length; i++) {
|
||||||
|
const node = app.graph.getNodeById(nodeIds[i]);
|
||||||
|
const nodeData = node?.constructor.nodeData;
|
||||||
|
|
||||||
|
let groupData = GroupNodeHandler.getGroupData(node);
|
||||||
|
if (groupData) {
|
||||||
|
groupData = groupData.nodeData;
|
||||||
|
if (!data.groupNodes) {
|
||||||
|
data.groupNodes = {};
|
||||||
|
}
|
||||||
|
data.groupNodes[nodeData.name] = groupData;
|
||||||
|
data.nodes[i].type = nodeData.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
manage.templates.push({
|
manage.templates.push({
|
||||||
name,
|
name,
|
||||||
data: localStorage.getItem("litegrapheditor_clipboard"),
|
data: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
manage.store();
|
manage.store();
|
||||||
});
|
});
|
||||||
|
@ -323,15 +342,19 @@ app.registerExtension({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map each template to a menu item
|
// Map each template to a menu item
|
||||||
const subItems = manage.templates.map((t) => ({
|
const subItems = manage.templates.map((t) => {
|
||||||
content: t.name,
|
return {
|
||||||
callback: () => {
|
content: t.name,
|
||||||
clipboardAction(() => {
|
callback: () => {
|
||||||
localStorage.setItem("litegrapheditor_clipboard", t.data);
|
clipboardAction(async () => {
|
||||||
app.canvas.pasteFromClipboard();
|
const data = JSON.parse(t.data);
|
||||||
});
|
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {});
|
||||||
},
|
localStorage.setItem("litegrapheditor_clipboard", t.data);
|
||||||
}));
|
app.canvas.pasteFromClipboard();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
subItems.push(null, {
|
subItems.push(null, {
|
||||||
content: "Manage",
|
content: "Manage",
|
||||||
|
|
|
@ -121,6 +121,110 @@ function isValidCombo(combo, obj) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeIfValid(output, config2, forceUpdate, recreateWidget, config1) {
|
||||||
|
if (!config1) {
|
||||||
|
config1 = output.widget[CONFIG] ?? output.widget[GET_CONFIG]();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config1[0] instanceof Array) {
|
||||||
|
if (!isValidCombo(config1[0], config2[0])) return false;
|
||||||
|
} else if (config1[0] !== config2[0]) {
|
||||||
|
// Types dont match
|
||||||
|
console.log(`connection rejected: types dont match`, config1[0], config2[0]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = new Set([...Object.keys(config1[1] ?? {}), ...Object.keys(config2[1] ?? {})]);
|
||||||
|
|
||||||
|
let customConfig;
|
||||||
|
const getCustomConfig = () => {
|
||||||
|
if (!customConfig) {
|
||||||
|
if (typeof structuredClone === "undefined") {
|
||||||
|
customConfig = JSON.parse(JSON.stringify(config1[1] ?? {}));
|
||||||
|
} else {
|
||||||
|
customConfig = structuredClone(config1[1] ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return customConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNumber = config1[0] === "INT" || config1[0] === "FLOAT";
|
||||||
|
for (const k of keys.values()) {
|
||||||
|
if (k !== "default" && k !== "forceInput" && k !== "defaultInput") {
|
||||||
|
let v1 = config1[1][k];
|
||||||
|
let v2 = config2[1]?.[k];
|
||||||
|
|
||||||
|
if (v1 === v2 || (!v1 && !v2)) continue;
|
||||||
|
|
||||||
|
if (isNumber) {
|
||||||
|
if (k === "min") {
|
||||||
|
const theirMax = config2[1]?.["max"];
|
||||||
|
if (theirMax != null && v1 > theirMax) {
|
||||||
|
console.log("connection rejected: min > max", v1, theirMax);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
getCustomConfig()[k] = v1 == null ? v2 : v2 == null ? v1 : Math.max(v1, v2);
|
||||||
|
continue;
|
||||||
|
} else if (k === "max") {
|
||||||
|
const theirMin = config2[1]?.["min"];
|
||||||
|
if (theirMin != null && v1 < theirMin) {
|
||||||
|
console.log("connection rejected: max < min", v1, theirMin);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
getCustomConfig()[k] = v1 == null ? v2 : v2 == null ? v1 : Math.min(v1, v2);
|
||||||
|
continue;
|
||||||
|
} else if (k === "step") {
|
||||||
|
let step;
|
||||||
|
if (v1 == null) {
|
||||||
|
// No current step
|
||||||
|
step = v2;
|
||||||
|
} else if (v2 == null) {
|
||||||
|
// No new step
|
||||||
|
step = v1;
|
||||||
|
} else {
|
||||||
|
if (v1 < v2) {
|
||||||
|
// Ensure v1 is larger for the mod
|
||||||
|
const a = v2;
|
||||||
|
v2 = v1;
|
||||||
|
v1 = a;
|
||||||
|
}
|
||||||
|
if (v1 % v2) {
|
||||||
|
console.log("connection rejected: steps not divisible", "current:", v1, "new:", v2);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
step = v1;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustomConfig()[k] = step;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`connection rejected: config ${k} values dont match`, v1, v2);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customConfig || forceUpdate) {
|
||||||
|
if (customConfig) {
|
||||||
|
output.widget[CONFIG] = [config1[0], customConfig];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = recreateWidget?.call(this);
|
||||||
|
// When deleting a node this can be null
|
||||||
|
if (widget) {
|
||||||
|
const min = widget.options.min;
|
||||||
|
const max = widget.options.max;
|
||||||
|
if (min != null && widget.value < min) widget.value = min;
|
||||||
|
if (max != null && widget.value > max) widget.value = max;
|
||||||
|
widget.callback(widget.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { customConfig };
|
||||||
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "Comfy.WidgetInputs",
|
name: "Comfy.WidgetInputs",
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
|
@ -308,7 +412,7 @@ app.registerExtension({
|
||||||
this.isVirtualNode = true;
|
this.isVirtualNode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyToGraph() {
|
applyToGraph(extraLinks = []) {
|
||||||
if (!this.outputs[0].links?.length) return;
|
if (!this.outputs[0].links?.length) return;
|
||||||
|
|
||||||
function get_links(node) {
|
function get_links(node) {
|
||||||
|
@ -325,10 +429,9 @@ app.registerExtension({
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
let links = get_links(this);
|
let links = [...get_links(this).map((l) => app.graph.links[l]), ...extraLinks];
|
||||||
// For each output link copy our value over the original widget value
|
// For each output link copy our value over the original widget value
|
||||||
for (const l of links) {
|
for (const linkInfo of links) {
|
||||||
const linkInfo = app.graph.links[l];
|
|
||||||
const node = this.graph.getNodeById(linkInfo.target_id);
|
const node = this.graph.getNodeById(linkInfo.target_id);
|
||||||
const input = node.inputs[linkInfo.target_slot];
|
const input = node.inputs[linkInfo.target_slot];
|
||||||
const widgetName = input.widget.name;
|
const widgetName = input.widget.name;
|
||||||
|
@ -405,7 +508,12 @@ app.registerExtension({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.outputs[slot].links?.length) {
|
if (this.outputs[slot].links?.length) {
|
||||||
return this.#isValidConnection(input);
|
const valid = this.#isValidConnection(input);
|
||||||
|
if (valid) {
|
||||||
|
// On connect of additional outputs, copy our value to their widget
|
||||||
|
this.applyToGraph([{ target_id: target_node.id, target_slot }]);
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -462,12 +570,12 @@ app.registerExtension({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.type === "number" || widget.type === "combo") {
|
if (!inputData?.[1]?.control_after_generate && (widget.type === "number" || widget.type === "combo")) {
|
||||||
let control_value = this.widgets_values?.[1];
|
let control_value = this.widgets_values?.[1];
|
||||||
if (!control_value) {
|
if (!control_value) {
|
||||||
control_value = "fixed";
|
control_value = "fixed";
|
||||||
}
|
}
|
||||||
addValueControlWidgets(this, widget, control_value);
|
addValueControlWidgets(this, widget, control_value, undefined, inputData);
|
||||||
let filter = this.widgets_values?.[2];
|
let filter = this.widgets_values?.[2];
|
||||||
if(filter && this.widgets.length === 3) {
|
if(filter && this.widgets.length === 3) {
|
||||||
this.widgets[2].value = filter;
|
this.widgets[2].value = filter;
|
||||||
|
@ -507,6 +615,7 @@ app.registerExtension({
|
||||||
this.#removeWidgets();
|
this.#removeWidgets();
|
||||||
this.#onFirstConnection(true);
|
this.#onFirstConnection(true);
|
||||||
for (let i = 0; i < this.widgets?.length; i++) this.widgets[i].value = values[i];
|
for (let i = 0; i < this.widgets?.length; i++) this.widgets[i].value = values[i];
|
||||||
|
return this.widgets[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
#mergeWidgetConfig() {
|
#mergeWidgetConfig() {
|
||||||
|
@ -547,108 +656,8 @@ app.registerExtension({
|
||||||
#isValidConnection(input, forceUpdate) {
|
#isValidConnection(input, forceUpdate) {
|
||||||
// Only allow connections where the configs match
|
// Only allow connections where the configs match
|
||||||
const output = this.outputs[0];
|
const output = this.outputs[0];
|
||||||
const config1 = output.widget[CONFIG] ?? output.widget[GET_CONFIG]();
|
|
||||||
const config2 = input.widget[GET_CONFIG]();
|
const config2 = input.widget[GET_CONFIG]();
|
||||||
|
return !!mergeIfValid.call(this, output, config2, forceUpdate, this.#recreateWidget);
|
||||||
if (config1[0] instanceof Array) {
|
|
||||||
if (!isValidCombo(config1[0], config2[0])) return false;
|
|
||||||
} else if (config1[0] !== config2[0]) {
|
|
||||||
// Types dont match
|
|
||||||
console.log(`connection rejected: types dont match`, config1[0], config2[0]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = new Set([...Object.keys(config1[1] ?? {}), ...Object.keys(config2[1] ?? {})]);
|
|
||||||
|
|
||||||
let customConfig;
|
|
||||||
const getCustomConfig = () => {
|
|
||||||
if (!customConfig) {
|
|
||||||
if (typeof structuredClone === "undefined") {
|
|
||||||
customConfig = JSON.parse(JSON.stringify(config1[1] ?? {}));
|
|
||||||
} else {
|
|
||||||
customConfig = structuredClone(config1[1] ?? {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return customConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNumber = config1[0] === "INT" || config1[0] === "FLOAT";
|
|
||||||
for (const k of keys.values()) {
|
|
||||||
if (k !== "default" && k !== "forceInput" && k !== "defaultInput") {
|
|
||||||
let v1 = config1[1][k];
|
|
||||||
let v2 = config2[1][k];
|
|
||||||
|
|
||||||
if (v1 === v2 || (!v1 && !v2)) continue;
|
|
||||||
|
|
||||||
if (isNumber) {
|
|
||||||
if (k === "min") {
|
|
||||||
const theirMax = config2[1]["max"];
|
|
||||||
if (theirMax != null && v1 > theirMax) {
|
|
||||||
console.log("connection rejected: min > max", v1, theirMax);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
getCustomConfig()[k] = v1 == null ? v2 : v2 == null ? v1 : Math.max(v1, v2);
|
|
||||||
continue;
|
|
||||||
} else if (k === "max") {
|
|
||||||
const theirMin = config2[1]["min"];
|
|
||||||
if (theirMin != null && v1 < theirMin) {
|
|
||||||
console.log("connection rejected: max < min", v1, theirMin);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
getCustomConfig()[k] = v1 == null ? v2 : v2 == null ? v1 : Math.min(v1, v2);
|
|
||||||
continue;
|
|
||||||
} else if (k === "step") {
|
|
||||||
let step;
|
|
||||||
if (v1 == null) {
|
|
||||||
// No current step
|
|
||||||
step = v2;
|
|
||||||
} else if (v2 == null) {
|
|
||||||
// No new step
|
|
||||||
step = v1;
|
|
||||||
} else {
|
|
||||||
if (v1 < v2) {
|
|
||||||
// Ensure v1 is larger for the mod
|
|
||||||
const a = v2;
|
|
||||||
v2 = v1;
|
|
||||||
v1 = a;
|
|
||||||
}
|
|
||||||
if (v1 % v2) {
|
|
||||||
console.log("connection rejected: steps not divisible", "current:", v1, "new:", v2);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
step = v1;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCustomConfig()[k] = step;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`connection rejected: config ${k} values dont match`, v1, v2);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customConfig || forceUpdate) {
|
|
||||||
if (customConfig) {
|
|
||||||
output.widget[CONFIG] = [config1[0], customConfig];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#recreateWidget();
|
|
||||||
|
|
||||||
const widget = this.widgets[0];
|
|
||||||
// When deleting a node this can be null
|
|
||||||
if (widget) {
|
|
||||||
const min = widget.options.min;
|
|
||||||
const max = widget.options.max;
|
|
||||||
if (min != null && widget.value < min) widget.value = min;
|
|
||||||
if (max != null && widget.value > max) widget.value = max;
|
|
||||||
widget.callback(widget.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#removeWidgets() {
|
#removeWidgets() {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ComfyLogging } from "./logging.js";
|
import { ComfyLogging } from "./logging.js";
|
||||||
import { ComfyWidgets } from "./widgets.js";
|
import { ComfyWidgets, getWidgetType } from "./widgets.js";
|
||||||
import { ComfyUI, $el } from "./ui.js";
|
import { ComfyUI, $el } from "./ui.js";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { defaultGraph } from "./defaultGraph.js";
|
import { defaultGraph } from "./defaultGraph.js";
|
||||||
|
@ -779,7 +779,7 @@ export class ComfyApp {
|
||||||
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
||||||
*/
|
*/
|
||||||
#addPasteHandler() {
|
#addPasteHandler() {
|
||||||
document.addEventListener("paste", (e) => {
|
document.addEventListener("paste", async (e) => {
|
||||||
// ctrl+shift+v is used to paste nodes with connections
|
// ctrl+shift+v is used to paste nodes with connections
|
||||||
// this is handled by litegraph
|
// this is handled by litegraph
|
||||||
if(this.shiftDown) return;
|
if(this.shiftDown) return;
|
||||||
|
@ -827,7 +827,7 @@ export class ComfyApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
|
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
|
||||||
this.loadGraphData(workflow);
|
await this.loadGraphData(workflow);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (e.target.type === "text" || e.target.type === "textarea") {
|
if (e.target.type === "text" || e.target.type === "textarea") {
|
||||||
|
@ -1177,7 +1177,19 @@ export class ComfyApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
api.addEventListener("executed", ({ detail }) => {
|
api.addEventListener("executed", ({ detail }) => {
|
||||||
this.nodeOutputs[detail.node] = detail.output;
|
const output = this.nodeOutputs[detail.node];
|
||||||
|
if (detail.merge && output) {
|
||||||
|
for (const k in detail.output ?? {}) {
|
||||||
|
const v = output[k];
|
||||||
|
if (v instanceof Array) {
|
||||||
|
output[k] = v.concat(detail.output[k]);
|
||||||
|
} else {
|
||||||
|
output[k] = detail.output[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.nodeOutputs[detail.node] = detail.output;
|
||||||
|
}
|
||||||
const node = this.graph.getNodeById(detail.node);
|
const node = this.graph.getNodeById(detail.node);
|
||||||
if (node) {
|
if (node) {
|
||||||
if (node.onExecuted)
|
if (node.onExecuted)
|
||||||
|
@ -1292,6 +1304,7 @@ export class ComfyApp {
|
||||||
this.#addProcessMouseHandler();
|
this.#addProcessMouseHandler();
|
||||||
this.#addProcessKeyHandler();
|
this.#addProcessKeyHandler();
|
||||||
this.#addConfigureHandler();
|
this.#addConfigureHandler();
|
||||||
|
this.#addApiUpdateHandlers();
|
||||||
|
|
||||||
this.graph = new LGraph();
|
this.graph = new LGraph();
|
||||||
|
|
||||||
|
@ -1328,7 +1341,7 @@ export class ComfyApp {
|
||||||
const json = localStorage.getItem("workflow");
|
const json = localStorage.getItem("workflow");
|
||||||
if (json) {
|
if (json) {
|
||||||
const workflow = JSON.parse(json);
|
const workflow = JSON.parse(json);
|
||||||
this.loadGraphData(workflow);
|
await this.loadGraphData(workflow);
|
||||||
restored = true;
|
restored = true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -1337,7 +1350,7 @@ export class ComfyApp {
|
||||||
|
|
||||||
// We failed to restore a workflow so load the default
|
// We failed to restore a workflow so load the default
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
this.loadGraphData();
|
await this.loadGraphData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current workflow automatically
|
// Save current workflow automatically
|
||||||
|
@ -1345,7 +1358,6 @@ export class ComfyApp {
|
||||||
|
|
||||||
this.#addDrawNodeHandler();
|
this.#addDrawNodeHandler();
|
||||||
this.#addDrawGroupsHandler();
|
this.#addDrawGroupsHandler();
|
||||||
this.#addApiUpdateHandlers();
|
|
||||||
this.#addDropHandler();
|
this.#addDropHandler();
|
||||||
this.#addCopyHandler();
|
this.#addCopyHandler();
|
||||||
this.#addPasteHandler();
|
this.#addPasteHandler();
|
||||||
|
@ -1365,11 +1377,81 @@ export class ComfyApp {
|
||||||
await this.#invokeExtensionsAsync("registerCustomNodes");
|
await this.#invokeExtensionsAsync("registerCustomNodes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerNodeDef(nodeId, nodeData) {
|
||||||
|
const self = this;
|
||||||
|
const node = Object.assign(
|
||||||
|
function ComfyNode() {
|
||||||
|
var inputs = nodeData["input"]["required"];
|
||||||
|
if (nodeData["input"]["optional"] != undefined) {
|
||||||
|
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"]);
|
||||||
|
}
|
||||||
|
const config = { minWidth: 1, minHeight: 1 };
|
||||||
|
for (const inputName in inputs) {
|
||||||
|
const inputData = inputs[inputName];
|
||||||
|
const type = inputData[0];
|
||||||
|
|
||||||
|
let widgetCreated = true;
|
||||||
|
const widgetType = getWidgetType(inputData, inputName);
|
||||||
|
if(widgetType) {
|
||||||
|
if(widgetType === "COMBO") {
|
||||||
|
Object.assign(config, self.widgets.COMBO(this, inputName, inputData, app) || {});
|
||||||
|
} else {
|
||||||
|
Object.assign(config, self.widgets[widgetType](this, inputName, inputData, app) || {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Node connection inputs
|
||||||
|
this.addInput(inputName, type);
|
||||||
|
widgetCreated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(widgetCreated && inputData[1]?.forceInput && config?.widget) {
|
||||||
|
if (!config.widget.options) config.widget.options = {};
|
||||||
|
config.widget.options.forceInput = inputData[1].forceInput;
|
||||||
|
}
|
||||||
|
if(widgetCreated && inputData[1]?.defaultInput && config?.widget) {
|
||||||
|
if (!config.widget.options) config.widget.options = {};
|
||||||
|
config.widget.options.defaultInput = inputData[1].defaultInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const o in nodeData["output"]) {
|
||||||
|
let output = nodeData["output"][o];
|
||||||
|
if(output instanceof Array) output = "COMBO";
|
||||||
|
const outputName = nodeData["output_name"][o] || output;
|
||||||
|
const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE ;
|
||||||
|
this.addOutput(outputName, output, { shape: outputShape });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = this.computeSize();
|
||||||
|
s[0] = Math.max(config.minWidth, s[0] * 1.5);
|
||||||
|
s[1] = Math.max(config.minHeight, s[1]);
|
||||||
|
this.size = s;
|
||||||
|
this.serialize_widgets = true;
|
||||||
|
|
||||||
|
app.#invokeExtensionsAsync("nodeCreated", this);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: nodeData.display_name || nodeData.name,
|
||||||
|
comfyClass: nodeData.name,
|
||||||
|
nodeData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
node.prototype.comfyClass = nodeData.name;
|
||||||
|
|
||||||
|
this.#addNodeContextMenuHandler(node);
|
||||||
|
this.#addDrawBackgroundHandler(node, app);
|
||||||
|
this.#addNodeKeyHandler(node);
|
||||||
|
|
||||||
|
await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
|
||||||
|
LiteGraph.registerNodeType(nodeId, node);
|
||||||
|
node.category = nodeData.category;
|
||||||
|
}
|
||||||
|
|
||||||
async registerNodesFromDefs(defs) {
|
async registerNodesFromDefs(defs) {
|
||||||
await this.#invokeExtensionsAsync("addCustomNodeDefs", defs);
|
await this.#invokeExtensionsAsync("addCustomNodeDefs", defs);
|
||||||
|
|
||||||
// Generate list of known widgets
|
// Generate list of known widgets
|
||||||
const widgets = Object.assign(
|
this.widgets = Object.assign(
|
||||||
{},
|
{},
|
||||||
ComfyWidgets,
|
ComfyWidgets,
|
||||||
...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean)
|
...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean)
|
||||||
|
@ -1377,75 +1459,7 @@ export class ComfyApp {
|
||||||
|
|
||||||
// Register a node for each definition
|
// Register a node for each definition
|
||||||
for (const nodeId in defs) {
|
for (const nodeId in defs) {
|
||||||
const nodeData = defs[nodeId];
|
this.registerNodeDef(nodeId, defs[nodeId]);
|
||||||
const node = Object.assign(
|
|
||||||
function ComfyNode() {
|
|
||||||
var inputs = nodeData["input"]["required"];
|
|
||||||
if (nodeData["input"]["optional"] != undefined){
|
|
||||||
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
|
|
||||||
}
|
|
||||||
const config = { minWidth: 1, minHeight: 1 };
|
|
||||||
for (const inputName in inputs) {
|
|
||||||
const inputData = inputs[inputName];
|
|
||||||
const type = inputData[0];
|
|
||||||
|
|
||||||
let widgetCreated = true;
|
|
||||||
if (Array.isArray(type)) {
|
|
||||||
// Enums
|
|
||||||
Object.assign(config, widgets.COMBO(this, inputName, inputData, app) || {});
|
|
||||||
} else if (`${type}:${inputName}` in widgets) {
|
|
||||||
// Support custom widgets by Type:Name
|
|
||||||
Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {});
|
|
||||||
} else if (type in widgets) {
|
|
||||||
// Standard type widgets
|
|
||||||
Object.assign(config, widgets[type](this, inputName, inputData, app) || {});
|
|
||||||
} else {
|
|
||||||
// Node connection inputs
|
|
||||||
this.addInput(inputName, type);
|
|
||||||
widgetCreated = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(widgetCreated && inputData[1]?.forceInput && config?.widget) {
|
|
||||||
if (!config.widget.options) config.widget.options = {};
|
|
||||||
config.widget.options.forceInput = inputData[1].forceInput;
|
|
||||||
}
|
|
||||||
if(widgetCreated && inputData[1]?.defaultInput && config?.widget) {
|
|
||||||
if (!config.widget.options) config.widget.options = {};
|
|
||||||
config.widget.options.defaultInput = inputData[1].defaultInput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const o in nodeData["output"]) {
|
|
||||||
let output = nodeData["output"][o];
|
|
||||||
if(output instanceof Array) output = "COMBO";
|
|
||||||
const outputName = nodeData["output_name"][o] || output;
|
|
||||||
const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE ;
|
|
||||||
this.addOutput(outputName, output, { shape: outputShape });
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = this.computeSize();
|
|
||||||
s[0] = Math.max(config.minWidth, s[0] * 1.5);
|
|
||||||
s[1] = Math.max(config.minHeight, s[1]);
|
|
||||||
this.size = s;
|
|
||||||
this.serialize_widgets = true;
|
|
||||||
|
|
||||||
app.#invokeExtensionsAsync("nodeCreated", this);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: nodeData.display_name || nodeData.name,
|
|
||||||
comfyClass: nodeData.name,
|
|
||||||
nodeData
|
|
||||||
}
|
|
||||||
);
|
|
||||||
node.prototype.comfyClass = nodeData.name;
|
|
||||||
|
|
||||||
this.#addNodeContextMenuHandler(node);
|
|
||||||
this.#addDrawBackgroundHandler(node, app);
|
|
||||||
this.#addNodeKeyHandler(node);
|
|
||||||
|
|
||||||
await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
|
|
||||||
LiteGraph.registerNodeType(nodeId, node);
|
|
||||||
node.category = nodeData.category;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1488,9 +1502,14 @@ export class ComfyApp {
|
||||||
|
|
||||||
showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
|
showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
|
||||||
this.ui.dialog.show(
|
this.ui.dialog.show(
|
||||||
`When loading the graph, the following node types were not found: <ul>${Array.from(new Set(missingNodeTypes)).map(
|
$el("div", [
|
||||||
(t) => `<li>${t}</li>`
|
$el("span", { textContent: "When loading the graph, the following node types were not found: " }),
|
||||||
).join("")}</ul>${hasAddedNodes ? "Nodes that have failed to load will show as red on the graph." : ""}`
|
$el(
|
||||||
|
"ul",
|
||||||
|
Array.from(new Set(missingNodeTypes)).map((t) => $el("li", { textContent: t }))
|
||||||
|
),
|
||||||
|
...(hasAddedNodes ? [$el("span", { textContent: "Nodes that have failed to load will show as red on the graph." })] : []),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
this.logging.addEntry("Comfy.App", "warn", {
|
this.logging.addEntry("Comfy.App", "warn", {
|
||||||
MissingNodes: missingNodeTypes,
|
MissingNodes: missingNodeTypes,
|
||||||
|
@ -1501,7 +1520,7 @@ export class ComfyApp {
|
||||||
* Populates the graph with the specified workflow data
|
* Populates the graph with the specified workflow data
|
||||||
* @param {*} graphData A serialized graph object
|
* @param {*} graphData A serialized graph object
|
||||||
*/
|
*/
|
||||||
loadGraphData(graphData) {
|
async loadGraphData(graphData) {
|
||||||
this.clean();
|
this.clean();
|
||||||
|
|
||||||
let reset_invalid_values = false;
|
let reset_invalid_values = false;
|
||||||
|
@ -1519,6 +1538,7 @@ export class ComfyApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingNodeTypes = [];
|
const missingNodeTypes = [];
|
||||||
|
await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes);
|
||||||
for (let n of graphData.nodes) {
|
for (let n of graphData.nodes) {
|
||||||
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
|
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
|
||||||
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
|
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
|
||||||
|
@ -1527,8 +1547,8 @@ export class ComfyApp {
|
||||||
|
|
||||||
// Find missing node types
|
// Find missing node types
|
||||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||||
n.type = sanitizeNodeName(n.type);
|
|
||||||
missingNodeTypes.push(n.type);
|
missingNodeTypes.push(n.type);
|
||||||
|
n.type = sanitizeNodeName(n.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1627,92 +1647,98 @@ export class ComfyApp {
|
||||||
* @returns The workflow and node links
|
* @returns The workflow and node links
|
||||||
*/
|
*/
|
||||||
async graphToPrompt() {
|
async graphToPrompt() {
|
||||||
for (const node of this.graph.computeExecutionOrder(false)) {
|
for (const outerNode of this.graph.computeExecutionOrder(false)) {
|
||||||
if (node.isVirtualNode) {
|
const innerNodes = outerNode.getInnerNodes ? outerNode.getInnerNodes() : [outerNode];
|
||||||
// Don't serialize frontend only nodes but let them make changes
|
for (const node of innerNodes) {
|
||||||
if (node.applyToGraph) {
|
if (node.isVirtualNode) {
|
||||||
node.applyToGraph();
|
// Don't serialize frontend only nodes but let them make changes
|
||||||
|
if (node.applyToGraph) {
|
||||||
|
node.applyToGraph();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflow = this.graph.serialize();
|
const workflow = this.graph.serialize();
|
||||||
const output = {};
|
const output = {};
|
||||||
// Process nodes in order of execution
|
// Process nodes in order of execution
|
||||||
for (const node of this.graph.computeExecutionOrder(false)) {
|
for (const outerNode of this.graph.computeExecutionOrder(false)) {
|
||||||
const n = workflow.nodes.find((n) => n.id === node.id);
|
const innerNodes = outerNode.getInnerNodes ? outerNode.getInnerNodes() : [outerNode];
|
||||||
|
for (const node of innerNodes) {
|
||||||
|
if (node.isVirtualNode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (node.isVirtualNode) {
|
if (node.mode === 2 || node.mode === 4) {
|
||||||
continue;
|
// Don't serialize muted nodes
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (node.mode === 2 || node.mode === 4) {
|
const inputs = {};
|
||||||
// Don't serialize muted nodes
|
const widgets = node.widgets;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputs = {};
|
// Store all widget values
|
||||||
const widgets = node.widgets;
|
if (widgets) {
|
||||||
|
for (const i in widgets) {
|
||||||
// Store all widget values
|
const widget = widgets[i];
|
||||||
if (widgets) {
|
if (!widget.options || widget.options.serialize !== false) {
|
||||||
for (const i in widgets) {
|
inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(node, i) : widget.value;
|
||||||
const widget = widgets[i];
|
}
|
||||||
if (!widget.options || widget.options.serialize !== false) {
|
|
||||||
inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(n, i) : widget.value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Store all node links
|
// Store all node links
|
||||||
for (let i in node.inputs) {
|
for (let i in node.inputs) {
|
||||||
let parent = node.getInputNode(i);
|
let parent = node.getInputNode(i);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
let link = node.getInputLink(i);
|
let link = node.getInputLink(i);
|
||||||
while (parent.mode === 4 || parent.isVirtualNode) {
|
while (parent.mode === 4 || parent.isVirtualNode) {
|
||||||
let found = false;
|
let found = false;
|
||||||
if (parent.isVirtualNode) {
|
if (parent.isVirtualNode) {
|
||||||
link = parent.getInputLink(link.origin_slot);
|
link = parent.getInputLink(link.origin_slot);
|
||||||
if (link) {
|
if (link) {
|
||||||
parent = parent.getInputNode(link.target_slot);
|
parent = parent.getInputNode(link.target_slot);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
found = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (link && parent.mode === 4) {
|
|
||||||
let all_inputs = [link.origin_slot];
|
|
||||||
if (parent.inputs) {
|
|
||||||
all_inputs = all_inputs.concat(Object.keys(parent.inputs))
|
|
||||||
for (let parent_input in all_inputs) {
|
|
||||||
parent_input = all_inputs[parent_input];
|
|
||||||
if (parent.inputs[parent_input]?.type === node.inputs[i].type) {
|
|
||||||
link = parent.getInputLink(parent_input);
|
|
||||||
if (link) {
|
|
||||||
parent = parent.getInputNode(parent_input);
|
|
||||||
}
|
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
}
|
||||||
|
}
|
||||||
|
} else if (link && parent.mode === 4) {
|
||||||
|
let all_inputs = [link.origin_slot];
|
||||||
|
if (parent.inputs) {
|
||||||
|
all_inputs = all_inputs.concat(Object.keys(parent.inputs))
|
||||||
|
for (let parent_input in all_inputs) {
|
||||||
|
parent_input = all_inputs[parent_input];
|
||||||
|
if (parent.inputs[parent_input]?.type === node.inputs[i].type) {
|
||||||
|
link = parent.getInputLink(parent_input);
|
||||||
|
if (link) {
|
||||||
|
parent = parent.getInputNode(parent_input);
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!found) {
|
if (link) {
|
||||||
break;
|
if (parent?.updateLink) {
|
||||||
|
link = parent.updateLink(link);
|
||||||
|
}
|
||||||
|
inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (link) {
|
|
||||||
inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
output[String(node.id)] = {
|
output[String(node.id)] = {
|
||||||
inputs,
|
inputs,
|
||||||
class_type: node.comfyClass,
|
class_type: node.comfyClass,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove inputs connected to removed nodes
|
// Remove inputs connected to removed nodes
|
||||||
|
@ -1832,7 +1858,7 @@ export class ComfyApp {
|
||||||
const pngInfo = await getPngMetadata(file);
|
const pngInfo = await getPngMetadata(file);
|
||||||
if (pngInfo) {
|
if (pngInfo) {
|
||||||
if (pngInfo.workflow) {
|
if (pngInfo.workflow) {
|
||||||
this.loadGraphData(JSON.parse(pngInfo.workflow));
|
await this.loadGraphData(JSON.parse(pngInfo.workflow));
|
||||||
} else if (pngInfo.parameters) {
|
} else if (pngInfo.parameters) {
|
||||||
importA1111(this.graph, pngInfo.parameters);
|
importA1111(this.graph, pngInfo.parameters);
|
||||||
}
|
}
|
||||||
|
@ -1848,21 +1874,21 @@ export class ComfyApp {
|
||||||
}
|
}
|
||||||
} else if (file.type === "application/json" || file.name?.endsWith(".json")) {
|
} else if (file.type === "application/json" || file.name?.endsWith(".json")) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = async () => {
|
||||||
const jsonContent = JSON.parse(reader.result);
|
const jsonContent = JSON.parse(reader.result);
|
||||||
if (jsonContent?.templates) {
|
if (jsonContent?.templates) {
|
||||||
this.loadTemplateData(jsonContent);
|
this.loadTemplateData(jsonContent);
|
||||||
} else if(this.isApiJson(jsonContent)) {
|
} else if(this.isApiJson(jsonContent)) {
|
||||||
this.loadApiJson(jsonContent);
|
this.loadApiJson(jsonContent);
|
||||||
} else {
|
} else {
|
||||||
this.loadGraphData(jsonContent);
|
await this.loadGraphData(jsonContent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
|
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
|
||||||
const info = await getLatentMetadata(file);
|
const info = await getLatentMetadata(file);
|
||||||
if (info.workflow) {
|
if (info.workflow) {
|
||||||
this.loadGraphData(JSON.parse(info.workflow));
|
await this.loadGraphData(JSON.parse(info.workflow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,7 +44,7 @@ function getClipPath(node, element, elRect) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSize(size) {
|
function computeSize(size) {
|
||||||
if (this.widgets?.[0].last_y == null) return;
|
if (this.widgets?.[0]?.last_y == null) return;
|
||||||
|
|
||||||
let y = this.widgets[0].last_y;
|
let y = this.widgets[0].last_y;
|
||||||
let freeSpace = size[1] - y;
|
let freeSpace = size[1] - y;
|
||||||
|
@ -195,7 +195,6 @@ export function addDomClippingSetting() {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: enableDomClipping,
|
defaultValue: enableDomClipping,
|
||||||
onChange(value) {
|
onChange(value) {
|
||||||
console.log("enableDomClipping", enableDomClipping);
|
|
||||||
enableDomClipping = !!value;
|
enableDomClipping = !!value;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -462,8 +462,8 @@ class ComfyList {
|
||||||
return $el("div", {textContent: item.prompt[0] + ": "}, [
|
return $el("div", {textContent: item.prompt[0] + ": "}, [
|
||||||
$el("button", {
|
$el("button", {
|
||||||
textContent: "Load",
|
textContent: "Load",
|
||||||
onclick: () => {
|
onclick: async () => {
|
||||||
app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
|
await app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
|
||||||
if (item.outputs) {
|
if (item.outputs) {
|
||||||
app.nodeOutputs = item.outputs;
|
app.nodeOutputs = item.outputs;
|
||||||
}
|
}
|
||||||
|
@ -784,9 +784,9 @@ export class ComfyUI {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
$el("button", {
|
$el("button", {
|
||||||
id: "comfy-load-default-button", textContent: "Load Default", onclick: () => {
|
id: "comfy-load-default-button", textContent: "Load Default", onclick: async () => {
|
||||||
if (!confirmClear.value || confirm("Load default workflow?")) {
|
if (!confirmClear.value || confirm("Load default workflow?")) {
|
||||||
app.loadGraphData()
|
await app.loadGraphData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -23,29 +23,73 @@ function getNumberDefaults(inputData, defaultStep, precision, enable_rounding) {
|
||||||
return { val: defaultVal, config: { min, max, step: 10.0 * step, round, precision } };
|
return { val: defaultVal, config: { min, max, step: 10.0 * step, round, precision } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addValueControlWidget(node, targetWidget, defaultValue = "randomize", values) {
|
export function getWidgetType(inputData, inputName) {
|
||||||
const widgets = addValueControlWidgets(node, targetWidget, defaultValue, values, {
|
const type = inputData[0];
|
||||||
|
|
||||||
|
if (Array.isArray(type)) {
|
||||||
|
return "COMBO";
|
||||||
|
} else if (`${type}:${inputName}` in ComfyWidgets) {
|
||||||
|
return `${type}:${inputName}`;
|
||||||
|
} else if (type in ComfyWidgets) {
|
||||||
|
return type;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addValueControlWidget(node, targetWidget, defaultValue = "randomize", values, widgetName, inputData) {
|
||||||
|
let name = inputData[1]?.control_after_generate;
|
||||||
|
if(typeof name !== "string") {
|
||||||
|
name = widgetName;
|
||||||
|
}
|
||||||
|
const widgets = addValueControlWidgets(node, targetWidget, defaultValue, {
|
||||||
addFilterList: false,
|
addFilterList: false,
|
||||||
});
|
controlAfterGenerateName: name
|
||||||
|
}, inputData);
|
||||||
return widgets[0];
|
return widgets[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addValueControlWidgets(node, targetWidget, defaultValue = "randomize", values, options) {
|
export function addValueControlWidgets(node, targetWidget, defaultValue = "randomize", options, inputData) {
|
||||||
|
if (!defaultValue) defaultValue = "randomize";
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
|
|
||||||
|
const getName = (defaultName, optionName) => {
|
||||||
|
let name = defaultName;
|
||||||
|
if (options[optionName]) {
|
||||||
|
name = options[optionName];
|
||||||
|
} else if (typeof inputData?.[1]?.[defaultName] === "string") {
|
||||||
|
name = inputData?.[1]?.[defaultName];
|
||||||
|
} else if (inputData?.[1]?.control_prefix) {
|
||||||
|
name = inputData?.[1]?.control_prefix + " " + name
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
const widgets = [];
|
const widgets = [];
|
||||||
const valueControl = node.addWidget("combo", "control_after_generate", defaultValue, function (v) { }, {
|
const valueControl = node.addWidget(
|
||||||
values: ["fixed", "increment", "decrement", "randomize"],
|
"combo",
|
||||||
serialize: false, // Don't include this in prompt.
|
getName("control_after_generate", "controlAfterGenerateName"),
|
||||||
});
|
defaultValue,
|
||||||
|
function () {},
|
||||||
|
{
|
||||||
|
values: ["fixed", "increment", "decrement", "randomize"],
|
||||||
|
serialize: false, // Don't include this in prompt.
|
||||||
|
}
|
||||||
|
);
|
||||||
widgets.push(valueControl);
|
widgets.push(valueControl);
|
||||||
|
|
||||||
const isCombo = targetWidget.type === "combo";
|
const isCombo = targetWidget.type === "combo";
|
||||||
let comboFilter;
|
let comboFilter;
|
||||||
if (isCombo && options.addFilterList !== false) {
|
if (isCombo && options.addFilterList !== false) {
|
||||||
comboFilter = node.addWidget("string", "control_filter_list", "", function (v) {}, {
|
comboFilter = node.addWidget(
|
||||||
serialize: false, // Don't include this in prompt.
|
"string",
|
||||||
});
|
getName("control_filter_list", "controlFilterListName"),
|
||||||
|
"",
|
||||||
|
function () {},
|
||||||
|
{
|
||||||
|
serialize: false, // Don't include this in prompt.
|
||||||
|
}
|
||||||
|
);
|
||||||
widgets.push(comboFilter);
|
widgets.push(comboFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,7 +140,8 @@ export function addValueControlWidgets(node, targetWidget, defaultValue = "rando
|
||||||
targetWidget.value = value;
|
targetWidget.value = value;
|
||||||
targetWidget.callback(value);
|
targetWidget.callback(value);
|
||||||
}
|
}
|
||||||
} else { //number
|
} else {
|
||||||
|
//number
|
||||||
let min = targetWidget.options.min;
|
let min = targetWidget.options.min;
|
||||||
let max = targetWidget.options.max;
|
let max = targetWidget.options.max;
|
||||||
// limit to something that javascript can handle
|
// limit to something that javascript can handle
|
||||||
|
@ -119,32 +164,54 @@ export function addValueControlWidgets(node, targetWidget, defaultValue = "rando
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
/*check if values are over or under their respective
|
/*check if values are over or under their respective
|
||||||
* ranges and set them to min or max.*/
|
* ranges and set them to min or max.*/
|
||||||
if (targetWidget.value < min)
|
if (targetWidget.value < min) targetWidget.value = min;
|
||||||
targetWidget.value = min;
|
|
||||||
|
|
||||||
if (targetWidget.value > max)
|
if (targetWidget.value > max)
|
||||||
targetWidget.value = max;
|
targetWidget.value = max;
|
||||||
targetWidget.callback(targetWidget.value);
|
targetWidget.callback(targetWidget.value);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return widgets;
|
return widgets;
|
||||||
};
|
};
|
||||||
|
|
||||||
function seedWidget(node, inputName, inputData, app) {
|
function seedWidget(node, inputName, inputData, app, widgetName) {
|
||||||
const seed = ComfyWidgets.INT(node, inputName, inputData, app);
|
const seed = createIntWidget(node, inputName, inputData, app, true);
|
||||||
const seedControl = addValueControlWidget(node, seed.widget, "randomize");
|
const seedControl = addValueControlWidget(node, seed.widget, "randomize", undefined, widgetName, inputData);
|
||||||
|
|
||||||
seed.widget.linkedWidgets = [seedControl];
|
seed.widget.linkedWidgets = [seedControl];
|
||||||
return seed;
|
return seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createIntWidget(node, inputName, inputData, app, isSeedInput) {
|
||||||
|
const control = inputData[1]?.control_after_generate;
|
||||||
|
if (!isSeedInput && control) {
|
||||||
|
return seedWidget(node, inputName, inputData, app, typeof control === "string" ? control : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let widgetType = isSlider(inputData[1]["display"], app);
|
||||||
|
const { val, config } = getNumberDefaults(inputData, 1, 0, true);
|
||||||
|
Object.assign(config, { precision: 0 });
|
||||||
|
return {
|
||||||
|
widget: node.addWidget(
|
||||||
|
widgetType,
|
||||||
|
inputName,
|
||||||
|
val,
|
||||||
|
function (v) {
|
||||||
|
const s = this.options.step / 10;
|
||||||
|
this.value = Math.round(v / s) * s;
|
||||||
|
},
|
||||||
|
config
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function addMultilineWidget(node, name, opts, app) {
|
function addMultilineWidget(node, name, opts, app) {
|
||||||
const inputEl = document.createElement("textarea");
|
const inputEl = document.createElement("textarea");
|
||||||
inputEl.className = "comfy-multiline-input";
|
inputEl.className = "comfy-multiline-input";
|
||||||
inputEl.value = opts.defaultVal;
|
inputEl.value = opts.defaultVal;
|
||||||
inputEl.placeholder = opts.placeholder || "";
|
inputEl.placeholder = opts.placeholder || name;
|
||||||
|
|
||||||
const widget = node.addDOMWidget(name, "customtext", inputEl, {
|
const widget = node.addDOMWidget(name, "customtext", inputEl, {
|
||||||
getValue() {
|
getValue() {
|
||||||
|
@ -156,6 +223,10 @@ function addMultilineWidget(node, name, opts, app) {
|
||||||
});
|
});
|
||||||
widget.inputEl = inputEl;
|
widget.inputEl = inputEl;
|
||||||
|
|
||||||
|
inputEl.addEventListener("input", () => {
|
||||||
|
widget.callback?.(widget.value);
|
||||||
|
});
|
||||||
|
|
||||||
return { minWidth: 400, minHeight: 200, widget };
|
return { minWidth: 400, minHeight: 200, widget };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,21 +257,7 @@ export const ComfyWidgets = {
|
||||||
}, config) };
|
}, config) };
|
||||||
},
|
},
|
||||||
INT(node, inputName, inputData, app) {
|
INT(node, inputName, inputData, app) {
|
||||||
let widgetType = isSlider(inputData[1]["display"], app);
|
return createIntWidget(node, inputName, inputData, app);
|
||||||
const { val, config } = getNumberDefaults(inputData, 1, 0, true);
|
|
||||||
Object.assign(config, { precision: 0 });
|
|
||||||
return {
|
|
||||||
widget: node.addWidget(
|
|
||||||
widgetType,
|
|
||||||
inputName,
|
|
||||||
val,
|
|
||||||
function (v) {
|
|
||||||
const s = this.options.step / 10;
|
|
||||||
this.value = Math.round(v / s) * s;
|
|
||||||
},
|
|
||||||
config
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
BOOLEAN(node, inputName, inputData) {
|
BOOLEAN(node, inputName, inputData) {
|
||||||
let defaultVal = false;
|
let defaultVal = false;
|
||||||
|
@ -245,10 +302,14 @@ export const ComfyWidgets = {
|
||||||
if (inputData[1] && inputData[1].default) {
|
if (inputData[1] && inputData[1].default) {
|
||||||
defaultValue = inputData[1].default;
|
defaultValue = inputData[1].default;
|
||||||
}
|
}
|
||||||
return { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) };
|
const res = { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) };
|
||||||
|
if (inputData[1]?.control_after_generate) {
|
||||||
|
res.widget.linkedWidgets = addValueControlWidgets(node, res.widget, undefined, undefined, inputData);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
IMAGEUPLOAD(node, inputName, inputData, app) {
|
IMAGEUPLOAD(node, inputName, inputData, app) {
|
||||||
const imageWidget = node.widgets.find((w) => w.name === "image");
|
const imageWidget = node.widgets.find((w) => w.name === (inputData[1]?.widget ?? "image"));
|
||||||
let uploadWidget;
|
let uploadWidget;
|
||||||
|
|
||||||
function showImage(name) {
|
function showImage(name) {
|
||||||
|
@ -362,9 +423,10 @@ export const ComfyWidgets = {
|
||||||
document.body.append(fileInput);
|
document.body.append(fileInput);
|
||||||
|
|
||||||
// Create the button widget for selecting the files
|
// Create the button widget for selecting the files
|
||||||
uploadWidget = node.addWidget("button", "choose file to upload", "image", () => {
|
uploadWidget = node.addWidget("button", inputName, "image", () => {
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
});
|
});
|
||||||
|
uploadWidget.label = "choose file to upload";
|
||||||
uploadWidget.serialize = false;
|
uploadWidget.serialize = false;
|
||||||
|
|
||||||
// Add handler to check if an image is being dragged over our node
|
// Add handler to check if an image is being dragged over our node
|
||||||
|
|
Loading…
Reference in New Issue