diff --git a/src/extensions/core/groupNodeManage.ts b/src/extensions/core/groupNodeManage.ts index ba377599d..0e770fc69 100644 --- a/src/extensions/core/groupNodeManage.ts +++ b/src/extensions/core/groupNodeManage.ts @@ -9,423 +9,423 @@ import type { LGraphNode, LGraphNodeConstructor } from "/types/litegraph"; const ORDER: symbol = Symbol(); function merge(target, source) { - if (typeof target === "object" && typeof source === "object") { - for (const key in source) { - const sv = source[key]; - if (typeof sv === "object") { - let tv = target[key]; - if (!tv) tv = target[key] = {}; - merge(tv, source[key]); - } else { - target[key] = sv; - } - } - } + if (typeof target === "object" && typeof source === "object") { + for (const key in source) { + const sv = source[key]; + if (typeof sv === "object") { + let tv = target[key]; + if (!tv) tv = target[key] = {}; + merge(tv, source[key]); + } else { + target[key] = sv; + } + } + } - return target; + return target; } export class ManageGroupDialog extends ComfyDialog { - tabs: Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}>; - selectedNodeIndex: number | null | undefined; - selectedTab: keyof ManageGroupDialog["tabs"] = "Inputs"; - selectedGroup: string | undefined; - modifications: Record>> = {}; - nodeItems: any[]; - app: ComfyApp; - groupNodeType: LGraphNodeConstructor; - groupNodeDef: any; - groupData: any; + tabs: Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}>; + selectedNodeIndex: number | null | undefined; + selectedTab: keyof ManageGroupDialog["tabs"] = "Inputs"; + selectedGroup: string | undefined; + modifications: Record>> = {}; + nodeItems: any[]; + app: ComfyApp; + groupNodeType: LGraphNodeConstructor; + groupNodeDef: any; + groupData: any; - innerNodesList: HTMLUListElement; - widgetsPage: HTMLElement; - inputsPage: HTMLElement; - outputsPage: HTMLElement; - draggable: any; + innerNodesList: HTMLUListElement; + widgetsPage: HTMLElement; + inputsPage: HTMLElement; + outputsPage: HTMLElement; + draggable: any; - get selectedNodeInnerIndex() { - return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex; - } + get selectedNodeInnerIndex() { + return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex; + } - constructor(app) { - super(); - this.app = app; - this.element = $el("dialog.comfy-group-manage", { - parent: document.body, - }) as HTMLDialogElement; - } + constructor(app) { + super(); + this.app = app; + this.element = $el("dialog.comfy-group-manage", { + parent: document.body, + }) as HTMLDialogElement; + } - changeTab(tab) { - this.tabs[this.selectedTab].tab.classList.remove("active"); - this.tabs[this.selectedTab].page.classList.remove("active"); - this.tabs[tab].tab.classList.add("active"); - this.tabs[tab].page.classList.add("active"); - this.selectedTab = tab; - } + changeTab(tab) { + this.tabs[this.selectedTab].tab.classList.remove("active"); + this.tabs[this.selectedTab].page.classList.remove("active"); + this.tabs[tab].tab.classList.add("active"); + this.tabs[tab].page.classList.add("active"); + this.selectedTab = tab; + } - changeNode(index, force?) { - if (!force && this.selectedNodeIndex === index) return; + changeNode(index, force?) { + if (!force && this.selectedNodeIndex === index) return; - if (this.selectedNodeIndex != null) { - this.nodeItems[this.selectedNodeIndex].classList.remove("selected"); - } - this.nodeItems[index].classList.add("selected"); - this.selectedNodeIndex = index; + if (this.selectedNodeIndex != null) { + this.nodeItems[this.selectedNodeIndex].classList.remove("selected"); + } + this.nodeItems[index].classList.add("selected"); + this.selectedNodeIndex = index; - if (!this.buildInputsPage() && this.selectedTab === "Inputs") { - this.changeTab("Widgets"); - } - if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") { - this.changeTab("Outputs"); - } - if (!this.buildOutputsPage() && this.selectedTab === "Outputs") { - this.changeTab("Inputs"); - } + if (!this.buildInputsPage() && this.selectedTab === "Inputs") { + this.changeTab("Widgets"); + } + if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") { + this.changeTab("Outputs"); + } + if (!this.buildOutputsPage() && this.selectedTab === "Outputs") { + this.changeTab("Inputs"); + } - this.changeTab(this.selectedTab); - } + this.changeTab(this.selectedTab); + } - getGroupData() { - this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup]; - this.groupNodeDef = this.groupNodeType.nodeData; - this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType); - } + getGroupData() { + this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup]; + this.groupNodeDef = this.groupNodeType.nodeData; + this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType); + } - changeGroup(group, reset = true) { - this.selectedGroup = group; - this.getGroupData(); + changeGroup(group, reset = true) { + this.selectedGroup = group; + this.getGroupData(); - const nodes = this.groupData.nodeData.nodes; - this.nodeItems = nodes.map((n, i) => - $el( - "li.draggable-item", - { - dataset: { - nodeindex: n.index + "", - }, - onclick: () => { - this.changeNode(i); - }, - }, - [ - $el("span.drag-handle"), - $el( - "div", - { - textContent: n.title ?? n.type, - }, - n.title - ? $el("span", { - textContent: n.type, - }) - : [] - ), - ] - ) - ); + const nodes = this.groupData.nodeData.nodes; + this.nodeItems = nodes.map((n, i) => + $el( + "li.draggable-item", + { + dataset: { + nodeindex: n.index + "", + }, + onclick: () => { + this.changeNode(i); + }, + }, + [ + $el("span.drag-handle"), + $el( + "div", + { + textContent: n.title ?? n.type, + }, + n.title + ? $el("span", { + textContent: n.type, + }) + : [] + ), + ] + ) + ); - this.innerNodesList.replaceChildren(...this.nodeItems); + this.innerNodesList.replaceChildren(...this.nodeItems); - if (reset) { - this.selectedNodeIndex = null; - this.changeNode(0); - } else { - const items = this.draggable.getAllItems(); - let index = items.findIndex(item => item.classList.contains("selected")); - if(index === -1) index = this.selectedNodeIndex; - this.changeNode(index, true); - } + if (reset) { + this.selectedNodeIndex = null; + this.changeNode(0); + } else { + const items = this.draggable.getAllItems(); + let index = items.findIndex(item => item.classList.contains("selected")); + if(index === -1) index = this.selectedNodeIndex; + this.changeNode(index, true); + } - const ordered = [...nodes]; - this.draggable?.dispose(); - this.draggable = new DraggableList(this.innerNodesList, "li"); - this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => { - if (oldPosition === newPosition) return; - ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]); - for (let i = 0; i < ordered.length; i++) { - this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i }); - } - }); - } + const ordered = [...nodes]; + this.draggable?.dispose(); + this.draggable = new DraggableList(this.innerNodesList, "li"); + this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => { + if (oldPosition === newPosition) return; + ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]); + for (let i = 0; i < ordered.length; i++) { + this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i }); + } + }); + } - storeModification(props: { nodeIndex?: number; section: symbol; prop: string; value: any }) { - const { nodeIndex, section, prop, value } = props; - const groupMod = (this.modifications[this.selectedGroup] ??= {}); - const nodesMod = (groupMod.nodes ??= {}); - const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}); - const typeMod = (nodeMod[section] ??= {}); - if (typeof value === "object") { - const objMod = (typeMod[prop] ??= {}); - Object.assign(objMod, value); - } else { - typeMod[prop] = value; - } - } + storeModification(props: { nodeIndex?: number; section: symbol; prop: string; value: any }) { + const { nodeIndex, section, prop, value } = props; + const groupMod = (this.modifications[this.selectedGroup] ??= {}); + const nodesMod = (groupMod.nodes ??= {}); + const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}); + const typeMod = (nodeMod[section] ??= {}); + if (typeof value === "object") { + const objMod = (typeMod[prop] ??= {}); + Object.assign(objMod, value); + } else { + typeMod[prop] = value; + } + } - getEditElement(section, prop, value, placeholder, checked, checkable = true) { - if (value === placeholder) value = ""; + getEditElement(section, prop, value, placeholder, checked, checkable = true) { + if (value === placeholder) value = ""; - const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop]; - if (mods) { - if (mods.name != null) { - value = mods.name; - } - if (mods.visible != null) { - checked = mods.visible; - } - } + const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop]; + if (mods) { + if (mods.name != null) { + value = mods.name; + } + if (mods.visible != null) { + checked = mods.visible; + } + } - return $el("div", [ - $el("input", { - value, - placeholder, - type: "text", - onchange: (e) => { - this.storeModification({ section, prop, value: { name: e.target.value } }); - }, - }), - $el("label", { textContent: "Visible" }, [ - $el("input", { - type: "checkbox", - checked, - disabled: !checkable, - onchange: (e) => { - this.storeModification({ section, prop, value: { visible: !!e.target.checked } }); - }, - }), - ]), - ]); - } + return $el("div", [ + $el("input", { + value, + placeholder, + type: "text", + onchange: (e) => { + this.storeModification({ section, prop, value: { name: e.target.value } }); + }, + }), + $el("label", { textContent: "Visible" }, [ + $el("input", { + type: "checkbox", + checked, + disabled: !checkable, + onchange: (e) => { + this.storeModification({ section, prop, value: { visible: !!e.target.checked } }); + }, + }), + ]), + ]); + } - buildWidgetsPage() { - const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]; - const items = Object.keys(widgets ?? {}); - const type = app.graph.extra.groupNodes[this.selectedGroup]; - const config = type.config?.[this.selectedNodeInnerIndex]?.input; - this.widgetsPage.replaceChildren( - ...items.map((oldName) => { - return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false); - }) - ); - return !!items.length; - } + buildWidgetsPage() { + const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]; + const items = Object.keys(widgets ?? {}); + const type = app.graph.extra.groupNodes[this.selectedGroup]; + const config = type.config?.[this.selectedNodeInnerIndex]?.input; + this.widgetsPage.replaceChildren( + ...items.map((oldName) => { + return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false); + }) + ); + return !!items.length; + } - buildInputsPage() { - const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]; - const items = Object.keys(inputs ?? {}); - const type = app.graph.extra.groupNodes[this.selectedGroup]; - const config = type.config?.[this.selectedNodeInnerIndex]?.input; - this.inputsPage.replaceChildren( - ...items - .map((oldName) => { - let value = inputs[oldName]; - if (!value) { - return; - } + buildInputsPage() { + const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]; + const items = Object.keys(inputs ?? {}); + const type = app.graph.extra.groupNodes[this.selectedGroup]; + const config = type.config?.[this.selectedNodeInnerIndex]?.input; + this.inputsPage.replaceChildren( + ...items + .map((oldName) => { + let value = inputs[oldName]; + if (!value) { + return; + } - return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false); - }) - .filter(Boolean) - ); - return !!items.length; - } + return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false); + }) + .filter(Boolean) + ); + return !!items.length; + } - buildOutputsPage() { - const nodes = this.groupData.nodeData.nodes; - const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]); - const outputs = innerNodeDef?.output ?? []; - const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]; + buildOutputsPage() { + const nodes = this.groupData.nodeData.nodes; + const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]); + const outputs = innerNodeDef?.output ?? []; + const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]; - const type = app.graph.extra.groupNodes[this.selectedGroup]; - const config = type.config?.[this.selectedNodeInnerIndex]?.output; - const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]; - const checkable = node.type !== "PrimitiveNode"; - this.outputsPage.replaceChildren( - ...outputs - .map((type, slot) => { - const groupOutputIndex = groupOutputs?.[slot]; - const oldName = innerNodeDef.output_name?.[slot] ?? type; - let value = config?.[slot]?.name; - const visible = config?.[slot]?.visible || groupOutputIndex != null; - if (!value || value === oldName) { - value = ""; - } - return this.getEditElement("output", slot, value, oldName, visible, checkable); - }) - .filter(Boolean) - ); - return !!outputs.length; - } + const type = app.graph.extra.groupNodes[this.selectedGroup]; + const config = type.config?.[this.selectedNodeInnerIndex]?.output; + const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]; + const checkable = node.type !== "PrimitiveNode"; + this.outputsPage.replaceChildren( + ...outputs + .map((type, slot) => { + const groupOutputIndex = groupOutputs?.[slot]; + const oldName = innerNodeDef.output_name?.[slot] ?? type; + let value = config?.[slot]?.name; + const visible = config?.[slot]?.visible || groupOutputIndex != null; + if (!value || value === oldName) { + value = ""; + } + return this.getEditElement("output", slot, value, oldName, visible, checkable); + }) + .filter(Boolean) + ); + return !!outputs.length; + } - show(type?) { - const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort((a, b) => a.localeCompare(b)); + show(type?) { + const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort((a, b) => a.localeCompare(b)); - this.innerNodesList = $el("ul.comfy-group-manage-list-items") as HTMLUListElement; - this.widgetsPage = $el("section.comfy-group-manage-node-page"); - this.inputsPage = $el("section.comfy-group-manage-node-page"); - this.outputsPage = $el("section.comfy-group-manage-node-page"); - const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]); + this.innerNodesList = $el("ul.comfy-group-manage-list-items") as HTMLUListElement; + this.widgetsPage = $el("section.comfy-group-manage-node-page"); + this.inputsPage = $el("section.comfy-group-manage-node-page"); + this.outputsPage = $el("section.comfy-group-manage-node-page"); + const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]); - this.tabs = [ - ["Inputs", this.inputsPage], - ["Widgets", this.widgetsPage], - ["Outputs", this.outputsPage], - ].reduce((p, [name, page]: [string, HTMLElement]) => { - p[name] = { - tab: $el("a", { - onclick: () => { - this.changeTab(name); - }, - textContent: name, - }), - page, - }; - return p; - }, {}) as any; + this.tabs = [ + ["Inputs", this.inputsPage], + ["Widgets", this.widgetsPage], + ["Outputs", this.outputsPage], + ].reduce((p, [name, page]: [string, HTMLElement]) => { + p[name] = { + tab: $el("a", { + onclick: () => { + this.changeTab(name); + }, + textContent: name, + }), + page, + }; + return p; + }, {}) as any; - const outer = $el("div.comfy-group-manage-outer", [ - $el("header", [ - $el("h2", "Group Nodes"), - $el( - "select", - { - onchange: (e) => { - this.changeGroup(e.target.value); - }, - }, - groupNodes.map((g) => - $el("option", { - textContent: g, - selected: "workflow/" + g === type, - value: g, - }) - ) - ), - ]), - $el("main", [ - $el("section.comfy-group-manage-list", this.innerNodesList), - $el("section.comfy-group-manage-node", [ - $el( - "header", - Object.values(this.tabs).map((t) => t.tab) - ), - pages, - ]), - ]), - $el("footer", [ - $el( - "button.comfy-btn", - { - onclick: (e) => { - // @ts-ignore - const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup); - if (node) { - alert("This group node is in use in the current workflow, please first remove these."); - return; - } - if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) { - delete app.graph.extra.groupNodes[this.selectedGroup]; - LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup); - } - this.show(); - }, - }, - "Delete Group Node" - ), - $el( - "button.comfy-btn", - { - onclick: async () => { - let nodesByType; - let recreateNodes = []; - const types = {}; - for (const g in this.modifications) { - const type = app.graph.extra.groupNodes[g]; - let config = (type.config ??= {}); + const outer = $el("div.comfy-group-manage-outer", [ + $el("header", [ + $el("h2", "Group Nodes"), + $el( + "select", + { + onchange: (e) => { + this.changeGroup(e.target.value); + }, + }, + groupNodes.map((g) => + $el("option", { + textContent: g, + selected: "workflow/" + g === type, + value: g, + }) + ) + ), + ]), + $el("main", [ + $el("section.comfy-group-manage-list", this.innerNodesList), + $el("section.comfy-group-manage-node", [ + $el( + "header", + Object.values(this.tabs).map((t) => t.tab) + ), + pages, + ]), + ]), + $el("footer", [ + $el( + "button.comfy-btn", + { + onclick: (e) => { + // @ts-ignore + const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup); + if (node) { + alert("This group node is in use in the current workflow, please first remove these."); + return; + } + if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) { + delete app.graph.extra.groupNodes[this.selectedGroup]; + LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup); + } + this.show(); + }, + }, + "Delete Group Node" + ), + $el( + "button.comfy-btn", + { + onclick: async () => { + let nodesByType; + let recreateNodes = []; + const types = {}; + for (const g in this.modifications) { + const type = app.graph.extra.groupNodes[g]; + let config = (type.config ??= {}); - let nodeMods = this.modifications[g]?.nodes; - if (nodeMods) { - const keys = Object.keys(nodeMods); - if (nodeMods[keys[0]][ORDER]) { - // If any node is reordered, they will all need sequencing - const orderedNodes = []; - const orderedMods = {}; - const orderedConfig = {}; + let nodeMods = this.modifications[g]?.nodes; + if (nodeMods) { + const keys = Object.keys(nodeMods); + if (nodeMods[keys[0]][ORDER]) { + // If any node is reordered, they will all need sequencing + const orderedNodes = []; + const orderedMods = {}; + const orderedConfig = {}; - for (const n of keys) { - const order = nodeMods[n][ORDER].order; - orderedNodes[order] = type.nodes[+n]; - orderedMods[order] = nodeMods[n]; - orderedNodes[order].index = order; - } + for (const n of keys) { + const order = nodeMods[n][ORDER].order; + orderedNodes[order] = type.nodes[+n]; + orderedMods[order] = nodeMods[n]; + orderedNodes[order].index = order; + } - // Rewrite links - for (const l of type.links) { - if (l[0] != null) l[0] = type.nodes[l[0]].index; - if (l[2] != null) l[2] = type.nodes[l[2]].index; - } + // Rewrite links + for (const l of type.links) { + if (l[0] != null) l[0] = type.nodes[l[0]].index; + if (l[2] != null) l[2] = type.nodes[l[2]].index; + } - // Rewrite externals - if (type.external) { - for (const ext of type.external) { - ext[0] = type.nodes[ext[0]]; - } - } + // Rewrite externals + if (type.external) { + for (const ext of type.external) { + ext[0] = type.nodes[ext[0]]; + } + } - // Rewrite modifications - for (const id of keys) { - if (config[id]) { - orderedConfig[type.nodes[id].index] = config[id]; - } - delete config[id]; - } + // Rewrite modifications + for (const id of keys) { + if (config[id]) { + orderedConfig[type.nodes[id].index] = config[id]; + } + delete config[id]; + } - type.nodes = orderedNodes; - nodeMods = orderedMods; - type.config = config = orderedConfig; - } + type.nodes = orderedNodes; + nodeMods = orderedMods; + type.config = config = orderedConfig; + } - merge(config, nodeMods); - } + merge(config, nodeMods); + } - types[g] = type; + types[g] = type; - if (!nodesByType) { - // @ts-ignore - nodesByType = app.graph._nodes.reduce((p, n) => { - p[n.type] ??= []; - p[n.type].push(n); - return p; - }, {}); - } + if (!nodesByType) { + // @ts-ignore + nodesByType = app.graph._nodes.reduce((p, n) => { + p[n.type] ??= []; + p[n.type].push(n); + return p; + }, {}); + } - const nodes = nodesByType["workflow/" + g]; - if (nodes) recreateNodes.push(...nodes); - } + const nodes = nodesByType["workflow/" + g]; + if (nodes) recreateNodes.push(...nodes); + } - await GroupNodeConfig.registerFromWorkflow(types, {}); + await GroupNodeConfig.registerFromWorkflow(types, {}); - for (const node of recreateNodes) { - node.recreate(); - } + for (const node of recreateNodes) { + node.recreate(); + } - this.modifications = {}; - this.app.graph.setDirtyCanvas(true, true); - this.changeGroup(this.selectedGroup, false); - }, - }, - "Save" - ), - $el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"), - ]), - ]); + this.modifications = {}; + this.app.graph.setDirtyCanvas(true, true); + this.changeGroup(this.selectedGroup, false); + }, + }, + "Save" + ), + $el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"), + ]), + ]); - this.element.replaceChildren(outer); - this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]); - this.element.showModal(); + this.element.replaceChildren(outer); + this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]); + this.element.showModal(); - this.element.addEventListener("close", () => { - this.draggable?.dispose(); - }); - } + this.element.addEventListener("close", () => { + this.draggable?.dispose(); + }); + } } \ No newline at end of file diff --git a/src/scripts/logging.ts b/src/scripts/logging.ts index 542fa9840..791e9f4f1 100644 --- a/src/scripts/logging.ts +++ b/src/scripts/logging.ts @@ -3,7 +3,7 @@ import { api } from "./api"; import type { ComfyApp } from "./app"; $el("style", { - textContent: ` + textContent: ` .comfy-logging-logs { display: grid; color: var(--fg-color); @@ -23,131 +23,131 @@ $el("style", { padding: 5px; } `, - parent: document.body, + parent: document.body, }); // Stringify function supporting max depth and removal of circular references // https://stackoverflow.com/a/57193345 function stringify(val, depth, replacer, space, onGetObjID?) { - depth = isNaN(+depth) ? 1 : depth; - var recursMap = new WeakMap(); - function _build(val, depth, o?, a?, r?) { - // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration) - return !val || typeof val != "object" - ? val - : ((r = recursMap.has(val)), - recursMap.set(val, true), - (a = Array.isArray(val)), - r - ? (o = (onGetObjID && onGetObjID(val)) || null) - : JSON.stringify(val, function (k, v) { - if (a || depth > 0) { - if (replacer) v = replacer(k, v); - if (!k) return (a = Array.isArray(v)), (val = v); - !o && (o = a ? [] : {}); - o[k] = _build(v, a ? depth : depth - 1); - } - }), - o === void 0 ? (a ? [] : {}) : o); - } - return JSON.stringify(_build(val, depth), null, space); + depth = isNaN(+depth) ? 1 : depth; + var recursMap = new WeakMap(); + function _build(val, depth, o?, a?, r?) { + // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration) + return !val || typeof val != "object" + ? val + : ((r = recursMap.has(val)), + recursMap.set(val, true), + (a = Array.isArray(val)), + r + ? (o = (onGetObjID && onGetObjID(val)) || null) + : JSON.stringify(val, function (k, v) { + if (a || depth > 0) { + if (replacer) v = replacer(k, v); + if (!k) return (a = Array.isArray(v)), (val = v); + !o && (o = a ? [] : {}); + o[k] = _build(v, a ? depth : depth - 1); + } + }), + o === void 0 ? (a ? [] : {}) : o); + } + return JSON.stringify(_build(val, depth), null, space); } const jsonReplacer = (k, v, ui) => { - if (v instanceof Array && v.length === 1) { - v = v[0]; - } - if (v instanceof Date) { - v = v.toISOString(); - if (ui) { - v = v.split("T")[1]; - } - } - if (v instanceof Error) { - let err = ""; - if (v.name) err += v.name + "\n"; - if (v.message) err += v.message + "\n"; - if (v.stack) err += v.stack + "\n"; - if (!err) { - err = v.toString(); - } - v = err; - } - return v; + if (v instanceof Array && v.length === 1) { + v = v[0]; + } + if (v instanceof Date) { + v = v.toISOString(); + if (ui) { + v = v.split("T")[1]; + } + } + if (v instanceof Error) { + let err = ""; + if (v.name) err += v.name + "\n"; + if (v.message) err += v.message + "\n"; + if (v.stack) err += v.stack + "\n"; + if (!err) { + err = v.toString(); + } + v = err; + } + return v; }; const fileInput: HTMLInputElement = $el("input", { - type: "file", - accept: ".json", - style: { display: "none" }, - parent: document.body, + type: "file", + accept: ".json", + style: { display: "none" }, + parent: document.body, }) as HTMLInputElement; class ComfyLoggingDialog extends ComfyDialog { - logging: any; + logging: any; - constructor(logging) { - super(); - this.logging = logging; - } + constructor(logging) { + super(); + this.logging = logging; + } - clear() { - this.logging.clear(); - this.show(); - } + clear() { + this.logging.clear(); + this.show(); + } - export() { - const blob = new Blob([stringify([...this.logging.entries], 20, jsonReplacer, "\t")], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = $el("a", { - href: url, - download: `comfyui-logs-${Date.now()}.json`, - style: { display: "none" }, - parent: document.body, - }); - a.click(); - setTimeout(function () { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); - } + export() { + const blob = new Blob([stringify([...this.logging.entries], 20, jsonReplacer, "\t")], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = $el("a", { + href: url, + download: `comfyui-logs-${Date.now()}.json`, + style: { display: "none" }, + parent: document.body, + }); + a.click(); + setTimeout(function () { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); + } - import() { - fileInput.onchange = () => { - const reader = new FileReader(); - reader.onload = () => { - fileInput.remove(); - try { - const obj = JSON.parse(reader.result as string); - if (obj instanceof Array) { - this.show(obj); - } else { - throw new Error("Invalid file selected."); - } - } catch (error) { - alert("Unable to load logs: " + error.message); - } - }; - reader.readAsText(fileInput.files[0]); - }; - fileInput.click(); - } + import() { + fileInput.onchange = () => { + const reader = new FileReader(); + reader.onload = () => { + fileInput.remove(); + try { + const obj = JSON.parse(reader.result as string); + if (obj instanceof Array) { + this.show(obj); + } else { + throw new Error("Invalid file selected."); + } + } catch (error) { + alert("Unable to load logs: " + error.message); + } + }; + reader.readAsText(fileInput.files[0]); + }; + fileInput.click(); + } - createButtons() { - return [ - $el("button", { - type: "button", - textContent: "Clear", - onclick: () => this.clear(), - }), - $el("button", { - type: "button", - textContent: "Export logs...", - onclick: () => this.export(), - }), - $el("button", { + createButtons() { + return [ + $el("button", { + type: "button", + textContent: "Clear", + onclick: () => this.clear(), + }), + $el("button", { + type: "button", + textContent: "Export logs...", + onclick: () => this.export(), + }), + $el("button", { type: "button", textContent: "View exported logs...", onclick: () => this.import(), diff --git a/src/scripts/pnginfo.ts b/src/scripts/pnginfo.ts index 3dd24f8fd..7a9c90e9f 100644 --- a/src/scripts/pnginfo.ts +++ b/src/scripts/pnginfo.ts @@ -1,504 +1,504 @@ import { api } from "./api"; export function getPngMetadata(file) { - return new Promise>((r) => { - const reader = new FileReader(); - reader.onload = (event) => { - // Get the PNG data as a Uint8Array - const pngData = new Uint8Array(event.target.result as ArrayBuffer); - const dataView = new DataView(pngData.buffer); + return new Promise>((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + // Get the PNG data as a Uint8Array + const pngData = new Uint8Array(event.target.result as ArrayBuffer); + const dataView = new DataView(pngData.buffer); - // Check that the PNG signature is present - if (dataView.getUint32(0) !== 0x89504e47) { - console.error("Not a valid PNG file"); - r({}); - return; - } + // Check that the PNG signature is present + if (dataView.getUint32(0) !== 0x89504e47) { + console.error("Not a valid PNG file"); + r({}); + return; + } - // Start searching for chunks after the PNG signature - let offset = 8; - let txt_chunks: Record = {}; - // Loop through the chunks in the PNG file - while (offset < pngData.length) { - // Get the length of the chunk - const length = dataView.getUint32(offset); - // Get the chunk type - const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); - if (type === "tEXt" || type == "comf" || type === "iTXt") { - // Get the keyword - let keyword_end = offset + 8; - while (pngData[keyword_end] !== 0) { - keyword_end++; - } - const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end)); - // Get the text - const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length); - const contentJson = new TextDecoder("utf-8").decode(contentArraySegment); - txt_chunks[keyword] = contentJson; - } + // Start searching for chunks after the PNG signature + let offset = 8; + let txt_chunks: Record = {}; + // Loop through the chunks in the PNG file + while (offset < pngData.length) { + // Get the length of the chunk + const length = dataView.getUint32(offset); + // Get the chunk type + const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); + if (type === "tEXt" || type == "comf" || type === "iTXt") { + // Get the keyword + let keyword_end = offset + 8; + while (pngData[keyword_end] !== 0) { + keyword_end++; + } + const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end)); + // Get the text + const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length); + const contentJson = new TextDecoder("utf-8").decode(contentArraySegment); + txt_chunks[keyword] = contentJson; + } - offset += 12 + length; - } + offset += 12 + length; + } - r(txt_chunks); - }; + r(txt_chunks); + }; - reader.readAsArrayBuffer(file); - }); + reader.readAsArrayBuffer(file); + }); } function parseExifData(exifData) { - // 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; + // 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; - // Function to read 16-bit and 32-bit integers from binary data - function readInt(offset, isLittleEndian, length) { - let arr = exifData.slice(offset, offset + length) - if (length === 2) { - return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian); - } else if (length === 4) { - return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian); - } - } + // Function to read 16-bit and 32-bit integers from binary data + function readInt(offset, isLittleEndian, length) { + let arr = exifData.slice(offset, offset + length) + if (length === 2) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian); + } else if (length === 4) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian); + } + } - // Read the offset to the first IFD (Image File Directory) - const ifdOffset = readInt(4, isLittleEndian, 4); + // Read the offset to the first IFD (Image File Directory) + const ifdOffset = readInt(4, isLittleEndian, 4); - function parseIFD(offset) { - const numEntries = readInt(offset, isLittleEndian, 2); - const result = {}; + function parseIFD(offset) { + const numEntries = readInt(offset, isLittleEndian, 2); + const result = {}; - for (let i = 0; i < numEntries; i++) { - const entryOffset = offset + 2 + i * 12; - const tag = readInt(entryOffset, isLittleEndian, 2); - const type = readInt(entryOffset + 2, isLittleEndian, 2); - const numValues = readInt(entryOffset + 4, isLittleEndian, 4); - const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4); + for (let i = 0; i < numEntries; i++) { + const entryOffset = offset + 2 + i * 12; + const tag = readInt(entryOffset, isLittleEndian, 2); + const type = readInt(entryOffset + 2, isLittleEndian, 2); + const numValues = readInt(entryOffset + 4, isLittleEndian, 4); + const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4); - // Read the value(s) based on the data type - let value; - if (type === 2) { - // ASCII string - value = String.fromCharCode(...exifData.slice(valueOffset, valueOffset + numValues - 1)); - } + // Read the value(s) based on the data type + let value; + if (type === 2) { + // ASCII string + value = String.fromCharCode(...exifData.slice(valueOffset, valueOffset + numValues - 1)); + } - result[tag] = value; - } + result[tag] = value; + } - return result; - } + return result; + } - // Parse the first IFD - const ifdData = parseIFD(ifdOffset); - return ifdData; + // Parse the first IFD + const ifdData = parseIFD(ifdOffset); + return ifdData; } function splitValues(input) { var output = {}; for (var key in input) { - var value = input[key]; - var splitValues = value.split(':', 2); - output[splitValues[0]] = splitValues[1]; + var value = input[key]; + var splitValues = value.split(':', 2); + output[splitValues[0]] = splitValues[1]; } return output; } export function getWebpMetadata(file) { - return new Promise>((r) => { - const reader = new FileReader(); - reader.onload = (event) => { - const webp = new Uint8Array(event.target.result as ArrayBuffer); - const dataView = new DataView(webp.buffer); + return new Promise>((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + const webp = new Uint8Array(event.target.result as ArrayBuffer); + const dataView = new DataView(webp.buffer); - // Check that the WEBP signature is present - if (dataView.getUint32(0) !== 0x52494646 || dataView.getUint32(8) !== 0x57454250) { - console.error("Not a valid WEBP file"); - r({}); - return; - } + // Check that the WEBP signature is present + if (dataView.getUint32(0) !== 0x52494646 || dataView.getUint32(8) !== 0x57454250) { + console.error("Not a valid WEBP file"); + r({}); + return; + } - // Start searching for chunks after the WEBP signature - let offset = 12; - let txt_chunks = {}; - // Loop through the chunks in the WEBP file - while (offset < webp.length) { - const chunk_length = dataView.getUint32(offset + 4, true); - const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); - 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)); - for (var key in data) { - var value = data[key] as string; - let index = value.indexOf(':'); - txt_chunks[value.slice(0, index)] = value.slice(index + 1); - } - } + // Start searching for chunks after the WEBP signature + let offset = 12; + let txt_chunks = {}; + // Loop through the chunks in the WEBP file + while (offset < webp.length) { + const chunk_length = dataView.getUint32(offset + 4, true); + const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); + 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)); + for (var key in data) { + var value = data[key] as string; + let index = value.indexOf(':'); + txt_chunks[value.slice(0, index)] = value.slice(index + 1); + } + } - offset += 8 + chunk_length; - } + offset += 8 + chunk_length; + } - r(txt_chunks); - }; + r(txt_chunks); + }; - reader.readAsArrayBuffer(file); - }); + reader.readAsArrayBuffer(file); + }); } export function getLatentMetadata(file) { - return new Promise((r) => { - const reader = new FileReader(); - reader.onload = (event) => { - const safetensorsData = new Uint8Array(event.target.result as ArrayBuffer); - const dataView = new DataView(safetensorsData.buffer); - let header_size = dataView.getUint32(0, true); - let offset = 8; - let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size))); - r(header.__metadata__); - }; + return new Promise((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + const safetensorsData = new Uint8Array(event.target.result as ArrayBuffer); + const dataView = new DataView(safetensorsData.buffer); + let header_size = dataView.getUint32(0, true); + let offset = 8; + let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size))); + r(header.__metadata__); + }; - var slice = file.slice(0, 1024 * 1024 * 4); - reader.readAsArrayBuffer(slice); - }); + var slice = file.slice(0, 1024 * 1024 * 4); + reader.readAsArrayBuffer(slice); + }); } function getString(dataView: DataView, offset: number, length: number): string { - let string = ''; - for (let i = 0; i < length; i++) { - string += String.fromCharCode(dataView.getUint8(offset + i)); - } - return string; + let string = ''; + for (let i = 0; i < length; i++) { + string += String.fromCharCode(dataView.getUint8(offset + i)); + } + return string; } // Function to parse the Vorbis Comment block function parseVorbisComment(dataView: DataView): Record { - let offset = 0; - const vendorLength = dataView.getUint32(offset, true); - offset += 4; - const vendorString = getString(dataView, offset, vendorLength); - offset += vendorLength; + let offset = 0; + const vendorLength = dataView.getUint32(offset, true); + offset += 4; + const vendorString = getString(dataView, offset, vendorLength); + offset += vendorLength; - const userCommentListLength = dataView.getUint32(offset, true); - offset += 4; - const comments = {}; - for (let i = 0; i < userCommentListLength; i++) { - const commentLength = dataView.getUint32(offset, true); - offset += 4; - const comment = getString(dataView, offset, commentLength); - offset += commentLength; + const userCommentListLength = dataView.getUint32(offset, true); + offset += 4; + const comments = {}; + for (let i = 0; i < userCommentListLength; i++) { + const commentLength = dataView.getUint32(offset, true); + offset += 4; + const comment = getString(dataView, offset, commentLength); + offset += commentLength; - const [key, value] = comment.split('='); + const [key, value] = comment.split('='); - comments[key] = value; - } + comments[key] = value; + } - return comments; + return comments; } // Function to read a FLAC file and parse Vorbis comments export function getFlacMetadata(file: Blob): Promise> { - return new Promise((r) => { - const reader = new FileReader(); - reader.onload = function(event) { - const arrayBuffer = event.target.result as ArrayBuffer; - const dataView = new DataView(arrayBuffer); + return new Promise((r) => { + const reader = new FileReader(); + reader.onload = function(event) { + const arrayBuffer = event.target.result as ArrayBuffer; + const dataView = new DataView(arrayBuffer); - // Verify the FLAC signature - const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4)); - if (signature !== 'fLaC') { - console.error('Not a valid FLAC file'); - return; - } + // Verify the FLAC signature + const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4)); + if (signature !== 'fLaC') { + console.error('Not a valid FLAC file'); + return; + } - // Parse metadata blocks - let offset = 4; - let vorbisComment = null; - while (offset < dataView.byteLength) { - const isLastBlock = dataView.getUint8(offset) & 0x80; - const blockType = dataView.getUint8(offset) & 0x7F; - const blockSize = dataView.getUint32(offset, false) & 0xFFFFFF; - offset += 4; + // Parse metadata blocks + let offset = 4; + let vorbisComment = null; + while (offset < dataView.byteLength) { + const isLastBlock = dataView.getUint8(offset) & 0x80; + const blockType = dataView.getUint8(offset) & 0x7F; + const blockSize = dataView.getUint32(offset, false) & 0xFFFFFF; + offset += 4; - if (blockType === 4) { // Vorbis Comment block type - vorbisComment = parseVorbisComment(new DataView(arrayBuffer, offset, blockSize)); - } + if (blockType === 4) { // Vorbis Comment block type + vorbisComment = parseVorbisComment(new DataView(arrayBuffer, offset, blockSize)); + } - offset += blockSize; - if (isLastBlock) break; - } + offset += blockSize; + if (isLastBlock) break; + } - r(vorbisComment); - }; - reader.readAsArrayBuffer(file); - }); + r(vorbisComment); + }; + reader.readAsArrayBuffer(file); + }); } export async function importA1111(graph, parameters) { - const p = parameters.lastIndexOf("\nSteps:"); - if (p > -1) { - const embeddings = await api.getEmbeddings(); - const opts = parameters - .substr(p) - .split("\n")[1] - .match(new RegExp("\\s*([^:]+:\\s*([^\"\\{].*?|\".*?\"|\\{.*?\\}))\\s*(,|$)", "g")) - .reduce((p, n) => { - const s = n.split(":"); - if (s[1].endsWith(',')) { - s[1] = s[1].substr(0, s[1].length -1); - } - p[s[0].trim().toLowerCase()] = s[1].trim(); - return p; - }, {}); - const p2 = parameters.lastIndexOf("\nNegative prompt:", p); - if (p2 > -1) { - let positive = parameters.substr(0, p2).trim(); - let negative = parameters.substring(p2 + 18, p).trim(); + const p = parameters.lastIndexOf("\nSteps:"); + if (p > -1) { + const embeddings = await api.getEmbeddings(); + const opts = parameters + .substr(p) + .split("\n")[1] + .match(new RegExp("\\s*([^:]+:\\s*([^\"\\{].*?|\".*?\"|\\{.*?\\}))\\s*(,|$)", "g")) + .reduce((p, n) => { + const s = n.split(":"); + if (s[1].endsWith(',')) { + s[1] = s[1].substr(0, s[1].length -1); + } + p[s[0].trim().toLowerCase()] = s[1].trim(); + return p; + }, {}); + const p2 = parameters.lastIndexOf("\nNegative prompt:", p); + if (p2 > -1) { + let positive = parameters.substr(0, p2).trim(); + let negative = parameters.substring(p2 + 18, p).trim(); - const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple"); - const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer"); - const positiveNode = LiteGraph.createNode("CLIPTextEncode"); - const negativeNode = LiteGraph.createNode("CLIPTextEncode"); - const samplerNode = LiteGraph.createNode("KSampler"); - const imageNode = LiteGraph.createNode("EmptyLatentImage"); - const vaeNode = LiteGraph.createNode("VAEDecode"); - const vaeLoaderNode = LiteGraph.createNode("VAELoader"); - const saveNode = LiteGraph.createNode("SaveImage"); - let hrSamplerNode = null; - let hrSteps = null; + const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple"); + const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer"); + const positiveNode = LiteGraph.createNode("CLIPTextEncode"); + const negativeNode = LiteGraph.createNode("CLIPTextEncode"); + const samplerNode = LiteGraph.createNode("KSampler"); + const imageNode = LiteGraph.createNode("EmptyLatentImage"); + const vaeNode = LiteGraph.createNode("VAEDecode"); + const vaeLoaderNode = LiteGraph.createNode("VAELoader"); + const saveNode = LiteGraph.createNode("SaveImage"); + let hrSamplerNode = null; + let hrSteps = null; - const ceil64 = (v) => Math.ceil(v / 64) * 64; + const ceil64 = (v) => Math.ceil(v / 64) * 64; - const getWidget = (node, name) => { - return node.widgets.find((w) => w.name === name); - } + const getWidget = (node, name) => { + return node.widgets.find((w) => w.name === name); + } - const setWidgetValue = (node, name, value, isOptionPrefix?) => { - const w = getWidget(node, name); - if (isOptionPrefix) { - const o = w.options.values.find((w) => w.startsWith(value)); - if (o) { - w.value = o; - } else { - console.warn(`Unknown value '${value}' for widget '${name}'`, node); - w.value = value; - } - } else { - w.value = value; - } - } + const setWidgetValue = (node, name, value, isOptionPrefix?) => { + const w = getWidget(node, name); + if (isOptionPrefix) { + const o = w.options.values.find((w) => w.startsWith(value)); + if (o) { + w.value = o; + } else { + console.warn(`Unknown value '${value}' for widget '${name}'`, node); + w.value = value; + } + } else { + w.value = value; + } + } - const createLoraNodes = (clipNode, text, prevClip, prevModel) => { - const loras = []; - text = text.replace(/]+)>/g, function (m, c) { - const s = c.split(":"); - const weight = parseFloat(s[1]); - if (isNaN(weight)) { - console.warn("Invalid LORA", m); - } else { - loras.push({ name: s[0], weight }); - } - return ""; - }); + const createLoraNodes = (clipNode, text, prevClip, prevModel) => { + const loras = []; + text = text.replace(/]+)>/g, function (m, c) { + const s = c.split(":"); + const weight = parseFloat(s[1]); + if (isNaN(weight)) { + console.warn("Invalid LORA", m); + } else { + loras.push({ name: s[0], weight }); + } + return ""; + }); - for (const l of loras) { - const loraNode = LiteGraph.createNode("LoraLoader"); - graph.add(loraNode); - setWidgetValue(loraNode, "lora_name", l.name, true); - setWidgetValue(loraNode, "strength_model", l.weight); - setWidgetValue(loraNode, "strength_clip", l.weight); - prevModel.node.connect(prevModel.index, loraNode, 0); - prevClip.node.connect(prevClip.index, loraNode, 1); - prevModel = { node: loraNode, index: 0 }; - prevClip = { node: loraNode, index: 1 }; - } + for (const l of loras) { + const loraNode = LiteGraph.createNode("LoraLoader"); + graph.add(loraNode); + setWidgetValue(loraNode, "lora_name", l.name, true); + setWidgetValue(loraNode, "strength_model", l.weight); + setWidgetValue(loraNode, "strength_clip", l.weight); + prevModel.node.connect(prevModel.index, loraNode, 0); + prevClip.node.connect(prevClip.index, loraNode, 1); + prevModel = { node: loraNode, index: 0 }; + prevClip = { node: loraNode, index: 1 }; + } - prevClip.node.connect(1, clipNode, 0); - prevModel.node.connect(0, samplerNode, 0); - if (hrSamplerNode) { - prevModel.node.connect(0, hrSamplerNode, 0); - } + prevClip.node.connect(1, clipNode, 0); + prevModel.node.connect(0, samplerNode, 0); + if (hrSamplerNode) { + prevModel.node.connect(0, hrSamplerNode, 0); + } - return { text, prevModel, prevClip }; - } + return { text, prevModel, prevClip }; + } - const replaceEmbeddings = (text) => { - if(!embeddings.length) return text; - return text.replaceAll( - new RegExp( - "\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b", - "ig" - ), - "embedding:$1" - ); - } + const replaceEmbeddings = (text) => { + if(!embeddings.length) return text; + return text.replaceAll( + new RegExp( + "\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b", + "ig" + ), + "embedding:$1" + ); + } - const popOpt = (name) => { - const v = opts[name]; - delete opts[name]; - return v; - } + const popOpt = (name) => { + const v = opts[name]; + delete opts[name]; + return v; + } - graph.clear(); - graph.add(ckptNode); - graph.add(clipSkipNode); - graph.add(positiveNode); - graph.add(negativeNode); - graph.add(samplerNode); - graph.add(imageNode); - graph.add(vaeNode); - graph.add(vaeLoaderNode); - graph.add(saveNode); + graph.clear(); + graph.add(ckptNode); + graph.add(clipSkipNode); + graph.add(positiveNode); + graph.add(negativeNode); + graph.add(samplerNode); + graph.add(imageNode); + graph.add(vaeNode); + graph.add(vaeLoaderNode); + graph.add(saveNode); - ckptNode.connect(1, clipSkipNode, 0); - clipSkipNode.connect(0, positiveNode, 0); - clipSkipNode.connect(0, negativeNode, 0); - ckptNode.connect(0, samplerNode, 0); - positiveNode.connect(0, samplerNode, 1); - negativeNode.connect(0, samplerNode, 2); - imageNode.connect(0, samplerNode, 3); - vaeNode.connect(0, saveNode, 0); - samplerNode.connect(0, vaeNode, 0); - vaeLoaderNode.connect(0, vaeNode, 1); + ckptNode.connect(1, clipSkipNode, 0); + clipSkipNode.connect(0, positiveNode, 0); + clipSkipNode.connect(0, negativeNode, 0); + ckptNode.connect(0, samplerNode, 0); + positiveNode.connect(0, samplerNode, 1); + negativeNode.connect(0, samplerNode, 2); + imageNode.connect(0, samplerNode, 3); + vaeNode.connect(0, saveNode, 0); + samplerNode.connect(0, vaeNode, 0); + vaeLoaderNode.connect(0, vaeNode, 1); - const handlers = { - model(v) { - setWidgetValue(ckptNode, "ckpt_name", v, true); - }, - "vae"(v) { - setWidgetValue(vaeLoaderNode, "vae_name", v, true); - }, - "cfg scale"(v) { - setWidgetValue(samplerNode, "cfg", +v); - }, - "clip skip"(v) { - setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v); - }, - sampler(v) { - let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_"); - if (name.includes("karras")) { - name = name.replace("karras", "").replace(/_+$/, ""); - setWidgetValue(samplerNode, "scheduler", "karras"); - } else { - setWidgetValue(samplerNode, "scheduler", "normal"); - } - const w = getWidget(samplerNode, "sampler_name"); - const o = w.options.values.find((w) => w === name || w === "sample_" + name); - if (o) { - setWidgetValue(samplerNode, "sampler_name", o); - } - }, - size(v) { - const wxh = v.split("x"); - const w = ceil64(+wxh[0]); - const h = ceil64(+wxh[1]); - const hrUp = popOpt("hires upscale"); - const hrSz = popOpt("hires resize"); - hrSteps = popOpt("hires steps"); - let hrMethod = popOpt("hires upscaler"); + const handlers = { + model(v) { + setWidgetValue(ckptNode, "ckpt_name", v, true); + }, + "vae"(v) { + setWidgetValue(vaeLoaderNode, "vae_name", v, true); + }, + "cfg scale"(v) { + setWidgetValue(samplerNode, "cfg", +v); + }, + "clip skip"(v) { + setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v); + }, + sampler(v) { + let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_"); + if (name.includes("karras")) { + name = name.replace("karras", "").replace(/_+$/, ""); + setWidgetValue(samplerNode, "scheduler", "karras"); + } else { + setWidgetValue(samplerNode, "scheduler", "normal"); + } + const w = getWidget(samplerNode, "sampler_name"); + const o = w.options.values.find((w) => w === name || w === "sample_" + name); + if (o) { + setWidgetValue(samplerNode, "sampler_name", o); + } + }, + size(v) { + const wxh = v.split("x"); + const w = ceil64(+wxh[0]); + const h = ceil64(+wxh[1]); + const hrUp = popOpt("hires upscale"); + const hrSz = popOpt("hires resize"); + hrSteps = popOpt("hires steps"); + let hrMethod = popOpt("hires upscaler"); - setWidgetValue(imageNode, "width", w); - setWidgetValue(imageNode, "height", h); + setWidgetValue(imageNode, "width", w); + setWidgetValue(imageNode, "height", h); - if (hrUp || hrSz) { - let uw, uh; - if (hrUp) { - uw = w * hrUp; - uh = h * hrUp; - } else { - const s = hrSz.split("x"); - uw = +s[0]; - uh = +s[1]; - } + if (hrUp || hrSz) { + let uw, uh; + if (hrUp) { + uw = w * hrUp; + uh = h * hrUp; + } else { + const s = hrSz.split("x"); + uw = +s[0]; + uh = +s[1]; + } - let upscaleNode; - let latentNode; + let upscaleNode; + let latentNode; - if (hrMethod.startsWith("Latent")) { - latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale"); - graph.add(upscaleNode); - samplerNode.connect(0, upscaleNode, 0); + if (hrMethod.startsWith("Latent")) { + latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale"); + graph.add(upscaleNode); + samplerNode.connect(0, upscaleNode, 0); - switch (hrMethod) { - case "Latent (nearest-exact)": - hrMethod = "nearest-exact"; - break; - } - setWidgetValue(upscaleNode, "upscale_method", hrMethod, true); - } else { - const decode = LiteGraph.createNode("VAEDecodeTiled"); - graph.add(decode); - samplerNode.connect(0, decode, 0); - vaeLoaderNode.connect(0, decode, 1); + switch (hrMethod) { + case "Latent (nearest-exact)": + hrMethod = "nearest-exact"; + break; + } + setWidgetValue(upscaleNode, "upscale_method", hrMethod, true); + } else { + const decode = LiteGraph.createNode("VAEDecodeTiled"); + graph.add(decode); + samplerNode.connect(0, decode, 0); + vaeLoaderNode.connect(0, decode, 1); - const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader"); - graph.add(upscaleLoaderNode); - setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true); + const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader"); + graph.add(upscaleLoaderNode); + setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true); - const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel"); - graph.add(modelUpscaleNode); - decode.connect(0, modelUpscaleNode, 1); - upscaleLoaderNode.connect(0, modelUpscaleNode, 0); + const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel"); + graph.add(modelUpscaleNode); + decode.connect(0, modelUpscaleNode, 1); + upscaleLoaderNode.connect(0, modelUpscaleNode, 0); - upscaleNode = LiteGraph.createNode("ImageScale"); - graph.add(upscaleNode); - modelUpscaleNode.connect(0, upscaleNode, 0); + upscaleNode = LiteGraph.createNode("ImageScale"); + graph.add(upscaleNode); + modelUpscaleNode.connect(0, upscaleNode, 0); - const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled")); - graph.add(vaeEncodeNode); - upscaleNode.connect(0, vaeEncodeNode, 0); - vaeLoaderNode.connect(0, vaeEncodeNode, 1); - } + const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled")); + graph.add(vaeEncodeNode); + upscaleNode.connect(0, vaeEncodeNode, 0); + vaeLoaderNode.connect(0, vaeEncodeNode, 1); + } - setWidgetValue(upscaleNode, "width", ceil64(uw)); - setWidgetValue(upscaleNode, "height", ceil64(uh)); + setWidgetValue(upscaleNode, "width", ceil64(uw)); + setWidgetValue(upscaleNode, "height", ceil64(uh)); - hrSamplerNode = LiteGraph.createNode("KSampler"); - graph.add(hrSamplerNode); - ckptNode.connect(0, hrSamplerNode, 0); - positiveNode.connect(0, hrSamplerNode, 1); - negativeNode.connect(0, hrSamplerNode, 2); - latentNode.connect(0, hrSamplerNode, 3); - hrSamplerNode.connect(0, vaeNode, 0); - } - }, - steps(v) { - setWidgetValue(samplerNode, "steps", +v); - }, - seed(v) { - setWidgetValue(samplerNode, "seed", +v); - }, - }; + hrSamplerNode = LiteGraph.createNode("KSampler"); + graph.add(hrSamplerNode); + ckptNode.connect(0, hrSamplerNode, 0); + positiveNode.connect(0, hrSamplerNode, 1); + negativeNode.connect(0, hrSamplerNode, 2); + latentNode.connect(0, hrSamplerNode, 3); + hrSamplerNode.connect(0, vaeNode, 0); + } + }, + steps(v) { + setWidgetValue(samplerNode, "steps", +v); + }, + seed(v) { + setWidgetValue(samplerNode, "seed", +v); + }, + }; - for (const opt in opts) { - if (opt in handlers) { - handlers[opt](popOpt(opt)); - } - } + for (const opt in opts) { + if (opt in handlers) { + handlers[opt](popOpt(opt)); + } + } - if (hrSamplerNode) { - setWidgetValue(hrSamplerNode, "steps", hrSteps? +hrSteps : getWidget(samplerNode, "steps").value); - setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value); - setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value); - setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value); - setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1")); - } + if (hrSamplerNode) { + setWidgetValue(hrSamplerNode, "steps", hrSteps? +hrSteps : getWidget(samplerNode, "steps").value); + setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value); + setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value); + setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value); + setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1")); + } - let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 }); - positive = n.text; - n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel); - negative = n.text; + let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 }); + positive = n.text; + n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel); + negative = n.text; - setWidgetValue(positiveNode, "text", replaceEmbeddings(positive)); - setWidgetValue(negativeNode, "text", replaceEmbeddings(negative)); + setWidgetValue(positiveNode, "text", replaceEmbeddings(positive)); + setWidgetValue(negativeNode, "text", replaceEmbeddings(negative)); - graph.arrange(); + graph.arrange(); - for (const opt of ["model hash", "ensd", "version", "vae hash", "ti hashes", "lora hashes", "hashes"]) { - delete opts[opt]; - } + for (const opt of ["model hash", "ensd", "version", "vae hash", "ti hashes", "lora hashes", "hashes"]) { + delete opts[opt]; + } - console.warn("Unhandled parameters:", opts); - } - } + console.warn("Unhandled parameters:", opts); + } + } } diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index fd49320bb..8f9ba1f46 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -8,8 +8,8 @@ import { TaskItem } from "/types/apiTypes"; export const ComfyDialog = _ComfyDialog; type Position2D = { - x: number, - y: number + x: number, + y: number }; type Props = { @@ -72,610 +72,610 @@ export function $el(tag: string, propsOrChildren?: Children | Props, children?: } function dragElement(dragEl, settings) { - var posDiffX = 0, - posDiffY = 0, - posStartX = 0, - posStartY = 0, - newPosX = 0, - newPosY = 0; - if (dragEl.getElementsByClassName("drag-handle")[0]) { - // if present, the handle is where you move the DIV from: - dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown; - } else { - // otherwise, move the DIV from anywhere inside the DIV: - dragEl.onmousedown = dragMouseDown; - } + var posDiffX = 0, + posDiffY = 0, + posStartX = 0, + posStartY = 0, + newPosX = 0, + newPosY = 0; + if (dragEl.getElementsByClassName("drag-handle")[0]) { + // if present, the handle is where you move the DIV from: + dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown; + } else { + // otherwise, move the DIV from anywhere inside the DIV: + dragEl.onmousedown = dragMouseDown; + } - // When the element resizes (e.g. view queue) ensure it is still in the windows bounds - const resizeObserver = new ResizeObserver(() => { - ensureInBounds(); - }).observe(dragEl); + // When the element resizes (e.g. view queue) ensure it is still in the windows bounds + const resizeObserver = new ResizeObserver(() => { + ensureInBounds(); + }).observe(dragEl); - function ensureInBounds() { - try { - newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft)); - newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop)); + function ensureInBounds() { + try { + newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft)); + newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop)); - positionElement(); - } - catch (exception) { - // robust - } - } + positionElement(); + } + catch (exception) { + // robust + } + } - function positionElement() { - if (dragEl.style.display === "none") return; + function positionElement() { + if (dragEl.style.display === "none") return; - const halfWidth = document.body.clientWidth / 2; - const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth; + const halfWidth = document.body.clientWidth / 2; + const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth; - // set the element's new position: - if (anchorRight) { - dragEl.style.left = "unset"; - dragEl.style.right = document.body.clientWidth - newPosX - dragEl.clientWidth + "px"; - } else { - dragEl.style.left = newPosX + "px"; - dragEl.style.right = "unset"; - } + // set the element's new position: + if (anchorRight) { + dragEl.style.left = "unset"; + dragEl.style.right = document.body.clientWidth - newPosX - dragEl.clientWidth + "px"; + } else { + dragEl.style.left = newPosX + "px"; + dragEl.style.right = "unset"; + } - dragEl.style.top = newPosY + "px"; - dragEl.style.bottom = "unset"; + dragEl.style.top = newPosY + "px"; + dragEl.style.bottom = "unset"; - if (savePos) { - localStorage.setItem( - "Comfy.MenuPosition", - JSON.stringify({ - x: dragEl.offsetLeft, - y: dragEl.offsetTop, - }) - ); - } - } + if (savePos) { + localStorage.setItem( + "Comfy.MenuPosition", + JSON.stringify({ + x: dragEl.offsetLeft, + y: dragEl.offsetTop, + }) + ); + } + } - function restorePos() { - let posString = localStorage.getItem("Comfy.MenuPosition"); - if (posString) { - const pos = JSON.parse(posString) as Position2D; - newPosX = pos.x; - newPosY = pos.y; - positionElement(); - ensureInBounds(); - } - } + function restorePos() { + let posString = localStorage.getItem("Comfy.MenuPosition"); + if (posString) { + const pos = JSON.parse(posString) as Position2D; + newPosX = pos.x; + newPosY = pos.y; + positionElement(); + ensureInBounds(); + } + } - let savePos = undefined; - settings.addSetting({ - id: "Comfy.MenuPosition", - name: "Save menu position", - type: "boolean", - defaultValue: savePos, - onChange(value) { - if (savePos === undefined && value) { - restorePos(); - } - savePos = value; - }, - }); + let savePos = undefined; + settings.addSetting({ + id: "Comfy.MenuPosition", + name: "Save menu position", + type: "boolean", + defaultValue: savePos, + onChange(value) { + if (savePos === undefined && value) { + restorePos(); + } + savePos = value; + }, + }); - function dragMouseDown(e) { - e = e || window.event; - e.preventDefault(); - // get the mouse cursor position at startup: - posStartX = e.clientX; - posStartY = e.clientY; - document.onmouseup = closeDragElement; - // call a function whenever the cursor moves: - document.onmousemove = elementDrag; - } + function dragMouseDown(e) { + e = e || window.event; + e.preventDefault(); + // get the mouse cursor position at startup: + posStartX = e.clientX; + posStartY = e.clientY; + document.onmouseup = closeDragElement; + // call a function whenever the cursor moves: + document.onmousemove = elementDrag; + } - function elementDrag(e) { - e = e || window.event; - e.preventDefault(); + function elementDrag(e) { + e = e || window.event; + e.preventDefault(); - dragEl.classList.add("comfy-menu-manual-pos"); + dragEl.classList.add("comfy-menu-manual-pos"); - // calculate the new cursor position: - posDiffX = e.clientX - posStartX; - posDiffY = e.clientY - posStartY; - posStartX = e.clientX; - posStartY = e.clientY; + // calculate the new cursor position: + posDiffX = e.clientX - posStartX; + posDiffY = e.clientY - posStartY; + posStartX = e.clientX; + posStartY = e.clientY; - newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft + posDiffX)); - newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop + posDiffY)); + newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft + posDiffX)); + newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop + posDiffY)); - positionElement(); - } + positionElement(); + } - window.addEventListener("resize", () => { - ensureInBounds(); - }); + window.addEventListener("resize", () => { + ensureInBounds(); + }); - function closeDragElement() { - // stop moving when mouse button is released: - document.onmouseup = null; - document.onmousemove = null; - } + function closeDragElement() { + // stop moving when mouse button is released: + document.onmouseup = null; + document.onmousemove = null; + } - return restorePos; + return restorePos; } class ComfyList { - #type; - #text; - #reverse; - element: HTMLDivElement; - button?: HTMLButtonElement; + #type; + #text; + #reverse; + element: HTMLDivElement; + button?: HTMLButtonElement; - constructor(text, type?, reverse?) { - this.#text = text; - this.#type = type || text.toLowerCase(); - this.#reverse = reverse || false; - this.element = $el("div.comfy-list") as HTMLDivElement; - this.element.style.display = "none"; - } + constructor(text, type?, reverse?) { + this.#text = text; + this.#type = type || text.toLowerCase(); + this.#reverse = reverse || false; + this.element = $el("div.comfy-list") as HTMLDivElement; + this.element.style.display = "none"; + } - get visible() { - return this.element.style.display !== "none"; - } + get visible() { + return this.element.style.display !== "none"; + } - async load() { - const items = await api.getItems(this.#type); - this.element.replaceChildren( - ...Object.keys(items).flatMap((section) => [ - $el("h4", { - textContent: section, - }), - $el("div.comfy-list-items", [ - ...(this.#reverse ? items[section].reverse() : items[section]).map((item: TaskItem) => { - // Allow items to specify a custom remove action (e.g. for interrupt current prompt) - const removeAction = "remove" in item ? item.remove : { - name: "Delete", - cb: () => api.deleteItem(this.#type, item.prompt[1]), - }; - return $el("div", { textContent: item.prompt[0] + ": " }, [ - $el("button", { - textContent: "Load", - onclick: async () => { - await app.loadGraphData(item.prompt[3].extra_pnginfo.workflow, true, false); - if ("outputs" in item) { - app.nodeOutputs = item.outputs; - } - }, - }), - $el("button", { - textContent: removeAction.name, - onclick: async () => { - await removeAction.cb(); - await this.update(); - }, - }), - ]); - }), - ]), - ]), - $el("div.comfy-list-actions", [ - $el("button", { - textContent: "Clear " + this.#text, - onclick: async () => { - await api.clearItems(this.#type); - await this.load(); - }, - }), - $el("button", { textContent: "Refresh", onclick: () => this.load() }), - ]) - ); - } + async load() { + const items = await api.getItems(this.#type); + this.element.replaceChildren( + ...Object.keys(items).flatMap((section) => [ + $el("h4", { + textContent: section, + }), + $el("div.comfy-list-items", [ + ...(this.#reverse ? items[section].reverse() : items[section]).map((item: TaskItem) => { + // Allow items to specify a custom remove action (e.g. for interrupt current prompt) + const removeAction = "remove" in item ? item.remove : { + name: "Delete", + cb: () => api.deleteItem(this.#type, item.prompt[1]), + }; + return $el("div", { textContent: item.prompt[0] + ": " }, [ + $el("button", { + textContent: "Load", + onclick: async () => { + await app.loadGraphData(item.prompt[3].extra_pnginfo.workflow, true, false); + if ("outputs" in item) { + app.nodeOutputs = item.outputs; + } + }, + }), + $el("button", { + textContent: removeAction.name, + onclick: async () => { + await removeAction.cb(); + await this.update(); + }, + }), + ]); + }), + ]), + ]), + $el("div.comfy-list-actions", [ + $el("button", { + textContent: "Clear " + this.#text, + onclick: async () => { + await api.clearItems(this.#type); + await this.load(); + }, + }), + $el("button", { textContent: "Refresh", onclick: () => this.load() }), + ]) + ); + } - async update() { - if (this.visible) { - await this.load(); - } - } + async update() { + if (this.visible) { + await this.load(); + } + } - async show() { - this.element.style.display = "block"; - this.button.textContent = "Close"; + async show() { + this.element.style.display = "block"; + this.button.textContent = "Close"; - await this.load(); - } + await this.load(); + } - hide() { - this.element.style.display = "none"; - this.button.textContent = "View " + this.#text; - } + hide() { + this.element.style.display = "none"; + this.button.textContent = "View " + this.#text; + } - toggle() { - if (this.visible) { - this.hide(); - return false; - } else { - this.show(); - return true; - } - } + toggle() { + if (this.visible) { + this.hide(); + return false; + } else { + this.show(); + return true; + } + } } export class ComfyUI { - app: ComfyApp; - dialog: _ComfyDialog; - settings: ComfySettingsDialog; - batchCount: number; - lastQueueSize: number; - queue: ComfyList; - history: ComfyList; - autoQueueMode: string; - graphHasChanged: boolean; - autoQueueEnabled: boolean; - menuHamburger: HTMLDivElement; - menuContainer: HTMLDivElement; - queueSize: Element; + app: ComfyApp; + dialog: _ComfyDialog; + settings: ComfySettingsDialog; + batchCount: number; + lastQueueSize: number; + queue: ComfyList; + history: ComfyList; + autoQueueMode: string; + graphHasChanged: boolean; + autoQueueEnabled: boolean; + menuHamburger: HTMLDivElement; + menuContainer: HTMLDivElement; + queueSize: Element; - constructor(app) { - this.app = app; - this.dialog = new ComfyDialog(); - this.settings = new ComfySettingsDialog(app); + constructor(app) { + this.app = app; + this.dialog = new ComfyDialog(); + this.settings = new ComfySettingsDialog(app); - this.batchCount = 1; - this.lastQueueSize = 0; - this.queue = new ComfyList("Queue"); - this.history = new ComfyList("History", "history", true); + this.batchCount = 1; + this.lastQueueSize = 0; + this.queue = new ComfyList("Queue"); + this.history = new ComfyList("History", "history", true); - api.addEventListener("status", () => { - this.queue.update(); - this.history.update(); - }); + api.addEventListener("status", () => { + this.queue.update(); + this.history.update(); + }); - const confirmClear = this.settings.addSetting({ - id: "Comfy.ConfirmClear", - name: "Require confirmation when clearing workflow", - type: "boolean", - defaultValue: true, - }); + const confirmClear = this.settings.addSetting({ + id: "Comfy.ConfirmClear", + name: "Require confirmation when clearing workflow", + type: "boolean", + defaultValue: true, + }); - const promptFilename = this.settings.addSetting({ - id: "Comfy.PromptFilename", - name: "Prompt for filename when saving workflow", - type: "boolean", - defaultValue: true, - }); + const promptFilename = this.settings.addSetting({ + id: "Comfy.PromptFilename", + name: "Prompt for filename when saving workflow", + type: "boolean", + defaultValue: true, + }); - /** - * file format for preview - * - * format;quality - * - * ex) - * webp;50 -> webp, quality 50 - * jpeg;80 -> rgb, jpeg, quality 80 - * - * @type {string} - */ - const previewImage = this.settings.addSetting({ - id: "Comfy.PreviewFormat", - name: "When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.", - type: "text", - defaultValue: "", - }); + /** + * file format for preview + * + * format;quality + * + * ex) + * webp;50 -> webp, quality 50 + * jpeg;80 -> rgb, jpeg, quality 80 + * + * @type {string} + */ + const previewImage = this.settings.addSetting({ + id: "Comfy.PreviewFormat", + name: "When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.", + type: "text", + defaultValue: "", + }); - this.settings.addSetting({ - id: "Comfy.DisableSliders", - name: "Disable sliders.", - type: "boolean", - defaultValue: false, - }); + this.settings.addSetting({ + id: "Comfy.DisableSliders", + name: "Disable sliders.", + type: "boolean", + defaultValue: false, + }); - this.settings.addSetting({ - id: "Comfy.DisableFloatRounding", - name: "Disable rounding floats (requires page reload).", - type: "boolean", - defaultValue: false, - }); + this.settings.addSetting({ + id: "Comfy.DisableFloatRounding", + name: "Disable rounding floats (requires page reload).", + type: "boolean", + defaultValue: false, + }); - this.settings.addSetting({ - id: "Comfy.FloatRoundingPrecision", - name: "Decimal places [0 = auto] (requires page reload).", - type: "slider", - attrs: { - min: 0, - max: 6, - step: 1, - }, - defaultValue: 0, - }); + this.settings.addSetting({ + id: "Comfy.FloatRoundingPrecision", + name: "Decimal places [0 = auto] (requires page reload).", + type: "slider", + attrs: { + min: 0, + max: 6, + step: 1, + }, + defaultValue: 0, + }); - const fileInput = $el("input", { - id: "comfy-file-input", - type: "file", - accept: ".json,image/png,.latent,.safetensors,image/webp,audio/flac", - style: { display: "none" }, - parent: document.body, - onchange: () => { - app.handleFile(fileInput.files[0]); - }, - }) as HTMLInputElement; + const fileInput = $el("input", { + id: "comfy-file-input", + type: "file", + accept: ".json,image/png,.latent,.safetensors,image/webp,audio/flac", + style: { display: "none" }, + parent: document.body, + onchange: () => { + app.handleFile(fileInput.files[0]); + }, + }) as HTMLInputElement; - // @ts-ignore - this.loadFile = () => fileInput.click(); + // @ts-ignore + this.loadFile = () => fileInput.click(); - const autoQueueModeEl = toggleSwitch( - "autoQueueMode", - [ - { text: "instant", tooltip: "A new prompt will be queued as soon as the queue reaches 0" }, - { text: "change", tooltip: "A new prompt will be queued when the queue is at 0 and the graph is/has changed" }, - ], - { - onChange: (value) => { - this.autoQueueMode = value.item.value; - }, - } - ); - autoQueueModeEl.style.display = "none"; + const autoQueueModeEl = toggleSwitch( + "autoQueueMode", + [ + { text: "instant", tooltip: "A new prompt will be queued as soon as the queue reaches 0" }, + { text: "change", tooltip: "A new prompt will be queued when the queue is at 0 and the graph is/has changed" }, + ], + { + onChange: (value) => { + this.autoQueueMode = value.item.value; + }, + } + ); + autoQueueModeEl.style.display = "none"; - api.addEventListener("graphChanged", () => { - if (this.autoQueueMode === "change" && this.autoQueueEnabled === true) { - if (this.lastQueueSize === 0) { - this.graphHasChanged = false; - app.queuePrompt(0, this.batchCount); - } else { - this.graphHasChanged = true; - } - } - }); + api.addEventListener("graphChanged", () => { + if (this.autoQueueMode === "change" && this.autoQueueEnabled === true) { + if (this.lastQueueSize === 0) { + this.graphHasChanged = false; + app.queuePrompt(0, this.batchCount); + } else { + this.graphHasChanged = true; + } + } + }); - this.menuHamburger = $el( - "div.comfy-menu-hamburger", - { - parent: document.body, - onclick: () => { - this.menuContainer.style.display = "block"; - this.menuHamburger.style.display = "none"; - }, - }, - [$el("div"), $el("div"), $el("div")] - ) as HTMLDivElement; + this.menuHamburger = $el( + "div.comfy-menu-hamburger", + { + parent: document.body, + onclick: () => { + this.menuContainer.style.display = "block"; + this.menuHamburger.style.display = "none"; + }, + }, + [$el("div"), $el("div"), $el("div")] + ) as HTMLDivElement; - this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [ - $el("div.drag-handle.comfy-menu-header", { - style: { - overflow: "hidden", - position: "relative", - width: "100%", - cursor: "default" - } - }, [ - $el("span.drag-handle"), - $el("span.comfy-menu-queue-size", { $: (q) => (this.queueSize = q) }), - $el("div.comfy-menu-actions", [ - $el("button.comfy-settings-btn", { - textContent: "⚙️", - onclick: () => this.settings.show(), - }), - $el("button.comfy-close-menu-btn", { - textContent: "\u00d7", - onclick: () => { - this.menuContainer.style.display = "none"; - this.menuHamburger.style.display = "flex"; - }, - }), - ]), - ]), - $el("button.comfy-queue-btn", { - id: "queue-button", - textContent: "Queue Prompt", - onclick: () => app.queuePrompt(0, this.batchCount), - }), - $el("div", {}, [ - $el("label", { innerHTML: "Extra options" }, [ - $el("input", { - type: "checkbox", - onchange: (i) => { - document.getElementById("extraOptions").style.display = i.srcElement.checked ? "block" : "none"; - this.batchCount = i.srcElement.checked ? - Number.parseInt((document.getElementById("batchCountInputRange") as HTMLInputElement).value) : 1; - (document.getElementById("autoQueueCheckbox") as HTMLInputElement).checked = false; - this.autoQueueEnabled = false; - }, - }), - ]), - ]), - $el("div", { id: "extraOptions", style: { width: "100%", display: "none" } }, [ - $el("div", [ + this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [ + $el("div.drag-handle.comfy-menu-header", { + style: { + overflow: "hidden", + position: "relative", + width: "100%", + cursor: "default" + } + }, [ + $el("span.drag-handle"), + $el("span.comfy-menu-queue-size", { $: (q) => (this.queueSize = q) }), + $el("div.comfy-menu-actions", [ + $el("button.comfy-settings-btn", { + textContent: "⚙️", + onclick: () => this.settings.show(), + }), + $el("button.comfy-close-menu-btn", { + textContent: "\u00d7", + onclick: () => { + this.menuContainer.style.display = "none"; + this.menuHamburger.style.display = "flex"; + }, + }), + ]), + ]), + $el("button.comfy-queue-btn", { + id: "queue-button", + textContent: "Queue Prompt", + onclick: () => app.queuePrompt(0, this.batchCount), + }), + $el("div", {}, [ + $el("label", { innerHTML: "Extra options" }, [ + $el("input", { + type: "checkbox", + onchange: (i) => { + document.getElementById("extraOptions").style.display = i.srcElement.checked ? "block" : "none"; + this.batchCount = i.srcElement.checked ? + Number.parseInt((document.getElementById("batchCountInputRange") as HTMLInputElement).value) : 1; + (document.getElementById("autoQueueCheckbox") as HTMLInputElement).checked = false; + this.autoQueueEnabled = false; + }, + }), + ]), + ]), + $el("div", { id: "extraOptions", style: { width: "100%", display: "none" } }, [ + $el("div", [ - $el("label", { innerHTML: "Batch count" }), - $el("input", { - id: "batchCountInputNumber", - type: "number", - value: this.batchCount, - min: "1", - style: { width: "35%", "marginLeft": "0.4em" }, - oninput: (i) => { - this.batchCount = i.target.value; - /* Even though an element with a type of range logically represents a number (since - it's used for numeric input), the value it holds is still treated as a string in HTML and - JavaScript. This behavior is consistent across all elements regardless of their type - (like text, number, or range), where the .value property is always a string. */ - (document.getElementById("batchCountInputRange") as HTMLInputElement).value = this.batchCount.toString(); - }, - }), - $el("input", { - id: "batchCountInputRange", - type: "range", - min: "1", - max: "100", - value: this.batchCount, - oninput: (i) => { - this.batchCount = i.srcElement.value; - // Note - (document.getElementById("batchCountInputNumber") as HTMLInputElement).value = i.srcElement.value; - }, - }), - ]), - $el("div", [ - $el("label", { - for: "autoQueueCheckbox", - innerHTML: "Auto Queue" - }), - $el("input", { - id: "autoQueueCheckbox", - type: "checkbox", - checked: false, - title: "Automatically queue prompt when the queue size hits 0", - onchange: (e) => { - this.autoQueueEnabled = e.target.checked; - autoQueueModeEl.style.display = this.autoQueueEnabled ? "" : "none"; - } - }), - autoQueueModeEl - ]) - ]), - $el("div.comfy-menu-btns", [ - $el("button", { - id: "queue-front-button", - textContent: "Queue Front", - onclick: () => app.queuePrompt(-1, this.batchCount) - }), - $el("button", { - $: (b) => (this.queue.button = b as HTMLButtonElement), - id: "comfy-view-queue-button", - textContent: "View Queue", - onclick: () => { - this.history.hide(); - this.queue.toggle(); - }, - }), - $el("button", { - $: (b) => (this.history.button = b as HTMLButtonElement), - id: "comfy-view-history-button", - textContent: "View History", - onclick: () => { - this.queue.hide(); - this.history.toggle(); - }, - }), - ]), - this.queue.element, - this.history.element, - $el("button", { - id: "comfy-save-button", - textContent: "Save", - onclick: () => { - let filename = "workflow.json"; - if (promptFilename.value) { - filename = prompt("Save workflow as:", filename); - if (!filename) return; - if (!filename.toLowerCase().endsWith(".json")) { - filename += ".json"; - } - } - app.graphToPrompt().then(p => { - const json = JSON.stringify(p.workflow, null, 2); // convert the data to a JSON string - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = $el("a", { - href: url, - download: filename, - style: { display: "none" }, - parent: document.body, - }); - a.click(); - setTimeout(function () { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); - }); - }, - }), - $el("button", { - id: "comfy-dev-save-api-button", - textContent: "Save (API Format)", - style: { width: "100%", display: "none" }, - onclick: () => { - let filename = "workflow_api.json"; - if (promptFilename.value) { - filename = prompt("Save workflow (API) as:", filename); - if (!filename) return; - if (!filename.toLowerCase().endsWith(".json")) { - filename += ".json"; - } - } - app.graphToPrompt().then(p => { - const json = JSON.stringify(p.output, null, 2); // convert the data to a JSON string - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = $el("a", { - href: url, - download: filename, - style: { display: "none" }, - parent: document.body, - }); - a.click(); - setTimeout(function () { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); - }); - }, - }), - $el("button", { id: "comfy-load-button", textContent: "Load", onclick: () => fileInput.click() }), - $el("button", { - id: "comfy-refresh-button", - textContent: "Refresh", - onclick: () => app.refreshComboInNodes() - }), - $el("button", { id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace() }), - $el("button", { - id: "comfy-clear-button", textContent: "Clear", onclick: () => { - if (!confirmClear.value || confirm("Clear workflow?")) { - app.clean(); - app.graph.clear(); - app.resetView(); - } - } - }), - $el("button", { - id: "comfy-load-default-button", textContent: "Load Default", onclick: async () => { - if (!confirmClear.value || confirm("Load default workflow?")) { - app.resetView(); - await app.loadGraphData() - } - } - }), - $el("button", { - id: "comfy-reset-view-button", textContent: "Reset View", onclick: async () => { - app.resetView(); - } - }), - ]) as HTMLDivElement; + $el("label", { innerHTML: "Batch count" }), + $el("input", { + id: "batchCountInputNumber", + type: "number", + value: this.batchCount, + min: "1", + style: { width: "35%", "marginLeft": "0.4em" }, + oninput: (i) => { + this.batchCount = i.target.value; + /* Even though an element with a type of range logically represents a number (since + it's used for numeric input), the value it holds is still treated as a string in HTML and + JavaScript. This behavior is consistent across all elements regardless of their type + (like text, number, or range), where the .value property is always a string. */ + (document.getElementById("batchCountInputRange") as HTMLInputElement).value = this.batchCount.toString(); + }, + }), + $el("input", { + id: "batchCountInputRange", + type: "range", + min: "1", + max: "100", + value: this.batchCount, + oninput: (i) => { + this.batchCount = i.srcElement.value; + // Note + (document.getElementById("batchCountInputNumber") as HTMLInputElement).value = i.srcElement.value; + }, + }), + ]), + $el("div", [ + $el("label", { + for: "autoQueueCheckbox", + innerHTML: "Auto Queue" + }), + $el("input", { + id: "autoQueueCheckbox", + type: "checkbox", + checked: false, + title: "Automatically queue prompt when the queue size hits 0", + onchange: (e) => { + this.autoQueueEnabled = e.target.checked; + autoQueueModeEl.style.display = this.autoQueueEnabled ? "" : "none"; + } + }), + autoQueueModeEl + ]) + ]), + $el("div.comfy-menu-btns", [ + $el("button", { + id: "queue-front-button", + textContent: "Queue Front", + onclick: () => app.queuePrompt(-1, this.batchCount) + }), + $el("button", { + $: (b) => (this.queue.button = b as HTMLButtonElement), + id: "comfy-view-queue-button", + textContent: "View Queue", + onclick: () => { + this.history.hide(); + this.queue.toggle(); + }, + }), + $el("button", { + $: (b) => (this.history.button = b as HTMLButtonElement), + id: "comfy-view-history-button", + textContent: "View History", + onclick: () => { + this.queue.hide(); + this.history.toggle(); + }, + }), + ]), + this.queue.element, + this.history.element, + $el("button", { + id: "comfy-save-button", + textContent: "Save", + onclick: () => { + let filename = "workflow.json"; + if (promptFilename.value) { + filename = prompt("Save workflow as:", filename); + if (!filename) return; + if (!filename.toLowerCase().endsWith(".json")) { + filename += ".json"; + } + } + app.graphToPrompt().then(p => { + const json = JSON.stringify(p.workflow, null, 2); // convert the data to a JSON string + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = $el("a", { + href: url, + download: filename, + style: { display: "none" }, + parent: document.body, + }); + a.click(); + setTimeout(function () { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); + }); + }, + }), + $el("button", { + id: "comfy-dev-save-api-button", + textContent: "Save (API Format)", + style: { width: "100%", display: "none" }, + onclick: () => { + let filename = "workflow_api.json"; + if (promptFilename.value) { + filename = prompt("Save workflow (API) as:", filename); + if (!filename) return; + if (!filename.toLowerCase().endsWith(".json")) { + filename += ".json"; + } + } + app.graphToPrompt().then(p => { + const json = JSON.stringify(p.output, null, 2); // convert the data to a JSON string + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = $el("a", { + href: url, + download: filename, + style: { display: "none" }, + parent: document.body, + }); + a.click(); + setTimeout(function () { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); + }); + }, + }), + $el("button", { id: "comfy-load-button", textContent: "Load", onclick: () => fileInput.click() }), + $el("button", { + id: "comfy-refresh-button", + textContent: "Refresh", + onclick: () => app.refreshComboInNodes() + }), + $el("button", { id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace() }), + $el("button", { + id: "comfy-clear-button", textContent: "Clear", onclick: () => { + if (!confirmClear.value || confirm("Clear workflow?")) { + app.clean(); + app.graph.clear(); + app.resetView(); + } + } + }), + $el("button", { + id: "comfy-load-default-button", textContent: "Load Default", onclick: async () => { + if (!confirmClear.value || confirm("Load default workflow?")) { + app.resetView(); + await app.loadGraphData() + } + } + }), + $el("button", { + id: "comfy-reset-view-button", textContent: "Reset View", onclick: async () => { + app.resetView(); + } + }), + ]) as HTMLDivElement; - const devMode = this.settings.addSetting({ - id: "Comfy.DevMode", - name: "Enable Dev mode Options", - type: "boolean", - defaultValue: false, - onChange: function (value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "flex" : "none" }, - }); + const devMode = this.settings.addSetting({ + id: "Comfy.DevMode", + name: "Enable Dev mode Options", + type: "boolean", + defaultValue: false, + onChange: function (value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "flex" : "none" }, + }); - // @ts-ignore - this.restoreMenuPosition = dragElement(this.menuContainer, this.settings); + // @ts-ignore + this.restoreMenuPosition = dragElement(this.menuContainer, this.settings); - this.setStatus({ exec_info: { queue_remaining: "X" } }); - } + this.setStatus({ exec_info: { queue_remaining: "X" } }); + } - setStatus(status) { - this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR"); - if (status) { - if ( - this.lastQueueSize != 0 && - status.exec_info.queue_remaining == 0 && - this.autoQueueEnabled && - (this.autoQueueMode === "instant" || this.graphHasChanged) && - !app.lastExecutionError - ) { - app.queuePrompt(0, this.batchCount); - status.exec_info.queue_remaining += this.batchCount; - this.graphHasChanged = false; - } - this.lastQueueSize = status.exec_info.queue_remaining; - } - } + setStatus(status) { + this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR"); + if (status) { + if ( + this.lastQueueSize != 0 && + status.exec_info.queue_remaining == 0 && + this.autoQueueEnabled && + (this.autoQueueMode === "instant" || this.graphHasChanged) && + !app.lastExecutionError + ) { + app.queuePrompt(0, this.batchCount); + status.exec_info.queue_remaining += this.batchCount; + this.graphHasChanged = false; + } + this.lastQueueSize = status.exec_info.queue_remaining; + } + } } diff --git a/src/scripts/ui/dialog.ts b/src/scripts/ui/dialog.ts index c42c904ed..8a90d2555 100644 --- a/src/scripts/ui/dialog.ts +++ b/src/scripts/ui/dialog.ts @@ -1,40 +1,40 @@ import { $el } from "../ui"; export class ComfyDialog extends EventTarget { - element: T; - textElement: HTMLElement; - #buttons: HTMLButtonElement[] | null; + element: T; + textElement: HTMLElement; + #buttons: HTMLButtonElement[] | null; - constructor(type = "div", buttons = null) { - super(); - this.#buttons = buttons; - this.element = $el(type + ".comfy-modal", { parent: document.body }, [ - $el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]), - ]) as T; - } + constructor(type = "div", buttons = null) { + super(); + this.#buttons = buttons; + this.element = $el(type + ".comfy-modal", { parent: document.body }, [ + $el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]), + ]) as T; + } - createButtons() { - return ( - this.#buttons ?? [ - $el("button", { - type: "button", - textContent: "Close", - onclick: () => this.close(), - }), - ] - ); - } + createButtons() { + return ( + this.#buttons ?? [ + $el("button", { + type: "button", + textContent: "Close", + onclick: () => this.close(), + }), + ] + ); + } - close() { - this.element.style.display = "none"; - } + close() { + this.element.style.display = "none"; + } - show(html) { - if (typeof html === "string") { - this.textElement.innerHTML = html; - } else { - this.textElement.replaceChildren(...(html instanceof Array ? html : [html])); - } - this.element.style.display = "flex"; - } + show(html) { + if (typeof html === "string") { + this.textElement.innerHTML = html; + } else { + this.textElement.replaceChildren(...(html instanceof Array ? html : [html])); + } + this.element.style.display = "flex"; + } } diff --git a/src/scripts/ui/draggableList.ts b/src/scripts/ui/draggableList.ts index 639bdfbd7..81c26c34e 100644 --- a/src/scripts/ui/draggableList.ts +++ b/src/scripts/ui/draggableList.ts @@ -1,5 +1,5 @@ /* - Original implementation: + Original implementation: https://github.com/TahaSh/drag-to-reorder MIT License @@ -44,243 +44,243 @@ $el("style", { }); export class DraggableList extends EventTarget { - listContainer; - draggableItem; - pointerStartX; - pointerStartY; - scrollYMax; - itemsGap = 0; - items = []; - itemSelector; - handleClass = "drag-handle"; - off = []; - offDrag = []; + listContainer; + draggableItem; + pointerStartX; + pointerStartY; + scrollYMax; + itemsGap = 0; + items = []; + itemSelector; + handleClass = "drag-handle"; + off = []; + offDrag = []; - constructor(element, itemSelector) { + constructor(element, itemSelector) { super(); - this.listContainer = element; + this.listContainer = element; this.itemSelector = itemSelector; - if (!this.listContainer) return; + if (!this.listContainer) return; - this.off.push(this.on(this.listContainer, "mousedown", this.dragStart)); - this.off.push(this.on(this.listContainer, "touchstart", this.dragStart)); - this.off.push(this.on(document, "mouseup", this.dragEnd)); - this.off.push(this.on(document, "touchend", this.dragEnd)); - } + this.off.push(this.on(this.listContainer, "mousedown", this.dragStart)); + this.off.push(this.on(this.listContainer, "touchstart", this.dragStart)); + this.off.push(this.on(document, "mouseup", this.dragEnd)); + this.off.push(this.on(document, "touchend", this.dragEnd)); + } - getAllItems() { - if (!this.items?.length) { - this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector)); - this.items.forEach((element) => { - element.classList.add("is-idle"); - }); - } - return this.items; - } + getAllItems() { + if (!this.items?.length) { + this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector)); + this.items.forEach((element) => { + element.classList.add("is-idle"); + }); + } + return this.items; + } - getIdleItems() { - return this.getAllItems().filter((item) => item.classList.contains("is-idle")); - } + getIdleItems() { + return this.getAllItems().filter((item) => item.classList.contains("is-idle")); + } - isItemAbove(item) { - return item.hasAttribute("data-is-above"); - } + isItemAbove(item) { + return item.hasAttribute("data-is-above"); + } - isItemToggled(item) { - return item.hasAttribute("data-is-toggled"); - } + isItemToggled(item) { + return item.hasAttribute("data-is-toggled"); + } - on(source, event, listener, options?) { - listener = listener.bind(this); - source.addEventListener(event, listener, options); - return () => source.removeEventListener(event, listener); - } + on(source, event, listener, options?) { + listener = listener.bind(this); + source.addEventListener(event, listener, options); + return () => source.removeEventListener(event, listener); + } - dragStart(e) { - if (e.target.classList.contains(this.handleClass)) { - this.draggableItem = e.target.closest(this.itemSelector); - } + dragStart(e) { + if (e.target.classList.contains(this.handleClass)) { + this.draggableItem = e.target.closest(this.itemSelector); + } - if (!this.draggableItem) return; + if (!this.draggableItem) return; - this.pointerStartX = e.clientX || e.touches[0].clientX; - this.pointerStartY = e.clientY || e.touches[0].clientY; - this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight; + this.pointerStartX = e.clientX || e.touches[0].clientX; + this.pointerStartY = e.clientY || e.touches[0].clientY; + this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight; - this.setItemsGap(); - this.initDraggableItem(); - this.initItemsState(); + this.setItemsGap(); + this.initDraggableItem(); + this.initItemsState(); - this.offDrag.push(this.on(document, "mousemove", this.drag)); - this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false })); + this.offDrag.push(this.on(document, "mousemove", this.drag)); + this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false })); - this.dispatchEvent( - new CustomEvent("dragstart", { - detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) }, - }) - ); - } + this.dispatchEvent( + new CustomEvent("dragstart", { + detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) }, + }) + ); + } - setItemsGap() { - if (this.getIdleItems().length <= 1) { - this.itemsGap = 0; - return; - } + setItemsGap() { + if (this.getIdleItems().length <= 1) { + this.itemsGap = 0; + return; + } - const item1 = this.getIdleItems()[0]; - const item2 = this.getIdleItems()[1]; + const item1 = this.getIdleItems()[0]; + const item2 = this.getIdleItems()[1]; - const item1Rect = item1.getBoundingClientRect(); - const item2Rect = item2.getBoundingClientRect(); + const item1Rect = item1.getBoundingClientRect(); + const item2Rect = item2.getBoundingClientRect(); - this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top); - } + this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top); + } - initItemsState() { - this.getIdleItems().forEach((item, i) => { - if (this.getAllItems().indexOf(this.draggableItem) > i) { - item.dataset.isAbove = ""; - } - }); - } + initItemsState() { + this.getIdleItems().forEach((item, i) => { + if (this.getAllItems().indexOf(this.draggableItem) > i) { + item.dataset.isAbove = ""; + } + }); + } - initDraggableItem() { - this.draggableItem.classList.remove("is-idle"); - this.draggableItem.classList.add("is-draggable"); - } + initDraggableItem() { + this.draggableItem.classList.remove("is-idle"); + this.draggableItem.classList.add("is-draggable"); + } - drag(e) { - if (!this.draggableItem) return; + drag(e) { + if (!this.draggableItem) return; - e.preventDefault(); + e.preventDefault(); - const clientX = e.clientX || e.touches[0].clientX; - const clientY = e.clientY || e.touches[0].clientY; + const clientX = e.clientX || e.touches[0].clientX; + const clientY = e.clientY || e.touches[0].clientY; - const listRect = this.listContainer.getBoundingClientRect(); + const listRect = this.listContainer.getBoundingClientRect(); - if (clientY > listRect.bottom) { - if (this.listContainer.scrollTop < this.scrollYMax) { - this.listContainer.scrollBy(0, 10); - this.pointerStartY -= 10; - } - } else if (clientY < listRect.top && this.listContainer.scrollTop > 0) { - this.pointerStartY += 10; - this.listContainer.scrollBy(0, -10); - } + if (clientY > listRect.bottom) { + if (this.listContainer.scrollTop < this.scrollYMax) { + this.listContainer.scrollBy(0, 10); + this.pointerStartY -= 10; + } + } else if (clientY < listRect.top && this.listContainer.scrollTop > 0) { + this.pointerStartY += 10; + this.listContainer.scrollBy(0, -10); + } - const pointerOffsetX = clientX - this.pointerStartX; - const pointerOffsetY = clientY - this.pointerStartY; + const pointerOffsetX = clientX - this.pointerStartX; + const pointerOffsetY = clientY - this.pointerStartY; - this.updateIdleItemsStateAndPosition(); - this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`; - } + this.updateIdleItemsStateAndPosition(); + this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`; + } - updateIdleItemsStateAndPosition() { - const draggableItemRect = this.draggableItem.getBoundingClientRect(); - const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2; + updateIdleItemsStateAndPosition() { + const draggableItemRect = this.draggableItem.getBoundingClientRect(); + const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2; - // Update state - this.getIdleItems().forEach((item) => { - const itemRect = item.getBoundingClientRect(); - const itemY = itemRect.top + itemRect.height / 2; - if (this.isItemAbove(item)) { - if (draggableItemY <= itemY) { - item.dataset.isToggled = ""; - } else { - delete item.dataset.isToggled; - } - } else { - if (draggableItemY >= itemY) { - item.dataset.isToggled = ""; - } else { - delete item.dataset.isToggled; - } - } - }); + // Update state + this.getIdleItems().forEach((item) => { + const itemRect = item.getBoundingClientRect(); + const itemY = itemRect.top + itemRect.height / 2; + if (this.isItemAbove(item)) { + if (draggableItemY <= itemY) { + item.dataset.isToggled = ""; + } else { + delete item.dataset.isToggled; + } + } else { + if (draggableItemY >= itemY) { + item.dataset.isToggled = ""; + } else { + delete item.dataset.isToggled; + } + } + }); - // Update position - this.getIdleItems().forEach((item) => { - if (this.isItemToggled(item)) { - const direction = this.isItemAbove(item) ? 1 : -1; - item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`; - } else { - item.style.transform = ""; - } - }); - } + // Update position + this.getIdleItems().forEach((item) => { + if (this.isItemToggled(item)) { + const direction = this.isItemAbove(item) ? 1 : -1; + item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`; + } else { + item.style.transform = ""; + } + }); + } - dragEnd() { - if (!this.draggableItem) return; + dragEnd() { + if (!this.draggableItem) return; - this.applyNewItemsOrder(); - this.cleanup(); - } + this.applyNewItemsOrder(); + this.cleanup(); + } - applyNewItemsOrder() { - const reorderedItems = []; + applyNewItemsOrder() { + const reorderedItems = []; - let oldPosition = -1; - this.getAllItems().forEach((item, index) => { - if (item === this.draggableItem) { - oldPosition = index; - return; - } - if (!this.isItemToggled(item)) { - reorderedItems[index] = item; - return; - } - const newIndex = this.isItemAbove(item) ? index + 1 : index - 1; - reorderedItems[newIndex] = item; - }); + let oldPosition = -1; + this.getAllItems().forEach((item, index) => { + if (item === this.draggableItem) { + oldPosition = index; + return; + } + if (!this.isItemToggled(item)) { + reorderedItems[index] = item; + return; + } + const newIndex = this.isItemAbove(item) ? index + 1 : index - 1; + reorderedItems[newIndex] = item; + }); - for (let index = 0; index < this.getAllItems().length; index++) { - const item = reorderedItems[index]; - if (typeof item === "undefined") { - reorderedItems[index] = this.draggableItem; - } - } + for (let index = 0; index < this.getAllItems().length; index++) { + const item = reorderedItems[index]; + if (typeof item === "undefined") { + reorderedItems[index] = this.draggableItem; + } + } - reorderedItems.forEach((item) => { - this.listContainer.appendChild(item); - }); + reorderedItems.forEach((item) => { + this.listContainer.appendChild(item); + }); - this.items = reorderedItems; + this.items = reorderedItems; - this.dispatchEvent( - new CustomEvent("dragend", { - detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) }, - }) - ); - } + this.dispatchEvent( + new CustomEvent("dragend", { + detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) }, + }) + ); + } - cleanup() { - this.itemsGap = 0; - this.items = []; - this.unsetDraggableItem(); - this.unsetItemState(); + cleanup() { + this.itemsGap = 0; + this.items = []; + this.unsetDraggableItem(); + this.unsetItemState(); - this.offDrag.forEach((f) => f()); - this.offDrag = []; - } + this.offDrag.forEach((f) => f()); + this.offDrag = []; + } - unsetDraggableItem() { - this.draggableItem.style = null; - this.draggableItem.classList.remove("is-draggable"); - this.draggableItem.classList.add("is-idle"); - this.draggableItem = null; - } + unsetDraggableItem() { + this.draggableItem.style = null; + this.draggableItem.classList.remove("is-draggable"); + this.draggableItem.classList.add("is-idle"); + this.draggableItem = null; + } - unsetItemState() { - this.getIdleItems().forEach((item, i) => { - delete item.dataset.isAbove; - delete item.dataset.isToggled; - item.style.transform = ""; - }); - } + unsetItemState() { + this.getIdleItems().forEach((item, i) => { + delete item.dataset.isAbove; + delete item.dataset.isToggled; + item.style.transform = ""; + }); + } - dispose() { - this.off.forEach((f) => f()); - } + dispose() { + this.off.forEach((f) => f()); + } } diff --git a/src/scripts/ui/imagePreview.ts b/src/scripts/ui/imagePreview.ts index 897122831..0f20f6729 100644 --- a/src/scripts/ui/imagePreview.ts +++ b/src/scripts/ui/imagePreview.ts @@ -2,97 +2,97 @@ import { app } from "../app"; import { $el } from "../ui"; export function calculateImageGrid(imgs, dw, dh) { - let best = 0; - let w = imgs[0].naturalWidth; - let h = imgs[0].naturalHeight; - const numImages = imgs.length; + 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; + 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; + 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); - } - } + if (area > best) { + best = area; + cellWidth = imageW; + cellHeight = imageH; + cols = c; + rows = r; + shiftX = c * ((cW - imageW) / 2); + } + } - return { cellWidth, cellHeight, cols, rows, shiftX }; + return { cellWidth, cellHeight, cols, rows, shiftX }; } export function createImageHost(node) { - const el = $el("div.comfy-img-preview"); - let currentImgs; - let first = true; + const el = $el("div.comfy-img-preview"); + let currentImgs; + let first = true; - function updateSize() { - let w = null; - let h = null; + 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.toString()); - } else { - el.style.setProperty("--comfy-widget-min-height", 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.toString()); + } 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"; + 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"; + 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; - }, - }; + if(!over) return; + // Set the overIndex so Open Image etc work + const idx = currentImgs.indexOf(over); + node.overIndex = idx; + }, + }; } diff --git a/src/scripts/ui/menu/index.js b/src/scripts/ui/menu/index.js index 3f5c23f79..ddaf861cb 100644 --- a/src/scripts/ui/menu/index.js +++ b/src/scripts/ui/menu/index.js @@ -13,291 +13,291 @@ import { getInteruptButton } from "./interruptButton"; import "./menu.css"; const collapseOnMobile = (t) => { - (t.element ?? t).classList.add("comfyui-menu-mobile-collapse"); - return t; + (t.element ?? t).classList.add("comfyui-menu-mobile-collapse"); + return t; }; const showOnMobile = (t) => { - (t.element ?? t).classList.add("lt-lg-show"); - return t; + (t.element ?? t).classList.add("lt-lg-show"); + return t; }; export class ComfyAppMenu { - #sizeBreak = "lg"; - #lastSizeBreaks = { - lg: null, - md: null, - sm: null, - xs: null, - }; - #sizeBreaks = Object.keys(this.#lastSizeBreaks); - #cachedInnerSize = null; - #cacheTimeout = null; + #sizeBreak = "lg"; + #lastSizeBreaks = { + lg: null, + md: null, + sm: null, + xs: null, + }; + #sizeBreaks = Object.keys(this.#lastSizeBreaks); + #cachedInnerSize = null; + #cacheTimeout = null; - /** - * @param { import("../../app").ComfyApp } app - */ - constructor(app) { - this.app = app; + /** + * @param { import("../../app").ComfyApp } app + */ + constructor(app) { + this.app = app; - this.workflows = new ComfyWorkflowsMenu(app); - const getSaveButton = (t) => - new ComfyButton({ - icon: "content-save", - tooltip: "Save the current workflow", - action: () => app.workflowManager.activeWorkflow.save(), - content: t, - }); + this.workflows = new ComfyWorkflowsMenu(app); + const getSaveButton = (t) => + new ComfyButton({ + icon: "content-save", + tooltip: "Save the current workflow", + action: () => app.workflowManager.activeWorkflow.save(), + content: t, + }); - this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI"); - this.saveButton = new ComfySplitButton( - { - primary: getSaveButton(), - mode: "hover", - position: "absolute", - }, - getSaveButton("Save"), - new ComfyButton({ - icon: "content-save-edit", - content: "Save As", - tooltip: "Save the current graph as a new workflow", - action: () => app.workflowManager.activeWorkflow.save(true), - }), - new ComfyButton({ - icon: "download", - content: "Export", - tooltip: "Export the current workflow as JSON", - action: () => this.exportWorkflow("workflow", "workflow"), - }), - new ComfyButton({ - icon: "api", - content: "Export (API Format)", - tooltip: "Export the current workflow as JSON for use with the ComfyUI API", - action: () => this.exportWorkflow("workflow_api", "output"), - visibilitySetting: { id: "Comfy.DevMode", showValue: true }, - app, - }) - ); - this.actionsGroup = new ComfyButtonGroup( - new ComfyButton({ - icon: "refresh", - content: "Refresh", - tooltip: "Refresh widgets in nodes to find new models or files", - action: () => app.refreshComboInNodes(), - }), - new ComfyButton({ - icon: "clipboard-edit-outline", - content: "Clipspace", - tooltip: "Open Clipspace window", - action: () => app["openClipspace"](), - }), - new ComfyButton({ - icon: "fit-to-page-outline", - content: "Reset View", - tooltip: "Reset the canvas view", - action: () => app.resetView(), - }), - new ComfyButton({ - icon: "cancel", - content: "Clear", - tooltip: "Clears current workflow", - action: () => { - if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) { - app.clean(); - app.graph.clear(); - } - }, - }) - ); - this.settingsGroup = new ComfyButtonGroup( - new ComfyButton({ - icon: "cog", - content: "Settings", - tooltip: "Open settings", - action: () => { - app.ui.settings.show(); - }, - }) - ); - this.viewGroup = new ComfyButtonGroup( - new ComfyViewHistoryButton(app).element, - new ComfyViewQueueButton(app).element, - getInteruptButton("nlg-hide").element - ); - this.mobileMenuButton = new ComfyButton({ - icon: "menu", - action: (_, btn) => { - btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu"; - window.dispatchEvent(new Event("resize")); - }, - classList: "comfyui-button comfyui-menu-button", - }); + this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI"); + this.saveButton = new ComfySplitButton( + { + primary: getSaveButton(), + mode: "hover", + position: "absolute", + }, + getSaveButton("Save"), + new ComfyButton({ + icon: "content-save-edit", + content: "Save As", + tooltip: "Save the current graph as a new workflow", + action: () => app.workflowManager.activeWorkflow.save(true), + }), + new ComfyButton({ + icon: "download", + content: "Export", + tooltip: "Export the current workflow as JSON", + action: () => this.exportWorkflow("workflow", "workflow"), + }), + new ComfyButton({ + icon: "api", + content: "Export (API Format)", + tooltip: "Export the current workflow as JSON for use with the ComfyUI API", + action: () => this.exportWorkflow("workflow_api", "output"), + visibilitySetting: { id: "Comfy.DevMode", showValue: true }, + app, + }) + ); + this.actionsGroup = new ComfyButtonGroup( + new ComfyButton({ + icon: "refresh", + content: "Refresh", + tooltip: "Refresh widgets in nodes to find new models or files", + action: () => app.refreshComboInNodes(), + }), + new ComfyButton({ + icon: "clipboard-edit-outline", + content: "Clipspace", + tooltip: "Open Clipspace window", + action: () => app["openClipspace"](), + }), + new ComfyButton({ + icon: "fit-to-page-outline", + content: "Reset View", + tooltip: "Reset the canvas view", + action: () => app.resetView(), + }), + new ComfyButton({ + icon: "cancel", + content: "Clear", + tooltip: "Clears current workflow", + action: () => { + if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) { + app.clean(); + app.graph.clear(); + } + }, + }) + ); + this.settingsGroup = new ComfyButtonGroup( + new ComfyButton({ + icon: "cog", + content: "Settings", + tooltip: "Open settings", + action: () => { + app.ui.settings.show(); + }, + }) + ); + this.viewGroup = new ComfyButtonGroup( + new ComfyViewHistoryButton(app).element, + new ComfyViewQueueButton(app).element, + getInteruptButton("nlg-hide").element + ); + this.mobileMenuButton = new ComfyButton({ + icon: "menu", + action: (_, btn) => { + btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu"; + window.dispatchEvent(new Event("resize")); + }, + classList: "comfyui-button comfyui-menu-button", + }); - this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [ - this.logo, - this.workflows.element, - this.saveButton.element, - collapseOnMobile(this.actionsGroup).element, - $el("section.comfyui-menu-push"), - collapseOnMobile(this.settingsGroup).element, - collapseOnMobile(this.viewGroup).element, + this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [ + this.logo, + this.workflows.element, + this.saveButton.element, + collapseOnMobile(this.actionsGroup).element, + $el("section.comfyui-menu-push"), + collapseOnMobile(this.settingsGroup).element, + collapseOnMobile(this.viewGroup).element, - getInteruptButton("lt-lg-show").element, - new ComfyQueueButton(app).element, - showOnMobile(this.mobileMenuButton).element, - ]); + getInteruptButton("lt-lg-show").element, + new ComfyQueueButton(app).element, + showOnMobile(this.mobileMenuButton).element, + ]); - let resizeHandler; - this.menuPositionSetting = app.ui.settings.addSetting({ - id: "Comfy.UseNewMenu", - defaultValue: "Disabled", - name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.", - type: "combo", - options: ["Disabled", "Top", "Bottom"], - onChange: async (v) => { - if (v && v !== "Disabled") { - if (!resizeHandler) { - resizeHandler = () => { - this.calculateSizeBreak(); - }; - window.addEventListener("resize", resizeHandler); - } - this.updatePosition(v); - } else { - if (resizeHandler) { - window.removeEventListener("resize", resizeHandler); - resizeHandler = null; - } - document.body.style.removeProperty("display"); - app.ui.menuContainer.style.removeProperty("display"); - this.element.style.display = "none"; - app.ui.restoreMenuPosition(); - } - window.dispatchEvent(new Event("resize")); - }, - }); - } + let resizeHandler; + this.menuPositionSetting = app.ui.settings.addSetting({ + id: "Comfy.UseNewMenu", + defaultValue: "Disabled", + name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.", + type: "combo", + options: ["Disabled", "Top", "Bottom"], + onChange: async (v) => { + if (v && v !== "Disabled") { + if (!resizeHandler) { + resizeHandler = () => { + this.calculateSizeBreak(); + }; + window.addEventListener("resize", resizeHandler); + } + this.updatePosition(v); + } else { + if (resizeHandler) { + window.removeEventListener("resize", resizeHandler); + resizeHandler = null; + } + document.body.style.removeProperty("display"); + app.ui.menuContainer.style.removeProperty("display"); + this.element.style.display = "none"; + app.ui.restoreMenuPosition(); + } + window.dispatchEvent(new Event("resize")); + }, + }); + } - updatePosition(v) { - document.body.style.display = "grid"; - this.app.ui.menuContainer.style.display = "none"; - this.element.style.removeProperty("display"); - this.position = v; - if (v === "Bottom") { - this.app.bodyBottom.append(this.element); - } else { - this.app.bodyTop.prepend(this.element); - } - this.calculateSizeBreak(); - } + updatePosition(v) { + document.body.style.display = "grid"; + this.app.ui.menuContainer.style.display = "none"; + this.element.style.removeProperty("display"); + this.position = v; + if (v === "Bottom") { + this.app.bodyBottom.append(this.element); + } else { + this.app.bodyTop.prepend(this.element); + } + this.calculateSizeBreak(); + } - updateSizeBreak(idx, prevIdx, direction) { - const newSize = this.#sizeBreaks[idx]; - if (newSize === this.#sizeBreak) return; - this.#cachedInnerSize = null; - clearTimeout(this.#cacheTimeout); + updateSizeBreak(idx, prevIdx, direction) { + const newSize = this.#sizeBreaks[idx]; + if (newSize === this.#sizeBreak) return; + this.#cachedInnerSize = null; + clearTimeout(this.#cacheTimeout); - this.#sizeBreak = this.#sizeBreaks[idx]; - for (let i = 0; i < this.#sizeBreaks.length; i++) { - const sz = this.#sizeBreaks[i]; - if (sz === this.#sizeBreak) { - this.element.classList.add(sz); - } else { - this.element.classList.remove(sz); - } - if (i < idx) { - this.element.classList.add("lt-" + sz); - } else { - this.element.classList.remove("lt-" + sz); - } - } + this.#sizeBreak = this.#sizeBreaks[idx]; + for (let i = 0; i < this.#sizeBreaks.length; i++) { + const sz = this.#sizeBreaks[i]; + if (sz === this.#sizeBreak) { + this.element.classList.add(sz); + } else { + this.element.classList.remove(sz); + } + if (i < idx) { + this.element.classList.add("lt-" + sz); + } else { + this.element.classList.remove("lt-" + sz); + } + } - if (idx) { - // We're on a small screen, force the menu at the top - if (this.position !== "Top") { - this.updatePosition("Top"); - } - } else if (this.position != this.menuPositionSetting.value) { - // Restore user position - this.updatePosition(this.menuPositionSetting.value); - } + if (idx) { + // We're on a small screen, force the menu at the top + if (this.position !== "Top") { + this.updatePosition("Top"); + } + } else if (this.position != this.menuPositionSetting.value) { + // Restore user position + this.updatePosition(this.menuPositionSetting.value); + } - // Allow multiple updates, but prevent bouncing - if (!direction) { - direction = prevIdx - idx; - } else if (direction != prevIdx - idx) { - return; - } - this.calculateSizeBreak(direction); - } + // Allow multiple updates, but prevent bouncing + if (!direction) { + direction = prevIdx - idx; + } else if (direction != prevIdx - idx) { + return; + } + this.calculateSizeBreak(direction); + } - calculateSizeBreak(direction = 0) { - let idx = this.#sizeBreaks.indexOf(this.#sizeBreak); - const currIdx = idx; - const innerSize = this.calculateInnerSize(idx); - if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) { - if (idx > 0) { - idx--; - } - } else if (innerSize > this.element.clientWidth) { - this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize); - // We need to shrink - if (idx < this.#sizeBreaks.length - 1) { - idx++; - } - } + calculateSizeBreak(direction = 0) { + let idx = this.#sizeBreaks.indexOf(this.#sizeBreak); + const currIdx = idx; + const innerSize = this.calculateInnerSize(idx); + if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) { + if (idx > 0) { + idx--; + } + } else if (innerSize > this.element.clientWidth) { + this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize); + // We need to shrink + if (idx < this.#sizeBreaks.length - 1) { + idx++; + } + } - this.updateSizeBreak(idx, currIdx, direction); - } + this.updateSizeBreak(idx, currIdx, direction); + } - calculateInnerSize(idx) { - // Cache the inner size to prevent too much calculation when resizing the window - clearTimeout(this.#cacheTimeout); - if (this.#cachedInnerSize) { - // Extend cache time - this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100); - } else { - let innerSize = 0; - let count = 1; - for (const c of this.element.children) { - if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push - if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items - innerSize += c.clientWidth; - count++; - } - innerSize += 8 * count; - this.#cachedInnerSize = innerSize; - this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100); - } - return this.#cachedInnerSize; - } + calculateInnerSize(idx) { + // Cache the inner size to prevent too much calculation when resizing the window + clearTimeout(this.#cacheTimeout); + if (this.#cachedInnerSize) { + // Extend cache time + this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100); + } else { + let innerSize = 0; + let count = 1; + for (const c of this.element.children) { + if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push + if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items + innerSize += c.clientWidth; + count++; + } + innerSize += 8 * count; + this.#cachedInnerSize = innerSize; + this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100); + } + return this.#cachedInnerSize; + } - /** - * @param {string} defaultName - */ - getFilename(defaultName) { - if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) { - defaultName = prompt("Save workflow as:", defaultName); - if (!defaultName) return; - if (!defaultName.toLowerCase().endsWith(".json")) { - defaultName += ".json"; - } - } - return defaultName; - } + /** + * @param {string} defaultName + */ + getFilename(defaultName) { + if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) { + defaultName = prompt("Save workflow as:", defaultName); + if (!defaultName) return; + if (!defaultName.toLowerCase().endsWith(".json")) { + defaultName += ".json"; + } + } + return defaultName; + } - /** - * @param {string} [filename] - * @param { "workflow" | "output" } [promptProperty] - */ - async exportWorkflow(filename, promptProperty) { - if (this.app.workflowManager.activeWorkflow?.path) { - filename = this.app.workflowManager.activeWorkflow.name; - } - const p = await this.app.graphToPrompt(); - const json = JSON.stringify(p[promptProperty], null, 2); - const blob = new Blob([json], { type: "application/json" }); - const file = this.getFilename(filename); - if (!file) return; - downloadBlob(file, blob); - } + /** + * @param {string} [filename] + * @param { "workflow" | "output" } [promptProperty] + */ + async exportWorkflow(filename, promptProperty) { + if (this.app.workflowManager.activeWorkflow?.path) { + filename = this.app.workflowManager.activeWorkflow.name; + } + const p = await this.app.graphToPrompt(); + const json = JSON.stringify(p[promptProperty], null, 2); + const blob = new Blob([json], { type: "application/json" }); + const file = this.getFilename(filename); + if (!file) return; + downloadBlob(file, blob); + } } diff --git a/src/scripts/ui/menu/interruptButton.js b/src/scripts/ui/menu/interruptButton.js index 609184e4a..353c5dd51 100644 --- a/src/scripts/ui/menu/interruptButton.js +++ b/src/scripts/ui/menu/interruptButton.js @@ -4,20 +4,20 @@ import { api } from "../../api"; import { ComfyButton } from "../components/button"; export function getInteruptButton(visibility) { - const btn = new ComfyButton({ - icon: "close", - tooltip: "Cancel current generation", - enabled: false, - action: () => { - api.interrupt(); - }, - classList: ["comfyui-button", "comfyui-interrupt-button", visibility], - }); + const btn = new ComfyButton({ + icon: "close", + tooltip: "Cancel current generation", + enabled: false, + action: () => { + api.interrupt(); + }, + classList: ["comfyui-button", "comfyui-interrupt-button", visibility], + }); - api.addEventListener("status", ({ detail }) => { - const sz = detail?.exec_info?.queue_remaining; - btn.enabled = sz > 0; - }); + api.addEventListener("status", ({ detail }) => { + const sz = detail?.exec_info?.queue_remaining; + btn.enabled = sz > 0; + }); - return btn; + return btn; } diff --git a/src/scripts/ui/menu/menu.css b/src/scripts/ui/menu/menu.css index d6cb27258..f88322780 100644 --- a/src/scripts/ui/menu/menu.css +++ b/src/scripts/ui/menu/menu.css @@ -1,706 +1,706 @@ .relative { - position: relative; + position: relative; } .hidden { - display: none !important; + display: none !important; } .mdi.rotate270::before { - transform: rotate(270deg); + transform: rotate(270deg); } /* Generic */ .comfyui-button { - display: flex; - align-items: center; - gap: 0.5em; - cursor: pointer; - border: none; - border-radius: 4px; - padding: 4px 8px; - box-sizing: border-box; - margin: 0; - transition: box-shadow 0.1s; + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + border: none; + border-radius: 4px; + padding: 4px 8px; + box-sizing: border-box; + margin: 0; + transition: box-shadow 0.1s; } .comfyui-button:active { - box-shadow: inset 1px 1px 10px rgba(0, 0, 0, 0.5); + box-shadow: inset 1px 1px 10px rgba(0, 0, 0, 0.5); } .comfyui-button:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .primary .comfyui-button, .primary.comfyui-button { - background-color: var(--primary-bg) !important; - color: var(--primary-fg) !important; + background-color: var(--primary-bg) !important; + color: var(--primary-fg) !important; } .primary .comfyui-button:not(:disabled):hover, .primary.comfyui-button:not(:disabled):hover { - background-color: var(--primary-hover-bg) !important; - color: var(--primary-hover-fg) !important; + background-color: var(--primary-hover-bg) !important; + color: var(--primary-hover-fg) !important; } /* Popup */ .comfyui-popup { - position: absolute; - left: var(--left); - right: var(--right); - top: var(--top); - bottom: var(--bottom); - z-index: 2000; - max-height: calc(100vh - var(--limit) - 10px); - box-shadow: 3px 3px 5px 0px rgba(0, 0, 0, 0.3); + position: absolute; + left: var(--left); + right: var(--right); + top: var(--top); + bottom: var(--bottom); + z-index: 2000; + max-height: calc(100vh - var(--limit) - 10px); + box-shadow: 3px 3px 5px 0px rgba(0, 0, 0, 0.3); } .comfyui-popup:not(.open) { - display: none; + display: none; } .comfyui-popup.right.open { - border-top-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom-left-radius: 4px; - overflow: hidden; + border-top-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + overflow: hidden; } /* Split button */ .comfyui-split-button { - position: relative; - display: flex; + position: relative; + display: flex; } .comfyui-split-primary { - flex: auto; + flex: auto; } .comfyui-split-primary .comfyui-button { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-right: 1px solid var(--comfy-menu-bg); - width: 100%; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 1px solid var(--comfy-menu-bg); + width: 100%; } .comfyui-split-arrow .comfyui-button { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding-left: 2px; - padding-right: 2px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding-left: 2px; + padding-right: 2px; } .comfyui-split-button-popup { - white-space: nowrap; - background-color: var(--content-bg); - color: var(--content-fg); - display: flex; - flex-direction: column; - overflow: auto; + white-space: nowrap; + background-color: var(--content-bg); + color: var(--content-fg); + display: flex; + flex-direction: column; + overflow: auto; } .comfyui-split-button-popup.hover { - z-index: 2001; + z-index: 2001; } .comfyui-split-button-popup > .comfyui-button { - border: none; - background-color: transparent; - color: var(--fg-color); - padding: 8px 12px 8px 8px; + border: none; + background-color: transparent; + color: var(--fg-color); + padding: 8px 12px 8px 8px; } .comfyui-split-button-popup > .comfyui-button:not(:disabled):hover { - background-color: var(--comfy-input-bg); + background-color: var(--comfy-input-bg); } /* Button group */ .comfyui-button-group { - display: flex; - border-radius: 4px; - overflow: hidden; + display: flex; + border-radius: 4px; + overflow: hidden; } .comfyui-button-group > .comfyui-button, .comfyui-button-group > .comfyui-button-wrapper > .comfyui-button { - padding: 4px 10px; - border-radius: 0; + padding: 4px 10px; + border-radius: 0; } /* Menu */ .comfyui-menu { - width: 100vw; - background: var(--comfy-menu-bg); - color: var(--fg-color); - font-family: Arial, Helvetica, sans-serif; - font-size: 0.8em; - display: flex; - padding: 4px 8px; - align-items: center; - gap: 8px; - box-sizing: border-box; - z-index: 1000; - order: 0; - grid-column: 1/-1; - overflow: auto; - max-height: 90vh; + width: 100vw; + background: var(--comfy-menu-bg); + color: var(--fg-color); + font-family: Arial, Helvetica, sans-serif; + font-size: 0.8em; + display: flex; + padding: 4px 8px; + align-items: center; + gap: 8px; + box-sizing: border-box; + z-index: 1000; + order: 0; + grid-column: 1/-1; + overflow: auto; + max-height: 90vh; } .comfyui-menu>* { - flex-shrink: 0; + flex-shrink: 0; } .comfyui-menu .mdi::before { - font-size: 18px; + font-size: 18px; } .comfyui-menu .comfyui-button { - background: var(--comfy-input-bg); - color: var(--fg-color); - white-space: nowrap; + background: var(--comfy-input-bg); + color: var(--fg-color); + white-space: nowrap; } .comfyui-menu .comfyui-button:not(:disabled):hover { - background: var(--border-color); - color: var(--content-fg); + background: var(--border-color); + color: var(--content-fg); } .comfyui-menu .comfyui-split-button-popup > .comfyui-button { - border-radius: 0; - background-color: transparent; + border-radius: 0; + background-color: transparent; } .comfyui-menu .comfyui-split-button-popup > .comfyui-button:not(:disabled):hover { - background-color: var(--comfy-input-bg); + background-color: var(--comfy-input-bg); } .comfyui-menu .comfyui-split-button-popup.left { - border-top-right-radius: 4px; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; + border-top-right-radius: 4px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; } .comfyui-menu .comfyui-button.popup-open { - background-color: var(--content-bg); - color: var(--content-fg); + background-color: var(--content-bg); + color: var(--content-fg); } .comfyui-menu-push { - margin-left: -0.8em; - flex: auto; + margin-left: -0.8em; + flex: auto; } .comfyui-logo { - font-size: 1.2em; - margin: 0; - user-select: none; - cursor: default; + font-size: 1.2em; + margin: 0; + user-select: none; + cursor: default; } /* Workflows */ .comfyui-workflows-button { - flex-direction: row-reverse; - max-width: 200px; - position: relative; - z-index: 0; + flex-direction: row-reverse; + max-width: 200px; + position: relative; + z-index: 0; } .comfyui-workflows-button.popup-open { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } .comfyui-workflows-button.unsaved { - font-style: italic; + font-style: italic; } .comfyui-workflows-button-progress { - position: absolute; - top: 0; - left: 0; - background-color: green; - height: 100%; - border-radius: 4px; - z-index: -1; + position: absolute; + top: 0; + left: 0; + background-color: green; + height: 100%; + border-radius: 4px; + z-index: -1; } .comfyui-workflows-button > span { - flex: auto; - text-align: left; - overflow: hidden; + flex: auto; + text-align: left; + overflow: hidden; } .comfyui-workflows-button-inner { - display: flex; - align-items: center; - gap: 7px; - width: 150px; + display: flex; + align-items: center; + gap: 7px; + width: 150px; } .comfyui-workflows-label { - overflow: hidden; - text-overflow: ellipsis; - direction: rtl; - flex: auto; - position: relative; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; + flex: auto; + position: relative; } .comfyui-workflows-button.unsaved .comfyui-workflows-label { - padding-left: 8px; + padding-left: 8px; } .comfyui-workflows-button.unsaved .comfyui-workflows-label:after { - content: "*"; - position: absolute; - top: 0; - left: 0; + content: "*"; + position: absolute; + top: 0; + left: 0; } .comfyui-workflows-button-inner .mdi-graph::before { - transform: rotate(-90deg); + transform: rotate(-90deg); } .comfyui-workflows-popup { - font-family: Arial, Helvetica, sans-serif; - font-size: 0.8em; - padding: 10px; - overflow: auto; - background-color: var(--content-bg); - color: var(--content-fg); - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom-left-radius: 4px; - z-index: 400; + font-family: Arial, Helvetica, sans-serif; + font-size: 0.8em; + padding: 10px; + overflow: auto; + background-color: var(--content-bg); + color: var(--content-fg); + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + z-index: 400; } .comfyui-workflows-panel { - min-height: 150px; + min-height: 150px; } .comfyui-workflows-panel .lds-ring { - transform: translate(-50%); - position: absolute; - left: 50%; - top: 75px; + transform: translate(-50%); + position: absolute; + left: 50%; + top: 75px; } .comfyui-workflows-panel h3 { - margin: 10px 0 10px 0; - font-size: 11px; - opacity: 0.8; + margin: 10px 0 10px 0; + font-size: 11px; + opacity: 0.8; } .comfyui-workflows-panel section header { - display: flex; - justify-content: space-between; - align-items: center; + display: flex; + justify-content: space-between; + align-items: center; } .comfy-ui-workflows-search .mdi { - position: relative; - top: 2px; - pointer-events: none; + position: relative; + top: 2px; + pointer-events: none; } .comfy-ui-workflows-search input { - background-color: var(--comfy-input-bg); - color: var(--input-text); - border: none; - border-radius: 4px; - padding: 4px 10px; - margin-left: -24px; - text-indent: 18px; + background-color: var(--comfy-input-bg); + color: var(--input-text); + border: none; + border-radius: 4px; + padding: 4px 10px; + margin-left: -24px; + text-indent: 18px; } .comfy-ui-workflows-search input:placeholder-shown { - width: 10px; + width: 10px; } .comfy-ui-workflows-search input:placeholder-shown:focus { - width: auto; + width: auto; } .comfyui-workflows-actions { - display: flex; - gap: 10px; - margin-bottom: 10px; + display: flex; + gap: 10px; + margin-bottom: 10px; } .comfyui-workflows-actions .comfyui-button { - background: var(--comfy-input-bg); - color: var(--input-text); + background: var(--comfy-input-bg); + color: var(--input-text); } .comfyui-workflows-actions .comfyui-button:not(:disabled):hover { - background: var(--primary-bg); - color: var(--primary-fg); + background: var(--primary-bg); + color: var(--primary-fg); } .comfyui-workflows-favorites, .comfyui-workflows-open { - border-bottom: 1px solid var(--comfy-input-bg); - padding-bottom: 5px; - margin-bottom: 5px; + border-bottom: 1px solid var(--comfy-input-bg); + padding-bottom: 5px; + margin-bottom: 5px; } .comfyui-workflows-open .active { - font-weight: bold; + font-weight: bold; } .comfyui-workflows-favorites:empty { - display: none; + display: none; } .comfyui-workflows-tree { - padding: 0; - margin: 0; + padding: 0; + margin: 0; } .comfyui-workflows-tree:empty::after { - content: "No saved workflows"; - display: block; - text-align: center; + content: "No saved workflows"; + display: block; + text-align: center; } .comfyui-workflows-tree > ul { - padding: 0; + padding: 0; } .comfyui-workflows-tree > ul ul { - margin: 0; - padding: 0 0 0 25px; + margin: 0; + padding: 0 0 0 25px; } .comfyui-workflows-tree:not(.filtered) .closed > ul { - display: none; + display: none; } .comfyui-workflows-tree li, .comfyui-workflows-tree-file { - --item-height: 32px; - list-style-type: none; - height: var(--item-height); - display: flex; - align-items: center; - gap: 5px; - cursor: pointer; - user-select: none; + --item-height: 32px; + list-style-type: none; + height: var(--item-height); + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + user-select: none; } .comfyui-workflows-tree-file.active::before, .comfyui-workflows-tree li:hover::before, .comfyui-workflows-tree-file:hover::before { - content: ""; - position: absolute; - width: 100%; - left: 0; - height: var(--item-height); - background-color: var(--content-hover-bg); - color: var(--content-hover-fg); - z-index: -1; + content: ""; + position: absolute; + width: 100%; + left: 0; + height: var(--item-height); + background-color: var(--content-hover-bg); + color: var(--content-hover-fg); + z-index: -1; } .comfyui-workflows-tree-file.active::before { - background-color: var(--primary-bg); - color: var(--primary-fg); + background-color: var(--primary-bg); + color: var(--primary-fg); } .comfyui-workflows-tree-file.running:not(:hover)::before { - content: ""; - position: absolute; - width: var(--progress, 0); - left: 0; - height: var(--item-height); - background-color: green; - z-index: -1; + content: ""; + position: absolute; + width: var(--progress, 0); + left: 0; + height: var(--item-height); + background-color: green; + z-index: -1; } .comfyui-workflows-tree-file.unsaved span { - font-style: italic; + font-style: italic; } .comfyui-workflows-tree-file span { - flex: auto; + flex: auto; } .comfyui-workflows-tree-file span + .comfyui-workflows-file-action { - margin-left: 10px; + margin-left: 10px; } .comfyui-workflows-tree-file .comfyui-workflows-file-action { - background-color: transparent; - color: var(--fg-color); - padding: 2px 4px; + background-color: transparent; + color: var(--fg-color); + padding: 2px 4px; } .lg ~ .comfyui-workflows-popup .comfyui-workflows-tree-file:not(:hover) .comfyui-workflows-file-action { - opacity: 0; + opacity: 0; } .comfyui-workflows-tree-file .comfyui-workflows-file-action:hover { - background-color: var(--primary-bg); - color: var(--primary-fg); + background-color: var(--primary-bg); + color: var(--primary-fg); } .comfyui-workflows-tree-file .comfyui-workflows-file-action-primary { - background-color: transparent; - color: var(--fg-color); - padding: 2px 4px; - margin: 0 -4px; + background-color: transparent; + color: var(--fg-color); + padding: 2px 4px; + margin: 0 -4px; } .comfyui-workflows-file-action-favorite .mdi-star { - color: orange; + color: orange; } /* View List */ .comfyui-view-list-popup { - padding: 10px; - background-color: var(--content-bg); - color: var(--content-fg); - min-width: 170px; - min-height: 435px; - display: flex; - flex-direction: column; - align-items: center; - box-sizing: border-box; + padding: 10px; + background-color: var(--content-bg); + color: var(--content-fg); + min-width: 170px; + min-height: 435px; + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; } .comfyui-view-list-popup h3 { - margin: 0 0 5px 0; + margin: 0 0 5px 0; } .comfyui-view-list-items { - width: 100%; - background: var(--comfy-menu-bg); - border-radius: 5px; - display: flex; - justify-content: center; - flex: auto; - align-items: center; - flex-direction: column; + width: 100%; + background: var(--comfy-menu-bg); + border-radius: 5px; + display: flex; + justify-content: center; + flex: auto; + align-items: center; + flex-direction: column; } .comfyui-view-list-items section { - max-height: 400px; - overflow: auto; - width: 100%; - display: grid; - grid-template-columns: auto auto auto; - align-items: center; - justify-content: center; - gap: 5px; - padding: 5px 0; + max-height: 400px; + overflow: auto; + width: 100%; + display: grid; + grid-template-columns: auto auto auto; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 0; } .comfyui-view-list-items section + section { - border-top: 1px solid var(--border-color); - margin-top: 10px; - padding-top: 5px; + border-top: 1px solid var(--border-color); + margin-top: 10px; + padding-top: 5px; } .comfyui-view-list-items section h5 { - grid-column: 1 / 4; - text-align: center; - margin: 5px; + grid-column: 1 / 4; + text-align: center; + margin: 5px; } .comfyui-view-list-items span { - text-align: center; - padding: 0 2px; + text-align: center; + padding: 0 2px; } .comfyui-view-list-popup header { - margin-bottom: 10px; - display: flex; - gap: 5px; + margin-bottom: 10px; + display: flex; + gap: 5px; } .comfyui-view-list-popup header .comfyui-button { - border: 1px solid transparent; + border: 1px solid transparent; } .comfyui-view-list-popup header .comfyui-button:not(:disabled):hover { - border: 1px solid var(--comfy-menu-bg); + border: 1px solid var(--comfy-menu-bg); } /* Queue button */ .comfyui-queue-button .comfyui-split-primary .comfyui-button { - padding-right: 12px; + padding-right: 12px; } .comfyui-queue-count { - margin-left: 5px; - border-radius: 10px; - background-color: rgb(8, 80, 153); - padding: 2px 4px; - font-size: 10px; - min-width: 1em; - display: inline-block; + margin-left: 5px; + border-radius: 10px; + background-color: rgb(8, 80, 153); + padding: 2px 4px; + font-size: 10px; + min-width: 1em; + display: inline-block; } /* Queue options*/ .comfyui-queue-options { - padding: 10px; - font-family: Arial, Helvetica, sans-serif; - font-size: 12px; - display: flex; - gap: 10px; + padding: 10px; + font-family: Arial, Helvetica, sans-serif; + font-size: 12px; + display: flex; + gap: 10px; } .comfyui-queue-batch { - display: flex; - flex-direction: column; - border-right: 1px solid var(--comfy-menu-bg); - padding-right: 10px; - gap: 5px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--comfy-menu-bg); + padding-right: 10px; + gap: 5px; } .comfyui-queue-batch input { - width: 145px; + width: 145px; } .comfyui-queue-batch .comfyui-queue-batch-value { - width: 70px; + width: 70px; } .comfyui-queue-mode { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .comfyui-queue-mode span { - font-weight: bold; - margin-bottom: 2px; + font-weight: bold; + margin-bottom: 2px; } .comfyui-queue-mode label { - display: flex; - flex-direction: row-reverse; - justify-content: start; - gap: 5px; - padding: 2px 0; + display: flex; + flex-direction: row-reverse; + justify-content: start; + gap: 5px; + padding: 2px 0; } .comfyui-queue-mode label input { - padding: 0; - margin: 0; + padding: 0; + margin: 0; } /** Send to workflow widget selection dialog */ .comfy-widget-selection-dialog { - border: none; + border: none; } .comfy-widget-selection-dialog div { - color: var(--fg-color); - font-family: Arial, Helvetica, sans-serif; + color: var(--fg-color); + font-family: Arial, Helvetica, sans-serif; } .comfy-widget-selection-dialog h2 { - margin-top: 0; + margin-top: 0; } .comfy-widget-selection-dialog section { - width: fit-content; - display: flex; - flex-direction: column; + width: fit-content; + display: flex; + flex-direction: column; } .comfy-widget-selection-item { - display: flex; - gap: 10px; - align-items: center; + display: flex; + gap: 10px; + align-items: center; } .comfy-widget-selection-item span { - margin-right: auto; + margin-right: auto; } .comfy-widget-selection-item span::before { - content: '#' attr(data-id); - opacity: 0.5; - margin-right: 5px; + content: '#' attr(data-id); + opacity: 0.5; + margin-right: 5px; } .comfy-modal .comfy-widget-selection-item button { - font-size: 1em; + font-size: 1em; } /***** Responsive *****/ .lg.comfyui-menu .lt-lg-show { - display: none !important; + display: none !important; } .comfyui-menu:not(.lg) .nlg-hide { - display: none !important; + display: none !important; } /** Large screen */ .lg.comfyui-menu>.comfyui-menu-mobile-collapse .comfyui-button span, .lg.comfyui-menu>.comfyui-menu-mobile-collapse.comfyui-button span { - display: none; + display: none; } .lg.comfyui-menu>.comfyui-menu-mobile-collapse .comfyui-popup .comfyui-button span { - display: unset; + display: unset; } /** Non large screen */ .lt-lg.comfyui-menu { - flex-wrap: wrap; + flex-wrap: wrap; } .lt-lg.comfyui-menu > *:not(.comfyui-menu-mobile-collapse) { - order: 1; + order: 1; } .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse { - order: 9999; - width: 100%; + order: 9999; + width: 100%; } .comfyui-body-bottom .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse { - order: -1; + order: -1; } .comfyui-body-bottom .lt-lg.comfyui-menu > .comfyui-menu-button { - top: unset; - bottom: 4px; + top: unset; + bottom: 4px; } .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse.comfyui-button-group { - flex-wrap: wrap; + flex-wrap: wrap; } .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button, .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse.comfyui-button { - padding: 10px; + padding: 10px; } .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button, .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button-wrapper { - width: 100%; + width: 100%; } .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-popup { - position: static; - background-color: var(--comfy-input-bg); - max-width: unset; - max-height: 50vh; - overflow: auto; + position: static; + background-color: var(--comfy-input-bg); + max-width: unset; + max-height: 50vh; + overflow: auto; } .lt-lg.comfyui-menu:not(.expanded) > .comfyui-menu-mobile-collapse { - display: none; + display: none; } .lt-lg .comfyui-queue-button { - margin-right: 44px; + margin-right: 44px; } .lt-lg .comfyui-menu-button { - position: absolute; - top: 4px; - right: 8px; + position: absolute; + top: 4px; + right: 8px; } .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-view-list-popup { - border-radius: 0; + border-radius: 0; } .lt-lg.comfyui-menu .comfyui-workflows-popup { - width: 100vw; + width: 100vw; } /** Small */ .lt-md .comfyui-workflows-button-inner { - width: unset !important; + width: unset !important; } .lt-md .comfyui-workflows-label { - display: none; + display: none; } /** Extra small */ .lt-sm .comfyui-queue-button { - margin-right: 0; - width: 100%; + margin-right: 0; + width: 100%; } .lt-sm .comfyui-queue-button .comfyui-button { - justify-content: center; + justify-content: center; } .lt-sm .comfyui-interrupt-button { - margin-right: 45px; + margin-right: 45px; } .comfyui-body-bottom .lt-sm.comfyui-menu > .comfyui-menu-button{ - bottom: 41px; + bottom: 41px; } \ No newline at end of file diff --git a/src/scripts/ui/menu/queueButton.js b/src/scripts/ui/menu/queueButton.js index 9c8ae67da..48501aa31 100644 --- a/src/scripts/ui/menu/queueButton.js +++ b/src/scripts/ui/menu/queueButton.js @@ -8,86 +8,86 @@ import { ComfyQueueOptions } from "./queueOptions"; import { prop } from "../../utils"; export class ComfyQueueButton { - element = $el("div.comfyui-queue-button"); - #internalQueueSize = 0; + element = $el("div.comfyui-queue-button"); + #internalQueueSize = 0; - queuePrompt = async (e) => { - this.#internalQueueSize += this.queueOptions.batchCount; - // Hold shift to queue front, event is undefined when auto-queue is enabled - await this.app.queuePrompt(e?.shiftKey ? -1 : 0, this.queueOptions.batchCount); - }; + queuePrompt = async (e) => { + this.#internalQueueSize += this.queueOptions.batchCount; + // Hold shift to queue front, event is undefined when auto-queue is enabled + await this.app.queuePrompt(e?.shiftKey ? -1 : 0, this.queueOptions.batchCount); + }; - constructor(app) { - this.app = app; - this.queueSizeElement = $el("span.comfyui-queue-count", { - textContent: "?", - }); + constructor(app) { + this.app = app; + this.queueSizeElement = $el("span.comfyui-queue-count", { + textContent: "?", + }); - const queue = new ComfyButton({ - content: $el("div", [ - $el("span", { - textContent: "Queue", - }), - this.queueSizeElement, - ]), - icon: "play", - classList: "comfyui-button", - action: this.queuePrompt, - }); + const queue = new ComfyButton({ + content: $el("div", [ + $el("span", { + textContent: "Queue", + }), + this.queueSizeElement, + ]), + icon: "play", + classList: "comfyui-button", + action: this.queuePrompt, + }); - this.queueOptions = new ComfyQueueOptions(app); + this.queueOptions = new ComfyQueueOptions(app); - const btn = new ComfySplitButton( - { - primary: queue, - mode: "click", - position: "absolute", - horizontal: "right", - }, - this.queueOptions.element - ); - btn.element.classList.add("primary"); - this.element.append(btn.element); + const btn = new ComfySplitButton( + { + primary: queue, + mode: "click", + position: "absolute", + horizontal: "right", + }, + this.queueOptions.element + ); + btn.element.classList.add("primary"); + this.element.append(btn.element); - this.autoQueueMode = prop(this, "autoQueueMode", "", () => { - switch (this.autoQueueMode) { - case "instant": - queue.icon = "infinity"; - break; - case "change": - queue.icon = "auto-mode"; - break; - default: - queue.icon = "play"; - break; - } - }); + this.autoQueueMode = prop(this, "autoQueueMode", "", () => { + switch (this.autoQueueMode) { + case "instant": + queue.icon = "infinity"; + break; + case "change": + queue.icon = "auto-mode"; + break; + default: + queue.icon = "play"; + break; + } + }); - this.queueOptions.addEventListener("autoQueueMode", (e) => (this.autoQueueMode = e["detail"])); + this.queueOptions.addEventListener("autoQueueMode", (e) => (this.autoQueueMode = e["detail"])); - api.addEventListener("graphChanged", () => { - if (this.autoQueueMode === "change") { - if (this.#internalQueueSize) { - this.graphHasChanged = true; - } else { - this.graphHasChanged = false; - this.queuePrompt(); - } - } - }); + api.addEventListener("graphChanged", () => { + if (this.autoQueueMode === "change") { + if (this.#internalQueueSize) { + this.graphHasChanged = true; + } else { + this.graphHasChanged = false; + this.queuePrompt(); + } + } + }); - api.addEventListener("status", ({ detail }) => { - this.#internalQueueSize = detail?.exec_info?.queue_remaining; - if (this.#internalQueueSize != null) { - this.queueSizeElement.textContent = this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + ""; - this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`; - if (!this.#internalQueueSize && !app.lastExecutionError) { - if (this.autoQueueMode === "instant" || (this.autoQueueMode === "change" && this.graphHasChanged)) { - this.graphHasChanged = false; - this.queuePrompt(); - } - } - } - }); - } + api.addEventListener("status", ({ detail }) => { + this.#internalQueueSize = detail?.exec_info?.queue_remaining; + if (this.#internalQueueSize != null) { + this.queueSizeElement.textContent = this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + ""; + this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`; + if (!this.#internalQueueSize && !app.lastExecutionError) { + if (this.autoQueueMode === "instant" || (this.autoQueueMode === "change" && this.graphHasChanged)) { + this.graphHasChanged = false; + this.queuePrompt(); + } + } + } + }); + } } diff --git a/src/scripts/ui/menu/queueOptions.js b/src/scripts/ui/menu/queueOptions.js index 48f8b7939..47baf9791 100644 --- a/src/scripts/ui/menu/queueOptions.js +++ b/src/scripts/ui/menu/queueOptions.js @@ -4,74 +4,74 @@ import { $el } from "../../ui"; import { prop } from "../../utils"; export class ComfyQueueOptions extends EventTarget { - element = $el("div.comfyui-queue-options"); + element = $el("div.comfyui-queue-options"); - constructor(app) { - super(); - this.app = app; + constructor(app) { + super(); + this.app = app; - this.batchCountInput = $el("input", { - className: "comfyui-queue-batch-value", - type: "number", - min: "1", - value: "1", - oninput: () => (this.batchCount = +this.batchCountInput.value), - }); + this.batchCountInput = $el("input", { + className: "comfyui-queue-batch-value", + type: "number", + min: "1", + value: "1", + oninput: () => (this.batchCount = +this.batchCountInput.value), + }); - this.batchCountRange = $el("input", { - type: "range", - min: "1", - max: "100", - value: "1", - oninput: () => (this.batchCount = +this.batchCountRange.value), - }); + this.batchCountRange = $el("input", { + type: "range", + min: "1", + max: "100", + value: "1", + oninput: () => (this.batchCount = +this.batchCountRange.value), + }); - this.element.append( - $el("div.comfyui-queue-batch", [ - $el( - "label", - { - textContent: "Batch count: ", - }, - this.batchCountInput - ), - this.batchCountRange, - ]) - ); + this.element.append( + $el("div.comfyui-queue-batch", [ + $el( + "label", + { + textContent: "Batch count: ", + }, + this.batchCountInput + ), + this.batchCountRange, + ]) + ); - const createOption = (text, value, checked = false) => - $el( - "label", - { textContent: text }, - $el("input", { - type: "radio", - name: "AutoQueueMode", - checked, - value, - oninput: (e) => (this.autoQueueMode = e.target["value"]), - }) - ); + const createOption = (text, value, checked = false) => + $el( + "label", + { textContent: text }, + $el("input", { + type: "radio", + name: "AutoQueueMode", + checked, + value, + oninput: (e) => (this.autoQueueMode = e.target["value"]), + }) + ); - this.autoQueueEl = $el("div.comfyui-queue-mode", [ - $el("span", "Auto Queue:"), - createOption("Disabled", "", true), - createOption("Instant", "instant"), - createOption("On Change", "change"), - ]); + this.autoQueueEl = $el("div.comfyui-queue-mode", [ + $el("span", "Auto Queue:"), + createOption("Disabled", "", true), + createOption("Instant", "instant"), + createOption("On Change", "change"), + ]); - this.element.append(this.autoQueueEl); + this.element.append(this.autoQueueEl); - this.batchCount = prop(this, "batchCount", 1, () => { - this.batchCountInput.value = this.batchCount + ""; - this.batchCountRange.value = this.batchCount + ""; - }); + this.batchCount = prop(this, "batchCount", 1, () => { + this.batchCountInput.value = this.batchCount + ""; + this.batchCountRange.value = this.batchCount + ""; + }); - this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => { - this.dispatchEvent( - new CustomEvent("autoQueueMode", { - detail: this.autoQueueMode, - }) - ); - }); - } + this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => { + this.dispatchEvent( + new CustomEvent("autoQueueMode", { + detail: this.autoQueueMode, + }) + ); + }); + } } diff --git a/src/scripts/ui/menu/viewHistory.js b/src/scripts/ui/menu/viewHistory.js index 9beb17648..bc38d74f6 100644 --- a/src/scripts/ui/menu/viewHistory.js +++ b/src/scripts/ui/menu/viewHistory.js @@ -4,24 +4,24 @@ import { ComfyButton } from "../components/button"; import { ComfyViewList, ComfyViewListButton } from "./viewList"; export class ComfyViewHistoryButton extends ComfyViewListButton { - constructor(app) { - super(app, { - button: new ComfyButton({ - content: "View History", - icon: "history", - tooltip: "View history", - classList: "comfyui-button comfyui-history-button", - }), - list: ComfyViewHistoryList, - mode: "History", - }); - } + constructor(app) { + super(app, { + button: new ComfyButton({ + content: "View History", + icon: "history", + tooltip: "View history", + classList: "comfyui-button comfyui-history-button", + }), + list: ComfyViewHistoryList, + mode: "History", + }); + } } export class ComfyViewHistoryList extends ComfyViewList { - async loadItems() { - const items = await super.loadItems(); - items["History"].reverse(); - return items; - } + async loadItems() { + const items = await super.loadItems(); + items["History"].reverse(); + return items; + } } diff --git a/src/scripts/ui/menu/viewList.js b/src/scripts/ui/menu/viewList.js index 90e55436f..69d307277 100644 --- a/src/scripts/ui/menu/viewList.js +++ b/src/scripts/ui/menu/viewList.js @@ -6,198 +6,198 @@ import { api } from "../../api"; import { ComfyPopup } from "../components/popup"; export class ComfyViewListButton { - get open() { - return this.popup.open; - } + get open() { + return this.popup.open; + } - set open(open) { - this.popup.open = open; - } + set open(open) { + this.popup.open = open; + } - constructor(app, { button, list, mode }) { - this.app = app; - this.button = button; - this.element = $el("div.comfyui-button-wrapper", this.button.element); - this.popup = new ComfyPopup({ - target: this.element, - container: this.element, - horizontal: "right", - }); - this.list = new (list ?? ComfyViewList)(app, mode, this.popup); - this.popup.children = [this.list.element]; - this.popup.addEventListener("open", () => { - this.list.update(); - }); - this.popup.addEventListener("close", () => { - this.list.close(); - }); - this.button.withPopup(this.popup); + constructor(app, { button, list, mode }) { + this.app = app; + this.button = button; + this.element = $el("div.comfyui-button-wrapper", this.button.element); + this.popup = new ComfyPopup({ + target: this.element, + container: this.element, + horizontal: "right", + }); + this.list = new (list ?? ComfyViewList)(app, mode, this.popup); + this.popup.children = [this.list.element]; + this.popup.addEventListener("open", () => { + this.list.update(); + }); + this.popup.addEventListener("close", () => { + this.list.close(); + }); + this.button.withPopup(this.popup); - api.addEventListener("status", () => { - if (this.popup.open) { - this.popup.update(); - } - }); - } + api.addEventListener("status", () => { + if (this.popup.open) { + this.popup.update(); + } + }); + } } export class ComfyViewList { - popup; + popup; - constructor(app, mode, popup) { - this.app = app; - this.mode = mode; - this.popup = popup; - this.type = mode.toLowerCase(); + constructor(app, mode, popup) { + this.app = app; + this.mode = mode; + this.popup = popup; + this.type = mode.toLowerCase(); - this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`); - this.clear = new ComfyButton({ - icon: "cancel", - content: "Clear", - action: async () => { - this.showSpinner(false); - await api.clearItems(this.type); - await this.update(); - }, - }); + this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`); + this.clear = new ComfyButton({ + icon: "cancel", + content: "Clear", + action: async () => { + this.showSpinner(false); + await api.clearItems(this.type); + await this.update(); + }, + }); - this.refresh = new ComfyButton({ - icon: "refresh", - content: "Refresh", - action: async () => { - await this.update(false); - }, - }); + this.refresh = new ComfyButton({ + icon: "refresh", + content: "Refresh", + action: async () => { + await this.update(false); + }, + }); - this.element = $el(`div.comfyui-${this.type}-popup.comfyui-view-list-popup`, [ - $el("h3", mode), - $el("header", [this.clear.element, this.refresh.element]), - this.items, - ]); + this.element = $el(`div.comfyui-${this.type}-popup.comfyui-view-list-popup`, [ + $el("h3", mode), + $el("header", [this.clear.element, this.refresh.element]), + this.items, + ]); - api.addEventListener("status", () => { - if (this.popup.open) { - this.update(); - } - }); - } + api.addEventListener("status", () => { + if (this.popup.open) { + this.update(); + } + }); + } - async close() { - this.items.replaceChildren(); - } + async close() { + this.items.replaceChildren(); + } - async update(resize = true) { - this.showSpinner(resize); - const res = await this.loadItems(); - let any = false; + async update(resize = true) { + this.showSpinner(resize); + const res = await this.loadItems(); + let any = false; - const names = Object.keys(res); - const sections = names - .map((section) => { - const items = res[section]; - if (items?.length) { - any = true; - } else { - return; - } + const names = Object.keys(res); + const sections = names + .map((section) => { + const items = res[section]; + if (items?.length) { + any = true; + } else { + return; + } - const rows = []; - if (names.length > 1) { - rows.push($el("h5", section)); - } - rows.push(...items.flatMap((item) => this.createRow(item, section))); - return $el("section", rows); - }) - .filter(Boolean); + const rows = []; + if (names.length > 1) { + rows.push($el("h5", section)); + } + rows.push(...items.flatMap((item) => this.createRow(item, section))); + return $el("section", rows); + }) + .filter(Boolean); - if (any) { - this.items.replaceChildren(...sections); - } else { - this.items.replaceChildren($el("h5", "None")); - } + if (any) { + this.items.replaceChildren(...sections); + } else { + this.items.replaceChildren($el("h5", "None")); + } - this.popup.update(); - this.clear.enabled = this.refresh.enabled = true; - this.element.style.removeProperty("height"); - } + this.popup.update(); + this.clear.enabled = this.refresh.enabled = true; + this.element.style.removeProperty("height"); + } - showSpinner(resize = true) { - // if (!this.spinner) { - // this.spinner = createSpinner(); - // } - // if (!resize) { - // this.element.style.height = this.element.clientHeight + "px"; - // } - // this.clear.enabled = this.refresh.enabled = false; - // this.items.replaceChildren( - // $el( - // "div", - // { - // style: { - // fontSize: "18px", - // }, - // }, - // this.spinner - // ) - // ); - // this.popup.update(); - } + showSpinner(resize = true) { + // if (!this.spinner) { + // this.spinner = createSpinner(); + // } + // if (!resize) { + // this.element.style.height = this.element.clientHeight + "px"; + // } + // this.clear.enabled = this.refresh.enabled = false; + // this.items.replaceChildren( + // $el( + // "div", + // { + // style: { + // fontSize: "18px", + // }, + // }, + // this.spinner + // ) + // ); + // this.popup.update(); + } - async loadItems() { - return await api.getItems(this.type); - } + async loadItems() { + return await api.getItems(this.type); + } - getRow(item, section) { - return { - text: item.prompt[0] + "", - actions: [ - { - text: "Load", - action: async () => { - try { - await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); - if (item.outputs) { - this.app.nodeOutputs = item.outputs; - } - } catch (error) { - alert("Error loading workflow: " + error.message); - console.error(error); - } - }, - }, - { - text: "Delete", - action: async () => { - try { - await api.deleteItem(this.type, item.prompt[1]); - this.update(); - } catch (error) {} - }, - }, - ], - }; - } + getRow(item, section) { + return { + text: item.prompt[0] + "", + actions: [ + { + text: "Load", + action: async () => { + try { + await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); + if (item.outputs) { + this.app.nodeOutputs = item.outputs; + } + } catch (error) { + alert("Error loading workflow: " + error.message); + console.error(error); + } + }, + }, + { + text: "Delete", + action: async () => { + try { + await api.deleteItem(this.type, item.prompt[1]); + this.update(); + } catch (error) {} + }, + }, + ], + }; + } - createRow = (item, section) => { - const row = this.getRow(item, section); - return [ - $el("span", row.text), - ...row.actions.map( - (a) => - new ComfyButton({ - content: a.text, - action: async (e, btn) => { - btn.enabled = false; - try { - await a.action(); - } catch (error) { - throw error; - } finally { - btn.enabled = true; - } - }, - }).element - ), - ]; - }; + createRow = (item, section) => { + const row = this.getRow(item, section); + return [ + $el("span", row.text), + ...row.actions.map( + (a) => + new ComfyButton({ + content: a.text, + action: async (e, btn) => { + btn.enabled = false; + try { + await a.action(); + } catch (error) { + throw error; + } finally { + btn.enabled = true; + } + }, + }).element + ), + ]; + }; } diff --git a/src/scripts/ui/menu/viewQueue.js b/src/scripts/ui/menu/viewQueue.js index b1401c3c6..945b040df 100644 --- a/src/scripts/ui/menu/viewQueue.js +++ b/src/scripts/ui/menu/viewQueue.js @@ -5,51 +5,51 @@ import { ComfyViewList, ComfyViewListButton } from "./viewList"; import { api } from "../../api"; export class ComfyViewQueueButton extends ComfyViewListButton { - constructor(app) { - super(app, { - button: new ComfyButton({ - content: "View Queue", - icon: "format-list-numbered", - tooltip: "View queue", - classList: "comfyui-button comfyui-queue-button", - }), - list: ComfyViewQueueList, - mode: "Queue", - }); - } + constructor(app) { + super(app, { + button: new ComfyButton({ + content: "View Queue", + icon: "format-list-numbered", + tooltip: "View queue", + classList: "comfyui-button comfyui-queue-button", + }), + list: ComfyViewQueueList, + mode: "Queue", + }); + } } export class ComfyViewQueueList extends ComfyViewList { - getRow = (item, section) => { - if (section !== "Running") { - return super.getRow(item, section); - } - return { - text: item.prompt[0] + "", - actions: [ - { - text: "Load", - action: async () => { - try { - await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); - if (item.outputs) { - this.app.nodeOutputs = item.outputs; - } - } catch (error) { - alert("Error loading workflow: " + error.message); - console.error(error); - } - }, - }, - { - text: "Cancel", - action: async () => { - try { - await api.interrupt(); - } catch (error) {} - }, - }, - ], - }; - } + getRow = (item, section) => { + if (section !== "Running") { + return super.getRow(item, section); + } + return { + text: item.prompt[0] + "", + actions: [ + { + text: "Load", + action: async () => { + try { + await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); + if (item.outputs) { + this.app.nodeOutputs = item.outputs; + } + } catch (error) { + alert("Error loading workflow: " + error.message); + console.error(error); + } + }, + }, + { + text: "Cancel", + action: async () => { + try { + await api.interrupt(); + } catch (error) {} + }, + }, + ], + }; + } } diff --git a/src/scripts/ui/menu/workflows.js b/src/scripts/ui/menu/workflows.js index a1edebb3a..ed3a56d89 100644 --- a/src/scripts/ui/menu/workflows.js +++ b/src/scripts/ui/menu/workflows.js @@ -10,754 +10,754 @@ import { ComfyWorkflow, trimJsonExt } from "../../workflows"; import { ComfyAsyncDialog } from "../components/asyncDialog"; export class ComfyWorkflowsMenu { - #first = true; - element = $el("div.comfyui-workflows"); + #first = true; + element = $el("div.comfyui-workflows"); - get open() { - return this.popup.open; - } + get open() { + return this.popup.open; + } - set open(open) { - this.popup.open = open; - } + set open(open) { + this.popup.open = open; + } - /** - * @param {import("../../app").ComfyApp} app - */ - constructor(app) { - this.app = app; - this.#bindEvents(); + /** + * @param {import("../../app").ComfyApp} app + */ + constructor(app) { + this.app = app; + this.#bindEvents(); - const classList = { - "comfyui-workflows-button": true, - "comfyui-button": true, - unsaved: getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true", - running: false, - }; - this.buttonProgress = $el("div.comfyui-workflows-button-progress"); - this.workflowLabel = $el("span.comfyui-workflows-label", ""); - this.button = new ComfyButton({ - content: $el("div.comfyui-workflows-button-inner", [$el("i.mdi.mdi-graph"), this.workflowLabel, this.buttonProgress]), - icon: "chevron-down", - classList, - }); + const classList = { + "comfyui-workflows-button": true, + "comfyui-button": true, + unsaved: getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true", + running: false, + }; + this.buttonProgress = $el("div.comfyui-workflows-button-progress"); + this.workflowLabel = $el("span.comfyui-workflows-label", ""); + this.button = new ComfyButton({ + content: $el("div.comfyui-workflows-button-inner", [$el("i.mdi.mdi-graph"), this.workflowLabel, this.buttonProgress]), + icon: "chevron-down", + classList, + }); - this.element.append(this.button.element); + this.element.append(this.button.element); - this.popup = new ComfyPopup({ target: this.element, classList: "comfyui-workflows-popup" }); - this.content = new ComfyWorkflowsContent(app, this.popup); - this.popup.children = [this.content.element]; - this.popup.addEventListener("change", () => { - this.button.icon = "chevron-" + (this.popup.open ? "up" : "down"); - }); - this.button.withPopup(this.popup); + this.popup = new ComfyPopup({ target: this.element, classList: "comfyui-workflows-popup" }); + this.content = new ComfyWorkflowsContent(app, this.popup); + this.popup.children = [this.content.element]; + this.popup.addEventListener("change", () => { + this.button.icon = "chevron-" + (this.popup.open ? "up" : "down"); + }); + this.button.withPopup(this.popup); - this.unsaved = prop(this, "unsaved", classList.unsaved, (v) => { - classList.unsaved = v; - this.button.classList = classList; - setStorageValue("Comfy.PreviousWorkflowUnsaved", v); - }); - } + this.unsaved = prop(this, "unsaved", classList.unsaved, (v) => { + classList.unsaved = v; + this.button.classList = classList; + setStorageValue("Comfy.PreviousWorkflowUnsaved", v); + }); + } - #updateProgress = () => { - const prompt = this.app.workflowManager.activePrompt; - let percent = 0; - if (this.app.workflowManager.activeWorkflow === prompt?.workflow) { - const total = Object.values(prompt.nodes); - const done = total.filter(Boolean); - percent = (done.length / total.length) * 100; - } - this.buttonProgress.style.width = percent + "%"; - }; + #updateProgress = () => { + const prompt = this.app.workflowManager.activePrompt; + let percent = 0; + if (this.app.workflowManager.activeWorkflow === prompt?.workflow) { + const total = Object.values(prompt.nodes); + const done = total.filter(Boolean); + percent = (done.length / total.length) * 100; + } + this.buttonProgress.style.width = percent + "%"; + }; - #updateActive = () => { - const active = this.app.workflowManager.activeWorkflow; - this.button.tooltip = active.path; - this.workflowLabel.textContent = active.name; - this.unsaved = active.unsaved; + #updateActive = () => { + const active = this.app.workflowManager.activeWorkflow; + this.button.tooltip = active.path; + this.workflowLabel.textContent = active.name; + this.unsaved = active.unsaved; - if (this.#first) { - this.#first = false; - this.content.load(); - } + if (this.#first) { + this.#first = false; + this.content.load(); + } - this.#updateProgress(); - }; + this.#updateProgress(); + }; - #bindEvents() { - this.app.workflowManager.addEventListener("changeWorkflow", this.#updateActive); - this.app.workflowManager.addEventListener("rename", this.#updateActive); - this.app.workflowManager.addEventListener("delete", this.#updateActive); + #bindEvents() { + this.app.workflowManager.addEventListener("changeWorkflow", this.#updateActive); + this.app.workflowManager.addEventListener("rename", this.#updateActive); + this.app.workflowManager.addEventListener("delete", this.#updateActive); - this.app.workflowManager.addEventListener("save", () => { - this.unsaved = this.app.workflowManager.activeWorkflow.unsaved; - }); + this.app.workflowManager.addEventListener("save", () => { + this.unsaved = this.app.workflowManager.activeWorkflow.unsaved; + }); - this.app.workflowManager.addEventListener("execute", (e) => { - this.#updateProgress(); - }); + this.app.workflowManager.addEventListener("execute", (e) => { + this.#updateProgress(); + }); - api.addEventListener("graphChanged", () => { - this.unsaved = true; - }); - } + api.addEventListener("graphChanged", () => { + this.unsaved = true; + }); + } - #getMenuOptions(callback) { - const menu = []; - const directories = new Map(); - for (const workflow of this.app.workflowManager.workflows || []) { - const path = workflow.pathParts; - if (!path) continue; - let parent = menu; - let currentPath = ""; - for (let i = 0; i < path.length - 1; i++) { - currentPath += "/" + path[i]; - let newParent = directories.get(currentPath); - if (!newParent) { - newParent = { - title: path[i], - has_submenu: true, - submenu: { - options: [], - }, - }; - parent.push(newParent); - newParent = newParent.submenu.options; - directories.set(currentPath, newParent); - } - parent = newParent; - } - parent.push({ - title: trimJsonExt(path[path.length - 1]), - callback: () => callback(workflow), - }); - } - return menu; - } + #getMenuOptions(callback) { + const menu = []; + const directories = new Map(); + for (const workflow of this.app.workflowManager.workflows || []) { + const path = workflow.pathParts; + if (!path) continue; + let parent = menu; + let currentPath = ""; + for (let i = 0; i < path.length - 1; i++) { + currentPath += "/" + path[i]; + let newParent = directories.get(currentPath); + if (!newParent) { + newParent = { + title: path[i], + has_submenu: true, + submenu: { + options: [], + }, + }; + parent.push(newParent); + newParent = newParent.submenu.options; + directories.set(currentPath, newParent); + } + parent = newParent; + } + parent.push({ + title: trimJsonExt(path[path.length - 1]), + callback: () => callback(workflow), + }); + } + return menu; + } - #getFavoriteMenuOptions(callback) { - const menu = []; - for (const workflow of this.app.workflowManager.workflows || []) { - if (workflow.isFavorite) { - menu.push({ - title: "⭐ " + workflow.name, - callback: () => callback(workflow), - }); - } - } - return menu; - } + #getFavoriteMenuOptions(callback) { + const menu = []; + for (const workflow of this.app.workflowManager.workflows || []) { + if (workflow.isFavorite) { + menu.push({ + title: "⭐ " + workflow.name, + callback: () => callback(workflow), + }); + } + } + return menu; + } - /** - * @param {import("../../app").ComfyApp} app - */ - registerExtension(app) { - const self = this; - app.registerExtension({ - name: "Comfy.Workflows", - async beforeRegisterNodeDef(nodeType) { - function getImageWidget(node) { - const inputs = { ...node.constructor?.nodeData?.input?.required, ...node.constructor?.nodeData?.input?.optional }; - for (const input in inputs) { - if (inputs[input][0] === "IMAGEUPLOAD") { - const imageWidget = node.widgets.find((w) => w.name === (inputs[input]?.[1]?.widget ?? "image")); - if (imageWidget) return imageWidget; - } - } - } + /** + * @param {import("../../app").ComfyApp} app + */ + registerExtension(app) { + const self = this; + app.registerExtension({ + name: "Comfy.Workflows", + async beforeRegisterNodeDef(nodeType) { + function getImageWidget(node) { + const inputs = { ...node.constructor?.nodeData?.input?.required, ...node.constructor?.nodeData?.input?.optional }; + for (const input in inputs) { + if (inputs[input][0] === "IMAGEUPLOAD") { + const imageWidget = node.widgets.find((w) => w.name === (inputs[input]?.[1]?.widget ?? "image")); + if (imageWidget) return imageWidget; + } + } + } - function setWidgetImage(node, widget, img) { - const url = new URL(img.src); - const filename = url.searchParams.get("filename"); - const subfolder = url.searchParams.get("subfolder"); - const type = url.searchParams.get("type"); - const imageId = `${subfolder ? subfolder + "/" : ""}${filename} [${type}]`; - widget.value = imageId; - node.imgs = [img]; - app.graph.setDirtyCanvas(true, true); - } + function setWidgetImage(node, widget, img) { + const url = new URL(img.src); + const filename = url.searchParams.get("filename"); + const subfolder = url.searchParams.get("subfolder"); + const type = url.searchParams.get("type"); + const imageId = `${subfolder ? subfolder + "/" : ""}${filename} [${type}]`; + widget.value = imageId; + node.imgs = [img]; + app.graph.setDirtyCanvas(true, true); + } - /** - * @param {HTMLImageElement} img - * @param {ComfyWorkflow} workflow - */ - async function sendToWorkflow(img, workflow) { - await workflow.load(); - let options = []; - const nodes = app.graph.computeExecutionOrder(false); - for (const node of nodes) { - const widget = getImageWidget(node); - if (widget == null) continue; + /** + * @param {HTMLImageElement} img + * @param {ComfyWorkflow} workflow + */ + async function sendToWorkflow(img, workflow) { + await workflow.load(); + let options = []; + const nodes = app.graph.computeExecutionOrder(false); + for (const node of nodes) { + const widget = getImageWidget(node); + if (widget == null) continue; - if (node.title?.toLowerCase().includes("input")) { - options = [{ widget, node }]; - break; - } else { - options.push({ widget, node }); - } - } + if (node.title?.toLowerCase().includes("input")) { + options = [{ widget, node }]; + break; + } else { + options.push({ widget, node }); + } + } - if (!options.length) { - alert("No image nodes have been found in this workflow!"); - return; - } else if (options.length > 1) { - const dialog = new WidgetSelectionDialog(options); - const res = await dialog.show(app); - if (!res) return; - options = [res]; - } + if (!options.length) { + alert("No image nodes have been found in this workflow!"); + return; + } else if (options.length > 1) { + const dialog = new WidgetSelectionDialog(options); + const res = await dialog.show(app); + if (!res) return; + options = [res]; + } - setWidgetImage(options[0].node, options[0].widget, img); - } + setWidgetImage(options[0].node, options[0].widget, img); + } - const getExtraMenuOptions = nodeType.prototype["getExtraMenuOptions"]; - nodeType.prototype["getExtraMenuOptions"] = function (_, options) { - const r = getExtraMenuOptions?.apply?.(this, arguments); - if (app.ui.settings.getSettingValue("Comfy.UseNewMenu", false) === true) { - const t = /** @type { {imageIndex?: number, overIndex?: number, imgs: string[]} } */ /** @type {any} */ (this); - let img; - if (t.imageIndex != null) { - // An image is selected so select that - img = t.imgs?.[t.imageIndex]; - } else if (t.overIndex != null) { - // No image is selected but one is hovered - img = t.img?.s[t.overIndex]; - } + const getExtraMenuOptions = nodeType.prototype["getExtraMenuOptions"]; + nodeType.prototype["getExtraMenuOptions"] = function (_, options) { + const r = getExtraMenuOptions?.apply?.(this, arguments); + if (app.ui.settings.getSettingValue("Comfy.UseNewMenu", false) === true) { + const t = /** @type { {imageIndex?: number, overIndex?: number, imgs: string[]} } */ /** @type {any} */ (this); + let img; + if (t.imageIndex != null) { + // An image is selected so select that + img = t.imgs?.[t.imageIndex]; + } else if (t.overIndex != null) { + // No image is selected but one is hovered + img = t.img?.s[t.overIndex]; + } - if (img) { - let pos = options.findIndex((o) => o.content === "Save Image"); - if (pos === -1) { - pos = 0; - } else { - pos++; - } + if (img) { + let pos = options.findIndex((o) => o.content === "Save Image"); + if (pos === -1) { + pos = 0; + } else { + pos++; + } - options.splice(pos, 0, { - content: "Send to workflow", - has_submenu: true, - submenu: { - options: [ - { - callback: () => sendToWorkflow(img, app.workflowManager.activeWorkflow), - title: "[Current workflow]", - }, - ...self.#getFavoriteMenuOptions(sendToWorkflow.bind(null, img)), - null, - ...self.#getMenuOptions(sendToWorkflow.bind(null, img)), - ], - }, - }); - } - } + options.splice(pos, 0, { + content: "Send to workflow", + has_submenu: true, + submenu: { + options: [ + { + callback: () => sendToWorkflow(img, app.workflowManager.activeWorkflow), + title: "[Current workflow]", + }, + ...self.#getFavoriteMenuOptions(sendToWorkflow.bind(null, img)), + null, + ...self.#getMenuOptions(sendToWorkflow.bind(null, img)), + ], + }, + }); + } + } - return r; - }; - }, - }); - } + return r; + }; + }, + }); + } } export class ComfyWorkflowsContent { - element = $el("div.comfyui-workflows-panel"); - treeState = {}; - treeFiles = {}; - /** @type { Map } */ - openFiles = new Map(); - /** @type {WorkflowElement} */ - activeElement = null; + element = $el("div.comfyui-workflows-panel"); + treeState = {}; + treeFiles = {}; + /** @type { Map } */ + openFiles = new Map(); + /** @type {WorkflowElement} */ + activeElement = null; - /** - * @param {import("../../app").ComfyApp} app - * @param {ComfyPopup} popup - */ - constructor(app, popup) { - this.app = app; - this.popup = popup; - this.actions = $el("div.comfyui-workflows-actions", [ - new ComfyButton({ - content: "Default", - icon: "file-code", - iconSize: 18, - classList: "comfyui-button primary", - tooltip: "Load default workflow", - action: () => { - popup.open = false; - app.loadGraphData(); - app.resetView(); - }, - }).element, - new ComfyButton({ - content: "Browse", - icon: "folder", - iconSize: 18, - tooltip: "Browse for an image or exported workflow", - action: () => { - popup.open = false; - app.ui.loadFile(); - }, - }).element, - new ComfyButton({ - content: "Blank", - icon: "plus-thick", - iconSize: 18, - tooltip: "Create a new blank workflow", - action: () => { - app.workflowManager.setWorkflow(null); - app.clean(); - app.graph.clear(); - app.workflowManager.activeWorkflow.track(); - popup.open = false; - }, - }).element, - ]); + /** + * @param {import("../../app").ComfyApp} app + * @param {ComfyPopup} popup + */ + constructor(app, popup) { + this.app = app; + this.popup = popup; + this.actions = $el("div.comfyui-workflows-actions", [ + new ComfyButton({ + content: "Default", + icon: "file-code", + iconSize: 18, + classList: "comfyui-button primary", + tooltip: "Load default workflow", + action: () => { + popup.open = false; + app.loadGraphData(); + app.resetView(); + }, + }).element, + new ComfyButton({ + content: "Browse", + icon: "folder", + iconSize: 18, + tooltip: "Browse for an image or exported workflow", + action: () => { + popup.open = false; + app.ui.loadFile(); + }, + }).element, + new ComfyButton({ + content: "Blank", + icon: "plus-thick", + iconSize: 18, + tooltip: "Create a new blank workflow", + action: () => { + app.workflowManager.setWorkflow(null); + app.clean(); + app.graph.clear(); + app.workflowManager.activeWorkflow.track(); + popup.open = false; + }, + }).element, + ]); - this.spinner = createSpinner(); - this.element.replaceChildren(this.actions, this.spinner); + this.spinner = createSpinner(); + this.element.replaceChildren(this.actions, this.spinner); - this.popup.addEventListener("open", () => this.load()); - this.popup.addEventListener("close", () => this.element.replaceChildren(this.actions, this.spinner)); + this.popup.addEventListener("open", () => this.load()); + this.popup.addEventListener("close", () => this.element.replaceChildren(this.actions, this.spinner)); - this.app.workflowManager.addEventListener("favorite", (e) => { - const workflow = e["detail"]; - const button = this.treeFiles[workflow.path]?.primary; - if (!button) return; // Can happen when a workflow is renamed - button.icon = this.#getFavoriteIcon(workflow); - button.overIcon = this.#getFavoriteOverIcon(workflow); - this.updateFavorites(); - }); + this.app.workflowManager.addEventListener("favorite", (e) => { + const workflow = e["detail"]; + const button = this.treeFiles[workflow.path]?.primary; + if (!button) return; // Can happen when a workflow is renamed + button.icon = this.#getFavoriteIcon(workflow); + button.overIcon = this.#getFavoriteOverIcon(workflow); + this.updateFavorites(); + }); - for (const e of ["save", "open", "close", "changeWorkflow"]) { - // TODO: dont be lazy and just update the specific element - app.workflowManager.addEventListener(e, () => this.updateOpen()); - } - this.app.workflowManager.addEventListener("rename", () => this.load()); - this.app.workflowManager.addEventListener("execute", (e) => this.#updateActive()); - } + for (const e of ["save", "open", "close", "changeWorkflow"]) { + // TODO: dont be lazy and just update the specific element + app.workflowManager.addEventListener(e, () => this.updateOpen()); + } + this.app.workflowManager.addEventListener("rename", () => this.load()); + this.app.workflowManager.addEventListener("execute", (e) => this.#updateActive()); + } - async load() { - await this.app.workflowManager.loadWorkflows(); - this.updateTree(); - this.updateFavorites(); - this.updateOpen(); - this.element.replaceChildren(this.actions, this.openElement, this.favoritesElement, this.treeElement); - } + async load() { + await this.app.workflowManager.loadWorkflows(); + this.updateTree(); + this.updateFavorites(); + this.updateOpen(); + this.element.replaceChildren(this.actions, this.openElement, this.favoritesElement, this.treeElement); + } - updateOpen() { - const current = this.openElement; - this.openFiles.clear(); + updateOpen() { + const current = this.openElement; + this.openFiles.clear(); - this.openElement = $el("div.comfyui-workflows-open", [ - $el("h3", "Open"), - ...this.app.workflowManager.openWorkflows.map((w) => { - const wrapper = new WorkflowElement(this, w, { - primary: { element: $el("i.mdi.mdi-18px.mdi-progress-pencil") }, - buttons: [ - this.#getRenameButton(w), - new ComfyButton({ - icon: "close", - iconSize: 18, - classList: "comfyui-button comfyui-workflows-file-action", - tooltip: "Close workflow", - action: (e) => { - e.stopImmediatePropagation(); - this.app.workflowManager.closeWorkflow(w); - }, - }), - ], - }); - if (w.unsaved) { - wrapper.element.classList.add("unsaved"); - } - if(w === this.app.workflowManager.activeWorkflow) { - wrapper.element.classList.add("active"); - } + this.openElement = $el("div.comfyui-workflows-open", [ + $el("h3", "Open"), + ...this.app.workflowManager.openWorkflows.map((w) => { + const wrapper = new WorkflowElement(this, w, { + primary: { element: $el("i.mdi.mdi-18px.mdi-progress-pencil") }, + buttons: [ + this.#getRenameButton(w), + new ComfyButton({ + icon: "close", + iconSize: 18, + classList: "comfyui-button comfyui-workflows-file-action", + tooltip: "Close workflow", + action: (e) => { + e.stopImmediatePropagation(); + this.app.workflowManager.closeWorkflow(w); + }, + }), + ], + }); + if (w.unsaved) { + wrapper.element.classList.add("unsaved"); + } + if(w === this.app.workflowManager.activeWorkflow) { + wrapper.element.classList.add("active"); + } - this.openFiles.set(w, wrapper); - return wrapper.element; - }), - ]); + this.openFiles.set(w, wrapper); + return wrapper.element; + }), + ]); - this.#updateActive(); - current?.replaceWith(this.openElement); - } + this.#updateActive(); + current?.replaceWith(this.openElement); + } - updateFavorites() { - const current = this.favoritesElement; - const favorites = [...this.app.workflowManager.workflows.filter((w) => w.isFavorite)]; + updateFavorites() { + const current = this.favoritesElement; + const favorites = [...this.app.workflowManager.workflows.filter((w) => w.isFavorite)]; - this.favoritesElement = $el("div.comfyui-workflows-favorites", [ - $el("h3", "Favorites"), - ...favorites - .map((w) => { - return this.#getWorkflowElement(w).element; - }) - .filter(Boolean), - ]); + this.favoritesElement = $el("div.comfyui-workflows-favorites", [ + $el("h3", "Favorites"), + ...favorites + .map((w) => { + return this.#getWorkflowElement(w).element; + }) + .filter(Boolean), + ]); - current?.replaceWith(this.favoritesElement); - } + current?.replaceWith(this.favoritesElement); + } - filterTree() { - if (!this.filterText) { - this.treeRoot.classList.remove("filtered"); - // Unfilter whole tree - for (const item of Object.values(this.treeFiles)) { - item.element.parentElement.style.removeProperty("display"); - this.showTreeParents(item.element.parentElement); - } - return; - } - this.treeRoot.classList.add("filtered"); - const searchTerms = this.filterText.toLocaleLowerCase().split(" "); - for (const item of Object.values(this.treeFiles)) { - const parts = item.workflow.pathParts; - let termIndex = 0; - let valid = false; - for (const part of parts) { - let currentIndex = 0; - do { - currentIndex = part.indexOf(searchTerms[termIndex], currentIndex); - if (currentIndex > -1) currentIndex += searchTerms[termIndex].length; - } while (currentIndex !== -1 && ++termIndex < searchTerms.length); + filterTree() { + if (!this.filterText) { + this.treeRoot.classList.remove("filtered"); + // Unfilter whole tree + for (const item of Object.values(this.treeFiles)) { + item.element.parentElement.style.removeProperty("display"); + this.showTreeParents(item.element.parentElement); + } + return; + } + this.treeRoot.classList.add("filtered"); + const searchTerms = this.filterText.toLocaleLowerCase().split(" "); + for (const item of Object.values(this.treeFiles)) { + const parts = item.workflow.pathParts; + let termIndex = 0; + let valid = false; + for (const part of parts) { + let currentIndex = 0; + do { + currentIndex = part.indexOf(searchTerms[termIndex], currentIndex); + if (currentIndex > -1) currentIndex += searchTerms[termIndex].length; + } while (currentIndex !== -1 && ++termIndex < searchTerms.length); - if (termIndex >= searchTerms.length) { - valid = true; - break; - } - } - if (valid) { - item.element.parentElement.style.removeProperty("display"); - this.showTreeParents(item.element.parentElement); - } else { - item.element.parentElement.style.display = "none"; - this.hideTreeParents(item.element.parentElement); - } - } - } + if (termIndex >= searchTerms.length) { + valid = true; + break; + } + } + if (valid) { + item.element.parentElement.style.removeProperty("display"); + this.showTreeParents(item.element.parentElement); + } else { + item.element.parentElement.style.display = "none"; + this.hideTreeParents(item.element.parentElement); + } + } + } - hideTreeParents(element) { - // Hide all parents if no children are visible - if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) { - for (let i = 1; i < element.parentElement.children.length; i++) { - const c = element.parentElement.children[i]; - if (c.style.display !== "none") { - return; - } - } - element.parentElement.style.display = "none"; - this.hideTreeParents(element.parentElement); - } - } + hideTreeParents(element) { + // Hide all parents if no children are visible + if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) { + for (let i = 1; i < element.parentElement.children.length; i++) { + const c = element.parentElement.children[i]; + if (c.style.display !== "none") { + return; + } + } + element.parentElement.style.display = "none"; + this.hideTreeParents(element.parentElement); + } + } - showTreeParents(element) { - if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) { - element.parentElement.style.removeProperty("display"); - this.showTreeParents(element.parentElement); - } - } + showTreeParents(element) { + if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) { + element.parentElement.style.removeProperty("display"); + this.showTreeParents(element.parentElement); + } + } - updateTree() { - const current = this.treeElement; - const nodes = {}; - let typingTimeout; + updateTree() { + const current = this.treeElement; + const nodes = {}; + let typingTimeout; - this.treeFiles = {}; - this.treeRoot = $el("ul.comfyui-workflows-tree"); - this.treeElement = $el("section", [ - $el("header", [ - $el("h3", "Browse"), - $el("div.comfy-ui-workflows-search", [ - $el("i.mdi.mdi-18px.mdi-magnify"), - $el("input", { - placeholder: "Search", - value: this.filterText ?? "", - oninput: (e) => { - this.filterText = e.target["value"]?.trim(); - clearTimeout(typingTimeout); - typingTimeout = setTimeout(() => this.filterTree(), 250); - }, - }), - ]), - ]), - this.treeRoot, - ]); + this.treeFiles = {}; + this.treeRoot = $el("ul.comfyui-workflows-tree"); + this.treeElement = $el("section", [ + $el("header", [ + $el("h3", "Browse"), + $el("div.comfy-ui-workflows-search", [ + $el("i.mdi.mdi-18px.mdi-magnify"), + $el("input", { + placeholder: "Search", + value: this.filterText ?? "", + oninput: (e) => { + this.filterText = e.target["value"]?.trim(); + clearTimeout(typingTimeout); + typingTimeout = setTimeout(() => this.filterTree(), 250); + }, + }), + ]), + ]), + this.treeRoot, + ]); - for (const workflow of this.app.workflowManager.workflows) { - if (!workflow.pathParts) continue; + for (const workflow of this.app.workflowManager.workflows) { + if (!workflow.pathParts) continue; - let currentPath = ""; - let currentRoot = this.treeRoot; + let currentPath = ""; + let currentRoot = this.treeRoot; - for (let i = 0; i < workflow.pathParts.length; i++) { - currentPath += (currentPath ? "\\" : "") + workflow.pathParts[i]; - const parentNode = nodes[currentPath] ?? this.#createNode(currentPath, workflow, i, currentRoot); + for (let i = 0; i < workflow.pathParts.length; i++) { + currentPath += (currentPath ? "\\" : "") + workflow.pathParts[i]; + const parentNode = nodes[currentPath] ?? this.#createNode(currentPath, workflow, i, currentRoot); - nodes[currentPath] = parentNode; - currentRoot = parentNode; - } - } + nodes[currentPath] = parentNode; + currentRoot = parentNode; + } + } - current?.replaceWith(this.treeElement); - this.filterTree(); - } + current?.replaceWith(this.treeElement); + this.filterTree(); + } - #expandNode(el, workflow, thisPath, i) { - const expanded = !el.classList.toggle("closed"); - if (expanded) { - let c = ""; - for (let j = 0; j <= i; j++) { - c += (c ? "\\" : "") + workflow.pathParts[j]; - this.treeState[c] = true; - } - } else { - let c = thisPath; - for (let j = i + 1; j < workflow.pathParts.length; j++) { - c += (c ? "\\" : "") + workflow.pathParts[j]; - delete this.treeState[c]; - } - delete this.treeState[thisPath]; - } - } + #expandNode(el, workflow, thisPath, i) { + const expanded = !el.classList.toggle("closed"); + if (expanded) { + let c = ""; + for (let j = 0; j <= i; j++) { + c += (c ? "\\" : "") + workflow.pathParts[j]; + this.treeState[c] = true; + } + } else { + let c = thisPath; + for (let j = i + 1; j < workflow.pathParts.length; j++) { + c += (c ? "\\" : "") + workflow.pathParts[j]; + delete this.treeState[c]; + } + delete this.treeState[thisPath]; + } + } - #updateActive() { - this.#removeActive(); + #updateActive() { + this.#removeActive(); - const active = this.app.workflowManager.activePrompt; - if (!active?.workflow) return; + const active = this.app.workflowManager.activePrompt; + if (!active?.workflow) return; - const open = this.openFiles.get(active.workflow); - if (!open) return; + const open = this.openFiles.get(active.workflow); + if (!open) return; - this.activeElement = open; + this.activeElement = open; - const total = Object.values(active.nodes); - const done = total.filter(Boolean); - const percent = done.length / total.length; - open.element.classList.add("running"); - open.element.style.setProperty("--progress", percent * 100 + "%"); - open.primary.element.classList.remove("mdi-progress-pencil"); - open.primary.element.classList.add("mdi-play"); - } + const total = Object.values(active.nodes); + const done = total.filter(Boolean); + const percent = done.length / total.length; + open.element.classList.add("running"); + open.element.style.setProperty("--progress", percent * 100 + "%"); + open.primary.element.classList.remove("mdi-progress-pencil"); + open.primary.element.classList.add("mdi-play"); + } - #removeActive() { - if (!this.activeElement) return; - this.activeElement.element.classList.remove("running"); - this.activeElement.element.style.removeProperty("--progress"); - this.activeElement.primary.element.classList.add("mdi-progress-pencil"); - this.activeElement.primary.element.classList.remove("mdi-play"); - } + #removeActive() { + if (!this.activeElement) return; + this.activeElement.element.classList.remove("running"); + this.activeElement.element.style.removeProperty("--progress"); + this.activeElement.primary.element.classList.add("mdi-progress-pencil"); + this.activeElement.primary.element.classList.remove("mdi-play"); + } - /** @param {ComfyWorkflow} workflow */ - #getFavoriteIcon(workflow) { - return workflow.isFavorite ? "star" : "file-outline"; - } + /** @param {ComfyWorkflow} workflow */ + #getFavoriteIcon(workflow) { + return workflow.isFavorite ? "star" : "file-outline"; + } - /** @param {ComfyWorkflow} workflow */ - #getFavoriteOverIcon(workflow) { - return workflow.isFavorite ? "star-off" : "star-outline"; - } + /** @param {ComfyWorkflow} workflow */ + #getFavoriteOverIcon(workflow) { + return workflow.isFavorite ? "star-off" : "star-outline"; + } - /** @param {ComfyWorkflow} workflow */ - #getFavoriteTooltip(workflow) { - return workflow.isFavorite ? "Remove this workflow from your favorites" : "Add this workflow to your favorites"; - } + /** @param {ComfyWorkflow} workflow */ + #getFavoriteTooltip(workflow) { + return workflow.isFavorite ? "Remove this workflow from your favorites" : "Add this workflow to your favorites"; + } - /** @param {ComfyWorkflow} workflow */ - #getFavoriteButton(workflow, primary) { - return new ComfyButton({ - icon: this.#getFavoriteIcon(workflow), - overIcon: this.#getFavoriteOverIcon(workflow), - iconSize: 18, - classList: "comfyui-button comfyui-workflows-file-action-favorite" + (primary ? " comfyui-workflows-file-action-primary" : ""), - tooltip: this.#getFavoriteTooltip(workflow), - action: (e) => { - e.stopImmediatePropagation(); - workflow.favorite(!workflow.isFavorite); - }, - }); - } + /** @param {ComfyWorkflow} workflow */ + #getFavoriteButton(workflow, primary) { + return new ComfyButton({ + icon: this.#getFavoriteIcon(workflow), + overIcon: this.#getFavoriteOverIcon(workflow), + iconSize: 18, + classList: "comfyui-button comfyui-workflows-file-action-favorite" + (primary ? " comfyui-workflows-file-action-primary" : ""), + tooltip: this.#getFavoriteTooltip(workflow), + action: (e) => { + e.stopImmediatePropagation(); + workflow.favorite(!workflow.isFavorite); + }, + }); + } - /** @param {ComfyWorkflow} workflow */ - #getDeleteButton(workflow) { - const deleteButton = new ComfyButton({ - icon: "delete", - tooltip: "Delete this workflow", - classList: "comfyui-button comfyui-workflows-file-action", - iconSize: 18, - action: async (e, btn) => { - e.stopImmediatePropagation(); + /** @param {ComfyWorkflow} workflow */ + #getDeleteButton(workflow) { + const deleteButton = new ComfyButton({ + icon: "delete", + tooltip: "Delete this workflow", + classList: "comfyui-button comfyui-workflows-file-action", + iconSize: 18, + action: async (e, btn) => { + e.stopImmediatePropagation(); - if (btn.icon === "delete-empty") { - btn.enabled = false; - await workflow.delete(); - await this.load(); - } else { - btn.icon = "delete-empty"; - btn.element.style.background = "red"; - } - }, - }); - deleteButton.element.addEventListener("mouseleave", () => { - deleteButton.icon = "delete"; - deleteButton.element.style.removeProperty("background"); - }); - return deleteButton; - } + if (btn.icon === "delete-empty") { + btn.enabled = false; + await workflow.delete(); + await this.load(); + } else { + btn.icon = "delete-empty"; + btn.element.style.background = "red"; + } + }, + }); + deleteButton.element.addEventListener("mouseleave", () => { + deleteButton.icon = "delete"; + deleteButton.element.style.removeProperty("background"); + }); + return deleteButton; + } - /** @param {ComfyWorkflow} workflow */ - #getInsertButton(workflow) { - return new ComfyButton({ - icon: "file-move-outline", - iconSize: 18, - tooltip: "Insert this workflow into the current workflow", - classList: "comfyui-button comfyui-workflows-file-action", - action: (e) => { - if (!this.app.shiftDown) { - this.popup.open = false; - } - e.stopImmediatePropagation(); - if (!this.app.shiftDown) { - this.popup.open = false; - } - workflow.insert(); - }, - }); - } + /** @param {ComfyWorkflow} workflow */ + #getInsertButton(workflow) { + return new ComfyButton({ + icon: "file-move-outline", + iconSize: 18, + tooltip: "Insert this workflow into the current workflow", + classList: "comfyui-button comfyui-workflows-file-action", + action: (e) => { + if (!this.app.shiftDown) { + this.popup.open = false; + } + e.stopImmediatePropagation(); + if (!this.app.shiftDown) { + this.popup.open = false; + } + workflow.insert(); + }, + }); + } - /** @param {ComfyWorkflow} workflow */ - #getRenameButton(workflow) { - return new ComfyButton({ - icon: "pencil", - tooltip: workflow.path ? "Rename this workflow" : "This workflow can't be renamed as it hasn't been saved.", - classList: "comfyui-button comfyui-workflows-file-action", - iconSize: 18, - enabled: !!workflow.path, - action: async (e) => { - e.stopImmediatePropagation(); - const newName = prompt("Enter new name", workflow.path); - if (newName) { - await workflow.rename(newName); - } - }, - }); - } + /** @param {ComfyWorkflow} workflow */ + #getRenameButton(workflow) { + return new ComfyButton({ + icon: "pencil", + tooltip: workflow.path ? "Rename this workflow" : "This workflow can't be renamed as it hasn't been saved.", + classList: "comfyui-button comfyui-workflows-file-action", + iconSize: 18, + enabled: !!workflow.path, + action: async (e) => { + e.stopImmediatePropagation(); + const newName = prompt("Enter new name", workflow.path); + if (newName) { + await workflow.rename(newName); + } + }, + }); + } - /** @param {ComfyWorkflow} workflow */ - #getWorkflowElement(workflow) { - return new WorkflowElement(this, workflow, { - primary: this.#getFavoriteButton(workflow, true), - buttons: [this.#getInsertButton(workflow), this.#getRenameButton(workflow), this.#getDeleteButton(workflow)], - }); - } + /** @param {ComfyWorkflow} workflow */ + #getWorkflowElement(workflow) { + return new WorkflowElement(this, workflow, { + primary: this.#getFavoriteButton(workflow, true), + buttons: [this.#getInsertButton(workflow), this.#getRenameButton(workflow), this.#getDeleteButton(workflow)], + }); + } - /** @param {ComfyWorkflow} workflow */ - #createLeafNode(workflow) { - const fileNode = this.#getWorkflowElement(workflow); - this.treeFiles[workflow.path] = fileNode; - return fileNode; - } + /** @param {ComfyWorkflow} workflow */ + #createLeafNode(workflow) { + const fileNode = this.#getWorkflowElement(workflow); + this.treeFiles[workflow.path] = fileNode; + return fileNode; + } - #createNode(currentPath, workflow, i, currentRoot) { - const part = workflow.pathParts[i]; + #createNode(currentPath, workflow, i, currentRoot) { + const part = workflow.pathParts[i]; - const parentNode = $el("ul" + (this.treeState[currentPath] ? "" : ".closed"), { - $: (el) => { - el.onclick = (e) => { - this.#expandNode(el, workflow, currentPath, i); - e.stopImmediatePropagation(); - }; - }, - }); - currentRoot.append(parentNode); + const parentNode = $el("ul" + (this.treeState[currentPath] ? "" : ".closed"), { + $: (el) => { + el.onclick = (e) => { + this.#expandNode(el, workflow, currentPath, i); + e.stopImmediatePropagation(); + }; + }, + }); + currentRoot.append(parentNode); - // Create a node for the current part and an inner UL for its children if it isnt a leaf node - const leaf = i === workflow.pathParts.length - 1; - let nodeElement; - if (leaf) { - nodeElement = this.#createLeafNode(workflow).element; - } else { - nodeElement = $el("li", [$el("i.mdi.mdi-18px.mdi-folder"), $el("span", part)]); - } - parentNode.append(nodeElement); - return parentNode; - } + // Create a node for the current part and an inner UL for its children if it isnt a leaf node + const leaf = i === workflow.pathParts.length - 1; + let nodeElement; + if (leaf) { + nodeElement = this.#createLeafNode(workflow).element; + } else { + nodeElement = $el("li", [$el("i.mdi.mdi-18px.mdi-folder"), $el("span", part)]); + } + parentNode.append(nodeElement); + return parentNode; + } } class WorkflowElement { - /** - * @param { ComfyWorkflowsContent } parent - * @param { ComfyWorkflow } workflow - */ - constructor(parent, workflow, { tagName = "li", primary, buttons }) { - this.parent = parent; - this.workflow = workflow; - this.primary = primary; - this.buttons = buttons; + /** + * @param { ComfyWorkflowsContent } parent + * @param { ComfyWorkflow } workflow + */ + constructor(parent, workflow, { tagName = "li", primary, buttons }) { + this.parent = parent; + this.workflow = workflow; + this.primary = primary; + this.buttons = buttons; - this.element = $el( - tagName + ".comfyui-workflows-tree-file", - { - onclick: () => { - workflow.load(); - this.parent.popup.open = false; - }, - title: this.workflow.path, - }, - [this.primary?.element, $el("span", workflow.name), ...buttons.map((b) => b.element)] - ); - } + this.element = $el( + tagName + ".comfyui-workflows-tree-file", + { + onclick: () => { + workflow.load(); + this.parent.popup.open = false; + }, + title: this.workflow.path, + }, + [this.primary?.element, $el("span", workflow.name), ...buttons.map((b) => b.element)] + ); + } } class WidgetSelectionDialog extends ComfyAsyncDialog { - #options; + #options; - /** - * @param {Array<{widget: {name: string}, node: {pos: [number, number], title: string, id: string, type: string}}>} options - */ - constructor(options) { - super(); - this.#options = options; - } + /** + * @param {Array<{widget: {name: string}, node: {pos: [number, number], title: string, id: string, type: string}}>} options + */ + constructor(options) { + super(); + this.#options = options; + } - show(app) { - this.element.classList.add("comfy-widget-selection-dialog"); - return super.show( - $el("div", [ - $el("h2", "Select image target"), - $el( - "p", - "This workflow has multiple image loader nodes, you can rename a node to include 'input' in the title for it to be automatically selected, or select one below." - ), - $el( - "section", - this.#options.map((opt) => { - return $el("div.comfy-widget-selection-item", [ - $el("span", { dataset: { id: opt.node.id } }, `${opt.node.title ?? opt.node.type} ${opt.widget.name}`), - $el( - "button.comfyui-button", - { - onclick: () => { - app.canvas.ds.offset[0] = -opt.node.pos[0] + 50; - app.canvas.ds.offset[1] = -opt.node.pos[1] + 50; - app.canvas.selectNode(opt.node); - app.graph.setDirtyCanvas(true, true); - }, - }, - "Show" - ), - $el( - "button.comfyui-button.primary", - { - onclick: () => { - this.close(opt); - }, - }, - "Select" - ), - ]); - }) - ), - ]) - ); - } + show(app) { + this.element.classList.add("comfy-widget-selection-dialog"); + return super.show( + $el("div", [ + $el("h2", "Select image target"), + $el( + "p", + "This workflow has multiple image loader nodes, you can rename a node to include 'input' in the title for it to be automatically selected, or select one below." + ), + $el( + "section", + this.#options.map((opt) => { + return $el("div.comfy-widget-selection-item", [ + $el("span", { dataset: { id: opt.node.id } }, `${opt.node.title ?? opt.node.type} ${opt.widget.name}`), + $el( + "button.comfyui-button", + { + onclick: () => { + app.canvas.ds.offset[0] = -opt.node.pos[0] + 50; + app.canvas.ds.offset[1] = -opt.node.pos[1] + 50; + app.canvas.selectNode(opt.node); + app.graph.setDirtyCanvas(true, true); + }, + }, + "Show" + ), + $el( + "button.comfyui-button.primary", + { + onclick: () => { + this.close(opt); + }, + }, + "Select" + ), + ]); + }) + ), + ]) + ); + } } \ No newline at end of file diff --git a/src/scripts/ui/settings.ts b/src/scripts/ui/settings.ts index de87a9567..03687d872 100644 --- a/src/scripts/ui/settings.ts +++ b/src/scripts/ui/settings.ts @@ -5,359 +5,359 @@ import type { ComfyApp } from "../app"; /* The Setting entry stored in `ComfySettingsDialog` */ interface Setting { - id: string; - onChange?: (value: any, oldValue?: any) => void; - name: string; - render: () => HTMLElement; + id: string; + onChange?: (value: any, oldValue?: any) => void; + name: string; + render: () => HTMLElement; } interface SettingOption { - text: string; - value?: string; + text: string; + value?: string; } interface SettingParams { - id: string; - name: string; - type: string | ((name: string, setter: (v: any) => void, value: any, attrs: any) => HTMLElement); - defaultValue: any; - onChange?: (newValue: any, oldValue?: any) => void; - attrs?: any; - tooltip?: string; - options?: SettingOption[] | ((value: any) => SettingOption[]); + id: string; + name: string; + type: string | ((name: string, setter: (v: any) => void, value: any, attrs: any) => HTMLElement); + defaultValue: any; + onChange?: (newValue: any, oldValue?: any) => void; + attrs?: any; + tooltip?: string; + options?: SettingOption[] | ((value: any) => SettingOption[]); } export class ComfySettingsDialog extends ComfyDialog { - app: ComfyApp; - settingsValues: any; - settingsLookup: Record; + app: ComfyApp; + settingsValues: any; + settingsLookup: Record; - constructor(app) { - super(); - this.app = app; - this.settingsValues = {}; - this.settingsLookup = {}; - this.element = $el( - "dialog", - { - id: "comfy-settings-dialog", - parent: document.body, - }, - [ - $el("table.comfy-modal-content.comfy-table", [ - $el( - "caption", - { textContent: "Settings" }, - $el("button.comfy-btn", { - type: "button", - textContent: "\u00d7", - onclick: () => { - this.element.close(); - }, - }) - ), - $el("tbody", { $: (tbody) => (this.textElement = tbody) }), - $el("button", { - type: "button", - textContent: "Close", - style: { - cursor: "pointer", - }, - onclick: () => { - this.element.close(); - }, - }), - ]), - ] - ) as HTMLDialogElement; - } + constructor(app) { + super(); + this.app = app; + this.settingsValues = {}; + this.settingsLookup = {}; + this.element = $el( + "dialog", + { + id: "comfy-settings-dialog", + parent: document.body, + }, + [ + $el("table.comfy-modal-content.comfy-table", [ + $el( + "caption", + { textContent: "Settings" }, + $el("button.comfy-btn", { + type: "button", + textContent: "\u00d7", + onclick: () => { + this.element.close(); + }, + }) + ), + $el("tbody", { $: (tbody) => (this.textElement = tbody) }), + $el("button", { + type: "button", + textContent: "Close", + style: { + cursor: "pointer", + }, + onclick: () => { + this.element.close(); + }, + }), + ]), + ] + ) as HTMLDialogElement; + } - get settings() { - return Object.values(this.settingsLookup); - } + get settings() { + return Object.values(this.settingsLookup); + } - #dispatchChange(id, value, oldValue?) { - this.dispatchEvent( - new CustomEvent(id + ".change", { - detail: { - value, - oldValue - }, - }) - ); - } + #dispatchChange(id, value, oldValue?) { + this.dispatchEvent( + new CustomEvent(id + ".change", { + detail: { + value, + oldValue + }, + }) + ); + } - async load() { - if (this.app.storageLocation === "browser") { - this.settingsValues = localStorage; - } else { - this.settingsValues = await api.getSettings(); - } + async load() { + if (this.app.storageLocation === "browser") { + this.settingsValues = localStorage; + } else { + this.settingsValues = await api.getSettings(); + } - // Trigger onChange for any settings added before load - for (const id in this.settingsLookup) { - const value = this.settingsValues[this.getId(id)]; - this.settingsLookup[id].onChange?.(value); - this.#dispatchChange(id, value); - } - } + // Trigger onChange for any settings added before load + for (const id in this.settingsLookup) { + const value = this.settingsValues[this.getId(id)]; + this.settingsLookup[id].onChange?.(value); + this.#dispatchChange(id, value); + } + } - getId(id) { - if (this.app.storageLocation === "browser") { - id = "Comfy.Settings." + id; - } - return id; - } + getId(id) { + if (this.app.storageLocation === "browser") { + id = "Comfy.Settings." + id; + } + return id; + } - getSettingValue(id, defaultValue?) { - let value = this.settingsValues[this.getId(id)]; - if (value != null) { - if (this.app.storageLocation === "browser") { - try { - value = JSON.parse(value); - } catch (error) { - } - } - } - return value ?? defaultValue; - } + getSettingValue(id, defaultValue?) { + let value = this.settingsValues[this.getId(id)]; + if (value != null) { + if (this.app.storageLocation === "browser") { + try { + value = JSON.parse(value); + } catch (error) { + } + } + } + return value ?? defaultValue; + } - async setSettingValueAsync(id, value) { - const json = JSON.stringify(value); - localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage + async setSettingValueAsync(id, value) { + const json = JSON.stringify(value); + localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage - let oldValue = this.getSettingValue(id, undefined); - this.settingsValues[this.getId(id)] = value; + let oldValue = this.getSettingValue(id, undefined); + this.settingsValues[this.getId(id)] = value; - if (id in this.settingsLookup) { - this.settingsLookup[id].onChange?.(value, oldValue); - } - this.#dispatchChange(id, value, oldValue); + if (id in this.settingsLookup) { + this.settingsLookup[id].onChange?.(value, oldValue); + } + this.#dispatchChange(id, value, oldValue); - await api.storeSetting(id, value); - } + await api.storeSetting(id, value); + } - setSettingValue(id, value) { - this.setSettingValueAsync(id, value).catch((err) => { - alert(`Error saving setting '${id}'`); - console.error(err); - }); - } + setSettingValue(id, value) { + this.setSettingValueAsync(id, value).catch((err) => { + alert(`Error saving setting '${id}'`); + console.error(err); + }); + } - addSetting(params: SettingParams) { - const { id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined } = params; - if (!id) { - throw new Error("Settings must have an ID"); - } + addSetting(params: SettingParams) { + const { id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined } = params; + if (!id) { + throw new Error("Settings must have an ID"); + } - if (id in this.settingsLookup) { - throw new Error(`Setting ${id} of type ${type} must have a unique ID.`); - } + if (id in this.settingsLookup) { + throw new Error(`Setting ${id} of type ${type} must have a unique ID.`); + } - let skipOnChange = false; - let value = this.getSettingValue(id); - if (value == null) { - if (this.app.isNewUserSession) { - // Check if we have a localStorage value but not a setting value and we are a new user - const localValue = localStorage["Comfy.Settings." + id]; - if (localValue) { - value = JSON.parse(localValue); - this.setSettingValue(id, value); // Store on the server - } - } - if (value == null) { - value = defaultValue; - } - } + let skipOnChange = false; + let value = this.getSettingValue(id); + if (value == null) { + if (this.app.isNewUserSession) { + // Check if we have a localStorage value but not a setting value and we are a new user + const localValue = localStorage["Comfy.Settings." + id]; + if (localValue) { + value = JSON.parse(localValue); + this.setSettingValue(id, value); // Store on the server + } + } + if (value == null) { + value = defaultValue; + } + } - // Trigger initial setting of value - if (!skipOnChange) { - onChange?.(value, undefined); - } + // Trigger initial setting of value + if (!skipOnChange) { + onChange?.(value, undefined); + } - this.settingsLookup[id] = { - id, - onChange, - name, - render: () => { - if (type === "hidden") return; + this.settingsLookup[id] = { + id, + onChange, + name, + render: () => { + if (type === "hidden") return; - const setter = (v) => { - if (onChange) { - onChange(v, value); - } + const setter = (v) => { + if (onChange) { + onChange(v, value); + } - this.setSettingValue(id, v); - value = v; - }; - value = this.getSettingValue(id, defaultValue); + this.setSettingValue(id, v); + value = v; + }; + value = this.getSettingValue(id, defaultValue); - let element; - const htmlID = id.replaceAll(".", "-"); + let element; + const htmlID = id.replaceAll(".", "-"); - const labelCell = $el("td", [ - $el("label", { - for: htmlID, - classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""], - textContent: name, - }), - ]); + const labelCell = $el("td", [ + $el("label", { + for: htmlID, + classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""], + textContent: name, + }), + ]); - if (typeof type === "function") { - element = type(name, setter, value, attrs); - } else { - switch (type) { - case "boolean": - element = $el("tr", [ - labelCell, - $el("td", [ - $el("input", { - id: htmlID, - type: "checkbox", - checked: value, - onchange: (event) => { - const isChecked = event.target.checked; - if (onChange !== undefined) { - onChange(isChecked); - } - this.setSettingValue(id, isChecked); - }, - }), - ]), - ]); - break; - case "number": - element = $el("tr", [ - labelCell, - $el("td", [ - $el("input", { - type, - value, - id: htmlID, - oninput: (e) => { - setter(e.target.value); - }, - ...attrs, - }), - ]), - ]); - break; - case "slider": - element = $el("tr", [ - labelCell, - $el("td", [ - $el( - "div", - { - style: { - display: "grid", - gridAutoFlow: "column", - }, - }, - [ - $el("input", { - ...attrs, - value, - type: "range", - oninput: (e) => { - setter(e.target.value); - e.target.nextElementSibling.value = e.target.value; - }, - }), - $el("input", { - ...attrs, - value, - id: htmlID, - type: "number", - style: { maxWidth: "4rem" }, - oninput: (e) => { - setter(e.target.value); - e.target.previousElementSibling.value = e.target.value; - }, - }), - ] - ), - ]), - ]); - break; - case "combo": - element = $el("tr", [ - labelCell, - $el("td", [ - $el( - "select", - { - oninput: (e) => { - setter(e.target.value); - }, - }, - (typeof options === "function" ? options(value) : options || []).map((opt) => { - if (typeof opt === "string") { - opt = { text: opt }; - } - const v = opt.value ?? opt.text; - return $el("option", { - value: v, - textContent: opt.text, - selected: value + "" === v + "", - }); - }) - ), - ]), - ]); - break; - case "text": - default: - if (type !== "text") { - console.warn(`Unsupported setting type '${type}, defaulting to text`); - } + if (typeof type === "function") { + element = type(name, setter, value, attrs); + } else { + switch (type) { + case "boolean": + element = $el("tr", [ + labelCell, + $el("td", [ + $el("input", { + id: htmlID, + type: "checkbox", + checked: value, + onchange: (event) => { + const isChecked = event.target.checked; + if (onChange !== undefined) { + onChange(isChecked); + } + this.setSettingValue(id, isChecked); + }, + }), + ]), + ]); + break; + case "number": + element = $el("tr", [ + labelCell, + $el("td", [ + $el("input", { + type, + value, + id: htmlID, + oninput: (e) => { + setter(e.target.value); + }, + ...attrs, + }), + ]), + ]); + break; + case "slider": + element = $el("tr", [ + labelCell, + $el("td", [ + $el( + "div", + { + style: { + display: "grid", + gridAutoFlow: "column", + }, + }, + [ + $el("input", { + ...attrs, + value, + type: "range", + oninput: (e) => { + setter(e.target.value); + e.target.nextElementSibling.value = e.target.value; + }, + }), + $el("input", { + ...attrs, + value, + id: htmlID, + type: "number", + style: { maxWidth: "4rem" }, + oninput: (e) => { + setter(e.target.value); + e.target.previousElementSibling.value = e.target.value; + }, + }), + ] + ), + ]), + ]); + break; + case "combo": + element = $el("tr", [ + labelCell, + $el("td", [ + $el( + "select", + { + oninput: (e) => { + setter(e.target.value); + }, + }, + (typeof options === "function" ? options(value) : options || []).map((opt) => { + if (typeof opt === "string") { + opt = { text: opt }; + } + const v = opt.value ?? opt.text; + return $el("option", { + value: v, + textContent: opt.text, + selected: value + "" === v + "", + }); + }) + ), + ]), + ]); + break; + case "text": + default: + if (type !== "text") { + console.warn(`Unsupported setting type '${type}, defaulting to text`); + } - element = $el("tr", [ - labelCell, - $el("td", [ - $el("input", { - value, - id: htmlID, - oninput: (e) => { - setter(e.target.value); - }, - ...attrs, - }), - ]), - ]); - break; - } - } - if (tooltip) { - element.title = tooltip; - } + element = $el("tr", [ + labelCell, + $el("td", [ + $el("input", { + value, + id: htmlID, + oninput: (e) => { + setter(e.target.value); + }, + ...attrs, + }), + ]), + ]); + break; + } + } + if (tooltip) { + element.title = tooltip; + } - return element; - }, - } as Setting; + return element; + }, + } as Setting; - const self = this; - return { - get value() { - return self.getSettingValue(id, defaultValue); - }, - set value(v) { - self.setSettingValue(id, v); - }, - }; - } + const self = this; + return { + get value() { + return self.getSettingValue(id, defaultValue); + }, + set value(v) { + self.setSettingValue(id, v); + }, + }; + } - show() { - this.textElement.replaceChildren( - $el( - "tr", - { - style: { display: "none" }, - }, - [$el("th"), $el("th", { style: { width: "33%" } })] - ), - ...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()).filter(Boolean) - ); - this.element.showModal(); - } + show() { + this.textElement.replaceChildren( + $el( + "tr", + { + style: { display: "none" }, + }, + [$el("th"), $el("th", { style: { width: "33%" } })] + ), + ...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()).filter(Boolean) + ); + this.element.showModal(); + } } diff --git a/src/scripts/ui/spinner.css b/src/scripts/ui/spinner.css index 56da6072e..5d20f8e23 100644 --- a/src/scripts/ui/spinner.css +++ b/src/scripts/ui/spinner.css @@ -1,34 +1,34 @@ .lds-ring { - display: inline-block; - position: relative; - width: 1em; - height: 1em; + display: inline-block; + position: relative; + width: 1em; + height: 1em; } .lds-ring div { - box-sizing: border-box; - display: block; - position: absolute; - width: 100%; - height: 100%; - border: 0.15em solid #fff; - border-radius: 50%; - animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; - border-color: #fff transparent transparent transparent; + box-sizing: border-box; + display: block; + position: absolute; + width: 100%; + height: 100%; + border: 0.15em solid #fff; + border-radius: 50%; + animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + border-color: #fff transparent transparent transparent; } .lds-ring div:nth-child(1) { - animation-delay: -0.45s; + animation-delay: -0.45s; } .lds-ring div:nth-child(2) { - animation-delay: -0.3s; + animation-delay: -0.3s; } .lds-ring div:nth-child(3) { - animation-delay: -0.15s; + animation-delay: -0.15s; } @keyframes lds-ring { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } diff --git a/src/scripts/ui/spinner.ts b/src/scripts/ui/spinner.ts index 57c77bba2..91fdaf9c0 100644 --- a/src/scripts/ui/spinner.ts +++ b/src/scripts/ui/spinner.ts @@ -2,7 +2,7 @@ import "./spinner.css"; export function createSpinner() { - const div = document.createElement("div"); - div.innerHTML = `
`; - return div.firstElementChild; + const div = document.createElement("div"); + div.innerHTML = `
`; + return div.firstElementChild; } diff --git a/src/scripts/ui/toggleSwitch.ts b/src/scripts/ui/toggleSwitch.ts index 670beda68..689528705 100644 --- a/src/scripts/ui/toggleSwitch.ts +++ b/src/scripts/ui/toggleSwitch.ts @@ -11,52 +11,52 @@ import { $el } from "../ui"; * @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange] */ export function toggleSwitch(name, items, e?) { - const onChange = e?.onChange; + const onChange = e?.onChange; - let selectedIndex; - let elements; + let selectedIndex; + let elements; - function updateSelected(index) { - if (selectedIndex != null) { - elements[selectedIndex].classList.remove("comfy-toggle-selected"); - } - onChange?.({ item: items[index], prev: selectedIndex == null ? undefined : items[selectedIndex] }); - selectedIndex = index; - elements[selectedIndex].classList.add("comfy-toggle-selected"); - } + function updateSelected(index) { + if (selectedIndex != null) { + elements[selectedIndex].classList.remove("comfy-toggle-selected"); + } + onChange?.({ item: items[index], prev: selectedIndex == null ? undefined : items[selectedIndex] }); + selectedIndex = index; + elements[selectedIndex].classList.add("comfy-toggle-selected"); + } - elements = items.map((item, i) => { - if (typeof item === "string") item = { text: item }; - if (!item.value) item.value = item.text; + elements = items.map((item, i) => { + if (typeof item === "string") item = { text: item }; + if (!item.value) item.value = item.text; - const toggle = $el( - "label", - { - textContent: item.text, - title: item.tooltip ?? "", - }, - $el("input", { - name, - type: "radio", - value: item.value ?? item.text, - checked: item.selected, - onchange: () => { - updateSelected(i); - }, - }) - ); - if (item.selected) { - updateSelected(i); - } - return toggle; - }); + const toggle = $el( + "label", + { + textContent: item.text, + title: item.tooltip ?? "", + }, + $el("input", { + name, + type: "radio", + value: item.value ?? item.text, + checked: item.selected, + onchange: () => { + updateSelected(i); + }, + }) + ); + if (item.selected) { + updateSelected(i); + } + return toggle; + }); - const container = $el("div.comfy-toggle-switch", elements); + const container = $el("div.comfy-toggle-switch", elements); - if (selectedIndex == null) { - elements[0].children[0].checked = true; - updateSelected(0); - } + if (selectedIndex == null) { + elements[0].children[0].checked = true; + updateSelected(0); + } - return container; + return container; } diff --git a/src/scripts/ui/userSelection.ts b/src/scripts/ui/userSelection.ts index f87f61a15..1d997c7ff 100644 --- a/src/scripts/ui/userSelection.ts +++ b/src/scripts/ui/userSelection.ts @@ -5,122 +5,122 @@ import "./userSelection.css"; interface SelectedUser { - username: string; - userId: string; - created: boolean; + username: string; + userId: string; + created: boolean; } export class UserSelectionScreen { - async show(users, user): Promise{ - const userSelection = document.getElementById("comfy-user-selection"); - userSelection.style.display = ""; - return new Promise((resolve) => { - const input = userSelection.getElementsByTagName("input")[0]; - const select = userSelection.getElementsByTagName("select")[0]; - const inputSection = input.closest("section"); - const selectSection = select.closest("section"); - const form = userSelection.getElementsByTagName("form")[0]; - const error = userSelection.getElementsByClassName("comfy-user-error")[0]; - const button = userSelection.getElementsByClassName("comfy-user-button-next")[0]; + async show(users, user): Promise{ + const userSelection = document.getElementById("comfy-user-selection"); + userSelection.style.display = ""; + return new Promise((resolve) => { + const input = userSelection.getElementsByTagName("input")[0]; + const select = userSelection.getElementsByTagName("select")[0]; + const inputSection = input.closest("section"); + const selectSection = select.closest("section"); + const form = userSelection.getElementsByTagName("form")[0]; + const error = userSelection.getElementsByClassName("comfy-user-error")[0]; + const button = userSelection.getElementsByClassName("comfy-user-button-next")[0]; - let inputActive = null; - input.addEventListener("focus", () => { - inputSection.classList.add("selected"); - selectSection.classList.remove("selected"); - inputActive = true; - }); - select.addEventListener("focus", () => { - inputSection.classList.remove("selected"); - selectSection.classList.add("selected"); - inputActive = false; - select.style.color = ""; - }); - select.addEventListener("blur", () => { - if (!select.value) { - select.style.color = "var(--descrip-text)"; - } - }); + let inputActive = null; + input.addEventListener("focus", () => { + inputSection.classList.add("selected"); + selectSection.classList.remove("selected"); + inputActive = true; + }); + select.addEventListener("focus", () => { + inputSection.classList.remove("selected"); + selectSection.classList.add("selected"); + inputActive = false; + select.style.color = ""; + }); + select.addEventListener("blur", () => { + if (!select.value) { + select.style.color = "var(--descrip-text)"; + } + }); - form.addEventListener("submit", async (e) => { - e.preventDefault(); - if (inputActive == null) { - error.textContent = "Please enter a username or select an existing user."; - } else if (inputActive) { - const username = input.value.trim(); - if (!username) { - error.textContent = "Please enter a username."; - return; - } + form.addEventListener("submit", async (e) => { + e.preventDefault(); + if (inputActive == null) { + error.textContent = "Please enter a username or select an existing user."; + } else if (inputActive) { + const username = input.value.trim(); + if (!username) { + error.textContent = "Please enter a username."; + return; + } - // Create new user - // @ts-ignore - // Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339) - // Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551) - input.disabled = select.disabled = input.readonly = select.readonly = true; - const spinner = createSpinner(); - button.prepend(spinner); - try { - const resp = await api.createUser(username); - if (resp.status >= 300) { - let message = "Error creating user: " + resp.status + " " + resp.statusText; - try { - const res = await resp.json(); - if(res.error) { - message = res.error; - } - } catch (error) { - } - throw new Error(message); - } + // Create new user + // @ts-ignore + // Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339) + // Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551) + input.disabled = select.disabled = input.readonly = select.readonly = true; + const spinner = createSpinner(); + button.prepend(spinner); + try { + const resp = await api.createUser(username); + if (resp.status >= 300) { + let message = "Error creating user: " + resp.status + " " + resp.statusText; + try { + const res = await resp.json(); + if(res.error) { + message = res.error; + } + } catch (error) { + } + throw new Error(message); + } - resolve({ username, userId: await resp.json(), created: true }); - } catch (err) { - spinner.remove(); - error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred."; - // @ts-ignore - // Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339) - // Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551) - input.disabled = select.disabled = input.readonly = select.readonly = false; - return; - } - } else if (!select.value) { - error.textContent = "Please select an existing user."; - return; - } else { - resolve({ username: users[select.value], userId: select.value, created: false }); - } - }); + resolve({ username, userId: await resp.json(), created: true }); + } catch (err) { + spinner.remove(); + error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred."; + // @ts-ignore + // Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339) + // Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551) + input.disabled = select.disabled = input.readonly = select.readonly = false; + return; + } + } else if (!select.value) { + error.textContent = "Please select an existing user."; + return; + } else { + resolve({ username: users[select.value], userId: select.value, created: false }); + } + }); - if (user) { - const name = localStorage["Comfy.userName"]; - if (name) { - input.value = name; - } - } - if (input.value) { - // Focus the input, do this separately as sometimes browsers like to fill in the value - input.focus(); - } + if (user) { + const name = localStorage["Comfy.userName"]; + if (name) { + input.value = name; + } + } + if (input.value) { + // Focus the input, do this separately as sometimes browsers like to fill in the value + input.focus(); + } - const userIds = Object.keys(users ?? {}); - if (userIds.length) { - for (const u of userIds) { - $el("option", { textContent: users[u], value: u, parent: select }); - } - select.style.color = "var(--descrip-text)"; + const userIds = Object.keys(users ?? {}); + if (userIds.length) { + for (const u of userIds) { + $el("option", { textContent: users[u], value: u, parent: select }); + } + select.style.color = "var(--descrip-text)"; - if (select.value) { - // Focus the select, do this separately as sometimes browsers like to fill in the value - select.focus(); - } - } else { - userSelection.classList.add("no-users"); - input.focus(); - } - }).then((r: SelectedUser) => { - userSelection.remove(); - return r; - }); - } + if (select.value) { + // Focus the select, do this separately as sometimes browsers like to fill in the value + select.focus(); + } + } else { + userSelection.classList.add("no-users"); + input.focus(); + } + }).then((r: SelectedUser) => { + userSelection.remove(); + return r; + }); + } } diff --git a/src/scripts/ui/utils.js b/src/scripts/ui/utils.js index e37d8b41e..1dc0609ef 100644 --- a/src/scripts/ui/utils.js +++ b/src/scripts/ui/utils.js @@ -8,25 +8,25 @@ * @param { string[] } requiredClasses */ export function applyClasses(element, classList, ...requiredClasses) { - classList ??= ""; + classList ??= ""; - let str; - if (typeof classList === "string") { - str = classList; - } else if (classList instanceof Array) { - str = classList.join(" "); - } else { - str = Object.entries(classList).reduce((p, c) => { - if (c[1]) { - p += (p.length ? " " : "") + c[0]; - } - return p; - }, ""); - } - element.className = str; - if (requiredClasses) { - element.classList.add(...requiredClasses); - } + let str; + if (typeof classList === "string") { + str = classList; + } else if (classList instanceof Array) { + str = classList.join(" "); + } else { + str = Object.entries(classList).reduce((p, c) => { + if (c[1]) { + p += (p.length ? " " : "") + c[0]; + } + return p; + }, ""); + } + element.className = str; + if (requiredClasses) { + element.classList.add(...requiredClasses); + } } /** @@ -35,22 +35,22 @@ export function applyClasses(element, classList, ...requiredClasses) { * @returns */ export function toggleElement(element, { onHide, onShow } = {}) { - let placeholder; - let hidden; - return (value) => { - if (value) { - if (hidden) { - hidden = false; - placeholder.replaceWith(element); - } - onShow?.(element, value); - } else { - if (!placeholder) { - placeholder = document.createComment(""); - } - hidden = true; - element.replaceWith(placeholder); - onHide?.(element); - } - }; + let placeholder; + let hidden; + return (value) => { + if (value) { + if (hidden) { + hidden = false; + placeholder.replaceWith(element); + } + onShow?.(element, value); + } else { + if (!placeholder) { + placeholder = document.createComment(""); + } + hidden = true; + element.replaceWith(placeholder); + onHide?.(element); + } + }; } diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 03f39fb94..d602f4991 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -4,103 +4,103 @@ import { $el } from "./ui"; // Simple date formatter const parts = { - d: (d) => d.getDate(), - M: (d) => d.getMonth() + 1, - h: (d) => d.getHours(), - m: (d) => d.getMinutes(), - s: (d) => d.getSeconds(), + d: (d) => d.getDate(), + M: (d) => d.getMonth() + 1, + h: (d) => d.getHours(), + m: (d) => d.getMinutes(), + s: (d) => d.getSeconds(), }; const format = - Object.keys(parts) - .map((k) => k + k + "?") - .join("|") + "|yyy?y?"; + Object.keys(parts) + .map((k) => k + k + "?") + .join("|") + "|yyy?y?"; function formatDate(text: string, date: Date) { - return text.replace(new RegExp(format, "g"), (text: string): string => { - if (text === "yy") return (date.getFullYear() + "").substring(2); - if (text === "yyyy") return date.getFullYear().toString(); - if (text[0] in parts) { - const p = parts[text[0]](date); - return (p + "").padStart(text.length, "0"); - } - return text; - }); + return text.replace(new RegExp(format, "g"), (text: string): string => { + if (text === "yy") return (date.getFullYear() + "").substring(2); + if (text === "yyyy") return date.getFullYear().toString(); + if (text[0] in parts) { + const p = parts[text[0]](date); + return (p + "").padStart(text.length, "0"); + } + return text; + }); } export 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 - } + 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)); + return JSON.parse(JSON.stringify(obj)); } export function applyTextReplacements(app: ComfyApp, value: string): string { - return value.replace(/%([^%]+)%/g, function (match, text) { - const split = text.split("."); - if (split.length !== 2) { - // Special handling for dates - if (split[0].startsWith("date:")) { - return formatDate(split[0].substring(5), new Date()); - } + return value.replace(/%([^%]+)%/g, function (match, text) { + const split = text.split("."); + if (split.length !== 2) { + // Special handling for dates + if (split[0].startsWith("date:")) { + return formatDate(split[0].substring(5), new Date()); + } - if (text !== "width" && text !== "height") { - // Dont warn on standard replacements - console.warn("Invalid replacement pattern", text); - } - return match; - } + if (text !== "width" && text !== "height") { + // Dont warn on standard replacements + console.warn("Invalid replacement pattern", text); + } + return match; + } - // Find node with matching S&R property name - // @ts-ignore - let nodes = app.graph._nodes.filter((n) => n.properties?.["Node name for S&R"] === split[0]); - // If we cant, see if there is a node with that title - if (!nodes.length) { - // @ts-ignore - nodes = app.graph._nodes.filter((n) => n.title === split[0]); - } - if (!nodes.length) { - console.warn("Unable to find node", split[0]); - return match; - } + // Find node with matching S&R property name + // @ts-ignore + let nodes = app.graph._nodes.filter((n) => n.properties?.["Node name for S&R"] === split[0]); + // If we cant, see if there is a node with that title + if (!nodes.length) { + // @ts-ignore + nodes = app.graph._nodes.filter((n) => n.title === split[0]); + } + if (!nodes.length) { + console.warn("Unable to find node", split[0]); + return match; + } - if (nodes.length > 1) { - console.warn("Multiple nodes matched", split[0], "using first match"); - } + if (nodes.length > 1) { + console.warn("Multiple nodes matched", split[0], "using first match"); + } - const node = nodes[0]; + const node = nodes[0]; - const widget = node.widgets?.find((w) => w.name === split[1]); - if (!widget) { - console.warn("Unable to find widget", split[1], "on node", split[0], node); - return match; - } + const widget = node.widgets?.find((w) => w.name === split[1]); + if (!widget) { + console.warn("Unable to find widget", split[1], "on node", split[0], node); + return match; + } - return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_"); - }); + return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_"); + }); } export async function addStylesheet(urlOrFile: string, relativeTo?: string): Promise { - return new Promise((res, rej) => { - let url; - if (urlOrFile.endsWith(".js")) { - url = urlOrFile.substr(0, urlOrFile.length - 2) + "css"; - } else { - url = new URL(urlOrFile, relativeTo ?? `${window.location.protocol}//${window.location.host}`).toString(); - } - $el("link", { - parent: document.head, - rel: "stylesheet", - type: "text/css", - href: url, - onload: res, - onerror: rej, - }); - }); + return new Promise((res, rej) => { + let url; + if (urlOrFile.endsWith(".js")) { + url = urlOrFile.substr(0, urlOrFile.length - 2) + "css"; + } else { + url = new URL(urlOrFile, relativeTo ?? `${window.location.protocol}//${window.location.host}`).toString(); + } + $el("link", { + parent: document.head, + rel: "stylesheet", + type: "text/css", + href: url, + onload: res, + onerror: rej, + }); + }); } @@ -109,18 +109,18 @@ export async function addStylesheet(urlOrFile: string, relativeTo?: string): Pro * @param { Blob } blob */ export function downloadBlob(filename, blob) { - const url = URL.createObjectURL(blob); - const a = $el("a", { - href: url, - download: filename, - style: { display: "none" }, - parent: document.body, - }); - a.click(); - setTimeout(function () { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); + const url = URL.createObjectURL(blob); + const a = $el("a", { + href: url, + download: filename, + style: { display: "none" }, + parent: document.body, + }); + a.click(); + setTimeout(function () { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); } /** @@ -131,29 +131,29 @@ export function downloadBlob(filename, blob) { * @returns {T} */ export function prop(target, name, defaultValue, onChanged) { - let currentValue; - Object.defineProperty(target, name, { - get() { - return currentValue; - }, - set(newValue) { - const prevValue = currentValue; - currentValue = newValue; - onChanged?.(currentValue, prevValue, target, name); - }, - }); - return defaultValue; + let currentValue; + Object.defineProperty(target, name, { + get() { + return currentValue; + }, + set(newValue) { + const prevValue = currentValue; + currentValue = newValue; + onChanged?.(currentValue, prevValue, target, name); + }, + }); + return defaultValue; } export function getStorageValue(id) { - const clientId = api.clientId ?? api.initialClientId; - return (clientId && sessionStorage.getItem(`${id}:${clientId}`)) ?? localStorage.getItem(id); + const clientId = api.clientId ?? api.initialClientId; + return (clientId && sessionStorage.getItem(`${id}:${clientId}`)) ?? localStorage.getItem(id); } export function setStorageValue(id, value) { - const clientId = api.clientId ?? api.initialClientId; - if (clientId) { - sessionStorage.setItem(`${id}:${clientId}`, value); - } - localStorage.setItem(id, value); + const clientId = api.clientId ?? api.initialClientId; + if (clientId) { + sessionStorage.setItem(`${id}:${clientId}`, value); + } + localStorage.setItem(id, value); } \ No newline at end of file diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index cb8210dfa..a92f94181 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -5,546 +5,546 @@ import type { IWidget, LGraphNode } from "/types/litegraph"; import { ComfyNodeDef } from "/types/apiTypes"; export type ComfyWidgetConstructor = ( - node: LGraphNode, inputName: string, inputData: ComfyNodeDef, app?: ComfyApp, widgetName?: string) => - {widget: IWidget, minWidth?: number; minHeight?: number }; + node: LGraphNode, inputName: string, inputData: ComfyNodeDef, app?: ComfyApp, widgetName?: string) => + {widget: IWidget, minWidth?: number; minHeight?: number }; let controlValueRunBefore = false; export function updateControlWidgetLabel(widget) { - let replacement = "after"; - let find = "before"; - if (controlValueRunBefore) { - [find, replacement] = [replacement, find] - } - widget.label = (widget.label ?? widget.name).replace(find, replacement); + let replacement = "after"; + let find = "before"; + if (controlValueRunBefore) { + [find, replacement] = [replacement, find] + } + widget.label = (widget.label ?? widget.name).replace(find, replacement); } const IS_CONTROL_WIDGET = Symbol(); const HAS_EXECUTED = Symbol(); function getNumberDefaults(inputData: ComfyNodeDef, defaultStep, precision, enable_rounding) { - let defaultVal = inputData[1]["default"]; - let { min, max, step, round} = inputData[1]; + let defaultVal = inputData[1]["default"]; + let { min, max, step, round} = inputData[1]; - if (defaultVal == undefined) defaultVal = 0; - if (min == undefined) min = 0; - if (max == undefined) max = 2048; - if (step == undefined) step = defaultStep; - // precision is the number of decimal places to show. - // by default, display the the smallest number of decimal places such that changes of size step are visible. - if (precision == undefined) { - precision = Math.max(-Math.floor(Math.log10(step)),0); - } + if (defaultVal == undefined) defaultVal = 0; + if (min == undefined) min = 0; + if (max == undefined) max = 2048; + if (step == undefined) step = defaultStep; + // precision is the number of decimal places to show. + // by default, display the the smallest number of decimal places such that changes of size step are visible. + if (precision == undefined) { + precision = Math.max(-Math.floor(Math.log10(step)),0); + } - if (enable_rounding && (round == undefined || round === true)) { - // by default, round the value to those decimal places shown. - round = Math.round(1000000*Math.pow(0.1,precision))/1000000; - } + if (enable_rounding && (round == undefined || round === true)) { + // by default, round the value to those decimal places shown. + round = Math.round(1000000*Math.pow(0.1,precision))/1000000; + } - 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, widgetName, inputData: ComfyNodeDef) { - let name = inputData[1]?.control_after_generate; - if(typeof name !== "string") { - name = widgetName; - } - const widgets = addValueControlWidgets(node, targetWidget, defaultValue, { - addFilterList: false, - controlAfterGenerateName: name - }, inputData); - return widgets[0]; + let name = inputData[1]?.control_after_generate; + if(typeof name !== "string") { + name = widgetName; + } + const widgets = addValueControlWidgets(node, targetWidget, defaultValue, { + addFilterList: false, + controlAfterGenerateName: name + }, inputData); + return widgets[0]; } export function addValueControlWidgets(node, targetWidget, defaultValue = "randomize", options, inputData: ComfyNodeDef) { - if (!defaultValue) defaultValue = "randomize"; - if (!options) options = {}; + 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 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. - } - ); - valueControl[IS_CONTROL_WIDGET] = true; - updateControlWidgetLabel(valueControl); - widgets.push(valueControl); + 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. + } + ); + valueControl[IS_CONTROL_WIDGET] = true; + updateControlWidgetLabel(valueControl); + widgets.push(valueControl); - const isCombo = targetWidget.type === "combo"; - let comboFilter; - if (isCombo) { - valueControl.options.values.push("increment-wrap"); - } - if (isCombo && options.addFilterList !== false) { - comboFilter = node.addWidget( - "string", - getName("control_filter_list", "controlFilterListName"), - "", - function () {}, - { - serialize: false, // Don't include this in prompt. - } - ); - updateControlWidgetLabel(comboFilter); + const isCombo = targetWidget.type === "combo"; + let comboFilter; + if (isCombo) { + valueControl.options.values.push("increment-wrap"); + } + if (isCombo && options.addFilterList !== false) { + comboFilter = node.addWidget( + "string", + getName("control_filter_list", "controlFilterListName"), + "", + function () {}, + { + serialize: false, // Don't include this in prompt. + } + ); + updateControlWidgetLabel(comboFilter); - widgets.push(comboFilter); - } + widgets.push(comboFilter); + } - const applyWidgetControl = () => { - var v = valueControl.value; + const applyWidgetControl = () => { + var v = valueControl.value; - if (isCombo && v !== "fixed") { - let values = targetWidget.options.values; - 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; + if (isCombo && v !== "fixed") { + let values = targetWidget.options.values; + 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) { - case "increment": - current_index += 1; - break; - case "increment-wrap": - current_index += 1; - if ( current_index >= current_length ) { - current_index = 0; - } - break; - case "decrement": - current_index -= 1; - break; - case "randomize": - current_index = Math.floor(Math.random() * current_length); - break; - default: - break; - } - current_index = Math.max(0, current_index); - current_index = Math.min(current_length - 1, current_index); - if (current_index >= 0) { - let value = values[current_index]; - targetWidget.value = value; - targetWidget.callback(value); - } - } else { - //number - let min = targetWidget.options.min; - let max = targetWidget.options.max; - // limit to something that javascript can handle - max = Math.min(1125899906842624, max); - min = Math.max(-1125899906842624, min); - let range = (max - min) / (targetWidget.options.step / 10); + switch (v) { + case "increment": + current_index += 1; + break; + case "increment-wrap": + current_index += 1; + if ( current_index >= current_length ) { + current_index = 0; + } + break; + case "decrement": + current_index -= 1; + break; + case "randomize": + current_index = Math.floor(Math.random() * current_length); + break; + default: + break; + } + current_index = Math.max(0, current_index); + current_index = Math.min(current_length - 1, current_index); + if (current_index >= 0) { + let value = values[current_index]; + targetWidget.value = value; + targetWidget.callback(value); + } + } else { + //number + let min = targetWidget.options.min; + let max = targetWidget.options.max; + // limit to something that javascript can handle + max = Math.min(1125899906842624, max); + min = Math.max(-1125899906842624, min); + let range = (max - min) / (targetWidget.options.step / 10); - //adjust values based on valueControl Behaviour - switch (v) { - case "fixed": - break; - case "increment": - targetWidget.value += targetWidget.options.step / 10; - break; - case "decrement": - targetWidget.value -= targetWidget.options.step / 10; - break; - case "randomize": - targetWidget.value = Math.floor(Math.random() * range) * (targetWidget.options.step / 10) + min; - break; - default: - break; - } - /*check if values are over or under their respective - * ranges and set them to min or max.*/ - if (targetWidget.value < min) targetWidget.value = min; + //adjust values based on valueControl Behaviour + switch (v) { + case "fixed": + break; + case "increment": + targetWidget.value += targetWidget.options.step / 10; + break; + case "decrement": + targetWidget.value -= targetWidget.options.step / 10; + break; + case "randomize": + targetWidget.value = Math.floor(Math.random() * range) * (targetWidget.options.step / 10) + min; + break; + default: + break; + } + /*check if values are over or under their respective + * ranges and set them to min or max.*/ + if (targetWidget.value < min) targetWidget.value = min; - if (targetWidget.value > max) - targetWidget.value = max; - targetWidget.callback(targetWidget.value); - } - }; + if (targetWidget.value > max) + targetWidget.value = max; + targetWidget.callback(targetWidget.value); + } + }; - valueControl.beforeQueued = () => { - if (controlValueRunBefore) { - // Don't run on first execution - if (valueControl[HAS_EXECUTED]) { - applyWidgetControl(); - } - } - valueControl[HAS_EXECUTED] = true; - }; + valueControl.beforeQueued = () => { + if (controlValueRunBefore) { + // Don't run on first execution + if (valueControl[HAS_EXECUTED]) { + applyWidgetControl(); + } + } + valueControl[HAS_EXECUTED] = true; + }; - valueControl.afterQueued = () => { - if (!controlValueRunBefore) { - applyWidgetControl(); - } - }; + valueControl.afterQueued = () => { + if (!controlValueRunBefore) { + applyWidgetControl(); + } + }; - return widgets; + return widgets; }; function seedWidget(node, inputName, inputData: ComfyNodeDef, app, widgetName) { - const seed = createIntWidget(node, inputName, inputData, app, true); - const seedControl = addValueControlWidget(node, seed.widget, "randomize", undefined, widgetName, inputData); + const seed = createIntWidget(node, inputName, inputData, app, true); + const seedControl = addValueControlWidget(node, seed.widget, "randomize", undefined, widgetName, inputData); - seed.widget.linkedWidgets = [seedControl]; - return seed; + seed.widget.linkedWidgets = [seedControl]; + return seed; } function createIntWidget(node, inputName, inputData: ComfyNodeDef, app, isSeedInput: boolean = false) { - const control = inputData[1]?.control_after_generate; - if (!isSeedInput && control) { - return seedWidget(node, inputName, inputData, app, typeof control === "string" ? control : undefined); - } + const control = inputData[1]?.control_after_generate; + if (!isSeedInput && control) { + return seedWidget(node, inputName, inputData, app, typeof control === "string" ? control : undefined); + } - let widgetType = isSlider(inputData[1]["display"], app); - const { val, config } = getNumberDefaults(inputData, 1, 0, true); - Object.assign(config, { precision: 0 }); - return { - widget: node.addWidget( - widgetType, - inputName, - val, - function (v) { - const s = this.options.step / 10; - let sh = this.options.min % s; - if (isNaN(sh)) { - sh = 0; - } - this.value = Math.round((v - sh) / s) * s + sh; - }, - config - ), - }; + let widgetType = isSlider(inputData[1]["display"], app); + const { val, config } = getNumberDefaults(inputData, 1, 0, true); + Object.assign(config, { precision: 0 }); + return { + widget: node.addWidget( + widgetType, + inputName, + val, + function (v) { + const s = this.options.step / 10; + let sh = this.options.min % s; + if (isNaN(sh)) { + sh = 0; + } + this.value = Math.round((v - sh) / s) * s + sh; + }, + 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 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; - }, - setValue(v) { - inputEl.value = v; - }, - }); - widget.inputEl = inputEl; + const widget = node.addDOMWidget(name, "customtext", inputEl, { + getValue() { + return inputEl.value; + }, + setValue(v) { + inputEl.value = v; + }, + }); + widget.inputEl = inputEl; - inputEl.addEventListener("input", () => { - widget.callback?.(widget.value); - }); + inputEl.addEventListener("input", () => { + widget.callback?.(widget.value); + }); - return { minWidth: 400, minHeight: 200, widget }; + return { minWidth: 400, minHeight: 200, widget }; } function isSlider(display, app) { - if (app.ui.settings.getSettingValue("Comfy.DisableSliders")) { - return "number" - } + if (app.ui.settings.getSettingValue("Comfy.DisableSliders")) { + return "number" + } - return (display==="slider") ? "slider" : "number" + return (display==="slider") ? "slider" : "number" } export function initWidgets(app) { - app.ui.settings.addSetting({ - id: "Comfy.WidgetControlMode", - name: "Widget Value Control Mode", - type: "combo", - defaultValue: "after", - options: ["before", "after"], - tooltip: "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.", - onChange(value) { - controlValueRunBefore = value === "before"; - for (const n of app.graph._nodes) { - if (!n.widgets) continue; - for (const w of n.widgets) { - if (w[IS_CONTROL_WIDGET]) { - updateControlWidgetLabel(w); - if (w.linkedWidgets) { - for (const l of w.linkedWidgets) { - updateControlWidgetLabel(l); - } - } - } - } - } - app.graph.setDirtyCanvas(true); - }, - }); + app.ui.settings.addSetting({ + id: "Comfy.WidgetControlMode", + name: "Widget Value Control Mode", + type: "combo", + defaultValue: "after", + options: ["before", "after"], + tooltip: "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.", + onChange(value) { + controlValueRunBefore = value === "before"; + for (const n of app.graph._nodes) { + if (!n.widgets) continue; + for (const w of n.widgets) { + if (w[IS_CONTROL_WIDGET]) { + updateControlWidgetLabel(w); + if (w.linkedWidgets) { + for (const l of w.linkedWidgets) { + updateControlWidgetLabel(l); + } + } + } + } + } + app.graph.setDirtyCanvas(true); + }, + }); } export const ComfyWidgets: Record = { - "INT:seed": seedWidget, - "INT:noise_seed": seedWidget, - FLOAT(node, inputName, inputData: ComfyNodeDef, app) { - let widgetType: "number" | "slider" = isSlider(inputData[1]["display"], app); - let precision = app.ui.settings.getSettingValue("Comfy.FloatRoundingPrecision"); - let disable_rounding = app.ui.settings.getSettingValue("Comfy.DisableFloatRounding") - if (precision == 0) precision = undefined; - const { val, config } = getNumberDefaults(inputData, 0.5, precision, !disable_rounding); - return { widget: node.addWidget(widgetType, inputName, val, - function (v) { - if (config.round) { - this.value = Math.round((v + Number.EPSILON)/config.round)*config.round; - if (this.value > config.max) this.value = config.max; - if (this.value < config.min) this.value = config.min; - } else { - this.value = v; - } - }, config) }; - }, - INT(node, inputName, inputData: ComfyNodeDef, app) { - return createIntWidget(node, inputName, inputData, app); - }, - BOOLEAN(node, inputName, inputData) { - 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 { - widget: node.addWidget( - "toggle", - inputName, - defaultVal, - () => {}, - options, - ) - }; - }, - STRING(node, inputName, inputData: ComfyNodeDef, app) { - const defaultVal = inputData[1].default || ""; - const multiline = !!inputData[1].multiline; + "INT:seed": seedWidget, + "INT:noise_seed": seedWidget, + FLOAT(node, inputName, inputData: ComfyNodeDef, app) { + let widgetType: "number" | "slider" = isSlider(inputData[1]["display"], app); + let precision = app.ui.settings.getSettingValue("Comfy.FloatRoundingPrecision"); + let disable_rounding = app.ui.settings.getSettingValue("Comfy.DisableFloatRounding") + if (precision == 0) precision = undefined; + const { val, config } = getNumberDefaults(inputData, 0.5, precision, !disable_rounding); + return { widget: node.addWidget(widgetType, inputName, val, + function (v) { + if (config.round) { + this.value = Math.round((v + Number.EPSILON)/config.round)*config.round; + if (this.value > config.max) this.value = config.max; + if (this.value < config.min) this.value = config.min; + } else { + this.value = v; + } + }, config) }; + }, + INT(node, inputName, inputData: ComfyNodeDef, app) { + return createIntWidget(node, inputName, inputData, app); + }, + BOOLEAN(node, inputName, inputData) { + 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 { + widget: node.addWidget( + "toggle", + inputName, + defaultVal, + () => {}, + options, + ) + }; + }, + STRING(node, inputName, inputData: ComfyNodeDef, app) { + const defaultVal = inputData[1].default || ""; + const multiline = !!inputData[1].multiline; - let res; - if (multiline) { - res = addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app); - } else { - res = { widget: node.addWidget("text", inputName, defaultVal, () => {}, {}) }; - } + let res; + if (multiline) { + res = addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app); + } else { + res = { widget: node.addWidget("text", inputName, defaultVal, () => {}, {}) }; + } - if(inputData[1].dynamicPrompts != undefined) - res.widget.dynamicPrompts = inputData[1].dynamicPrompts; + if(inputData[1].dynamicPrompts != undefined) + res.widget.dynamicPrompts = inputData[1].dynamicPrompts; - return res; - }, - COMBO(node, inputName, inputData: ComfyNodeDef) { - const type = inputData[0]; - let defaultValue = type[0]; - if (inputData[1] && inputData[1].default) { - defaultValue = inputData[1].default; - } - const res = { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) }; - if (inputData[1]?.control_after_generate) { - // TODO make combo handle a widget node type? - // @ts-ignore - res.widget.linkedWidgets = addValueControlWidgets(node, res.widget, undefined, undefined, inputData); - } - return res; - }, - IMAGEUPLOAD(node: LGraphNode, inputName: string, inputData: ComfyNodeDef, app) { - // TODO make image upload handle a custom node type? - // @ts-ignore - const imageWidget = node.widgets.find((w) => w.name === (inputData[1]?.widget ?? "image")); - let uploadWidget; + return res; + }, + COMBO(node, inputName, inputData: ComfyNodeDef) { + const type = inputData[0]; + let defaultValue = type[0]; + if (inputData[1] && inputData[1].default) { + defaultValue = inputData[1].default; + } + const res = { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) }; + if (inputData[1]?.control_after_generate) { + // TODO make combo handle a widget node type? + // @ts-ignore + res.widget.linkedWidgets = addValueControlWidgets(node, res.widget, undefined, undefined, inputData); + } + return res; + }, + IMAGEUPLOAD(node: LGraphNode, inputName: string, inputData: ComfyNodeDef, app) { + // TODO make image upload handle a custom node type? + // @ts-ignore + const imageWidget = node.widgets.find((w) => w.name === (inputData[1]?.widget ?? "image")); + let uploadWidget; - function showImage(name) { - const img = new Image(); - img.onload = () => { - // @ts-ignore - node.imgs = [img]; - app.graph.setDirtyCanvas(true); - }; - let folder_separator = name.lastIndexOf("/"); - let subfolder = ""; - if (folder_separator > -1) { - subfolder = name.substring(0, folder_separator); - name = name.substring(folder_separator + 1); - } - img.src = api.apiURL(`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`); - // @ts-ignore - node.setSizeForImage?.(); - } + function showImage(name) { + const img = new Image(); + img.onload = () => { + // @ts-ignore + node.imgs = [img]; + app.graph.setDirtyCanvas(true); + }; + let folder_separator = name.lastIndexOf("/"); + let subfolder = ""; + if (folder_separator > -1) { + subfolder = name.substring(0, folder_separator); + name = name.substring(folder_separator + 1); + } + img.src = api.apiURL(`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`); + // @ts-ignore + node.setSizeForImage?.(); + } - var default_value = imageWidget.value; - Object.defineProperty(imageWidget, "value", { - set : function(value) { - this._real_value = value; - }, + var default_value = imageWidget.value; + Object.defineProperty(imageWidget, "value", { + set : function(value) { + this._real_value = value; + }, - get : function() { - if (!this._real_value) { - return default_value; - } + get : function() { + if (!this._real_value) { + return default_value; + } - let value = this._real_value; - if (value.filename) { - let real_value = value; - value = ""; - if (real_value.subfolder) { - value = real_value.subfolder + "/"; - } + let value = this._real_value; + if (value.filename) { + let real_value = value; + value = ""; + if (real_value.subfolder) { + value = real_value.subfolder + "/"; + } - value += real_value.filename; + value += real_value.filename; - if(real_value.type && real_value.type !== "input") - value += ` [${real_value.type}]`; - } - return value; - } - }); + if(real_value.type && real_value.type !== "input") + value += ` [${real_value.type}]`; + } + return value; + } + }); - // Add our own callback to the combo widget to render an image when it changes - // TODO: Explain this? - // @ts-ignore - const cb = node.callback; - imageWidget.callback = function () { - showImage(imageWidget.value); - if (cb) { - return cb.apply(this, arguments); - } - }; + // Add our own callback to the combo widget to render an image when it changes + // TODO: Explain this? + // @ts-ignore + const cb = node.callback; + imageWidget.callback = function () { + showImage(imageWidget.value); + if (cb) { + return cb.apply(this, arguments); + } + }; - // On load if we have a value then render the image - // The value isnt set immediately so we need to wait a moment - // No change callbacks seem to be fired on initial setting of the value - requestAnimationFrame(() => { - if (imageWidget.value) { - showImage(imageWidget.value); - } - }); + // On load if we have a value then render the image + // The value isnt set immediately so we need to wait a moment + // No change callbacks seem to be fired on initial setting of the value + requestAnimationFrame(() => { + if (imageWidget.value) { + showImage(imageWidget.value); + } + }); - async function uploadFile(file, updateNode, pasted = false) { - try { - // Wrap file in formdata so it includes filename - const body = new FormData(); - body.append("image", file); - if (pasted) body.append("subfolder", "pasted"); - const resp = await api.fetchApi("/upload/image", { - method: "POST", - body, - }); + async function uploadFile(file, updateNode, pasted = false) { + try { + // Wrap file in formdata so it includes filename + const body = new FormData(); + body.append("image", file); + if (pasted) body.append("subfolder", "pasted"); + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body, + }); - if (resp.status === 200) { - const data = await resp.json(); - // Add the file to the dropdown list and update the widget value - let path = data.name; - if (data.subfolder) path = data.subfolder + "/" + path; + if (resp.status === 200) { + const data = await resp.json(); + // Add the file to the dropdown list and update the widget value + let path = data.name; + if (data.subfolder) path = data.subfolder + "/" + path; - if (!imageWidget.options.values.includes(path)) { - imageWidget.options.values.push(path); - } + if (!imageWidget.options.values.includes(path)) { + imageWidget.options.values.push(path); + } - if (updateNode) { - showImage(path); - imageWidget.value = path; - } - } else { - alert(resp.status + " - " + resp.statusText); - } - } catch (error) { - alert(error); - } - } + if (updateNode) { + showImage(path); + imageWidget.value = path; + } + } else { + alert(resp.status + " - " + resp.statusText); + } + } catch (error) { + alert(error); + } + } - const fileInput = document.createElement("input"); - Object.assign(fileInput, { - type: "file", - accept: "image/jpeg,image/png,image/webp", - style: "display: none", - onchange: async () => { - if (fileInput.files.length) { - await uploadFile(fileInput.files[0], true); - } - }, - }); - document.body.append(fileInput); + const fileInput = document.createElement("input"); + Object.assign(fileInput, { + type: "file", + accept: "image/jpeg,image/png,image/webp", + style: "display: none", + onchange: async () => { + if (fileInput.files.length) { + await uploadFile(fileInput.files[0], true); + } + }, + }); + document.body.append(fileInput); - // Create the button widget for selecting the files - uploadWidget = node.addWidget("button", inputName, "image", () => { - fileInput.click(); - }); - uploadWidget.label = "choose file to upload"; - uploadWidget.serialize = false; + // Create the button widget for selecting the files + uploadWidget = node.addWidget("button", inputName, "image", () => { + fileInput.click(); + }); + uploadWidget.label = "choose file to upload"; + uploadWidget.serialize = false; - // Add handler to check if an image is being dragged over our node - // @ts-ignore - node.onDragOver = function (e) { - if (e.dataTransfer && e.dataTransfer.items) { - const image = [...e.dataTransfer.items].find((f) => f.kind === "file"); - return !!image; - } + // Add handler to check if an image is being dragged over our node + // @ts-ignore + node.onDragOver = function (e) { + if (e.dataTransfer && e.dataTransfer.items) { + const image = [...e.dataTransfer.items].find((f) => f.kind === "file"); + return !!image; + } - return false; - }; + return false; + }; - // On drop upload files - // @ts-ignore - node.onDragDrop = function (e) { - console.log("onDragDrop called"); - let handled = false; - for (const file of e.dataTransfer.files) { - if (file.type.startsWith("image/")) { - uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one - handled = true; - } - } + // On drop upload files + // @ts-ignore + node.onDragDrop = function (e) { + console.log("onDragDrop called"); + let handled = false; + for (const file of e.dataTransfer.files) { + if (file.type.startsWith("image/")) { + uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one + handled = true; + } + } - return handled; - }; + return handled; + }; - // @ts-ignore - node.pasteFile = function(file) { - if (file.type.startsWith("image/")) { - const is_pasted = (file.name === "image.png") && - (file.lastModified - Date.now() < 2000); - uploadFile(file, true, is_pasted); - return true; - } - return false; - } + // @ts-ignore + node.pasteFile = function(file) { + if (file.type.startsWith("image/")) { + const is_pasted = (file.name === "image.png") && + (file.lastModified - Date.now() < 2000); + uploadFile(file, true, is_pasted); + return true; + } + return false; + } - return { widget: uploadWidget }; - }, + return { widget: uploadWidget }; + }, }; diff --git a/src/scripts/workflows.js b/src/scripts/workflows.js index e4cbb1162..1f17f151f 100644 --- a/src/scripts/workflows.js +++ b/src/scripts/workflows.js @@ -6,445 +6,445 @@ import { ComfyAsyncDialog } from "./ui/components/asyncDialog"; import { getStorageValue, setStorageValue } from "./utils"; function appendJsonExt(path) { - if (!path.toLowerCase().endsWith(".json")) { - path += ".json"; - } - return path; + if (!path.toLowerCase().endsWith(".json")) { + path += ".json"; + } + return path; } export function trimJsonExt(path) { - return path?.replace(/\.json$/, ""); + return path?.replace(/\.json$/, ""); } export class ComfyWorkflowManager extends EventTarget { - /** @type {string | null} */ - #activePromptId = null; - #unsavedCount = 0; - #activeWorkflow; + /** @type {string | null} */ + #activePromptId = null; + #unsavedCount = 0; + #activeWorkflow; - /** @type {Record} */ - workflowLookup = {}; - /** @type {Array} */ - workflows = []; - /** @type {Array} */ - openWorkflows = []; - /** @type {Record}>} */ - queuedPrompts = {}; + /** @type {Record} */ + workflowLookup = {}; + /** @type {Array} */ + workflows = []; + /** @type {Array} */ + openWorkflows = []; + /** @type {Record}>} */ + queuedPrompts = {}; - get activeWorkflow() { - return this.#activeWorkflow ?? this.openWorkflows[0]; - } + get activeWorkflow() { + return this.#activeWorkflow ?? this.openWorkflows[0]; + } - get activePromptId() { - return this.#activePromptId; - } + get activePromptId() { + return this.#activePromptId; + } - get activePrompt() { - return this.queuedPrompts[this.#activePromptId]; - } + get activePrompt() { + return this.queuedPrompts[this.#activePromptId]; + } - /** - * @param {import("./app").ComfyApp} app - */ - constructor(app) { - super(); - this.app = app; - ChangeTracker.init(app); + /** + * @param {import("./app").ComfyApp} app + */ + constructor(app) { + super(); + this.app = app; + ChangeTracker.init(app); - this.#bindExecutionEvents(); - } + this.#bindExecutionEvents(); + } - #bindExecutionEvents() { - // TODO: on reload, set active prompt based on the latest ws message + #bindExecutionEvents() { + // TODO: on reload, set active prompt based on the latest ws message - const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt })); - let executing = null; - api.addEventListener("execution_start", (e) => { - this.#activePromptId = e.detail.prompt_id; + const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt })); + let executing = null; + api.addEventListener("execution_start", (e) => { + this.#activePromptId = e.detail.prompt_id; - // This event can fire before the event is stored, so put a placeholder - this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }; - emit(); - }); - api.addEventListener("execution_cached", (e) => { - if (!this.activePrompt) return; - for (const n of e.detail.nodes) { - this.activePrompt.nodes[n] = true; - } - emit(); - }); - api.addEventListener("executed", (e) => { - if (!this.activePrompt) return; - this.activePrompt.nodes[e.detail.node] = true; - emit(); - }); - api.addEventListener("executing", (e) => { - if (!this.activePrompt) return; + // This event can fire before the event is stored, so put a placeholder + this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }; + emit(); + }); + api.addEventListener("execution_cached", (e) => { + if (!this.activePrompt) return; + for (const n of e.detail.nodes) { + this.activePrompt.nodes[n] = true; + } + emit(); + }); + api.addEventListener("executed", (e) => { + if (!this.activePrompt) return; + this.activePrompt.nodes[e.detail.node] = true; + emit(); + }); + api.addEventListener("executing", (e) => { + if (!this.activePrompt) return; - if (executing) { - // Seems sometimes nodes that are cached fire executing but not executed - this.activePrompt.nodes[executing] = true; - } - executing = e.detail; - if (!executing) { - delete this.queuedPrompts[this.#activePromptId]; - this.#activePromptId = null; - } - emit(); - }); - } + if (executing) { + // Seems sometimes nodes that are cached fire executing but not executed + this.activePrompt.nodes[executing] = true; + } + executing = e.detail; + if (!executing) { + delete this.queuedPrompts[this.#activePromptId]; + this.#activePromptId = null; + } + emit(); + }); + } - async loadWorkflows() { - try { - let favorites; - const resp = await api.getUserData("workflows/.index.json"); - let info; - if (resp.status === 200) { - info = await resp.json(); - favorites = new Set(info?.favorites ?? []); - } else { - favorites = new Set(); - } + async loadWorkflows() { + try { + let favorites; + const resp = await api.getUserData("workflows/.index.json"); + let info; + if (resp.status === 200) { + info = await resp.json(); + favorites = new Set(info?.favorites ?? []); + } else { + favorites = new Set(); + } - const workflows = (await api.listUserData("workflows", true, true)).map((w) => { - let workflow = this.workflowLookup[w[0]]; - if (!workflow) { - workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0])); - this.workflowLookup[workflow.path] = workflow; - } - return workflow; - }); + const workflows = (await api.listUserData("workflows", true, true)).map((w) => { + let workflow = this.workflowLookup[w[0]]; + if (!workflow) { + workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0])); + this.workflowLookup[workflow.path] = workflow; + } + return workflow; + }); - this.workflows = workflows; - } catch (error) { - alert("Error loading workflows: " + (error.message ?? error)); - this.workflows = []; - } - } + this.workflows = workflows; + } catch (error) { + alert("Error loading workflows: " + (error.message ?? error)); + this.workflows = []; + } + } - async saveWorkflowMetadata() { - await api.storeUserData("workflows/.index.json", { - favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)], - }); - } + async saveWorkflowMetadata() { + await api.storeUserData("workflows/.index.json", { + favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)], + }); + } - /** - * @param {string | ComfyWorkflow | null} workflow - */ - setWorkflow(workflow) { - if (workflow && typeof workflow === "string") { - // Selected by path, i.e. on reload of last workflow - const found = this.workflows.find((w) => w.path === workflow); - if (found) { - workflow = found; - workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true"; - } - } + /** + * @param {string | ComfyWorkflow | null} workflow + */ + setWorkflow(workflow) { + if (workflow && typeof workflow === "string") { + // Selected by path, i.e. on reload of last workflow + const found = this.workflows.find((w) => w.path === workflow); + if (found) { + workflow = found; + workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true"; + } + } - if (!(workflow instanceof ComfyWorkflow)) { - // Still not found, either reloading a deleted workflow or blank - workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : "")); - } + if (!(workflow instanceof ComfyWorkflow)) { + // Still not found, either reloading a deleted workflow or blank + workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : "")); + } - const index = this.openWorkflows.indexOf(workflow); - if (index === -1) { - // Opening a new workflow - this.openWorkflows.push(workflow); - } + const index = this.openWorkflows.indexOf(workflow); + if (index === -1) { + // Opening a new workflow + this.openWorkflows.push(workflow); + } - this.#activeWorkflow = workflow; + this.#activeWorkflow = workflow; - setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? ""); - this.dispatchEvent(new CustomEvent("changeWorkflow")); - } + setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? ""); + this.dispatchEvent(new CustomEvent("changeWorkflow")); + } - storePrompt({ nodes, id }) { - this.queuedPrompts[id] ??= {}; - this.queuedPrompts[id].nodes = { - ...nodes.reduce((p, n) => { - p[n] = false; - return p; - }, {}), - ...this.queuedPrompts[id].nodes, - }; - this.queuedPrompts[id].workflow = this.activeWorkflow; - } + storePrompt({ nodes, id }) { + this.queuedPrompts[id] ??= {}; + this.queuedPrompts[id].nodes = { + ...nodes.reduce((p, n) => { + p[n] = false; + return p; + }, {}), + ...this.queuedPrompts[id].nodes, + }; + this.queuedPrompts[id].workflow = this.activeWorkflow; + } - /** - * @param {ComfyWorkflow} workflow - */ - async closeWorkflow(workflow, warnIfUnsaved = true) { - if (!workflow.isOpen) { - return true; - } - if (workflow.unsaved && warnIfUnsaved) { - const res = await ComfyAsyncDialog.prompt({ - title: "Save Changes?", - message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`, - actions: ["Yes", "No", "Cancel"], - }); - if (res === "Yes") { - const active = this.activeWorkflow; - if (active !== workflow) { - // We need to switch to the workflow to save it - await workflow.load(); - } + /** + * @param {ComfyWorkflow} workflow + */ + async closeWorkflow(workflow, warnIfUnsaved = true) { + if (!workflow.isOpen) { + return true; + } + if (workflow.unsaved && warnIfUnsaved) { + const res = await ComfyAsyncDialog.prompt({ + title: "Save Changes?", + message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`, + actions: ["Yes", "No", "Cancel"], + }); + if (res === "Yes") { + const active = this.activeWorkflow; + if (active !== workflow) { + // We need to switch to the workflow to save it + await workflow.load(); + } - if (!(await workflow.save())) { - // Save was canceled, restore the previous workflow - if (active !== workflow) { - await active.load(); - } - return; - } - } else if (res === "Cancel") { - return; - } - } - workflow.changeTracker = null; - this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1); - if (this.openWorkflows.length) { - this.#activeWorkflow = this.openWorkflows[0]; - await this.#activeWorkflow.load(); - } else { - // Load default - await this.app.loadGraphData(); - } - } + if (!(await workflow.save())) { + // Save was canceled, restore the previous workflow + if (active !== workflow) { + await active.load(); + } + return; + } + } else if (res === "Cancel") { + return; + } + } + workflow.changeTracker = null; + this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1); + if (this.openWorkflows.length) { + this.#activeWorkflow = this.openWorkflows[0]; + await this.#activeWorkflow.load(); + } else { + // Load default + await this.app.loadGraphData(); + } + } } export class ComfyWorkflow { - #name; - #path; - #pathParts; - #isFavorite = false; - /** @type {ChangeTracker | null} */ - changeTracker = null; - unsaved = false; + #name; + #path; + #pathParts; + #isFavorite = false; + /** @type {ChangeTracker | null} */ + changeTracker = null; + unsaved = false; - get name() { - return this.#name; - } + get name() { + return this.#name; + } - get path() { - return this.#path; - } + get path() { + return this.#path; + } - get pathParts() { - return this.#pathParts; - } + get pathParts() { + return this.#pathParts; + } - get isFavorite() { - return this.#isFavorite; - } + get isFavorite() { + return this.#isFavorite; + } - get isOpen() { - return !!this.changeTracker; - } + get isOpen() { + return !!this.changeTracker; + } - /** - * @overload - * @param {ComfyWorkflowManager} manager - * @param {string} path - */ - /** - * @overload - * @param {ComfyWorkflowManager} manager - * @param {string} path - * @param {string[]} pathParts - * @param {boolean} isFavorite - */ - /** - * @param {ComfyWorkflowManager} manager - * @param {string} path - * @param {string[]} [pathParts] - * @param {boolean} [isFavorite] - */ - constructor(manager, path, pathParts, isFavorite) { - this.manager = manager; - if (pathParts) { - this.#updatePath(path, pathParts); - this.#isFavorite = isFavorite; - } else { - this.#name = path; - this.unsaved = true; - } - } + /** + * @overload + * @param {ComfyWorkflowManager} manager + * @param {string} path + */ + /** + * @overload + * @param {ComfyWorkflowManager} manager + * @param {string} path + * @param {string[]} pathParts + * @param {boolean} isFavorite + */ + /** + * @param {ComfyWorkflowManager} manager + * @param {string} path + * @param {string[]} [pathParts] + * @param {boolean} [isFavorite] + */ + constructor(manager, path, pathParts, isFavorite) { + this.manager = manager; + if (pathParts) { + this.#updatePath(path, pathParts); + this.#isFavorite = isFavorite; + } else { + this.#name = path; + this.unsaved = true; + } + } - /** - * @param {string} path - * @param {string[]} [pathParts] - */ - #updatePath(path, pathParts) { - this.#path = path; + /** + * @param {string} path + * @param {string[]} [pathParts] + */ + #updatePath(path, pathParts) { + this.#path = path; - if (!pathParts) { - if (!path.includes("\\")) { - pathParts = path.split("/"); - } else { - pathParts = path.split("\\"); - } - } + if (!pathParts) { + if (!path.includes("\\")) { + pathParts = path.split("/"); + } else { + pathParts = path.split("\\"); + } + } - this.#pathParts = pathParts; - this.#name = trimJsonExt(pathParts[pathParts.length - 1]); - } + this.#pathParts = pathParts; + this.#name = trimJsonExt(pathParts[pathParts.length - 1]); + } - async getWorkflowData() { - const resp = await api.getUserData("workflows/" + this.path); - if (resp.status !== 200) { - alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`); - return; - } - return await resp.json(); - } + async getWorkflowData() { + const resp = await api.getUserData("workflows/" + this.path); + if (resp.status !== 200) { + alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`); + return; + } + return await resp.json(); + } - load = async () => { - if (this.isOpen) { - await this.manager.app.loadGraphData(this.changeTracker.activeState, true, true, this); - } else { - const data = await this.getWorkflowData(); - if (!data) return; - await this.manager.app.loadGraphData(data, true, true, this); - } - }; + load = async () => { + if (this.isOpen) { + await this.manager.app.loadGraphData(this.changeTracker.activeState, true, true, this); + } else { + const data = await this.getWorkflowData(); + if (!data) return; + await this.manager.app.loadGraphData(data, true, true, this); + } + }; - async save(saveAs = false) { - if (!this.path || saveAs) { - return !!(await this.#save(null, false)); - } else { - return !!(await this.#save(this.path, true)); - } - } + async save(saveAs = false) { + if (!this.path || saveAs) { + return !!(await this.#save(null, false)); + } else { + return !!(await this.#save(this.path, true)); + } + } - /** - * @param {boolean} value - */ - async favorite(value) { - try { - if (this.#isFavorite === value) return; - this.#isFavorite = value; - await this.manager.saveWorkflowMetadata(); - this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this })); - } catch (error) { - alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error)); - } - } + /** + * @param {boolean} value + */ + async favorite(value) { + try { + if (this.#isFavorite === value) return; + this.#isFavorite = value; + await this.manager.saveWorkflowMetadata(); + this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this })); + } catch (error) { + alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error)); + } + } - /** - * @param {string} path - */ - async rename(path) { - path = appendJsonExt(path); - let resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path); + /** + * @param {string} path + */ + async rename(path) { + path = appendJsonExt(path); + let resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path); - if (resp.status === 409) { - if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return resp; - resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path, { overwrite: true }); - } + if (resp.status === 409) { + if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return resp; + resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path, { overwrite: true }); + } - if (resp.status !== 200) { - alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`); - return; - } + if (resp.status !== 200) { + alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`); + return; + } - const isFav = this.isFavorite; - if (isFav) { - await this.favorite(false); - } - path = (await resp.json()).substring("workflows/".length); - this.#updatePath(path, null); - if (isFav) { - await this.favorite(true); - } - this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); - setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); - } + const isFav = this.isFavorite; + if (isFav) { + await this.favorite(false); + } + path = (await resp.json()).substring("workflows/".length); + this.#updatePath(path, null); + if (isFav) { + await this.favorite(true); + } + this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); + setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); + } - async insert() { - const data = await this.getWorkflowData(); - if (!data) return; + async insert() { + const data = await this.getWorkflowData(); + if (!data) return; - const old = localStorage.getItem("litegrapheditor_clipboard"); - const graph = new LGraph(data); - const canvas = new LGraphCanvas(null, graph, { skip_events: true, skip_render: true }); - canvas.selectNodes(); - canvas.copyToClipboard(); - this.manager.app.canvas.pasteFromClipboard(); - localStorage.setItem("litegrapheditor_clipboard", old); - } + const old = localStorage.getItem("litegrapheditor_clipboard"); + const graph = new LGraph(data); + const canvas = new LGraphCanvas(null, graph, { skip_events: true, skip_render: true }); + canvas.selectNodes(); + canvas.copyToClipboard(); + this.manager.app.canvas.pasteFromClipboard(); + localStorage.setItem("litegrapheditor_clipboard", old); + } - async delete() { - // TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default + async delete() { + // TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default - try { - if (this.isFavorite) { - await this.favorite(false); - } - await api.deleteUserData("workflows/" + this.path); - this.unsaved = true; - this.#path = null; - this.#pathParts = null; - this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1); - this.manager.dispatchEvent(new CustomEvent("delete", { detail: this })); - } catch (error) { - alert(`Error deleting workflow: ${error.message || error}`); - } - } + try { + if (this.isFavorite) { + await this.favorite(false); + } + await api.deleteUserData("workflows/" + this.path); + this.unsaved = true; + this.#path = null; + this.#pathParts = null; + this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1); + this.manager.dispatchEvent(new CustomEvent("delete", { detail: this })); + } catch (error) { + alert(`Error deleting workflow: ${error.message || error}`); + } + } - track() { - if (this.changeTracker) { - this.changeTracker.restore(); - } else { - this.changeTracker = new ChangeTracker(this); - } - } + track() { + if (this.changeTracker) { + this.changeTracker.restore(); + } else { + this.changeTracker = new ChangeTracker(this); + } + } - /** - * @param {string|null} path - * @param {boolean} overwrite - */ - async #save(path, overwrite) { - if (!path) { - path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow"); - if (!path) return; - } + /** + * @param {string|null} path + * @param {boolean} overwrite + */ + async #save(path, overwrite) { + if (!path) { + path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow"); + if (!path) return; + } - path = appendJsonExt(path); + path = appendJsonExt(path); - const p = await this.manager.app.graphToPrompt(); - const json = JSON.stringify(p.workflow, null, 2); - let resp = await api.storeUserData("workflows/" + path, json, { stringify: false, throwOnError: false, overwrite }); - if (resp.status === 409) { - if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return; - resp = await api.storeUserData("workflows/" + path, json, { stringify: false }); - } + const p = await this.manager.app.graphToPrompt(); + const json = JSON.stringify(p.workflow, null, 2); + let resp = await api.storeUserData("workflows/" + path, json, { stringify: false, throwOnError: false, overwrite }); + if (resp.status === 409) { + if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return; + resp = await api.storeUserData("workflows/" + path, json, { stringify: false }); + } - if (resp.status !== 200) { - alert(`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`); - return; - } + if (resp.status !== 200) { + alert(`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`); + return; + } - path = (await resp.json()).substring("workflows/".length); + path = (await resp.json()).substring("workflows/".length); - if (!this.path) { - // Saved new workflow, patch this instance - this.#updatePath(path, null); - await this.manager.loadWorkflows(); - this.unsaved = false; - this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); - setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); - } else if (path !== this.path) { - // Saved as, open the new copy - await this.manager.loadWorkflows(); - const workflow = this.manager.workflowLookup[path]; - await workflow.load(); - } else { - // Normal save - this.unsaved = false; - this.manager.dispatchEvent(new CustomEvent("save", { detail: this })); - } + if (!this.path) { + // Saved new workflow, patch this instance + this.#updatePath(path, null); + await this.manager.loadWorkflows(); + this.unsaved = false; + this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); + setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); + } else if (path !== this.path) { + // Saved as, open the new copy + await this.manager.loadWorkflows(); + const workflow = this.manager.workflowLookup[path]; + await workflow.load(); + } else { + // Normal save + this.unsaved = false; + this.manager.dispatchEvent(new CustomEvent("save", { detail: this })); + } - return true; - } + return true; + } } diff --git a/src/types/litegraph.d.ts b/src/types/litegraph.d.ts index 254bcb0eb..30954d961 100644 --- a/src/types/litegraph.d.ts +++ b/src/types/litegraph.d.ts @@ -53,7 +53,7 @@ export type WidgetCallback = ( export interface IWidget { // linked widgets, e.g. seed+seedControl - linkedWidgets: IWidget[]; + linkedWidgets: IWidget[]; name: string | null; value: TValue; @@ -165,7 +165,7 @@ export declare class LGraph { static supported_types: string[]; static STATUS_STOPPED: 1; static STATUS_RUNNING: 2; - extra: any; + extra: any; constructor(o?: object); @@ -411,12 +411,12 @@ export type SerializedLGraphNode = { /** https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md#lgraphnode */ export declare class LGraphNode { - onResize?: Function; + onResize?: Function; // Used in group node - setInnerNodes(nodes: any) { - throw new Error("Method not implemented."); - } + setInnerNodes(nodes: any) { + throw new Error("Method not implemented."); + } static title_color: string; static title: string; @@ -857,7 +857,7 @@ export declare class LGraphNode { } export type LGraphNodeConstructor = { - nodeData: any; // Used by group node. + nodeData: any; // Used by group node. new (): T; }; @@ -1341,11 +1341,11 @@ declare global { } const LiteGraph: { - DEFAULT_GROUP_FONT_SIZE: any; - overlapBounding(visible_area: any, _bounding: any): unknown; - release_link_on_empty_shows_menu: boolean; - alt_drag_do_clone_nodes: boolean; - GRID_SHAPE: number; + DEFAULT_GROUP_FONT_SIZE: any; + overlapBounding(visible_area: any, _bounding: any): unknown; + release_link_on_empty_shows_menu: boolean; + alt_drag_do_clone_nodes: boolean; + GRID_SHAPE: number; VERSION: number; CANVAS_GRID_SIZE: number; diff --git a/tests-ui/afterSetup.ts b/tests-ui/afterSetup.ts index 418cf62ba..d41fa40ef 100644 --- a/tests-ui/afterSetup.ts +++ b/tests-ui/afterSetup.ts @@ -3,7 +3,7 @@ import lg from "./utils/litegraph"; // Load things once per test file before to ensure its all warmed up for the tests beforeAll(async () => { - lg.setup(global); - await start({ resetEnv: true }); - lg.teardown(global); + lg.setup(global); + await start({ resetEnv: true }); + lg.teardown(global); }); diff --git a/tests-ui/globalSetup.ts b/tests-ui/globalSetup.ts index b9d97f58a..689aa9868 100644 --- a/tests-ui/globalSetup.ts +++ b/tests-ui/globalSetup.ts @@ -1,14 +1,14 @@ module.exports = async function () { - global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }; + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; - const { nop } = require("./utils/nopProxy"); - global.enableWebGLCanvas = nop; + const { nop } = require("./utils/nopProxy"); + global.enableWebGLCanvas = nop; - HTMLCanvasElement.prototype.getContext = nop; + HTMLCanvasElement.prototype.getContext = nop; - localStorage["Comfy.Settings.Comfy.Logging.Enabled"] = "false"; + localStorage["Comfy.Settings.Comfy.Logging.Enabled"] = "false"; }; diff --git a/tests-ui/setup.ts b/tests-ui/setup.ts index 15bc07f3e..1703234cf 100644 --- a/tests-ui/setup.ts +++ b/tests-ui/setup.ts @@ -3,36 +3,36 @@ import { existsSync, mkdirSync, writeFileSync } from "fs"; import http from "http"; async function setup() { - await new Promise((res, rej) => { - http - .get("http://127.0.0.1:8188/object_info", (resp) => { - let data = ""; - resp.on("data", (chunk) => { - data += chunk; - }); - resp.on("end", () => { - // Modify the response data to add some checkpoints - const objectInfo = JSON.parse(data); - objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"]; - objectInfo.VAELoader.input.required.vae_name[0] = ["vae1.safetensors", "vae2.ckpt"]; + await new Promise((res, rej) => { + http + .get("http://127.0.0.1:8188/object_info", (resp) => { + let data = ""; + resp.on("data", (chunk) => { + data += chunk; + }); + resp.on("end", () => { + // Modify the response data to add some checkpoints + const objectInfo = JSON.parse(data); + objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"]; + objectInfo.VAELoader.input.required.vae_name[0] = ["vae1.safetensors", "vae2.ckpt"]; - data = JSON.stringify(objectInfo, undefined, "\t"); + data = JSON.stringify(objectInfo, undefined, "\t"); - const outDir = resolve("./tests-ui/data"); - if (!existsSync(outDir)) { - mkdirSync(outDir); - } + const outDir = resolve("./tests-ui/data"); + if (!existsSync(outDir)) { + mkdirSync(outDir); + } - const outPath = resolve(outDir, "object_info.json"); - console.log(`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`); - writeFileSync(outPath, data, { - encoding: "utf8", - }); - res(); - }); - }) - .on("error", rej); - }); + const outPath = resolve(outDir, "object_info.json"); + console.log(`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`); + writeFileSync(outPath, data, { + encoding: "utf8", + }); + res(); + }); + }) + .on("error", rej); + }); } setup(); \ No newline at end of file diff --git a/tests-ui/tests/extensions.test.ts b/tests-ui/tests/extensions.test.ts index 3f1f34e1c..d12ac73cd 100644 --- a/tests-ui/tests/extensions.test.ts +++ b/tests-ui/tests/extensions.test.ts @@ -2,193 +2,193 @@ import { start } from "../utils"; import lg from "../utils/litegraph"; describe("extensions", () => { - beforeEach(() => { - lg.setup(global); - }); + beforeEach(() => { + lg.setup(global); + }); - afterEach(() => { - lg.teardown(global); - }); + afterEach(() => { + lg.teardown(global); + }); - it("calls each extension hook", async () => { - const mockExtension = { - name: "TestExtension", - init: jest.fn(), - setup: jest.fn(), - addCustomNodeDefs: jest.fn(), - getCustomWidgets: jest.fn(), - beforeRegisterNodeDef: jest.fn(), - registerCustomNodes: jest.fn(), - loadedGraphNode: jest.fn(), - nodeCreated: jest.fn(), - beforeConfigureGraph: jest.fn(), - afterConfigureGraph: jest.fn(), - }; + it("calls each extension hook", async () => { + const mockExtension = { + name: "TestExtension", + init: jest.fn(), + setup: jest.fn(), + addCustomNodeDefs: jest.fn(), + getCustomWidgets: jest.fn(), + beforeRegisterNodeDef: jest.fn(), + registerCustomNodes: jest.fn(), + loadedGraphNode: jest.fn(), + nodeCreated: jest.fn(), + beforeConfigureGraph: jest.fn(), + afterConfigureGraph: jest.fn(), + }; - const { app, ez, graph } = await start({ - async preSetup(app) { - app.registerExtension(mockExtension); - }, - }); + const { app, ez, graph } = await start({ + async preSetup(app) { + app.registerExtension(mockExtension); + }, + }); - // Basic initialisation hooks should be called once, with app - expect(mockExtension.init).toHaveBeenCalledTimes(1); - expect(mockExtension.init).toHaveBeenCalledWith(app); + // Basic initialisation hooks should be called once, with app + expect(mockExtension.init).toHaveBeenCalledTimes(1); + expect(mockExtension.init).toHaveBeenCalledWith(app); - // Adding custom node defs should be passed the full list of nodes - expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1); - expect(mockExtension.addCustomNodeDefs.mock.calls[0][1]).toStrictEqual(app); - const defs = mockExtension.addCustomNodeDefs.mock.calls[0][0]; - expect(defs).toHaveProperty("KSampler"); - expect(defs).toHaveProperty("LoadImage"); + // Adding custom node defs should be passed the full list of nodes + expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1); + expect(mockExtension.addCustomNodeDefs.mock.calls[0][1]).toStrictEqual(app); + const defs = mockExtension.addCustomNodeDefs.mock.calls[0][0]; + expect(defs).toHaveProperty("KSampler"); + expect(defs).toHaveProperty("LoadImage"); - // Get custom widgets is called once and should return new widget types - expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1); - expect(mockExtension.getCustomWidgets).toHaveBeenCalledWith(app); + // Get custom widgets is called once and should return new widget types + expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1); + expect(mockExtension.getCustomWidgets).toHaveBeenCalledWith(app); - // Before register node def will be called once per node type - const nodeNames = Object.keys(defs); - const nodeCount = nodeNames.length; - expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount); - for (let i = 0; i < 10; i++) { - // It should be send the JS class and the original JSON definition - const nodeClass = mockExtension.beforeRegisterNodeDef.mock.calls[i][0]; - const nodeDef = mockExtension.beforeRegisterNodeDef.mock.calls[i][1]; + // Before register node def will be called once per node type + const nodeNames = Object.keys(defs); + const nodeCount = nodeNames.length; + expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount); + for (let i = 0; i < 10; i++) { + // It should be send the JS class and the original JSON definition + const nodeClass = mockExtension.beforeRegisterNodeDef.mock.calls[i][0]; + const nodeDef = mockExtension.beforeRegisterNodeDef.mock.calls[i][1]; - expect(nodeClass.name).toBe("ComfyNode"); - expect(nodeClass.comfyClass).toBe(nodeNames[i]); - expect(nodeDef.name).toBe(nodeNames[i]); - expect(nodeDef).toHaveProperty("input"); - expect(nodeDef).toHaveProperty("output"); - } + expect(nodeClass.name).toBe("ComfyNode"); + expect(nodeClass.comfyClass).toBe(nodeNames[i]); + expect(nodeDef.name).toBe(nodeNames[i]); + expect(nodeDef).toHaveProperty("input"); + expect(nodeDef).toHaveProperty("output"); + } - // Register custom nodes is called once after registerNode defs to allow adding other frontend nodes - expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1); + // Register custom nodes is called once after registerNode defs to allow adding other frontend nodes + expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1); - // Before configure graph will be called here as the default graph is being loaded - expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(1); - // it gets sent the graph data that is going to be loaded - const graphData = mockExtension.beforeConfigureGraph.mock.calls[0][0]; + // Before configure graph will be called here as the default graph is being loaded + expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(1); + // it gets sent the graph data that is going to be loaded + const graphData = mockExtension.beforeConfigureGraph.mock.calls[0][0]; - // A node created is fired for each node constructor that is called - expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length); - for (let i = 0; i < graphData.nodes.length; i++) { - expect(mockExtension.nodeCreated.mock.calls[i][0].type).toBe(graphData.nodes[i].type); - } + // A node created is fired for each node constructor that is called + expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length); + for (let i = 0; i < graphData.nodes.length; i++) { + expect(mockExtension.nodeCreated.mock.calls[i][0].type).toBe(graphData.nodes[i].type); + } - // Each node then calls loadedGraphNode to allow them to be updated - expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length); - for (let i = 0; i < graphData.nodes.length; i++) { - expect(mockExtension.loadedGraphNode.mock.calls[i][0].type).toBe(graphData.nodes[i].type); - } + // Each node then calls loadedGraphNode to allow them to be updated + expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length); + for (let i = 0; i < graphData.nodes.length; i++) { + expect(mockExtension.loadedGraphNode.mock.calls[i][0].type).toBe(graphData.nodes[i].type); + } - // After configure is then called once all the setup is done - expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(1); + // After configure is then called once all the setup is done + expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(1); - expect(mockExtension.setup).toHaveBeenCalledTimes(1); - expect(mockExtension.setup).toHaveBeenCalledWith(app); + expect(mockExtension.setup).toHaveBeenCalledTimes(1); + expect(mockExtension.setup).toHaveBeenCalledWith(app); - // Ensure hooks are called in the correct order - const callOrder = [ - "init", - "addCustomNodeDefs", - "getCustomWidgets", - "beforeRegisterNodeDef", - "registerCustomNodes", - "beforeConfigureGraph", - "nodeCreated", - "loadedGraphNode", - "afterConfigureGraph", - "setup", - ]; - for (let i = 1; i < callOrder.length; i++) { - const fn1 = mockExtension[callOrder[i - 1]]; - const fn2 = mockExtension[callOrder[i]]; - expect(fn1.mock.invocationCallOrder[0]).toBeLessThan(fn2.mock.invocationCallOrder[0]); - } + // Ensure hooks are called in the correct order + const callOrder = [ + "init", + "addCustomNodeDefs", + "getCustomWidgets", + "beforeRegisterNodeDef", + "registerCustomNodes", + "beforeConfigureGraph", + "nodeCreated", + "loadedGraphNode", + "afterConfigureGraph", + "setup", + ]; + for (let i = 1; i < callOrder.length; i++) { + const fn1 = mockExtension[callOrder[i - 1]]; + const fn2 = mockExtension[callOrder[i]]; + expect(fn1.mock.invocationCallOrder[0]).toBeLessThan(fn2.mock.invocationCallOrder[0]); + } - graph.clear(); + graph.clear(); - // Ensure adding a new node calls the correct callback - ez.LoadImage(); - expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length); - expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 1); - expect(mockExtension.nodeCreated.mock.lastCall[0].type).toBe("LoadImage"); + // Ensure adding a new node calls the correct callback + ez.LoadImage(); + expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length); + expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 1); + expect(mockExtension.nodeCreated.mock.lastCall[0].type).toBe("LoadImage"); - // Reload the graph to ensure correct hooks are fired - await graph.reload(); + // Reload the graph to ensure correct hooks are fired + await graph.reload(); - // These hooks should not be fired again - expect(mockExtension.init).toHaveBeenCalledTimes(1); - expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1); - expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1); - expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1); - expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount); - expect(mockExtension.setup).toHaveBeenCalledTimes(1); + // These hooks should not be fired again + expect(mockExtension.init).toHaveBeenCalledTimes(1); + expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1); + expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1); + expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1); + expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount); + expect(mockExtension.setup).toHaveBeenCalledTimes(1); - // These should be called again - expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(2); - expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 2); - expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length + 1); - expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(2); - }, 15000); + // These should be called again + expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(2); + expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 2); + expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length + 1); + expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(2); + }, 15000); - it("allows custom nodeDefs and widgets to be registered", async () => { - const widgetMock = jest.fn((node, inputName, inputData, app) => { - expect(node.constructor.comfyClass).toBe("TestNode"); - expect(inputName).toBe("test_input"); - expect(inputData[0]).toBe("CUSTOMWIDGET"); - expect(inputData[1]?.hello).toBe("world"); - expect(app).toStrictEqual(app); + it("allows custom nodeDefs and widgets to be registered", async () => { + const widgetMock = jest.fn((node, inputName, inputData, app) => { + expect(node.constructor.comfyClass).toBe("TestNode"); + expect(inputName).toBe("test_input"); + expect(inputData[0]).toBe("CUSTOMWIDGET"); + expect(inputData[1]?.hello).toBe("world"); + expect(app).toStrictEqual(app); - return { - widget: node.addWidget("button", inputName, "hello", () => {}), - }; - }); + return { + widget: node.addWidget("button", inputName, "hello", () => {}), + }; + }); - // Register our extension that adds a custom node + widget type - const mockExtension = { - name: "TestExtension", - addCustomNodeDefs: (nodeDefs) => { - nodeDefs["TestNode"] = { - output: [], - output_name: [], - output_is_list: [], - name: "TestNode", - display_name: "TestNode", - category: "Test", - input: { - required: { - test_input: ["CUSTOMWIDGET", { hello: "world" }], - }, - }, - }; - }, - getCustomWidgets: jest.fn(() => { - return { - CUSTOMWIDGET: widgetMock, - }; - }), - }; + // Register our extension that adds a custom node + widget type + const mockExtension = { + name: "TestExtension", + addCustomNodeDefs: (nodeDefs) => { + nodeDefs["TestNode"] = { + output: [], + output_name: [], + output_is_list: [], + name: "TestNode", + display_name: "TestNode", + category: "Test", + input: { + required: { + test_input: ["CUSTOMWIDGET", { hello: "world" }], + }, + }, + }; + }, + getCustomWidgets: jest.fn(() => { + return { + CUSTOMWIDGET: widgetMock, + }; + }), + }; - const { graph, ez } = await start({ - async preSetup(app) { - app.registerExtension(mockExtension); - }, - }); + const { graph, ez } = await start({ + async preSetup(app) { + app.registerExtension(mockExtension); + }, + }); - expect(mockExtension.getCustomWidgets).toBeCalledTimes(1); + expect(mockExtension.getCustomWidgets).toBeCalledTimes(1); - graph.clear(); - expect(widgetMock).toBeCalledTimes(0); - const node = ez.TestNode(); - expect(widgetMock).toBeCalledTimes(1); + graph.clear(); + expect(widgetMock).toBeCalledTimes(0); + const node = ez.TestNode(); + expect(widgetMock).toBeCalledTimes(1); - // Ensure our custom widget is created - expect(node.inputs.length).toBe(0); - expect(node.widgets.length).toBe(1); - const w = node.widgets[0].widget; - expect(w.name).toBe("test_input"); - expect(w.type).toBe("button"); - }); + // Ensure our custom widget is created + expect(node.inputs.length).toBe(0); + expect(node.widgets.length).toBe(1); + const w = node.widgets[0].widget; + expect(w.name).toBe("test_input"); + expect(w.type).toBe("button"); + }); }); diff --git a/tests-ui/tests/groupNode.test.ts b/tests-ui/tests/groupNode.test.ts index f46de6149..c01b8e2b4 100644 --- a/tests-ui/tests/groupNode.test.ts +++ b/tests-ui/tests/groupNode.test.ts @@ -3,1002 +3,1002 @@ import { EzNode } from "../utils/ezgraph"; import lg from "../utils/litegraph"; describe("group node", () => { - beforeEach(() => { - lg.setup(global); - }); - - afterEach(() => { - lg.teardown(global); - }); - - /** - * - * @param {*} app - * @param {*} graph - * @param {*} name - * @param {*} nodes - * @returns { Promise> } - */ - async function convertToGroup(app, graph, name, nodes) { - // Select the nodes we are converting - for (const n of nodes) { - n.select(true); - } - - expect(Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b)).toEqual( - nodes.map((n) => n.id + "").sort((a, b) => +a - +b) - ); - - global.prompt = jest.fn().mockImplementation(() => name); - const groupNode = await nodes[0].menu["Convert to Group Node"].call(false); - - // Check group name was requested - expect(window.prompt).toHaveBeenCalled(); - - // Ensure old nodes are removed - for (const n of nodes) { - expect(n.isRemoved).toBeTruthy(); - } - - expect(groupNode.type).toEqual("workflow/" + name); - - return graph.find(groupNode); - } - - /** - * @param { Record | number[] } idMap - * @param { Record> } valueMap - */ - function getOutput(idMap = {}, valueMap = {}) { - if (idMap instanceof Array) { - idMap = idMap.reduce((p, n) => { - p[n] = n + ""; - return p; - }, {}); - } - const expected = { - 1: { inputs: { ckpt_name: "model1.safetensors", ...valueMap?.[1] }, class_type: "CheckpointLoaderSimple" }, - 2: { inputs: { text: "positive", clip: ["1", 1], ...valueMap?.[2] }, class_type: "CLIPTextEncode" }, - 3: { inputs: { text: "negative", clip: ["1", 1], ...valueMap?.[3] }, class_type: "CLIPTextEncode" }, - 4: { inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] }, class_type: "EmptyLatentImage" }, - 5: { - inputs: { - seed: 0, - steps: 20, - cfg: 8, - sampler_name: "euler", - scheduler: "normal", - denoise: 1, - model: ["1", 0], - positive: ["2", 0], - negative: ["3", 0], - latent_image: ["4", 0], - ...valueMap?.[5], - }, - class_type: "KSampler", - }, - 6: { inputs: { samples: ["5", 0], vae: ["1", 2], ...valueMap?.[6] }, class_type: "VAEDecode" }, - 7: { inputs: { filename_prefix: "ComfyUI", images: ["6", 0], ...valueMap?.[7] }, class_type: "SaveImage" }, - }; - - // Map old IDs to new at the top level - const mapped = {}; - for (const oldId in idMap) { - mapped[idMap[oldId]] = expected[oldId]; - delete expected[oldId]; - } - Object.assign(mapped, expected); - - // Map old IDs to new inside links - for (const k in mapped) { - for (const input in mapped[k].inputs) { - const v = mapped[k].inputs[input]; - if (v instanceof Array) { - if (v[0] in idMap) { - v[0] = idMap[v[0]] + ""; - } - } - } - } - - return mapped; - } - - test("can be created from selected nodes", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty]); - - // Ensure links are now to the group node - expect(group.inputs).toHaveLength(2); - expect(group.outputs).toHaveLength(3); - - expect(group.inputs.map((i) => i.input.name)).toEqual(["clip", "CLIPTextEncode clip"]); - expect(group.outputs.map((i) => i.output.name)).toEqual(["LATENT", "CONDITIONING", "CLIPTextEncode CONDITIONING"]); - - // ckpt clip to both clip inputs on the group - expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [group.id, 0], - [group.id, 1], - ]); - - // group conditioning to sampler - expect(group.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [nodes.sampler.id, 1], - ]); - // group conditioning 2 to sampler - expect( - group.outputs["CLIPTextEncode CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index]) - ).toEqual([[nodes.sampler.id, 2]]); - // group latent to sampler - expect(group.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [nodes.sampler.id, 3], - ]); - }); - - test("maintains all output links on conversion", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const save2 = ez.SaveImage(...nodes.decode.outputs); - const save3 = ez.SaveImage(...nodes.decode.outputs); - // Ensure an output with multiple links maintains them on convert to group - const group = await convertToGroup(app, graph, "test", [nodes.sampler, nodes.decode]); - expect(group.outputs[0].connections.length).toBe(3); - expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id); - expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id); - expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id); - - // and they're still linked when converting back to nodes - const newNodes = group.menu["Convert to nodes"].call(); - const decode = graph.find(newNodes.find((n) => n.type === "VAEDecode")); - expect(decode.outputs[0].connections.length).toBe(3); - expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id); - expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id); - expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id); - }); - test("can be be converted back to nodes", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler]; - const group = await convertToGroup(app, graph, "test", toConvert); - - // Edit some values to ensure they are set back onto the converted nodes - expect(group.widgets["text"].value).toBe("positive"); - group.widgets["text"].value = "pos"; - expect(group.widgets["CLIPTextEncode text"].value).toBe("negative"); - group.widgets["CLIPTextEncode text"].value = "neg"; - expect(group.widgets["width"].value).toBe(512); - group.widgets["width"].value = 1024; - expect(group.widgets["sampler_name"].value).toBe("euler"); - group.widgets["sampler_name"].value = "ddim"; - expect(group.widgets["control_after_generate"].value).toBe("randomize"); - group.widgets["control_after_generate"].value = "fixed"; - - /** @type { Array } */ - group.menu["Convert to nodes"].call(); - - // ensure widget values are set - const pos = graph.find(nodes.pos.id); - expect(pos.node.type).toBe("CLIPTextEncode"); - expect(pos.widgets["text"].value).toBe("pos"); - const neg = graph.find(nodes.neg.id); - expect(neg.node.type).toBe("CLIPTextEncode"); - expect(neg.widgets["text"].value).toBe("neg"); - const empty = graph.find(nodes.empty.id); - expect(empty.node.type).toBe("EmptyLatentImage"); - expect(empty.widgets["width"].value).toBe(1024); - const sampler = graph.find(nodes.sampler.id); - expect(sampler.node.type).toBe("KSampler"); - expect(sampler.widgets["sampler_name"].value).toBe("ddim"); - expect(sampler.widgets["control_after_generate"].value).toBe("fixed"); - - // validate links - expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [pos.id, 0], - [neg.id, 0], - ]); - - expect(pos.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [nodes.sampler.id, 1], - ]); - - expect(neg.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [nodes.sampler.id, 2], - ]); - - expect(empty.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ - [nodes.sampler.id, 3], - ]); - }); - test("it can embed reroutes as inputs", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - // Add and connect a reroute to the clip text encodes - const reroute = ez.Reroute(); - nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]); - reroute.outputs[0].connectTo(nodes.pos.inputs[0]); - reroute.outputs[0].connectTo(nodes.neg.inputs[0]); - - // Convert to group and ensure we only have 1 input of the correct type - const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty, reroute]); - expect(group.inputs).toHaveLength(1); - expect(group.inputs[0].input.type).toEqual("CLIP"); - - expect((await graph.toPrompt()).output).toEqual(getOutput()); - }); - test("it can embed reroutes as outputs", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - // Add a reroute with no output so we output IMAGE even though its used internally - const reroute = ez.Reroute(); - nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0]); - - // Convert to group and ensure there is an IMAGE output - const group = await convertToGroup(app, graph, "test", [nodes.decode, nodes.save, reroute]); - expect(group.outputs).toHaveLength(1); - expect(group.outputs[0].output.type).toEqual("IMAGE"); - expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.decode.id, nodes.save.id])); - }); - test("it can embed reroutes as pipes", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - // Use reroutes as a pipe - const rerouteModel = ez.Reroute(); - const rerouteClip = ez.Reroute(); - const rerouteVae = ez.Reroute(); - nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0]); - nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0]); - nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0]); - - const group = await convertToGroup(app, graph, "test", [rerouteModel, rerouteClip, rerouteVae]); - - expect(group.outputs).toHaveLength(3); - expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]); - - expect(group.outputs).toHaveLength(3); - expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]); - - group.outputs[0].connectTo(nodes.sampler.inputs.model); - group.outputs[1].connectTo(nodes.pos.inputs.clip); - group.outputs[1].connectTo(nodes.neg.inputs.clip); - }); - test("can handle reroutes used internally", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - let reroutes: EzNode[] = []; - let prevNode = nodes.ckpt; - for (let i = 0; i < 5; i++) { - const reroute = ez.Reroute(); - prevNode.outputs[0].connectTo(reroute.inputs[0]); - prevNode = reroute; - reroutes.push(reroute); - } - prevNode.outputs[0].connectTo(nodes.sampler.inputs.model); - - const group = await convertToGroup(app, graph, "test", [...reroutes, ...Object.values(nodes)]); - expect((await graph.toPrompt()).output).toEqual(getOutput()); - - group.menu["Convert to nodes"].call(); - expect((await graph.toPrompt()).output).toEqual(getOutput()); - }); - test("creates with widget values from inner nodes", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - nodes.ckpt.widgets.ckpt_name.value = "model2.ckpt"; - nodes.pos.widgets.text.value = "hello"; - nodes.neg.widgets.text.value = "world"; - nodes.empty.widgets.width.value = 256; - nodes.empty.widgets.height.value = 1024; - nodes.sampler.widgets.seed.value = 1; - nodes.sampler.widgets.control_after_generate.value = "increment"; - nodes.sampler.widgets.steps.value = 8; - nodes.sampler.widgets.cfg.value = 4.5; - nodes.sampler.widgets.sampler_name.value = "uni_pc"; - nodes.sampler.widgets.scheduler.value = "karras"; - nodes.sampler.widgets.denoise.value = 0.9; - - const group = await convertToGroup(app, graph, "test", [ - nodes.ckpt, - nodes.pos, - nodes.neg, - nodes.empty, - nodes.sampler, - ]); - - expect(group.widgets["ckpt_name"].value).toEqual("model2.ckpt"); - expect(group.widgets["text"].value).toEqual("hello"); - expect(group.widgets["CLIPTextEncode text"].value).toEqual("world"); - expect(group.widgets["width"].value).toEqual(256); - expect(group.widgets["height"].value).toEqual(1024); - expect(group.widgets["seed"].value).toEqual(1); - expect(group.widgets["control_after_generate"].value).toEqual("increment"); - expect(group.widgets["steps"].value).toEqual(8); - expect(group.widgets["cfg"].value).toEqual(4.5); - expect(group.widgets["sampler_name"].value).toEqual("uni_pc"); - expect(group.widgets["scheduler"].value).toEqual("karras"); - expect(group.widgets["denoise"].value).toEqual(0.9); - - expect((await graph.toPrompt()).output).toEqual( - getOutput([nodes.ckpt.id, nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id], { - [nodes.ckpt.id]: { ckpt_name: "model2.ckpt" }, - [nodes.pos.id]: { text: "hello" }, - [nodes.neg.id]: { text: "world" }, - [nodes.empty.id]: { width: 256, height: 1024 }, - [nodes.sampler.id]: { - seed: 1, - steps: 8, - cfg: 4.5, - sampler_name: "uni_pc", - scheduler: "karras", - denoise: 0.9, - }, - }) - ); - }); - test("group inputs can be reroutes", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); - - const reroute = ez.Reroute(); - nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]); - - reroute.outputs[0].connectTo(group.inputs[0]); - reroute.outputs[0].connectTo(group.inputs[1]); - - expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id])); - }); - test("group outputs can be reroutes", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); - - const reroute1 = ez.Reroute(); - const reroute2 = ez.Reroute(); - group.outputs[0].connectTo(reroute1.inputs[0]); - group.outputs[1].connectTo(reroute2.inputs[0]); - - reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive); - reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative); - - expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id])); - }); - test("groups can connect to each other", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const group1 = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); - const group2 = await convertToGroup(app, graph, "test2", [nodes.empty, nodes.sampler]); - - group1.outputs[0].connectTo(group2.inputs["positive"]); - group1.outputs[1].connectTo(group2.inputs["negative"]); - - expect((await graph.toPrompt()).output).toEqual( - getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id]) - ); - }); - test("groups can connect to each other via internal reroutes", async () => { - const { ez, graph, app } = await start(); - - const latent = ez.EmptyLatentImage(); - const vae = ez.VAELoader(); - const latentReroute = ez.Reroute(); - const vaeReroute = ez.Reroute(); - - latent.outputs[0].connectTo(latentReroute.inputs[0]); - vae.outputs[0].connectTo(vaeReroute.inputs[0]); - - const group1 = await convertToGroup(app, graph, "test", [latentReroute, vaeReroute]); - group1.menu.Clone.call(); - expect(app.graph._nodes).toHaveLength(4); - const group2 = graph.find(app.graph._nodes[3]); - expect(group2.node.type).toEqual("workflow/test"); - expect(group2.id).not.toEqual(group1.id); - - group1.outputs.VAE.connectTo(group2.inputs.VAE); - group1.outputs.LATENT.connectTo(group2.inputs.LATENT); - - const decode = ez.VAEDecode(group2.outputs.LATENT, group2.outputs.VAE); - const preview = ez.PreviewImage(decode.outputs[0]); - - const output = { - [latent.id]: { inputs: { width: 512, height: 512, batch_size: 1 }, class_type: "EmptyLatentImage" }, - [vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" }, - [decode.id]: { inputs: { samples: [latent.id + "", 0], vae: [vae.id + "", 0] }, class_type: "VAEDecode" }, - [preview.id]: { inputs: { images: [decode.id + "", 0] }, class_type: "PreviewImage" }, - }; - expect((await graph.toPrompt()).output).toEqual(output); - - // Ensure missing connections dont cause errors - group2.inputs.VAE.disconnect(); - delete output[decode.id].inputs.vae; - expect((await graph.toPrompt()).output).toEqual(output); - }); - test("displays generated image on group node", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - let group = await convertToGroup(app, graph, "test", [ - nodes.pos, - nodes.neg, - nodes.empty, - nodes.sampler, - nodes.decode, - nodes.save, - ]); - - const { api } = await import("../../src/scripts/api"); - - api.dispatchEvent(new CustomEvent("execution_start", {})); - api.dispatchEvent(new CustomEvent("executing", { detail: `${nodes.save.id}` })); - // Event should be forwarded to group node id - expect(+app.runningNodeId).toEqual(group.id); - expect(group.node["imgs"]).toBeFalsy(); - api.dispatchEvent( - new CustomEvent("executed", { - detail: { - node: `${nodes.save.id}`, - output: { - images: [ - { - filename: "test.png", - type: "output", - }, - ], - }, - }, - }) - ); - - // Trigger paint - group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas); - - expect(group.node["images"]).toEqual([ - { - filename: "test.png", - type: "output", - }, - ]); - - // Reload - const workflow = JSON.stringify((await graph.toPrompt()).workflow); - await app.loadGraphData(JSON.parse(workflow)); - group = graph.find(group); - - // Trigger inner nodes to get created - group.node["getInnerNodes"](); - - // Check it works for internal node ids - api.dispatchEvent(new CustomEvent("execution_start", {})); - api.dispatchEvent(new CustomEvent("executing", { detail: `${group.id}:5` })); - // Event should be forwarded to group node id - expect(+app.runningNodeId).toEqual(group.id); - expect(group.node["imgs"]).toBeFalsy(); - api.dispatchEvent( - new CustomEvent("executed", { - detail: { - node: `${group.id}:5`, - output: { - images: [ - { - filename: "test2.png", - type: "output", - }, - ], - }, - }, - }) - ); - - // Trigger paint - group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas); - - expect(group.node["images"]).toEqual([ - { - filename: "test2.png", - type: "output", - }, - ]); - }); - test("allows widgets to be converted to inputs", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); - group.widgets[0].convertToInput(); - - const primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(group.inputs["text"]); - primitive.widgets[0].value = "hello"; - - expect((await graph.toPrompt()).output).toEqual( - getOutput([nodes.pos.id, nodes.neg.id], { - [nodes.pos.id]: { text: "hello" }, - }) - ); - }); - test("can be copied", async () => { - const { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - const group1 = await convertToGroup(app, graph, "test", [ - nodes.pos, - nodes.neg, - nodes.empty, - nodes.sampler, - nodes.decode, - nodes.save, - ]); - - group1.widgets["text"].value = "hello"; - group1.widgets["width"].value = 256; - group1.widgets["seed"].value = 1; - - // Clone the node - group1.menu.Clone.call(); - expect(app.graph._nodes).toHaveLength(3); - const group2 = graph.find(app.graph._nodes[2]); - expect(group2.node.type).toEqual("workflow/test"); - expect(group2.id).not.toEqual(group1.id); - - // Reconnect ckpt - nodes.ckpt.outputs.MODEL.connectTo(group2.inputs["model"]); - nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["clip"]); - nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["CLIPTextEncode clip"]); - nodes.ckpt.outputs.VAE.connectTo(group2.inputs["vae"]); - - group2.widgets["text"].value = "world"; - group2.widgets["width"].value = 1024; - group2.widgets["seed"].value = 100; - - let i = 0; - expect((await graph.toPrompt()).output).toEqual({ - ...getOutput([nodes.empty.id, nodes.pos.id, nodes.neg.id, nodes.sampler.id, nodes.decode.id, nodes.save.id], { - [nodes.empty.id]: { width: 256 }, - [nodes.pos.id]: { text: "hello" }, - [nodes.sampler.id]: { seed: 1 }, - }), - ...getOutput( - { - [nodes.empty.id]: `${group2.id}:${i++}`, - [nodes.pos.id]: `${group2.id}:${i++}`, - [nodes.neg.id]: `${group2.id}:${i++}`, - [nodes.sampler.id]: `${group2.id}:${i++}`, - [nodes.decode.id]: `${group2.id}:${i++}`, - [nodes.save.id]: `${group2.id}:${i++}`, - }, - { - [nodes.empty.id]: { width: 1024 }, - [nodes.pos.id]: { text: "world" }, - [nodes.sampler.id]: { seed: 100 }, - } - ), - }); - - graph.arrange(); - }); - test("is embedded in workflow", async () => { - let { ez, graph, app } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - let group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); - const workflow = JSON.stringify((await graph.toPrompt()).workflow); - - // Clear the environment - ({ ez, graph, app } = await start({ - resetEnv: true, - })); - // Ensure the node isnt registered - expect(() => ez["workflow/test"]).toThrow(); - - // Reload the workflow - await app.loadGraphData(JSON.parse(workflow)); - - // Ensure the node is found - group = graph.find(group); - - // Generate prompt and ensure it is as expected - expect((await graph.toPrompt()).output).toEqual( - getOutput({ - [nodes.pos.id]: `${group.id}:0`, - [nodes.neg.id]: `${group.id}:1`, - }) - ); - }); - test("shows missing node error on missing internal node when loading graph data", async () => { - const { graph } = await start(); - - const dialogShow = jest.spyOn(graph.app.ui.dialog, "show"); - await graph.app.loadGraphData({ - last_node_id: 3, - last_link_id: 1, - nodes: [ - { - id: 3, - type: "workflow/testerror", - }, - ], - links: [], - groups: [], - config: {}, - extra: { - groupNodes: { - testerror: { - nodes: [ - { - type: "NotKSampler", - }, - { - type: "NotVAEDecode", - }, - ], - }, - }, - }, - }); - - expect(dialogShow).toBeCalledTimes(1); - // @ts-ignore - const call = dialogShow.mock.calls[0][0].innerHTML; - expect(call).toContain("the following node types were not found"); - expect(call).toContain("NotKSampler"); - expect(call).toContain("NotVAEDecode"); - expect(call).toContain("workflow/testerror"); - }); - test("maintains widget inputs on conversion back to nodes", async () => { - const { ez, graph, app } = await start(); - let pos = ez.CLIPTextEncode({ text: "positive" }); - pos.node.title = "Positive"; - let neg = ez.CLIPTextEncode({ text: "negative" }); - neg.node.title = "Negative"; - pos.widgets.text.convertToInput(); - neg.widgets.text.convertToInput(); - - let primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(pos.inputs.text); - primitive.outputs[0].connectTo(neg.inputs.text); - - const group = await convertToGroup(app, graph, "test", [pos, neg, primitive]); - // This will use a primitive widget named 'value' - expect(group.widgets.length).toBe(1); - expect(group.widgets["value"].value).toBe("positive"); - - const newNodes = group.menu["Convert to nodes"].call(); - pos = graph.find(newNodes.find((n) => n.title === "Positive")); - neg = graph.find(newNodes.find((n) => n.title === "Negative")); - primitive = graph.find(newNodes.find((n) => n.type === "PrimitiveNode")); - - expect(pos.inputs).toHaveLength(2); - expect(neg.inputs).toHaveLength(2); - expect(primitive.outputs[0].connections).toHaveLength(2); - - expect((await graph.toPrompt()).output).toEqual({ - 1: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" }, - 2: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" }, - }); - }); - test("correctly handles widget inputs", async () => { - const { ez, graph, app } = await start(); - const upscaleMethods = (await getNodeDef("ImageScaleBy")).input.required["upscale_method"][0]; - - const image = ez.LoadImage(); - const scale1 = ez.ImageScaleBy(image.outputs[0]); - const scale2 = ez.ImageScaleBy(image.outputs[0]); - const preview1 = ez.PreviewImage(scale1.outputs[0]); - const preview2 = ez.PreviewImage(scale2.outputs[0]); - scale1.widgets.upscale_method.value = upscaleMethods[1]; - scale1.widgets.upscale_method.convertToInput(); - - const group = await convertToGroup(app, graph, "test", [scale1, scale2]); - expect(group.inputs.length).toBe(3); - expect(group.inputs[0].input.type).toBe("IMAGE"); - expect(group.inputs[1].input.type).toBe("IMAGE"); - expect(group.inputs[2].input.type).toBe("COMBO"); - - // Ensure links are maintained - expect(group.inputs[0].connection?.originNode?.id).toBe(image.id); - expect(group.inputs[1].connection?.originNode?.id).toBe(image.id); - expect(group.inputs[2].connection).toBeFalsy(); - - // Ensure primitive gets correct type - const primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(group.inputs[2]); - expect(primitive.widgets.value.widget.options.values).toBe(upscaleMethods); - expect(primitive.widgets.value.value).toBe(upscaleMethods[1]); // Ensure value is copied - primitive.widgets.value.value = upscaleMethods[1]; - - await checkBeforeAndAfterReload(graph, async (r) => { - const scale1id = r ? `${group.id}:0` : scale1.id; - const scale2id = r ? `${group.id}:1` : scale2.id; - // Ensure widget value is applied to prompt - expect((await graph.toPrompt()).output).toStrictEqual({ - [image.id]: { inputs: { image: "example.png", upload: "image" }, class_type: "LoadImage" }, - [scale1id]: { - inputs: { upscale_method: upscaleMethods[1], scale_by: 1, image: [`${image.id}`, 0] }, - class_type: "ImageScaleBy", - }, - [scale2id]: { - inputs: { upscale_method: "nearest-exact", scale_by: 1, image: [`${image.id}`, 0] }, - class_type: "ImageScaleBy", - }, - [preview1.id]: { inputs: { images: [`${scale1id}`, 0] }, class_type: "PreviewImage" }, - [preview2.id]: { inputs: { images: [`${scale2id}`, 0] }, class_type: "PreviewImage" }, - }); - }); - }); - test("adds widgets in node execution order", async () => { - const { ez, graph, app } = await start(); - const scale = ez.LatentUpscale(); - const save = ez.SaveImage(); - const empty = ez.EmptyLatentImage(); - const decode = ez.VAEDecode(); - - scale.outputs.LATENT.connectTo(decode.inputs.samples); - decode.outputs.IMAGE.connectTo(save.inputs.images); - empty.outputs.LATENT.connectTo(scale.inputs.samples); - - const group = await convertToGroup(app, graph, "test", [scale, save, empty, decode]); - const widgets = group.widgets.map((w) => w.widget.name); - expect(widgets).toStrictEqual([ - "width", - "height", - "batch_size", - "upscale_method", - "LatentUpscale width", - "LatentUpscale height", - "crop", - "filename_prefix", - ]); - }); - test("adds output for external links when converting to group", async () => { - const { ez, graph, app } = await start(); - const img = ez.EmptyLatentImage(); - let decode = ez.VAEDecode(...img.outputs); - const preview1 = ez.PreviewImage(...decode.outputs); - const preview2 = ez.PreviewImage(...decode.outputs); - - const group = await convertToGroup(app, graph, "test", [img, decode, preview1]); - - // Ensure we have an output connected to the 2nd preview node - expect(group.outputs.length).toBe(1); - expect(group.outputs[0].connections.length).toBe(1); - expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id); - - // Convert back and ensure bothe previews are still connected - group.menu["Convert to nodes"].call(); - decode = graph.find(decode); - expect(decode.outputs[0].connections.length).toBe(2); - expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id); - expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id); - }); - test("adds output for external links when converting to group when nodes are not in execution order", async () => { - const { ez, graph, app } = await start(); - const sampler = ez.KSampler(); - const ckpt = ez.CheckpointLoaderSimple(); - const empty = ez.EmptyLatentImage(); - const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" }); - const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" }); - const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE); - const save = ez.SaveImage(decode1.outputs.IMAGE); - ckpt.outputs.MODEL.connectTo(sampler.inputs.model); - pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive); - neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative); - empty.outputs.LATENT.connectTo(sampler.inputs.latent_image); - - const encode = ez.VAEEncode(decode1.outputs.IMAGE); - const vae = ez.VAELoader(); - const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE); - const preview = ez.PreviewImage(decode2.outputs.IMAGE); - vae.outputs.VAE.connectTo(encode.inputs.vae); - - const group = await convertToGroup(app, graph, "test", [vae, decode1, encode, sampler]); - - expect(group.outputs.length).toBe(3); - expect(group.outputs[0].output.name).toBe("VAE"); - expect(group.outputs[0].output.type).toBe("VAE"); - expect(group.outputs[1].output.name).toBe("IMAGE"); - expect(group.outputs[1].output.type).toBe("IMAGE"); - expect(group.outputs[2].output.name).toBe("LATENT"); - expect(group.outputs[2].output.type).toBe("LATENT"); - - expect(group.outputs[0].connections.length).toBe(1); - expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id); - expect(group.outputs[0].connections[0].targetInput.index).toBe(1); - - expect(group.outputs[1].connections.length).toBe(1); - expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id); - expect(group.outputs[1].connections[0].targetInput.index).toBe(0); - - expect(group.outputs[2].connections.length).toBe(1); - expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id); - expect(group.outputs[2].connections[0].targetInput.index).toBe(0); - - expect((await graph.toPrompt()).output).toEqual({ - ...getOutput({ 1: ckpt.id, 2: pos.id, 3: neg.id, 4: empty.id, 5: sampler.id, 6: decode1.id, 7: save.id }), - [vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: vae.node.type }, - [encode.id]: { inputs: { pixels: ["6", 0], vae: [vae.id + "", 0] }, class_type: encode.node.type }, - [decode2.id]: { inputs: { samples: [encode.id + "", 0], vae: [vae.id + "", 0] }, class_type: decode2.node.type }, - [preview.id]: { inputs: { images: [decode2.id + "", 0] }, class_type: preview.node.type }, - }); - }); - test("works with IMAGEUPLOAD widget", async () => { - const { ez, graph, app } = await start(); - const img = ez.LoadImage(); - const preview1 = ez.PreviewImage(img.outputs[0]); - - const group = await convertToGroup(app, graph, "test", [img, preview1]); - const widget = group.widgets["upload"]; - expect(widget).toBeTruthy(); - expect(widget.widget.type).toBe("button"); - }); - test("internal primitive populates widgets for all linked inputs", async () => { - const { ez, graph, app } = await start(); - const img = ez.LoadImage(); - const scale1 = ez.ImageScale(img.outputs[0]); - const scale2 = ez.ImageScale(img.outputs[0]); - ez.PreviewImage(scale1.outputs[0]); - ez.PreviewImage(scale2.outputs[0]); - - scale1.widgets.width.convertToInput(); - scale2.widgets.height.convertToInput(); - - const primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(scale1.inputs.width); - primitive.outputs[0].connectTo(scale2.inputs.height); - - const group = await convertToGroup(app, graph, "test", [img, primitive, scale1, scale2]); - group.widgets.value.value = 100; - expect((await graph.toPrompt()).output).toEqual({ - 1: { - inputs: { image: img.widgets.image.value, upload: "image" }, - class_type: "LoadImage", - }, - 2: { - inputs: { upscale_method: "nearest-exact", width: 100, height: 512, crop: "disabled", image: ["1", 0] }, - class_type: "ImageScale", - }, - 3: { - inputs: { upscale_method: "nearest-exact", width: 512, height: 100, crop: "disabled", image: ["1", 0] }, - class_type: "ImageScale", - }, - 4: { inputs: { images: ["2", 0] }, class_type: "PreviewImage" }, - 5: { inputs: { images: ["3", 0] }, class_type: "PreviewImage" }, - }); - }); - test("primitive control widgets values are copied on convert", async () => { - const { ez, graph, app } = await start(); - const sampler = ez.KSampler(); - sampler.widgets.seed.convertToInput(); - sampler.widgets.sampler_name.convertToInput(); - - let p1 = ez.PrimitiveNode(); - let p2 = ez.PrimitiveNode(); - p1.outputs[0].connectTo(sampler.inputs.seed); - p2.outputs[0].connectTo(sampler.inputs.sampler_name); - - p1.widgets.control_after_generate.value = "increment"; - p2.widgets.control_after_generate.value = "decrement"; - p2.widgets.control_filter_list.value = "/.*/"; - - p2.node.title = "p2"; - - const group = await convertToGroup(app, graph, "test", [sampler, p1, p2]); - expect(group.widgets.control_after_generate.value).toBe("increment"); - expect(group.widgets["p2 control_after_generate"].value).toBe("decrement"); - expect(group.widgets["p2 control_filter_list"].value).toBe("/.*/"); - - group.widgets.control_after_generate.value = "fixed"; - group.widgets["p2 control_after_generate"].value = "randomize"; - group.widgets["p2 control_filter_list"].value = "/.+/"; - - group.menu["Convert to nodes"].call(); - p1 = graph.find(p1); - p2 = graph.find(p2); - - expect(p1.widgets.control_after_generate.value).toBe("fixed"); - expect(p2.widgets.control_after_generate.value).toBe("randomize"); - expect(p2.widgets.control_filter_list.value).toBe("/.+/"); - }); - test("internal reroutes work with converted inputs and merge options", async () => { - const { ez, graph, app } = await start(); - const vae = ez.VAELoader(); - const latent = ez.EmptyLatentImage(); - const decode = ez.VAEDecode(latent.outputs.LATENT, vae.outputs.VAE); - const scale = ez.ImageScale(decode.outputs.IMAGE); - ez.PreviewImage(scale.outputs.IMAGE); - - const r1 = ez.Reroute(); - const r2 = ez.Reroute(); - - latent.widgets.width.value = 64; - latent.widgets.height.value = 128; - - latent.widgets.width.convertToInput(); - latent.widgets.height.convertToInput(); - latent.widgets.batch_size.convertToInput(); - - scale.widgets.width.convertToInput(); - scale.widgets.height.convertToInput(); - - r1.inputs[0].input.label = "hbw"; - r1.outputs[0].connectTo(latent.inputs.height); - r1.outputs[0].connectTo(latent.inputs.batch_size); - r1.outputs[0].connectTo(scale.inputs.width); - - r2.inputs[0].input.label = "wh"; - r2.outputs[0].connectTo(latent.inputs.width); - r2.outputs[0].connectTo(scale.inputs.height); - - const group = await convertToGroup(app, graph, "test", [r1, r2, latent, decode, scale]); - - expect(group.inputs[0].input.type).toBe("VAE"); - expect(group.inputs[1].input.type).toBe("INT"); - expect(group.inputs[2].input.type).toBe("INT"); - - const p1 = ez.PrimitiveNode(); - const p2 = ez.PrimitiveNode(); - p1.outputs[0].connectTo(group.inputs[1]); - p2.outputs[0].connectTo(group.inputs[2]); - - expect(p1.widgets.value.widget.options?.min).toBe(16); // width/height min - expect(p1.widgets.value.widget.options?.max).toBe(4096); // batch max - expect(p1.widgets.value.widget.options?.step).toBe(80); // width/height step * 10 - - expect(p2.widgets.value.widget.options?.min).toBe(16); // width/height min - expect(p2.widgets.value.widget.options?.max).toBe(16384); // width/height max - expect(p2.widgets.value.widget.options?.step).toBe(80); // width/height step * 10 - - expect(p1.widgets.value.value).toBe(128); - expect(p2.widgets.value.value).toBe(64); - - p1.widgets.value.value = 16; - p2.widgets.value.value = 32; - - await checkBeforeAndAfterReload(graph, async (r) => { - const id = (v) => (r ? `${group.id}:` : "") + v; - expect((await graph.toPrompt()).output).toStrictEqual({ - 1: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" }, - [id(2)]: { inputs: { width: 32, height: 16, batch_size: 16 }, class_type: "EmptyLatentImage" }, - [id(3)]: { inputs: { samples: [id(2), 0], vae: ["1", 0] }, class_type: "VAEDecode" }, - [id(4)]: { - inputs: { upscale_method: "nearest-exact", width: 16, height: 32, crop: "disabled", image: [id(3), 0] }, - class_type: "ImageScale", - }, - 5: { inputs: { images: [id(4), 0] }, class_type: "PreviewImage" }, - }); - }); - }); - test("converted inputs with linked widgets map values correctly on creation", async () => { - const { ez, graph, app } = await start(); - const k1 = ez.KSampler(); - const k2 = ez.KSampler(); - k1.widgets.seed.convertToInput(); - k2.widgets.seed.convertToInput(); - - const rr = ez.Reroute(); - rr.outputs[0].connectTo(k1.inputs.seed); - rr.outputs[0].connectTo(k2.inputs.seed); - - const group = await convertToGroup(app, graph, "test", [k1, k2, rr]); - expect(group.widgets.steps.value).toBe(20); - expect(group.widgets.cfg.value).toBe(8); - expect(group.widgets.scheduler.value).toBe("normal"); - expect(group.widgets["KSampler steps"].value).toBe(20); - expect(group.widgets["KSampler cfg"].value).toBe(8); - expect(group.widgets["KSampler scheduler"].value).toBe("normal"); - }); - test("allow multiple of the same node type to be added", async () => { - const { ez, graph, app } = await start(); - const nodes = [...Array(10)].map(() => ez.ImageScaleBy()); - const group = await convertToGroup(app, graph, "test", nodes); - expect(group.inputs.length).toBe(10); - expect(group.outputs.length).toBe(10); - expect(group.widgets.length).toBe(20); - expect(group.widgets.map((w) => w.widget.name)).toStrictEqual( - [...Array(10)] - .map((_, i) => `${i > 0 ? "ImageScaleBy " : ""}${i > 1 ? i + " " : ""}`) - .flatMap((p) => [`${p}upscale_method`, `${p}scale_by`]) - ); - }); + beforeEach(() => { + lg.setup(global); + }); + + afterEach(() => { + lg.teardown(global); + }); + + /** + * + * @param {*} app + * @param {*} graph + * @param {*} name + * @param {*} nodes + * @returns { Promise> } + */ + async function convertToGroup(app, graph, name, nodes) { + // Select the nodes we are converting + for (const n of nodes) { + n.select(true); + } + + expect(Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b)).toEqual( + nodes.map((n) => n.id + "").sort((a, b) => +a - +b) + ); + + global.prompt = jest.fn().mockImplementation(() => name); + const groupNode = await nodes[0].menu["Convert to Group Node"].call(false); + + // Check group name was requested + expect(window.prompt).toHaveBeenCalled(); + + // Ensure old nodes are removed + for (const n of nodes) { + expect(n.isRemoved).toBeTruthy(); + } + + expect(groupNode.type).toEqual("workflow/" + name); + + return graph.find(groupNode); + } + + /** + * @param { Record | number[] } idMap + * @param { Record> } valueMap + */ + function getOutput(idMap = {}, valueMap = {}) { + if (idMap instanceof Array) { + idMap = idMap.reduce((p, n) => { + p[n] = n + ""; + return p; + }, {}); + } + const expected = { + 1: { inputs: { ckpt_name: "model1.safetensors", ...valueMap?.[1] }, class_type: "CheckpointLoaderSimple" }, + 2: { inputs: { text: "positive", clip: ["1", 1], ...valueMap?.[2] }, class_type: "CLIPTextEncode" }, + 3: { inputs: { text: "negative", clip: ["1", 1], ...valueMap?.[3] }, class_type: "CLIPTextEncode" }, + 4: { inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] }, class_type: "EmptyLatentImage" }, + 5: { + inputs: { + seed: 0, + steps: 20, + cfg: 8, + sampler_name: "euler", + scheduler: "normal", + denoise: 1, + model: ["1", 0], + positive: ["2", 0], + negative: ["3", 0], + latent_image: ["4", 0], + ...valueMap?.[5], + }, + class_type: "KSampler", + }, + 6: { inputs: { samples: ["5", 0], vae: ["1", 2], ...valueMap?.[6] }, class_type: "VAEDecode" }, + 7: { inputs: { filename_prefix: "ComfyUI", images: ["6", 0], ...valueMap?.[7] }, class_type: "SaveImage" }, + }; + + // Map old IDs to new at the top level + const mapped = {}; + for (const oldId in idMap) { + mapped[idMap[oldId]] = expected[oldId]; + delete expected[oldId]; + } + Object.assign(mapped, expected); + + // Map old IDs to new inside links + for (const k in mapped) { + for (const input in mapped[k].inputs) { + const v = mapped[k].inputs[input]; + if (v instanceof Array) { + if (v[0] in idMap) { + v[0] = idMap[v[0]] + ""; + } + } + } + } + + return mapped; + } + + test("can be created from selected nodes", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty]); + + // Ensure links are now to the group node + expect(group.inputs).toHaveLength(2); + expect(group.outputs).toHaveLength(3); + + expect(group.inputs.map((i) => i.input.name)).toEqual(["clip", "CLIPTextEncode clip"]); + expect(group.outputs.map((i) => i.output.name)).toEqual(["LATENT", "CONDITIONING", "CLIPTextEncode CONDITIONING"]); + + // ckpt clip to both clip inputs on the group + expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [group.id, 0], + [group.id, 1], + ]); + + // group conditioning to sampler + expect(group.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [nodes.sampler.id, 1], + ]); + // group conditioning 2 to sampler + expect( + group.outputs["CLIPTextEncode CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index]) + ).toEqual([[nodes.sampler.id, 2]]); + // group latent to sampler + expect(group.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [nodes.sampler.id, 3], + ]); + }); + + test("maintains all output links on conversion", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const save2 = ez.SaveImage(...nodes.decode.outputs); + const save3 = ez.SaveImage(...nodes.decode.outputs); + // Ensure an output with multiple links maintains them on convert to group + const group = await convertToGroup(app, graph, "test", [nodes.sampler, nodes.decode]); + expect(group.outputs[0].connections.length).toBe(3); + expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id); + expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id); + expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id); + + // and they're still linked when converting back to nodes + const newNodes = group.menu["Convert to nodes"].call(); + const decode = graph.find(newNodes.find((n) => n.type === "VAEDecode")); + expect(decode.outputs[0].connections.length).toBe(3); + expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id); + expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id); + expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id); + }); + test("can be be converted back to nodes", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler]; + const group = await convertToGroup(app, graph, "test", toConvert); + + // Edit some values to ensure they are set back onto the converted nodes + expect(group.widgets["text"].value).toBe("positive"); + group.widgets["text"].value = "pos"; + expect(group.widgets["CLIPTextEncode text"].value).toBe("negative"); + group.widgets["CLIPTextEncode text"].value = "neg"; + expect(group.widgets["width"].value).toBe(512); + group.widgets["width"].value = 1024; + expect(group.widgets["sampler_name"].value).toBe("euler"); + group.widgets["sampler_name"].value = "ddim"; + expect(group.widgets["control_after_generate"].value).toBe("randomize"); + group.widgets["control_after_generate"].value = "fixed"; + + /** @type { Array } */ + group.menu["Convert to nodes"].call(); + + // ensure widget values are set + const pos = graph.find(nodes.pos.id); + expect(pos.node.type).toBe("CLIPTextEncode"); + expect(pos.widgets["text"].value).toBe("pos"); + const neg = graph.find(nodes.neg.id); + expect(neg.node.type).toBe("CLIPTextEncode"); + expect(neg.widgets["text"].value).toBe("neg"); + const empty = graph.find(nodes.empty.id); + expect(empty.node.type).toBe("EmptyLatentImage"); + expect(empty.widgets["width"].value).toBe(1024); + const sampler = graph.find(nodes.sampler.id); + expect(sampler.node.type).toBe("KSampler"); + expect(sampler.widgets["sampler_name"].value).toBe("ddim"); + expect(sampler.widgets["control_after_generate"].value).toBe("fixed"); + + // validate links + expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [pos.id, 0], + [neg.id, 0], + ]); + + expect(pos.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [nodes.sampler.id, 1], + ]); + + expect(neg.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [nodes.sampler.id, 2], + ]); + + expect(empty.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([ + [nodes.sampler.id, 3], + ]); + }); + test("it can embed reroutes as inputs", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + // Add and connect a reroute to the clip text encodes + const reroute = ez.Reroute(); + nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]); + reroute.outputs[0].connectTo(nodes.pos.inputs[0]); + reroute.outputs[0].connectTo(nodes.neg.inputs[0]); + + // Convert to group and ensure we only have 1 input of the correct type + const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty, reroute]); + expect(group.inputs).toHaveLength(1); + expect(group.inputs[0].input.type).toEqual("CLIP"); + + expect((await graph.toPrompt()).output).toEqual(getOutput()); + }); + test("it can embed reroutes as outputs", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + // Add a reroute with no output so we output IMAGE even though its used internally + const reroute = ez.Reroute(); + nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0]); + + // Convert to group and ensure there is an IMAGE output + const group = await convertToGroup(app, graph, "test", [nodes.decode, nodes.save, reroute]); + expect(group.outputs).toHaveLength(1); + expect(group.outputs[0].output.type).toEqual("IMAGE"); + expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.decode.id, nodes.save.id])); + }); + test("it can embed reroutes as pipes", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + // Use reroutes as a pipe + const rerouteModel = ez.Reroute(); + const rerouteClip = ez.Reroute(); + const rerouteVae = ez.Reroute(); + nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0]); + nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0]); + nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0]); + + const group = await convertToGroup(app, graph, "test", [rerouteModel, rerouteClip, rerouteVae]); + + expect(group.outputs).toHaveLength(3); + expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]); + + expect(group.outputs).toHaveLength(3); + expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]); + + group.outputs[0].connectTo(nodes.sampler.inputs.model); + group.outputs[1].connectTo(nodes.pos.inputs.clip); + group.outputs[1].connectTo(nodes.neg.inputs.clip); + }); + test("can handle reroutes used internally", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + let reroutes: EzNode[] = []; + let prevNode = nodes.ckpt; + for (let i = 0; i < 5; i++) { + const reroute = ez.Reroute(); + prevNode.outputs[0].connectTo(reroute.inputs[0]); + prevNode = reroute; + reroutes.push(reroute); + } + prevNode.outputs[0].connectTo(nodes.sampler.inputs.model); + + const group = await convertToGroup(app, graph, "test", [...reroutes, ...Object.values(nodes)]); + expect((await graph.toPrompt()).output).toEqual(getOutput()); + + group.menu["Convert to nodes"].call(); + expect((await graph.toPrompt()).output).toEqual(getOutput()); + }); + test("creates with widget values from inner nodes", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + nodes.ckpt.widgets.ckpt_name.value = "model2.ckpt"; + nodes.pos.widgets.text.value = "hello"; + nodes.neg.widgets.text.value = "world"; + nodes.empty.widgets.width.value = 256; + nodes.empty.widgets.height.value = 1024; + nodes.sampler.widgets.seed.value = 1; + nodes.sampler.widgets.control_after_generate.value = "increment"; + nodes.sampler.widgets.steps.value = 8; + nodes.sampler.widgets.cfg.value = 4.5; + nodes.sampler.widgets.sampler_name.value = "uni_pc"; + nodes.sampler.widgets.scheduler.value = "karras"; + nodes.sampler.widgets.denoise.value = 0.9; + + const group = await convertToGroup(app, graph, "test", [ + nodes.ckpt, + nodes.pos, + nodes.neg, + nodes.empty, + nodes.sampler, + ]); + + expect(group.widgets["ckpt_name"].value).toEqual("model2.ckpt"); + expect(group.widgets["text"].value).toEqual("hello"); + expect(group.widgets["CLIPTextEncode text"].value).toEqual("world"); + expect(group.widgets["width"].value).toEqual(256); + expect(group.widgets["height"].value).toEqual(1024); + expect(group.widgets["seed"].value).toEqual(1); + expect(group.widgets["control_after_generate"].value).toEqual("increment"); + expect(group.widgets["steps"].value).toEqual(8); + expect(group.widgets["cfg"].value).toEqual(4.5); + expect(group.widgets["sampler_name"].value).toEqual("uni_pc"); + expect(group.widgets["scheduler"].value).toEqual("karras"); + expect(group.widgets["denoise"].value).toEqual(0.9); + + expect((await graph.toPrompt()).output).toEqual( + getOutput([nodes.ckpt.id, nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id], { + [nodes.ckpt.id]: { ckpt_name: "model2.ckpt" }, + [nodes.pos.id]: { text: "hello" }, + [nodes.neg.id]: { text: "world" }, + [nodes.empty.id]: { width: 256, height: 1024 }, + [nodes.sampler.id]: { + seed: 1, + steps: 8, + cfg: 4.5, + sampler_name: "uni_pc", + scheduler: "karras", + denoise: 0.9, + }, + }) + ); + }); + test("group inputs can be reroutes", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); + + const reroute = ez.Reroute(); + nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]); + + reroute.outputs[0].connectTo(group.inputs[0]); + reroute.outputs[0].connectTo(group.inputs[1]); + + expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id])); + }); + test("group outputs can be reroutes", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); + + const reroute1 = ez.Reroute(); + const reroute2 = ez.Reroute(); + group.outputs[0].connectTo(reroute1.inputs[0]); + group.outputs[1].connectTo(reroute2.inputs[0]); + + reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive); + reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative); + + expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id])); + }); + test("groups can connect to each other", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const group1 = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); + const group2 = await convertToGroup(app, graph, "test2", [nodes.empty, nodes.sampler]); + + group1.outputs[0].connectTo(group2.inputs["positive"]); + group1.outputs[1].connectTo(group2.inputs["negative"]); + + expect((await graph.toPrompt()).output).toEqual( + getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id]) + ); + }); + test("groups can connect to each other via internal reroutes", async () => { + const { ez, graph, app } = await start(); + + const latent = ez.EmptyLatentImage(); + const vae = ez.VAELoader(); + const latentReroute = ez.Reroute(); + const vaeReroute = ez.Reroute(); + + latent.outputs[0].connectTo(latentReroute.inputs[0]); + vae.outputs[0].connectTo(vaeReroute.inputs[0]); + + const group1 = await convertToGroup(app, graph, "test", [latentReroute, vaeReroute]); + group1.menu.Clone.call(); + expect(app.graph._nodes).toHaveLength(4); + const group2 = graph.find(app.graph._nodes[3]); + expect(group2.node.type).toEqual("workflow/test"); + expect(group2.id).not.toEqual(group1.id); + + group1.outputs.VAE.connectTo(group2.inputs.VAE); + group1.outputs.LATENT.connectTo(group2.inputs.LATENT); + + const decode = ez.VAEDecode(group2.outputs.LATENT, group2.outputs.VAE); + const preview = ez.PreviewImage(decode.outputs[0]); + + const output = { + [latent.id]: { inputs: { width: 512, height: 512, batch_size: 1 }, class_type: "EmptyLatentImage" }, + [vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" }, + [decode.id]: { inputs: { samples: [latent.id + "", 0], vae: [vae.id + "", 0] }, class_type: "VAEDecode" }, + [preview.id]: { inputs: { images: [decode.id + "", 0] }, class_type: "PreviewImage" }, + }; + expect((await graph.toPrompt()).output).toEqual(output); + + // Ensure missing connections dont cause errors + group2.inputs.VAE.disconnect(); + delete output[decode.id].inputs.vae; + expect((await graph.toPrompt()).output).toEqual(output); + }); + test("displays generated image on group node", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + let group = await convertToGroup(app, graph, "test", [ + nodes.pos, + nodes.neg, + nodes.empty, + nodes.sampler, + nodes.decode, + nodes.save, + ]); + + const { api } = await import("../../src/scripts/api"); + + api.dispatchEvent(new CustomEvent("execution_start", {})); + api.dispatchEvent(new CustomEvent("executing", { detail: `${nodes.save.id}` })); + // Event should be forwarded to group node id + expect(+app.runningNodeId).toEqual(group.id); + expect(group.node["imgs"]).toBeFalsy(); + api.dispatchEvent( + new CustomEvent("executed", { + detail: { + node: `${nodes.save.id}`, + output: { + images: [ + { + filename: "test.png", + type: "output", + }, + ], + }, + }, + }) + ); + + // Trigger paint + group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas); + + expect(group.node["images"]).toEqual([ + { + filename: "test.png", + type: "output", + }, + ]); + + // Reload + const workflow = JSON.stringify((await graph.toPrompt()).workflow); + await app.loadGraphData(JSON.parse(workflow)); + group = graph.find(group); + + // Trigger inner nodes to get created + group.node["getInnerNodes"](); + + // Check it works for internal node ids + api.dispatchEvent(new CustomEvent("execution_start", {})); + api.dispatchEvent(new CustomEvent("executing", { detail: `${group.id}:5` })); + // Event should be forwarded to group node id + expect(+app.runningNodeId).toEqual(group.id); + expect(group.node["imgs"]).toBeFalsy(); + api.dispatchEvent( + new CustomEvent("executed", { + detail: { + node: `${group.id}:5`, + output: { + images: [ + { + filename: "test2.png", + type: "output", + }, + ], + }, + }, + }) + ); + + // Trigger paint + group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas); + + expect(group.node["images"]).toEqual([ + { + filename: "test2.png", + type: "output", + }, + ]); + }); + test("allows widgets to be converted to inputs", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); + group.widgets[0].convertToInput(); + + const primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(group.inputs["text"]); + primitive.widgets[0].value = "hello"; + + expect((await graph.toPrompt()).output).toEqual( + getOutput([nodes.pos.id, nodes.neg.id], { + [nodes.pos.id]: { text: "hello" }, + }) + ); + }); + test("can be copied", async () => { + const { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + const group1 = await convertToGroup(app, graph, "test", [ + nodes.pos, + nodes.neg, + nodes.empty, + nodes.sampler, + nodes.decode, + nodes.save, + ]); + + group1.widgets["text"].value = "hello"; + group1.widgets["width"].value = 256; + group1.widgets["seed"].value = 1; + + // Clone the node + group1.menu.Clone.call(); + expect(app.graph._nodes).toHaveLength(3); + const group2 = graph.find(app.graph._nodes[2]); + expect(group2.node.type).toEqual("workflow/test"); + expect(group2.id).not.toEqual(group1.id); + + // Reconnect ckpt + nodes.ckpt.outputs.MODEL.connectTo(group2.inputs["model"]); + nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["clip"]); + nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["CLIPTextEncode clip"]); + nodes.ckpt.outputs.VAE.connectTo(group2.inputs["vae"]); + + group2.widgets["text"].value = "world"; + group2.widgets["width"].value = 1024; + group2.widgets["seed"].value = 100; + + let i = 0; + expect((await graph.toPrompt()).output).toEqual({ + ...getOutput([nodes.empty.id, nodes.pos.id, nodes.neg.id, nodes.sampler.id, nodes.decode.id, nodes.save.id], { + [nodes.empty.id]: { width: 256 }, + [nodes.pos.id]: { text: "hello" }, + [nodes.sampler.id]: { seed: 1 }, + }), + ...getOutput( + { + [nodes.empty.id]: `${group2.id}:${i++}`, + [nodes.pos.id]: `${group2.id}:${i++}`, + [nodes.neg.id]: `${group2.id}:${i++}`, + [nodes.sampler.id]: `${group2.id}:${i++}`, + [nodes.decode.id]: `${group2.id}:${i++}`, + [nodes.save.id]: `${group2.id}:${i++}`, + }, + { + [nodes.empty.id]: { width: 1024 }, + [nodes.pos.id]: { text: "world" }, + [nodes.sampler.id]: { seed: 100 }, + } + ), + }); + + graph.arrange(); + }); + test("is embedded in workflow", async () => { + let { ez, graph, app } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + let group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]); + const workflow = JSON.stringify((await graph.toPrompt()).workflow); + + // Clear the environment + ({ ez, graph, app } = await start({ + resetEnv: true, + })); + // Ensure the node isnt registered + expect(() => ez["workflow/test"]).toThrow(); + + // Reload the workflow + await app.loadGraphData(JSON.parse(workflow)); + + // Ensure the node is found + group = graph.find(group); + + // Generate prompt and ensure it is as expected + expect((await graph.toPrompt()).output).toEqual( + getOutput({ + [nodes.pos.id]: `${group.id}:0`, + [nodes.neg.id]: `${group.id}:1`, + }) + ); + }); + test("shows missing node error on missing internal node when loading graph data", async () => { + const { graph } = await start(); + + const dialogShow = jest.spyOn(graph.app.ui.dialog, "show"); + await graph.app.loadGraphData({ + last_node_id: 3, + last_link_id: 1, + nodes: [ + { + id: 3, + type: "workflow/testerror", + }, + ], + links: [], + groups: [], + config: {}, + extra: { + groupNodes: { + testerror: { + nodes: [ + { + type: "NotKSampler", + }, + { + type: "NotVAEDecode", + }, + ], + }, + }, + }, + }); + + expect(dialogShow).toBeCalledTimes(1); + // @ts-ignore + const call = dialogShow.mock.calls[0][0].innerHTML; + expect(call).toContain("the following node types were not found"); + expect(call).toContain("NotKSampler"); + expect(call).toContain("NotVAEDecode"); + expect(call).toContain("workflow/testerror"); + }); + test("maintains widget inputs on conversion back to nodes", async () => { + const { ez, graph, app } = await start(); + let pos = ez.CLIPTextEncode({ text: "positive" }); + pos.node.title = "Positive"; + let neg = ez.CLIPTextEncode({ text: "negative" }); + neg.node.title = "Negative"; + pos.widgets.text.convertToInput(); + neg.widgets.text.convertToInput(); + + let primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(pos.inputs.text); + primitive.outputs[0].connectTo(neg.inputs.text); + + const group = await convertToGroup(app, graph, "test", [pos, neg, primitive]); + // This will use a primitive widget named 'value' + expect(group.widgets.length).toBe(1); + expect(group.widgets["value"].value).toBe("positive"); + + const newNodes = group.menu["Convert to nodes"].call(); + pos = graph.find(newNodes.find((n) => n.title === "Positive")); + neg = graph.find(newNodes.find((n) => n.title === "Negative")); + primitive = graph.find(newNodes.find((n) => n.type === "PrimitiveNode")); + + expect(pos.inputs).toHaveLength(2); + expect(neg.inputs).toHaveLength(2); + expect(primitive.outputs[0].connections).toHaveLength(2); + + expect((await graph.toPrompt()).output).toEqual({ + 1: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" }, + 2: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" }, + }); + }); + test("correctly handles widget inputs", async () => { + const { ez, graph, app } = await start(); + const upscaleMethods = (await getNodeDef("ImageScaleBy")).input.required["upscale_method"][0]; + + const image = ez.LoadImage(); + const scale1 = ez.ImageScaleBy(image.outputs[0]); + const scale2 = ez.ImageScaleBy(image.outputs[0]); + const preview1 = ez.PreviewImage(scale1.outputs[0]); + const preview2 = ez.PreviewImage(scale2.outputs[0]); + scale1.widgets.upscale_method.value = upscaleMethods[1]; + scale1.widgets.upscale_method.convertToInput(); + + const group = await convertToGroup(app, graph, "test", [scale1, scale2]); + expect(group.inputs.length).toBe(3); + expect(group.inputs[0].input.type).toBe("IMAGE"); + expect(group.inputs[1].input.type).toBe("IMAGE"); + expect(group.inputs[2].input.type).toBe("COMBO"); + + // Ensure links are maintained + expect(group.inputs[0].connection?.originNode?.id).toBe(image.id); + expect(group.inputs[1].connection?.originNode?.id).toBe(image.id); + expect(group.inputs[2].connection).toBeFalsy(); + + // Ensure primitive gets correct type + const primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(group.inputs[2]); + expect(primitive.widgets.value.widget.options.values).toBe(upscaleMethods); + expect(primitive.widgets.value.value).toBe(upscaleMethods[1]); // Ensure value is copied + primitive.widgets.value.value = upscaleMethods[1]; + + await checkBeforeAndAfterReload(graph, async (r) => { + const scale1id = r ? `${group.id}:0` : scale1.id; + const scale2id = r ? `${group.id}:1` : scale2.id; + // Ensure widget value is applied to prompt + expect((await graph.toPrompt()).output).toStrictEqual({ + [image.id]: { inputs: { image: "example.png", upload: "image" }, class_type: "LoadImage" }, + [scale1id]: { + inputs: { upscale_method: upscaleMethods[1], scale_by: 1, image: [`${image.id}`, 0] }, + class_type: "ImageScaleBy", + }, + [scale2id]: { + inputs: { upscale_method: "nearest-exact", scale_by: 1, image: [`${image.id}`, 0] }, + class_type: "ImageScaleBy", + }, + [preview1.id]: { inputs: { images: [`${scale1id}`, 0] }, class_type: "PreviewImage" }, + [preview2.id]: { inputs: { images: [`${scale2id}`, 0] }, class_type: "PreviewImage" }, + }); + }); + }); + test("adds widgets in node execution order", async () => { + const { ez, graph, app } = await start(); + const scale = ez.LatentUpscale(); + const save = ez.SaveImage(); + const empty = ez.EmptyLatentImage(); + const decode = ez.VAEDecode(); + + scale.outputs.LATENT.connectTo(decode.inputs.samples); + decode.outputs.IMAGE.connectTo(save.inputs.images); + empty.outputs.LATENT.connectTo(scale.inputs.samples); + + const group = await convertToGroup(app, graph, "test", [scale, save, empty, decode]); + const widgets = group.widgets.map((w) => w.widget.name); + expect(widgets).toStrictEqual([ + "width", + "height", + "batch_size", + "upscale_method", + "LatentUpscale width", + "LatentUpscale height", + "crop", + "filename_prefix", + ]); + }); + test("adds output for external links when converting to group", async () => { + const { ez, graph, app } = await start(); + const img = ez.EmptyLatentImage(); + let decode = ez.VAEDecode(...img.outputs); + const preview1 = ez.PreviewImage(...decode.outputs); + const preview2 = ez.PreviewImage(...decode.outputs); + + const group = await convertToGroup(app, graph, "test", [img, decode, preview1]); + + // Ensure we have an output connected to the 2nd preview node + expect(group.outputs.length).toBe(1); + expect(group.outputs[0].connections.length).toBe(1); + expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id); + + // Convert back and ensure bothe previews are still connected + group.menu["Convert to nodes"].call(); + decode = graph.find(decode); + expect(decode.outputs[0].connections.length).toBe(2); + expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id); + expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id); + }); + test("adds output for external links when converting to group when nodes are not in execution order", async () => { + const { ez, graph, app } = await start(); + const sampler = ez.KSampler(); + const ckpt = ez.CheckpointLoaderSimple(); + const empty = ez.EmptyLatentImage(); + const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" }); + const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" }); + const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE); + const save = ez.SaveImage(decode1.outputs.IMAGE); + ckpt.outputs.MODEL.connectTo(sampler.inputs.model); + pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive); + neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative); + empty.outputs.LATENT.connectTo(sampler.inputs.latent_image); + + const encode = ez.VAEEncode(decode1.outputs.IMAGE); + const vae = ez.VAELoader(); + const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE); + const preview = ez.PreviewImage(decode2.outputs.IMAGE); + vae.outputs.VAE.connectTo(encode.inputs.vae); + + const group = await convertToGroup(app, graph, "test", [vae, decode1, encode, sampler]); + + expect(group.outputs.length).toBe(3); + expect(group.outputs[0].output.name).toBe("VAE"); + expect(group.outputs[0].output.type).toBe("VAE"); + expect(group.outputs[1].output.name).toBe("IMAGE"); + expect(group.outputs[1].output.type).toBe("IMAGE"); + expect(group.outputs[2].output.name).toBe("LATENT"); + expect(group.outputs[2].output.type).toBe("LATENT"); + + expect(group.outputs[0].connections.length).toBe(1); + expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id); + expect(group.outputs[0].connections[0].targetInput.index).toBe(1); + + expect(group.outputs[1].connections.length).toBe(1); + expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id); + expect(group.outputs[1].connections[0].targetInput.index).toBe(0); + + expect(group.outputs[2].connections.length).toBe(1); + expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id); + expect(group.outputs[2].connections[0].targetInput.index).toBe(0); + + expect((await graph.toPrompt()).output).toEqual({ + ...getOutput({ 1: ckpt.id, 2: pos.id, 3: neg.id, 4: empty.id, 5: sampler.id, 6: decode1.id, 7: save.id }), + [vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: vae.node.type }, + [encode.id]: { inputs: { pixels: ["6", 0], vae: [vae.id + "", 0] }, class_type: encode.node.type }, + [decode2.id]: { inputs: { samples: [encode.id + "", 0], vae: [vae.id + "", 0] }, class_type: decode2.node.type }, + [preview.id]: { inputs: { images: [decode2.id + "", 0] }, class_type: preview.node.type }, + }); + }); + test("works with IMAGEUPLOAD widget", async () => { + const { ez, graph, app } = await start(); + const img = ez.LoadImage(); + const preview1 = ez.PreviewImage(img.outputs[0]); + + const group = await convertToGroup(app, graph, "test", [img, preview1]); + const widget = group.widgets["upload"]; + expect(widget).toBeTruthy(); + expect(widget.widget.type).toBe("button"); + }); + test("internal primitive populates widgets for all linked inputs", async () => { + const { ez, graph, app } = await start(); + const img = ez.LoadImage(); + const scale1 = ez.ImageScale(img.outputs[0]); + const scale2 = ez.ImageScale(img.outputs[0]); + ez.PreviewImage(scale1.outputs[0]); + ez.PreviewImage(scale2.outputs[0]); + + scale1.widgets.width.convertToInput(); + scale2.widgets.height.convertToInput(); + + const primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(scale1.inputs.width); + primitive.outputs[0].connectTo(scale2.inputs.height); + + const group = await convertToGroup(app, graph, "test", [img, primitive, scale1, scale2]); + group.widgets.value.value = 100; + expect((await graph.toPrompt()).output).toEqual({ + 1: { + inputs: { image: img.widgets.image.value, upload: "image" }, + class_type: "LoadImage", + }, + 2: { + inputs: { upscale_method: "nearest-exact", width: 100, height: 512, crop: "disabled", image: ["1", 0] }, + class_type: "ImageScale", + }, + 3: { + inputs: { upscale_method: "nearest-exact", width: 512, height: 100, crop: "disabled", image: ["1", 0] }, + class_type: "ImageScale", + }, + 4: { inputs: { images: ["2", 0] }, class_type: "PreviewImage" }, + 5: { inputs: { images: ["3", 0] }, class_type: "PreviewImage" }, + }); + }); + test("primitive control widgets values are copied on convert", async () => { + const { ez, graph, app } = await start(); + const sampler = ez.KSampler(); + sampler.widgets.seed.convertToInput(); + sampler.widgets.sampler_name.convertToInput(); + + let p1 = ez.PrimitiveNode(); + let p2 = ez.PrimitiveNode(); + p1.outputs[0].connectTo(sampler.inputs.seed); + p2.outputs[0].connectTo(sampler.inputs.sampler_name); + + p1.widgets.control_after_generate.value = "increment"; + p2.widgets.control_after_generate.value = "decrement"; + p2.widgets.control_filter_list.value = "/.*/"; + + p2.node.title = "p2"; + + const group = await convertToGroup(app, graph, "test", [sampler, p1, p2]); + expect(group.widgets.control_after_generate.value).toBe("increment"); + expect(group.widgets["p2 control_after_generate"].value).toBe("decrement"); + expect(group.widgets["p2 control_filter_list"].value).toBe("/.*/"); + + group.widgets.control_after_generate.value = "fixed"; + group.widgets["p2 control_after_generate"].value = "randomize"; + group.widgets["p2 control_filter_list"].value = "/.+/"; + + group.menu["Convert to nodes"].call(); + p1 = graph.find(p1); + p2 = graph.find(p2); + + expect(p1.widgets.control_after_generate.value).toBe("fixed"); + expect(p2.widgets.control_after_generate.value).toBe("randomize"); + expect(p2.widgets.control_filter_list.value).toBe("/.+/"); + }); + test("internal reroutes work with converted inputs and merge options", async () => { + const { ez, graph, app } = await start(); + const vae = ez.VAELoader(); + const latent = ez.EmptyLatentImage(); + const decode = ez.VAEDecode(latent.outputs.LATENT, vae.outputs.VAE); + const scale = ez.ImageScale(decode.outputs.IMAGE); + ez.PreviewImage(scale.outputs.IMAGE); + + const r1 = ez.Reroute(); + const r2 = ez.Reroute(); + + latent.widgets.width.value = 64; + latent.widgets.height.value = 128; + + latent.widgets.width.convertToInput(); + latent.widgets.height.convertToInput(); + latent.widgets.batch_size.convertToInput(); + + scale.widgets.width.convertToInput(); + scale.widgets.height.convertToInput(); + + r1.inputs[0].input.label = "hbw"; + r1.outputs[0].connectTo(latent.inputs.height); + r1.outputs[0].connectTo(latent.inputs.batch_size); + r1.outputs[0].connectTo(scale.inputs.width); + + r2.inputs[0].input.label = "wh"; + r2.outputs[0].connectTo(latent.inputs.width); + r2.outputs[0].connectTo(scale.inputs.height); + + const group = await convertToGroup(app, graph, "test", [r1, r2, latent, decode, scale]); + + expect(group.inputs[0].input.type).toBe("VAE"); + expect(group.inputs[1].input.type).toBe("INT"); + expect(group.inputs[2].input.type).toBe("INT"); + + const p1 = ez.PrimitiveNode(); + const p2 = ez.PrimitiveNode(); + p1.outputs[0].connectTo(group.inputs[1]); + p2.outputs[0].connectTo(group.inputs[2]); + + expect(p1.widgets.value.widget.options?.min).toBe(16); // width/height min + expect(p1.widgets.value.widget.options?.max).toBe(4096); // batch max + expect(p1.widgets.value.widget.options?.step).toBe(80); // width/height step * 10 + + expect(p2.widgets.value.widget.options?.min).toBe(16); // width/height min + expect(p2.widgets.value.widget.options?.max).toBe(16384); // width/height max + expect(p2.widgets.value.widget.options?.step).toBe(80); // width/height step * 10 + + expect(p1.widgets.value.value).toBe(128); + expect(p2.widgets.value.value).toBe(64); + + p1.widgets.value.value = 16; + p2.widgets.value.value = 32; + + await checkBeforeAndAfterReload(graph, async (r) => { + const id = (v) => (r ? `${group.id}:` : "") + v; + expect((await graph.toPrompt()).output).toStrictEqual({ + 1: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" }, + [id(2)]: { inputs: { width: 32, height: 16, batch_size: 16 }, class_type: "EmptyLatentImage" }, + [id(3)]: { inputs: { samples: [id(2), 0], vae: ["1", 0] }, class_type: "VAEDecode" }, + [id(4)]: { + inputs: { upscale_method: "nearest-exact", width: 16, height: 32, crop: "disabled", image: [id(3), 0] }, + class_type: "ImageScale", + }, + 5: { inputs: { images: [id(4), 0] }, class_type: "PreviewImage" }, + }); + }); + }); + test("converted inputs with linked widgets map values correctly on creation", async () => { + const { ez, graph, app } = await start(); + const k1 = ez.KSampler(); + const k2 = ez.KSampler(); + k1.widgets.seed.convertToInput(); + k2.widgets.seed.convertToInput(); + + const rr = ez.Reroute(); + rr.outputs[0].connectTo(k1.inputs.seed); + rr.outputs[0].connectTo(k2.inputs.seed); + + const group = await convertToGroup(app, graph, "test", [k1, k2, rr]); + expect(group.widgets.steps.value).toBe(20); + expect(group.widgets.cfg.value).toBe(8); + expect(group.widgets.scheduler.value).toBe("normal"); + expect(group.widgets["KSampler steps"].value).toBe(20); + expect(group.widgets["KSampler cfg"].value).toBe(8); + expect(group.widgets["KSampler scheduler"].value).toBe("normal"); + }); + test("allow multiple of the same node type to be added", async () => { + const { ez, graph, app } = await start(); + const nodes = [...Array(10)].map(() => ez.ImageScaleBy()); + const group = await convertToGroup(app, graph, "test", nodes); + expect(group.inputs.length).toBe(10); + expect(group.outputs.length).toBe(10); + expect(group.widgets.length).toBe(20); + expect(group.widgets.map((w) => w.widget.name)).toStrictEqual( + [...Array(10)] + .map((_, i) => `${i > 0 ? "ImageScaleBy " : ""}${i > 1 ? i + " " : ""}`) + .flatMap((p) => [`${p}upscale_method`, `${p}scale_by`]) + ); + }); }); diff --git a/tests-ui/tests/users.test.ts b/tests-ui/tests/users.test.ts index 2b6faddb1..ce54a3528 100644 --- a/tests-ui/tests/users.test.ts +++ b/tests-ui/tests/users.test.ts @@ -2,291 +2,291 @@ import { start } from "../utils"; import lg from "../utils/litegraph"; describe("users", () => { - beforeEach(() => { - lg.setup(global); - }); + beforeEach(() => { + lg.setup(global); + }); - afterEach(() => { - lg.teardown(global); - }); + afterEach(() => { + lg.teardown(global); + }); - function expectNoUserScreen() { - // Ensure login isnt visible - const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; - expect(selection["style"].display).toBe("none"); - const menu = document.querySelectorAll(".comfy-menu")?.[0]; - expect(window.getComputedStyle(menu)?.display).not.toBe("none"); - } + function expectNoUserScreen() { + // Ensure login isnt visible + const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; + expect(selection["style"].display).toBe("none"); + const menu = document.querySelectorAll(".comfy-menu")?.[0]; + expect(window.getComputedStyle(menu)?.display).not.toBe("none"); + } - describe("multi-user", () => { - async function mockAddStylesheet() { - const utils = await import("../../src/scripts/utils"); - utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve()); - } + describe("multi-user", () => { + async function mockAddStylesheet() { + const utils = await import("../../src/scripts/utils"); + utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve()); + } - async function waitForUserScreenShow() { - // Wait for "show" to be called - const { UserSelectionScreen } = await import("../../src/scripts/ui/userSelection"); - let resolve, reject; - const fn = UserSelectionScreen.prototype.show; - const p = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => { - const res = fn(...args); - await new Promise(process.nextTick); // wait for promises to resolve - resolve(); - return res; - }); - // @ts-ignore - setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500); - await p; - await new Promise(process.nextTick); // wait for promises to resolve - } + async function waitForUserScreenShow() { + // Wait for "show" to be called + const { UserSelectionScreen } = await import("../../src/scripts/ui/userSelection"); + let resolve, reject; + const fn = UserSelectionScreen.prototype.show; + const p = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => { + const res = fn(...args); + await new Promise(process.nextTick); // wait for promises to resolve + resolve(); + return res; + }); + // @ts-ignore + setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500); + await p; + await new Promise(process.nextTick); // wait for promises to resolve + } - async function testUserScreen(onShown, users?) { - if (!users) { - users = {}; - } - const starting = start({ - resetEnv: true, - userConfig: { storage: "server", users }, - preSetup: mockAddStylesheet, - }); + async function testUserScreen(onShown, users?) { + if (!users) { + users = {}; + } + const starting = start({ + resetEnv: true, + userConfig: { storage: "server", users }, + preSetup: mockAddStylesheet, + }); - // Ensure no current user - expect(localStorage["Comfy.userId"]).toBeFalsy(); - expect(localStorage["Comfy.userName"]).toBeFalsy(); + // Ensure no current user + expect(localStorage["Comfy.userId"]).toBeFalsy(); + expect(localStorage["Comfy.userName"]).toBeFalsy(); - await waitForUserScreenShow(); + await waitForUserScreenShow(); - const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; - expect(selection).toBeTruthy(); + const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; + expect(selection).toBeTruthy(); - // Ensure login is visible - expect(window.getComputedStyle(selection)?.display).not.toBe("none"); - // Ensure menu is hidden - const menu = document.querySelectorAll(".comfy-menu")?.[0]; - expect(window.getComputedStyle(menu)?.display).toBe("none"); + // Ensure login is visible + expect(window.getComputedStyle(selection)?.display).not.toBe("none"); + // Ensure menu is hidden + const menu = document.querySelectorAll(".comfy-menu")?.[0]; + expect(window.getComputedStyle(menu)?.display).toBe("none"); - const isCreate = await onShown(selection); + const isCreate = await onShown(selection); - // Submit form - selection.querySelectorAll("form")[0].submit(); - await new Promise(process.nextTick); // wait for promises to resolve + // Submit form + selection.querySelectorAll("form")[0].submit(); + await new Promise(process.nextTick); // wait for promises to resolve - // Wait for start - const s = await starting; + // Wait for start + const s = await starting; - // Ensure login is removed - expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0); - expect(window.getComputedStyle(menu)?.display).not.toBe("none"); + // Ensure login is removed + expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0); + expect(window.getComputedStyle(menu)?.display).not.toBe("none"); - // Ensure settings + templates are saved - const { api } = await import("../../src/scripts/api"); - expect(api.createUser).toHaveBeenCalledTimes(+isCreate); - expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate); - expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate); - if (isCreate) { - expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); - expect(s.app.isNewUserSession).toBeTruthy(); - } else { - expect(s.app.isNewUserSession).toBeFalsy(); - } + // Ensure settings + templates are saved + const { api } = await import("../../src/scripts/api"); + expect(api.createUser).toHaveBeenCalledTimes(+isCreate); + expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate); + expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate); + if (isCreate) { + expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); + expect(s.app.isNewUserSession).toBeTruthy(); + } else { + expect(s.app.isNewUserSession).toBeFalsy(); + } - return { users, selection, ...s }; - } + return { users, selection, ...s }; + } - it("allows user creation if no users", async () => { - const { users } = await testUserScreen((selection) => { - // Ensure we have no users flag added - expect(selection.classList.contains("no-users")).toBeTruthy(); + it("allows user creation if no users", async () => { + const { users } = await testUserScreen((selection) => { + // Ensure we have no users flag added + expect(selection.classList.contains("no-users")).toBeTruthy(); - // Enter a username - const input = selection.getElementsByTagName("input")[0]; - input.focus(); - input.value = "Test User"; + // Enter a username + const input = selection.getElementsByTagName("input")[0]; + input.focus(); + input.value = "Test User"; - return true; - }); + return true; + }); - expect(users).toStrictEqual({ - "Test User!": "Test User", - }); + expect(users).toStrictEqual({ + "Test User!": "Test User", + }); - expect(localStorage["Comfy.userId"]).toBe("Test User!"); - expect(localStorage["Comfy.userName"]).toBe("Test User"); - }); - it("allows user creation if no current user but other users", async () => { - const users = { - "Test User 2!": "Test User 2", - }; + expect(localStorage["Comfy.userId"]).toBe("Test User!"); + expect(localStorage["Comfy.userName"]).toBe("Test User"); + }); + it("allows user creation if no current user but other users", async () => { + const users = { + "Test User 2!": "Test User 2", + }; - await testUserScreen((selection) => { - expect(selection.classList.contains("no-users")).toBeFalsy(); + await testUserScreen((selection) => { + expect(selection.classList.contains("no-users")).toBeFalsy(); - // Enter a username - const input = selection.getElementsByTagName("input")[0]; - input.focus(); - input.value = "Test User 3"; - return true; - }, users); + // Enter a username + const input = selection.getElementsByTagName("input")[0]; + input.focus(); + input.value = "Test User 3"; + return true; + }, users); - expect(users).toStrictEqual({ - "Test User 2!": "Test User 2", - "Test User 3!": "Test User 3", - }); + expect(users).toStrictEqual({ + "Test User 2!": "Test User 2", + "Test User 3!": "Test User 3", + }); - expect(localStorage["Comfy.userId"]).toBe("Test User 3!"); - expect(localStorage["Comfy.userName"]).toBe("Test User 3"); - }); - it("allows user selection if no current user but other users", async () => { - const users = { - "A!": "A", - "B!": "B", - "C!": "C", - }; + expect(localStorage["Comfy.userId"]).toBe("Test User 3!"); + expect(localStorage["Comfy.userName"]).toBe("Test User 3"); + }); + it("allows user selection if no current user but other users", async () => { + const users = { + "A!": "A", + "B!": "B", + "C!": "C", + }; - await testUserScreen((selection) => { - expect(selection.classList.contains("no-users")).toBeFalsy(); + await testUserScreen((selection) => { + expect(selection.classList.contains("no-users")).toBeFalsy(); - // Check user list - const select = selection.getElementsByTagName("select")[0]; - const options = select.getElementsByTagName("option"); - expect( - [...options] - .filter((o) => !o.disabled) - .reduce((p, n) => { - p[n.getAttribute("value")] = n.textContent; - return p; - }, {}) - ).toStrictEqual(users); + // Check user list + const select = selection.getElementsByTagName("select")[0]; + const options = select.getElementsByTagName("option"); + expect( + [...options] + .filter((o) => !o.disabled) + .reduce((p, n) => { + p[n.getAttribute("value")] = n.textContent; + return p; + }, {}) + ).toStrictEqual(users); - // Select an option - select.focus(); - select.value = options[2].value; + // Select an option + select.focus(); + select.value = options[2].value; - return false; - }, users); + return false; + }, users); - expect(users).toStrictEqual(users); + expect(users).toStrictEqual(users); - expect(localStorage["Comfy.userId"]).toBe("B!"); - expect(localStorage["Comfy.userName"]).toBe("B"); - }); - it("doesnt show user screen if current user", async () => { - const starting = start({ - resetEnv: true, - userConfig: { - storage: "server", - users: { - "User!": "User", - }, - }, - localStorage: { - "Comfy.userId": "User!", - "Comfy.userName": "User", - }, - }); - await new Promise(process.nextTick); // wait for promises to resolve + expect(localStorage["Comfy.userId"]).toBe("B!"); + expect(localStorage["Comfy.userName"]).toBe("B"); + }); + it("doesnt show user screen if current user", async () => { + const starting = start({ + resetEnv: true, + userConfig: { + storage: "server", + users: { + "User!": "User", + }, + }, + localStorage: { + "Comfy.userId": "User!", + "Comfy.userName": "User", + }, + }); + await new Promise(process.nextTick); // wait for promises to resolve - expectNoUserScreen(); + expectNoUserScreen(); - await starting; - }); - it("allows user switching", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { - storage: "server", - users: { - "User!": "User", - }, - }, - localStorage: { - "Comfy.userId": "User!", - "Comfy.userName": "User", - }, - }); + await starting; + }); + it("allows user switching", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { + storage: "server", + users: { + "User!": "User", + }, + }, + localStorage: { + "Comfy.userId": "User!", + "Comfy.userName": "User", + }, + }); - // cant actually test switching user easily but can check the setting is present - expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy(); - }); - }); - describe("single-user", () => { - it("doesnt show user creation if no default user", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { migrated: false, storage: "server" }, - }); - expectNoUserScreen(); + // cant actually test switching user easily but can check the setting is present + expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy(); + }); + }); + describe("single-user", () => { + it("doesnt show user creation if no default user", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { migrated: false, storage: "server" }, + }); + expectNoUserScreen(); - // It should store the settings - const { api } = await import("../../src/scripts/api"); - expect(api.storeSettings).toHaveBeenCalledTimes(1); - expect(api.storeUserData).toHaveBeenCalledTimes(1); - expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); - expect(app.isNewUserSession).toBeTruthy(); - }); - it("doesnt show user creation if default user", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { migrated: true, storage: "server" }, - }); - expectNoUserScreen(); + // It should store the settings + const { api } = await import("../../src/scripts/api"); + expect(api.storeSettings).toHaveBeenCalledTimes(1); + expect(api.storeUserData).toHaveBeenCalledTimes(1); + expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); + expect(app.isNewUserSession).toBeTruthy(); + }); + it("doesnt show user creation if default user", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { migrated: true, storage: "server" }, + }); + expectNoUserScreen(); - // It should store the settings - const { api } = await import("../../src/scripts/api"); - expect(api.storeSettings).toHaveBeenCalledTimes(0); - expect(api.storeUserData).toHaveBeenCalledTimes(0); - expect(app.isNewUserSession).toBeFalsy(); - }); - it("doesnt allow user switching", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { migrated: true, storage: "server" }, - }); - expectNoUserScreen(); + // It should store the settings + const { api } = await import("../../src/scripts/api"); + expect(api.storeSettings).toHaveBeenCalledTimes(0); + expect(api.storeUserData).toHaveBeenCalledTimes(0); + expect(app.isNewUserSession).toBeFalsy(); + }); + it("doesnt allow user switching", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { migrated: true, storage: "server" }, + }); + expectNoUserScreen(); - expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); - }); - }); - describe("browser-user", () => { - it("doesnt show user creation if no default user", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { migrated: false, storage: "browser" }, - }); - expectNoUserScreen(); + expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); + }); + }); + describe("browser-user", () => { + it("doesnt show user creation if no default user", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { migrated: false, storage: "browser" }, + }); + expectNoUserScreen(); - // It should store the settings - const { api } = await import("../../src/scripts/api"); - expect(api.storeSettings).toHaveBeenCalledTimes(0); - expect(api.storeUserData).toHaveBeenCalledTimes(0); - expect(app.isNewUserSession).toBeFalsy(); - }); - it("doesnt show user creation if default user", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { migrated: true, storage: "server" }, - }); - expectNoUserScreen(); + // It should store the settings + const { api } = await import("../../src/scripts/api"); + expect(api.storeSettings).toHaveBeenCalledTimes(0); + expect(api.storeUserData).toHaveBeenCalledTimes(0); + expect(app.isNewUserSession).toBeFalsy(); + }); + it("doesnt show user creation if default user", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { migrated: true, storage: "server" }, + }); + expectNoUserScreen(); - // It should store the settings - const { api } = await import("../../src/scripts/api"); - expect(api.storeSettings).toHaveBeenCalledTimes(0); - expect(api.storeUserData).toHaveBeenCalledTimes(0); - expect(app.isNewUserSession).toBeFalsy(); - }); - it("doesnt allow user switching", async () => { - const { app } = await start({ - resetEnv: true, - userConfig: { migrated: true, storage: "browser" }, - }); - expectNoUserScreen(); + // It should store the settings + const { api } = await import("../../src/scripts/api"); + expect(api.storeSettings).toHaveBeenCalledTimes(0); + expect(api.storeUserData).toHaveBeenCalledTimes(0); + expect(app.isNewUserSession).toBeFalsy(); + }); + it("doesnt allow user switching", async () => { + const { app } = await start({ + resetEnv: true, + userConfig: { migrated: true, storage: "browser" }, + }); + expectNoUserScreen(); - expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); - }); - }); + expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); + }); + }); }); diff --git a/tests-ui/tests/widgetInputs.test.ts b/tests-ui/tests/widgetInputs.test.ts index 8bfbe79b7..336a1246b 100644 --- a/tests-ui/tests/widgetInputs.test.ts +++ b/tests-ui/tests/widgetInputs.test.ts @@ -15,536 +15,536 @@ import lg from "../utils/litegraph"; * @returns */ async function connectPrimitiveAndReload(ez, graph, input, widgetType, controlWidgetCount = 0) { - // Connect to primitive and ensure its still connected after - let primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(input); + // Connect to primitive and ensure its still connected after + let primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(input); - await checkBeforeAndAfterReload(graph, async () => { - primitive = graph.find(primitive); - let { connections } = primitive.outputs[0]; - expect(connections).toHaveLength(1); - expect(connections[0].targetNode.id).toBe(input.node.node.id); + await checkBeforeAndAfterReload(graph, async () => { + primitive = graph.find(primitive); + let { connections } = primitive.outputs[0]; + expect(connections).toHaveLength(1); + expect(connections[0].targetNode.id).toBe(input.node.node.id); - // Ensure widget is correct type - const valueWidget = primitive.widgets.value; - expect(valueWidget.widget.type).toBe(widgetType); + // Ensure widget is correct type + const valueWidget = primitive.widgets.value; + expect(valueWidget.widget.type).toBe(widgetType); - // Check if control_after_generate should be added - if (controlWidgetCount) { - const controlWidget = primitive.widgets.control_after_generate; - expect(controlWidget.widget.type).toBe("combo"); - if (widgetType === "combo") { - const filterWidget = primitive.widgets.control_filter_list; - expect(filterWidget.widget.type).toBe("string"); - } - } + // Check if control_after_generate should be added + if (controlWidgetCount) { + const controlWidget = primitive.widgets.control_after_generate; + expect(controlWidget.widget.type).toBe("combo"); + if (widgetType === "combo") { + const filterWidget = primitive.widgets.control_filter_list; + expect(filterWidget.widget.type).toBe("string"); + } + } - // Ensure we dont have other widgets - expect(primitive.node.widgets).toHaveLength(1 + controlWidgetCount); - }); + // Ensure we dont have other widgets + expect(primitive.node.widgets).toHaveLength(1 + controlWidgetCount); + }); - return primitive; + return primitive; } describe("widget inputs", () => { - beforeEach(() => { - lg.setup(global); - }); - - afterEach(() => { - lg.teardown(global); - }); - - [ - { name: "int", type: "INT", widget: "number", control: 1 }, - { name: "float", type: "FLOAT", widget: "number", control: 1 }, - { name: "text", type: "STRING" }, - { - name: "customtext", - type: "STRING", - opt: { multiline: true }, - }, - { name: "toggle", type: "BOOLEAN" }, - { name: "combo", type: ["a", "b", "c"], control: 2 }, - ].forEach((c) => { - test(`widget conversion + primitive works on ${c.name}`, async () => { - const { ez, graph } = await start({ - mockNodeDefs: makeNodeDef("TestNode", { [c.name]: [c.type, c.opt ?? {}] }), - }); - - // Create test node and convert to input - const n = ez.TestNode(); - const w = n.widgets[c.name]; - w.convertToInput(); - expect(w.isConvertedToInput).toBeTruthy(); - const input = w.getConvertedInput(); - expect(input).toBeTruthy(); - - // @ts-ignore : input is valid here - await connectPrimitiveAndReload(ez, graph, input, c.widget ?? c.name, c.control); - }); - }); - - test("converted widget works after reload", async () => { - const { ez, graph } = await start(); - let n = ez.CheckpointLoaderSimple(); - - const inputCount = n.inputs.length; - - // Convert ckpt name to an input - n.widgets.ckpt_name.convertToInput(); - expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); - expect(n.inputs.ckpt_name).toBeTruthy(); - expect(n.inputs.length).toEqual(inputCount + 1); - - // Convert back to widget and ensure input is removed - n.widgets.ckpt_name.convertToWidget(); - expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); - expect(n.inputs.ckpt_name).toBeFalsy(); - expect(n.inputs.length).toEqual(inputCount); - - // Convert again and reload the graph to ensure it maintains state - n.widgets.ckpt_name.convertToInput(); - expect(n.inputs.length).toEqual(inputCount + 1); - - const primitive = await connectPrimitiveAndReload(ez, graph, n.inputs.ckpt_name, "combo", 2); - - // Disconnect & reconnect - primitive.outputs[0].connections[0].disconnect(); - let { connections } = primitive.outputs[0]; - expect(connections).toHaveLength(0); - - primitive.outputs[0].connectTo(n.inputs.ckpt_name); - ({ connections } = primitive.outputs[0]); - expect(connections).toHaveLength(1); - expect(connections[0].targetNode.id).toBe(n.node.id); - - // Convert back to widget and ensure input is removed - n.widgets.ckpt_name.convertToWidget(); - expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); - expect(n.inputs.ckpt_name).toBeFalsy(); - expect(n.inputs.length).toEqual(inputCount); - }); - - test("converted widget works on clone", async () => { - const { graph, ez } = await start(); - let n = ez.CheckpointLoaderSimple(); - - // Convert the widget to an input - n.widgets.ckpt_name.convertToInput(); - expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); - - // Clone the node - n.menu["Clone"].call(); - expect(graph.nodes).toHaveLength(2); - const clone = graph.nodes[1]; - expect(clone.id).not.toEqual(n.id); - - // Ensure the clone has an input - expect(clone.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); - expect(clone.inputs.ckpt_name).toBeTruthy(); - - // Ensure primitive connects to both nodes - let primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(n.inputs.ckpt_name); - primitive.outputs[0].connectTo(clone.inputs.ckpt_name); - expect(primitive.outputs[0].connections).toHaveLength(2); - - // Convert back to widget and ensure input is removed - clone.widgets.ckpt_name.convertToWidget(); - expect(clone.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); - expect(clone.inputs.ckpt_name).toBeFalsy(); - }); - - test("shows missing node error on custom node with converted input", async () => { - const { graph } = await start(); - - const dialogShow = jest.spyOn(graph.app.ui.dialog, "show"); - - await graph.app.loadGraphData({ - last_node_id: 3, - last_link_id: 4, - nodes: [ - { - id: 1, - type: "TestNode", - pos: [41.87329101561909, 389.7381480823742], - size: { 0: 220, 1: 374 }, - flags: {}, - order: 1, - mode: 0, - inputs: [{ name: "test", type: "FLOAT", link: 4, widget: { name: "test" }, slot_index: 0 }], - outputs: [], - properties: { "Node name for S&R": "TestNode" }, - widgets_values: [1], - }, - { - id: 3, - type: "PrimitiveNode", - pos: [-312, 433], - size: { 0: 210, 1: 82 }, - flags: {}, - order: 0, - mode: 0, - outputs: [{ links: [4], widget: { name: "test" } }], - title: "test", - properties: {}, - }, - ], - links: [[4, 3, 0, 1, 6, "FLOAT"]], - groups: [], - config: {}, - extra: {}, - version: 0.4, - }); - - expect(dialogShow).toBeCalledTimes(1); - // @ts-ignore - expect(dialogShow.mock.calls[0][0].innerHTML).toContain("the following node types were not found"); - // @ts-ignore - expect(dialogShow.mock.calls[0][0].innerHTML).toContain("TestNode"); - }); - - test("defaultInput widgets can be converted back to inputs", async () => { - const { graph, ez } = await start({ - mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { defaultInput: true }] }), - }); - - // Create test node and ensure it starts as an input - let n = ez.TestNode(); - let w = n.widgets.example; - expect(w.isConvertedToInput).toBeTruthy(); - let input = w.getConvertedInput(); - expect(input).toBeTruthy(); - - // Ensure it can be converted to - w.convertToWidget(); - expect(w.isConvertedToInput).toBeFalsy(); - expect(n.inputs.length).toEqual(0); - // and from - w.convertToInput(); - expect(w.isConvertedToInput).toBeTruthy(); - input = w.getConvertedInput(); - - // Reload and ensure it still only has 1 converted widget - if (!assertNotNullOrUndefined(input)) return; - - await connectPrimitiveAndReload(ez, graph, input, "number", 1); - n = graph.find(n); - expect(n.widgets).toHaveLength(1); - w = n.widgets.example; - expect(w.isConvertedToInput).toBeTruthy(); - - // Convert back to widget and ensure it is still a widget after reload - w.convertToWidget(); - await graph.reload(); - n = graph.find(n); - expect(n.widgets).toHaveLength(1); - expect(n.widgets[0].isConvertedToInput).toBeFalsy(); - expect(n.inputs.length).toEqual(0); - }); - - test("forceInput widgets can not be converted back to inputs", async () => { - const { graph, ez } = await start({ - mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { forceInput: true }] }), - }); - - // Create test node and ensure it starts as an input - let n = ez.TestNode(); - let w = n.widgets.example; - expect(w.isConvertedToInput).toBeTruthy(); - const input = w.getConvertedInput(); - expect(input).toBeTruthy(); - - // Convert to widget should error - expect(() => w.convertToWidget()).toThrow(); - - // Reload and ensure it still only has 1 converted widget - if (assertNotNullOrUndefined(input)) { - await connectPrimitiveAndReload(ez, graph, input, "number", 1); - n = graph.find(n); - expect(n.widgets).toHaveLength(1); - expect(n.widgets.example.isConvertedToInput).toBeTruthy(); - } - }); - - test("primitive can connect to matching combos on converted widgets", async () => { - const { ez } = await start({ - mockNodeDefs: { - ...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }), - ...makeNodeDef("TestNode2", { example: [["A", "B", "C"], { forceInput: true }] }), - }, - }); - - const n1 = ez.TestNode1(); - const n2 = ez.TestNode2(); - const p = ez.PrimitiveNode(); - p.outputs[0].connectTo(n1.inputs[0]); - p.outputs[0].connectTo(n2.inputs[0]); - expect(p.outputs[0].connections).toHaveLength(2); - const valueWidget = p.widgets.value; - expect(valueWidget.widget.type).toBe("combo"); - expect(valueWidget.widget.options.values).toEqual(["A", "B", "C"]); - }); - - test("primitive can not connect to non matching combos on converted widgets", async () => { - const { ez } = await start({ - mockNodeDefs: { - ...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }), - ...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }), - }, - }); - - const n1 = ez.TestNode1(); - const n2 = ez.TestNode2(); - const p = ez.PrimitiveNode(); - p.outputs[0].connectTo(n1.inputs[0]); - expect(() => p.outputs[0].connectTo(n2.inputs[0])).toThrow(); - expect(p.outputs[0].connections).toHaveLength(1); - }); - - test("combo output can not connect to non matching combos list input", async () => { - const { ez } = await start({ - mockNodeDefs: { - ...makeNodeDef("TestNode1", {}, [["A", "B"]]), - ...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }), - ...makeNodeDef("TestNode3", { example: [["A", "B", "C"], { forceInput: true }] }), - }, - }); - - const n1 = ez.TestNode1(); - const n2 = ez.TestNode2(); - const n3 = ez.TestNode3(); - - n1.outputs[0].connectTo(n2.inputs[0]); - expect(() => n1.outputs[0].connectTo(n3.inputs[0])).toThrow(); - }); - - test("combo primitive can filter list when control_after_generate called", async () => { - const { ez } = await start({ - mockNodeDefs: { - ...makeNodeDef("TestNode1", { example: [["A", "B", "C", "D", "AA", "BB", "CC", "DD", "AAA", "BBB"], {}] }), - }, - }); - - const n1 = ez.TestNode1(); - n1.widgets.example.convertToInput(); - const p = ez.PrimitiveNode(); - p.outputs[0].connectTo(n1.inputs[0]); - - const value = p.widgets.value; - const control = p.widgets.control_after_generate.widget; - const filter = p.widgets.control_filter_list; - - expect(p.widgets.length).toBe(3); - control.value = "increment"; - expect(value.value).toBe("A"); - - // Manually trigger after queue when set to increment - control["afterQueued"](); - expect(value.value).toBe("B"); - - // Filter to items containing D - filter.value = "D"; - control["afterQueued"](); - expect(value.value).toBe("D"); - control["afterQueued"](); - expect(value.value).toBe("DD"); - - // Check decrement - value.value = "BBB"; - control.value = "decrement"; - filter.value = "B"; - control["afterQueued"](); - expect(value.value).toBe("BB"); - control["afterQueued"](); - expect(value.value).toBe("B"); - - // Check regex works - value.value = "BBB"; - filter.value = "/[AB]|^C$/"; - control["afterQueued"](); - expect(value.value).toBe("AAA"); - control["afterQueued"](); - expect(value.value).toBe("BB"); - control["afterQueued"](); - expect(value.value).toBe("AA"); - control["afterQueued"](); - expect(value.value).toBe("C"); - control["afterQueued"](); - expect(value.value).toBe("B"); - control["afterQueued"](); - expect(value.value).toBe("A"); - - // Check random - control.value = "randomize"; - filter.value = "/D/"; - for (let i = 0; i < 100; i++) { - control["afterQueued"](); - expect(value.value === "D" || value.value === "DD").toBeTruthy(); - } - - // Ensure it doesnt apply when fixed - control.value = "fixed"; - value.value = "B"; - filter.value = "C"; - control["afterQueued"](); - expect(value.value).toBe("B"); - }); - - describe("reroutes", () => { - async function checkOutput(graph, values) { - expect((await graph.toPrompt()).output).toStrictEqual({ - 1: { inputs: { ckpt_name: "model1.safetensors" }, class_type: "CheckpointLoaderSimple" }, - 2: { inputs: { text: "positive", clip: ["1", 1] }, class_type: "CLIPTextEncode" }, - 3: { inputs: { text: "negative", clip: ["1", 1] }, class_type: "CLIPTextEncode" }, - 4: { - inputs: { width: values.width ?? 512, height: values.height ?? 512, batch_size: values?.batch_size ?? 1 }, - class_type: "EmptyLatentImage", - }, - 5: { - inputs: { - seed: 0, - steps: 20, - cfg: 8, - sampler_name: "euler", - scheduler: values?.scheduler ?? "normal", - denoise: 1, - model: ["1", 0], - positive: ["2", 0], - negative: ["3", 0], - latent_image: ["4", 0], - }, - class_type: "KSampler", - }, - 6: { inputs: { samples: ["5", 0], vae: ["1", 2] }, class_type: "VAEDecode" }, - 7: { - inputs: { filename_prefix: values.filename_prefix ?? "ComfyUI", images: ["6", 0] }, - class_type: "SaveImage", - }, - }); - } - - async function waitForWidget(node) { - // widgets are created slightly after the graph is ready - // hard to find an exact hook to get these so just wait for them to be ready - for (let i = 0; i < 10; i++) { - await new Promise((r) => setTimeout(r, 10)); - if (node.widgets?.value) { - return; - } - } - } - - it("can connect primitive via a reroute path to a widget input", async () => { - const { ez, graph } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - nodes.empty.widgets.width.convertToInput(); - nodes.sampler.widgets.scheduler.convertToInput(); - nodes.save.widgets.filename_prefix.convertToInput(); - - let widthReroute = ez.Reroute(); - let schedulerReroute = ez.Reroute(); - let fileReroute = ez.Reroute(); - - let widthNext = widthReroute; - let schedulerNext = schedulerReroute; - let fileNext = fileReroute; - - for (let i = 0; i < 5; i++) { - let next = ez.Reroute(); - widthNext.outputs[0].connectTo(next.inputs[0]); - widthNext = next; - - next = ez.Reroute(); - schedulerNext.outputs[0].connectTo(next.inputs[0]); - schedulerNext = next; - - next = ez.Reroute(); - fileNext.outputs[0].connectTo(next.inputs[0]); - fileNext = next; - } - - widthNext.outputs[0].connectTo(nodes.empty.inputs.width); - schedulerNext.outputs[0].connectTo(nodes.sampler.inputs.scheduler); - fileNext.outputs[0].connectTo(nodes.save.inputs.filename_prefix); - - let widthPrimitive = ez.PrimitiveNode(); - let schedulerPrimitive = ez.PrimitiveNode(); - let filePrimitive = ez.PrimitiveNode(); - - widthPrimitive.outputs[0].connectTo(widthReroute.inputs[0]); - schedulerPrimitive.outputs[0].connectTo(schedulerReroute.inputs[0]); - filePrimitive.outputs[0].connectTo(fileReroute.inputs[0]); - expect(widthPrimitive.widgets.value.value).toBe(512); - widthPrimitive.widgets.value.value = 1024; - expect(schedulerPrimitive.widgets.value.value).toBe("normal"); - schedulerPrimitive.widgets.value.value = "simple"; - expect(filePrimitive.widgets.value.value).toBe("ComfyUI"); - filePrimitive.widgets.value.value = "ComfyTest"; - - await checkBeforeAndAfterReload(graph, async () => { - widthPrimitive = graph.find(widthPrimitive); - schedulerPrimitive = graph.find(schedulerPrimitive); - filePrimitive = graph.find(filePrimitive); - await waitForWidget(filePrimitive); - expect(widthPrimitive.widgets.length).toBe(2); - expect(schedulerPrimitive.widgets.length).toBe(3); - expect(filePrimitive.widgets.length).toBe(1); - - await checkOutput(graph, { - width: 1024, - scheduler: "simple", - filename_prefix: "ComfyTest", - }); - }); - }); - it("can connect primitive via a reroute path to multiple widget inputs", async () => { - const { ez, graph } = await start(); - const nodes = createDefaultWorkflow(ez, graph); - - nodes.empty.widgets.width.convertToInput(); - nodes.empty.widgets.height.convertToInput(); - nodes.empty.widgets.batch_size.convertToInput(); - - let reroute = ez.Reroute(); - let prevReroute = reroute; - for (let i = 0; i < 5; i++) { - const next = ez.Reroute(); - prevReroute.outputs[0].connectTo(next.inputs[0]); - prevReroute = next; - } - - const r1 = ez.Reroute(prevReroute.outputs[0]); - const r2 = ez.Reroute(prevReroute.outputs[0]); - const r3 = ez.Reroute(r2.outputs[0]); - const r4 = ez.Reroute(r2.outputs[0]); - - r1.outputs[0].connectTo(nodes.empty.inputs.width); - r3.outputs[0].connectTo(nodes.empty.inputs.height); - r4.outputs[0].connectTo(nodes.empty.inputs.batch_size); - - let primitive = ez.PrimitiveNode(); - primitive.outputs[0].connectTo(reroute.inputs[0]); - expect(primitive.widgets.value.value).toBe(1); - primitive.widgets.value.value = 64; - - await checkBeforeAndAfterReload(graph, async (r) => { - primitive = graph.find(primitive); - await waitForWidget(primitive); - - // Ensure widget configs are merged - expect(primitive.widgets.value.widget.options?.min).toBe(16); // width/height min - expect(primitive.widgets.value.widget.options?.max).toBe(4096); // batch max - expect(primitive.widgets.value.widget.options?.step).toBe(80); // width/height step * 10 - - await checkOutput(graph, { - width: 64, - height: 64, - batch_size: 64, - }); - }); - }); - }); + beforeEach(() => { + lg.setup(global); + }); + + afterEach(() => { + lg.teardown(global); + }); + + [ + { name: "int", type: "INT", widget: "number", control: 1 }, + { name: "float", type: "FLOAT", widget: "number", control: 1 }, + { name: "text", type: "STRING" }, + { + name: "customtext", + type: "STRING", + opt: { multiline: true }, + }, + { name: "toggle", type: "BOOLEAN" }, + { name: "combo", type: ["a", "b", "c"], control: 2 }, + ].forEach((c) => { + test(`widget conversion + primitive works on ${c.name}`, async () => { + const { ez, graph } = await start({ + mockNodeDefs: makeNodeDef("TestNode", { [c.name]: [c.type, c.opt ?? {}] }), + }); + + // Create test node and convert to input + const n = ez.TestNode(); + const w = n.widgets[c.name]; + w.convertToInput(); + expect(w.isConvertedToInput).toBeTruthy(); + const input = w.getConvertedInput(); + expect(input).toBeTruthy(); + + // @ts-ignore : input is valid here + await connectPrimitiveAndReload(ez, graph, input, c.widget ?? c.name, c.control); + }); + }); + + test("converted widget works after reload", async () => { + const { ez, graph } = await start(); + let n = ez.CheckpointLoaderSimple(); + + const inputCount = n.inputs.length; + + // Convert ckpt name to an input + n.widgets.ckpt_name.convertToInput(); + expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); + expect(n.inputs.ckpt_name).toBeTruthy(); + expect(n.inputs.length).toEqual(inputCount + 1); + + // Convert back to widget and ensure input is removed + n.widgets.ckpt_name.convertToWidget(); + expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); + expect(n.inputs.ckpt_name).toBeFalsy(); + expect(n.inputs.length).toEqual(inputCount); + + // Convert again and reload the graph to ensure it maintains state + n.widgets.ckpt_name.convertToInput(); + expect(n.inputs.length).toEqual(inputCount + 1); + + const primitive = await connectPrimitiveAndReload(ez, graph, n.inputs.ckpt_name, "combo", 2); + + // Disconnect & reconnect + primitive.outputs[0].connections[0].disconnect(); + let { connections } = primitive.outputs[0]; + expect(connections).toHaveLength(0); + + primitive.outputs[0].connectTo(n.inputs.ckpt_name); + ({ connections } = primitive.outputs[0]); + expect(connections).toHaveLength(1); + expect(connections[0].targetNode.id).toBe(n.node.id); + + // Convert back to widget and ensure input is removed + n.widgets.ckpt_name.convertToWidget(); + expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); + expect(n.inputs.ckpt_name).toBeFalsy(); + expect(n.inputs.length).toEqual(inputCount); + }); + + test("converted widget works on clone", async () => { + const { graph, ez } = await start(); + let n = ez.CheckpointLoaderSimple(); + + // Convert the widget to an input + n.widgets.ckpt_name.convertToInput(); + expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); + + // Clone the node + n.menu["Clone"].call(); + expect(graph.nodes).toHaveLength(2); + const clone = graph.nodes[1]; + expect(clone.id).not.toEqual(n.id); + + // Ensure the clone has an input + expect(clone.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); + expect(clone.inputs.ckpt_name).toBeTruthy(); + + // Ensure primitive connects to both nodes + let primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(n.inputs.ckpt_name); + primitive.outputs[0].connectTo(clone.inputs.ckpt_name); + expect(primitive.outputs[0].connections).toHaveLength(2); + + // Convert back to widget and ensure input is removed + clone.widgets.ckpt_name.convertToWidget(); + expect(clone.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); + expect(clone.inputs.ckpt_name).toBeFalsy(); + }); + + test("shows missing node error on custom node with converted input", async () => { + const { graph } = await start(); + + const dialogShow = jest.spyOn(graph.app.ui.dialog, "show"); + + await graph.app.loadGraphData({ + last_node_id: 3, + last_link_id: 4, + nodes: [ + { + id: 1, + type: "TestNode", + pos: [41.87329101561909, 389.7381480823742], + size: { 0: 220, 1: 374 }, + flags: {}, + order: 1, + mode: 0, + inputs: [{ name: "test", type: "FLOAT", link: 4, widget: { name: "test" }, slot_index: 0 }], + outputs: [], + properties: { "Node name for S&R": "TestNode" }, + widgets_values: [1], + }, + { + id: 3, + type: "PrimitiveNode", + pos: [-312, 433], + size: { 0: 210, 1: 82 }, + flags: {}, + order: 0, + mode: 0, + outputs: [{ links: [4], widget: { name: "test" } }], + title: "test", + properties: {}, + }, + ], + links: [[4, 3, 0, 1, 6, "FLOAT"]], + groups: [], + config: {}, + extra: {}, + version: 0.4, + }); + + expect(dialogShow).toBeCalledTimes(1); + // @ts-ignore + expect(dialogShow.mock.calls[0][0].innerHTML).toContain("the following node types were not found"); + // @ts-ignore + expect(dialogShow.mock.calls[0][0].innerHTML).toContain("TestNode"); + }); + + test("defaultInput widgets can be converted back to inputs", async () => { + const { graph, ez } = await start({ + mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { defaultInput: true }] }), + }); + + // Create test node and ensure it starts as an input + let n = ez.TestNode(); + let w = n.widgets.example; + expect(w.isConvertedToInput).toBeTruthy(); + let input = w.getConvertedInput(); + expect(input).toBeTruthy(); + + // Ensure it can be converted to + w.convertToWidget(); + expect(w.isConvertedToInput).toBeFalsy(); + expect(n.inputs.length).toEqual(0); + // and from + w.convertToInput(); + expect(w.isConvertedToInput).toBeTruthy(); + input = w.getConvertedInput(); + + // Reload and ensure it still only has 1 converted widget + if (!assertNotNullOrUndefined(input)) return; + + await connectPrimitiveAndReload(ez, graph, input, "number", 1); + n = graph.find(n); + expect(n.widgets).toHaveLength(1); + w = n.widgets.example; + expect(w.isConvertedToInput).toBeTruthy(); + + // Convert back to widget and ensure it is still a widget after reload + w.convertToWidget(); + await graph.reload(); + n = graph.find(n); + expect(n.widgets).toHaveLength(1); + expect(n.widgets[0].isConvertedToInput).toBeFalsy(); + expect(n.inputs.length).toEqual(0); + }); + + test("forceInput widgets can not be converted back to inputs", async () => { + const { graph, ez } = await start({ + mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { forceInput: true }] }), + }); + + // Create test node and ensure it starts as an input + let n = ez.TestNode(); + let w = n.widgets.example; + expect(w.isConvertedToInput).toBeTruthy(); + const input = w.getConvertedInput(); + expect(input).toBeTruthy(); + + // Convert to widget should error + expect(() => w.convertToWidget()).toThrow(); + + // Reload and ensure it still only has 1 converted widget + if (assertNotNullOrUndefined(input)) { + await connectPrimitiveAndReload(ez, graph, input, "number", 1); + n = graph.find(n); + expect(n.widgets).toHaveLength(1); + expect(n.widgets.example.isConvertedToInput).toBeTruthy(); + } + }); + + test("primitive can connect to matching combos on converted widgets", async () => { + const { ez } = await start({ + mockNodeDefs: { + ...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }), + ...makeNodeDef("TestNode2", { example: [["A", "B", "C"], { forceInput: true }] }), + }, + }); + + const n1 = ez.TestNode1(); + const n2 = ez.TestNode2(); + const p = ez.PrimitiveNode(); + p.outputs[0].connectTo(n1.inputs[0]); + p.outputs[0].connectTo(n2.inputs[0]); + expect(p.outputs[0].connections).toHaveLength(2); + const valueWidget = p.widgets.value; + expect(valueWidget.widget.type).toBe("combo"); + expect(valueWidget.widget.options.values).toEqual(["A", "B", "C"]); + }); + + test("primitive can not connect to non matching combos on converted widgets", async () => { + const { ez } = await start({ + mockNodeDefs: { + ...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }), + ...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }), + }, + }); + + const n1 = ez.TestNode1(); + const n2 = ez.TestNode2(); + const p = ez.PrimitiveNode(); + p.outputs[0].connectTo(n1.inputs[0]); + expect(() => p.outputs[0].connectTo(n2.inputs[0])).toThrow(); + expect(p.outputs[0].connections).toHaveLength(1); + }); + + test("combo output can not connect to non matching combos list input", async () => { + const { ez } = await start({ + mockNodeDefs: { + ...makeNodeDef("TestNode1", {}, [["A", "B"]]), + ...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }), + ...makeNodeDef("TestNode3", { example: [["A", "B", "C"], { forceInput: true }] }), + }, + }); + + const n1 = ez.TestNode1(); + const n2 = ez.TestNode2(); + const n3 = ez.TestNode3(); + + n1.outputs[0].connectTo(n2.inputs[0]); + expect(() => n1.outputs[0].connectTo(n3.inputs[0])).toThrow(); + }); + + test("combo primitive can filter list when control_after_generate called", async () => { + const { ez } = await start({ + mockNodeDefs: { + ...makeNodeDef("TestNode1", { example: [["A", "B", "C", "D", "AA", "BB", "CC", "DD", "AAA", "BBB"], {}] }), + }, + }); + + const n1 = ez.TestNode1(); + n1.widgets.example.convertToInput(); + const p = ez.PrimitiveNode(); + p.outputs[0].connectTo(n1.inputs[0]); + + const value = p.widgets.value; + const control = p.widgets.control_after_generate.widget; + const filter = p.widgets.control_filter_list; + + expect(p.widgets.length).toBe(3); + control.value = "increment"; + expect(value.value).toBe("A"); + + // Manually trigger after queue when set to increment + control["afterQueued"](); + expect(value.value).toBe("B"); + + // Filter to items containing D + filter.value = "D"; + control["afterQueued"](); + expect(value.value).toBe("D"); + control["afterQueued"](); + expect(value.value).toBe("DD"); + + // Check decrement + value.value = "BBB"; + control.value = "decrement"; + filter.value = "B"; + control["afterQueued"](); + expect(value.value).toBe("BB"); + control["afterQueued"](); + expect(value.value).toBe("B"); + + // Check regex works + value.value = "BBB"; + filter.value = "/[AB]|^C$/"; + control["afterQueued"](); + expect(value.value).toBe("AAA"); + control["afterQueued"](); + expect(value.value).toBe("BB"); + control["afterQueued"](); + expect(value.value).toBe("AA"); + control["afterQueued"](); + expect(value.value).toBe("C"); + control["afterQueued"](); + expect(value.value).toBe("B"); + control["afterQueued"](); + expect(value.value).toBe("A"); + + // Check random + control.value = "randomize"; + filter.value = "/D/"; + for (let i = 0; i < 100; i++) { + control["afterQueued"](); + expect(value.value === "D" || value.value === "DD").toBeTruthy(); + } + + // Ensure it doesnt apply when fixed + control.value = "fixed"; + value.value = "B"; + filter.value = "C"; + control["afterQueued"](); + expect(value.value).toBe("B"); + }); + + describe("reroutes", () => { + async function checkOutput(graph, values) { + expect((await graph.toPrompt()).output).toStrictEqual({ + 1: { inputs: { ckpt_name: "model1.safetensors" }, class_type: "CheckpointLoaderSimple" }, + 2: { inputs: { text: "positive", clip: ["1", 1] }, class_type: "CLIPTextEncode" }, + 3: { inputs: { text: "negative", clip: ["1", 1] }, class_type: "CLIPTextEncode" }, + 4: { + inputs: { width: values.width ?? 512, height: values.height ?? 512, batch_size: values?.batch_size ?? 1 }, + class_type: "EmptyLatentImage", + }, + 5: { + inputs: { + seed: 0, + steps: 20, + cfg: 8, + sampler_name: "euler", + scheduler: values?.scheduler ?? "normal", + denoise: 1, + model: ["1", 0], + positive: ["2", 0], + negative: ["3", 0], + latent_image: ["4", 0], + }, + class_type: "KSampler", + }, + 6: { inputs: { samples: ["5", 0], vae: ["1", 2] }, class_type: "VAEDecode" }, + 7: { + inputs: { filename_prefix: values.filename_prefix ?? "ComfyUI", images: ["6", 0] }, + class_type: "SaveImage", + }, + }); + } + + async function waitForWidget(node) { + // widgets are created slightly after the graph is ready + // hard to find an exact hook to get these so just wait for them to be ready + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 10)); + if (node.widgets?.value) { + return; + } + } + } + + it("can connect primitive via a reroute path to a widget input", async () => { + const { ez, graph } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + nodes.empty.widgets.width.convertToInput(); + nodes.sampler.widgets.scheduler.convertToInput(); + nodes.save.widgets.filename_prefix.convertToInput(); + + let widthReroute = ez.Reroute(); + let schedulerReroute = ez.Reroute(); + let fileReroute = ez.Reroute(); + + let widthNext = widthReroute; + let schedulerNext = schedulerReroute; + let fileNext = fileReroute; + + for (let i = 0; i < 5; i++) { + let next = ez.Reroute(); + widthNext.outputs[0].connectTo(next.inputs[0]); + widthNext = next; + + next = ez.Reroute(); + schedulerNext.outputs[0].connectTo(next.inputs[0]); + schedulerNext = next; + + next = ez.Reroute(); + fileNext.outputs[0].connectTo(next.inputs[0]); + fileNext = next; + } + + widthNext.outputs[0].connectTo(nodes.empty.inputs.width); + schedulerNext.outputs[0].connectTo(nodes.sampler.inputs.scheduler); + fileNext.outputs[0].connectTo(nodes.save.inputs.filename_prefix); + + let widthPrimitive = ez.PrimitiveNode(); + let schedulerPrimitive = ez.PrimitiveNode(); + let filePrimitive = ez.PrimitiveNode(); + + widthPrimitive.outputs[0].connectTo(widthReroute.inputs[0]); + schedulerPrimitive.outputs[0].connectTo(schedulerReroute.inputs[0]); + filePrimitive.outputs[0].connectTo(fileReroute.inputs[0]); + expect(widthPrimitive.widgets.value.value).toBe(512); + widthPrimitive.widgets.value.value = 1024; + expect(schedulerPrimitive.widgets.value.value).toBe("normal"); + schedulerPrimitive.widgets.value.value = "simple"; + expect(filePrimitive.widgets.value.value).toBe("ComfyUI"); + filePrimitive.widgets.value.value = "ComfyTest"; + + await checkBeforeAndAfterReload(graph, async () => { + widthPrimitive = graph.find(widthPrimitive); + schedulerPrimitive = graph.find(schedulerPrimitive); + filePrimitive = graph.find(filePrimitive); + await waitForWidget(filePrimitive); + expect(widthPrimitive.widgets.length).toBe(2); + expect(schedulerPrimitive.widgets.length).toBe(3); + expect(filePrimitive.widgets.length).toBe(1); + + await checkOutput(graph, { + width: 1024, + scheduler: "simple", + filename_prefix: "ComfyTest", + }); + }); + }); + it("can connect primitive via a reroute path to multiple widget inputs", async () => { + const { ez, graph } = await start(); + const nodes = createDefaultWorkflow(ez, graph); + + nodes.empty.widgets.width.convertToInput(); + nodes.empty.widgets.height.convertToInput(); + nodes.empty.widgets.batch_size.convertToInput(); + + let reroute = ez.Reroute(); + let prevReroute = reroute; + for (let i = 0; i < 5; i++) { + const next = ez.Reroute(); + prevReroute.outputs[0].connectTo(next.inputs[0]); + prevReroute = next; + } + + const r1 = ez.Reroute(prevReroute.outputs[0]); + const r2 = ez.Reroute(prevReroute.outputs[0]); + const r3 = ez.Reroute(r2.outputs[0]); + const r4 = ez.Reroute(r2.outputs[0]); + + r1.outputs[0].connectTo(nodes.empty.inputs.width); + r3.outputs[0].connectTo(nodes.empty.inputs.height); + r4.outputs[0].connectTo(nodes.empty.inputs.batch_size); + + let primitive = ez.PrimitiveNode(); + primitive.outputs[0].connectTo(reroute.inputs[0]); + expect(primitive.widgets.value.value).toBe(1); + primitive.widgets.value.value = 64; + + await checkBeforeAndAfterReload(graph, async (r) => { + primitive = graph.find(primitive); + await waitForWidget(primitive); + + // Ensure widget configs are merged + expect(primitive.widgets.value.widget.options?.min).toBe(16); // width/height min + expect(primitive.widgets.value.widget.options?.max).toBe(4096); // batch max + expect(primitive.widgets.value.widget.options?.step).toBe(80); // width/height step * 10 + + await checkOutput(graph, { + width: 64, + height: 64, + batch_size: 64, + }); + }); + }); + }); }); diff --git a/tests-ui/utils/ezgraph.ts b/tests-ui/utils/ezgraph.ts index 12453507d..a6a7d287c 100644 --- a/tests-ui/utils/ezgraph.ts +++ b/tests-ui/utils/ezgraph.ts @@ -15,441 +15,441 @@ export type EzNameSpace = Record EzNode>; export class EzConnection { - /** @type { app } */ - app; - /** @type { InstanceType } */ - link; + /** @type { app } */ + app; + /** @type { InstanceType } */ + link; - get originNode() { - return new EzNode(this.app, this.app.graph.getNodeById(this.link.origin_id)); - } + 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 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 targetNode() { + return new EzNode(this.app, this.app.graph.getNodeById(this.link.target_id)); + } - get targetInput() { - return this.targetNode.inputs[this.link.target_slot]; - } + 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; - } + /** + * @param { app } app + * @param { InstanceType } link + */ + constructor(app, link) { + this.app = app; + this.link = link; + } - disconnect() { - this.targetInput.disconnect(); - } + disconnect() { + this.targetInput.disconnect(); + } } export class EzSlot { - /** @type { EzNode } */ - node; - /** @type { number } */ - index; + /** @type { EzNode } */ + node; + /** @type { number } */ + index; - /** - * @param { EzNode } node - * @param { number } index - */ - constructor(node, index) { - this.node = node; - this.index = index; - } + /** + * @param { EzNode } node + * @param { number } index + */ + constructor(node, index) { + this.node = node; + this.index = index; + } } export class EzInput extends EzSlot { - /** @type { INodeInputSlot } */ - input; + /** @type { INodeInputSlot } */ + input; - /** - * @param { EzNode } node - * @param { number } index - * @param { INodeInputSlot } input - */ - constructor(node, index, input) { - super(node, index); - this.input = 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]); - } + 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); - } + disconnect() { + this.node.node.disconnectInput(this.index); + } } export class EzOutput extends EzSlot { - /** @type { INodeOutputSlot } */ - output; + /** @type { INodeOutputSlot } */ + output; - /** - * @param { EzNode } node - * @param { number } index - * @param { INodeOutputSlot } output - */ - constructor(node, index, output) { - super(node, index); - this.output = 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]) - ); - } + 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"); + /** + * @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; - } + /** + * @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; + /** @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; - } + /** + * @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); - } + 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; + /** @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; - } + /** + * @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; - } + get value() { + return this.widget.value; + } - set value(v) { - this.widget.value = v; - this.widget.callback?.call?.(this.widget, v) - } + 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"; - } + 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.`); + 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); - } + 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 Input to Widget"].item.submenu.options; - var index = menu.findIndex(a => a.content == `Convert ${this.widget.name} to widget`); - menu[index].callback.call(); - } + 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 Input 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 Widget to Input"].item.submenu.options; - var index = menu.findIndex(a => a.content == `Convert ${this.widget.name} to input`); - 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 Widget to Input"].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; + /** @type { app } */ + app; + /** @type { LGNode } */ + node; - /** - * @param { app } app - * @param { LGNode } node - */ - constructor(app, node) { - this.app = app; - this.node = node; - } + /** + * @param { app } app + * @param { LGNode } node + */ + constructor(app, node) { + this.app = app; + this.node = node; + } - get id() { - return this.node.id; - } + get id() { + return this.node.id; + } - get inputs() { - return this.#makeLookupArray("inputs", "name", EzInput); - } + get inputs() { + return this.#makeLookupArray("inputs", "name", EzInput); + } - get outputs() { - return this.#makeLookupArray("outputs", "name", EzOutput); - } + get outputs() { + return this.#makeLookupArray("outputs", "name", EzOutput); + } - get widgets() { - return this.#makeLookupArray("widgets", "name", EzWidget); - } + get widgets() { + return this.#makeLookupArray("widgets", "name", EzWidget); + } - get menu() { - return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem); - } + get menu() { + return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem); + } - get isRemoved() { - return !this.app.graph.getNodeById(this.id); - } + get isRemoved() { + return !this.app.graph.getNodeById(this.id); + } - select(addToSelection = false) { - this.app.canvas.selectNode(this.node, addToSelection); - } + 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 { "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; + /** + * @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 })); - } + 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; + /** @type { app } */ + app; - /** - * @param { app } app - */ - constructor(app) { - this.app = app; - } + /** + * @param { app } app + */ + constructor(app) { + this.app = app; + } - get nodes() { - return this.app.graph._nodes.map((n) => new EzNode(this.app, n)); - } + get nodes() { + return this.app.graph._nodes.map((n) => new EzNode(this.app, n)); + } - clear() { - this.app.graph.clear(); - } + clear() { + this.app.graph.clear(); + } - arrange() { - this.app.graph.arrange(); - } + arrange() { + this.app.graph.arrange(); + } - stringify() { - return JSON.stringify(this.app.graph.serialize(), undefined); - } + 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; - } + /** + * @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); + match = this.app.graph.getNodeById(id); - if (!match) { - throw new Error(`Unable to find node with ID ${id}.`); - } + if (!match) { + throw new Error(`Unable to find node with ID ${id}.`); + } - return new EzNode(this.app, match); - } + 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); - // @ts-ignore - r(); - }, 10); - }); - } + /** + * @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); + // @ts-ignore + r(); + }, 10); + }); + } - /** - * @returns { Promise<{ - * workflow: {}, - * output: Record - * }>}> } - */ - toPrompt() { - // @ts-ignore - return this.app.graphToPrompt(); - } + /** + * @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; + /** + * 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(); - } + 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); + // @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; + /** + * @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]; - } - } - } + 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 ezNode; + }; + }, + } + ); - return { graph: new EzGraph(app), ez: factory }; - }, + return { graph: new EzGraph(app), ez: factory }; + }, }; diff --git a/tests-ui/utils/index.ts b/tests-ui/utils/index.ts index c9054443c..450eb893b 100644 --- a/tests-ui/utils/index.ts +++ b/tests-ui/utils/index.ts @@ -7,45 +7,45 @@ import path from "path"; const html = fs.readFileSync(path.resolve(__dirname, "../../index.html")) interface StartConfig extends APIConfig { - resetEnv?: boolean; - preSetup?(app): Promise; - localStorage?: Record; + resetEnv?: boolean; + preSetup?(app): Promise; + localStorage?: Record; } interface StartResult { - app: any; - graph: EzGraph; - ez: EzNameSpace; + app: any; + graph: EzGraph; + ez: EzNameSpace; } /** * * @param { Parameters[0] & { - * resetEnv?: boolean, - * preSetup?(app): Promise, + * resetEnv?: boolean, + * preSetup?(app): Promise, * localStorage?: Record * } } config * @returns */ export async function start(config: StartConfig = {}): Promise { - if(config.resetEnv) { - jest.resetModules(); - jest.resetAllMocks(); + if(config.resetEnv) { + jest.resetModules(); + jest.resetAllMocks(); lg.setup(global); - localStorage.clear(); - sessionStorage.clear(); - } + localStorage.clear(); + sessionStorage.clear(); + } - Object.assign(localStorage, config.localStorage ?? {}); - document.body.innerHTML = html.toString(); + Object.assign(localStorage, config.localStorage ?? {}); + document.body.innerHTML = html.toString(); - mockApi(config); - const { app } = await import("../../src/scripts/app"); - config.preSetup?.(app); - await app.setup(); + mockApi(config); + const { app } = await import("../../src/scripts/app"); + config.preSetup?.(app); + await app.setup(); - // @ts-ignore - return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app }; + // @ts-ignore + return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app }; } /** @@ -53,9 +53,9 @@ export async function start(config: StartConfig = {}): Promise { * @param { (hasReloaded: boolean) => (Promise | void) } cb */ export async function checkBeforeAndAfterReload(graph, cb) { - await cb(false); - await graph.reload(); - await cb(true); + await cb(false); + await graph.reload(); + await cb(true); } /** @@ -65,35 +65,35 @@ export async function checkBeforeAndAfterReload(graph, cb) { * @returns { Record } */ export function makeNodeDef(name, input, output = {}) { - const nodeDef = { - name, - category: "test", - output: [], - output_name: [], - output_is_list: [], - input: { - required: {}, - }, - }; - for (const k in input) { - nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]]; - } - if (output instanceof Array) { - output = output.reduce((p, c) => { - p[c] = c; - return p; - }, {}); - } - for (const k in output) { - // @ts-ignore - nodeDef.output.push(output[k]); - // @ts-ignore - nodeDef.output_name.push(k); - // @ts-ignore - nodeDef.output_is_list.push(false); - } + const nodeDef = { + name, + category: "test", + output: [], + output_name: [], + output_is_list: [], + input: { + required: {}, + }, + }; + for (const k in input) { + nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]]; + } + if (output instanceof Array) { + output = output.reduce((p, c) => { + p[c] = c; + return p; + }, {}); + } + for (const k in output) { + // @ts-ignore + nodeDef.output.push(output[k]); + // @ts-ignore + nodeDef.output_name.push(k); + // @ts-ignore + nodeDef.output_is_list.push(false); + } - return { [name]: nodeDef }; + return { [name]: nodeDef }; } /** @@ -103,9 +103,9 @@ export function makeNodeDef(name, input, output = {}) { * @returns { x is Exclude } */ export function assertNotNullOrUndefined(x) { - expect(x).not.toEqual(null); - expect(x).not.toEqual(undefined); - return true; + expect(x).not.toEqual(null); + expect(x).not.toEqual(undefined); + return true; } /** @@ -114,32 +114,32 @@ export function assertNotNullOrUndefined(x) { * @param { ReturnType["graph"] } graph */ export function createDefaultWorkflow(ez, graph) { - graph.clear(); - const ckpt = ez.CheckpointLoaderSimple(); + 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 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 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(); + 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 }; + return { ckpt, pos, neg, empty, sampler, decode, save }; } export async function getNodeDefs() { - const { api } = await import("../../src/scripts/api"); - return api.getNodeDefs(); + const { api } = await import("../../src/scripts/api"); + return api.getNodeDefs(); } export async function getNodeDef(nodeId) { - return (await getNodeDefs())[nodeId]; + return (await getNodeDefs())[nodeId]; } \ No newline at end of file diff --git a/tests-ui/utils/litegraph.ts b/tests-ui/utils/litegraph.ts index 2dbe7efb6..cba502c75 100644 --- a/tests-ui/utils/litegraph.ts +++ b/tests-ui/utils/litegraph.ts @@ -3,37 +3,37 @@ import path from "path"; import { nop } from "../utils/nopProxy"; function forEachKey(cb) { - for (const k of [ - "LiteGraph", - "LGraph", - "LLink", - "LGraphNode", - "LGraphGroup", - "DragAndScale", - "LGraphCanvas", - "ContextMenu", - ]) { - cb(k); - } + for (const k of [ + "LiteGraph", + "LGraph", + "LLink", + "LGraphNode", + "LGraphGroup", + "DragAndScale", + "LGraphCanvas", + "ContextMenu", + ]) { + cb(k); + } } export default { - setup(ctx) { - const lg = fs.readFileSync(path.resolve("./src/lib/litegraph.core.js"), "utf-8"); - const globalTemp = {}; - (function (console) { - eval(lg); - }).call(globalTemp, nop); + setup(ctx) { + const lg = fs.readFileSync(path.resolve("./src/lib/litegraph.core.js"), "utf-8"); + const globalTemp = {}; + (function (console) { + eval(lg); + }).call(globalTemp, nop); - forEachKey((k) => (ctx[k] = globalTemp[k])); - const lg_ext = fs.readFileSync(path.resolve("./src/lib/litegraph.extensions.js"), "utf-8"); - eval(lg_ext); - }, + forEachKey((k) => (ctx[k] = globalTemp[k])); + const lg_ext = fs.readFileSync(path.resolve("./src/lib/litegraph.extensions.js"), "utf-8"); + eval(lg_ext); + }, - teardown(ctx) { - forEachKey((k) => delete ctx[k]); + teardown(ctx) { + forEachKey((k) => delete ctx[k]); - // Clear document after each run - document.getElementsByTagName("html")[0].innerHTML = ""; - } + // Clear document after each run + document.getElementsByTagName("html")[0].innerHTML = ""; + } }; \ No newline at end of file diff --git a/tests-ui/utils/nopProxy.ts b/tests-ui/utils/nopProxy.ts index 2502d9d03..ddd69a150 100644 --- a/tests-ui/utils/nopProxy.ts +++ b/tests-ui/utils/nopProxy.ts @@ -1,6 +1,6 @@ export const nop = new Proxy(function () {}, { - get: () => nop, - set: () => true, - apply: () => nop, - construct: () => nop, + get: () => nop, + set: () => true, + apply: () => nop, + construct: () => nop, }); diff --git a/tests-ui/utils/setup.ts b/tests-ui/utils/setup.ts index 2183e211d..37392958a 100644 --- a/tests-ui/utils/setup.ts +++ b/tests-ui/utils/setup.ts @@ -3,22 +3,22 @@ import "../../src/scripts/api"; const fs = require("fs"); const path = require("path"); function* walkSync(dir: string): Generator { - const files = fs.readdirSync(dir, { withFileTypes: true }); - for (const file of files) { - if (file.isDirectory()) { - yield* walkSync(path.join(dir, file.name)); - } else { - yield path.join(dir, file.name); - } - } + const files = fs.readdirSync(dir, { withFileTypes: true }); + for (const file of files) { + if (file.isDirectory()) { + yield* walkSync(path.join(dir, file.name)); + } else { + yield path.join(dir, file.name); + } + } } export interface APIConfig { - mockExtensions?: string[]; - mockNodeDefs?: Record; - settings?: Record; - userConfig?: { storage: "server" | "browser"; users?: Record; migrated?: boolean }; - userData?: Record; + mockExtensions?: string[]; + mockNodeDefs?: Record; + settings?: Record; + userConfig?: { storage: "server" | "browser"; users?: Record; migrated?: boolean }; + userData?: Record; } /** @@ -27,66 +27,66 @@ export interface APIConfig { /** * @param {{ - * mockExtensions?: string[], - * mockNodeDefs?: Record, -* settings?: Record -* userConfig?: {storage: "server" | "browser", users?: Record, migrated?: boolean }, -* userData?: Record + * mockExtensions?: string[], + * mockNodeDefs?: Record, +* settings?: Record +* userConfig?: {storage: "server" | "browser", users?: Record, migrated?: boolean }, +* userData?: Record * }} config */ export function mockApi(config: APIConfig = {}) { - let { mockExtensions, mockNodeDefs, userConfig, settings, userData } = { - settings: {}, - userData: {}, - ...config, - }; - if (!mockExtensions) { - mockExtensions = Array.from(walkSync(path.resolve("./src/extensions/core"))) - .filter((x) => x.endsWith(".js")) - .map((x) => path.relative(path.resolve("./src/"), x).replace(/\\/g, "/")); - } - if (!mockNodeDefs) { - mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./tests-ui/data/object_info.json"))); - } + let { mockExtensions, mockNodeDefs, userConfig, settings, userData } = { + settings: {}, + userData: {}, + ...config, + }; + if (!mockExtensions) { + mockExtensions = Array.from(walkSync(path.resolve("./src/extensions/core"))) + .filter((x) => x.endsWith(".js")) + .map((x) => path.relative(path.resolve("./src/"), x).replace(/\\/g, "/")); + } + if (!mockNodeDefs) { + mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./tests-ui/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) => "src/" + x), - fileURL: jest.fn((x) => "src/" + x), - createUser: jest.fn((username) => { - // @ts-ignore - if(username in userConfig.users) { - return { status: 400, json: () => "Duplicate" } - } - // @ts-ignore - userConfig.users[username + "!"] = username; - return { status: 200, json: () => username + "!" } - }), - getUserConfig: jest.fn(() => userConfig ?? { storage: "browser", migrated: false }), - getSettings: jest.fn(() => settings), - storeSettings: jest.fn((v) => Object.assign(settings, v)), - getUserData: jest.fn((f) => { - if (f in userData) { - return { status: 200, json: () => userData[f] }; - } else { - return { status: 404 }; - } - }), - storeUserData: jest.fn((file, data) => { - userData[file] = data; - }), - listUserData: jest.fn(() => []), - }; - jest.mock("../../src/scripts/api", () => ({ - get api() { - return mockApi; - }, - })); + 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) => "src/" + x), + fileURL: jest.fn((x) => "src/" + x), + createUser: jest.fn((username) => { + // @ts-ignore + if(username in userConfig.users) { + return { status: 400, json: () => "Duplicate" } + } + // @ts-ignore + userConfig.users[username + "!"] = username; + return { status: 200, json: () => username + "!" } + }), + getUserConfig: jest.fn(() => userConfig ?? { storage: "browser", migrated: false }), + getSettings: jest.fn(() => settings), + storeSettings: jest.fn((v) => Object.assign(settings, v)), + getUserData: jest.fn((f) => { + if (f in userData) { + return { status: 200, json: () => userData[f] }; + } else { + return { status: 404 }; + } + }), + storeUserData: jest.fn((file, data) => { + userData[file] = data; + }), + listUserData: jest.fn(() => []), + }; + jest.mock("../../src/scripts/api", () => ({ + get api() { + return mockApi; + }, + })); } diff --git a/vite.config.mts b/vite.config.mts index 87e5219d9..fe46fb7e6 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -7,115 +7,115 @@ dotenv.config(); const IS_DEV = process.env.NODE_ENV === 'development'; interface ShimResult { - code: string; - exports: string[]; + code: string; + exports: string[]; } function comfyAPIPlugin(): Plugin { - return { - name: 'comfy-api-plugin', - transform(code: string, id: string) { - if (IS_DEV) - return null; + return { + name: 'comfy-api-plugin', + transform(code: string, id: string) { + if (IS_DEV) + return null; - // TODO: Remove second condition after all js files are converted to ts - if (id.endsWith('.ts') || (id.endsWith('.js') && id.includes("extensions/core"))) { - const result = transformExports(code, id); + // TODO: Remove second condition after all js files are converted to ts + if (id.endsWith('.ts') || (id.endsWith('.js') && id.includes("extensions/core"))) { + const result = transformExports(code, id); - if (result.exports.length > 0) { - const projectRoot = process.cwd(); - const relativePath = path.relative(path.join(projectRoot, 'src'), id); - const shimFileName = relativePath.replace(/\.ts$/, '.js'); + if (result.exports.length > 0) { + const projectRoot = process.cwd(); + const relativePath = path.relative(path.join(projectRoot, 'src'), id); + const shimFileName = relativePath.replace(/\.ts$/, '.js'); - const shimComment = `// Shim for ${relativePath}\n`; + const shimComment = `// Shim for ${relativePath}\n`; - this.emitFile({ - type: "asset", - fileName: shimFileName, - source: shimComment + result.exports.join(""), - }); - } + this.emitFile({ + type: "asset", + fileName: shimFileName, + source: shimComment + result.exports.join(""), + }); + } - return { - code: result.code, - map: null // If you're not modifying the source map, return null - }; - } - } - }; + return { + code: result.code, + map: null // If you're not modifying the source map, return null + }; + } + } + }; } function transformExports(code: string, id: string): ShimResult { - const moduleName = getModuleName(id); - const exports: string[] = []; - let newCode = code; + const moduleName = getModuleName(id); + const exports: string[] = []; + let newCode = code; - // Regex to match different types of exports - const regex = /export\s+(const|let|var|function|class|async function)\s+([a-zA-Z$_][a-zA-Z\d$_]*)(\s|\()/g; - let match; + // Regex to match different types of exports + const regex = /export\s+(const|let|var|function|class|async function)\s+([a-zA-Z$_][a-zA-Z\d$_]*)(\s|\()/g; + let match; - while ((match = regex.exec(code)) !== null) { - const name = match[2]; - // All exports should be bind to the window object as new API endpoint. - if (exports.length == 0) { - newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`; - newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`; - } - newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`; - exports.push(`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`); - } + while ((match = regex.exec(code)) !== null) { + const name = match[2]; + // All exports should be bind to the window object as new API endpoint. + if (exports.length == 0) { + newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`; + newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`; + } + newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`; + exports.push(`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`); + } - return { - code: newCode, - exports, - }; + return { + code: newCode, + exports, + }; } function getModuleName(id: string): string { - // Simple example to derive a module name from the file path - const parts = id.split('/'); - const fileName = parts[parts.length - 1]; - return fileName.replace(/\.\w+$/, ''); // Remove file extension + // Simple example to derive a module name from the file path + const parts = id.split('/'); + const fileName = parts[parts.length - 1]; + return fileName.replace(/\.\w+$/, ''); // Remove file extension } export default defineConfig({ - server: { - proxy: { - '/api': { - target: process.env.DEV_SERVER_COMFYUI_URL || 'http://127.0.0.1:8188', - // Return empty array for extensions API as these modules - // are not on vite's dev server. - bypass: (req, res, options) => { - if (req.url === '/api/extensions') { - res.end(JSON.stringify([])); - } - return null; - }, - }, - '/ws': { - target: 'ws://127.0.0.1:8188', - ws: true, - }, - } - }, - plugins: [ - comfyAPIPlugin(), - viteStaticCopy({ - targets: [ - { src: "src/lib/*", dest: "lib/" }, - ], - }), - ], - build: { - minify: false, - sourcemap: true, - rollupOptions: { - // Disabling tree-shaking - // Prevent vite remove unused exports - treeshake: false - } - }, - define: { - '__COMFYUI_FRONTEND_VERSION__': JSON.stringify(process.env.npm_package_version), - }, + server: { + proxy: { + '/api': { + target: process.env.DEV_SERVER_COMFYUI_URL || 'http://127.0.0.1:8188', + // Return empty array for extensions API as these modules + // are not on vite's dev server. + bypass: (req, res, options) => { + if (req.url === '/api/extensions') { + res.end(JSON.stringify([])); + } + return null; + }, + }, + '/ws': { + target: 'ws://127.0.0.1:8188', + ws: true, + }, + } + }, + plugins: [ + comfyAPIPlugin(), + viteStaticCopy({ + targets: [ + { src: "src/lib/*", dest: "lib/" }, + ], + }), + ], + build: { + minify: false, + sourcemap: true, + rollupOptions: { + // Disabling tree-shaking + // Prevent vite remove unused exports + treeshake: false + } + }, + define: { + '__COMFYUI_FRONTEND_VERSION__': JSON.stringify(process.env.npm_package_version), + }, }); \ No newline at end of file