Replace \t with spaces (#80)

This commit is contained in:
Chenlei Hu
2024-07-02 12:29:17 -04:00
committed by GitHub
parent 38fdd19e5a
commit fc020e08c5
39 changed files with 7503 additions and 7503 deletions

View File

@@ -9,423 +9,423 @@ import type { LGraphNode, LGraphNodeConstructor } from "/types/litegraph";
const ORDER: symbol = Symbol(); const ORDER: symbol = Symbol();
function merge(target, source) { function merge(target, source) {
if (typeof target === "object" && typeof source === "object") { if (typeof target === "object" && typeof source === "object") {
for (const key in source) { for (const key in source) {
const sv = source[key]; const sv = source[key];
if (typeof sv === "object") { if (typeof sv === "object") {
let tv = target[key]; let tv = target[key];
if (!tv) tv = target[key] = {}; if (!tv) tv = target[key] = {};
merge(tv, source[key]); merge(tv, source[key]);
} else { } else {
target[key] = sv; target[key] = sv;
} }
} }
} }
return target; return target;
} }
export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> { export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
tabs: Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}>; tabs: Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}>;
selectedNodeIndex: number | null | undefined; selectedNodeIndex: number | null | undefined;
selectedTab: keyof ManageGroupDialog["tabs"] = "Inputs"; selectedTab: keyof ManageGroupDialog["tabs"] = "Inputs";
selectedGroup: string | undefined; selectedGroup: string | undefined;
modifications: Record<string, Record<string, Record<string, { name?: string | undefined, visible?: boolean | undefined }>>> = {}; modifications: Record<string, Record<string, Record<string, { name?: string | undefined, visible?: boolean | undefined }>>> = {};
nodeItems: any[]; nodeItems: any[];
app: ComfyApp; app: ComfyApp;
groupNodeType: LGraphNodeConstructor<LGraphNode>; groupNodeType: LGraphNodeConstructor<LGraphNode>;
groupNodeDef: any; groupNodeDef: any;
groupData: any; groupData: any;
innerNodesList: HTMLUListElement; innerNodesList: HTMLUListElement;
widgetsPage: HTMLElement; widgetsPage: HTMLElement;
inputsPage: HTMLElement; inputsPage: HTMLElement;
outputsPage: HTMLElement; outputsPage: HTMLElement;
draggable: any; draggable: any;
get selectedNodeInnerIndex() { get selectedNodeInnerIndex() {
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex; return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex;
} }
constructor(app) { constructor(app) {
super(); super();
this.app = app; this.app = app;
this.element = $el("dialog.comfy-group-manage", { this.element = $el("dialog.comfy-group-manage", {
parent: document.body, parent: document.body,
}) as HTMLDialogElement; }) as HTMLDialogElement;
} }
changeTab(tab) { changeTab(tab) {
this.tabs[this.selectedTab].tab.classList.remove("active"); this.tabs[this.selectedTab].tab.classList.remove("active");
this.tabs[this.selectedTab].page.classList.remove("active"); this.tabs[this.selectedTab].page.classList.remove("active");
this.tabs[tab].tab.classList.add("active"); this.tabs[tab].tab.classList.add("active");
this.tabs[tab].page.classList.add("active"); this.tabs[tab].page.classList.add("active");
this.selectedTab = tab; this.selectedTab = tab;
} }
changeNode(index, force?) { changeNode(index, force?) {
if (!force && this.selectedNodeIndex === index) return; if (!force && this.selectedNodeIndex === index) return;
if (this.selectedNodeIndex != null) { if (this.selectedNodeIndex != null) {
this.nodeItems[this.selectedNodeIndex].classList.remove("selected"); this.nodeItems[this.selectedNodeIndex].classList.remove("selected");
} }
this.nodeItems[index].classList.add("selected"); this.nodeItems[index].classList.add("selected");
this.selectedNodeIndex = index; this.selectedNodeIndex = index;
if (!this.buildInputsPage() && this.selectedTab === "Inputs") { if (!this.buildInputsPage() && this.selectedTab === "Inputs") {
this.changeTab("Widgets"); this.changeTab("Widgets");
} }
if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") { if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") {
this.changeTab("Outputs"); this.changeTab("Outputs");
} }
if (!this.buildOutputsPage() && this.selectedTab === "Outputs") { if (!this.buildOutputsPage() && this.selectedTab === "Outputs") {
this.changeTab("Inputs"); this.changeTab("Inputs");
} }
this.changeTab(this.selectedTab); this.changeTab(this.selectedTab);
} }
getGroupData() { getGroupData() {
this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup]; this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup];
this.groupNodeDef = this.groupNodeType.nodeData; this.groupNodeDef = this.groupNodeType.nodeData;
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType); this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType);
} }
changeGroup(group, reset = true) { changeGroup(group, reset = true) {
this.selectedGroup = group; this.selectedGroup = group;
this.getGroupData(); this.getGroupData();
const nodes = this.groupData.nodeData.nodes; const nodes = this.groupData.nodeData.nodes;
this.nodeItems = nodes.map((n, i) => this.nodeItems = nodes.map((n, i) =>
$el( $el(
"li.draggable-item", "li.draggable-item",
{ {
dataset: { dataset: {
nodeindex: n.index + "", nodeindex: n.index + "",
}, },
onclick: () => { onclick: () => {
this.changeNode(i); this.changeNode(i);
}, },
}, },
[ [
$el("span.drag-handle"), $el("span.drag-handle"),
$el( $el(
"div", "div",
{ {
textContent: n.title ?? n.type, textContent: n.title ?? n.type,
}, },
n.title n.title
? $el("span", { ? $el("span", {
textContent: n.type, textContent: n.type,
}) })
: [] : []
), ),
] ]
) )
); );
this.innerNodesList.replaceChildren(...this.nodeItems); this.innerNodesList.replaceChildren(...this.nodeItems);
if (reset) { if (reset) {
this.selectedNodeIndex = null; this.selectedNodeIndex = null;
this.changeNode(0); this.changeNode(0);
} else { } else {
const items = this.draggable.getAllItems(); const items = this.draggable.getAllItems();
let index = items.findIndex(item => item.classList.contains("selected")); let index = items.findIndex(item => item.classList.contains("selected"));
if(index === -1) index = this.selectedNodeIndex; if(index === -1) index = this.selectedNodeIndex;
this.changeNode(index, true); this.changeNode(index, true);
} }
const ordered = [...nodes]; const ordered = [...nodes];
this.draggable?.dispose(); this.draggable?.dispose();
this.draggable = new DraggableList(this.innerNodesList, "li"); this.draggable = new DraggableList(this.innerNodesList, "li");
this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => { this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => {
if (oldPosition === newPosition) return; if (oldPosition === newPosition) return;
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]); ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]);
for (let i = 0; i < ordered.length; i++) { for (let i = 0; i < ordered.length; i++) {
this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i }); this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i });
} }
}); });
} }
storeModification(props: { nodeIndex?: number; section: symbol; prop: string; value: any }) { storeModification(props: { nodeIndex?: number; section: symbol; prop: string; value: any }) {
const { nodeIndex, section, prop, value } = props; const { nodeIndex, section, prop, value } = props;
const groupMod = (this.modifications[this.selectedGroup] ??= {}); const groupMod = (this.modifications[this.selectedGroup] ??= {});
const nodesMod = (groupMod.nodes ??= {}); const nodesMod = (groupMod.nodes ??= {});
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}); const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {});
const typeMod = (nodeMod[section] ??= {}); const typeMod = (nodeMod[section] ??= {});
if (typeof value === "object") { if (typeof value === "object") {
const objMod = (typeMod[prop] ??= {}); const objMod = (typeMod[prop] ??= {});
Object.assign(objMod, value); Object.assign(objMod, value);
} else { } else {
typeMod[prop] = value; typeMod[prop] = value;
} }
} }
getEditElement(section, prop, value, placeholder, checked, checkable = true) { getEditElement(section, prop, value, placeholder, checked, checkable = true) {
if (value === placeholder) value = ""; if (value === placeholder) value = "";
const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop]; const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop];
if (mods) { if (mods) {
if (mods.name != null) { if (mods.name != null) {
value = mods.name; value = mods.name;
} }
if (mods.visible != null) { if (mods.visible != null) {
checked = mods.visible; checked = mods.visible;
} }
} }
return $el("div", [ return $el("div", [
$el("input", { $el("input", {
value, value,
placeholder, placeholder,
type: "text", type: "text",
onchange: (e) => { onchange: (e) => {
this.storeModification({ section, prop, value: { name: e.target.value } }); this.storeModification({ section, prop, value: { name: e.target.value } });
}, },
}), }),
$el("label", { textContent: "Visible" }, [ $el("label", { textContent: "Visible" }, [
$el("input", { $el("input", {
type: "checkbox", type: "checkbox",
checked, checked,
disabled: !checkable, disabled: !checkable,
onchange: (e) => { onchange: (e) => {
this.storeModification({ section, prop, value: { visible: !!e.target.checked } }); this.storeModification({ section, prop, value: { visible: !!e.target.checked } });
}, },
}), }),
]), ]),
]); ]);
} }
buildWidgetsPage() { buildWidgetsPage() {
const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]; const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex];
const items = Object.keys(widgets ?? {}); const items = Object.keys(widgets ?? {});
const type = app.graph.extra.groupNodes[this.selectedGroup]; const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.input; const config = type.config?.[this.selectedNodeInnerIndex]?.input;
this.widgetsPage.replaceChildren( this.widgetsPage.replaceChildren(
...items.map((oldName) => { ...items.map((oldName) => {
return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false); return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false);
}) })
); );
return !!items.length; return !!items.length;
} }
buildInputsPage() { buildInputsPage() {
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]; const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex];
const items = Object.keys(inputs ?? {}); const items = Object.keys(inputs ?? {});
const type = app.graph.extra.groupNodes[this.selectedGroup]; const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.input; const config = type.config?.[this.selectedNodeInnerIndex]?.input;
this.inputsPage.replaceChildren( this.inputsPage.replaceChildren(
...items ...items
.map((oldName) => { .map((oldName) => {
let value = inputs[oldName]; let value = inputs[oldName];
if (!value) { if (!value) {
return; return;
} }
return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false); return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false);
}) })
.filter(Boolean) .filter(Boolean)
); );
return !!items.length; return !!items.length;
} }
buildOutputsPage() { buildOutputsPage() {
const nodes = this.groupData.nodeData.nodes; const nodes = this.groupData.nodeData.nodes;
const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]); const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]);
const outputs = innerNodeDef?.output ?? []; const outputs = innerNodeDef?.output ?? [];
const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]; const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex];
const type = app.graph.extra.groupNodes[this.selectedGroup]; const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.output; const config = type.config?.[this.selectedNodeInnerIndex]?.output;
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]; const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex];
const checkable = node.type !== "PrimitiveNode"; const checkable = node.type !== "PrimitiveNode";
this.outputsPage.replaceChildren( this.outputsPage.replaceChildren(
...outputs ...outputs
.map((type, slot) => { .map((type, slot) => {
const groupOutputIndex = groupOutputs?.[slot]; const groupOutputIndex = groupOutputs?.[slot];
const oldName = innerNodeDef.output_name?.[slot] ?? type; const oldName = innerNodeDef.output_name?.[slot] ?? type;
let value = config?.[slot]?.name; let value = config?.[slot]?.name;
const visible = config?.[slot]?.visible || groupOutputIndex != null; const visible = config?.[slot]?.visible || groupOutputIndex != null;
if (!value || value === oldName) { if (!value || value === oldName) {
value = ""; value = "";
} }
return this.getEditElement("output", slot, value, oldName, visible, checkable); return this.getEditElement("output", slot, value, oldName, visible, checkable);
}) })
.filter(Boolean) .filter(Boolean)
); );
return !!outputs.length; return !!outputs.length;
} }
show(type?) { show(type?) {
const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort((a, b) => a.localeCompare(b)); 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.innerNodesList = $el("ul.comfy-group-manage-list-items") as HTMLUListElement;
this.widgetsPage = $el("section.comfy-group-manage-node-page"); this.widgetsPage = $el("section.comfy-group-manage-node-page");
this.inputsPage = $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"); this.outputsPage = $el("section.comfy-group-manage-node-page");
const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]); const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]);
this.tabs = [ this.tabs = [
["Inputs", this.inputsPage], ["Inputs", this.inputsPage],
["Widgets", this.widgetsPage], ["Widgets", this.widgetsPage],
["Outputs", this.outputsPage], ["Outputs", this.outputsPage],
].reduce((p, [name, page]: [string, HTMLElement]) => { ].reduce((p, [name, page]: [string, HTMLElement]) => {
p[name] = { p[name] = {
tab: $el("a", { tab: $el("a", {
onclick: () => { onclick: () => {
this.changeTab(name); this.changeTab(name);
}, },
textContent: name, textContent: name,
}), }),
page, page,
}; };
return p; return p;
}, {}) as any; }, {}) as any;
const outer = $el("div.comfy-group-manage-outer", [ const outer = $el("div.comfy-group-manage-outer", [
$el("header", [ $el("header", [
$el("h2", "Group Nodes"), $el("h2", "Group Nodes"),
$el( $el(
"select", "select",
{ {
onchange: (e) => { onchange: (e) => {
this.changeGroup(e.target.value); this.changeGroup(e.target.value);
}, },
}, },
groupNodes.map((g) => groupNodes.map((g) =>
$el("option", { $el("option", {
textContent: g, textContent: g,
selected: "workflow/" + g === type, selected: "workflow/" + g === type,
value: g, value: g,
}) })
) )
), ),
]), ]),
$el("main", [ $el("main", [
$el("section.comfy-group-manage-list", this.innerNodesList), $el("section.comfy-group-manage-list", this.innerNodesList),
$el("section.comfy-group-manage-node", [ $el("section.comfy-group-manage-node", [
$el( $el(
"header", "header",
Object.values(this.tabs).map((t) => t.tab) Object.values(this.tabs).map((t) => t.tab)
), ),
pages, pages,
]), ]),
]), ]),
$el("footer", [ $el("footer", [
$el( $el(
"button.comfy-btn", "button.comfy-btn",
{ {
onclick: (e) => { onclick: (e) => {
// @ts-ignore // @ts-ignore
const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup); const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup);
if (node) { if (node) {
alert("This group node is in use in the current workflow, please first remove these."); alert("This group node is in use in the current workflow, please first remove these.");
return; return;
} }
if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) { if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) {
delete app.graph.extra.groupNodes[this.selectedGroup]; delete app.graph.extra.groupNodes[this.selectedGroup];
LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup); LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup);
} }
this.show(); this.show();
}, },
}, },
"Delete Group Node" "Delete Group Node"
), ),
$el( $el(
"button.comfy-btn", "button.comfy-btn",
{ {
onclick: async () => { onclick: async () => {
let nodesByType; let nodesByType;
let recreateNodes = []; let recreateNodes = [];
const types = {}; const types = {};
for (const g in this.modifications) { for (const g in this.modifications) {
const type = app.graph.extra.groupNodes[g]; const type = app.graph.extra.groupNodes[g];
let config = (type.config ??= {}); let config = (type.config ??= {});
let nodeMods = this.modifications[g]?.nodes; let nodeMods = this.modifications[g]?.nodes;
if (nodeMods) { if (nodeMods) {
const keys = Object.keys(nodeMods); const keys = Object.keys(nodeMods);
if (nodeMods[keys[0]][ORDER]) { if (nodeMods[keys[0]][ORDER]) {
// If any node is reordered, they will all need sequencing // If any node is reordered, they will all need sequencing
const orderedNodes = []; const orderedNodes = [];
const orderedMods = {}; const orderedMods = {};
const orderedConfig = {}; const orderedConfig = {};
for (const n of keys) { for (const n of keys) {
const order = nodeMods[n][ORDER].order; const order = nodeMods[n][ORDER].order;
orderedNodes[order] = type.nodes[+n]; orderedNodes[order] = type.nodes[+n];
orderedMods[order] = nodeMods[n]; orderedMods[order] = nodeMods[n];
orderedNodes[order].index = order; orderedNodes[order].index = order;
} }
// Rewrite links // Rewrite links
for (const l of type.links) { for (const l of type.links) {
if (l[0] != null) l[0] = type.nodes[l[0]].index; if (l[0] != null) l[0] = type.nodes[l[0]].index;
if (l[2] != null) l[2] = type.nodes[l[2]].index; if (l[2] != null) l[2] = type.nodes[l[2]].index;
} }
// Rewrite externals // Rewrite externals
if (type.external) { if (type.external) {
for (const ext of type.external) { for (const ext of type.external) {
ext[0] = type.nodes[ext[0]]; ext[0] = type.nodes[ext[0]];
} }
} }
// Rewrite modifications // Rewrite modifications
for (const id of keys) { for (const id of keys) {
if (config[id]) { if (config[id]) {
orderedConfig[type.nodes[id].index] = config[id]; orderedConfig[type.nodes[id].index] = config[id];
} }
delete config[id]; delete config[id];
} }
type.nodes = orderedNodes; type.nodes = orderedNodes;
nodeMods = orderedMods; nodeMods = orderedMods;
type.config = config = orderedConfig; type.config = config = orderedConfig;
} }
merge(config, nodeMods); merge(config, nodeMods);
} }
types[g] = type; types[g] = type;
if (!nodesByType) { if (!nodesByType) {
// @ts-ignore // @ts-ignore
nodesByType = app.graph._nodes.reduce((p, n) => { nodesByType = app.graph._nodes.reduce((p, n) => {
p[n.type] ??= []; p[n.type] ??= [];
p[n.type].push(n); p[n.type].push(n);
return p; return p;
}, {}); }, {});
} }
const nodes = nodesByType["workflow/" + g]; const nodes = nodesByType["workflow/" + g];
if (nodes) recreateNodes.push(...nodes); if (nodes) recreateNodes.push(...nodes);
} }
await GroupNodeConfig.registerFromWorkflow(types, {}); await GroupNodeConfig.registerFromWorkflow(types, {});
for (const node of recreateNodes) { for (const node of recreateNodes) {
node.recreate(); node.recreate();
} }
this.modifications = {}; this.modifications = {};
this.app.graph.setDirtyCanvas(true, true); this.app.graph.setDirtyCanvas(true, true);
this.changeGroup(this.selectedGroup, false); this.changeGroup(this.selectedGroup, false);
}, },
}, },
"Save" "Save"
), ),
$el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"), $el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"),
]), ]),
]); ]);
this.element.replaceChildren(outer); this.element.replaceChildren(outer);
this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]); this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]);
this.element.showModal(); this.element.showModal();
this.element.addEventListener("close", () => { this.element.addEventListener("close", () => {
this.draggable?.dispose(); this.draggable?.dispose();
}); });
} }
} }

View File

@@ -3,7 +3,7 @@ import { api } from "./api";
import type { ComfyApp } from "./app"; import type { ComfyApp } from "./app";
$el("style", { $el("style", {
textContent: ` textContent: `
.comfy-logging-logs { .comfy-logging-logs {
display: grid; display: grid;
color: var(--fg-color); color: var(--fg-color);
@@ -23,131 +23,131 @@ $el("style", {
padding: 5px; padding: 5px;
} }
`, `,
parent: document.body, parent: document.body,
}); });
// Stringify function supporting max depth and removal of circular references // Stringify function supporting max depth and removal of circular references
// https://stackoverflow.com/a/57193345 // https://stackoverflow.com/a/57193345
function stringify(val, depth, replacer, space, onGetObjID?) { function stringify(val, depth, replacer, space, onGetObjID?) {
depth = isNaN(+depth) ? 1 : depth; depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap(); var recursMap = new WeakMap();
function _build(val, depth, o?, a?, r?) { function _build(val, depth, o?, a?, r?) {
// (JSON.stringify() has it's own rules, which we respect here by using it for property iteration) // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
return !val || typeof val != "object" return !val || typeof val != "object"
? val ? val
: ((r = recursMap.has(val)), : ((r = recursMap.has(val)),
recursMap.set(val, true), recursMap.set(val, true),
(a = Array.isArray(val)), (a = Array.isArray(val)),
r r
? (o = (onGetObjID && onGetObjID(val)) || null) ? (o = (onGetObjID && onGetObjID(val)) || null)
: JSON.stringify(val, function (k, v) { : JSON.stringify(val, function (k, v) {
if (a || depth > 0) { if (a || depth > 0) {
if (replacer) v = replacer(k, v); if (replacer) v = replacer(k, v);
if (!k) return (a = Array.isArray(v)), (val = v); if (!k) return (a = Array.isArray(v)), (val = v);
!o && (o = a ? [] : {}); !o && (o = a ? [] : {});
o[k] = _build(v, a ? depth : depth - 1); o[k] = _build(v, a ? depth : depth - 1);
} }
}), }),
o === void 0 ? (a ? [] : {}) : o); o === void 0 ? (a ? [] : {}) : o);
} }
return JSON.stringify(_build(val, depth), null, space); return JSON.stringify(_build(val, depth), null, space);
} }
const jsonReplacer = (k, v, ui) => { const jsonReplacer = (k, v, ui) => {
if (v instanceof Array && v.length === 1) { if (v instanceof Array && v.length === 1) {
v = v[0]; v = v[0];
} }
if (v instanceof Date) { if (v instanceof Date) {
v = v.toISOString(); v = v.toISOString();
if (ui) { if (ui) {
v = v.split("T")[1]; v = v.split("T")[1];
} }
} }
if (v instanceof Error) { if (v instanceof Error) {
let err = ""; let err = "";
if (v.name) err += v.name + "\n"; if (v.name) err += v.name + "\n";
if (v.message) err += v.message + "\n"; if (v.message) err += v.message + "\n";
if (v.stack) err += v.stack + "\n"; if (v.stack) err += v.stack + "\n";
if (!err) { if (!err) {
err = v.toString(); err = v.toString();
} }
v = err; v = err;
} }
return v; return v;
}; };
const fileInput: HTMLInputElement = $el("input", { const fileInput: HTMLInputElement = $el("input", {
type: "file", type: "file",
accept: ".json", accept: ".json",
style: { display: "none" }, style: { display: "none" },
parent: document.body, parent: document.body,
}) as HTMLInputElement; }) as HTMLInputElement;
class ComfyLoggingDialog extends ComfyDialog { class ComfyLoggingDialog extends ComfyDialog {
logging: any; logging: any;
constructor(logging) { constructor(logging) {
super(); super();
this.logging = logging; this.logging = logging;
} }
clear() { clear() {
this.logging.clear(); this.logging.clear();
this.show(); this.show();
} }
export() { export() {
const blob = new Blob([stringify([...this.logging.entries], 20, jsonReplacer, "\t")], { const blob = new Blob([stringify([...this.logging.entries], 20, jsonReplacer, "\t")], {
type: "application/json", type: "application/json",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = $el("a", { const a = $el("a", {
href: url, href: url,
download: `comfyui-logs-${Date.now()}.json`, download: `comfyui-logs-${Date.now()}.json`,
style: { display: "none" }, style: { display: "none" },
parent: document.body, parent: document.body,
}); });
a.click(); a.click();
setTimeout(function () { setTimeout(function () {
a.remove(); a.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, 0); }, 0);
} }
import() { import() {
fileInput.onchange = () => { fileInput.onchange = () => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
fileInput.remove(); fileInput.remove();
try { try {
const obj = JSON.parse(reader.result as string); const obj = JSON.parse(reader.result as string);
if (obj instanceof Array) { if (obj instanceof Array) {
this.show(obj); this.show(obj);
} else { } else {
throw new Error("Invalid file selected."); throw new Error("Invalid file selected.");
} }
} catch (error) { } catch (error) {
alert("Unable to load logs: " + error.message); alert("Unable to load logs: " + error.message);
} }
}; };
reader.readAsText(fileInput.files[0]); reader.readAsText(fileInput.files[0]);
}; };
fileInput.click(); fileInput.click();
} }
createButtons() { createButtons() {
return [ return [
$el("button", { $el("button", {
type: "button", type: "button",
textContent: "Clear", textContent: "Clear",
onclick: () => this.clear(), onclick: () => this.clear(),
}), }),
$el("button", { $el("button", {
type: "button", type: "button",
textContent: "Export logs...", textContent: "Export logs...",
onclick: () => this.export(), onclick: () => this.export(),
}), }),
$el("button", { $el("button", {
type: "button", type: "button",
textContent: "View exported logs...", textContent: "View exported logs...",
onclick: () => this.import(), onclick: () => this.import(),

View File

@@ -1,504 +1,504 @@
import { api } from "./api"; import { api } from "./api";
export function getPngMetadata(file) { export function getPngMetadata(file) {
return new Promise<Record<string, string>>((r) => { return new Promise<Record<string, string>>((r) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
// Get the PNG data as a Uint8Array // Get the PNG data as a Uint8Array
const pngData = new Uint8Array(event.target.result as ArrayBuffer); const pngData = new Uint8Array(event.target.result as ArrayBuffer);
const dataView = new DataView(pngData.buffer); const dataView = new DataView(pngData.buffer);
// Check that the PNG signature is present // Check that the PNG signature is present
if (dataView.getUint32(0) !== 0x89504e47) { if (dataView.getUint32(0) !== 0x89504e47) {
console.error("Not a valid PNG file"); console.error("Not a valid PNG file");
r({}); r({});
return; return;
} }
// Start searching for chunks after the PNG signature // Start searching for chunks after the PNG signature
let offset = 8; let offset = 8;
let txt_chunks: Record<string, string> = {}; let txt_chunks: Record<string, string> = {};
// Loop through the chunks in the PNG file // Loop through the chunks in the PNG file
while (offset < pngData.length) { while (offset < pngData.length) {
// Get the length of the chunk // Get the length of the chunk
const length = dataView.getUint32(offset); const length = dataView.getUint32(offset);
// Get the chunk type // Get the chunk type
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
if (type === "tEXt" || type == "comf" || type === "iTXt") { if (type === "tEXt" || type == "comf" || type === "iTXt") {
// Get the keyword // Get the keyword
let keyword_end = offset + 8; let keyword_end = offset + 8;
while (pngData[keyword_end] !== 0) { while (pngData[keyword_end] !== 0) {
keyword_end++; keyword_end++;
} }
const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end)); const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end));
// Get the text // Get the text
const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length); const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length);
const contentJson = new TextDecoder("utf-8").decode(contentArraySegment); const contentJson = new TextDecoder("utf-8").decode(contentArraySegment);
txt_chunks[keyword] = contentJson; 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) { function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian) // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949; const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949;
// Function to read 16-bit and 32-bit integers from binary data // Function to read 16-bit and 32-bit integers from binary data
function readInt(offset, isLittleEndian, length) { function readInt(offset, isLittleEndian, length) {
let arr = exifData.slice(offset, offset + length) let arr = exifData.slice(offset, offset + length)
if (length === 2) { if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian); return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian);
} else if (length === 4) { } else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian); return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian);
} }
} }
// Read the offset to the first IFD (Image File Directory) // Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4); const ifdOffset = readInt(4, isLittleEndian, 4);
function parseIFD(offset) { function parseIFD(offset) {
const numEntries = readInt(offset, isLittleEndian, 2); const numEntries = readInt(offset, isLittleEndian, 2);
const result = {}; const result = {};
for (let i = 0; i < numEntries; i++) { for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12; const entryOffset = offset + 2 + i * 12;
const tag = readInt(entryOffset, isLittleEndian, 2); const tag = readInt(entryOffset, isLittleEndian, 2);
const type = readInt(entryOffset + 2, isLittleEndian, 2); const type = readInt(entryOffset + 2, isLittleEndian, 2);
const numValues = readInt(entryOffset + 4, isLittleEndian, 4); const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4); const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
// Read the value(s) based on the data type // Read the value(s) based on the data type
let value; let value;
if (type === 2) { if (type === 2) {
// ASCII string // ASCII string
value = String.fromCharCode(...exifData.slice(valueOffset, valueOffset + numValues - 1)); value = String.fromCharCode(...exifData.slice(valueOffset, valueOffset + numValues - 1));
} }
result[tag] = value; result[tag] = value;
} }
return result; return result;
} }
// Parse the first IFD // Parse the first IFD
const ifdData = parseIFD(ifdOffset); const ifdData = parseIFD(ifdOffset);
return ifdData; return ifdData;
} }
function splitValues(input) { function splitValues(input) {
var output = {}; var output = {};
for (var key in input) { for (var key in input) {
var value = input[key]; var value = input[key];
var splitValues = value.split(':', 2); var splitValues = value.split(':', 2);
output[splitValues[0]] = splitValues[1]; output[splitValues[0]] = splitValues[1];
} }
return output; return output;
} }
export function getWebpMetadata(file) { export function getWebpMetadata(file) {
return new Promise<Record<string, string>>((r) => { return new Promise<Record<string, string>>((r) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const webp = new Uint8Array(event.target.result as ArrayBuffer); const webp = new Uint8Array(event.target.result as ArrayBuffer);
const dataView = new DataView(webp.buffer); const dataView = new DataView(webp.buffer);
// Check that the WEBP signature is present // Check that the WEBP signature is present
if (dataView.getUint32(0) !== 0x52494646 || dataView.getUint32(8) !== 0x57454250) { if (dataView.getUint32(0) !== 0x52494646 || dataView.getUint32(8) !== 0x57454250) {
console.error("Not a valid WEBP file"); console.error("Not a valid WEBP file");
r({}); r({});
return; return;
} }
// Start searching for chunks after the WEBP signature // Start searching for chunks after the WEBP signature
let offset = 12; let offset = 12;
let txt_chunks = {}; let txt_chunks = {};
// Loop through the chunks in the WEBP file // Loop through the chunks in the WEBP file
while (offset < webp.length) { while (offset < webp.length) {
const chunk_length = dataView.getUint32(offset + 4, true); const chunk_length = dataView.getUint32(offset + 4, true);
const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4));
if (chunk_type === "EXIF") { if (chunk_type === "EXIF") {
if (String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == "Exif\0\0") { if (String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == "Exif\0\0") {
offset += 6; offset += 6;
} }
let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length)); let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length));
for (var key in data) { for (var key in data) {
var value = data[key] as string; var value = data[key] as string;
let index = value.indexOf(':'); let index = value.indexOf(':');
txt_chunks[value.slice(0, index)] = value.slice(index + 1); 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) { export function getLatentMetadata(file) {
return new Promise((r) => { return new Promise((r) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const safetensorsData = new Uint8Array(event.target.result as ArrayBuffer); const safetensorsData = new Uint8Array(event.target.result as ArrayBuffer);
const dataView = new DataView(safetensorsData.buffer); const dataView = new DataView(safetensorsData.buffer);
let header_size = dataView.getUint32(0, true); let header_size = dataView.getUint32(0, true);
let offset = 8; let offset = 8;
let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size))); let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size)));
r(header.__metadata__); r(header.__metadata__);
}; };
var slice = file.slice(0, 1024 * 1024 * 4); var slice = file.slice(0, 1024 * 1024 * 4);
reader.readAsArrayBuffer(slice); reader.readAsArrayBuffer(slice);
}); });
} }
function getString(dataView: DataView, offset: number, length: number): string { function getString(dataView: DataView, offset: number, length: number): string {
let string = ''; let string = '';
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
string += String.fromCharCode(dataView.getUint8(offset + i)); string += String.fromCharCode(dataView.getUint8(offset + i));
} }
return string; return string;
} }
// Function to parse the Vorbis Comment block // Function to parse the Vorbis Comment block
function parseVorbisComment(dataView: DataView): Record<string, string> { function parseVorbisComment(dataView: DataView): Record<string, string> {
let offset = 0; let offset = 0;
const vendorLength = dataView.getUint32(offset, true); const vendorLength = dataView.getUint32(offset, true);
offset += 4; offset += 4;
const vendorString = getString(dataView, offset, vendorLength); const vendorString = getString(dataView, offset, vendorLength);
offset += vendorLength; offset += vendorLength;
const userCommentListLength = dataView.getUint32(offset, true); const userCommentListLength = dataView.getUint32(offset, true);
offset += 4; offset += 4;
const comments = {}; const comments = {};
for (let i = 0; i < userCommentListLength; i++) { for (let i = 0; i < userCommentListLength; i++) {
const commentLength = dataView.getUint32(offset, true); const commentLength = dataView.getUint32(offset, true);
offset += 4; offset += 4;
const comment = getString(dataView, offset, commentLength); const comment = getString(dataView, offset, commentLength);
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 // Function to read a FLAC file and parse Vorbis comments
export function getFlacMetadata(file: Blob): Promise<Record<string, string>> { export function getFlacMetadata(file: Blob): Promise<Record<string, string>> {
return new Promise((r) => { return new Promise((r) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(event) { reader.onload = function(event) {
const arrayBuffer = event.target.result as ArrayBuffer; const arrayBuffer = event.target.result as ArrayBuffer;
const dataView = new DataView(arrayBuffer); const dataView = new DataView(arrayBuffer);
// Verify the FLAC signature // Verify the FLAC signature
const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4)); const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4));
if (signature !== 'fLaC') { if (signature !== 'fLaC') {
console.error('Not a valid FLAC file'); console.error('Not a valid FLAC file');
return; return;
} }
// Parse metadata blocks // Parse metadata blocks
let offset = 4; let offset = 4;
let vorbisComment = null; let vorbisComment = null;
while (offset < dataView.byteLength) { while (offset < dataView.byteLength) {
const isLastBlock = dataView.getUint8(offset) & 0x80; const isLastBlock = dataView.getUint8(offset) & 0x80;
const blockType = dataView.getUint8(offset) & 0x7F; const blockType = dataView.getUint8(offset) & 0x7F;
const blockSize = dataView.getUint32(offset, false) & 0xFFFFFF; const blockSize = dataView.getUint32(offset, false) & 0xFFFFFF;
offset += 4; offset += 4;
if (blockType === 4) { // Vorbis Comment block type if (blockType === 4) { // Vorbis Comment block type
vorbisComment = parseVorbisComment(new DataView(arrayBuffer, offset, blockSize)); vorbisComment = parseVorbisComment(new DataView(arrayBuffer, offset, blockSize));
} }
offset += blockSize; offset += blockSize;
if (isLastBlock) break; if (isLastBlock) break;
} }
r(vorbisComment); r(vorbisComment);
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}); });
} }
export async function importA1111(graph, parameters) { export async function importA1111(graph, parameters) {
const p = parameters.lastIndexOf("\nSteps:"); const p = parameters.lastIndexOf("\nSteps:");
if (p > -1) { if (p > -1) {
const embeddings = await api.getEmbeddings(); const embeddings = await api.getEmbeddings();
const opts = parameters const opts = parameters
.substr(p) .substr(p)
.split("\n")[1] .split("\n")[1]
.match(new RegExp("\\s*([^:]+:\\s*([^\"\\{].*?|\".*?\"|\\{.*?\\}))\\s*(,|$)", "g")) .match(new RegExp("\\s*([^:]+:\\s*([^\"\\{].*?|\".*?\"|\\{.*?\\}))\\s*(,|$)", "g"))
.reduce((p, n) => { .reduce((p, n) => {
const s = n.split(":"); const s = n.split(":");
if (s[1].endsWith(',')) { if (s[1].endsWith(',')) {
s[1] = s[1].substr(0, s[1].length -1); s[1] = s[1].substr(0, s[1].length -1);
} }
p[s[0].trim().toLowerCase()] = s[1].trim(); p[s[0].trim().toLowerCase()] = s[1].trim();
return p; return p;
}, {}); }, {});
const p2 = parameters.lastIndexOf("\nNegative prompt:", p); const p2 = parameters.lastIndexOf("\nNegative prompt:", p);
if (p2 > -1) { if (p2 > -1) {
let positive = parameters.substr(0, p2).trim(); let positive = parameters.substr(0, p2).trim();
let negative = parameters.substring(p2 + 18, p).trim(); let negative = parameters.substring(p2 + 18, p).trim();
const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple"); const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple");
const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer"); const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer");
const positiveNode = LiteGraph.createNode("CLIPTextEncode"); const positiveNode = LiteGraph.createNode("CLIPTextEncode");
const negativeNode = LiteGraph.createNode("CLIPTextEncode"); const negativeNode = LiteGraph.createNode("CLIPTextEncode");
const samplerNode = LiteGraph.createNode("KSampler"); const samplerNode = LiteGraph.createNode("KSampler");
const imageNode = LiteGraph.createNode("EmptyLatentImage"); const imageNode = LiteGraph.createNode("EmptyLatentImage");
const vaeNode = LiteGraph.createNode("VAEDecode"); const vaeNode = LiteGraph.createNode("VAEDecode");
const vaeLoaderNode = LiteGraph.createNode("VAELoader"); const vaeLoaderNode = LiteGraph.createNode("VAELoader");
const saveNode = LiteGraph.createNode("SaveImage"); const saveNode = LiteGraph.createNode("SaveImage");
let hrSamplerNode = null; let hrSamplerNode = null;
let hrSteps = null; let hrSteps = null;
const ceil64 = (v) => Math.ceil(v / 64) * 64; const ceil64 = (v) => Math.ceil(v / 64) * 64;
const getWidget = (node, name) => { const getWidget = (node, name) => {
return node.widgets.find((w) => w.name === name); return node.widgets.find((w) => w.name === name);
} }
const setWidgetValue = (node, name, value, isOptionPrefix?) => { const setWidgetValue = (node, name, value, isOptionPrefix?) => {
const w = getWidget(node, name); const w = getWidget(node, name);
if (isOptionPrefix) { if (isOptionPrefix) {
const o = w.options.values.find((w) => w.startsWith(value)); const o = w.options.values.find((w) => w.startsWith(value));
if (o) { if (o) {
w.value = o; w.value = o;
} else { } else {
console.warn(`Unknown value '${value}' for widget '${name}'`, node); console.warn(`Unknown value '${value}' for widget '${name}'`, node);
w.value = value; w.value = value;
} }
} else { } else {
w.value = value; w.value = value;
} }
} }
const createLoraNodes = (clipNode, text, prevClip, prevModel) => { const createLoraNodes = (clipNode, text, prevClip, prevModel) => {
const loras = []; const loras = [];
text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) { text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) {
const s = c.split(":"); const s = c.split(":");
const weight = parseFloat(s[1]); const weight = parseFloat(s[1]);
if (isNaN(weight)) { if (isNaN(weight)) {
console.warn("Invalid LORA", m); console.warn("Invalid LORA", m);
} else { } else {
loras.push({ name: s[0], weight }); loras.push({ name: s[0], weight });
} }
return ""; return "";
}); });
for (const l of loras) { for (const l of loras) {
const loraNode = LiteGraph.createNode("LoraLoader"); const loraNode = LiteGraph.createNode("LoraLoader");
graph.add(loraNode); graph.add(loraNode);
setWidgetValue(loraNode, "lora_name", l.name, true); setWidgetValue(loraNode, "lora_name", l.name, true);
setWidgetValue(loraNode, "strength_model", l.weight); setWidgetValue(loraNode, "strength_model", l.weight);
setWidgetValue(loraNode, "strength_clip", l.weight); setWidgetValue(loraNode, "strength_clip", l.weight);
prevModel.node.connect(prevModel.index, loraNode, 0); prevModel.node.connect(prevModel.index, loraNode, 0);
prevClip.node.connect(prevClip.index, loraNode, 1); prevClip.node.connect(prevClip.index, loraNode, 1);
prevModel = { node: loraNode, index: 0 }; prevModel = { node: loraNode, index: 0 };
prevClip = { node: loraNode, index: 1 }; prevClip = { node: loraNode, index: 1 };
} }
prevClip.node.connect(1, clipNode, 0); prevClip.node.connect(1, clipNode, 0);
prevModel.node.connect(0, samplerNode, 0); prevModel.node.connect(0, samplerNode, 0);
if (hrSamplerNode) { if (hrSamplerNode) {
prevModel.node.connect(0, hrSamplerNode, 0); prevModel.node.connect(0, hrSamplerNode, 0);
} }
return { text, prevModel, prevClip }; return { text, prevModel, prevClip };
} }
const replaceEmbeddings = (text) => { const replaceEmbeddings = (text) => {
if(!embeddings.length) return text; if(!embeddings.length) return text;
return text.replaceAll( return text.replaceAll(
new RegExp( new RegExp(
"\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b", "\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b",
"ig" "ig"
), ),
"embedding:$1" "embedding:$1"
); );
} }
const popOpt = (name) => { const popOpt = (name) => {
const v = opts[name]; const v = opts[name];
delete opts[name]; delete opts[name];
return v; return v;
} }
graph.clear(); graph.clear();
graph.add(ckptNode); graph.add(ckptNode);
graph.add(clipSkipNode); graph.add(clipSkipNode);
graph.add(positiveNode); graph.add(positiveNode);
graph.add(negativeNode); graph.add(negativeNode);
graph.add(samplerNode); graph.add(samplerNode);
graph.add(imageNode); graph.add(imageNode);
graph.add(vaeNode); graph.add(vaeNode);
graph.add(vaeLoaderNode); graph.add(vaeLoaderNode);
graph.add(saveNode); graph.add(saveNode);
ckptNode.connect(1, clipSkipNode, 0); ckptNode.connect(1, clipSkipNode, 0);
clipSkipNode.connect(0, positiveNode, 0); clipSkipNode.connect(0, positiveNode, 0);
clipSkipNode.connect(0, negativeNode, 0); clipSkipNode.connect(0, negativeNode, 0);
ckptNode.connect(0, samplerNode, 0); ckptNode.connect(0, samplerNode, 0);
positiveNode.connect(0, samplerNode, 1); positiveNode.connect(0, samplerNode, 1);
negativeNode.connect(0, samplerNode, 2); negativeNode.connect(0, samplerNode, 2);
imageNode.connect(0, samplerNode, 3); imageNode.connect(0, samplerNode, 3);
vaeNode.connect(0, saveNode, 0); vaeNode.connect(0, saveNode, 0);
samplerNode.connect(0, vaeNode, 0); samplerNode.connect(0, vaeNode, 0);
vaeLoaderNode.connect(0, vaeNode, 1); vaeLoaderNode.connect(0, vaeNode, 1);
const handlers = { const handlers = {
model(v) { model(v) {
setWidgetValue(ckptNode, "ckpt_name", v, true); setWidgetValue(ckptNode, "ckpt_name", v, true);
}, },
"vae"(v) { "vae"(v) {
setWidgetValue(vaeLoaderNode, "vae_name", v, true); setWidgetValue(vaeLoaderNode, "vae_name", v, true);
}, },
"cfg scale"(v) { "cfg scale"(v) {
setWidgetValue(samplerNode, "cfg", +v); setWidgetValue(samplerNode, "cfg", +v);
}, },
"clip skip"(v) { "clip skip"(v) {
setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v); setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v);
}, },
sampler(v) { sampler(v) {
let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_"); let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_");
if (name.includes("karras")) { if (name.includes("karras")) {
name = name.replace("karras", "").replace(/_+$/, ""); name = name.replace("karras", "").replace(/_+$/, "");
setWidgetValue(samplerNode, "scheduler", "karras"); setWidgetValue(samplerNode, "scheduler", "karras");
} else { } else {
setWidgetValue(samplerNode, "scheduler", "normal"); setWidgetValue(samplerNode, "scheduler", "normal");
} }
const w = getWidget(samplerNode, "sampler_name"); const w = getWidget(samplerNode, "sampler_name");
const o = w.options.values.find((w) => w === name || w === "sample_" + name); const o = w.options.values.find((w) => w === name || w === "sample_" + name);
if (o) { if (o) {
setWidgetValue(samplerNode, "sampler_name", o); setWidgetValue(samplerNode, "sampler_name", o);
} }
}, },
size(v) { size(v) {
const wxh = v.split("x"); const wxh = v.split("x");
const w = ceil64(+wxh[0]); const w = ceil64(+wxh[0]);
const h = ceil64(+wxh[1]); const h = ceil64(+wxh[1]);
const hrUp = popOpt("hires upscale"); const hrUp = popOpt("hires upscale");
const hrSz = popOpt("hires resize"); const hrSz = popOpt("hires resize");
hrSteps = popOpt("hires steps"); hrSteps = popOpt("hires steps");
let hrMethod = popOpt("hires upscaler"); let hrMethod = popOpt("hires upscaler");
setWidgetValue(imageNode, "width", w); setWidgetValue(imageNode, "width", w);
setWidgetValue(imageNode, "height", h); setWidgetValue(imageNode, "height", h);
if (hrUp || hrSz) { if (hrUp || hrSz) {
let uw, uh; let uw, uh;
if (hrUp) { if (hrUp) {
uw = w * hrUp; uw = w * hrUp;
uh = h * hrUp; uh = h * hrUp;
} else { } else {
const s = hrSz.split("x"); const s = hrSz.split("x");
uw = +s[0]; uw = +s[0];
uh = +s[1]; uh = +s[1];
} }
let upscaleNode; let upscaleNode;
let latentNode; let latentNode;
if (hrMethod.startsWith("Latent")) { if (hrMethod.startsWith("Latent")) {
latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale"); latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale");
graph.add(upscaleNode); graph.add(upscaleNode);
samplerNode.connect(0, upscaleNode, 0); samplerNode.connect(0, upscaleNode, 0);
switch (hrMethod) { switch (hrMethod) {
case "Latent (nearest-exact)": case "Latent (nearest-exact)":
hrMethod = "nearest-exact"; hrMethod = "nearest-exact";
break; break;
} }
setWidgetValue(upscaleNode, "upscale_method", hrMethod, true); setWidgetValue(upscaleNode, "upscale_method", hrMethod, true);
} else { } else {
const decode = LiteGraph.createNode("VAEDecodeTiled"); const decode = LiteGraph.createNode("VAEDecodeTiled");
graph.add(decode); graph.add(decode);
samplerNode.connect(0, decode, 0); samplerNode.connect(0, decode, 0);
vaeLoaderNode.connect(0, decode, 1); vaeLoaderNode.connect(0, decode, 1);
const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader"); const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader");
graph.add(upscaleLoaderNode); graph.add(upscaleLoaderNode);
setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true); setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true);
const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel"); const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel");
graph.add(modelUpscaleNode); graph.add(modelUpscaleNode);
decode.connect(0, modelUpscaleNode, 1); decode.connect(0, modelUpscaleNode, 1);
upscaleLoaderNode.connect(0, modelUpscaleNode, 0); upscaleLoaderNode.connect(0, modelUpscaleNode, 0);
upscaleNode = LiteGraph.createNode("ImageScale"); upscaleNode = LiteGraph.createNode("ImageScale");
graph.add(upscaleNode); graph.add(upscaleNode);
modelUpscaleNode.connect(0, upscaleNode, 0); modelUpscaleNode.connect(0, upscaleNode, 0);
const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled")); const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled"));
graph.add(vaeEncodeNode); graph.add(vaeEncodeNode);
upscaleNode.connect(0, vaeEncodeNode, 0); upscaleNode.connect(0, vaeEncodeNode, 0);
vaeLoaderNode.connect(0, vaeEncodeNode, 1); vaeLoaderNode.connect(0, vaeEncodeNode, 1);
} }
setWidgetValue(upscaleNode, "width", ceil64(uw)); setWidgetValue(upscaleNode, "width", ceil64(uw));
setWidgetValue(upscaleNode, "height", ceil64(uh)); setWidgetValue(upscaleNode, "height", ceil64(uh));
hrSamplerNode = LiteGraph.createNode("KSampler"); hrSamplerNode = LiteGraph.createNode("KSampler");
graph.add(hrSamplerNode); graph.add(hrSamplerNode);
ckptNode.connect(0, hrSamplerNode, 0); ckptNode.connect(0, hrSamplerNode, 0);
positiveNode.connect(0, hrSamplerNode, 1); positiveNode.connect(0, hrSamplerNode, 1);
negativeNode.connect(0, hrSamplerNode, 2); negativeNode.connect(0, hrSamplerNode, 2);
latentNode.connect(0, hrSamplerNode, 3); latentNode.connect(0, hrSamplerNode, 3);
hrSamplerNode.connect(0, vaeNode, 0); hrSamplerNode.connect(0, vaeNode, 0);
} }
}, },
steps(v) { steps(v) {
setWidgetValue(samplerNode, "steps", +v); setWidgetValue(samplerNode, "steps", +v);
}, },
seed(v) { seed(v) {
setWidgetValue(samplerNode, "seed", +v); setWidgetValue(samplerNode, "seed", +v);
}, },
}; };
for (const opt in opts) { for (const opt in opts) {
if (opt in handlers) { if (opt in handlers) {
handlers[opt](popOpt(opt)); handlers[opt](popOpt(opt));
} }
} }
if (hrSamplerNode) { if (hrSamplerNode) {
setWidgetValue(hrSamplerNode, "steps", hrSteps? +hrSteps : getWidget(samplerNode, "steps").value); setWidgetValue(hrSamplerNode, "steps", hrSteps? +hrSteps : getWidget(samplerNode, "steps").value);
setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value); setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value);
setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value); setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value);
setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value); setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value);
setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1")); setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1"));
} }
let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 }); let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 });
positive = n.text; positive = n.text;
n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel); n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel);
negative = n.text; negative = n.text;
setWidgetValue(positiveNode, "text", replaceEmbeddings(positive)); setWidgetValue(positiveNode, "text", replaceEmbeddings(positive));
setWidgetValue(negativeNode, "text", replaceEmbeddings(negative)); setWidgetValue(negativeNode, "text", replaceEmbeddings(negative));
graph.arrange(); graph.arrange();
for (const opt of ["model hash", "ensd", "version", "vae hash", "ti hashes", "lora hashes", "hashes"]) { for (const opt of ["model hash", "ensd", "version", "vae hash", "ti hashes", "lora hashes", "hashes"]) {
delete opts[opt]; delete opts[opt];
} }
console.warn("Unhandled parameters:", opts); console.warn("Unhandled parameters:", opts);
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,40 @@
import { $el } from "../ui"; import { $el } from "../ui";
export class ComfyDialog<T extends HTMLElement = HTMLElement> extends EventTarget { export class ComfyDialog<T extends HTMLElement = HTMLElement> extends EventTarget {
element: T; element: T;
textElement: HTMLElement; textElement: HTMLElement;
#buttons: HTMLButtonElement[] | null; #buttons: HTMLButtonElement[] | null;
constructor(type = "div", buttons = null) { constructor(type = "div", buttons = null) {
super(); super();
this.#buttons = buttons; this.#buttons = buttons;
this.element = $el(type + ".comfy-modal", { parent: document.body }, [ this.element = $el(type + ".comfy-modal", { parent: document.body }, [
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]), $el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
]) as T; ]) as T;
} }
createButtons() { createButtons() {
return ( return (
this.#buttons ?? [ this.#buttons ?? [
$el("button", { $el("button", {
type: "button", type: "button",
textContent: "Close", textContent: "Close",
onclick: () => this.close(), onclick: () => this.close(),
}), }),
] ]
); );
} }
close() { close() {
this.element.style.display = "none"; this.element.style.display = "none";
} }
show(html) { show(html) {
if (typeof html === "string") { if (typeof html === "string") {
this.textElement.innerHTML = html; this.textElement.innerHTML = html;
} else { } else {
this.textElement.replaceChildren(...(html instanceof Array ? html : [html])); this.textElement.replaceChildren(...(html instanceof Array ? html : [html]));
} }
this.element.style.display = "flex"; this.element.style.display = "flex";
} }
} }

View File

@@ -1,5 +1,5 @@
/* /*
Original implementation: Original implementation:
https://github.com/TahaSh/drag-to-reorder https://github.com/TahaSh/drag-to-reorder
MIT License MIT License
@@ -44,243 +44,243 @@ $el("style", {
}); });
export class DraggableList extends EventTarget { export class DraggableList extends EventTarget {
listContainer; listContainer;
draggableItem; draggableItem;
pointerStartX; pointerStartX;
pointerStartY; pointerStartY;
scrollYMax; scrollYMax;
itemsGap = 0; itemsGap = 0;
items = []; items = [];
itemSelector; itemSelector;
handleClass = "drag-handle"; handleClass = "drag-handle";
off = []; off = [];
offDrag = []; offDrag = [];
constructor(element, itemSelector) { constructor(element, itemSelector) {
super(); super();
this.listContainer = element; this.listContainer = element;
this.itemSelector = itemSelector; 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, "mousedown", this.dragStart));
this.off.push(this.on(this.listContainer, "touchstart", 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, "mouseup", this.dragEnd));
this.off.push(this.on(document, "touchend", this.dragEnd)); this.off.push(this.on(document, "touchend", this.dragEnd));
} }
getAllItems() { getAllItems() {
if (!this.items?.length) { if (!this.items?.length) {
this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector)); this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector));
this.items.forEach((element) => { this.items.forEach((element) => {
element.classList.add("is-idle"); element.classList.add("is-idle");
}); });
} }
return this.items; return this.items;
} }
getIdleItems() { getIdleItems() {
return this.getAllItems().filter((item) => item.classList.contains("is-idle")); return this.getAllItems().filter((item) => item.classList.contains("is-idle"));
} }
isItemAbove(item) { isItemAbove(item) {
return item.hasAttribute("data-is-above"); return item.hasAttribute("data-is-above");
} }
isItemToggled(item) { isItemToggled(item) {
return item.hasAttribute("data-is-toggled"); return item.hasAttribute("data-is-toggled");
} }
on(source, event, listener, options?) { on(source, event, listener, options?) {
listener = listener.bind(this); listener = listener.bind(this);
source.addEventListener(event, listener, options); source.addEventListener(event, listener, options);
return () => source.removeEventListener(event, listener); return () => source.removeEventListener(event, listener);
} }
dragStart(e) { dragStart(e) {
if (e.target.classList.contains(this.handleClass)) { if (e.target.classList.contains(this.handleClass)) {
this.draggableItem = e.target.closest(this.itemSelector); this.draggableItem = e.target.closest(this.itemSelector);
} }
if (!this.draggableItem) return; if (!this.draggableItem) return;
this.pointerStartX = e.clientX || e.touches[0].clientX; this.pointerStartX = e.clientX || e.touches[0].clientX;
this.pointerStartY = e.clientY || e.touches[0].clientY; this.pointerStartY = e.clientY || e.touches[0].clientY;
this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight; this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight;
this.setItemsGap(); this.setItemsGap();
this.initDraggableItem(); this.initDraggableItem();
this.initItemsState(); this.initItemsState();
this.offDrag.push(this.on(document, "mousemove", this.drag)); 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, "touchmove", this.drag, { passive: false }));
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("dragstart", { new CustomEvent("dragstart", {
detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) }, detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) },
}) })
); );
} }
setItemsGap() { setItemsGap() {
if (this.getIdleItems().length <= 1) { if (this.getIdleItems().length <= 1) {
this.itemsGap = 0; this.itemsGap = 0;
return; return;
} }
const item1 = this.getIdleItems()[0]; const item1 = this.getIdleItems()[0];
const item2 = this.getIdleItems()[1]; const item2 = this.getIdleItems()[1];
const item1Rect = item1.getBoundingClientRect(); const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect(); const item2Rect = item2.getBoundingClientRect();
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top); this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
} }
initItemsState() { initItemsState() {
this.getIdleItems().forEach((item, i) => { this.getIdleItems().forEach((item, i) => {
if (this.getAllItems().indexOf(this.draggableItem) > i) { if (this.getAllItems().indexOf(this.draggableItem) > i) {
item.dataset.isAbove = ""; item.dataset.isAbove = "";
} }
}); });
} }
initDraggableItem() { initDraggableItem() {
this.draggableItem.classList.remove("is-idle"); this.draggableItem.classList.remove("is-idle");
this.draggableItem.classList.add("is-draggable"); this.draggableItem.classList.add("is-draggable");
} }
drag(e) { drag(e) {
if (!this.draggableItem) return; if (!this.draggableItem) return;
e.preventDefault(); e.preventDefault();
const clientX = e.clientX || e.touches[0].clientX; const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY; const clientY = e.clientY || e.touches[0].clientY;
const listRect = this.listContainer.getBoundingClientRect(); const listRect = this.listContainer.getBoundingClientRect();
if (clientY > listRect.bottom) { if (clientY > listRect.bottom) {
if (this.listContainer.scrollTop < this.scrollYMax) { if (this.listContainer.scrollTop < this.scrollYMax) {
this.listContainer.scrollBy(0, 10); this.listContainer.scrollBy(0, 10);
this.pointerStartY -= 10; this.pointerStartY -= 10;
} }
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) { } else if (clientY < listRect.top && this.listContainer.scrollTop > 0) {
this.pointerStartY += 10; this.pointerStartY += 10;
this.listContainer.scrollBy(0, -10); this.listContainer.scrollBy(0, -10);
} }
const pointerOffsetX = clientX - this.pointerStartX; const pointerOffsetX = clientX - this.pointerStartX;
const pointerOffsetY = clientY - this.pointerStartY; const pointerOffsetY = clientY - this.pointerStartY;
this.updateIdleItemsStateAndPosition(); this.updateIdleItemsStateAndPosition();
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`; this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
} }
updateIdleItemsStateAndPosition() { updateIdleItemsStateAndPosition() {
const draggableItemRect = this.draggableItem.getBoundingClientRect(); const draggableItemRect = this.draggableItem.getBoundingClientRect();
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2; const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
// Update state // Update state
this.getIdleItems().forEach((item) => { this.getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect(); const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2; const itemY = itemRect.top + itemRect.height / 2;
if (this.isItemAbove(item)) { if (this.isItemAbove(item)) {
if (draggableItemY <= itemY) { if (draggableItemY <= itemY) {
item.dataset.isToggled = ""; item.dataset.isToggled = "";
} else { } else {
delete item.dataset.isToggled; delete item.dataset.isToggled;
} }
} else { } else {
if (draggableItemY >= itemY) { if (draggableItemY >= itemY) {
item.dataset.isToggled = ""; item.dataset.isToggled = "";
} else { } else {
delete item.dataset.isToggled; delete item.dataset.isToggled;
} }
} }
}); });
// Update position // Update position
this.getIdleItems().forEach((item) => { this.getIdleItems().forEach((item) => {
if (this.isItemToggled(item)) { if (this.isItemToggled(item)) {
const direction = this.isItemAbove(item) ? 1 : -1; const direction = this.isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`; item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`;
} else { } else {
item.style.transform = ""; item.style.transform = "";
} }
}); });
} }
dragEnd() { dragEnd() {
if (!this.draggableItem) return; if (!this.draggableItem) return;
this.applyNewItemsOrder(); this.applyNewItemsOrder();
this.cleanup(); this.cleanup();
} }
applyNewItemsOrder() { applyNewItemsOrder() {
const reorderedItems = []; const reorderedItems = [];
let oldPosition = -1; let oldPosition = -1;
this.getAllItems().forEach((item, index) => { this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) { if (item === this.draggableItem) {
oldPosition = index; oldPosition = index;
return; return;
} }
if (!this.isItemToggled(item)) { if (!this.isItemToggled(item)) {
reorderedItems[index] = item; reorderedItems[index] = item;
return; return;
} }
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1; const newIndex = this.isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item; reorderedItems[newIndex] = item;
}); });
for (let index = 0; index < this.getAllItems().length; index++) { for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]; const item = reorderedItems[index];
if (typeof item === "undefined") { if (typeof item === "undefined") {
reorderedItems[index] = this.draggableItem; reorderedItems[index] = this.draggableItem;
} }
} }
reorderedItems.forEach((item) => { reorderedItems.forEach((item) => {
this.listContainer.appendChild(item); this.listContainer.appendChild(item);
}); });
this.items = reorderedItems; this.items = reorderedItems;
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("dragend", { new CustomEvent("dragend", {
detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) }, detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) },
}) })
); );
} }
cleanup() { cleanup() {
this.itemsGap = 0; this.itemsGap = 0;
this.items = []; this.items = [];
this.unsetDraggableItem(); this.unsetDraggableItem();
this.unsetItemState(); this.unsetItemState();
this.offDrag.forEach((f) => f()); this.offDrag.forEach((f) => f());
this.offDrag = []; this.offDrag = [];
} }
unsetDraggableItem() { unsetDraggableItem() {
this.draggableItem.style = null; this.draggableItem.style = null;
this.draggableItem.classList.remove("is-draggable"); this.draggableItem.classList.remove("is-draggable");
this.draggableItem.classList.add("is-idle"); this.draggableItem.classList.add("is-idle");
this.draggableItem = null; this.draggableItem = null;
} }
unsetItemState() { unsetItemState() {
this.getIdleItems().forEach((item, i) => { this.getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove; delete item.dataset.isAbove;
delete item.dataset.isToggled; delete item.dataset.isToggled;
item.style.transform = ""; item.style.transform = "";
}); });
} }
dispose() { dispose() {
this.off.forEach((f) => f()); this.off.forEach((f) => f());
} }
} }

View File

@@ -2,97 +2,97 @@ import { app } from "../app";
import { $el } from "../ui"; import { $el } from "../ui";
export function calculateImageGrid(imgs, dw, dh) { export function calculateImageGrid(imgs, dw, dh) {
let best = 0; let best = 0;
let w = imgs[0].naturalWidth; let w = imgs[0].naturalWidth;
let h = imgs[0].naturalHeight; let h = imgs[0].naturalHeight;
const numImages = imgs.length; const numImages = imgs.length;
let cellWidth, cellHeight, cols, rows, shiftX; let cellWidth, cellHeight, cols, rows, shiftX;
// compact style // compact style
for (let c = 1; c <= numImages; c++) { for (let c = 1; c <= numImages; c++) {
const r = Math.ceil(numImages / c); const r = Math.ceil(numImages / c);
const cW = dw / c; const cW = dw / c;
const cH = dh / r; const cH = dh / r;
const scaleX = cW / w; const scaleX = cW / w;
const scaleY = cH / h; const scaleY = cH / h;
const scale = Math.min(scaleX, scaleY, 1); const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale; const imageW = w * scale;
const imageH = h * scale; const imageH = h * scale;
const area = imageW * imageH * numImages; const area = imageW * imageH * numImages;
if (area > best) { if (area > best) {
best = area; best = area;
cellWidth = imageW; cellWidth = imageW;
cellHeight = imageH; cellHeight = imageH;
cols = c; cols = c;
rows = r; rows = r;
shiftX = c * ((cW - imageW) / 2); shiftX = c * ((cW - imageW) / 2);
} }
} }
return { cellWidth, cellHeight, cols, rows, shiftX }; return { cellWidth, cellHeight, cols, rows, shiftX };
} }
export function createImageHost(node) { export function createImageHost(node) {
const el = $el("div.comfy-img-preview"); const el = $el("div.comfy-img-preview");
let currentImgs; let currentImgs;
let first = true; let first = true;
function updateSize() { function updateSize() {
let w = null; let w = null;
let h = null; let h = null;
if (currentImgs) { if (currentImgs) {
let elH = el.clientHeight; let elH = el.clientHeight;
if (first) { if (first) {
first = false; first = false;
// On first run, if we are small then grow a bit // On first run, if we are small then grow a bit
if (elH < 190) { if (elH < 190) {
elH = 190; elH = 190;
} }
el.style.setProperty("--comfy-widget-min-height", elH.toString()); el.style.setProperty("--comfy-widget-min-height", elH.toString());
} else { } else {
el.style.setProperty("--comfy-widget-min-height", null); el.style.setProperty("--comfy-widget-min-height", null);
} }
const nw = node.size[0]; const nw = node.size[0];
({ cellWidth: w, cellHeight: h } = calculateImageGrid(currentImgs, nw - 20, elH)); ({ cellWidth: w, cellHeight: h } = calculateImageGrid(currentImgs, nw - 20, elH));
w += "px"; w += "px";
h += "px"; h += "px";
el.style.setProperty("--comfy-img-preview-width", w); el.style.setProperty("--comfy-img-preview-width", w);
el.style.setProperty("--comfy-img-preview-height", h); el.style.setProperty("--comfy-img-preview-height", h);
} }
} }
return { return {
el, el,
updateImages(imgs) { updateImages(imgs) {
if (imgs !== currentImgs) { if (imgs !== currentImgs) {
if (currentImgs == null) { if (currentImgs == null) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
updateSize(); updateSize();
}); });
} }
el.replaceChildren(...imgs); el.replaceChildren(...imgs);
currentImgs = imgs; currentImgs = imgs;
node.onResize(node.size); node.onResize(node.size);
node.graph.setDirtyCanvas(true, true); node.graph.setDirtyCanvas(true, true);
} }
}, },
getHeight() { getHeight() {
updateSize(); updateSize();
}, },
onDraw() { onDraw() {
// Element from point uses a hittest find elements so we need to toggle pointer events // Element from point uses a hittest find elements so we need to toggle pointer events
el.style.pointerEvents = "all"; el.style.pointerEvents = "all";
const over = document.elementFromPoint(app.canvas.mouse[0], app.canvas.mouse[1]); const over = document.elementFromPoint(app.canvas.mouse[0], app.canvas.mouse[1]);
el.style.pointerEvents = "none"; el.style.pointerEvents = "none";
if(!over) return; if(!over) return;
// Set the overIndex so Open Image etc work // Set the overIndex so Open Image etc work
const idx = currentImgs.indexOf(over); const idx = currentImgs.indexOf(over);
node.overIndex = idx; node.overIndex = idx;
}, },
}; };
} }

View File

@@ -13,291 +13,291 @@ import { getInteruptButton } from "./interruptButton";
import "./menu.css"; import "./menu.css";
const collapseOnMobile = (t) => { const collapseOnMobile = (t) => {
(t.element ?? t).classList.add("comfyui-menu-mobile-collapse"); (t.element ?? t).classList.add("comfyui-menu-mobile-collapse");
return t; return t;
}; };
const showOnMobile = (t) => { const showOnMobile = (t) => {
(t.element ?? t).classList.add("lt-lg-show"); (t.element ?? t).classList.add("lt-lg-show");
return t; return t;
}; };
export class ComfyAppMenu { export class ComfyAppMenu {
#sizeBreak = "lg"; #sizeBreak = "lg";
#lastSizeBreaks = { #lastSizeBreaks = {
lg: null, lg: null,
md: null, md: null,
sm: null, sm: null,
xs: null, xs: null,
}; };
#sizeBreaks = Object.keys(this.#lastSizeBreaks); #sizeBreaks = Object.keys(this.#lastSizeBreaks);
#cachedInnerSize = null; #cachedInnerSize = null;
#cacheTimeout = null; #cacheTimeout = null;
/** /**
* @param { import("../../app").ComfyApp } app * @param { import("../../app").ComfyApp } app
*/ */
constructor(app) { constructor(app) {
this.app = app; this.app = app;
this.workflows = new ComfyWorkflowsMenu(app); this.workflows = new ComfyWorkflowsMenu(app);
const getSaveButton = (t) => const getSaveButton = (t) =>
new ComfyButton({ new ComfyButton({
icon: "content-save", icon: "content-save",
tooltip: "Save the current workflow", tooltip: "Save the current workflow",
action: () => app.workflowManager.activeWorkflow.save(), action: () => app.workflowManager.activeWorkflow.save(),
content: t, content: t,
}); });
this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI"); this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI");
this.saveButton = new ComfySplitButton( this.saveButton = new ComfySplitButton(
{ {
primary: getSaveButton(), primary: getSaveButton(),
mode: "hover", mode: "hover",
position: "absolute", position: "absolute",
}, },
getSaveButton("Save"), getSaveButton("Save"),
new ComfyButton({ new ComfyButton({
icon: "content-save-edit", icon: "content-save-edit",
content: "Save As", content: "Save As",
tooltip: "Save the current graph as a new workflow", tooltip: "Save the current graph as a new workflow",
action: () => app.workflowManager.activeWorkflow.save(true), action: () => app.workflowManager.activeWorkflow.save(true),
}), }),
new ComfyButton({ new ComfyButton({
icon: "download", icon: "download",
content: "Export", content: "Export",
tooltip: "Export the current workflow as JSON", tooltip: "Export the current workflow as JSON",
action: () => this.exportWorkflow("workflow", "workflow"), action: () => this.exportWorkflow("workflow", "workflow"),
}), }),
new ComfyButton({ new ComfyButton({
icon: "api", icon: "api",
content: "Export (API Format)", content: "Export (API Format)",
tooltip: "Export the current workflow as JSON for use with the ComfyUI API", tooltip: "Export the current workflow as JSON for use with the ComfyUI API",
action: () => this.exportWorkflow("workflow_api", "output"), action: () => this.exportWorkflow("workflow_api", "output"),
visibilitySetting: { id: "Comfy.DevMode", showValue: true }, visibilitySetting: { id: "Comfy.DevMode", showValue: true },
app, app,
}) })
); );
this.actionsGroup = new ComfyButtonGroup( this.actionsGroup = new ComfyButtonGroup(
new ComfyButton({ new ComfyButton({
icon: "refresh", icon: "refresh",
content: "Refresh", content: "Refresh",
tooltip: "Refresh widgets in nodes to find new models or files", tooltip: "Refresh widgets in nodes to find new models or files",
action: () => app.refreshComboInNodes(), action: () => app.refreshComboInNodes(),
}), }),
new ComfyButton({ new ComfyButton({
icon: "clipboard-edit-outline", icon: "clipboard-edit-outline",
content: "Clipspace", content: "Clipspace",
tooltip: "Open Clipspace window", tooltip: "Open Clipspace window",
action: () => app["openClipspace"](), action: () => app["openClipspace"](),
}), }),
new ComfyButton({ new ComfyButton({
icon: "fit-to-page-outline", icon: "fit-to-page-outline",
content: "Reset View", content: "Reset View",
tooltip: "Reset the canvas view", tooltip: "Reset the canvas view",
action: () => app.resetView(), action: () => app.resetView(),
}), }),
new ComfyButton({ new ComfyButton({
icon: "cancel", icon: "cancel",
content: "Clear", content: "Clear",
tooltip: "Clears current workflow", tooltip: "Clears current workflow",
action: () => { action: () => {
if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) { if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) {
app.clean(); app.clean();
app.graph.clear(); app.graph.clear();
} }
}, },
}) })
); );
this.settingsGroup = new ComfyButtonGroup( this.settingsGroup = new ComfyButtonGroup(
new ComfyButton({ new ComfyButton({
icon: "cog", icon: "cog",
content: "Settings", content: "Settings",
tooltip: "Open settings", tooltip: "Open settings",
action: () => { action: () => {
app.ui.settings.show(); app.ui.settings.show();
}, },
}) })
); );
this.viewGroup = new ComfyButtonGroup( this.viewGroup = new ComfyButtonGroup(
new ComfyViewHistoryButton(app).element, new ComfyViewHistoryButton(app).element,
new ComfyViewQueueButton(app).element, new ComfyViewQueueButton(app).element,
getInteruptButton("nlg-hide").element getInteruptButton("nlg-hide").element
); );
this.mobileMenuButton = new ComfyButton({ this.mobileMenuButton = new ComfyButton({
icon: "menu", icon: "menu",
action: (_, btn) => { action: (_, btn) => {
btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu"; btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu";
window.dispatchEvent(new Event("resize")); window.dispatchEvent(new Event("resize"));
}, },
classList: "comfyui-button comfyui-menu-button", classList: "comfyui-button comfyui-menu-button",
}); });
this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [ this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [
this.logo, this.logo,
this.workflows.element, this.workflows.element,
this.saveButton.element, this.saveButton.element,
collapseOnMobile(this.actionsGroup).element, collapseOnMobile(this.actionsGroup).element,
$el("section.comfyui-menu-push"), $el("section.comfyui-menu-push"),
collapseOnMobile(this.settingsGroup).element, collapseOnMobile(this.settingsGroup).element,
collapseOnMobile(this.viewGroup).element, collapseOnMobile(this.viewGroup).element,
getInteruptButton("lt-lg-show").element, getInteruptButton("lt-lg-show").element,
new ComfyQueueButton(app).element, new ComfyQueueButton(app).element,
showOnMobile(this.mobileMenuButton).element, showOnMobile(this.mobileMenuButton).element,
]); ]);
let resizeHandler; let resizeHandler;
this.menuPositionSetting = app.ui.settings.addSetting({ this.menuPositionSetting = app.ui.settings.addSetting({
id: "Comfy.UseNewMenu", id: "Comfy.UseNewMenu",
defaultValue: "Disabled", defaultValue: "Disabled",
name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.", name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.",
type: "combo", type: "combo",
options: ["Disabled", "Top", "Bottom"], options: ["Disabled", "Top", "Bottom"],
onChange: async (v) => { onChange: async (v) => {
if (v && v !== "Disabled") { if (v && v !== "Disabled") {
if (!resizeHandler) { if (!resizeHandler) {
resizeHandler = () => { resizeHandler = () => {
this.calculateSizeBreak(); this.calculateSizeBreak();
}; };
window.addEventListener("resize", resizeHandler); window.addEventListener("resize", resizeHandler);
} }
this.updatePosition(v); this.updatePosition(v);
} else { } else {
if (resizeHandler) { if (resizeHandler) {
window.removeEventListener("resize", resizeHandler); window.removeEventListener("resize", resizeHandler);
resizeHandler = null; resizeHandler = null;
} }
document.body.style.removeProperty("display"); document.body.style.removeProperty("display");
app.ui.menuContainer.style.removeProperty("display"); app.ui.menuContainer.style.removeProperty("display");
this.element.style.display = "none"; this.element.style.display = "none";
app.ui.restoreMenuPosition(); app.ui.restoreMenuPosition();
} }
window.dispatchEvent(new Event("resize")); window.dispatchEvent(new Event("resize"));
}, },
}); });
} }
updatePosition(v) { updatePosition(v) {
document.body.style.display = "grid"; document.body.style.display = "grid";
this.app.ui.menuContainer.style.display = "none"; this.app.ui.menuContainer.style.display = "none";
this.element.style.removeProperty("display"); this.element.style.removeProperty("display");
this.position = v; this.position = v;
if (v === "Bottom") { if (v === "Bottom") {
this.app.bodyBottom.append(this.element); this.app.bodyBottom.append(this.element);
} else { } else {
this.app.bodyTop.prepend(this.element); this.app.bodyTop.prepend(this.element);
} }
this.calculateSizeBreak(); this.calculateSizeBreak();
} }
updateSizeBreak(idx, prevIdx, direction) { updateSizeBreak(idx, prevIdx, direction) {
const newSize = this.#sizeBreaks[idx]; const newSize = this.#sizeBreaks[idx];
if (newSize === this.#sizeBreak) return; if (newSize === this.#sizeBreak) return;
this.#cachedInnerSize = null; this.#cachedInnerSize = null;
clearTimeout(this.#cacheTimeout); clearTimeout(this.#cacheTimeout);
this.#sizeBreak = this.#sizeBreaks[idx]; this.#sizeBreak = this.#sizeBreaks[idx];
for (let i = 0; i < this.#sizeBreaks.length; i++) { for (let i = 0; i < this.#sizeBreaks.length; i++) {
const sz = this.#sizeBreaks[i]; const sz = this.#sizeBreaks[i];
if (sz === this.#sizeBreak) { if (sz === this.#sizeBreak) {
this.element.classList.add(sz); this.element.classList.add(sz);
} else { } else {
this.element.classList.remove(sz); this.element.classList.remove(sz);
} }
if (i < idx) { if (i < idx) {
this.element.classList.add("lt-" + sz); this.element.classList.add("lt-" + sz);
} else { } else {
this.element.classList.remove("lt-" + sz); this.element.classList.remove("lt-" + sz);
} }
} }
if (idx) { if (idx) {
// We're on a small screen, force the menu at the top // We're on a small screen, force the menu at the top
if (this.position !== "Top") { if (this.position !== "Top") {
this.updatePosition("Top"); this.updatePosition("Top");
} }
} else if (this.position != this.menuPositionSetting.value) { } else if (this.position != this.menuPositionSetting.value) {
// Restore user position // Restore user position
this.updatePosition(this.menuPositionSetting.value); this.updatePosition(this.menuPositionSetting.value);
} }
// Allow multiple updates, but prevent bouncing // Allow multiple updates, but prevent bouncing
if (!direction) { if (!direction) {
direction = prevIdx - idx; direction = prevIdx - idx;
} else if (direction != prevIdx - idx) { } else if (direction != prevIdx - idx) {
return; return;
} }
this.calculateSizeBreak(direction); this.calculateSizeBreak(direction);
} }
calculateSizeBreak(direction = 0) { calculateSizeBreak(direction = 0) {
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak); let idx = this.#sizeBreaks.indexOf(this.#sizeBreak);
const currIdx = idx; const currIdx = idx;
const innerSize = this.calculateInnerSize(idx); const innerSize = this.calculateInnerSize(idx);
if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) { if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) {
if (idx > 0) { if (idx > 0) {
idx--; idx--;
} }
} else if (innerSize > this.element.clientWidth) { } else if (innerSize > this.element.clientWidth) {
this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize); this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize);
// We need to shrink // We need to shrink
if (idx < this.#sizeBreaks.length - 1) { if (idx < this.#sizeBreaks.length - 1) {
idx++; idx++;
} }
} }
this.updateSizeBreak(idx, currIdx, direction); this.updateSizeBreak(idx, currIdx, direction);
} }
calculateInnerSize(idx) { calculateInnerSize(idx) {
// Cache the inner size to prevent too much calculation when resizing the window // Cache the inner size to prevent too much calculation when resizing the window
clearTimeout(this.#cacheTimeout); clearTimeout(this.#cacheTimeout);
if (this.#cachedInnerSize) { if (this.#cachedInnerSize) {
// Extend cache time // Extend cache time
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100); this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
} else { } else {
let innerSize = 0; let innerSize = 0;
let count = 1; let count = 1;
for (const c of this.element.children) { for (const c of this.element.children) {
if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push
if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items
innerSize += c.clientWidth; innerSize += c.clientWidth;
count++; count++;
} }
innerSize += 8 * count; innerSize += 8 * count;
this.#cachedInnerSize = innerSize; this.#cachedInnerSize = innerSize;
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100); this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
} }
return this.#cachedInnerSize; return this.#cachedInnerSize;
} }
/** /**
* @param {string} defaultName * @param {string} defaultName
*/ */
getFilename(defaultName) { getFilename(defaultName) {
if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) { if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) {
defaultName = prompt("Save workflow as:", defaultName); defaultName = prompt("Save workflow as:", defaultName);
if (!defaultName) return; if (!defaultName) return;
if (!defaultName.toLowerCase().endsWith(".json")) { if (!defaultName.toLowerCase().endsWith(".json")) {
defaultName += ".json"; defaultName += ".json";
} }
} }
return defaultName; return defaultName;
} }
/** /**
* @param {string} [filename] * @param {string} [filename]
* @param { "workflow" | "output" } [promptProperty] * @param { "workflow" | "output" } [promptProperty]
*/ */
async exportWorkflow(filename, promptProperty) { async exportWorkflow(filename, promptProperty) {
if (this.app.workflowManager.activeWorkflow?.path) { if (this.app.workflowManager.activeWorkflow?.path) {
filename = this.app.workflowManager.activeWorkflow.name; filename = this.app.workflowManager.activeWorkflow.name;
} }
const p = await this.app.graphToPrompt(); const p = await this.app.graphToPrompt();
const json = JSON.stringify(p[promptProperty], null, 2); const json = JSON.stringify(p[promptProperty], null, 2);
const blob = new Blob([json], { type: "application/json" }); const blob = new Blob([json], { type: "application/json" });
const file = this.getFilename(filename); const file = this.getFilename(filename);
if (!file) return; if (!file) return;
downloadBlob(file, blob); downloadBlob(file, blob);
} }
} }

View File

@@ -4,20 +4,20 @@ import { api } from "../../api";
import { ComfyButton } from "../components/button"; import { ComfyButton } from "../components/button";
export function getInteruptButton(visibility) { export function getInteruptButton(visibility) {
const btn = new ComfyButton({ const btn = new ComfyButton({
icon: "close", icon: "close",
tooltip: "Cancel current generation", tooltip: "Cancel current generation",
enabled: false, enabled: false,
action: () => { action: () => {
api.interrupt(); api.interrupt();
}, },
classList: ["comfyui-button", "comfyui-interrupt-button", visibility], classList: ["comfyui-button", "comfyui-interrupt-button", visibility],
}); });
api.addEventListener("status", ({ detail }) => { api.addEventListener("status", ({ detail }) => {
const sz = detail?.exec_info?.queue_remaining; const sz = detail?.exec_info?.queue_remaining;
btn.enabled = sz > 0; btn.enabled = sz > 0;
}); });
return btn; return btn;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -8,86 +8,86 @@ import { ComfyQueueOptions } from "./queueOptions";
import { prop } from "../../utils"; import { prop } from "../../utils";
export class ComfyQueueButton { export class ComfyQueueButton {
element = $el("div.comfyui-queue-button"); element = $el("div.comfyui-queue-button");
#internalQueueSize = 0; #internalQueueSize = 0;
queuePrompt = async (e) => { queuePrompt = async (e) => {
this.#internalQueueSize += this.queueOptions.batchCount; this.#internalQueueSize += this.queueOptions.batchCount;
// Hold shift to queue front, event is undefined when auto-queue is enabled // Hold shift to queue front, event is undefined when auto-queue is enabled
await this.app.queuePrompt(e?.shiftKey ? -1 : 0, this.queueOptions.batchCount); await this.app.queuePrompt(e?.shiftKey ? -1 : 0, this.queueOptions.batchCount);
}; };
constructor(app) { constructor(app) {
this.app = app; this.app = app;
this.queueSizeElement = $el("span.comfyui-queue-count", { this.queueSizeElement = $el("span.comfyui-queue-count", {
textContent: "?", textContent: "?",
}); });
const queue = new ComfyButton({ const queue = new ComfyButton({
content: $el("div", [ content: $el("div", [
$el("span", { $el("span", {
textContent: "Queue", textContent: "Queue",
}), }),
this.queueSizeElement, this.queueSizeElement,
]), ]),
icon: "play", icon: "play",
classList: "comfyui-button", classList: "comfyui-button",
action: this.queuePrompt, action: this.queuePrompt,
}); });
this.queueOptions = new ComfyQueueOptions(app); this.queueOptions = new ComfyQueueOptions(app);
const btn = new ComfySplitButton( const btn = new ComfySplitButton(
{ {
primary: queue, primary: queue,
mode: "click", mode: "click",
position: "absolute", position: "absolute",
horizontal: "right", horizontal: "right",
}, },
this.queueOptions.element this.queueOptions.element
); );
btn.element.classList.add("primary"); btn.element.classList.add("primary");
this.element.append(btn.element); this.element.append(btn.element);
this.autoQueueMode = prop(this, "autoQueueMode", "", () => { this.autoQueueMode = prop(this, "autoQueueMode", "", () => {
switch (this.autoQueueMode) { switch (this.autoQueueMode) {
case "instant": case "instant":
queue.icon = "infinity"; queue.icon = "infinity";
break; break;
case "change": case "change":
queue.icon = "auto-mode"; queue.icon = "auto-mode";
break; break;
default: default:
queue.icon = "play"; queue.icon = "play";
break; break;
} }
}); });
this.queueOptions.addEventListener("autoQueueMode", (e) => (this.autoQueueMode = e["detail"])); this.queueOptions.addEventListener("autoQueueMode", (e) => (this.autoQueueMode = e["detail"]));
api.addEventListener("graphChanged", () => { api.addEventListener("graphChanged", () => {
if (this.autoQueueMode === "change") { if (this.autoQueueMode === "change") {
if (this.#internalQueueSize) { if (this.#internalQueueSize) {
this.graphHasChanged = true; this.graphHasChanged = true;
} else { } else {
this.graphHasChanged = false; this.graphHasChanged = false;
this.queuePrompt(); this.queuePrompt();
} }
} }
}); });
api.addEventListener("status", ({ detail }) => { api.addEventListener("status", ({ detail }) => {
this.#internalQueueSize = detail?.exec_info?.queue_remaining; this.#internalQueueSize = detail?.exec_info?.queue_remaining;
if (this.#internalQueueSize != null) { if (this.#internalQueueSize != null) {
this.queueSizeElement.textContent = this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + ""; this.queueSizeElement.textContent = this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + "";
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`; this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`;
if (!this.#internalQueueSize && !app.lastExecutionError) { if (!this.#internalQueueSize && !app.lastExecutionError) {
if (this.autoQueueMode === "instant" || (this.autoQueueMode === "change" && this.graphHasChanged)) { if (this.autoQueueMode === "instant" || (this.autoQueueMode === "change" && this.graphHasChanged)) {
this.graphHasChanged = false; this.graphHasChanged = false;
this.queuePrompt(); this.queuePrompt();
} }
} }
} }
}); });
} }
} }

View File

@@ -4,74 +4,74 @@ import { $el } from "../../ui";
import { prop } from "../../utils"; import { prop } from "../../utils";
export class ComfyQueueOptions extends EventTarget { export class ComfyQueueOptions extends EventTarget {
element = $el("div.comfyui-queue-options"); element = $el("div.comfyui-queue-options");
constructor(app) { constructor(app) {
super(); super();
this.app = app; this.app = app;
this.batchCountInput = $el("input", { this.batchCountInput = $el("input", {
className: "comfyui-queue-batch-value", className: "comfyui-queue-batch-value",
type: "number", type: "number",
min: "1", min: "1",
value: "1", value: "1",
oninput: () => (this.batchCount = +this.batchCountInput.value), oninput: () => (this.batchCount = +this.batchCountInput.value),
}); });
this.batchCountRange = $el("input", { this.batchCountRange = $el("input", {
type: "range", type: "range",
min: "1", min: "1",
max: "100", max: "100",
value: "1", value: "1",
oninput: () => (this.batchCount = +this.batchCountRange.value), oninput: () => (this.batchCount = +this.batchCountRange.value),
}); });
this.element.append( this.element.append(
$el("div.comfyui-queue-batch", [ $el("div.comfyui-queue-batch", [
$el( $el(
"label", "label",
{ {
textContent: "Batch count: ", textContent: "Batch count: ",
}, },
this.batchCountInput this.batchCountInput
), ),
this.batchCountRange, this.batchCountRange,
]) ])
); );
const createOption = (text, value, checked = false) => const createOption = (text, value, checked = false) =>
$el( $el(
"label", "label",
{ textContent: text }, { textContent: text },
$el("input", { $el("input", {
type: "radio", type: "radio",
name: "AutoQueueMode", name: "AutoQueueMode",
checked, checked,
value, value,
oninput: (e) => (this.autoQueueMode = e.target["value"]), oninput: (e) => (this.autoQueueMode = e.target["value"]),
}) })
); );
this.autoQueueEl = $el("div.comfyui-queue-mode", [ this.autoQueueEl = $el("div.comfyui-queue-mode", [
$el("span", "Auto Queue:"), $el("span", "Auto Queue:"),
createOption("Disabled", "", true), createOption("Disabled", "", true),
createOption("Instant", "instant"), createOption("Instant", "instant"),
createOption("On Change", "change"), createOption("On Change", "change"),
]); ]);
this.element.append(this.autoQueueEl); this.element.append(this.autoQueueEl);
this.batchCount = prop(this, "batchCount", 1, () => { this.batchCount = prop(this, "batchCount", 1, () => {
this.batchCountInput.value = this.batchCount + ""; this.batchCountInput.value = this.batchCount + "";
this.batchCountRange.value = this.batchCount + ""; this.batchCountRange.value = this.batchCount + "";
}); });
this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => { this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("autoQueueMode", { new CustomEvent("autoQueueMode", {
detail: this.autoQueueMode, detail: this.autoQueueMode,
}) })
); );
}); });
} }
} }

View File

@@ -4,24 +4,24 @@ import { ComfyButton } from "../components/button";
import { ComfyViewList, ComfyViewListButton } from "./viewList"; import { ComfyViewList, ComfyViewListButton } from "./viewList";
export class ComfyViewHistoryButton extends ComfyViewListButton { export class ComfyViewHistoryButton extends ComfyViewListButton {
constructor(app) { constructor(app) {
super(app, { super(app, {
button: new ComfyButton({ button: new ComfyButton({
content: "View History", content: "View History",
icon: "history", icon: "history",
tooltip: "View history", tooltip: "View history",
classList: "comfyui-button comfyui-history-button", classList: "comfyui-button comfyui-history-button",
}), }),
list: ComfyViewHistoryList, list: ComfyViewHistoryList,
mode: "History", mode: "History",
}); });
} }
} }
export class ComfyViewHistoryList extends ComfyViewList { export class ComfyViewHistoryList extends ComfyViewList {
async loadItems() { async loadItems() {
const items = await super.loadItems(); const items = await super.loadItems();
items["History"].reverse(); items["History"].reverse();
return items; return items;
} }
} }

View File

@@ -6,198 +6,198 @@ import { api } from "../../api";
import { ComfyPopup } from "../components/popup"; import { ComfyPopup } from "../components/popup";
export class ComfyViewListButton { export class ComfyViewListButton {
get open() { get open() {
return this.popup.open; return this.popup.open;
} }
set open(open) { set open(open) {
this.popup.open = open; this.popup.open = open;
} }
constructor(app, { button, list, mode }) { constructor(app, { button, list, mode }) {
this.app = app; this.app = app;
this.button = button; this.button = button;
this.element = $el("div.comfyui-button-wrapper", this.button.element); this.element = $el("div.comfyui-button-wrapper", this.button.element);
this.popup = new ComfyPopup({ this.popup = new ComfyPopup({
target: this.element, target: this.element,
container: this.element, container: this.element,
horizontal: "right", horizontal: "right",
}); });
this.list = new (list ?? ComfyViewList)(app, mode, this.popup); this.list = new (list ?? ComfyViewList)(app, mode, this.popup);
this.popup.children = [this.list.element]; this.popup.children = [this.list.element];
this.popup.addEventListener("open", () => { this.popup.addEventListener("open", () => {
this.list.update(); this.list.update();
}); });
this.popup.addEventListener("close", () => { this.popup.addEventListener("close", () => {
this.list.close(); this.list.close();
}); });
this.button.withPopup(this.popup); this.button.withPopup(this.popup);
api.addEventListener("status", () => { api.addEventListener("status", () => {
if (this.popup.open) { if (this.popup.open) {
this.popup.update(); this.popup.update();
} }
}); });
} }
} }
export class ComfyViewList { export class ComfyViewList {
popup; popup;
constructor(app, mode, popup) { constructor(app, mode, popup) {
this.app = app; this.app = app;
this.mode = mode; this.mode = mode;
this.popup = popup; this.popup = popup;
this.type = mode.toLowerCase(); this.type = mode.toLowerCase();
this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`); this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`);
this.clear = new ComfyButton({ this.clear = new ComfyButton({
icon: "cancel", icon: "cancel",
content: "Clear", content: "Clear",
action: async () => { action: async () => {
this.showSpinner(false); this.showSpinner(false);
await api.clearItems(this.type); await api.clearItems(this.type);
await this.update(); await this.update();
}, },
}); });
this.refresh = new ComfyButton({ this.refresh = new ComfyButton({
icon: "refresh", icon: "refresh",
content: "Refresh", content: "Refresh",
action: async () => { action: async () => {
await this.update(false); await this.update(false);
}, },
}); });
this.element = $el(`div.comfyui-${this.type}-popup.comfyui-view-list-popup`, [ this.element = $el(`div.comfyui-${this.type}-popup.comfyui-view-list-popup`, [
$el("h3", mode), $el("h3", mode),
$el("header", [this.clear.element, this.refresh.element]), $el("header", [this.clear.element, this.refresh.element]),
this.items, this.items,
]); ]);
api.addEventListener("status", () => { api.addEventListener("status", () => {
if (this.popup.open) { if (this.popup.open) {
this.update(); this.update();
} }
}); });
} }
async close() { async close() {
this.items.replaceChildren(); this.items.replaceChildren();
} }
async update(resize = true) { async update(resize = true) {
this.showSpinner(resize); this.showSpinner(resize);
const res = await this.loadItems(); const res = await this.loadItems();
let any = false; let any = false;
const names = Object.keys(res); const names = Object.keys(res);
const sections = names const sections = names
.map((section) => { .map((section) => {
const items = res[section]; const items = res[section];
if (items?.length) { if (items?.length) {
any = true; any = true;
} else { } else {
return; return;
} }
const rows = []; const rows = [];
if (names.length > 1) { if (names.length > 1) {
rows.push($el("h5", section)); rows.push($el("h5", section));
} }
rows.push(...items.flatMap((item) => this.createRow(item, section))); rows.push(...items.flatMap((item) => this.createRow(item, section)));
return $el("section", rows); return $el("section", rows);
}) })
.filter(Boolean); .filter(Boolean);
if (any) { if (any) {
this.items.replaceChildren(...sections); this.items.replaceChildren(...sections);
} else { } else {
this.items.replaceChildren($el("h5", "None")); this.items.replaceChildren($el("h5", "None"));
} }
this.popup.update(); this.popup.update();
this.clear.enabled = this.refresh.enabled = true; this.clear.enabled = this.refresh.enabled = true;
this.element.style.removeProperty("height"); this.element.style.removeProperty("height");
} }
showSpinner(resize = true) { showSpinner(resize = true) {
// if (!this.spinner) { // if (!this.spinner) {
// this.spinner = createSpinner(); // this.spinner = createSpinner();
// } // }
// if (!resize) { // if (!resize) {
// this.element.style.height = this.element.clientHeight + "px"; // this.element.style.height = this.element.clientHeight + "px";
// } // }
// this.clear.enabled = this.refresh.enabled = false; // this.clear.enabled = this.refresh.enabled = false;
// this.items.replaceChildren( // this.items.replaceChildren(
// $el( // $el(
// "div", // "div",
// { // {
// style: { // style: {
// fontSize: "18px", // fontSize: "18px",
// }, // },
// }, // },
// this.spinner // this.spinner
// ) // )
// ); // );
// this.popup.update(); // this.popup.update();
} }
async loadItems() { async loadItems() {
return await api.getItems(this.type); return await api.getItems(this.type);
} }
getRow(item, section) { getRow(item, section) {
return { return {
text: item.prompt[0] + "", text: item.prompt[0] + "",
actions: [ actions: [
{ {
text: "Load", text: "Load",
action: async () => { action: async () => {
try { try {
await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
if (item.outputs) { if (item.outputs) {
this.app.nodeOutputs = item.outputs; this.app.nodeOutputs = item.outputs;
} }
} catch (error) { } catch (error) {
alert("Error loading workflow: " + error.message); alert("Error loading workflow: " + error.message);
console.error(error); console.error(error);
} }
}, },
}, },
{ {
text: "Delete", text: "Delete",
action: async () => { action: async () => {
try { try {
await api.deleteItem(this.type, item.prompt[1]); await api.deleteItem(this.type, item.prompt[1]);
this.update(); this.update();
} catch (error) {} } catch (error) {}
}, },
}, },
], ],
}; };
} }
createRow = (item, section) => { createRow = (item, section) => {
const row = this.getRow(item, section); const row = this.getRow(item, section);
return [ return [
$el("span", row.text), $el("span", row.text),
...row.actions.map( ...row.actions.map(
(a) => (a) =>
new ComfyButton({ new ComfyButton({
content: a.text, content: a.text,
action: async (e, btn) => { action: async (e, btn) => {
btn.enabled = false; btn.enabled = false;
try { try {
await a.action(); await a.action();
} catch (error) { } catch (error) {
throw error; throw error;
} finally { } finally {
btn.enabled = true; btn.enabled = true;
} }
}, },
}).element }).element
), ),
]; ];
}; };
} }

View File

@@ -5,51 +5,51 @@ import { ComfyViewList, ComfyViewListButton } from "./viewList";
import { api } from "../../api"; import { api } from "../../api";
export class ComfyViewQueueButton extends ComfyViewListButton { export class ComfyViewQueueButton extends ComfyViewListButton {
constructor(app) { constructor(app) {
super(app, { super(app, {
button: new ComfyButton({ button: new ComfyButton({
content: "View Queue", content: "View Queue",
icon: "format-list-numbered", icon: "format-list-numbered",
tooltip: "View queue", tooltip: "View queue",
classList: "comfyui-button comfyui-queue-button", classList: "comfyui-button comfyui-queue-button",
}), }),
list: ComfyViewQueueList, list: ComfyViewQueueList,
mode: "Queue", mode: "Queue",
}); });
} }
} }
export class ComfyViewQueueList extends ComfyViewList { export class ComfyViewQueueList extends ComfyViewList {
getRow = (item, section) => { getRow = (item, section) => {
if (section !== "Running") { if (section !== "Running") {
return super.getRow(item, section); return super.getRow(item, section);
} }
return { return {
text: item.prompt[0] + "", text: item.prompt[0] + "",
actions: [ actions: [
{ {
text: "Load", text: "Load",
action: async () => { action: async () => {
try { try {
await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
if (item.outputs) { if (item.outputs) {
this.app.nodeOutputs = item.outputs; this.app.nodeOutputs = item.outputs;
} }
} catch (error) { } catch (error) {
alert("Error loading workflow: " + error.message); alert("Error loading workflow: " + error.message);
console.error(error); console.error(error);
} }
}, },
}, },
{ {
text: "Cancel", text: "Cancel",
action: async () => { action: async () => {
try { try {
await api.interrupt(); await api.interrupt();
} catch (error) {} } catch (error) {}
}, },
}, },
], ],
}; };
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,359 +5,359 @@ import type { ComfyApp } from "../app";
/* The Setting entry stored in `ComfySettingsDialog` */ /* The Setting entry stored in `ComfySettingsDialog` */
interface Setting { interface Setting {
id: string; id: string;
onChange?: (value: any, oldValue?: any) => void; onChange?: (value: any, oldValue?: any) => void;
name: string; name: string;
render: () => HTMLElement; render: () => HTMLElement;
} }
interface SettingOption { interface SettingOption {
text: string; text: string;
value?: string; value?: string;
} }
interface SettingParams { interface SettingParams {
id: string; id: string;
name: string; name: string;
type: string | ((name: string, setter: (v: any) => void, value: any, attrs: any) => HTMLElement); type: string | ((name: string, setter: (v: any) => void, value: any, attrs: any) => HTMLElement);
defaultValue: any; defaultValue: any;
onChange?: (newValue: any, oldValue?: any) => void; onChange?: (newValue: any, oldValue?: any) => void;
attrs?: any; attrs?: any;
tooltip?: string; tooltip?: string;
options?: SettingOption[] | ((value: any) => SettingOption[]); options?: SettingOption[] | ((value: any) => SettingOption[]);
} }
export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> { export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
app: ComfyApp; app: ComfyApp;
settingsValues: any; settingsValues: any;
settingsLookup: Record<string, Setting>; settingsLookup: Record<string, Setting>;
constructor(app) { constructor(app) {
super(); super();
this.app = app; this.app = app;
this.settingsValues = {}; this.settingsValues = {};
this.settingsLookup = {}; this.settingsLookup = {};
this.element = $el( this.element = $el(
"dialog", "dialog",
{ {
id: "comfy-settings-dialog", id: "comfy-settings-dialog",
parent: document.body, parent: document.body,
}, },
[ [
$el("table.comfy-modal-content.comfy-table", [ $el("table.comfy-modal-content.comfy-table", [
$el( $el(
"caption", "caption",
{ textContent: "Settings" }, { textContent: "Settings" },
$el("button.comfy-btn", { $el("button.comfy-btn", {
type: "button", type: "button",
textContent: "\u00d7", textContent: "\u00d7",
onclick: () => { onclick: () => {
this.element.close(); this.element.close();
}, },
}) })
), ),
$el("tbody", { $: (tbody) => (this.textElement = tbody) }), $el("tbody", { $: (tbody) => (this.textElement = tbody) }),
$el("button", { $el("button", {
type: "button", type: "button",
textContent: "Close", textContent: "Close",
style: { style: {
cursor: "pointer", cursor: "pointer",
}, },
onclick: () => { onclick: () => {
this.element.close(); this.element.close();
}, },
}), }),
]), ]),
] ]
) as HTMLDialogElement; ) as HTMLDialogElement;
} }
get settings() { get settings() {
return Object.values(this.settingsLookup); return Object.values(this.settingsLookup);
} }
#dispatchChange(id, value, oldValue?) { #dispatchChange(id, value, oldValue?) {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(id + ".change", { new CustomEvent(id + ".change", {
detail: { detail: {
value, value,
oldValue oldValue
}, },
}) })
); );
} }
async load() { async load() {
if (this.app.storageLocation === "browser") { if (this.app.storageLocation === "browser") {
this.settingsValues = localStorage; this.settingsValues = localStorage;
} else { } else {
this.settingsValues = await api.getSettings(); this.settingsValues = await api.getSettings();
} }
// Trigger onChange for any settings added before load // Trigger onChange for any settings added before load
for (const id in this.settingsLookup) { for (const id in this.settingsLookup) {
const value = this.settingsValues[this.getId(id)]; const value = this.settingsValues[this.getId(id)];
this.settingsLookup[id].onChange?.(value); this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value); this.#dispatchChange(id, value);
} }
} }
getId(id) { getId(id) {
if (this.app.storageLocation === "browser") { if (this.app.storageLocation === "browser") {
id = "Comfy.Settings." + id; id = "Comfy.Settings." + id;
} }
return id; return id;
} }
getSettingValue(id, defaultValue?) { getSettingValue(id, defaultValue?) {
let value = this.settingsValues[this.getId(id)]; let value = this.settingsValues[this.getId(id)];
if (value != null) { if (value != null) {
if (this.app.storageLocation === "browser") { if (this.app.storageLocation === "browser") {
try { try {
value = JSON.parse(value); value = JSON.parse(value);
} catch (error) { } catch (error) {
} }
} }
} }
return value ?? defaultValue; return value ?? defaultValue;
} }
async setSettingValueAsync(id, value) { async setSettingValueAsync(id, value) {
const json = JSON.stringify(value); const json = JSON.stringify(value);
localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage
let oldValue = this.getSettingValue(id, undefined); let oldValue = this.getSettingValue(id, undefined);
this.settingsValues[this.getId(id)] = value; this.settingsValues[this.getId(id)] = value;
if (id in this.settingsLookup) { if (id in this.settingsLookup) {
this.settingsLookup[id].onChange?.(value, oldValue); this.settingsLookup[id].onChange?.(value, oldValue);
} }
this.#dispatchChange(id, value, oldValue); this.#dispatchChange(id, value, oldValue);
await api.storeSetting(id, value); await api.storeSetting(id, value);
} }
setSettingValue(id, value) { setSettingValue(id, value) {
this.setSettingValueAsync(id, value).catch((err) => { this.setSettingValueAsync(id, value).catch((err) => {
alert(`Error saving setting '${id}'`); alert(`Error saving setting '${id}'`);
console.error(err); console.error(err);
}); });
} }
addSetting(params: SettingParams) { addSetting(params: SettingParams) {
const { id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined } = params; const { id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined } = params;
if (!id) { if (!id) {
throw new Error("Settings must have an ID"); throw new Error("Settings must have an ID");
} }
if (id in this.settingsLookup) { if (id in this.settingsLookup) {
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`); throw new Error(`Setting ${id} of type ${type} must have a unique ID.`);
} }
let skipOnChange = false; let skipOnChange = false;
let value = this.getSettingValue(id); let value = this.getSettingValue(id);
if (value == null) { if (value == null) {
if (this.app.isNewUserSession) { if (this.app.isNewUserSession) {
// Check if we have a localStorage value but not a setting value and we are a new user // Check if we have a localStorage value but not a setting value and we are a new user
const localValue = localStorage["Comfy.Settings." + id]; const localValue = localStorage["Comfy.Settings." + id];
if (localValue) { if (localValue) {
value = JSON.parse(localValue); value = JSON.parse(localValue);
this.setSettingValue(id, value); // Store on the server this.setSettingValue(id, value); // Store on the server
} }
} }
if (value == null) { if (value == null) {
value = defaultValue; value = defaultValue;
} }
} }
// Trigger initial setting of value // Trigger initial setting of value
if (!skipOnChange) { if (!skipOnChange) {
onChange?.(value, undefined); onChange?.(value, undefined);
} }
this.settingsLookup[id] = { this.settingsLookup[id] = {
id, id,
onChange, onChange,
name, name,
render: () => { render: () => {
if (type === "hidden") return; if (type === "hidden") return;
const setter = (v) => { const setter = (v) => {
if (onChange) { if (onChange) {
onChange(v, value); onChange(v, value);
} }
this.setSettingValue(id, v); this.setSettingValue(id, v);
value = v; value = v;
}; };
value = this.getSettingValue(id, defaultValue); value = this.getSettingValue(id, defaultValue);
let element; let element;
const htmlID = id.replaceAll(".", "-"); const htmlID = id.replaceAll(".", "-");
const labelCell = $el("td", [ const labelCell = $el("td", [
$el("label", { $el("label", {
for: htmlID, for: htmlID,
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""], classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""],
textContent: name, textContent: name,
}), }),
]); ]);
if (typeof type === "function") { if (typeof type === "function") {
element = type(name, setter, value, attrs); element = type(name, setter, value, attrs);
} else { } else {
switch (type) { switch (type) {
case "boolean": case "boolean":
element = $el("tr", [ element = $el("tr", [
labelCell, labelCell,
$el("td", [ $el("td", [
$el("input", { $el("input", {
id: htmlID, id: htmlID,
type: "checkbox", type: "checkbox",
checked: value, checked: value,
onchange: (event) => { onchange: (event) => {
const isChecked = event.target.checked; const isChecked = event.target.checked;
if (onChange !== undefined) { if (onChange !== undefined) {
onChange(isChecked); onChange(isChecked);
} }
this.setSettingValue(id, isChecked); this.setSettingValue(id, isChecked);
}, },
}), }),
]), ]),
]); ]);
break; break;
case "number": case "number":
element = $el("tr", [ element = $el("tr", [
labelCell, labelCell,
$el("td", [ $el("td", [
$el("input", { $el("input", {
type, type,
value, value,
id: htmlID, id: htmlID,
oninput: (e) => { oninput: (e) => {
setter(e.target.value); setter(e.target.value);
}, },
...attrs, ...attrs,
}), }),
]), ]),
]); ]);
break; break;
case "slider": case "slider":
element = $el("tr", [ element = $el("tr", [
labelCell, labelCell,
$el("td", [ $el("td", [
$el( $el(
"div", "div",
{ {
style: { style: {
display: "grid", display: "grid",
gridAutoFlow: "column", gridAutoFlow: "column",
}, },
}, },
[ [
$el("input", { $el("input", {
...attrs, ...attrs,
value, value,
type: "range", type: "range",
oninput: (e) => { oninput: (e) => {
setter(e.target.value); setter(e.target.value);
e.target.nextElementSibling.value = e.target.value; e.target.nextElementSibling.value = e.target.value;
}, },
}), }),
$el("input", { $el("input", {
...attrs, ...attrs,
value, value,
id: htmlID, id: htmlID,
type: "number", type: "number",
style: { maxWidth: "4rem" }, style: { maxWidth: "4rem" },
oninput: (e) => { oninput: (e) => {
setter(e.target.value); setter(e.target.value);
e.target.previousElementSibling.value = e.target.value; e.target.previousElementSibling.value = e.target.value;
}, },
}), }),
] ]
), ),
]), ]),
]); ]);
break; break;
case "combo": case "combo":
element = $el("tr", [ element = $el("tr", [
labelCell, labelCell,
$el("td", [ $el("td", [
$el( $el(
"select", "select",
{ {
oninput: (e) => { oninput: (e) => {
setter(e.target.value); setter(e.target.value);
}, },
}, },
(typeof options === "function" ? options(value) : options || []).map((opt) => { (typeof options === "function" ? options(value) : options || []).map((opt) => {
if (typeof opt === "string") { if (typeof opt === "string") {
opt = { text: opt }; opt = { text: opt };
} }
const v = opt.value ?? opt.text; const v = opt.value ?? opt.text;
return $el("option", { return $el("option", {
value: v, value: v,
textContent: opt.text, textContent: opt.text,
selected: value + "" === v + "", selected: value + "" === v + "",
}); });
}) })
), ),
]), ]),
]); ]);
break; break;
case "text": case "text":
default: default:
if (type !== "text") { if (type !== "text") {
console.warn(`Unsupported setting type '${type}, defaulting to text`); console.warn(`Unsupported setting type '${type}, defaulting to text`);
} }
element = $el("tr", [ element = $el("tr", [
labelCell, labelCell,
$el("td", [ $el("td", [
$el("input", { $el("input", {
value, value,
id: htmlID, id: htmlID,
oninput: (e) => { oninput: (e) => {
setter(e.target.value); setter(e.target.value);
}, },
...attrs, ...attrs,
}), }),
]), ]),
]); ]);
break; break;
} }
} }
if (tooltip) { if (tooltip) {
element.title = tooltip; element.title = tooltip;
} }
return element; return element;
}, },
} as Setting; } as Setting;
const self = this; const self = this;
return { return {
get value() { get value() {
return self.getSettingValue(id, defaultValue); return self.getSettingValue(id, defaultValue);
}, },
set value(v) { set value(v) {
self.setSettingValue(id, v); self.setSettingValue(id, v);
}, },
}; };
} }
show() { show() {
this.textElement.replaceChildren( this.textElement.replaceChildren(
$el( $el(
"tr", "tr",
{ {
style: { display: "none" }, style: { display: "none" },
}, },
[$el("th"), $el("th", { style: { width: "33%" } })] [$el("th"), $el("th", { style: { width: "33%" } })]
), ),
...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()).filter(Boolean) ...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()).filter(Boolean)
); );
this.element.showModal(); this.element.showModal();
} }
} }

View File

@@ -1,34 +1,34 @@
.lds-ring { .lds-ring {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 1em; width: 1em;
height: 1em; height: 1em;
} }
.lds-ring div { .lds-ring div {
box-sizing: border-box; box-sizing: border-box;
display: block; display: block;
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 0.15em solid #fff; border: 0.15em solid #fff;
border-radius: 50%; border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent; border-color: #fff transparent transparent transparent;
} }
.lds-ring div:nth-child(1) { .lds-ring div:nth-child(1) {
animation-delay: -0.45s; animation-delay: -0.45s;
} }
.lds-ring div:nth-child(2) { .lds-ring div:nth-child(2) {
animation-delay: -0.3s; animation-delay: -0.3s;
} }
.lds-ring div:nth-child(3) { .lds-ring div:nth-child(3) {
animation-delay: -0.15s; animation-delay: -0.15s;
} }
@keyframes lds-ring { @keyframes lds-ring {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }

View File

@@ -2,7 +2,7 @@ import "./spinner.css";
export function createSpinner() { export function createSpinner() {
const div = document.createElement("div"); const div = document.createElement("div");
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`; div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild; return div.firstElementChild;
} }

View File

@@ -11,52 +11,52 @@ import { $el } from "../ui";
* @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange] * @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange]
*/ */
export function toggleSwitch(name, items, e?) { export function toggleSwitch(name, items, e?) {
const onChange = e?.onChange; const onChange = e?.onChange;
let selectedIndex; let selectedIndex;
let elements; let elements;
function updateSelected(index) { function updateSelected(index) {
if (selectedIndex != null) { if (selectedIndex != null) {
elements[selectedIndex].classList.remove("comfy-toggle-selected"); elements[selectedIndex].classList.remove("comfy-toggle-selected");
} }
onChange?.({ item: items[index], prev: selectedIndex == null ? undefined : items[selectedIndex] }); onChange?.({ item: items[index], prev: selectedIndex == null ? undefined : items[selectedIndex] });
selectedIndex = index; selectedIndex = index;
elements[selectedIndex].classList.add("comfy-toggle-selected"); elements[selectedIndex].classList.add("comfy-toggle-selected");
} }
elements = items.map((item, i) => { elements = items.map((item, i) => {
if (typeof item === "string") item = { text: item }; if (typeof item === "string") item = { text: item };
if (!item.value) item.value = item.text; if (!item.value) item.value = item.text;
const toggle = $el( const toggle = $el(
"label", "label",
{ {
textContent: item.text, textContent: item.text,
title: item.tooltip ?? "", title: item.tooltip ?? "",
}, },
$el("input", { $el("input", {
name, name,
type: "radio", type: "radio",
value: item.value ?? item.text, value: item.value ?? item.text,
checked: item.selected, checked: item.selected,
onchange: () => { onchange: () => {
updateSelected(i); updateSelected(i);
}, },
}) })
); );
if (item.selected) { if (item.selected) {
updateSelected(i); updateSelected(i);
} }
return toggle; return toggle;
}); });
const container = $el("div.comfy-toggle-switch", elements); const container = $el("div.comfy-toggle-switch", elements);
if (selectedIndex == null) { if (selectedIndex == null) {
elements[0].children[0].checked = true; elements[0].children[0].checked = true;
updateSelected(0); updateSelected(0);
} }
return container; return container;
} }

View File

@@ -5,122 +5,122 @@ import "./userSelection.css";
interface SelectedUser { interface SelectedUser {
username: string; username: string;
userId: string; userId: string;
created: boolean; created: boolean;
} }
export class UserSelectionScreen { export class UserSelectionScreen {
async show(users, user): Promise<SelectedUser>{ async show(users, user): Promise<SelectedUser>{
const userSelection = document.getElementById("comfy-user-selection"); const userSelection = document.getElementById("comfy-user-selection");
userSelection.style.display = ""; userSelection.style.display = "";
return new Promise((resolve) => { return new Promise((resolve) => {
const input = userSelection.getElementsByTagName("input")[0]; const input = userSelection.getElementsByTagName("input")[0];
const select = userSelection.getElementsByTagName("select")[0]; const select = userSelection.getElementsByTagName("select")[0];
const inputSection = input.closest("section"); const inputSection = input.closest("section");
const selectSection = select.closest("section"); const selectSection = select.closest("section");
const form = userSelection.getElementsByTagName("form")[0]; const form = userSelection.getElementsByTagName("form")[0];
const error = userSelection.getElementsByClassName("comfy-user-error")[0]; const error = userSelection.getElementsByClassName("comfy-user-error")[0];
const button = userSelection.getElementsByClassName("comfy-user-button-next")[0]; const button = userSelection.getElementsByClassName("comfy-user-button-next")[0];
let inputActive = null; let inputActive = null;
input.addEventListener("focus", () => { input.addEventListener("focus", () => {
inputSection.classList.add("selected"); inputSection.classList.add("selected");
selectSection.classList.remove("selected"); selectSection.classList.remove("selected");
inputActive = true; inputActive = true;
}); });
select.addEventListener("focus", () => { select.addEventListener("focus", () => {
inputSection.classList.remove("selected"); inputSection.classList.remove("selected");
selectSection.classList.add("selected"); selectSection.classList.add("selected");
inputActive = false; inputActive = false;
select.style.color = ""; select.style.color = "";
}); });
select.addEventListener("blur", () => { select.addEventListener("blur", () => {
if (!select.value) { if (!select.value) {
select.style.color = "var(--descrip-text)"; select.style.color = "var(--descrip-text)";
} }
}); });
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
if (inputActive == null) { if (inputActive == null) {
error.textContent = "Please enter a username or select an existing user."; error.textContent = "Please enter a username or select an existing user.";
} else if (inputActive) { } else if (inputActive) {
const username = input.value.trim(); const username = input.value.trim();
if (!username) { if (!username) {
error.textContent = "Please enter a username."; error.textContent = "Please enter a username.";
return; return;
} }
// Create new user // Create new user
// @ts-ignore // @ts-ignore
// Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339) // Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339)
// Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551) // Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551)
input.disabled = select.disabled = input.readonly = select.readonly = true; input.disabled = select.disabled = input.readonly = select.readonly = true;
const spinner = createSpinner(); const spinner = createSpinner();
button.prepend(spinner); button.prepend(spinner);
try { try {
const resp = await api.createUser(username); const resp = await api.createUser(username);
if (resp.status >= 300) { if (resp.status >= 300) {
let message = "Error creating user: " + resp.status + " " + resp.statusText; let message = "Error creating user: " + resp.status + " " + resp.statusText;
try { try {
const res = await resp.json(); const res = await resp.json();
if(res.error) { if(res.error) {
message = res.error; message = res.error;
} }
} catch (error) { } catch (error) {
} }
throw new Error(message); throw new Error(message);
} }
resolve({ username, userId: await resp.json(), created: true }); resolve({ username, userId: await resp.json(), created: true });
} catch (err) { } catch (err) {
spinner.remove(); spinner.remove();
error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred."; error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred.";
// @ts-ignore // @ts-ignore
// Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339) // Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339)
// Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551) // Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551)
input.disabled = select.disabled = input.readonly = select.readonly = false; input.disabled = select.disabled = input.readonly = select.readonly = false;
return; return;
} }
} else if (!select.value) { } else if (!select.value) {
error.textContent = "Please select an existing user."; error.textContent = "Please select an existing user.";
return; return;
} else { } else {
resolve({ username: users[select.value], userId: select.value, created: false }); resolve({ username: users[select.value], userId: select.value, created: false });
} }
}); });
if (user) { if (user) {
const name = localStorage["Comfy.userName"]; const name = localStorage["Comfy.userName"];
if (name) { if (name) {
input.value = name; input.value = name;
} }
} }
if (input.value) { if (input.value) {
// Focus the input, do this separately as sometimes browsers like to fill in the value // Focus the input, do this separately as sometimes browsers like to fill in the value
input.focus(); input.focus();
} }
const userIds = Object.keys(users ?? {}); const userIds = Object.keys(users ?? {});
if (userIds.length) { if (userIds.length) {
for (const u of userIds) { for (const u of userIds) {
$el("option", { textContent: users[u], value: u, parent: select }); $el("option", { textContent: users[u], value: u, parent: select });
} }
select.style.color = "var(--descrip-text)"; select.style.color = "var(--descrip-text)";
if (select.value) { if (select.value) {
// Focus the select, do this separately as sometimes browsers like to fill in the value // Focus the select, do this separately as sometimes browsers like to fill in the value
select.focus(); select.focus();
} }
} else { } else {
userSelection.classList.add("no-users"); userSelection.classList.add("no-users");
input.focus(); input.focus();
} }
}).then((r: SelectedUser) => { }).then((r: SelectedUser) => {
userSelection.remove(); userSelection.remove();
return r; return r;
}); });
} }
} }

View File

@@ -8,25 +8,25 @@
* @param { string[] } requiredClasses * @param { string[] } requiredClasses
*/ */
export function applyClasses(element, classList, ...requiredClasses) { export function applyClasses(element, classList, ...requiredClasses) {
classList ??= ""; classList ??= "";
let str; let str;
if (typeof classList === "string") { if (typeof classList === "string") {
str = classList; str = classList;
} else if (classList instanceof Array) { } else if (classList instanceof Array) {
str = classList.join(" "); str = classList.join(" ");
} else { } else {
str = Object.entries(classList).reduce((p, c) => { str = Object.entries(classList).reduce((p, c) => {
if (c[1]) { if (c[1]) {
p += (p.length ? " " : "") + c[0]; p += (p.length ? " " : "") + c[0];
} }
return p; return p;
}, ""); }, "");
} }
element.className = str; element.className = str;
if (requiredClasses) { if (requiredClasses) {
element.classList.add(...requiredClasses); element.classList.add(...requiredClasses);
} }
} }
/** /**
@@ -35,22 +35,22 @@ export function applyClasses(element, classList, ...requiredClasses) {
* @returns * @returns
*/ */
export function toggleElement(element, { onHide, onShow } = {}) { export function toggleElement(element, { onHide, onShow } = {}) {
let placeholder; let placeholder;
let hidden; let hidden;
return (value) => { return (value) => {
if (value) { if (value) {
if (hidden) { if (hidden) {
hidden = false; hidden = false;
placeholder.replaceWith(element); placeholder.replaceWith(element);
} }
onShow?.(element, value); onShow?.(element, value);
} else { } else {
if (!placeholder) { if (!placeholder) {
placeholder = document.createComment(""); placeholder = document.createComment("");
} }
hidden = true; hidden = true;
element.replaceWith(placeholder); element.replaceWith(placeholder);
onHide?.(element); onHide?.(element);
} }
}; };
} }

View File

@@ -4,103 +4,103 @@ import { $el } from "./ui";
// Simple date formatter // Simple date formatter
const parts = { const parts = {
d: (d) => d.getDate(), d: (d) => d.getDate(),
M: (d) => d.getMonth() + 1, M: (d) => d.getMonth() + 1,
h: (d) => d.getHours(), h: (d) => d.getHours(),
m: (d) => d.getMinutes(), m: (d) => d.getMinutes(),
s: (d) => d.getSeconds(), s: (d) => d.getSeconds(),
}; };
const format = const format =
Object.keys(parts) Object.keys(parts)
.map((k) => k + k + "?") .map((k) => k + k + "?")
.join("|") + "|yyy?y?"; .join("|") + "|yyy?y?";
function formatDate(text: string, date: Date) { function formatDate(text: string, date: Date) {
return text.replace(new RegExp(format, "g"), (text: string): string => { return text.replace(new RegExp(format, "g"), (text: string): string => {
if (text === "yy") return (date.getFullYear() + "").substring(2); if (text === "yy") return (date.getFullYear() + "").substring(2);
if (text === "yyyy") return date.getFullYear().toString(); if (text === "yyyy") return date.getFullYear().toString();
if (text[0] in parts) { if (text[0] in parts) {
const p = parts[text[0]](date); const p = parts[text[0]](date);
return (p + "").padStart(text.length, "0"); return (p + "").padStart(text.length, "0");
} }
return text; return text;
}); });
} }
export function clone(obj) { export function clone(obj) {
try { try {
if (typeof structuredClone !== "undefined") { if (typeof structuredClone !== "undefined") {
return structuredClone(obj); return structuredClone(obj);
} }
} catch (error) { } catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that // 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 { export function applyTextReplacements(app: ComfyApp, value: string): string {
return value.replace(/%([^%]+)%/g, function (match, text) { return value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split("."); const split = text.split(".");
if (split.length !== 2) { if (split.length !== 2) {
// Special handling for dates // Special handling for dates
if (split[0].startsWith("date:")) { if (split[0].startsWith("date:")) {
return formatDate(split[0].substring(5), new Date()); return formatDate(split[0].substring(5), new Date());
} }
if (text !== "width" && text !== "height") { if (text !== "width" && text !== "height") {
// Dont warn on standard replacements // Dont warn on standard replacements
console.warn("Invalid replacement pattern", text); console.warn("Invalid replacement pattern", text);
} }
return match; return match;
} }
// Find node with matching S&R property name // Find node with matching S&R property name
// @ts-ignore // @ts-ignore
let nodes = app.graph._nodes.filter((n) => n.properties?.["Node name for S&R"] === split[0]); 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 we cant, see if there is a node with that title
if (!nodes.length) { if (!nodes.length) {
// @ts-ignore // @ts-ignore
nodes = app.graph._nodes.filter((n) => n.title === split[0]); nodes = app.graph._nodes.filter((n) => n.title === split[0]);
} }
if (!nodes.length) { if (!nodes.length) {
console.warn("Unable to find node", split[0]); console.warn("Unable to find node", split[0]);
return match; return match;
} }
if (nodes.length > 1) { if (nodes.length > 1) {
console.warn("Multiple nodes matched", split[0], "using first match"); 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]); const widget = node.widgets?.find((w) => w.name === split[1]);
if (!widget) { if (!widget) {
console.warn("Unable to find widget", split[1], "on node", split[0], node); console.warn("Unable to find widget", split[1], "on node", split[0], node);
return match; return match;
} }
return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_"); return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
}); });
} }
export async function addStylesheet(urlOrFile: string, relativeTo?: string): Promise<void> { export async function addStylesheet(urlOrFile: string, relativeTo?: string): Promise<void> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
let url; let url;
if (urlOrFile.endsWith(".js")) { if (urlOrFile.endsWith(".js")) {
url = urlOrFile.substr(0, urlOrFile.length - 2) + "css"; url = urlOrFile.substr(0, urlOrFile.length - 2) + "css";
} else { } else {
url = new URL(urlOrFile, relativeTo ?? `${window.location.protocol}//${window.location.host}`).toString(); url = new URL(urlOrFile, relativeTo ?? `${window.location.protocol}//${window.location.host}`).toString();
} }
$el("link", { $el("link", {
parent: document.head, parent: document.head,
rel: "stylesheet", rel: "stylesheet",
type: "text/css", type: "text/css",
href: url, href: url,
onload: res, onload: res,
onerror: rej, onerror: rej,
}); });
}); });
} }
@@ -109,18 +109,18 @@ export async function addStylesheet(urlOrFile: string, relativeTo?: string): Pro
* @param { Blob } blob * @param { Blob } blob
*/ */
export function downloadBlob(filename, blob) { export function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = $el("a", { const a = $el("a", {
href: url, href: url,
download: filename, download: filename,
style: { display: "none" }, style: { display: "none" },
parent: document.body, parent: document.body,
}); });
a.click(); a.click();
setTimeout(function () { setTimeout(function () {
a.remove(); a.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, 0); }, 0);
} }
/** /**
@@ -131,29 +131,29 @@ export function downloadBlob(filename, blob) {
* @returns {T} * @returns {T}
*/ */
export function prop(target, name, defaultValue, onChanged) { export function prop(target, name, defaultValue, onChanged) {
let currentValue; let currentValue;
Object.defineProperty(target, name, { Object.defineProperty(target, name, {
get() { get() {
return currentValue; return currentValue;
}, },
set(newValue) { set(newValue) {
const prevValue = currentValue; const prevValue = currentValue;
currentValue = newValue; currentValue = newValue;
onChanged?.(currentValue, prevValue, target, name); onChanged?.(currentValue, prevValue, target, name);
}, },
}); });
return defaultValue; return defaultValue;
} }
export function getStorageValue(id) { export function getStorageValue(id) {
const clientId = api.clientId ?? api.initialClientId; const clientId = api.clientId ?? api.initialClientId;
return (clientId && sessionStorage.getItem(`${id}:${clientId}`)) ?? localStorage.getItem(id); return (clientId && sessionStorage.getItem(`${id}:${clientId}`)) ?? localStorage.getItem(id);
} }
export function setStorageValue(id, value) { export function setStorageValue(id, value) {
const clientId = api.clientId ?? api.initialClientId; const clientId = api.clientId ?? api.initialClientId;
if (clientId) { if (clientId) {
sessionStorage.setItem(`${id}:${clientId}`, value); sessionStorage.setItem(`${id}:${clientId}`, value);
} }
localStorage.setItem(id, value); localStorage.setItem(id, value);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -6,445 +6,445 @@ import { ComfyAsyncDialog } from "./ui/components/asyncDialog";
import { getStorageValue, setStorageValue } from "./utils"; import { getStorageValue, setStorageValue } from "./utils";
function appendJsonExt(path) { function appendJsonExt(path) {
if (!path.toLowerCase().endsWith(".json")) { if (!path.toLowerCase().endsWith(".json")) {
path += ".json"; path += ".json";
} }
return path; return path;
} }
export function trimJsonExt(path) { export function trimJsonExt(path) {
return path?.replace(/\.json$/, ""); return path?.replace(/\.json$/, "");
} }
export class ComfyWorkflowManager extends EventTarget { export class ComfyWorkflowManager extends EventTarget {
/** @type {string | null} */ /** @type {string | null} */
#activePromptId = null; #activePromptId = null;
#unsavedCount = 0; #unsavedCount = 0;
#activeWorkflow; #activeWorkflow;
/** @type {Record<string, ComfyWorkflow>} */ /** @type {Record<string, ComfyWorkflow>} */
workflowLookup = {}; workflowLookup = {};
/** @type {Array<ComfyWorkflow>} */ /** @type {Array<ComfyWorkflow>} */
workflows = []; workflows = [];
/** @type {Array<ComfyWorkflow>} */ /** @type {Array<ComfyWorkflow>} */
openWorkflows = []; openWorkflows = [];
/** @type {Record<string, {workflow?: ComfyWorkflow, nodes?: Record<string, boolean>}>} */ /** @type {Record<string, {workflow?: ComfyWorkflow, nodes?: Record<string, boolean>}>} */
queuedPrompts = {}; queuedPrompts = {};
get activeWorkflow() { get activeWorkflow() {
return this.#activeWorkflow ?? this.openWorkflows[0]; return this.#activeWorkflow ?? this.openWorkflows[0];
} }
get activePromptId() { get activePromptId() {
return this.#activePromptId; return this.#activePromptId;
} }
get activePrompt() { get activePrompt() {
return this.queuedPrompts[this.#activePromptId]; return this.queuedPrompts[this.#activePromptId];
} }
/** /**
* @param {import("./app").ComfyApp} app * @param {import("./app").ComfyApp} app
*/ */
constructor(app) { constructor(app) {
super(); super();
this.app = app; this.app = app;
ChangeTracker.init(app); ChangeTracker.init(app);
this.#bindExecutionEvents(); this.#bindExecutionEvents();
} }
#bindExecutionEvents() { #bindExecutionEvents() {
// TODO: on reload, set active prompt based on the latest ws message // TODO: on reload, set active prompt based on the latest ws message
const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt })); const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt }));
let executing = null; let executing = null;
api.addEventListener("execution_start", (e) => { api.addEventListener("execution_start", (e) => {
this.#activePromptId = e.detail.prompt_id; this.#activePromptId = e.detail.prompt_id;
// This event can fire before the event is stored, so put a placeholder // This event can fire before the event is stored, so put a placeholder
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }; this.queuedPrompts[this.#activePromptId] ??= { nodes: {} };
emit(); emit();
}); });
api.addEventListener("execution_cached", (e) => { api.addEventListener("execution_cached", (e) => {
if (!this.activePrompt) return; if (!this.activePrompt) return;
for (const n of e.detail.nodes) { for (const n of e.detail.nodes) {
this.activePrompt.nodes[n] = true; this.activePrompt.nodes[n] = true;
} }
emit(); emit();
}); });
api.addEventListener("executed", (e) => { api.addEventListener("executed", (e) => {
if (!this.activePrompt) return; if (!this.activePrompt) return;
this.activePrompt.nodes[e.detail.node] = true; this.activePrompt.nodes[e.detail.node] = true;
emit(); emit();
}); });
api.addEventListener("executing", (e) => { api.addEventListener("executing", (e) => {
if (!this.activePrompt) return; if (!this.activePrompt) return;
if (executing) { if (executing) {
// Seems sometimes nodes that are cached fire executing but not executed // Seems sometimes nodes that are cached fire executing but not executed
this.activePrompt.nodes[executing] = true; this.activePrompt.nodes[executing] = true;
} }
executing = e.detail; executing = e.detail;
if (!executing) { if (!executing) {
delete this.queuedPrompts[this.#activePromptId]; delete this.queuedPrompts[this.#activePromptId];
this.#activePromptId = null; this.#activePromptId = null;
} }
emit(); emit();
}); });
} }
async loadWorkflows() { async loadWorkflows() {
try { try {
let favorites; let favorites;
const resp = await api.getUserData("workflows/.index.json"); const resp = await api.getUserData("workflows/.index.json");
let info; let info;
if (resp.status === 200) { if (resp.status === 200) {
info = await resp.json(); info = await resp.json();
favorites = new Set(info?.favorites ?? []); favorites = new Set(info?.favorites ?? []);
} else { } else {
favorites = new Set(); favorites = new Set();
} }
const workflows = (await api.listUserData("workflows", true, true)).map((w) => { const workflows = (await api.listUserData("workflows", true, true)).map((w) => {
let workflow = this.workflowLookup[w[0]]; let workflow = this.workflowLookup[w[0]];
if (!workflow) { if (!workflow) {
workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0])); workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0]));
this.workflowLookup[workflow.path] = workflow; this.workflowLookup[workflow.path] = workflow;
} }
return workflow; return workflow;
}); });
this.workflows = workflows; this.workflows = workflows;
} catch (error) { } catch (error) {
alert("Error loading workflows: " + (error.message ?? error)); alert("Error loading workflows: " + (error.message ?? error));
this.workflows = []; this.workflows = [];
} }
} }
async saveWorkflowMetadata() { async saveWorkflowMetadata() {
await api.storeUserData("workflows/.index.json", { await api.storeUserData("workflows/.index.json", {
favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)], favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)],
}); });
} }
/** /**
* @param {string | ComfyWorkflow | null} workflow * @param {string | ComfyWorkflow | null} workflow
*/ */
setWorkflow(workflow) { setWorkflow(workflow) {
if (workflow && typeof workflow === "string") { if (workflow && typeof workflow === "string") {
// Selected by path, i.e. on reload of last workflow // Selected by path, i.e. on reload of last workflow
const found = this.workflows.find((w) => w.path === workflow); const found = this.workflows.find((w) => w.path === workflow);
if (found) { if (found) {
workflow = found; workflow = found;
workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true"; workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true";
} }
} }
if (!(workflow instanceof ComfyWorkflow)) { if (!(workflow instanceof ComfyWorkflow)) {
// Still not found, either reloading a deleted workflow or blank // Still not found, either reloading a deleted workflow or blank
workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : "")); workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : ""));
} }
const index = this.openWorkflows.indexOf(workflow); const index = this.openWorkflows.indexOf(workflow);
if (index === -1) { if (index === -1) {
// Opening a new workflow // Opening a new workflow
this.openWorkflows.push(workflow); this.openWorkflows.push(workflow);
} }
this.#activeWorkflow = workflow; this.#activeWorkflow = workflow;
setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? ""); setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? "");
this.dispatchEvent(new CustomEvent("changeWorkflow")); this.dispatchEvent(new CustomEvent("changeWorkflow"));
} }
storePrompt({ nodes, id }) { storePrompt({ nodes, id }) {
this.queuedPrompts[id] ??= {}; this.queuedPrompts[id] ??= {};
this.queuedPrompts[id].nodes = { this.queuedPrompts[id].nodes = {
...nodes.reduce((p, n) => { ...nodes.reduce((p, n) => {
p[n] = false; p[n] = false;
return p; return p;
}, {}), }, {}),
...this.queuedPrompts[id].nodes, ...this.queuedPrompts[id].nodes,
}; };
this.queuedPrompts[id].workflow = this.activeWorkflow; this.queuedPrompts[id].workflow = this.activeWorkflow;
} }
/** /**
* @param {ComfyWorkflow} workflow * @param {ComfyWorkflow} workflow
*/ */
async closeWorkflow(workflow, warnIfUnsaved = true) { async closeWorkflow(workflow, warnIfUnsaved = true) {
if (!workflow.isOpen) { if (!workflow.isOpen) {
return true; return true;
} }
if (workflow.unsaved && warnIfUnsaved) { if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({ const res = await ComfyAsyncDialog.prompt({
title: "Save Changes?", title: "Save Changes?",
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`, message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ["Yes", "No", "Cancel"], actions: ["Yes", "No", "Cancel"],
}); });
if (res === "Yes") { if (res === "Yes") {
const active = this.activeWorkflow; const active = this.activeWorkflow;
if (active !== workflow) { if (active !== workflow) {
// We need to switch to the workflow to save it // We need to switch to the workflow to save it
await workflow.load(); await workflow.load();
} }
if (!(await workflow.save())) { if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow // Save was canceled, restore the previous workflow
if (active !== workflow) { if (active !== workflow) {
await active.load(); await active.load();
} }
return; return;
} }
} else if (res === "Cancel") { } else if (res === "Cancel") {
return; return;
} }
} }
workflow.changeTracker = null; workflow.changeTracker = null;
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1); this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1);
if (this.openWorkflows.length) { if (this.openWorkflows.length) {
this.#activeWorkflow = this.openWorkflows[0]; this.#activeWorkflow = this.openWorkflows[0];
await this.#activeWorkflow.load(); await this.#activeWorkflow.load();
} else { } else {
// Load default // Load default
await this.app.loadGraphData(); await this.app.loadGraphData();
} }
} }
} }
export class ComfyWorkflow { export class ComfyWorkflow {
#name; #name;
#path; #path;
#pathParts; #pathParts;
#isFavorite = false; #isFavorite = false;
/** @type {ChangeTracker | null} */ /** @type {ChangeTracker | null} */
changeTracker = null; changeTracker = null;
unsaved = false; unsaved = false;
get name() { get name() {
return this.#name; return this.#name;
} }
get path() { get path() {
return this.#path; return this.#path;
} }
get pathParts() { get pathParts() {
return this.#pathParts; return this.#pathParts;
} }
get isFavorite() { get isFavorite() {
return this.#isFavorite; return this.#isFavorite;
} }
get isOpen() { get isOpen() {
return !!this.changeTracker; return !!this.changeTracker;
} }
/** /**
* @overload * @overload
* @param {ComfyWorkflowManager} manager * @param {ComfyWorkflowManager} manager
* @param {string} path * @param {string} path
*/ */
/** /**
* @overload * @overload
* @param {ComfyWorkflowManager} manager * @param {ComfyWorkflowManager} manager
* @param {string} path * @param {string} path
* @param {string[]} pathParts * @param {string[]} pathParts
* @param {boolean} isFavorite * @param {boolean} isFavorite
*/ */
/** /**
* @param {ComfyWorkflowManager} manager * @param {ComfyWorkflowManager} manager
* @param {string} path * @param {string} path
* @param {string[]} [pathParts] * @param {string[]} [pathParts]
* @param {boolean} [isFavorite] * @param {boolean} [isFavorite]
*/ */
constructor(manager, path, pathParts, isFavorite) { constructor(manager, path, pathParts, isFavorite) {
this.manager = manager; this.manager = manager;
if (pathParts) { if (pathParts) {
this.#updatePath(path, pathParts); this.#updatePath(path, pathParts);
this.#isFavorite = isFavorite; this.#isFavorite = isFavorite;
} else { } else {
this.#name = path; this.#name = path;
this.unsaved = true; this.unsaved = true;
} }
} }
/** /**
* @param {string} path * @param {string} path
* @param {string[]} [pathParts] * @param {string[]} [pathParts]
*/ */
#updatePath(path, pathParts) { #updatePath(path, pathParts) {
this.#path = path; this.#path = path;
if (!pathParts) { if (!pathParts) {
if (!path.includes("\\")) { if (!path.includes("\\")) {
pathParts = path.split("/"); pathParts = path.split("/");
} else { } else {
pathParts = path.split("\\"); pathParts = path.split("\\");
} }
} }
this.#pathParts = pathParts; this.#pathParts = pathParts;
this.#name = trimJsonExt(pathParts[pathParts.length - 1]); this.#name = trimJsonExt(pathParts[pathParts.length - 1]);
} }
async getWorkflowData() { async getWorkflowData() {
const resp = await api.getUserData("workflows/" + this.path); const resp = await api.getUserData("workflows/" + this.path);
if (resp.status !== 200) { if (resp.status !== 200) {
alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`); alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`);
return; return;
} }
return await resp.json(); return await resp.json();
} }
load = async () => { load = async () => {
if (this.isOpen) { if (this.isOpen) {
await this.manager.app.loadGraphData(this.changeTracker.activeState, true, true, this); await this.manager.app.loadGraphData(this.changeTracker.activeState, true, true, this);
} else { } else {
const data = await this.getWorkflowData(); const data = await this.getWorkflowData();
if (!data) return; if (!data) return;
await this.manager.app.loadGraphData(data, true, true, this); await this.manager.app.loadGraphData(data, true, true, this);
} }
}; };
async save(saveAs = false) { async save(saveAs = false) {
if (!this.path || saveAs) { if (!this.path || saveAs) {
return !!(await this.#save(null, false)); return !!(await this.#save(null, false));
} else { } else {
return !!(await this.#save(this.path, true)); return !!(await this.#save(this.path, true));
} }
} }
/** /**
* @param {boolean} value * @param {boolean} value
*/ */
async favorite(value) { async favorite(value) {
try { try {
if (this.#isFavorite === value) return; if (this.#isFavorite === value) return;
this.#isFavorite = value; this.#isFavorite = value;
await this.manager.saveWorkflowMetadata(); await this.manager.saveWorkflowMetadata();
this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this })); this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this }));
} catch (error) { } catch (error) {
alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error)); alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error));
} }
} }
/** /**
* @param {string} path * @param {string} path
*/ */
async rename(path) { async rename(path) {
path = appendJsonExt(path); path = appendJsonExt(path);
let resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path); let resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path);
if (resp.status === 409) { if (resp.status === 409) {
if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return resp; 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 }); resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path, { overwrite: true });
} }
if (resp.status !== 200) { if (resp.status !== 200) {
alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`); alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`);
return; return;
} }
const isFav = this.isFavorite; const isFav = this.isFavorite;
if (isFav) { if (isFav) {
await this.favorite(false); await this.favorite(false);
} }
path = (await resp.json()).substring("workflows/".length); path = (await resp.json()).substring("workflows/".length);
this.#updatePath(path, null); this.#updatePath(path, null);
if (isFav) { if (isFav) {
await this.favorite(true); await this.favorite(true);
} }
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
} }
async insert() { async insert() {
const data = await this.getWorkflowData(); const data = await this.getWorkflowData();
if (!data) return; if (!data) return;
const old = localStorage.getItem("litegrapheditor_clipboard"); const old = localStorage.getItem("litegrapheditor_clipboard");
const graph = new LGraph(data); const graph = new LGraph(data);
const canvas = new LGraphCanvas(null, graph, { skip_events: true, skip_render: true }); const canvas = new LGraphCanvas(null, graph, { skip_events: true, skip_render: true });
canvas.selectNodes(); canvas.selectNodes();
canvas.copyToClipboard(); canvas.copyToClipboard();
this.manager.app.canvas.pasteFromClipboard(); this.manager.app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", old); localStorage.setItem("litegrapheditor_clipboard", old);
} }
async delete() { async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default // TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
try { try {
if (this.isFavorite) { if (this.isFavorite) {
await this.favorite(false); await this.favorite(false);
} }
await api.deleteUserData("workflows/" + this.path); await api.deleteUserData("workflows/" + this.path);
this.unsaved = true; this.unsaved = true;
this.#path = null; this.#path = null;
this.#pathParts = null; this.#pathParts = null;
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1); this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1);
this.manager.dispatchEvent(new CustomEvent("delete", { detail: this })); this.manager.dispatchEvent(new CustomEvent("delete", { detail: this }));
} catch (error) { } catch (error) {
alert(`Error deleting workflow: ${error.message || error}`); alert(`Error deleting workflow: ${error.message || error}`);
} }
} }
track() { track() {
if (this.changeTracker) { if (this.changeTracker) {
this.changeTracker.restore(); this.changeTracker.restore();
} else { } else {
this.changeTracker = new ChangeTracker(this); this.changeTracker = new ChangeTracker(this);
} }
} }
/** /**
* @param {string|null} path * @param {string|null} path
* @param {boolean} overwrite * @param {boolean} overwrite
*/ */
async #save(path, overwrite) { async #save(path, overwrite) {
if (!path) { if (!path) {
path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow"); path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow");
if (!path) return; if (!path) return;
} }
path = appendJsonExt(path); path = appendJsonExt(path);
const p = await this.manager.app.graphToPrompt(); const p = await this.manager.app.graphToPrompt();
const json = JSON.stringify(p.workflow, null, 2); const json = JSON.stringify(p.workflow, null, 2);
let resp = await api.storeUserData("workflows/" + path, json, { stringify: false, throwOnError: false, overwrite }); let resp = await api.storeUserData("workflows/" + path, json, { stringify: false, throwOnError: false, overwrite });
if (resp.status === 409) { if (resp.status === 409) {
if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return; if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return;
resp = await api.storeUserData("workflows/" + path, json, { stringify: false }); resp = await api.storeUserData("workflows/" + path, json, { stringify: false });
} }
if (resp.status !== 200) { if (resp.status !== 200) {
alert(`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`); alert(`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`);
return; return;
} }
path = (await resp.json()).substring("workflows/".length); path = (await resp.json()).substring("workflows/".length);
if (!this.path) { if (!this.path) {
// Saved new workflow, patch this instance // Saved new workflow, patch this instance
this.#updatePath(path, null); this.#updatePath(path, null);
await this.manager.loadWorkflows(); await this.manager.loadWorkflows();
this.unsaved = false; this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
} else if (path !== this.path) { } else if (path !== this.path) {
// Saved as, open the new copy // Saved as, open the new copy
await this.manager.loadWorkflows(); await this.manager.loadWorkflows();
const workflow = this.manager.workflowLookup[path]; const workflow = this.manager.workflowLookup[path];
await workflow.load(); await workflow.load();
} else { } else {
// Normal save // Normal save
this.unsaved = false; this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("save", { detail: this })); this.manager.dispatchEvent(new CustomEvent("save", { detail: this }));
} }
return true; return true;
} }
} }

View File

@@ -53,7 +53,7 @@ export type WidgetCallback<T extends IWidget = IWidget> = (
export interface IWidget<TValue = any, TOptions = any> { export interface IWidget<TValue = any, TOptions = any> {
// linked widgets, e.g. seed+seedControl // linked widgets, e.g. seed+seedControl
linkedWidgets: IWidget[]; linkedWidgets: IWidget[];
name: string | null; name: string | null;
value: TValue; value: TValue;
@@ -165,7 +165,7 @@ export declare class LGraph {
static supported_types: string[]; static supported_types: string[];
static STATUS_STOPPED: 1; static STATUS_STOPPED: 1;
static STATUS_RUNNING: 2; static STATUS_RUNNING: 2;
extra: any; extra: any;
constructor(o?: object); constructor(o?: object);
@@ -411,12 +411,12 @@ export type SerializedLGraphNode<T extends LGraphNode = LGraphNode> = {
/** https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md#lgraphnode */ /** https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md#lgraphnode */
export declare class LGraphNode { export declare class LGraphNode {
onResize?: Function; onResize?: Function;
// Used in group node // Used in group node
setInnerNodes(nodes: any) { setInnerNodes(nodes: any) {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
static title_color: string; static title_color: string;
static title: string; static title: string;
@@ -857,7 +857,7 @@ export declare class LGraphNode {
} }
export type LGraphNodeConstructor<T extends LGraphNode = LGraphNode> = { export type LGraphNodeConstructor<T extends LGraphNode = LGraphNode> = {
nodeData: any; // Used by group node. nodeData: any; // Used by group node.
new (): T; new (): T;
}; };
@@ -1341,11 +1341,11 @@ declare global {
} }
const LiteGraph: { const LiteGraph: {
DEFAULT_GROUP_FONT_SIZE: any; DEFAULT_GROUP_FONT_SIZE: any;
overlapBounding(visible_area: any, _bounding: any): unknown; overlapBounding(visible_area: any, _bounding: any): unknown;
release_link_on_empty_shows_menu: boolean; release_link_on_empty_shows_menu: boolean;
alt_drag_do_clone_nodes: boolean; alt_drag_do_clone_nodes: boolean;
GRID_SHAPE: number; GRID_SHAPE: number;
VERSION: number; VERSION: number;
CANVAS_GRID_SIZE: number; CANVAS_GRID_SIZE: number;

View File

@@ -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 // Load things once per test file before to ensure its all warmed up for the tests
beforeAll(async () => { beforeAll(async () => {
lg.setup(global); lg.setup(global);
await start({ resetEnv: true }); await start({ resetEnv: true });
lg.teardown(global); lg.teardown(global);
}); });

View File

@@ -1,14 +1,14 @@
module.exports = async function () { module.exports = async function () {
global.ResizeObserver = class ResizeObserver { global.ResizeObserver = class ResizeObserver {
observe() {} observe() {}
unobserve() {} unobserve() {}
disconnect() {} disconnect() {}
}; };
const { nop } = require("./utils/nopProxy"); const { nop } = require("./utils/nopProxy");
global.enableWebGLCanvas = nop; 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";
}; };

View File

@@ -3,36 +3,36 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
import http from "http"; import http from "http";
async function setup() { async function setup() {
await new Promise<void>((res, rej) => { await new Promise<void>((res, rej) => {
http http
.get("http://127.0.0.1:8188/object_info", (resp) => { .get("http://127.0.0.1:8188/object_info", (resp) => {
let data = ""; let data = "";
resp.on("data", (chunk) => { resp.on("data", (chunk) => {
data += chunk; data += chunk;
}); });
resp.on("end", () => { resp.on("end", () => {
// Modify the response data to add some checkpoints // Modify the response data to add some checkpoints
const objectInfo = JSON.parse(data); const objectInfo = JSON.parse(data);
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"]; objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"];
objectInfo.VAELoader.input.required.vae_name[0] = ["vae1.safetensors", "vae2.ckpt"]; 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"); const outDir = resolve("./tests-ui/data");
if (!existsSync(outDir)) { if (!existsSync(outDir)) {
mkdirSync(outDir); mkdirSync(outDir);
} }
const outPath = resolve(outDir, "object_info.json"); const outPath = resolve(outDir, "object_info.json");
console.log(`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`); console.log(`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`);
writeFileSync(outPath, data, { writeFileSync(outPath, data, {
encoding: "utf8", encoding: "utf8",
}); });
res(); res();
}); });
}) })
.on("error", rej); .on("error", rej);
}); });
} }
setup(); setup();

View File

@@ -2,193 +2,193 @@ import { start } from "../utils";
import lg from "../utils/litegraph"; import lg from "../utils/litegraph";
describe("extensions", () => { describe("extensions", () => {
beforeEach(() => { beforeEach(() => {
lg.setup(global); lg.setup(global);
}); });
afterEach(() => { afterEach(() => {
lg.teardown(global); lg.teardown(global);
}); });
it("calls each extension hook", async () => { it("calls each extension hook", async () => {
const mockExtension = { const mockExtension = {
name: "TestExtension", name: "TestExtension",
init: jest.fn(), init: jest.fn(),
setup: jest.fn(), setup: jest.fn(),
addCustomNodeDefs: jest.fn(), addCustomNodeDefs: jest.fn(),
getCustomWidgets: jest.fn(), getCustomWidgets: jest.fn(),
beforeRegisterNodeDef: jest.fn(), beforeRegisterNodeDef: jest.fn(),
registerCustomNodes: jest.fn(), registerCustomNodes: jest.fn(),
loadedGraphNode: jest.fn(), loadedGraphNode: jest.fn(),
nodeCreated: jest.fn(), nodeCreated: jest.fn(),
beforeConfigureGraph: jest.fn(), beforeConfigureGraph: jest.fn(),
afterConfigureGraph: jest.fn(), afterConfigureGraph: jest.fn(),
}; };
const { app, ez, graph } = await start({ const { app, ez, graph } = await start({
async preSetup(app) { async preSetup(app) {
app.registerExtension(mockExtension); app.registerExtension(mockExtension);
}, },
}); });
// Basic initialisation hooks should be called once, with app // Basic initialisation hooks should be called once, with app
expect(mockExtension.init).toHaveBeenCalledTimes(1); expect(mockExtension.init).toHaveBeenCalledTimes(1);
expect(mockExtension.init).toHaveBeenCalledWith(app); expect(mockExtension.init).toHaveBeenCalledWith(app);
// Adding custom node defs should be passed the full list of nodes // Adding custom node defs should be passed the full list of nodes
expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1); expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1);
expect(mockExtension.addCustomNodeDefs.mock.calls[0][1]).toStrictEqual(app); expect(mockExtension.addCustomNodeDefs.mock.calls[0][1]).toStrictEqual(app);
const defs = mockExtension.addCustomNodeDefs.mock.calls[0][0]; const defs = mockExtension.addCustomNodeDefs.mock.calls[0][0];
expect(defs).toHaveProperty("KSampler"); expect(defs).toHaveProperty("KSampler");
expect(defs).toHaveProperty("LoadImage"); expect(defs).toHaveProperty("LoadImage");
// Get custom widgets is called once and should return new widget types // Get custom widgets is called once and should return new widget types
expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1); expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1);
expect(mockExtension.getCustomWidgets).toHaveBeenCalledWith(app); expect(mockExtension.getCustomWidgets).toHaveBeenCalledWith(app);
// Before register node def will be called once per node type // Before register node def will be called once per node type
const nodeNames = Object.keys(defs); const nodeNames = Object.keys(defs);
const nodeCount = nodeNames.length; const nodeCount = nodeNames.length;
expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount); expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
// It should be send the JS class and the original JSON definition // It should be send the JS class and the original JSON definition
const nodeClass = mockExtension.beforeRegisterNodeDef.mock.calls[i][0]; const nodeClass = mockExtension.beforeRegisterNodeDef.mock.calls[i][0];
const nodeDef = mockExtension.beforeRegisterNodeDef.mock.calls[i][1]; const nodeDef = mockExtension.beforeRegisterNodeDef.mock.calls[i][1];
expect(nodeClass.name).toBe("ComfyNode"); expect(nodeClass.name).toBe("ComfyNode");
expect(nodeClass.comfyClass).toBe(nodeNames[i]); expect(nodeClass.comfyClass).toBe(nodeNames[i]);
expect(nodeDef.name).toBe(nodeNames[i]); expect(nodeDef.name).toBe(nodeNames[i]);
expect(nodeDef).toHaveProperty("input"); expect(nodeDef).toHaveProperty("input");
expect(nodeDef).toHaveProperty("output"); expect(nodeDef).toHaveProperty("output");
} }
// Register custom nodes is called once after registerNode defs to allow adding other frontend nodes // Register custom nodes is called once after registerNode defs to allow adding other frontend nodes
expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1); expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1);
// Before configure graph will be called here as the default graph is being loaded // Before configure graph will be called here as the default graph is being loaded
expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(1); expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(1);
// it gets sent the graph data that is going to be loaded // it gets sent the graph data that is going to be loaded
const graphData = mockExtension.beforeConfigureGraph.mock.calls[0][0]; const graphData = mockExtension.beforeConfigureGraph.mock.calls[0][0];
// A node created is fired for each node constructor that is called // A node created is fired for each node constructor that is called
expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length); expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length);
for (let i = 0; i < graphData.nodes.length; i++) { for (let i = 0; i < graphData.nodes.length; i++) {
expect(mockExtension.nodeCreated.mock.calls[i][0].type).toBe(graphData.nodes[i].type); expect(mockExtension.nodeCreated.mock.calls[i][0].type).toBe(graphData.nodes[i].type);
} }
// Each node then calls loadedGraphNode to allow them to be updated // Each node then calls loadedGraphNode to allow them to be updated
expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length); expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length);
for (let i = 0; i < graphData.nodes.length; i++) { for (let i = 0; i < graphData.nodes.length; i++) {
expect(mockExtension.loadedGraphNode.mock.calls[i][0].type).toBe(graphData.nodes[i].type); expect(mockExtension.loadedGraphNode.mock.calls[i][0].type).toBe(graphData.nodes[i].type);
} }
// After configure is then called once all the setup is done // After configure is then called once all the setup is done
expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(1); expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(1);
expect(mockExtension.setup).toHaveBeenCalledTimes(1); expect(mockExtension.setup).toHaveBeenCalledTimes(1);
expect(mockExtension.setup).toHaveBeenCalledWith(app); expect(mockExtension.setup).toHaveBeenCalledWith(app);
// Ensure hooks are called in the correct order // Ensure hooks are called in the correct order
const callOrder = [ const callOrder = [
"init", "init",
"addCustomNodeDefs", "addCustomNodeDefs",
"getCustomWidgets", "getCustomWidgets",
"beforeRegisterNodeDef", "beforeRegisterNodeDef",
"registerCustomNodes", "registerCustomNodes",
"beforeConfigureGraph", "beforeConfigureGraph",
"nodeCreated", "nodeCreated",
"loadedGraphNode", "loadedGraphNode",
"afterConfigureGraph", "afterConfigureGraph",
"setup", "setup",
]; ];
for (let i = 1; i < callOrder.length; i++) { for (let i = 1; i < callOrder.length; i++) {
const fn1 = mockExtension[callOrder[i - 1]]; const fn1 = mockExtension[callOrder[i - 1]];
const fn2 = mockExtension[callOrder[i]]; const fn2 = mockExtension[callOrder[i]];
expect(fn1.mock.invocationCallOrder[0]).toBeLessThan(fn2.mock.invocationCallOrder[0]); expect(fn1.mock.invocationCallOrder[0]).toBeLessThan(fn2.mock.invocationCallOrder[0]);
} }
graph.clear(); graph.clear();
// Ensure adding a new node calls the correct callback // Ensure adding a new node calls the correct callback
ez.LoadImage(); ez.LoadImage();
expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length); expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length);
expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 1); expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 1);
expect(mockExtension.nodeCreated.mock.lastCall[0].type).toBe("LoadImage"); expect(mockExtension.nodeCreated.mock.lastCall[0].type).toBe("LoadImage");
// Reload the graph to ensure correct hooks are fired // Reload the graph to ensure correct hooks are fired
await graph.reload(); await graph.reload();
// These hooks should not be fired again // These hooks should not be fired again
expect(mockExtension.init).toHaveBeenCalledTimes(1); expect(mockExtension.init).toHaveBeenCalledTimes(1);
expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1); expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1);
expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1); expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1);
expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1); expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1);
expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount); expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount);
expect(mockExtension.setup).toHaveBeenCalledTimes(1); expect(mockExtension.setup).toHaveBeenCalledTimes(1);
// These should be called again // These should be called again
expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(2); expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(2);
expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 2); expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 2);
expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length + 1); expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length + 1);
expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(2); expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(2);
}, 15000); }, 15000);
it("allows custom nodeDefs and widgets to be registered", async () => { it("allows custom nodeDefs and widgets to be registered", async () => {
const widgetMock = jest.fn((node, inputName, inputData, app) => { const widgetMock = jest.fn((node, inputName, inputData, app) => {
expect(node.constructor.comfyClass).toBe("TestNode"); expect(node.constructor.comfyClass).toBe("TestNode");
expect(inputName).toBe("test_input"); expect(inputName).toBe("test_input");
expect(inputData[0]).toBe("CUSTOMWIDGET"); expect(inputData[0]).toBe("CUSTOMWIDGET");
expect(inputData[1]?.hello).toBe("world"); expect(inputData[1]?.hello).toBe("world");
expect(app).toStrictEqual(app); expect(app).toStrictEqual(app);
return { return {
widget: node.addWidget("button", inputName, "hello", () => {}), widget: node.addWidget("button", inputName, "hello", () => {}),
}; };
}); });
// Register our extension that adds a custom node + widget type // Register our extension that adds a custom node + widget type
const mockExtension = { const mockExtension = {
name: "TestExtension", name: "TestExtension",
addCustomNodeDefs: (nodeDefs) => { addCustomNodeDefs: (nodeDefs) => {
nodeDefs["TestNode"] = { nodeDefs["TestNode"] = {
output: [], output: [],
output_name: [], output_name: [],
output_is_list: [], output_is_list: [],
name: "TestNode", name: "TestNode",
display_name: "TestNode", display_name: "TestNode",
category: "Test", category: "Test",
input: { input: {
required: { required: {
test_input: ["CUSTOMWIDGET", { hello: "world" }], test_input: ["CUSTOMWIDGET", { hello: "world" }],
}, },
}, },
}; };
}, },
getCustomWidgets: jest.fn(() => { getCustomWidgets: jest.fn(() => {
return { return {
CUSTOMWIDGET: widgetMock, CUSTOMWIDGET: widgetMock,
}; };
}), }),
}; };
const { graph, ez } = await start({ const { graph, ez } = await start({
async preSetup(app) { async preSetup(app) {
app.registerExtension(mockExtension); app.registerExtension(mockExtension);
}, },
}); });
expect(mockExtension.getCustomWidgets).toBeCalledTimes(1); expect(mockExtension.getCustomWidgets).toBeCalledTimes(1);
graph.clear(); graph.clear();
expect(widgetMock).toBeCalledTimes(0); expect(widgetMock).toBeCalledTimes(0);
const node = ez.TestNode(); const node = ez.TestNode();
expect(widgetMock).toBeCalledTimes(1); expect(widgetMock).toBeCalledTimes(1);
// Ensure our custom widget is created // Ensure our custom widget is created
expect(node.inputs.length).toBe(0); expect(node.inputs.length).toBe(0);
expect(node.widgets.length).toBe(1); expect(node.widgets.length).toBe(1);
const w = node.widgets[0].widget; const w = node.widgets[0].widget;
expect(w.name).toBe("test_input"); expect(w.name).toBe("test_input");
expect(w.type).toBe("button"); expect(w.type).toBe("button");
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -2,291 +2,291 @@ import { start } from "../utils";
import lg from "../utils/litegraph"; import lg from "../utils/litegraph";
describe("users", () => { describe("users", () => {
beforeEach(() => { beforeEach(() => {
lg.setup(global); lg.setup(global);
}); });
afterEach(() => { afterEach(() => {
lg.teardown(global); lg.teardown(global);
}); });
function expectNoUserScreen() { function expectNoUserScreen() {
// Ensure login isnt visible // Ensure login isnt visible
const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; const selection = document.querySelectorAll("#comfy-user-selection")?.[0];
expect(selection["style"].display).toBe("none"); expect(selection["style"].display).toBe("none");
const menu = document.querySelectorAll(".comfy-menu")?.[0]; const menu = document.querySelectorAll(".comfy-menu")?.[0];
expect(window.getComputedStyle(menu)?.display).not.toBe("none"); expect(window.getComputedStyle(menu)?.display).not.toBe("none");
} }
describe("multi-user", () => { describe("multi-user", () => {
async function mockAddStylesheet() { async function mockAddStylesheet() {
const utils = await import("../../src/scripts/utils"); const utils = await import("../../src/scripts/utils");
utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve()); utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve());
} }
async function waitForUserScreenShow() { async function waitForUserScreenShow() {
// Wait for "show" to be called // Wait for "show" to be called
const { UserSelectionScreen } = await import("../../src/scripts/ui/userSelection"); const { UserSelectionScreen } = await import("../../src/scripts/ui/userSelection");
let resolve, reject; let resolve, reject;
const fn = UserSelectionScreen.prototype.show; const fn = UserSelectionScreen.prototype.show;
const p = new Promise((res, rej) => { const p = new Promise((res, rej) => {
resolve = res; resolve = res;
reject = rej; reject = rej;
}); });
jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => { jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => {
const res = fn(...args); const res = fn(...args);
await new Promise(process.nextTick); // wait for promises to resolve await new Promise(process.nextTick); // wait for promises to resolve
resolve(); resolve();
return res; return res;
}); });
// @ts-ignore // @ts-ignore
setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500); setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500);
await p; await p;
await new Promise(process.nextTick); // wait for promises to resolve await new Promise(process.nextTick); // wait for promises to resolve
} }
async function testUserScreen(onShown, users?) { async function testUserScreen(onShown, users?) {
if (!users) { if (!users) {
users = {}; users = {};
} }
const starting = start({ const starting = start({
resetEnv: true, resetEnv: true,
userConfig: { storage: "server", users }, userConfig: { storage: "server", users },
preSetup: mockAddStylesheet, preSetup: mockAddStylesheet,
}); });
// Ensure no current user // Ensure no current user
expect(localStorage["Comfy.userId"]).toBeFalsy(); expect(localStorage["Comfy.userId"]).toBeFalsy();
expect(localStorage["Comfy.userName"]).toBeFalsy(); expect(localStorage["Comfy.userName"]).toBeFalsy();
await waitForUserScreenShow(); await waitForUserScreenShow();
const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; const selection = document.querySelectorAll("#comfy-user-selection")?.[0];
expect(selection).toBeTruthy(); expect(selection).toBeTruthy();
// Ensure login is visible // Ensure login is visible
expect(window.getComputedStyle(selection)?.display).not.toBe("none"); expect(window.getComputedStyle(selection)?.display).not.toBe("none");
// Ensure menu is hidden // Ensure menu is hidden
const menu = document.querySelectorAll(".comfy-menu")?.[0]; const menu = document.querySelectorAll(".comfy-menu")?.[0];
expect(window.getComputedStyle(menu)?.display).toBe("none"); expect(window.getComputedStyle(menu)?.display).toBe("none");
const isCreate = await onShown(selection); const isCreate = await onShown(selection);
// Submit form // Submit form
selection.querySelectorAll("form")[0].submit(); selection.querySelectorAll("form")[0].submit();
await new Promise(process.nextTick); // wait for promises to resolve await new Promise(process.nextTick); // wait for promises to resolve
// Wait for start // Wait for start
const s = await starting; const s = await starting;
// Ensure login is removed // Ensure login is removed
expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0); expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0);
expect(window.getComputedStyle(menu)?.display).not.toBe("none"); expect(window.getComputedStyle(menu)?.display).not.toBe("none");
// Ensure settings + templates are saved // Ensure settings + templates are saved
const { api } = await import("../../src/scripts/api"); const { api } = await import("../../src/scripts/api");
expect(api.createUser).toHaveBeenCalledTimes(+isCreate); expect(api.createUser).toHaveBeenCalledTimes(+isCreate);
expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate); expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate);
expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate); expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate);
if (isCreate) { if (isCreate) {
expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false });
expect(s.app.isNewUserSession).toBeTruthy(); expect(s.app.isNewUserSession).toBeTruthy();
} else { } else {
expect(s.app.isNewUserSession).toBeFalsy(); expect(s.app.isNewUserSession).toBeFalsy();
} }
return { users, selection, ...s }; return { users, selection, ...s };
} }
it("allows user creation if no users", async () => { it("allows user creation if no users", async () => {
const { users } = await testUserScreen((selection) => { const { users } = await testUserScreen((selection) => {
// Ensure we have no users flag added // Ensure we have no users flag added
expect(selection.classList.contains("no-users")).toBeTruthy(); expect(selection.classList.contains("no-users")).toBeTruthy();
// Enter a username // Enter a username
const input = selection.getElementsByTagName("input")[0]; const input = selection.getElementsByTagName("input")[0];
input.focus(); input.focus();
input.value = "Test User"; input.value = "Test User";
return true; return true;
}); });
expect(users).toStrictEqual({ expect(users).toStrictEqual({
"Test User!": "Test User", "Test User!": "Test User",
}); });
expect(localStorage["Comfy.userId"]).toBe("Test User!"); expect(localStorage["Comfy.userId"]).toBe("Test User!");
expect(localStorage["Comfy.userName"]).toBe("Test User"); expect(localStorage["Comfy.userName"]).toBe("Test User");
}); });
it("allows user creation if no current user but other users", async () => { it("allows user creation if no current user but other users", async () => {
const users = { const users = {
"Test User 2!": "Test User 2", "Test User 2!": "Test User 2",
}; };
await testUserScreen((selection) => { await testUserScreen((selection) => {
expect(selection.classList.contains("no-users")).toBeFalsy(); expect(selection.classList.contains("no-users")).toBeFalsy();
// Enter a username // Enter a username
const input = selection.getElementsByTagName("input")[0]; const input = selection.getElementsByTagName("input")[0];
input.focus(); input.focus();
input.value = "Test User 3"; input.value = "Test User 3";
return true; return true;
}, users); }, users);
expect(users).toStrictEqual({ expect(users).toStrictEqual({
"Test User 2!": "Test User 2", "Test User 2!": "Test User 2",
"Test User 3!": "Test User 3", "Test User 3!": "Test User 3",
}); });
expect(localStorage["Comfy.userId"]).toBe("Test User 3!"); expect(localStorage["Comfy.userId"]).toBe("Test User 3!");
expect(localStorage["Comfy.userName"]).toBe("Test User 3"); expect(localStorage["Comfy.userName"]).toBe("Test User 3");
}); });
it("allows user selection if no current user but other users", async () => { it("allows user selection if no current user but other users", async () => {
const users = { const users = {
"A!": "A", "A!": "A",
"B!": "B", "B!": "B",
"C!": "C", "C!": "C",
}; };
await testUserScreen((selection) => { await testUserScreen((selection) => {
expect(selection.classList.contains("no-users")).toBeFalsy(); expect(selection.classList.contains("no-users")).toBeFalsy();
// Check user list // Check user list
const select = selection.getElementsByTagName("select")[0]; const select = selection.getElementsByTagName("select")[0];
const options = select.getElementsByTagName("option"); const options = select.getElementsByTagName("option");
expect( expect(
[...options] [...options]
.filter((o) => !o.disabled) .filter((o) => !o.disabled)
.reduce((p, n) => { .reduce((p, n) => {
p[n.getAttribute("value")] = n.textContent; p[n.getAttribute("value")] = n.textContent;
return p; return p;
}, {}) }, {})
).toStrictEqual(users); ).toStrictEqual(users);
// Select an option // Select an option
select.focus(); select.focus();
select.value = options[2].value; select.value = options[2].value;
return false; return false;
}, users); }, users);
expect(users).toStrictEqual(users); expect(users).toStrictEqual(users);
expect(localStorage["Comfy.userId"]).toBe("B!"); expect(localStorage["Comfy.userId"]).toBe("B!");
expect(localStorage["Comfy.userName"]).toBe("B"); expect(localStorage["Comfy.userName"]).toBe("B");
}); });
it("doesnt show user screen if current user", async () => { it("doesnt show user screen if current user", async () => {
const starting = start({ const starting = start({
resetEnv: true, resetEnv: true,
userConfig: { userConfig: {
storage: "server", storage: "server",
users: { users: {
"User!": "User", "User!": "User",
}, },
}, },
localStorage: { localStorage: {
"Comfy.userId": "User!", "Comfy.userId": "User!",
"Comfy.userName": "User", "Comfy.userName": "User",
}, },
}); });
await new Promise(process.nextTick); // wait for promises to resolve await new Promise(process.nextTick); // wait for promises to resolve
expectNoUserScreen(); expectNoUserScreen();
await starting; await starting;
}); });
it("allows user switching", async () => { it("allows user switching", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { userConfig: {
storage: "server", storage: "server",
users: { users: {
"User!": "User", "User!": "User",
}, },
}, },
localStorage: { localStorage: {
"Comfy.userId": "User!", "Comfy.userId": "User!",
"Comfy.userName": "User", "Comfy.userName": "User",
}, },
}); });
// cant actually test switching user easily but can check the setting is present // cant actually test switching user easily but can check the setting is present
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy(); expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy();
}); });
}); });
describe("single-user", () => { describe("single-user", () => {
it("doesnt show user creation if no default user", async () => { it("doesnt show user creation if no default user", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { migrated: false, storage: "server" }, userConfig: { migrated: false, storage: "server" },
}); });
expectNoUserScreen(); expectNoUserScreen();
// It should store the settings // It should store the settings
const { api } = await import("../../src/scripts/api"); const { api } = await import("../../src/scripts/api");
expect(api.storeSettings).toHaveBeenCalledTimes(1); expect(api.storeSettings).toHaveBeenCalledTimes(1);
expect(api.storeUserData).toHaveBeenCalledTimes(1); expect(api.storeUserData).toHaveBeenCalledTimes(1);
expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false });
expect(app.isNewUserSession).toBeTruthy(); expect(app.isNewUserSession).toBeTruthy();
}); });
it("doesnt show user creation if default user", async () => { it("doesnt show user creation if default user", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { migrated: true, storage: "server" }, userConfig: { migrated: true, storage: "server" },
}); });
expectNoUserScreen(); expectNoUserScreen();
// It should store the settings // It should store the settings
const { api } = await import("../../src/scripts/api"); const { api } = await import("../../src/scripts/api");
expect(api.storeSettings).toHaveBeenCalledTimes(0); expect(api.storeSettings).toHaveBeenCalledTimes(0);
expect(api.storeUserData).toHaveBeenCalledTimes(0); expect(api.storeUserData).toHaveBeenCalledTimes(0);
expect(app.isNewUserSession).toBeFalsy(); expect(app.isNewUserSession).toBeFalsy();
}); });
it("doesnt allow user switching", async () => { it("doesnt allow user switching", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { migrated: true, storage: "server" }, userConfig: { migrated: true, storage: "server" },
}); });
expectNoUserScreen(); expectNoUserScreen();
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy();
}); });
}); });
describe("browser-user", () => { describe("browser-user", () => {
it("doesnt show user creation if no default user", async () => { it("doesnt show user creation if no default user", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { migrated: false, storage: "browser" }, userConfig: { migrated: false, storage: "browser" },
}); });
expectNoUserScreen(); expectNoUserScreen();
// It should store the settings // It should store the settings
const { api } = await import("../../src/scripts/api"); const { api } = await import("../../src/scripts/api");
expect(api.storeSettings).toHaveBeenCalledTimes(0); expect(api.storeSettings).toHaveBeenCalledTimes(0);
expect(api.storeUserData).toHaveBeenCalledTimes(0); expect(api.storeUserData).toHaveBeenCalledTimes(0);
expect(app.isNewUserSession).toBeFalsy(); expect(app.isNewUserSession).toBeFalsy();
}); });
it("doesnt show user creation if default user", async () => { it("doesnt show user creation if default user", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { migrated: true, storage: "server" }, userConfig: { migrated: true, storage: "server" },
}); });
expectNoUserScreen(); expectNoUserScreen();
// It should store the settings // It should store the settings
const { api } = await import("../../src/scripts/api"); const { api } = await import("../../src/scripts/api");
expect(api.storeSettings).toHaveBeenCalledTimes(0); expect(api.storeSettings).toHaveBeenCalledTimes(0);
expect(api.storeUserData).toHaveBeenCalledTimes(0); expect(api.storeUserData).toHaveBeenCalledTimes(0);
expect(app.isNewUserSession).toBeFalsy(); expect(app.isNewUserSession).toBeFalsy();
}); });
it("doesnt allow user switching", async () => { it("doesnt allow user switching", async () => {
const { app } = await start({ const { app } = await start({
resetEnv: true, resetEnv: true,
userConfig: { migrated: true, storage: "browser" }, userConfig: { migrated: true, storage: "browser" },
}); });
expectNoUserScreen(); expectNoUserScreen();
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy();
}); });
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,45 +7,45 @@ import path from "path";
const html = fs.readFileSync(path.resolve(__dirname, "../../index.html")) const html = fs.readFileSync(path.resolve(__dirname, "../../index.html"))
interface StartConfig extends APIConfig { interface StartConfig extends APIConfig {
resetEnv?: boolean; resetEnv?: boolean;
preSetup?(app): Promise<void>; preSetup?(app): Promise<void>;
localStorage?: Record<string, string>; localStorage?: Record<string, string>;
} }
interface StartResult { interface StartResult {
app: any; app: any;
graph: EzGraph; graph: EzGraph;
ez: EzNameSpace; ez: EzNameSpace;
} }
/** /**
* *
* @param { Parameters<typeof mockApi>[0] & { * @param { Parameters<typeof mockApi>[0] & {
* resetEnv?: boolean, * resetEnv?: boolean,
* preSetup?(app): Promise<void>, * preSetup?(app): Promise<void>,
* localStorage?: Record<string, string> * localStorage?: Record<string, string>
* } } config * } } config
* @returns * @returns
*/ */
export async function start(config: StartConfig = {}): Promise<StartResult> { export async function start(config: StartConfig = {}): Promise<StartResult> {
if(config.resetEnv) { if(config.resetEnv) {
jest.resetModules(); jest.resetModules();
jest.resetAllMocks(); jest.resetAllMocks();
lg.setup(global); lg.setup(global);
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
} }
Object.assign(localStorage, config.localStorage ?? {}); Object.assign(localStorage, config.localStorage ?? {});
document.body.innerHTML = html.toString(); document.body.innerHTML = html.toString();
mockApi(config); mockApi(config);
const { app } = await import("../../src/scripts/app"); const { app } = await import("../../src/scripts/app");
config.preSetup?.(app); config.preSetup?.(app);
await app.setup(); await app.setup();
// @ts-ignore // @ts-ignore
return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app }; return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app };
} }
/** /**
@@ -53,9 +53,9 @@ export async function start(config: StartConfig = {}): Promise<StartResult> {
* @param { (hasReloaded: boolean) => (Promise<void> | void) } cb * @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
*/ */
export async function checkBeforeAndAfterReload(graph, cb) { export async function checkBeforeAndAfterReload(graph, cb) {
await cb(false); await cb(false);
await graph.reload(); await graph.reload();
await cb(true); await cb(true);
} }
/** /**
@@ -65,35 +65,35 @@ export async function checkBeforeAndAfterReload(graph, cb) {
* @returns { Record<string, import("./src/types/comfy").ComfyObjectInfo> } * @returns { Record<string, import("./src/types/comfy").ComfyObjectInfo> }
*/ */
export function makeNodeDef(name, input, output = {}) { export function makeNodeDef(name, input, output = {}) {
const nodeDef = { const nodeDef = {
name, name,
category: "test", category: "test",
output: [], output: [],
output_name: [], output_name: [],
output_is_list: [], output_is_list: [],
input: { input: {
required: {}, required: {},
}, },
}; };
for (const k in input) { for (const k in input) {
nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]]; nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]];
} }
if (output instanceof Array) { if (output instanceof Array) {
output = output.reduce((p, c) => { output = output.reduce((p, c) => {
p[c] = c; p[c] = c;
return p; return p;
}, {}); }, {});
} }
for (const k in output) { for (const k in output) {
// @ts-ignore // @ts-ignore
nodeDef.output.push(output[k]); nodeDef.output.push(output[k]);
// @ts-ignore // @ts-ignore
nodeDef.output_name.push(k); nodeDef.output_name.push(k);
// @ts-ignore // @ts-ignore
nodeDef.output_is_list.push(false); 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<T, null | undefined> } * @returns { x is Exclude<T, null | undefined> }
*/ */
export function assertNotNullOrUndefined(x) { export function assertNotNullOrUndefined(x) {
expect(x).not.toEqual(null); expect(x).not.toEqual(null);
expect(x).not.toEqual(undefined); expect(x).not.toEqual(undefined);
return true; return true;
} }
/** /**
@@ -114,32 +114,32 @@ export function assertNotNullOrUndefined(x) {
* @param { ReturnType<Ez["graph"]>["graph"] } graph * @param { ReturnType<Ez["graph"]>["graph"] } graph
*/ */
export function createDefaultWorkflow(ez, graph) { export function createDefaultWorkflow(ez, graph) {
graph.clear(); graph.clear();
const ckpt = ez.CheckpointLoaderSimple(); const ckpt = ez.CheckpointLoaderSimple();
const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" }); const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" });
const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" }); const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" });
const empty = ez.EmptyLatentImage(); const empty = ez.EmptyLatentImage();
const sampler = ez.KSampler( const sampler = ez.KSampler(
ckpt.outputs.MODEL, ckpt.outputs.MODEL,
pos.outputs.CONDITIONING, pos.outputs.CONDITIONING,
neg.outputs.CONDITIONING, neg.outputs.CONDITIONING,
empty.outputs.LATENT empty.outputs.LATENT
); );
const decode = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE); const decode = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE);
const save = ez.SaveImage(decode.outputs.IMAGE); const save = ez.SaveImage(decode.outputs.IMAGE);
graph.arrange(); graph.arrange();
return { ckpt, pos, neg, empty, sampler, decode, save }; return { ckpt, pos, neg, empty, sampler, decode, save };
} }
export async function getNodeDefs() { export async function getNodeDefs() {
const { api } = await import("../../src/scripts/api"); const { api } = await import("../../src/scripts/api");
return api.getNodeDefs(); return api.getNodeDefs();
} }
export async function getNodeDef(nodeId) { export async function getNodeDef(nodeId) {
return (await getNodeDefs())[nodeId]; return (await getNodeDefs())[nodeId];
} }

View File

@@ -3,37 +3,37 @@ import path from "path";
import { nop } from "../utils/nopProxy"; import { nop } from "../utils/nopProxy";
function forEachKey(cb) { function forEachKey(cb) {
for (const k of [ for (const k of [
"LiteGraph", "LiteGraph",
"LGraph", "LGraph",
"LLink", "LLink",
"LGraphNode", "LGraphNode",
"LGraphGroup", "LGraphGroup",
"DragAndScale", "DragAndScale",
"LGraphCanvas", "LGraphCanvas",
"ContextMenu", "ContextMenu",
]) { ]) {
cb(k); cb(k);
} }
} }
export default { export default {
setup(ctx) { setup(ctx) {
const lg = fs.readFileSync(path.resolve("./src/lib/litegraph.core.js"), "utf-8"); const lg = fs.readFileSync(path.resolve("./src/lib/litegraph.core.js"), "utf-8");
const globalTemp = {}; const globalTemp = {};
(function (console) { (function (console) {
eval(lg); eval(lg);
}).call(globalTemp, nop); }).call(globalTemp, nop);
forEachKey((k) => (ctx[k] = globalTemp[k])); forEachKey((k) => (ctx[k] = globalTemp[k]));
const lg_ext = fs.readFileSync(path.resolve("./src/lib/litegraph.extensions.js"), "utf-8"); const lg_ext = fs.readFileSync(path.resolve("./src/lib/litegraph.extensions.js"), "utf-8");
eval(lg_ext); eval(lg_ext);
}, },
teardown(ctx) { teardown(ctx) {
forEachKey((k) => delete ctx[k]); forEachKey((k) => delete ctx[k]);
// Clear document after each run // Clear document after each run
document.getElementsByTagName("html")[0].innerHTML = ""; document.getElementsByTagName("html")[0].innerHTML = "";
} }
}; };

View File

@@ -1,6 +1,6 @@
export const nop = new Proxy(function () {}, { export const nop = new Proxy(function () {}, {
get: () => nop, get: () => nop,
set: () => true, set: () => true,
apply: () => nop, apply: () => nop,
construct: () => nop, construct: () => nop,
}); });

View File

@@ -3,22 +3,22 @@ import "../../src/scripts/api";
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
function* walkSync(dir: string): Generator<string> { function* walkSync(dir: string): Generator<string> {
const files = fs.readdirSync(dir, { withFileTypes: true }); const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) { for (const file of files) {
if (file.isDirectory()) { if (file.isDirectory()) {
yield* walkSync(path.join(dir, file.name)); yield* walkSync(path.join(dir, file.name));
} else { } else {
yield path.join(dir, file.name); yield path.join(dir, file.name);
} }
} }
} }
export interface APIConfig { export interface APIConfig {
mockExtensions?: string[]; mockExtensions?: string[];
mockNodeDefs?: Record<string, any>; mockNodeDefs?: Record<string, any>;
settings?: Record<string, string>; settings?: Record<string, string>;
userConfig?: { storage: "server" | "browser"; users?: Record<string, any>; migrated?: boolean }; userConfig?: { storage: "server" | "browser"; users?: Record<string, any>; migrated?: boolean };
userData?: Record<string, any>; userData?: Record<string, any>;
} }
/** /**
@@ -27,66 +27,66 @@ export interface APIConfig {
/** /**
* @param {{ * @param {{
* mockExtensions?: string[], * mockExtensions?: string[],
* mockNodeDefs?: Record<string, ComfyObjectInfo>, * mockNodeDefs?: Record<string, ComfyObjectInfo>,
* settings?: Record<string, string> * settings?: Record<string, string>
* userConfig?: {storage: "server" | "browser", users?: Record<string, any>, migrated?: boolean }, * userConfig?: {storage: "server" | "browser", users?: Record<string, any>, migrated?: boolean },
* userData?: Record<string, any> * userData?: Record<string, any>
* }} config * }} config
*/ */
export function mockApi(config: APIConfig = {}) { export function mockApi(config: APIConfig = {}) {
let { mockExtensions, mockNodeDefs, userConfig, settings, userData } = { let { mockExtensions, mockNodeDefs, userConfig, settings, userData } = {
settings: {}, settings: {},
userData: {}, userData: {},
...config, ...config,
}; };
if (!mockExtensions) { if (!mockExtensions) {
mockExtensions = Array.from(walkSync(path.resolve("./src/extensions/core"))) mockExtensions = Array.from(walkSync(path.resolve("./src/extensions/core")))
.filter((x) => x.endsWith(".js")) .filter((x) => x.endsWith(".js"))
.map((x) => path.relative(path.resolve("./src/"), x).replace(/\\/g, "/")); .map((x) => path.relative(path.resolve("./src/"), x).replace(/\\/g, "/"));
} }
if (!mockNodeDefs) { if (!mockNodeDefs) {
mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./tests-ui/data/object_info.json"))); mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./tests-ui/data/object_info.json")));
} }
const events = new EventTarget(); const events = new EventTarget();
const mockApi = { const mockApi = {
addEventListener: events.addEventListener.bind(events), addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events), dispatchEvent: events.dispatchEvent.bind(events),
getSystemStats: jest.fn(), getSystemStats: jest.fn(),
getExtensions: jest.fn(() => mockExtensions), getExtensions: jest.fn(() => mockExtensions),
getNodeDefs: jest.fn(() => mockNodeDefs), getNodeDefs: jest.fn(() => mockNodeDefs),
init: jest.fn(), init: jest.fn(),
apiURL: jest.fn((x) => "src/" + x), apiURL: jest.fn((x) => "src/" + x),
fileURL: jest.fn((x) => "src/" + x), fileURL: jest.fn((x) => "src/" + x),
createUser: jest.fn((username) => { createUser: jest.fn((username) => {
// @ts-ignore // @ts-ignore
if(username in userConfig.users) { if(username in userConfig.users) {
return { status: 400, json: () => "Duplicate" } return { status: 400, json: () => "Duplicate" }
} }
// @ts-ignore // @ts-ignore
userConfig.users[username + "!"] = username; userConfig.users[username + "!"] = username;
return { status: 200, json: () => username + "!" } return { status: 200, json: () => username + "!" }
}), }),
getUserConfig: jest.fn(() => userConfig ?? { storage: "browser", migrated: false }), getUserConfig: jest.fn(() => userConfig ?? { storage: "browser", migrated: false }),
getSettings: jest.fn(() => settings), getSettings: jest.fn(() => settings),
storeSettings: jest.fn((v) => Object.assign(settings, v)), storeSettings: jest.fn((v) => Object.assign(settings, v)),
getUserData: jest.fn((f) => { getUserData: jest.fn((f) => {
if (f in userData) { if (f in userData) {
return { status: 200, json: () => userData[f] }; return { status: 200, json: () => userData[f] };
} else { } else {
return { status: 404 }; return { status: 404 };
} }
}), }),
storeUserData: jest.fn((file, data) => { storeUserData: jest.fn((file, data) => {
userData[file] = data; userData[file] = data;
}), }),
listUserData: jest.fn(() => []), listUserData: jest.fn(() => []),
}; };
jest.mock("../../src/scripts/api", () => ({ jest.mock("../../src/scripts/api", () => ({
get api() { get api() {
return mockApi; return mockApi;
}, },
})); }));
} }

View File

@@ -7,115 +7,115 @@ dotenv.config();
const IS_DEV = process.env.NODE_ENV === 'development'; const IS_DEV = process.env.NODE_ENV === 'development';
interface ShimResult { interface ShimResult {
code: string; code: string;
exports: string[]; exports: string[];
} }
function comfyAPIPlugin(): Plugin { function comfyAPIPlugin(): Plugin {
return { return {
name: 'comfy-api-plugin', name: 'comfy-api-plugin',
transform(code: string, id: string) { transform(code: string, id: string) {
if (IS_DEV) if (IS_DEV)
return null; return null;
// TODO: Remove second condition after all js files are converted to ts // TODO: Remove second condition after all js files are converted to ts
if (id.endsWith('.ts') || (id.endsWith('.js') && id.includes("extensions/core"))) { if (id.endsWith('.ts') || (id.endsWith('.js') && id.includes("extensions/core"))) {
const result = transformExports(code, id); const result = transformExports(code, id);
if (result.exports.length > 0) { if (result.exports.length > 0) {
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const relativePath = path.relative(path.join(projectRoot, 'src'), id); const relativePath = path.relative(path.join(projectRoot, 'src'), id);
const shimFileName = relativePath.replace(/\.ts$/, '.js'); const shimFileName = relativePath.replace(/\.ts$/, '.js');
const shimComment = `// Shim for ${relativePath}\n`; const shimComment = `// Shim for ${relativePath}\n`;
this.emitFile({ this.emitFile({
type: "asset", type: "asset",
fileName: shimFileName, fileName: shimFileName,
source: shimComment + result.exports.join(""), source: shimComment + result.exports.join(""),
}); });
} }
return { return {
code: result.code, code: result.code,
map: null // If you're not modifying the source map, return null map: null // If you're not modifying the source map, return null
}; };
} }
} }
}; };
} }
function transformExports(code: string, id: string): ShimResult { function transformExports(code: string, id: string): ShimResult {
const moduleName = getModuleName(id); const moduleName = getModuleName(id);
const exports: string[] = []; const exports: string[] = [];
let newCode = code; let newCode = code;
// Regex to match different types of exports // 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; const regex = /export\s+(const|let|var|function|class|async function)\s+([a-zA-Z$_][a-zA-Z\d$_]*)(\s|\()/g;
let match; let match;
while ((match = regex.exec(code)) !== null) { while ((match = regex.exec(code)) !== null) {
const name = match[2]; const name = match[2];
// All exports should be bind to the window object as new API endpoint. // All exports should be bind to the window object as new API endpoint.
if (exports.length == 0) { if (exports.length == 0) {
newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`; newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`;
newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`; newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`;
} }
newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`; newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`;
exports.push(`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`); exports.push(`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`);
} }
return { return {
code: newCode, code: newCode,
exports, exports,
}; };
} }
function getModuleName(id: string): string { function getModuleName(id: string): string {
// Simple example to derive a module name from the file path // Simple example to derive a module name from the file path
const parts = id.split('/'); const parts = id.split('/');
const fileName = parts[parts.length - 1]; const fileName = parts[parts.length - 1];
return fileName.replace(/\.\w+$/, ''); // Remove file extension return fileName.replace(/\.\w+$/, ''); // Remove file extension
} }
export default defineConfig({ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: process.env.DEV_SERVER_COMFYUI_URL || 'http://127.0.0.1:8188', target: process.env.DEV_SERVER_COMFYUI_URL || 'http://127.0.0.1:8188',
// Return empty array for extensions API as these modules // Return empty array for extensions API as these modules
// are not on vite's dev server. // are not on vite's dev server.
bypass: (req, res, options) => { bypass: (req, res, options) => {
if (req.url === '/api/extensions') { if (req.url === '/api/extensions') {
res.end(JSON.stringify([])); res.end(JSON.stringify([]));
} }
return null; return null;
}, },
}, },
'/ws': { '/ws': {
target: 'ws://127.0.0.1:8188', target: 'ws://127.0.0.1:8188',
ws: true, ws: true,
}, },
} }
}, },
plugins: [ plugins: [
comfyAPIPlugin(), comfyAPIPlugin(),
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{ src: "src/lib/*", dest: "lib/" }, { src: "src/lib/*", dest: "lib/" },
], ],
}), }),
], ],
build: { build: {
minify: false, minify: false,
sourcemap: true, sourcemap: true,
rollupOptions: { rollupOptions: {
// Disabling tree-shaking // Disabling tree-shaking
// Prevent vite remove unused exports // Prevent vite remove unused exports
treeshake: false treeshake: false
} }
}, },
define: { define: {
'__COMFYUI_FRONTEND_VERSION__': JSON.stringify(process.env.npm_package_version), '__COMFYUI_FRONTEND_VERSION__': JSON.stringify(process.env.npm_package_version),
}, },
}); });