// @ts-check /// /** * @typedef { import("../../web/scripts/app")["app"] } app * @typedef { import("../../web/types/litegraph") } LG * @typedef { import("../../web/types/litegraph").IWidget } IWidget * @typedef { import("../../web/types/litegraph").ContextMenuItem } ContextMenuItem * @typedef { import("../../web/types/litegraph").INodeInputSlot } INodeInputSlot * @typedef { import("../../web/types/litegraph").INodeOutputSlot } INodeOutputSlot * @typedef { InstanceType & { widgets?: Array } } LGNode * @typedef { (...args: EzOutput[] | [...EzOutput[], Record]) => EzNode } EzNodeFactory */ export class EzConnection { /** @type { app } */ app; /** @type { InstanceType } */ link; get originNode() { return new EzNode(this.app, this.app.graph.getNodeById(this.link.origin_id)); } get originOutput() { return this.originNode.outputs[this.link.origin_slot]; } get targetNode() { return new EzNode(this.app, this.app.graph.getNodeById(this.link.target_id)); } get targetInput() { return this.targetNode.inputs[this.link.target_slot]; } /** * @param { app } app * @param { InstanceType } link */ constructor(app, link) { this.app = app; this.link = link; } disconnect() { this.targetInput.disconnect(); } } export class EzSlot { /** @type { EzNode } */ node; /** @type { number } */ index; /** * @param { EzNode } node * @param { number } index */ constructor(node, index) { this.node = node; this.index = index; } } export class EzInput extends EzSlot { /** @type { INodeInputSlot } */ input; /** * @param { EzNode } node * @param { number } index * @param { INodeInputSlot } input */ constructor(node, index, input) { super(node, index); this.input = input; } get connection() { const link = this.node.node.inputs?.[this.index]?.link; if (link == null) { return null; } return new EzConnection(this.node.app, this.node.app.graph.links[link]); } disconnect() { this.node.node.disconnectInput(this.index); } } export class EzOutput extends EzSlot { /** @type { INodeOutputSlot } */ output; /** * @param { EzNode } node * @param { number } index * @param { INodeOutputSlot } output */ constructor(node, index, output) { super(node, index); this.output = output; } get connections() { return (this.node.node.outputs?.[this.index]?.links ?? []).map( (l) => new EzConnection(this.node.app, this.node.app.graph.links[l]) ); } /** * @param { EzInput } input */ connectTo(input) { if (!input) throw new Error("Invalid input"); /** * @type { LG["LLink"] | null } */ const link = this.node.node.connect(this.index, input.node.node, input.index); if (!link) { const inp = input.input; const inName = inp.name || inp.label || inp.type; throw new Error( `Connecting from ${input.node.node.type}#${input.node.id}[${inName}#${input.index}] -> ${this.node.node.type}#${this.node.id}[${ this.output.name ?? this.output.type }#${this.index}] failed.` ); } return link; } } export class EzNodeMenuItem { /** @type { EzNode } */ node; /** @type { number } */ index; /** @type { ContextMenuItem } */ item; /** * @param { EzNode } node * @param { number } index * @param { ContextMenuItem } item */ constructor(node, index, item) { this.node = node; this.index = index; this.item = item; } call(selectNode = true) { if (!this.item?.callback) throw new Error(`Menu Item ${this.item?.content ?? "[null]"} has no callback.`); if (selectNode) { this.node.select(); } return this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node); } } export class EzWidget { /** @type { EzNode } */ node; /** @type { number } */ index; /** @type { IWidget } */ widget; /** * @param { EzNode } node * @param { number } index * @param { IWidget } widget */ constructor(node, index, widget) { this.node = node; this.index = index; this.widget = widget; } get value() { return this.widget.value; } set value(v) { this.widget.value = v; this.widget.callback?.call?.(this.widget, v) } get isConvertedToInput() { // @ts-ignore : this type is valid for converted widgets return this.widget.type === "converted-widget"; } getConvertedInput() { if (!this.isConvertedToInput) throw new Error(`Widget ${this.widget.name} is not converted to input.`); return this.node.inputs.find((inp) => inp.input["widget"]?.name === this.widget.name); } convertToWidget() { if (!this.isConvertedToInput) throw new Error(`Widget ${this.widget.name} cannot be converted as it is already a widget.`); var menu = this.node.menu["Convert 🔘 to widget.."].item.submenu.options; var index = menu.findIndex(a => a.content == `Convert ${this.widget.name} to widget`); menu[index].callback.call(); } convertToInput() { if (this.isConvertedToInput) throw new Error(`Widget ${this.widget.name} cannot be converted as it is already an input.`); var menu = this.node.menu["Convert input to 🔘.."].item.submenu.options; var index = menu.findIndex(a => a.content == `Convert ${this.widget.name} to input`); menu[index].callback.call(); } } export class EzNode { /** @type { app } */ app; /** @type { LGNode } */ node; /** * @param { app } app * @param { LGNode } node */ constructor(app, node) { this.app = app; this.node = node; } get id() { return this.node.id; } get inputs() { return this.#makeLookupArray("inputs", "name", EzInput); } get outputs() { return this.#makeLookupArray("outputs", "name", EzOutput); } get widgets() { return this.#makeLookupArray("widgets", "name", EzWidget); } get menu() { return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem); } get isRemoved() { return !this.app.graph.getNodeById(this.id); } select(addToSelection = false) { this.app.canvas.selectNode(this.node, addToSelection); } // /** // * @template { "inputs" | "outputs" } T // * @param { T } type // * @returns { Record & (type extends "inputs" ? EzInput [] : EzOutput[]) } // */ // #getSlotItems(type) { // // @ts-ignore : these items are correct // return (this.node[type] ?? []).reduce((p, s, i) => { // if (s.name in p) { // throw new Error(`Unable to store input ${s.name} on array as name conflicts.`); // } // // @ts-ignore // p.push((p[s.name] = new (type === "inputs" ? EzInput : EzOutput)(this, i, s))); // return p; // }, Object.assign([], { $: this })); // } /** * @template { { new(node: EzNode, index: number, obj: any): any } } T * @param { "inputs" | "outputs" | "widgets" | (() => Array) } nodeProperty * @param { string } nameProperty * @param { T } ctor * @returns { Record> & Array> } */ #makeLookupArray(nodeProperty, nameProperty, ctor) { const items = typeof nodeProperty === "function" ? nodeProperty() : this.node[nodeProperty]; // @ts-ignore return (items ?? []).reduce((p, s, i) => { if (!s) return p; const name = s[nameProperty]; const item = new ctor(this, i, s); // @ts-ignore p.push(item); if (name) { // @ts-ignore if (name in p) { throw new Error(`Unable to store ${nodeProperty} ${name} on array as name conflicts.`); } } // @ts-ignore p[name] = item; return p; }, Object.assign([], { $: this })); } } export class EzGraph { /** @type { app } */ app; /** * @param { app } app */ constructor(app) { this.app = app; } get nodes() { return this.app.graph._nodes.map((n) => new EzNode(this.app, n)); } clear() { this.app.graph.clear(); } arrange() { this.app.graph.arrange(); } stringify() { return JSON.stringify(this.app.graph.serialize(), undefined); } /** * @param { number | LGNode | EzNode } obj * @returns { EzNode } */ find(obj) { let match; let id; if (typeof obj === "number") { id = obj; } else { id = obj.id; } match = this.app.graph.getNodeById(id); if (!match) { throw new Error(`Unable to find node with ID ${id}.`); } return new EzNode(this.app, match); } /** * @returns { Promise } */ reload() { const graph = JSON.parse(JSON.stringify(this.app.graph.serialize())); return new Promise((r) => { this.app.graph.clear(); setTimeout(async () => { await this.app.loadGraphData(graph); r(); }, 10); }); } /** * @returns { Promise<{ * workflow: {}, * output: Record * }>}> } */ toPrompt() { // @ts-ignore return this.app.graphToPrompt(); } } export const Ez = { /** * Quickly build and interact with a ComfyUI graph * @example * const { ez, graph } = Ez.graph(app); * graph.clear(); * const [model, clip, vae] = ez.CheckpointLoaderSimple().outputs; * const [pos] = ez.CLIPTextEncode(clip, { text: "positive" }).outputs; * const [neg] = ez.CLIPTextEncode(clip, { text: "negative" }).outputs; * const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage().outputs).outputs; * const [image] = ez.VAEDecode(latent, vae).outputs; * const saveNode = ez.SaveImage(image); * console.log(saveNode); * graph.arrange(); * @param { app } app * @param { LG["LiteGraph"] } LiteGraph * @param { LG["LGraphCanvas"] } LGraphCanvas * @param { boolean } clearGraph * @returns { { graph: EzGraph, ez: Record } } */ graph(app, LiteGraph = window["LiteGraph"], LGraphCanvas = window["LGraphCanvas"], clearGraph = true) { // Always set the active canvas so things work LGraphCanvas.active_canvas = app.canvas; if (clearGraph) { app.graph.clear(); } // @ts-ignore : this proxy handles utility methods & node creation const factory = new Proxy( {}, { get(_, p) { if (typeof p !== "string") throw new Error("Invalid node"); const node = LiteGraph.createNode(p); if (!node) throw new Error(`Unknown node "${p}"`); app.graph.add(node); /** * @param {Parameters} args */ return function (...args) { const ezNode = new EzNode(app, node); const inputs = ezNode.inputs; let slot = 0; for (const arg of args) { if (arg instanceof EzOutput) { arg.connectTo(inputs[slot++]); } else { for (const k in arg) { ezNode.widgets[k].value = arg[k]; } } } return ezNode; }; }, } ); return { graph: new EzGraph(app), ez: factory }; }, };