Unverified Commit c92f3dca authored by Jairo Correa's avatar Jairo Correa Committed by GitHub
Browse files

Merge branch 'master' into image-cache

parents 006b24cc 2995a247
...@@ -150,7 +150,7 @@ export class EzNodeMenuItem { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 = { ...@@ -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
......
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, preSetup?(app): Promise<void> } } config
* @returns * @returns
*/ */
export async function start(config = undefined) { export async function start(config = {}) {
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");
config.preSetup?.(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 +32,10 @@ export async function checkBeforeAndAfterReload(graph, cb) { ...@@ -24,10 +32,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 +45,19 @@ export function makeNodeDef(name, input, output = {}) { ...@@ -37,19 +45,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 +76,31 @@ export function assertNotNullOrUndefined(x) { ...@@ -68,4 +76,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;
} }
\ No newline at end of file
/**
*
* @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 } = {}) { ...@@ -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),
};
}, },
})); }));
} }
...@@ -174,6 +174,213 @@ const colorPalettes = { ...@@ -174,6 +174,213 @@ const colorPalettes = {
"tr-odd-bg-color": "#073642", "tr-odd-bg-color": "#073642",
} }
}, },
},
"arc": {
"id": "arc",
"name": "Arc",
"colors": {
"node_slot": {
"BOOLEAN": "",
"CLIP": "#eacb8b",
"CLIP_VISION": "#A8DADC",
"CLIP_VISION_OUTPUT": "#ad7452",
"CONDITIONING": "#cf876f",
"CONTROL_NET": "#00d78d",
"CONTROL_NET_WEIGHTS": "",
"FLOAT": "",
"GLIGEN": "",
"IMAGE": "#80a1c0",
"IMAGEUPLOAD": "",
"INT": "",
"LATENT": "#b38ead",
"LATENT_KEYFRAME": "",
"MASK": "#a3bd8d",
"MODEL": "#8978a7",
"SAMPLER": "",
"SIGMAS": "",
"STRING": "",
"STYLE_MODEL": "#C2FFAE",
"T2I_ADAPTER_WEIGHTS": "",
"TAESD": "#DCC274",
"TIMESTEP_KEYFRAME": "",
"UPSCALE_MODEL": "",
"VAE": "#be616b"
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAAsTAAALEwEAmpwYAAABcklEQVR4nO3YMUoDARgF4RfxBqZI6/0vZqFn0MYtrLIQMFN8U6V4LAtD+Jm9XG/v30OGl2e/AP7yevz4+vx45nvgF/+QGITEICQGITEIiUFIjNNC3q43u3/YnRJyPOzeQ+0e220nhRzReC8e7R7bbdvl+Jal1Bs46jEIiUFIDEJiEBKDkBhKPbZT6qHdptRTu02p53DUYxASg5AYhMQgJAYhMZR6bKfUQ7tNqad2m1LP4ajHICQGITEIiUFIDEJiKPXYTqmHdptST+02pZ7DUY9BSAxCYhASg5AYhMRQ6rGdUg/tNqWe2m1KPYejHoOQGITEICQGITEIiaHUYzulHtptSj2125R6Dkc9BiExCIlBSAxCYhASQ6nHdko9tNuUemq3KfUcjnoMQmIQEoOQGITEICSGUo/tlHpotyn11G5T6jkc9RiExCAkBiExCIlBSAylHtsp9dBuU+qp3abUczjqMQiJQUgMQmIQEoOQGITE+AHFISNQrFTGuwAAAABJRU5ErkJggg==",
"CLEAR_BACKGROUND_COLOR": "#2b2f38",
"NODE_TITLE_COLOR": "#b2b7bd",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2b2f38",
"NODE_DEFAULT_BGCOLOR": "#242730",
"NODE_DEFAULT_BOXCOLOR": "#6e7581",
"NODE_DEFAULT_SHAPE": "box",
"NODE_BOX_OUTLINE_COLOR": "#FFF",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 22,
"WIDGET_BGCOLOR": "#2b2f38",
"WIDGET_OUTLINE_COLOR": "#6e7581",
"WIDGET_TEXT_COLOR": "#DDD",
"WIDGET_SECONDARY_TEXT_COLOR": "#b2b7bd",
"LINK_COLOR": "#9A9",
"EVENT_LINK_COLOR": "#A86",
"CONNECTING_LINK_COLOR": "#AFA"
},
"comfy_base": {
"fg-color": "#fff",
"bg-color": "#2b2f38",
"comfy-menu-bg": "#242730",
"comfy-input-bg": "#2b2f38",
"input-text": "#ddd",
"descrip-text": "#b2b7bd",
"drag-text": "#ccc",
"error-text": "#ff4444",
"border-color": "#6e7581",
"tr-even-bg-color": "#2b2f38",
"tr-odd-bg-color": "#242730"
}
},
},
"nord": {
"id": "nord",
"name": "Nord",
"colors": {
"node_slot": {
"BOOLEAN": "",
"CLIP": "#eacb8b",
"CLIP_VISION": "#A8DADC",
"CLIP_VISION_OUTPUT": "#ad7452",
"CONDITIONING": "#cf876f",
"CONTROL_NET": "#00d78d",
"CONTROL_NET_WEIGHTS": "",
"FLOAT": "",
"GLIGEN": "",
"IMAGE": "#80a1c0",
"IMAGEUPLOAD": "",
"INT": "",
"LATENT": "#b38ead",
"LATENT_KEYFRAME": "",
"MASK": "#a3bd8d",
"MODEL": "#8978a7",
"SAMPLER": "",
"SIGMAS": "",
"STRING": "",
"STYLE_MODEL": "#C2FFAE",
"T2I_ADAPTER_WEIGHTS": "",
"TAESD": "#DCC274",
"TIMESTEP_KEYFRAME": "",
"UPSCALE_MODEL": "",
"VAE": "#be616b"
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFu2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUwNDFhMmZjLTEzNzQtMTk0ZC1hZWY4LTYxMzM1MTVmNjUwMCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoyMzFiMTBiMC1iNGZiLTAyNGUtYjEyZS0zMDUzMDNjZDA3YzgiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoyMzFiMTBiMC1iNGZiLTAyNGUtYjEyZS0zMDUzMDNjZDA3YzgiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1MDQxYTJmYy0xMzc0LTE5NGQtYWVmOC02MTMzNTE1ZjY1MDAiIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz73jWg/AAAAyUlEQVR42u3WKwoAIBRFQRdiMb1idv9Lsxn9gEFw4Dbb8JCTojbbXEJwjJVL2HKwYMGCBQuWLbDmjr+9zrBGjHl1WVcvy2DBggULFizTWQpewSt4HzwsgwULFiwFr7MUvMtS8D54WLBgGSxYCl7BK3iXZbBgwYIFC5bpLAWv4BW8Dx6WwYIFC5aC11kK3mUpeB88LFiwDBYsBa/gFbzLMliwYMGCBct0loJX8AreBw/LYMGCBUvB6ywF77IUvA8eFixYBgsWrNfWAZPltufdad+1AAAAAElFTkSuQmCC",
"CLEAR_BACKGROUND_COLOR": "#212732",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2e3440",
"NODE_DEFAULT_BGCOLOR": "#161b22",
"NODE_DEFAULT_BOXCOLOR": "#545d70",
"NODE_DEFAULT_SHAPE": "box",
"NODE_BOX_OUTLINE_COLOR": "#e5eaf0",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#2e3440",
"WIDGET_OUTLINE_COLOR": "#545d70",
"WIDGET_TEXT_COLOR": "#bcc2c8",
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
"LINK_COLOR": "#9A9",
"EVENT_LINK_COLOR": "#A86",
"CONNECTING_LINK_COLOR": "#AFA"
},
"comfy_base": {
"fg-color": "#e5eaf0",
"bg-color": "#2e3440",
"comfy-menu-bg": "#161b22",
"comfy-input-bg": "#2e3440",
"input-text": "#bcc2c8",
"descrip-text": "#999",
"drag-text": "#ccc",
"error-text": "#ff4444",
"border-color": "#545d70",
"tr-even-bg-color": "#2e3440",
"tr-odd-bg-color": "#161b22"
}
},
},
"github": {
"id": "github",
"name": "Github",
"colors": {
"node_slot": {
"BOOLEAN": "",
"CLIP": "#eacb8b",
"CLIP_VISION": "#A8DADC",
"CLIP_VISION_OUTPUT": "#ad7452",
"CONDITIONING": "#cf876f",
"CONTROL_NET": "#00d78d",
"CONTROL_NET_WEIGHTS": "",
"FLOAT": "",
"GLIGEN": "",
"IMAGE": "#80a1c0",
"IMAGEUPLOAD": "",
"INT": "",
"LATENT": "#b38ead",
"LATENT_KEYFRAME": "",
"MASK": "#a3bd8d",
"MODEL": "#8978a7",
"SAMPLER": "",
"SIGMAS": "",
"STRING": "",
"STYLE_MODEL": "#C2FFAE",
"T2I_ADAPTER_WEIGHTS": "",
"TAESD": "#DCC274",
"TIMESTEP_KEYFRAME": "",
"UPSCALE_MODEL": "",
"VAE": "#be616b"
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGlmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmIyYzRhNjA5LWJmYTctYTg0MC1iOGFlLTk3MzE2ZjM1ZGIyNyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjk0ZmNlZGU4LTE1MTctZmQ0MC04ZGU3LWYzOTgxM2E3ODk5ZiIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MjMxYjEwYjAtYjRmYi0wMjRlLWIxMmUtMzA1MzAzY2QwN2M4IiBzdEV2dDp3aGVuPSIyMDIzLTExLTEzVDAwOjE4OjAyKzAxOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjUuMSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjQ4OWY1NzlmLTJkNjUtZWQ0Zi04OTg0LTA4NGE2MGE1ZTMzNSIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xNVQwMjowNDo1OSswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMmM0YTYwOS1iZmE3LWE4NDAtYjhhZS05NzMxNmYzNWRiMjciIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4OTe6GAAAAx0lEQVR42u3WMQoAIQxFwRzJys77X8vSLiRgITif7bYbgrwYc/mKXyBoY4VVBgsWLFiwYFmOlTv+9jfDOjHmr8u6eVkGCxYsWLBgmc5S8ApewXvgYRksWLBgKXidpeBdloL3wMOCBctgwVLwCl7BuyyDBQsWLFiwTGcpeAWv4D3wsAwWLFiwFLzOUvAuS8F74GHBgmWwYCl4Ba/gXZbBggULFixYprMUvIJX8B54WAYLFixYCl5nKXiXpeA98LBgwTJYsGC9tg1o8f4TTtqzNQAAAABJRU5ErkJggg==",
"CLEAR_BACKGROUND_COLOR": "#040506",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#161b22",
"NODE_DEFAULT_BGCOLOR": "#13171d",
"NODE_DEFAULT_BOXCOLOR": "#30363d",
"NODE_DEFAULT_SHAPE": "box",
"NODE_BOX_OUTLINE_COLOR": "#e5eaf0",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#161b22",
"WIDGET_OUTLINE_COLOR": "#30363d",
"WIDGET_TEXT_COLOR": "#bcc2c8",
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
"LINK_COLOR": "#9A9",
"EVENT_LINK_COLOR": "#A86",
"CONNECTING_LINK_COLOR": "#AFA"
},
"comfy_base": {
"fg-color": "#e5eaf0",
"bg-color": "#161b22",
"comfy-menu-bg": "#13171d",
"comfy-input-bg": "#161b22",
"input-text": "#bcc2c8",
"descrip-text": "#999",
"drag-text": "#ccc",
"error-text": "#ff4444",
"border-color": "#30363d",
"tr-even-bg-color": "#161b22",
"tr-odd-bg-color": "#13171d"
}
},
} }
}; };
......
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { mergeIfValid } from "./widgetInputs.js";
const GROUP = Symbol();
const Workflow = {
InUse: {
Free: 0,
Registered: 1,
InWorkflow: 2,
},
isInUseGroupNode(name) {
const id = `workflow/${name}`;
// Check if lready registered/in use in this workflow
if (app.graph.extra?.groupNodes?.[name]) {
if (app.graph._nodes.find((n) => n.type === id)) {
return Workflow.InUse.InWorkflow;
} else {
return Workflow.InUse.Registered;
}
}
return Workflow.InUse.Free;
},
storeGroupNode(name, data) {
let extra = app.graph.extra;
if (!extra) app.graph.extra = extra = {};
let groupNodes = extra.groupNodes;
if (!groupNodes) extra.groupNodes = groupNodes = {};
groupNodes[name] = data;
},
};
class GroupNodeBuilder {
constructor(nodes) {
this.nodes = nodes;
}
build() {
const name = this.getName();
if (!name) return;
// Sort the nodes so they are in execution order
// this allows for widgets to be in the correct order when reconstructing
this.sortNodes();
this.nodeData = this.getNodeData();
Workflow.storeGroupNode(name, this.nodeData);
return { name, nodeData: this.nodeData };
}
getName() {
const name = prompt("Enter group name");
if (!name) return;
const used = Workflow.isInUseGroupNode(name);
switch (used) {
case Workflow.InUse.InWorkflow:
alert(
"An in use group node with this name already exists embedded in this workflow, please remove any instances or use a new name."
);
return;
case Workflow.InUse.Registered:
if (
!confirm(
"An group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?"
)
) {
return;
}
break;
}
return name;
}
sortNodes() {
// Gets the builders nodes in graph execution order
const nodesInOrder = app.graph.computeExecutionOrder(false);
this.nodes = this.nodes
.map((node) => ({ index: nodesInOrder.indexOf(node), node }))
.sort((a, b) => a.index - b.index || a.node.id - b.node.id)
.map(({ node }) => node);
}
getNodeData() {
const storeLinkTypes = (config) => {
// Store link types for dynamically typed nodes e.g. reroutes
for (const link of config.links) {
const origin = app.graph.getNodeById(link[4]);
const type = origin.outputs[link[1]].type;
link.push(type);
}
};
const storeExternalLinks = (config) => {
// Store any external links to the group in the config so when rebuilding we add extra slots
config.external = [];
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i];
if (!node.outputs?.length) continue;
for (let slot = 0; slot < node.outputs.length; slot++) {
let hasExternal = false;
const output = node.outputs[slot];
let type = output.type;
if (!output.links?.length) continue;
for (const l of output.links) {
const link = app.graph.links[l];
if (!link) continue;
if (type === "*") type = link.type;
if (!app.canvas.selected_nodes[link.target_id]) {
hasExternal = true;
break;
}
}
if (hasExternal) {
config.external.push([i, slot, type]);
}
}
}
};
// Use the built in copyToClipboard function to generate the node data we need
const backup = localStorage.getItem("litegrapheditor_clipboard");
try {
app.canvas.copyToClipboard(this.nodes);
const config = JSON.parse(localStorage.getItem("litegrapheditor_clipboard"));
storeLinkTypes(config);
storeExternalLinks(config);
return config;
} finally {
localStorage.setItem("litegrapheditor_clipboard", backup);
}
}
}
export class GroupNodeConfig {
constructor(name, nodeData) {
this.name = name;
this.nodeData = nodeData;
this.getLinks();
this.inputCount = 0;
this.oldToNewOutputMap = {};
this.newToOldOutputMap = {};
this.oldToNewInputMap = {};
this.oldToNewWidgetMap = {};
this.newToOldWidgetMap = {};
this.primitiveDefs = {};
this.widgetToPrimitive = {};
this.primitiveToWidget = {};
}
async registerType(source = "workflow") {
this.nodeDef = {
output: [],
output_name: [],
output_is_list: [],
name: source + "/" + this.name,
display_name: this.name,
category: "group nodes" + ("/" + source),
input: { required: {} },
[GROUP]: this,
};
this.inputs = [];
const seenInputs = {};
const seenOutputs = {};
for (let i = 0; i < this.nodeData.nodes.length; i++) {
const node = this.nodeData.nodes[i];
node.index = i;
this.processNode(node, seenInputs, seenOutputs);
}
await app.registerNodeDef("workflow/" + this.name, this.nodeDef);
}
getLinks() {
this.linksFrom = {};
this.linksTo = {};
this.externalFrom = {};
// Extract links for easy lookup
for (const l of this.nodeData.links) {
const [sourceNodeId, sourceNodeSlot, targetNodeId, targetNodeSlot] = l;
// Skip links outside the copy config
if (sourceNodeId == null) continue;
if (!this.linksFrom[sourceNodeId]) {
this.linksFrom[sourceNodeId] = {};
}
this.linksFrom[sourceNodeId][sourceNodeSlot] = l;
if (!this.linksTo[targetNodeId]) {
this.linksTo[targetNodeId] = {};
}
this.linksTo[targetNodeId][targetNodeSlot] = l;
}
if (this.nodeData.external) {
for (const ext of this.nodeData.external) {
if (!this.externalFrom[ext[0]]) {
this.externalFrom[ext[0]] = { [ext[1]]: ext[2] };
} else {
this.externalFrom[ext[0]][ext[1]] = ext[2];
}
}
}
}
processNode(node, seenInputs, seenOutputs) {
const def = this.getNodeDef(node);
if (!def) return;
const inputs = { ...def.input?.required, ...def.input?.optional };
this.inputs.push(this.processNodeInputs(node, seenInputs, inputs));
if (def.output?.length) this.processNodeOutputs(node, seenOutputs, def);
}
getNodeDef(node) {
const def = globalDefs[node.type];
if (def) return def;
const linksFrom = this.linksFrom[node.index];
if (node.type === "PrimitiveNode") {
// Skip as its not linked
if (!linksFrom) return;
let type = linksFrom["0"][5];
if (type === "COMBO") {
// Use the array items
const source = node.outputs[0].widget.name;
const fromTypeName = this.nodeData.nodes[linksFrom["0"][2]].type;
const fromType = globalDefs[fromTypeName];
const input = fromType.input.required[source] ?? fromType.input.optional[source];
type = input[0];
}
const def = (this.primitiveDefs[node.index] = {
input: {
required: {
value: [type, {}],
},
},
output: [type],
output_name: [],
output_is_list: [],
});
return def;
} else if (node.type === "Reroute") {
const linksTo = this.linksTo[node.index];
if (linksTo && linksFrom && !this.externalFrom[node.index]?.[0]) {
// Being used internally
return null;
}
let rerouteType = "*";
if (linksFrom) {
const [, , id, slot] = linksFrom["0"];
rerouteType = this.nodeData.nodes[id].inputs[slot].type;
} else if (linksTo) {
const [id, slot] = linksTo["0"];
rerouteType = this.nodeData.nodes[id].outputs[slot].type;
} else {
// Reroute used as a pipe
for (const l of this.nodeData.links) {
if (l[2] === node.index) {
rerouteType = l[5];
break;
}
}
if (rerouteType === "*") {
// Check for an external link
const t = this.externalFrom[node.index]?.[0];
if (t) {
rerouteType = t;
}
}
}
return {
input: {
required: {
[rerouteType]: [rerouteType, {}],
},
},
output: [rerouteType],
output_name: [],
output_is_list: [],
};
}
console.warn("Skipping virtual node " + node.type + " when building group node " + this.name);
}
getInputConfig(node, inputName, seenInputs, config, extra) {
let name = node.inputs?.find((inp) => inp.name === inputName)?.label ?? inputName;
let prefix = "";
// Special handling for primitive to include the title if it is set rather than just "value"
if ((node.type === "PrimitiveNode" && node.title) || name in seenInputs) {
prefix = `${node.title ?? node.type} `;
name = `${prefix}${inputName}`;
if (name in seenInputs) {
name = `${prefix}${seenInputs[name]} ${inputName}`;
}
}
seenInputs[name] = (seenInputs[name] ?? 1) + 1;
if (inputName === "seed" || inputName === "noise_seed") {
if (!extra) extra = {};
extra.control_after_generate = `${prefix}control_after_generate`;
}
if (config[0] === "IMAGEUPLOAD") {
if (!extra) extra = {};
extra.widget = `${prefix}${config[1]?.widget ?? "image"}`;
}
if (extra) {
config = [config[0], { ...config[1], ...extra }];
}
return { name, config };
}
processWidgetInputs(inputs, node, inputNames, seenInputs) {
const slots = [];
const converted = new Map();
const widgetMap = (this.oldToNewWidgetMap[node.index] = {});
for (const inputName of inputNames) {
let widgetType = app.getWidgetType(inputs[inputName], inputName);
if (widgetType) {
const convertedIndex = node.inputs?.findIndex(
(inp) => inp.name === inputName && inp.widget?.name === inputName
);
if (convertedIndex > -1) {
// This widget has been converted to a widget
// We need to store this in the correct position so link ids line up
converted.set(convertedIndex, inputName);
widgetMap[inputName] = null;
} else {
// Normal widget
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
this.nodeDef.input.required[name] = config;
widgetMap[inputName] = name;
this.newToOldWidgetMap[name] = { node, inputName };
}
} else {
// Normal input
slots.push(inputName);
}
}
return { converted, slots };
}
checkPrimitiveConnection(link, inputName, inputs) {
const sourceNode = this.nodeData.nodes[link[0]];
if (sourceNode.type === "PrimitiveNode") {
// Merge link configurations
const [sourceNodeId, _, targetNodeId, __] = link;
const primitiveDef = this.primitiveDefs[sourceNodeId];
const targetWidget = inputs[inputName];
const primitiveConfig = primitiveDef.input.required.value;
const output = { widget: primitiveConfig };
const config = mergeIfValid(output, targetWidget, false, null, primitiveConfig);
primitiveConfig[1] = config?.customConfig ?? inputs[inputName][1] ? { ...inputs[inputName][1] } : {};
let name = this.oldToNewWidgetMap[sourceNodeId]["value"];
name = name.substr(0, name.length - 6);
primitiveConfig[1].control_after_generate = true;
primitiveConfig[1].control_prefix = name;
let toPrimitive = this.widgetToPrimitive[targetNodeId];
if (!toPrimitive) {
toPrimitive = this.widgetToPrimitive[targetNodeId] = {};
}
if (toPrimitive[inputName]) {
toPrimitive[inputName].push(sourceNodeId);
}
toPrimitive[inputName] = sourceNodeId;
let toWidget = this.primitiveToWidget[sourceNodeId];
if (!toWidget) {
toWidget = this.primitiveToWidget[sourceNodeId] = [];
}
toWidget.push({ nodeId: targetNodeId, inputName });
}
}
processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
for (let i = 0; i < slots.length; i++) {
const inputName = slots[i];
if (linksTo[i]) {
this.checkPrimitiveConnection(linksTo[i], inputName, inputs);
// This input is linked so we can skip it
continue;
}
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
this.nodeDef.input.required[name] = config;
inputMap[i] = this.inputCount++;
}
}
processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs) {
// Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up
const convertedSlots = [...converted.keys()].sort().map((k) => converted.get(k));
for (let i = 0; i < convertedSlots.length; i++) {
const inputName = convertedSlots[i];
if (linksTo[slots.length + i]) {
this.checkPrimitiveConnection(linksTo[slots.length + i], inputName, inputs);
// This input is linked so we can skip it
continue;
}
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], {
defaultInput: true,
});
this.nodeDef.input.required[name] = config;
inputMap[slots.length + i] = this.inputCount++;
}
}
processNodeInputs(node, seenInputs, inputs) {
const inputMapping = [];
const inputNames = Object.keys(inputs);
if (!inputNames.length) return;
const { converted, slots } = this.processWidgetInputs(inputs, node, inputNames, seenInputs);
const linksTo = this.linksTo[node.index] ?? {};
const inputMap = (this.oldToNewInputMap[node.index] = {});
this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs);
this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs);
return inputMapping;
}
processNodeOutputs(node, seenOutputs, def) {
const oldToNew = (this.oldToNewOutputMap[node.index] = {});
// Add outputs
for (let outputId = 0; outputId < def.output.length; outputId++) {
const linksFrom = this.linksFrom[node.index];
if (linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId]) {
// This output is linked internally so we can skip it
continue;
}
oldToNew[outputId] = this.nodeDef.output.length;
this.newToOldOutputMap[this.nodeDef.output.length] = { node, slot: outputId };
this.nodeDef.output.push(def.output[outputId]);
this.nodeDef.output_is_list.push(def.output_is_list[outputId]);
let label = def.output_name?.[outputId] ?? def.output[outputId];
const output = node.outputs.find((o) => o.name === label);
if (output?.label) {
label = output.label;
}
let name = label;
if (name in seenOutputs) {
const prefix = `${node.title ?? node.type} `;
name = `${prefix}${label}`;
if (name in seenOutputs) {
name = `${prefix}${node.index} ${label}`;
}
}
seenOutputs[name] = 1;
this.nodeDef.output_name.push(name);
}
}
static async registerFromWorkflow(groupNodes, missingNodeTypes) {
for (const g in groupNodes) {
const groupData = groupNodes[g];
let hasMissing = false;
for (const n of groupData.nodes) {
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
missingNodeTypes.push(n.type);
hasMissing = true;
}
}
if (hasMissing) continue;
const config = new GroupNodeConfig(g, groupData);
await config.registerType();
}
}
}
export class GroupNodeHandler {
node;
groupData;
constructor(node) {
this.node = node;
this.groupData = node.constructor?.nodeData?.[GROUP];
this.node.setInnerNodes = (innerNodes) => {
this.innerNodes = innerNodes;
for (let innerNodeIndex = 0; innerNodeIndex < this.innerNodes.length; innerNodeIndex++) {
const innerNode = this.innerNodes[innerNodeIndex];
for (const w of innerNode.widgets ?? []) {
if (w.type === "converted-widget") {
w.serializeValue = w.origSerializeValue;
}
}
innerNode.index = innerNodeIndex;
innerNode.getInputNode = (slot) => {
// Check if this input is internal or external
const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
if (externalSlot != null) {
return this.node.getInputNode(externalSlot);
}
// Internal link
const innerLink = this.groupData.linksTo[innerNode.index]?.[slot];
if (!innerLink) return null;
const inputNode = innerNodes[innerLink[0]];
// Primitives will already apply their values
if (inputNode.type === "PrimitiveNode") return null;
return inputNode;
};
innerNode.getInputLink = (slot) => {
const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
if (externalSlot != null) {
// The inner node is connected via the group node inputs
const linkId = this.node.inputs[externalSlot].link;
let link = app.graph.links[linkId];
// Use the outer link, but update the target to the inner node
link = {
...link,
target_id: innerNode.id,
target_slot: +slot,
};
return link;
}
let link = this.groupData.linksTo[innerNode.index]?.[slot];
if (!link) return null;
// Use the inner link, but update the origin node to be inner node id
link = {
origin_id: innerNodes[link[0]].id,
origin_slot: link[1],
target_id: innerNode.id,
target_slot: +slot,
};
return link;
};
}
};
this.node.updateLink = (link) => {
// Replace the group node reference with the internal node
link = { ...link };
const output = this.groupData.newToOldOutputMap[link.origin_slot];
let innerNode = this.innerNodes[output.node.index];
let l;
while (innerNode.type === "Reroute") {
l = innerNode.getInputLink(0);
innerNode = innerNode.getInputNode(0);
}
link.origin_id = innerNode.id;
link.origin_slot = l?.origin_slot ?? output.slot;
return link;
};
this.node.getInnerNodes = () => {
if (!this.innerNodes) {
this.node.setInnerNodes(
this.groupData.nodeData.nodes.map((n, i) => {
const innerNode = LiteGraph.createNode(n.type);
innerNode.configure(n);
innerNode.id = `${this.node.id}:${i}`;
return innerNode;
})
);
}
this.updateInnerWidgets();
return this.innerNodes;
};
this.node.convertToNodes = () => {
const addInnerNodes = () => {
const backup = localStorage.getItem("litegrapheditor_clipboard");
// Clone the node data so we dont mutate it for other nodes
const c = { ...this.groupData.nodeData };
c.nodes = [...c.nodes];
const innerNodes = this.node.getInnerNodes();
let ids = [];
for (let i = 0; i < c.nodes.length; i++) {
let id = innerNodes?.[i]?.id;
// Use existing IDs if they are set on the inner nodes
if (id == null || isNaN(id)) {
id = undefined;
} else {
ids.push(id);
}
c.nodes[i] = { ...c.nodes[i], id };
}
localStorage.setItem("litegrapheditor_clipboard", JSON.stringify(c));
app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", backup);
const [x, y] = this.node.pos;
let top;
let left;
// Configure nodes with current widget data
const selectedIds = ids.length ? ids : Object.keys(app.canvas.selected_nodes);
const newNodes = [];
for (let i = 0; i < selectedIds.length; i++) {
const id = selectedIds[i];
const newNode = app.graph.getNodeById(id);
const innerNode = innerNodes[i];
newNodes.push(newNode);
if (left == null || newNode.pos[0] < left) {
left = newNode.pos[0];
}
if (top == null || newNode.pos[1] < top) {
top = newNode.pos[1];
}
const map = this.groupData.oldToNewWidgetMap[innerNode.index];
if (map) {
const widgets = Object.keys(map);
for (const oldName of widgets) {
const newName = map[oldName];
if (!newName) continue;
const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
if (widgetIndex === -1) continue;
// Populate the main and any linked widgets
if (innerNode.type === "PrimitiveNode") {
for (let i = 0; i < newNode.widgets.length; i++) {
newNode.widgets[i].value = this.node.widgets[widgetIndex + i].value;
}
} else {
const outerWidget = this.node.widgets[widgetIndex];
const newWidget = newNode.widgets.find((w) => w.name === oldName);
if (!newWidget) continue;
newWidget.value = outerWidget.value;
for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) {
newWidget.linkedWidgets[w].value = outerWidget.linkedWidgets[w].value;
}
}
}
}
}
// Shift each node
for (const newNode of newNodes) {
newNode.pos = [newNode.pos[0] - (left - x), newNode.pos[1] - (top - y)];
}
return { newNodes, selectedIds };
};
const reconnectInputs = (selectedIds) => {
for (const innerNodeIndex in this.groupData.oldToNewInputMap) {
const id = selectedIds[innerNodeIndex];
const newNode = app.graph.getNodeById(id);
const map = this.groupData.oldToNewInputMap[innerNodeIndex];
for (const innerInputId in map) {
const groupSlotId = map[innerInputId];
if (groupSlotId == null) continue;
const slot = node.inputs[groupSlotId];
if (slot.link == null) continue;
const link = app.graph.links[slot.link];
// connect this node output to the input of another node
const originNode = app.graph.getNodeById(link.origin_id);
originNode.connect(link.origin_slot, newNode, +innerInputId);
}
}
};
const reconnectOutputs = () => {
for (let groupOutputId = 0; groupOutputId < node.outputs?.length; groupOutputId++) {
const output = node.outputs[groupOutputId];
if (!output.links) continue;
const links = [...output.links];
for (const l of links) {
const slot = this.groupData.newToOldOutputMap[groupOutputId];
const link = app.graph.links[l];
const targetNode = app.graph.getNodeById(link.target_id);
const newNode = app.graph.getNodeById(selectedIds[slot.node.index]);
newNode.connect(slot.slot, targetNode, link.target_slot);
}
}
};
const { newNodes, selectedIds } = addInnerNodes();
reconnectInputs(selectedIds);
reconnectOutputs(selectedIds);
app.graph.remove(this.node);
return newNodes;
};
const getExtraMenuOptions = this.node.getExtraMenuOptions;
this.node.getExtraMenuOptions = function (_, options) {
getExtraMenuOptions?.apply(this, arguments);
let optionIndex = options.findIndex((o) => o.content === "Outputs");
if (optionIndex === -1) optionIndex = options.length;
else optionIndex++;
options.splice(optionIndex, 0, null, {
content: "Convert to nodes",
callback: () => {
return this.convertToNodes();
},
});
};
// Draw custom collapse icon to identity this as a group
const onDrawTitleBox = this.node.onDrawTitleBox;
this.node.onDrawTitleBox = function (ctx, height, size, scale) {
onDrawTitleBox?.apply(this, arguments);
const fill = ctx.fillStyle;
ctx.beginPath();
ctx.rect(11, -height + 11, 2, 2);
ctx.rect(14, -height + 11, 2, 2);
ctx.rect(17, -height + 11, 2, 2);
ctx.rect(11, -height + 14, 2, 2);
ctx.rect(14, -height + 14, 2, 2);
ctx.rect(17, -height + 14, 2, 2);
ctx.rect(11, -height + 17, 2, 2);
ctx.rect(14, -height + 17, 2, 2);
ctx.rect(17, -height + 17, 2, 2);
ctx.fillStyle = this.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
ctx.fill();
ctx.fillStyle = fill;
};
// Draw progress label
const onDrawForeground = node.onDrawForeground;
const groupData = this.groupData.nodeData;
node.onDrawForeground = function (ctx) {
const r = onDrawForeground?.apply?.(this, arguments);
if (+app.runningNodeId === this.id && this.runningInternalNodeId !== null) {
const n = groupData.nodes[this.runningInternalNodeId];
const message = `Running ${n.title || n.type} (${this.runningInternalNodeId}/${groupData.nodes.length})`;
ctx.save();
ctx.font = "12px sans-serif";
const sz = ctx.measureText(message);
ctx.fillStyle = node.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
ctx.beginPath();
ctx.roundRect(0, -LiteGraph.NODE_TITLE_HEIGHT - 20, sz.width + 12, 20, 5);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.fillText(message, 6, -LiteGraph.NODE_TITLE_HEIGHT - 6);
ctx.restore();
}
};
// Flag this node as needing to be reset
const onExecutionStart = this.node.onExecutionStart;
this.node.onExecutionStart = function () {
this.resetExecution = true;
return onExecutionStart?.apply(this, arguments);
};
function handleEvent(type, getId, getEvent) {
const handler = ({ detail }) => {
const id = getId(detail);
if (!id) return;
const node = app.graph.getNodeById(id);
if (node) return;
const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == id);
if (innerNodeIndex > -1) {
this.node.runningInternalNodeId = innerNodeIndex;
api.dispatchEvent(new CustomEvent(type, { detail: getEvent(detail, this.node.id + "", this.node) }));
}
};
api.addEventListener(type, handler);
return handler;
}
const executing = handleEvent.call(
this,
"executing",
(d) => d,
(d, id, node) => id
);
const executed = handleEvent.call(
this,
"executed",
(d) => d?.node,
(d, id, node) => ({ ...d, node: id, merge: !node.resetExecution })
);
const onRemoved = node.onRemoved;
this.node.onRemoved = function () {
onRemoved?.apply(this, arguments);
api.removeEventListener("executing", executing);
api.removeEventListener("executed", executed);
};
}
updateInnerWidgets() {
for (const newWidgetName in this.groupData.newToOldWidgetMap) {
const newWidget = this.node.widgets.find((w) => w.name === newWidgetName);
if (!newWidget) continue;
const newValue = newWidget.value;
const old = this.groupData.newToOldWidgetMap[newWidgetName];
let innerNode = this.innerNodes[old.node.index];
if (innerNode.type === "PrimitiveNode") {
innerNode.primitiveValue = newValue;
const primitiveLinked = this.groupData.primitiveToWidget[old.node.index];
for (const linked of primitiveLinked) {
const node = this.innerNodes[linked.nodeId];
const widget = node.widgets.find((w) => w.name === linked.inputName);
if (widget) {
widget.value = newValue;
}
}
continue;
}
const widget = innerNode.widgets?.find((w) => w.name === old.inputName);
if (widget) {
widget.value = newValue;
}
}
}
populatePrimitive(node, nodeId, oldName, i, linkedShift) {
// Converted widget, populate primitive if linked
const primitiveId = this.groupData.widgetToPrimitive[nodeId]?.[oldName];
if (primitiveId == null) return;
const targetWidgetName = this.groupData.oldToNewWidgetMap[primitiveId]["value"];
const targetWidgetIndex = this.node.widgets.findIndex((w) => w.name === targetWidgetName);
if (targetWidgetIndex > -1) {
const primitiveNode = this.innerNodes[primitiveId];
let len = primitiveNode.widgets.length;
if (len - 1 !== this.node.widgets[targetWidgetIndex].linkedWidgets?.length) {
// Fallback handling for if some reason the primitive has a different number of widgets
// we dont want to overwrite random widgets, better to leave blank
len = 1;
}
for (let i = 0; i < len; i++) {
this.node.widgets[targetWidgetIndex + i].value = primitiveNode.widgets[i].value;
}
}
}
populateWidgets() {
for (let nodeId = 0; nodeId < this.groupData.nodeData.nodes.length; nodeId++) {
const node = this.groupData.nodeData.nodes[nodeId];
if (!node.widgets_values?.length) continue;
const map = this.groupData.oldToNewWidgetMap[nodeId];
const widgets = Object.keys(map);
let linkedShift = 0;
for (let i = 0; i < widgets.length; i++) {
const oldName = widgets[i];
const newName = map[oldName];
const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
const mainWidget = this.node.widgets[widgetIndex];
if (!newName) {
// New name will be null if its a converted widget
this.populatePrimitive(node, nodeId, oldName, i, linkedShift);
// Find the inner widget and shift by the number of linked widgets as they will have been removed too
const innerWidget = this.innerNodes[nodeId].widgets?.find((w) => w.name === oldName);
linkedShift += innerWidget.linkedWidgets?.length ?? 0;
continue;
}
if (widgetIndex === -1) {
continue;
}
// Populate the main and any linked widget
mainWidget.value = node.widgets_values[i + linkedShift];
for (let w = 0; w < mainWidget.linkedWidgets?.length; w++) {
this.node.widgets[widgetIndex + w + 1].value = node.widgets_values[i + ++linkedShift];
}
}
}
}
replaceNodes(nodes) {
let top;
let left;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (left == null || node.pos[0] < left) {
left = node.pos[0];
}
if (top == null || node.pos[1] < top) {
top = node.pos[1];
}
this.linkOutputs(node, i);
app.graph.remove(node);
}
this.linkInputs();
this.node.pos = [left, top];
}
linkOutputs(originalNode, nodeId) {
if (!originalNode.outputs) return;
for (const output of originalNode.outputs) {
if (!output.links) continue;
// Clone the links as they'll be changed if we reconnect
const links = [...output.links];
for (const l of links) {
const link = app.graph.links[l];
if (!link) continue;
const targetNode = app.graph.getNodeById(link.target_id);
const newSlot = this.groupData.oldToNewOutputMap[nodeId]?.[link.origin_slot];
if (newSlot != null) {
this.node.connect(newSlot, targetNode, link.target_slot);
}
}
}
}
linkInputs() {
for (const link of this.groupData.nodeData.links ?? []) {
const [, originSlot, targetId, targetSlot, actualOriginId] = link;
const originNode = app.graph.getNodeById(actualOriginId);
if (!originNode) continue; // this node is in the group
originNode.connect(originSlot, this.node.id, this.groupData.oldToNewInputMap[targetId][targetSlot]);
}
}
static getGroupData(node) {
return node.constructor?.nodeData?.[GROUP];
}
static isGroupNode(node) {
return !!node.constructor?.nodeData?.[GROUP];
}
static async fromNodes(nodes) {
// Process the nodes into the stored workflow group node data
const builder = new GroupNodeBuilder(nodes);
const res = builder.build();
if (!res) return;
const { name, nodeData } = res;
// Convert this data into a LG node definition and register it
const config = new GroupNodeConfig(name, nodeData);
await config.registerType();
const groupNode = LiteGraph.createNode(`workflow/${name}`);
// Reuse the existing nodes for this instance
groupNode.setInnerNodes(builder.nodes);
groupNode[GROUP].populateWidgets();
app.graph.add(groupNode);
// Remove all converted nodes and relink them
groupNode[GROUP].replaceNodes(builder.nodes);
return groupNode;
}
}
function addConvertToGroupOptions() {
function addOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {});
const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n));
options.splice(index + 1, null, {
content: `Convert to Group Node`,
disabled,
callback: async () => {
return await GroupNodeHandler.fromNodes(selected);
},
});
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = getCanvasMenuOptions.apply(this, arguments);
const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length;
addOption(options, index);
return options;
};
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
const options = getNodeMenuOptions.apply(this, arguments);
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1;
addOption(options, index);
}
return options;
};
}
const id = "Comfy.GroupNode";
let globalDefs;
const ext = {
name: id,
setup() {
addConvertToGroupOptions();
},
async beforeConfigureGraph(graphData, missingNodeTypes) {
const nodes = graphData?.extra?.groupNodes;
if (nodes) {
await GroupNodeConfig.registerFromWorkflow(nodes, missingNodeTypes);
}
},
addCustomNodeDefs(defs) {
// Store this so we can mutate it later with group nodes
globalDefs = defs;
},
nodeCreated(node) {
if (GroupNodeHandler.isGroupNode(node)) {
node[GROUP] = new GroupNodeHandler(node);
}
},
};
app.registerExtension(ext);
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 { ...@@ -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 { ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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",
......
import { app } from "../../scripts/app.js";
const MAX_HISTORY = 50;
let undo = [];
let redo = [];
let activeState = null;
let isOurLoad = false;
function checkState() {
const currentState = app.graph.serialize();
if (!graphEqual(activeState, currentState)) {
undo.push(activeState);
if (undo.length > MAX_HISTORY) {
undo.shift();
}
activeState = clone(currentState);
redo.length = 0;
}
}
const loadGraphData = app.loadGraphData;
app.loadGraphData = async function () {
const v = await loadGraphData.apply(this, arguments);
if (isOurLoad) {
isOurLoad = false;
} else {
checkState();
}
return v;
};
function clone(obj) {
try {
if (typeof structuredClone !== "undefined") {
return structuredClone(obj);
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj));
}
function graphEqual(a, b, root = true) {
if (a === b) return true;
if (typeof a == "object" && a && typeof b == "object" && b) {
const keys = Object.getOwnPropertyNames(a);
if (keys.length != Object.getOwnPropertyNames(b).length) {
return false;
}
for (const key of keys) {
let av = a[key];
let bv = b[key];
if (root && key === "nodes") {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id);
bv = [...bv].sort((a, b) => a.id - b.id);
}
if (!graphEqual(av, bv, false)) {
return false;
}
}
return true;
}
return false;
}
const undoRedo = async (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "y") {
const prevState = redo.pop();
if (prevState) {
undo.push(activeState);
isOurLoad = true;
await app.loadGraphData(prevState);
activeState = prevState;
}
return true;
} else if (e.key === "z") {
const prevState = undo.pop();
if (prevState) {
redo.push(activeState);
isOurLoad = true;
await app.loadGraphData(prevState);
activeState = prevState;
}
return true;
}
}
};
const bindInput = (activeEl) => {
if (activeEl?.tagName !== "CANVAS" && activeEl?.tagName !== "BODY") {
for (const evt of ["change", "input", "blur"]) {
if (`on${evt}` in activeEl) {
const listener = () => {
checkState();
activeEl.removeEventListener(evt, listener);
};
activeEl.addEventListener(evt, listener);
return true;
}
}
}
};
window.addEventListener(
"keydown",
(e) => {
requestAnimationFrame(async () => {
const activeEl = document.activeElement;
if (activeEl?.tagName === "INPUT" || activeEl?.type === "textarea") {
// Ignore events on inputs, they have their native history
return;
}
// Check if this is a ctrl+z ctrl+y
if (await undoRedo(e)) return;
// If our active element is some type of input then handle changes after they're done
if (bindInput(activeEl)) return;
checkState();
});
},
true
);
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener("mouseup", () => {
checkState();
});
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, arguments);
checkState();
return v;
};
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, arguments);
checkState();
return v;
};
import { ComfyWidgets, addValueControlWidget } from "../../scripts/widgets.js"; import { ComfyWidgets, addValueControlWidgets } from "../../scripts/widgets.js";
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
const CONVERTED_TYPE = "converted-widget"; const CONVERTED_TYPE = "converted-widget";
...@@ -121,6 +121,110 @@ function isValidCombo(combo, obj) { ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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,16 @@ app.registerExtension({ ...@@ -462,12 +570,16 @@ 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";
} }
addValueControlWidget(this, widget, control_value); addValueControlWidgets(this, widget, control_value, undefined, inputData);
let filter = this.widgets_values?.[2];
if(filter && this.widgets.length === 3) {
this.widgets[2].value = filter;
}
} }
// When our value changes, update other widgets to reflect our changes // When our value changes, update other widgets to reflect our changes
...@@ -503,6 +615,7 @@ app.registerExtension({ ...@@ -503,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() {
...@@ -543,108 +656,8 @@ app.registerExtension({ ...@@ -543,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() {
......
...@@ -2533,7 +2533,7 @@ ...@@ -2533,7 +2533,7 @@
var w = this.widgets[i]; var w = this.widgets[i];
if(!w) if(!w)
continue; continue;
if(w.options && w.options.property && this.properties[ w.options.property ]) if(w.options && w.options.property && (this.properties[ w.options.property ] != undefined))
w.value = JSON.parse( JSON.stringify( this.properties[ w.options.property ] ) ); w.value = JSON.parse( JSON.stringify( this.properties[ w.options.property ] ) );
} }
if (info.widgets_values) { if (info.widgets_values) {
...@@ -5714,10 +5714,10 @@ LGraphNode.prototype.executeAction = function(action) ...@@ -5714,10 +5714,10 @@ LGraphNode.prototype.executeAction = function(action)
* @method enableWebGL * @method enableWebGL
**/ **/
LGraphCanvas.prototype.enableWebGL = function() { LGraphCanvas.prototype.enableWebGL = function() {
if (typeof GL === undefined) { if (typeof GL === "undefined") {
throw "litegl.js must be included to use a WebGL canvas"; throw "litegl.js must be included to use a WebGL canvas";
} }
if (typeof enableWebGLCanvas === undefined) { if (typeof enableWebGLCanvas === "undefined") {
throw "webglCanvas.js must be included to use this feature"; throw "webglCanvas.js must be included to use this feature";
} }
...@@ -7110,15 +7110,16 @@ LGraphNode.prototype.executeAction = function(action) ...@@ -7110,15 +7110,16 @@ LGraphNode.prototype.executeAction = function(action)
} }
}; };
LGraphCanvas.prototype.copyToClipboard = function() { LGraphCanvas.prototype.copyToClipboard = function(nodes) {
var clipboard_info = { var clipboard_info = {
nodes: [], nodes: [],
links: [] links: []
}; };
var index = 0; var index = 0;
var selected_nodes_array = []; var selected_nodes_array = [];
for (var i in this.selected_nodes) { if (!nodes) nodes = this.selected_nodes;
var node = this.selected_nodes[i]; for (var i in nodes) {
var node = nodes[i];
if (node.clonable === false) if (node.clonable === false)
continue; continue;
node._relative_id = index; node._relative_id = index;
...@@ -11702,7 +11703,7 @@ LGraphNode.prototype.executeAction = function(action) ...@@ -11702,7 +11703,7 @@ LGraphNode.prototype.executeAction = function(action)
default: default:
iS = 0; // try with first if no name set iS = 0; // try with first if no name set
} }
if (typeof options.node_from.outputs[iS] !== undefined){ if (typeof options.node_from.outputs[iS] !== "undefined"){
if (iS!==false && iS>-1){ if (iS!==false && iS>-1){
options.node_from.connectByType( iS, node, options.node_from.outputs[iS].type ); options.node_from.connectByType( iS, node, options.node_from.outputs[iS].type );
} }
...@@ -11730,7 +11731,7 @@ LGraphNode.prototype.executeAction = function(action) ...@@ -11730,7 +11731,7 @@ LGraphNode.prototype.executeAction = function(action)
default: default:
iS = 0; // try with first if no name set iS = 0; // try with first if no name set
} }
if (typeof options.node_to.inputs[iS] !== undefined){ if (typeof options.node_to.inputs[iS] !== "undefined"){
if (iS!==false && iS>-1){ if (iS!==false && iS>-1){
// try connection // try connection
options.node_to.connectByTypeOutput(iS,node,options.node_to.inputs[iS].type); options.node_to.connectByTypeOutput(iS,node,options.node_to.inputs[iS].type);
......
...@@ -254,9 +254,9 @@ class ComfyApi extends EventTarget { ...@@ -254,9 +254,9 @@ class ComfyApi extends EventTarget {
* Gets the prompt execution history * Gets the prompt execution history
* @returns Prompt history including node outputs * @returns Prompt history including node outputs
*/ */
async getHistory() { async getHistory(max_items=200) {
try { try {
const res = await this.fetchApi("/history"); const res = await this.fetchApi(`/history?max_items=${max_items}`);
return { History: Object.values(await res.json()) }; return { History: Object.values(await res.json()) };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
......
...@@ -4,7 +4,10 @@ import { ComfyUI, $el } from "./ui.js"; ...@@ -4,7 +4,10 @@ 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";
import { getPngMetadata, getWebpMetadata, importA1111, getLatentMetadata } from "./pnginfo.js"; import { getPngMetadata, getWebpMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
import { addDomClippingSetting } from "./domWidget.js";
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js"
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"
function sanitizeNodeName(string) { function sanitizeNodeName(string) {
let entityMap = { let entityMap = {
...@@ -409,7 +412,9 @@ export class ComfyApp { ...@@ -409,7 +412,9 @@ export class ComfyApp {
return shiftY; return shiftY;
} }
node.prototype.setSizeForImage = function () { node.prototype.setSizeForImage = function (force) {
if(!force && this.animatedImages) return;
if (this.inputHeight) { if (this.inputHeight) {
this.setSize(this.size); this.setSize(this.size);
return; return;
...@@ -426,13 +431,20 @@ export class ComfyApp { ...@@ -426,13 +431,20 @@ export class ComfyApp {
let imagesChanged = false let imagesChanged = false
const output = app.nodeOutputs[this.id + ""]; const output = app.nodeOutputs[this.id + ""];
if (output && output.images) { if (output?.images) {
this.animatedImages = output?.animated?.find(Boolean);
if (this.images !== output.images) { if (this.images !== output.images) {
this.images = output.images; this.images = output.images;
imagesChanged = true; imagesChanged = true;
imgURLs = imgURLs.concat(output.images.map(params => { imgURLs = imgURLs.concat(
return api.apiURL("/view?" + new URLSearchParams(params).toString() + app.getPreviewFormatParam() + app.getRandParam()); output.images.map((params) => {
})) return api.apiURL(
"/view?" +
new URLSearchParams(params).toString() +
(this.animatedImages ? "" : app.getPreviewFormatParam()) + app.getRandParam()
);
})
);
} }
} }
...@@ -511,7 +523,35 @@ export class ComfyApp { ...@@ -511,7 +523,35 @@ export class ComfyApp {
return true; return true;
} }
if (this.imgs && this.imgs.length) { if (this.imgs?.length) {
const widgetIdx = this.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET);
if(this.animatedImages) {
// Instead of using the canvas we'll use a IMG
if(widgetIdx > -1) {
// Replace content
const widget = this.widgets[widgetIdx];
widget.options.host.updateImages(this.imgs);
} else {
const host = createImageHost(this);
this.setSizeForImage(true);
const widget = this.addDOMWidget(ANIM_PREVIEW_WIDGET, "img", host.el, {
host,
getHeight: host.getHeight,
onDraw: host.onDraw,
hideOnZoom: false
});
widget.serializeValue = () => undefined;
widget.options.host.updateImages(this.imgs);
}
return;
}
if (widgetIdx > -1) {
this.widgets[widgetIdx].onRemove?.();
this.widgets.splice(widgetIdx, 1);
}
const canvas = app.graph.list_of_graphcanvas[0]; const canvas = app.graph.list_of_graphcanvas[0];
const mouse = canvas.graph_mouse; const mouse = canvas.graph_mouse;
if (!canvas.pointer_is_down && this.pointerDown) { if (!canvas.pointer_is_down && this.pointerDown) {
...@@ -551,31 +591,7 @@ export class ComfyApp { ...@@ -551,31 +591,7 @@ export class ComfyApp {
} }
else { else {
cell_padding = 0; cell_padding = 0;
let best = 0; ({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(this.imgs, dw, dh));
let w = this.imgs[0].naturalWidth;
let h = this.imgs[0].naturalHeight;
// compact style
for (let c = 1; c <= numImages; c++) {
const rows = Math.ceil(numImages / c);
const cW = dw / c;
const cH = dh / rows;
const scaleX = cW / w;
const scaleY = cH / h;
const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale;
const imageH = h * scale;
const area = imageW * imageH * numImages;
if (area > best) {
best = area;
cellWidth = imageW;
cellHeight = imageH;
cols = c;
shiftX = c * ((cW - imageW) / 2);
}
}
} }
let anyHovered = false; let anyHovered = false;
...@@ -767,7 +783,7 @@ export class ComfyApp { ...@@ -767,7 +783,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;
...@@ -815,7 +831,7 @@ export class ComfyApp { ...@@ -815,7 +831,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") {
...@@ -1165,7 +1181,19 @@ export class ComfyApp { ...@@ -1165,7 +1181,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)
...@@ -1276,9 +1304,11 @@ export class ComfyApp { ...@@ -1276,9 +1304,11 @@ export class ComfyApp {
canvasEl.tabIndex = "1"; canvasEl.tabIndex = "1";
document.body.prepend(canvasEl); document.body.prepend(canvasEl);
addDomClippingSetting();
this.#addProcessMouseHandler(); this.#addProcessMouseHandler();
this.#addProcessKeyHandler(); this.#addProcessKeyHandler();
this.#addConfigureHandler(); this.#addConfigureHandler();
this.#addApiUpdateHandlers();
this.graph = new LGraph(); this.graph = new LGraph();
...@@ -1315,7 +1345,7 @@ export class ComfyApp { ...@@ -1315,7 +1345,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) {
...@@ -1324,7 +1354,7 @@ export class ComfyApp { ...@@ -1324,7 +1354,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
...@@ -1332,7 +1362,6 @@ export class ComfyApp { ...@@ -1332,7 +1362,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();
...@@ -1352,11 +1381,95 @@ export class ComfyApp { ...@@ -1352,11 +1381,95 @@ export class ComfyApp {
await this.#invokeExtensionsAsync("registerCustomNodes"); await this.#invokeExtensionsAsync("registerCustomNodes");
} }
getWidgetType(inputData, inputName) {
const type = inputData[0];
if (Array.isArray(type)) {
return "COMBO";
} else if (`${type}:${inputName}` in this.widgets) {
return `${type}:${inputName}`;
} else if (type in this.widgets) {
return type;
} else {
return null;
}
}
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 = self.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)
...@@ -1364,75 +1477,7 @@ export class ComfyApp { ...@@ -1364,75 +1477,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;
} }
} }
...@@ -1475,9 +1520,14 @@ export class ComfyApp { ...@@ -1475,9 +1520,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,
...@@ -1488,31 +1538,35 @@ export class ComfyApp { ...@@ -1488,31 +1538,35 @@ 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;
if (!graphData) { if (!graphData) {
if (typeof structuredClone === "undefined") graphData = defaultGraph;
{
graphData = JSON.parse(JSON.stringify(defaultGraph));
}else
{
graphData = structuredClone(defaultGraph);
}
reset_invalid_values = true; reset_invalid_values = true;
} }
if (typeof structuredClone === "undefined")
{
graphData = JSON.parse(JSON.stringify(graphData));
}else
{
graphData = structuredClone(graphData);
}
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";
if (n.type == "ConditioningAverage ") n.type = "ConditioningAverage"; //typo fix if (n.type == "ConditioningAverage ") n.type = "ConditioningAverage"; //typo fix
if (n.type == "SDV_img2vid_Conditioning") n.type = "SVD_img2vid_Conditioning"; //typo fix
// 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);
} }
} }
...@@ -1604,6 +1658,7 @@ export class ComfyApp { ...@@ -1604,6 +1658,7 @@ export class ComfyApp {
if (missingNodeTypes.length) { if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes); this.showMissingNodesError(missingNodeTypes);
} }
await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
} }
/** /**
...@@ -1611,92 +1666,98 @@ export class ComfyApp { ...@@ -1611,92 +1666,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) { if (node.isVirtualNode) {
continue; continue;
} }
if (node.mode === 2 || node.mode === 4) { if (node.mode === 2 || node.mode === 4) {
// Don't serialize muted nodes // Don't serialize muted nodes
continue; continue;
} }
const inputs = {}; const inputs = {};
const widgets = node.widgets; const widgets = node.widgets;
// Store all widget values // Store all widget values
if (widgets) { if (widgets) {
for (const i in widgets) { for (const i in widgets) {
const widget = widgets[i]; const widget = widgets[i];
if (!widget.options || widget.options.serialize !== false) { if (!widget.options || widget.options.serialize !== false) {
inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(n, i) : widget.value; inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(node, 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; found = true;
}
} }
} } else if (link && parent.mode === 4) {
} else if (link && parent.mode === 4) { let all_inputs = [link.origin_slot];
let all_inputs = [link.origin_slot]; if (parent.inputs) {
if (parent.inputs) { all_inputs = all_inputs.concat(Object.keys(parent.inputs))
all_inputs = all_inputs.concat(Object.keys(parent.inputs)) for (let parent_input in all_inputs) {
for (let parent_input in all_inputs) { parent_input = all_inputs[parent_input];
parent_input = all_inputs[parent_input]; if (parent.inputs[parent_input]?.type === node.inputs[i].type) {
if (parent.inputs[parent_input]?.type === node.inputs[i].type) { link = parent.getInputLink(parent_input);
link = parent.getInputLink(parent_input); if (link) {
if (link) { parent = parent.getInputNode(parent_input);
parent = parent.getInputNode(parent_input); }
found = true;
break;
} }
found = true;
break;
} }
} }
} }
}
if (!found) { if (!found) {
break; break;
}
} }
}
if (link) { if (link) {
inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)]; if (parent?.updateLink) {
link = parent.updateLink(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
...@@ -1816,7 +1877,7 @@ export class ComfyApp { ...@@ -1816,7 +1877,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);
} }
...@@ -1832,21 +1893,21 @@ export class ComfyApp { ...@@ -1832,21 +1893,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));
} }
} }
} }
...@@ -1867,7 +1928,7 @@ export class ComfyApp { ...@@ -1867,7 +1928,7 @@ export class ComfyApp {
for (const id of ids) { for (const id of ids) {
const data = apiData[id]; const data = apiData[id];
const node = LiteGraph.createNode(data.class_type); const node = LiteGraph.createNode(data.class_type);
node.id = id; node.id = isNaN(+id) ? id : +id;
graph.add(node); graph.add(node);
} }
......
import { app, ANIM_PREVIEW_WIDGET } from "./app.js";
const SIZE = Symbol();
function intersect(a, b) {
const x = Math.max(a.x, b.x);
const num1 = Math.min(a.x + a.width, b.x + b.width);
const y = Math.max(a.y, b.y);
const num2 = Math.min(a.y + a.height, b.y + b.height);
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y];
else return null;
}
function getClipPath(node, element, elRect) {
const selectedNode = Object.values(app.canvas.selected_nodes)[0];
if (selectedNode && selectedNode !== node) {
const MARGIN = 7;
const scale = app.canvas.ds.scale;
const bounding = selectedNode.getBounding();
const intersection = intersect(
{ x: elRect.x / scale, y: elRect.y / scale, width: elRect.width / scale, height: elRect.height / scale },
{
x: selectedNode.pos[0] + app.canvas.ds.offset[0] - MARGIN,
y: selectedNode.pos[1] + app.canvas.ds.offset[1] - LiteGraph.NODE_TITLE_HEIGHT - MARGIN,
width: bounding[2] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN,
}
);
if (!intersection) {
return "";
}
const widgetRect = element.getBoundingClientRect();
const clipX = intersection[0] - widgetRect.x / scale + "px";
const clipY = intersection[1] - widgetRect.y / scale + "px";
const clipWidth = intersection[2] + "px";
const clipHeight = intersection[3] + "px";
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
return path;
}
return "";
}
function computeSize(size) {
if (this.widgets?.[0]?.last_y == null) return;
let y = this.widgets[0].last_y;
let freeSpace = size[1] - y;
let widgetHeight = 0;
let dom = [];
for (const w of this.widgets) {
if (w.type === "converted-widget") {
// Ignore
delete w.computedHeight;
} else if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else if (w.element) {
// Extract DOM widget size info
const styles = getComputedStyle(w.element);
let minHeight = w.options.getMinHeight?.() ?? parseInt(styles.getPropertyValue("--comfy-widget-min-height"));
let maxHeight = w.options.getMaxHeight?.() ?? parseInt(styles.getPropertyValue("--comfy-widget-max-height"));
let prefHeight = w.options.getHeight?.() ?? styles.getPropertyValue("--comfy-widget-height");
if (prefHeight.endsWith?.("%")) {
prefHeight = size[1] * (parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100);
} else {
prefHeight = parseInt(prefHeight);
if (isNaN(minHeight)) {
minHeight = prefHeight;
}
}
if (isNaN(minHeight)) {
minHeight = 50;
}
if (!isNaN(maxHeight)) {
if (!isNaN(prefHeight)) {
prefHeight = Math.min(prefHeight, maxHeight);
} else {
prefHeight = maxHeight;
}
}
dom.push({
minHeight,
prefHeight,
w,
});
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
freeSpace -= widgetHeight;
// Calculate sizes with all widgets at their min height
const prefGrow = []; // Nodes that want to grow to their prefd size
const canGrow = []; // Nodes that can grow to auto size
let growBy = 0;
for (const d of dom) {
freeSpace -= d.minHeight;
if (isNaN(d.prefHeight)) {
canGrow.push(d);
d.w.computedHeight = d.minHeight;
} else {
const diff = d.prefHeight - d.minHeight;
if (diff > 0) {
prefGrow.push(d);
growBy += diff;
d.diff = diff;
} else {
d.w.computedHeight = d.minHeight;
}
}
}
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
// Allocate space for image
freeSpace -= 220;
}
if (freeSpace < 0) {
// Not enough space for all widgets so we need to grow
size[1] -= freeSpace;
this.graph.setDirtyCanvas(true);
} else {
// Share the space between each
const growDiff = freeSpace - growBy;
if (growDiff > 0) {
// All pref sizes can be fulfilled
freeSpace = growDiff;
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight;
}
} else {
// We need to grow evenly
const shared = -growDiff / prefGrow.length;
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight - shared;
}
freeSpace = 0;
}
if (freeSpace > 0 && canGrow.length) {
// Grow any that are auto height
const shared = freeSpace / canGrow.length;
for (const d of canGrow) {
d.w.computedHeight += shared;
}
}
}
// Position each of the widgets
for (const w of this.widgets) {
w.y = y;
if (w.computedHeight) {
y += w.computedHeight;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set();
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
LGraphCanvas.prototype.computeVisibleNodes = function () {
const visibleNodes = computeVisibleNodes.apply(this, arguments);
for (const node of app.graph._nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1;
for (const w of node.widgets) {
if (w.element) {
w.element.hidden = hidden;
if (hidden) {
w.options.onHide?.(w);
}
}
}
}
}
return visibleNodes;
};
let enableDomClipping = true;
export function addDomClippingSetting() {
app.ui.settings.addSetting({
id: "Comfy.DOMClippingEnabled",
name: "Enable DOM element clipping (enabling may reduce performance)",
type: "boolean",
defaultValue: enableDomClipping,
onChange(value) {
enableDomClipping = !!value;
},
});
}
LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
options = { hideOnZoom: true, selectOn: ["focus", "click"], ...options };
if (!element.parentElement) {
document.body.append(element);
}
let mouseDownHandler;
if (element.blur) {
mouseDownHandler = (event) => {
if (!element.contains(event.target)) {
element.blur();
}
};
document.addEventListener("mousedown", mouseDownHandler);
}
const widget = {
type,
name,
get value() {
return options.getValue?.() ?? undefined;
},
set value(v) {
options.setValue?.(v);
widget.callback?.(widget.value);
},
draw: function (ctx, node, widgetWidth, y, widgetHeight) {
if (widget.computedHeight == null) {
computeSize.call(node, node.size);
}
const hidden =
node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 ||
widget.type === "converted-widget";
element.hidden = hidden;
element.style.display = hidden ? "none" : null;
if (hidden) {
widget.options.onHide?.(widget);
return;
}
const margin = 10;
const elRect = ctx.canvas.getBoundingClientRect();
const transform = new DOMMatrix()
.scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y);
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
Object.assign(element.style, {
transformOrigin: "0 0",
transform: scale,
left: `${transform.a + transform.e}px`,
top: `${transform.d + transform.f}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: "absolute",
zIndex: app.graph._nodes.indexOf(node),
});
if (enableDomClipping) {
element.style.clipPath = getClipPath(node, element, elRect);
element.style.willChange = "clip-path";
}
this.options.onDraw?.(widget);
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener("mousedown", mouseDownHandler);
}
element.remove();
},
};
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this);
app.canvas.bringToFront(this);
});
}
this.addCustomWidget(widget);
elementWidgets.add(this);
const collapse = this.collapse;
this.collapse = function() {
collapse.apply(this, arguments);
if(this.flags?.collapsed) {
element.hidden = true;
element.style.display = "none";
}
}
const onRemoved = this.onRemoved;
this.onRemoved = function () {
element.remove();
elementWidgets.delete(this);
onRemoved?.apply(this, arguments);
};
if (!this[SIZE]) {
this[SIZE] = true;
const onResize = this.onResize;
this.onResize = function (size) {
options.beforeResize?.call(widget, this);
computeSize.call(this, size);
onResize?.apply(this, arguments);
options.afterResize?.call(widget, this);
};
}
return widget;
};
...@@ -24,7 +24,7 @@ export function getPngMetadata(file) { ...@@ -24,7 +24,7 @@ export function getPngMetadata(file) {
const length = dataView.getUint32(offset); const length = dataView.getUint32(offset);
// Get the chunk type // Get the chunk type
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
if (type === "tEXt") { if (type === "tEXt" || type == "comf") {
// Get the keyword // Get the keyword
let keyword_end = offset + 8; let keyword_end = offset + 8;
while (pngData[keyword_end] !== 0) { while (pngData[keyword_end] !== 0) {
...@@ -50,7 +50,6 @@ export function getPngMetadata(file) { ...@@ -50,7 +50,6 @@ export function getPngMetadata(file) {
function parseExifData(exifData) { function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian) // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949; const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949;
console.log(exifData);
// Function to read 16-bit and 32-bit integers from binary data // Function to read 16-bit and 32-bit integers from binary data
function readInt(offset, isLittleEndian, length) { function readInt(offset, isLittleEndian, length) {
...@@ -126,6 +125,9 @@ export function getWebpMetadata(file) { ...@@ -126,6 +125,9 @@ export function getWebpMetadata(file) {
const chunk_length = dataView.getUint32(offset + 4, true); const chunk_length = dataView.getUint32(offset + 4, true);
const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4));
if (chunk_type === "EXIF") { if (chunk_type === "EXIF") {
if (String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == "Exif\0\0") {
offset += 6;
}
let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length)); let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length));
for (var key in data) { for (var key in data) {
var value = data[key]; var value = data[key];
......
...@@ -462,8 +462,8 @@ class ComfyList { ...@@ -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;
} }
...@@ -599,7 +599,7 @@ export class ComfyUI { ...@@ -599,7 +599,7 @@ export class ComfyUI {
const fileInput = $el("input", { const fileInput = $el("input", {
id: "comfy-file-input", id: "comfy-file-input",
type: "file", type: "file",
accept: ".json,image/png,.latent,.safetensors", accept: ".json,image/png,.latent,.safetensors,image/webp",
style: {display: "none"}, style: {display: "none"},
parent: document.body, parent: document.body,
onchange: () => { onchange: () => {
...@@ -784,9 +784,9 @@ export class ComfyUI { ...@@ -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()
} }
} }
}), }),
......
import { $el } from "../ui.js";
export function calculateImageGrid(imgs, dw, dh) {
let best = 0;
let w = imgs[0].naturalWidth;
let h = imgs[0].naturalHeight;
const numImages = imgs.length;
let cellWidth, cellHeight, cols, rows, shiftX;
// compact style
for (let c = 1; c <= numImages; c++) {
const r = Math.ceil(numImages / c);
const cW = dw / c;
const cH = dh / r;
const scaleX = cW / w;
const scaleY = cH / h;
const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale;
const imageH = h * scale;
const area = imageW * imageH * numImages;
if (area > best) {
best = area;
cellWidth = imageW;
cellHeight = imageH;
cols = c;
rows = r;
shiftX = c * ((cW - imageW) / 2);
}
}
return { cellWidth, cellHeight, cols, rows, shiftX };
}
export function createImageHost(node) {
const el = $el("div.comfy-img-preview");
let currentImgs;
let first = true;
function updateSize() {
let w = null;
let h = null;
if (currentImgs) {
let elH = el.clientHeight;
if (first) {
first = false;
// On first run, if we are small then grow a bit
if (elH < 190) {
elH = 190;
}
el.style.setProperty("--comfy-widget-min-height", elH);
} else {
el.style.setProperty("--comfy-widget-min-height", null);
}
const nw = node.size[0];
({ cellWidth: w, cellHeight: h } = calculateImageGrid(currentImgs, nw - 20, elH));
w += "px";
h += "px";
el.style.setProperty("--comfy-img-preview-width", w);
el.style.setProperty("--comfy-img-preview-height", h);
}
}
return {
el,
updateImages(imgs) {
if (imgs !== currentImgs) {
if (currentImgs == null) {
requestAnimationFrame(() => {
updateSize();
});
}
el.replaceChildren(...imgs);
currentImgs = imgs;
node.onResize(node.size);
node.graph.setDirtyCanvas(true, true);
}
},
getHeight() {
updateSize();
},
onDraw() {
// Element from point uses a hittest find elements so we need to toggle pointer events
el.style.pointerEvents = "all";
const over = document.elementFromPoint(app.canvas.mouse[0], app.canvas.mouse[1]);
el.style.pointerEvents = "none";
if(!over) return;
// Set the overIndex so Open Image etc work
const idx = currentImgs.indexOf(over);
node.overIndex = idx;
},
};
}
import { api } from "./api.js" import { api } from "./api.js"
import "./domWidget.js";
function getNumberDefaults(inputData, defaultStep, precision, enable_rounding) { function getNumberDefaults(inputData, defaultStep, precision, enable_rounding) {
let defaultVal = inputData[1]["default"]; let defaultVal = inputData[1]["default"];
...@@ -22,18 +23,89 @@ function getNumberDefaults(inputData, defaultStep, precision, enable_rounding) { ...@@ -22,18 +23,89 @@ 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 addValueControlWidget(node, targetWidget, defaultValue = "randomize", values, widgetName, inputData) {
const valueControl = node.addWidget("combo", "control_after_generate", defaultValue, function (v) { }, { let name = inputData[1]?.control_after_generate;
values: ["fixed", "increment", "decrement", "randomize"], if(typeof name !== "string") {
serialize: false, // Don't include this in prompt. name = widgetName;
}); }
valueControl.afterQueued = () => { const widgets = addValueControlWidgets(node, targetWidget, defaultValue, {
addFilterList: false,
controlAfterGenerateName: name
}, inputData);
return widgets[0];
}
export function addValueControlWidgets(node, targetWidget, defaultValue = "randomize", options, inputData) {
if (!defaultValue) defaultValue = "randomize";
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 valueControl = node.addWidget(
"combo",
getName("control_after_generate", "controlAfterGenerateName"),
defaultValue,
function () {},
{
values: ["fixed", "increment", "decrement", "randomize"],
serialize: false, // Don't include this in prompt.
}
);
widgets.push(valueControl);
const isCombo = targetWidget.type === "combo";
let comboFilter;
if (isCombo && options.addFilterList !== false) {
comboFilter = node.addWidget(
"string",
getName("control_filter_list", "controlFilterListName"),
"",
function () {},
{
serialize: false, // Don't include this in prompt.
}
);
widgets.push(comboFilter);
}
valueControl.afterQueued = () => {
var v = valueControl.value; var v = valueControl.value;
if (targetWidget.type == "combo" && v !== "fixed") { if (isCombo && v !== "fixed") {
let current_index = targetWidget.options.values.indexOf(targetWidget.value); let values = targetWidget.options.values;
let current_length = targetWidget.options.values.length; const filter = comboFilter?.value;
if (filter) {
let check;
if (filter.startsWith("/") && filter.endsWith("/")) {
try {
const regex = new RegExp(filter.substring(1, filter.length - 1));
check = (item) => regex.test(item);
} catch (error) {
console.error("Error constructing RegExp filter for node " + node.id, filter, error);
}
}
if (!check) {
const lower = filter.toLocaleLowerCase();
check = (item) => item.toLocaleLowerCase().includes(lower);
}
values = values.filter(item => check(item));
if (!values.length && targetWidget.options.values.length) {
console.warn("Filter for node " + node.id + " has filtered out all items", filter);
}
}
let current_index = values.indexOf(targetWidget.value);
let current_length = values.length;
switch (v) { switch (v) {
case "increment": case "increment":
...@@ -50,11 +122,12 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random ...@@ -50,11 +122,12 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random
current_index = Math.max(0, current_index); current_index = Math.max(0, current_index);
current_index = Math.min(current_length - 1, current_index); current_index = Math.min(current_length - 1, current_index);
if (current_index >= 0) { if (current_index >= 0) {
let value = targetWidget.options.values[current_index]; let value = values[current_index];
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
...@@ -77,186 +150,68 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random ...@@ -77,186 +150,68 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random
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 valueControl; 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;
} }
const MultilineSymbol = Symbol(); function createIntWidget(node, inputName, inputData, app, isSeedInput) {
const MultilineResizeSymbol = Symbol(); const control = inputData[1]?.control_after_generate;
if (!isSeedInput && control) {
function addMultilineWidget(node, name, opts, app) { return seedWidget(node, inputName, inputData, app, typeof control === "string" ? control : undefined);
const MIN_SIZE = 50;
function computeSize(size) {
if (node.widgets[0].last_y == null) return;
let y = node.widgets[0].last_y;
let freeSpace = size[1] - y;
// Compute the height of all non customtext widgets
let widgetHeight = 0;
const multi = [];
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (w.type === "customtext") {
multi.push(w);
} else {
if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
}
// See how large each text input can be
freeSpace -= widgetHeight;
freeSpace /= multi.length + (!!node.imgs?.length);
if (freeSpace < MIN_SIZE) {
// There isnt enough space for all the widgets, increase the size of the node
freeSpace = MIN_SIZE;
node.size[1] = y + widgetHeight + freeSpace * (multi.length + (!!node.imgs?.length));
node.graph.setDirtyCanvas(true);
}
// Position each of the widgets
for (const w of node.widgets) {
w.y = y;
if (w.type === "customtext") {
y += freeSpace;
w.computedHeight = freeSpace - multi.length*4;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
node.inputHeight = freeSpace;
} }
const widget = { let widgetType = isSlider(inputData[1]["display"], app);
type: "customtext", const { val, config } = getNumberDefaults(inputData, 1, 0, true);
name, Object.assign(config, { precision: 0 });
get value() { return {
return this.inputEl.value; widget: node.addWidget(
}, widgetType,
set value(x) { inputName,
this.inputEl.value = x; val,
function (v) {
const s = this.options.step / 10;
this.value = Math.round(v / s) * s;
},
config
),
};
}
function addMultilineWidget(node, name, opts, app) {
const inputEl = document.createElement("textarea");
inputEl.className = "comfy-multiline-input";
inputEl.value = opts.defaultVal;
inputEl.placeholder = opts.placeholder || name;
const widget = node.addDOMWidget(name, "customtext", inputEl, {
getValue() {
return inputEl.value;
}, },
draw: function (ctx, _, widgetWidth, y, widgetHeight) { setValue(v) {
if (!this.parent.inputHeight) { inputEl.value = v;
// If we are initially offscreen when created we wont have received a resize event
// Calculate it here instead
computeSize(node.size);
}
const visible = app.canvas.ds.scale > 0.5 && this.type === "customtext";
const margin = 10;
const elRect = ctx.canvas.getBoundingClientRect();
const transform = new DOMMatrix()
.scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y);
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d)
Object.assign(this.inputEl.style, {
transformOrigin: "0 0",
transform: scale,
left: `${transform.a + transform.e}px`,
top: `${transform.d + transform.f}px`,
width: `${widgetWidth - (margin * 2)}px`,
height: `${this.parent.inputHeight - (margin * 2)}px`,
position: "absolute",
background: (!node.color)?'':node.color,
color: (!node.color)?'':'white',
zIndex: app.graph._nodes.indexOf(node),
});
this.inputEl.hidden = !visible;
}, },
};
widget.inputEl = document.createElement("textarea");
widget.inputEl.className = "comfy-multiline-input";
widget.inputEl.value = opts.defaultVal;
widget.inputEl.placeholder = opts.placeholder || "";
document.addEventListener("mousedown", function (event) {
if (!widget.inputEl.contains(event.target)) {
widget.inputEl.blur();
}
}); });
widget.parent = node; widget.inputEl = inputEl;
document.body.appendChild(widget.inputEl);
node.addCustomWidget(widget);
app.canvas.onDrawBackground = function () {
// Draw node isnt fired once the node is off the screen
// if it goes off screen quickly, the input may not be removed
// this shifts it off screen so it can be moved back if the node is visible.
for (let n in app.graph._nodes) {
n = graph._nodes[n];
for (let w in n.widgets) {
let wid = n.widgets[w];
if (Object.hasOwn(wid, "inputEl")) {
wid.inputEl.style.left = -8000 + "px";
wid.inputEl.style.position = "absolute";
}
}
}
};
node.onRemoved = function () {
// When removing this node we need to remove the input from the DOM
for (let y in this.widgets) {
if (this.widgets[y].inputEl) {
this.widgets[y].inputEl.remove();
}
}
};
widget.onRemove = () => { inputEl.addEventListener("input", () => {
widget.inputEl?.remove(); widget.callback?.(widget.value);
});
// Restore original size handler if we are the last
if (!--node[MultilineSymbol]) {
node.onResize = node[MultilineResizeSymbol];
delete node[MultilineSymbol];
delete node[MultilineResizeSymbol];
}
};
if (node[MultilineSymbol]) {
node[MultilineSymbol]++;
} else {
node[MultilineSymbol] = 1;
const onResize = (node[MultilineResizeSymbol] = node.onResize);
node.onResize = function (size) {
computeSize(size);
// Call original resizer handler
if (onResize) {
onResize.apply(this, arguments);
}
};
}
return { minWidth: 400, minHeight: 200, widget }; return { minWidth: 400, minHeight: 200, widget };
} }
...@@ -288,31 +243,26 @@ export const ComfyWidgets = { ...@@ -288,31 +243,26 @@ 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 = inputData[1]["default"]; let defaultVal = false;
let options = {};
if (inputData[1]) {
if (inputData[1].default)
defaultVal = inputData[1].default;
if (inputData[1].label_on)
options["on"] = inputData[1].label_on;
if (inputData[1].label_off)
options["off"] = inputData[1].label_off;
}
return { return {
widget: node.addWidget( widget: node.addWidget(
"toggle", "toggle",
inputName, inputName,
defaultVal, defaultVal,
() => {}, () => {},
{"on": inputData[1].label_on, "off": inputData[1].label_off} options,
) )
}; };
}, },
...@@ -338,10 +288,14 @@ export const ComfyWidgets = { ...@@ -338,10 +288,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) {
...@@ -455,9 +409,10 @@ export const ComfyWidgets = { ...@@ -455,9 +409,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
......
...@@ -409,6 +409,21 @@ dialog::backdrop { ...@@ -409,6 +409,21 @@ dialog::backdrop {
width: calc(100% - 10px); width: calc(100% - 10px);
} }
.comfy-img-preview {
pointer-events: none;
overflow: hidden;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
justify-content: center;
}
.comfy-img-preview img {
object-fit: contain;
width: var(--comfy-img-preview-width);
height: var(--comfy-img-preview-height);
}
/* Search box */ /* Search box */
.litegraph.litesearchbox { .litegraph.litesearchbox {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment