Files
ComfyUI_frontend/src/scripts/app.ts
Chenlei Hu 65740a30c5 v1.2.0 Side Bar & Menu rework (#189)
* Basic side tool bar skeleton + Theme toggle (#164)

* Side bar skeleton

* Fix grid layout

* nit

* Add theme toggle logic

* Change primevue color theme to blue to match beta menu UI

* Add litegraph canvas splitter overlay (#177)

* Add vue wrapper

* Splitter overlay

* Move teleport to side bar comp

* Toolbar placeholder

* Move settings button from top menu to side bar (#178)

* Reverse relationship between splitter overlay and sidebar component (#180)

* Reverse relationship between splitter overlay and sidebar component

* nit

* Remove border on splitter

* Fix canvas shift (#186)

* Move queue/history display to side bar (#185)

* Side bar placeholder

* Pinia store for queue items

* Flatten task item

* Fix schema

* computed

* Switch running / pending order

* Use class-transformer

* nit

* Show display status

* Add tag severity style

* Add execution time

* nit

* Rename to execution success

* Add time display

* Sort queue desc order

* nit

* Add remove item feature

* Load workflow

* Add confirmation popup

* Add empty table placeholder

* Remove beta menu UI's queue button/list

* Add tests on litegraph widget text truncate (#191)

* Add tests on litegraph widget text truncate

* Updated screenshots

* Revert port change

* Remove screenshots

* Update test expectations [skip ci]

* Add back menu.settingsGroup for compatibility (#192)

* Close side bar on menu location set as disabled (#194)

* Remove placeholder side bar tabs (#196)

---------

Co-authored-by: bymyself <abolkonsky.rem@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 10:15:41 -04:00

2934 lines
85 KiB
TypeScript

import { ComfyLogging } from "./logging";
import { ComfyWidgetConstructor, ComfyWidgets, initWidgets } from "./widgets";
import { ComfyUI, $el } from "./ui";
import { api } from "./api";
import { defaultGraph } from "./defaultGraph";
import {
getPngMetadata,
getWebpMetadata,
getFlacMetadata,
importA1111,
getLatentMetadata,
} from "./pnginfo";
import { addDomClippingSetting } from "./domWidget";
import { createImageHost, calculateImageGrid } from "./ui/imagePreview";
import { DraggableList } from "./ui/draggableList";
import { applyTextReplacements, addStylesheet } from "./utils";
import type { ComfyExtension } from "@/types/comfy";
import {
type ComfyWorkflowJSON,
parseComfyWorkflow,
} from "../types/comfyWorkflow";
import { ComfyNodeDef } from "@/types/apiTypes";
import { lightenColor } from "@/utils/colorUtil";
import { ComfyAppMenu } from "./ui/menu/index";
import { getStorageValue } from "./utils";
import { ComfyWorkflowManager, ComfyWorkflow } from "./workflows";
import {
LGraphCanvas,
LGraph,
LGraphNode,
LiteGraph,
} from "@comfyorg/litegraph";
import { StorageLocation } from "@/types/settingTypes";
// CSS imports. style.css must be imported later as it overwrites some litegraph styles.
import "@comfyorg/litegraph/css/litegraph.css";
import "../assets/css/style.css";
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview";
function sanitizeNodeName(string) {
let entityMap = {
"&": "",
"<": "",
">": "",
'"': "",
"'": "",
"`": "",
"=": "",
};
return String(string).replace(/[&<>"'`=]/g, function fromEntityMap(s) {
return entityMap[s];
});
}
/**
* @typedef {import("types/comfy").ComfyExtension} ComfyExtension
*/
export class ComfyApp {
/**
* List of entries to queue
* @type {{number: number, batchCount: number}[]}
*/
#queueItems = [];
/**
* If the queue is currently being processed
* @type {boolean}
*/
#processingQueue = false;
/**
* Content Clipboard
* @type {serialized node object}
*/
static clipspace = null;
static clipspace_invalidate_handler = null;
static open_maskeditor = null;
static clipspace_return_node = null;
// Force vite to import utils.ts as part of index.
// Force import of DraggableList.
static utils = {
applyTextReplacements,
addStylesheet,
DraggableList,
};
ui: ComfyUI;
logging: ComfyLogging;
extensions: ComfyExtension[];
_nodeOutputs: Record<string, any>;
nodePreviewImages: Record<string, typeof Image>;
shiftDown: boolean;
graph: LGraph;
enableWorkflowViewRestore: any;
canvas: LGraphCanvas;
dragOverNode: LGraphNode | null;
canvasEl: HTMLCanvasElement;
// x, y, scale
zoom_drag_start: [number, number, number] | null;
lastNodeErrors: any[] | null;
runningNodeId: number | null;
lastExecutionError: { node_id: number } | null;
progress: { value: number; max: number } | null;
configuringGraph: boolean;
isNewUserSession: boolean;
storageLocation: StorageLocation;
multiUserServer: boolean;
ctx: CanvasRenderingContext2D;
widgets: Record<string, ComfyWidgetConstructor>;
workflowManager: ComfyWorkflowManager;
bodyTop: HTMLElement;
bodyLeft: HTMLElement;
bodyRight: HTMLElement;
bodyBottom: HTMLElement;
menu: ComfyAppMenu;
nodeDefs: Record<string, ComfyNodeDef>;
constructor() {
this.ui = new ComfyUI(this);
this.logging = new ComfyLogging(this);
this.workflowManager = new ComfyWorkflowManager(this);
this.bodyTop = $el("div.comfyui-body-top", { parent: document.body });
this.bodyLeft = $el("div.comfyui-body-left", { parent: document.body });
this.bodyRight = $el("div.comfyui-body-right", { parent: document.body });
this.bodyBottom = $el("div.comfyui-body-bottom", { parent: document.body });
this.menu = new ComfyAppMenu(this);
/**
* List of extensions that are registered with the app
* @type {ComfyExtension[]}
*/
this.extensions = [];
/**
* Stores the execution output data for each node
* @type {Record<string, any>}
*/
this.nodeOutputs = {};
/**
* Stores the preview image data for each node
* @type {Record<string, Image>}
*/
this.nodePreviewImages = {};
/**
* If the shift key on the keyboard is pressed
* @type {boolean}
*/
this.shiftDown = false;
}
get nodeOutputs() {
return this._nodeOutputs;
}
set nodeOutputs(value) {
this._nodeOutputs = value;
this.#invokeExtensions("onNodeOutputsUpdated", value);
}
getPreviewFormatParam() {
let preview_format = this.ui.settings.getSettingValue(
"Comfy.PreviewFormat"
);
if (preview_format) return `&preview=${preview_format}`;
else return "";
}
getRandParam() {
return "&rand=" + Math.random();
}
static isImageNode(node) {
return (
node.imgs ||
(node &&
node.widgets &&
node.widgets.findIndex((obj) => obj.name === "image") >= 0)
);
}
static onClipspaceEditorSave() {
if (ComfyApp.clipspace_return_node) {
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node);
}
}
static onClipspaceEditorClosed() {
ComfyApp.clipspace_return_node = null;
}
static copyToClipspace(node) {
var widgets = null;
if (node.widgets) {
widgets = node.widgets.map(({ type, name, value }) => ({
type,
name,
value,
}));
}
var imgs = undefined;
var orig_imgs = undefined;
if (node.imgs != undefined) {
imgs = [];
orig_imgs = [];
for (let i = 0; i < node.imgs.length; i++) {
imgs[i] = new Image();
imgs[i].src = node.imgs[i].src;
orig_imgs[i] = imgs[i];
}
}
var selectedIndex = 0;
if (node.imageIndex) {
selectedIndex = node.imageIndex;
}
ComfyApp.clipspace = {
widgets: widgets,
imgs: imgs,
original_imgs: orig_imgs,
images: node.images,
selectedIndex: selectedIndex,
img_paste_mode: "selected", // reset to default im_paste_mode state on copy action
};
ComfyApp.clipspace_return_node = null;
if (ComfyApp.clipspace_invalidate_handler) {
ComfyApp.clipspace_invalidate_handler();
}
}
static pasteFromClipspace(node) {
if (ComfyApp.clipspace) {
// image paste
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace["img_paste_mode"] == "selected") {
node.images = [
ComfyApp.clipspace.images[ComfyApp.clipspace["selectedIndex"]],
];
} else {
node.images = ComfyApp.clipspace.images;
}
if (app.nodeOutputs[node.id + ""])
app.nodeOutputs[node.id + ""].images = node.images;
}
if (ComfyApp.clipspace.imgs) {
// deep-copy to cut link with clipspace
if (ComfyApp.clipspace["img_paste_mode"] == "selected") {
const img = new Image();
img.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace["selectedIndex"]].src;
node.imgs = [img];
node.imageIndex = 0;
} else {
const imgs = [];
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
imgs[i] = new Image();
imgs[i].src = ComfyApp.clipspace.imgs[i].src;
node.imgs = imgs;
}
}
}
}
if (node.widgets) {
if (ComfyApp.clipspace.images) {
const clip_image =
ComfyApp.clipspace.images[ComfyApp.clipspace["selectedIndex"]];
const index = node.widgets.findIndex((obj) => obj.name === "image");
if (index >= 0) {
if (
node.widgets[index].type != "image" &&
typeof node.widgets[index].value == "string" &&
clip_image.filename
) {
node.widgets[index].value =
(clip_image.subfolder ? clip_image.subfolder + "/" : "") +
clip_image.filename +
(clip_image.type ? ` [${clip_image.type}]` : "");
} else {
node.widgets[index].value = clip_image;
}
}
}
if (ComfyApp.clipspace.widgets) {
ComfyApp.clipspace.widgets.forEach(({ type, name, value }) => {
const prop = Object.values(node.widgets).find(
// @ts-ignoreg
(obj) => obj.type === type && obj.name === name
);
// @ts-ignore
if (prop && prop.type != "button") {
if (
// @ts-ignore
prop.type != "image" &&
// @ts-ignore
typeof prop.value == "string" &&
value.filename
) {
// @ts-ignore
prop.value =
(value.subfolder ? value.subfolder + "/" : "") +
value.filename +
(value.type ? ` [${value.type}]` : "");
} else {
// @ts-ignore
prop.value = value;
// @ts-ignore
prop.callback(value);
}
}
});
}
}
app.graph.setDirtyCanvas(true);
}
}
/**
* Invoke an extension callback
* @param {keyof ComfyExtension} method The extension callback to execute
* @param {any[]} args Any arguments to pass to the callback
* @returns
*/
#invokeExtensions(method, ...args) {
let results = [];
for (const ext of this.extensions) {
if (method in ext) {
try {
results.push(ext[method](...args, this));
} catch (error) {
console.error(
`Error calling extension '${ext.name}' method '${method}'`,
{ error },
{ extension: ext },
{ args }
);
}
}
}
return results;
}
/**
* Invoke an async extension callback
* Each callback will be invoked concurrently
* @param {string} method The extension callback to execute
* @param {...any} args Any arguments to pass to the callback
* @returns
*/
async #invokeExtensionsAsync(method, ...args) {
return await Promise.all(
this.extensions.map(async (ext) => {
if (method in ext) {
try {
return await ext[method](...args, this);
} catch (error) {
console.error(
`Error calling extension '${ext.name}' method '${method}'`,
{ error },
{ extension: ext },
{ args }
);
}
}
})
);
}
#addRestoreWorkflowView() {
// @ts-ignore
const serialize = LGraph.prototype.serialize;
const self = this;
// @ts-ignore
LGraph.prototype.serialize = function () {
const workflow = serialize.apply(this, arguments);
// Store the drag & scale info in the serialized workflow if the setting is enabled
if (self.enableWorkflowViewRestore.value) {
if (!workflow.extra) {
workflow.extra = {};
}
workflow.extra.ds = {
scale: self.canvas.ds.scale,
offset: self.canvas.ds.offset,
};
} else if (workflow.extra?.ds) {
// Clear any old view data
delete workflow.extra.ds;
}
return workflow;
};
this.enableWorkflowViewRestore = this.ui.settings.addSetting({
id: "Comfy.EnableWorkflowViewRestore",
name: "Save and restore canvas position and zoom level in workflows",
type: "boolean",
defaultValue: true,
});
}
/**
* Adds special context menu handling for nodes
* e.g. this adds Open Image functionality for nodes that show images
* @param {*} node The node to add the menu handler
*/
#addNodeContextMenuHandler(node) {
function getCopyImageOption(img) {
if (typeof window.ClipboardItem === "undefined") return [];
return [
{
content: "Copy Image",
callback: async () => {
const url = new URL(img.src);
url.searchParams.delete("preview");
const writeImage = async (blob) => {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
};
try {
const data = await fetch(url);
const blob = await data.blob();
try {
await writeImage(blob);
} catch (error) {
// Chrome seems to only support PNG on write, convert and try again
if (blob.type !== "image/png") {
const canvas = $el("canvas", {
width: img.naturalWidth,
height: img.naturalHeight,
}) as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
let image;
if (typeof window.createImageBitmap === "undefined") {
image = new Image();
const p = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
}).finally(() => {
URL.revokeObjectURL(image.src);
});
image.src = URL.createObjectURL(blob);
await p;
} else {
image = await createImageBitmap(blob);
}
try {
ctx.drawImage(image, 0, 0);
canvas.toBlob(writeImage, "image/png");
} finally {
if (typeof image.close === "function") {
image.close();
}
}
return;
}
throw error;
}
} catch (error) {
alert("Error copying image: " + (error.message ?? error));
}
},
},
];
}
node.prototype.getExtraMenuOptions = function (_, options) {
if (this.imgs) {
// If this node has images then we add an open in new tab item
let img;
if (this.imageIndex != null) {
// An image is selected so select that
img = this.imgs[this.imageIndex];
} else if (this.overIndex != null) {
// No image is selected but one is hovered
img = this.imgs[this.overIndex];
}
if (img) {
options.unshift(
{
content: "Open Image",
callback: () => {
let url = new URL(img.src);
url.searchParams.delete("preview");
window.open(url, "_blank");
},
},
...getCopyImageOption(img),
{
content: "Save Image",
callback: () => {
const a = document.createElement("a");
let url = new URL(img.src);
url.searchParams.delete("preview");
a.href = url.toString();
a.setAttribute(
"download",
new URLSearchParams(url.search).get("filename")
);
document.body.append(a);
a.click();
requestAnimationFrame(() => a.remove());
},
}
);
}
}
options.push({
content: "Bypass",
callback: (obj) => {
if (this.mode === 4) this.mode = 0;
else this.mode = 4;
this.graph.change();
},
});
// prevent conflict of clipspace content
if (!ComfyApp.clipspace_return_node) {
options.push({
content: "Copy (Clipspace)",
callback: (obj) => {
ComfyApp.copyToClipspace(this);
},
});
if (ComfyApp.clipspace != null) {
options.push({
content: "Paste (Clipspace)",
callback: () => {
ComfyApp.pasteFromClipspace(this);
},
});
}
if (ComfyApp.isImageNode(this)) {
options.push({
content: "Open in MaskEditor",
callback: (obj) => {
ComfyApp.copyToClipspace(this);
ComfyApp.clipspace_return_node = this;
ComfyApp.open_maskeditor();
},
});
}
}
};
}
#addNodeKeyHandler(node) {
const app = this;
const origNodeOnKeyDown = node.prototype.onKeyDown;
node.prototype.onKeyDown = function (e) {
if (origNodeOnKeyDown && origNodeOnKeyDown.apply(this, e) === false) {
return false;
}
if (this.flags.collapsed || !this.imgs || this.imageIndex === null) {
return;
}
let handled = false;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
if (e.key === "ArrowLeft") {
this.imageIndex -= 1;
} else if (e.key === "ArrowRight") {
this.imageIndex += 1;
}
this.imageIndex %= this.imgs.length;
if (this.imageIndex < 0) {
this.imageIndex = this.imgs.length + this.imageIndex;
}
handled = true;
} else if (e.key === "Escape") {
this.imageIndex = null;
handled = true;
}
if (handled === true) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
};
}
/**
* Adds Custom drawing logic for nodes
* e.g. Draws images and handles thumbnail navigation on nodes that output images
* @param {*} node The node to add the draw handler
*/
#addDrawBackgroundHandler(node) {
const app = this;
function getImageTop(node) {
let shiftY;
if (node.imageOffset != null) {
shiftY = node.imageOffset;
} else {
if (node.widgets?.length) {
const w = node.widgets[node.widgets.length - 1];
shiftY = w.last_y;
if (w.computeSize) {
shiftY += w.computeSize()[1] + 4;
} else if (w.computedHeight) {
shiftY += w.computedHeight;
} else {
shiftY += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
} else {
shiftY = node.computeSize()[1];
}
}
return shiftY;
}
node.prototype.setSizeForImage = function (force) {
if (!force && this.animatedImages) return;
if (this.inputHeight || this.freeWidgetSpace > 210) {
this.setSize(this.size);
return;
}
const minHeight = getImageTop(this) + 220;
if (this.size[1] < minHeight) {
this.setSize([this.size[0], minHeight]);
}
};
node.prototype.onDrawBackground = function (ctx) {
if (!this.flags.collapsed) {
let imgURLs = [];
let imagesChanged = false;
const output = app.nodeOutputs[this.id + ""];
if (output?.images) {
this.animatedImages = output?.animated?.find(Boolean);
if (this.images !== output.images) {
this.images = output.images;
imagesChanged = true;
imgURLs = imgURLs.concat(
output.images.map((params) => {
return api.apiURL(
"/view?" +
new URLSearchParams(params).toString() +
(this.animatedImages ? "" : app.getPreviewFormatParam()) +
app.getRandParam()
);
})
);
}
}
const preview = app.nodePreviewImages[this.id + ""];
if (this.preview !== preview) {
this.preview = preview;
imagesChanged = true;
if (preview != null) {
imgURLs.push(preview);
}
}
if (imagesChanged) {
this.imageIndex = null;
if (imgURLs.length > 0) {
Promise.all(
imgURLs.map((src) => {
return new Promise((r) => {
const img = new Image();
img.onload = () => r(img);
img.onerror = () => r(null);
img.src = src;
});
})
).then((imgs) => {
if (
(!output || this.images === output.images) &&
(!preview || this.preview === preview)
) {
this.imgs = imgs.filter(Boolean);
this.setSizeForImage?.();
app.graph.setDirtyCanvas(true);
}
});
} else {
this.imgs = null;
}
}
const calculateGrid = (w, h, n) => {
let columns, rows, cellsize;
if (w > h) {
cellsize = h;
columns = Math.ceil(w / cellsize);
rows = Math.ceil(n / columns);
} else {
cellsize = w;
rows = Math.ceil(h / cellsize);
columns = Math.ceil(n / rows);
}
while (columns * rows < n) {
cellsize++;
if (w >= h) {
columns = Math.ceil(w / cellsize);
rows = Math.ceil(n / columns);
} else {
rows = Math.ceil(h / cellsize);
columns = Math.ceil(n / rows);
}
}
const cell_size = Math.min(w / columns, h / rows);
return { cell_size, columns, rows };
};
const is_all_same_aspect_ratio = (imgs) => {
// assume: imgs.length >= 2
let ratio = imgs[0].naturalWidth / imgs[0].naturalHeight;
for (let i = 1; i < imgs.length; i++) {
let this_ratio = imgs[i].naturalWidth / imgs[i].naturalHeight;
if (ratio != this_ratio) return false;
}
return true;
};
if (this.imgs?.length) {
const widgetIdx = this.widgets?.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
);
if (this.animatedImages) {
// Instead of using the canvas we'll use a IMG
if (widgetIdx > -1) {
// Replace content
const widget = this.widgets[widgetIdx];
widget.options.host.updateImages(this.imgs);
} else {
const host = createImageHost(this);
this.setSizeForImage(true);
const widget = this.addDOMWidget(
ANIM_PREVIEW_WIDGET,
"img",
host.el,
{
host,
getHeight: host.getHeight,
onDraw: host.onDraw,
hideOnZoom: false,
}
);
widget.serializeValue = () => undefined;
widget.options.host.updateImages(this.imgs);
}
return;
}
if (widgetIdx > -1) {
this.widgets[widgetIdx].onRemove?.();
this.widgets.splice(widgetIdx, 1);
}
const canvas = app.graph.list_of_graphcanvas[0];
const mouse = canvas.graph_mouse;
if (!canvas.pointer_is_down && this.pointerDown) {
if (
mouse[0] === this.pointerDown.pos[0] &&
mouse[1] === this.pointerDown.pos[1]
) {
this.imageIndex = this.pointerDown.index;
}
this.pointerDown = null;
}
let imageIndex = this.imageIndex;
const numImages = this.imgs.length;
if (numImages === 1 && !imageIndex) {
this.imageIndex = imageIndex = 0;
}
const top = getImageTop(this);
var shiftY = top;
let dw = this.size[0];
let dh = this.size[1];
dh -= shiftY;
if (imageIndex == null) {
var cellWidth, cellHeight, shiftX, cell_padding, cols;
const compact_mode = is_all_same_aspect_ratio(this.imgs);
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2;
const { cell_size, columns, rows } = calculateGrid(
dw,
dh,
numImages
);
cols = columns;
cellWidth = cell_size;
cellHeight = cell_size;
shiftX = (dw - cell_size * cols) / 2;
shiftY = (dh - cell_size * rows) / 2 + top;
} else {
cell_padding = 0;
({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
this.imgs,
dw,
dh
));
}
let anyHovered = false;
this.imageRects = [];
for (let i = 0; i < numImages; i++) {
const img = this.imgs[i];
const row = Math.floor(i / cols);
const col = i % cols;
const x = col * cellWidth + shiftX;
const y = row * cellHeight + shiftY;
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + this.pos[0],
y + this.pos[1],
cellWidth,
cellHeight
);
if (anyHovered) {
this.overIndex = i;
let value = 110;
if (canvas.pointer_is_down) {
if (!this.pointerDown || this.pointerDown.index !== i) {
this.pointerDown = { index: i, pos: [...mouse] };
}
value = 125;
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`;
canvas.canvas.style.cursor = "pointer";
}
}
this.imageRects.push([x, y, cellWidth, cellHeight]);
let wratio = cellWidth / img.width;
let hratio = cellHeight / img.height;
var ratio = Math.min(wratio, hratio);
let imgHeight = ratio * img.height;
let imgY =
row * cellHeight + shiftY + (cellHeight - imgHeight) / 2;
let imgWidth = ratio * img.width;
let imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2;
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
);
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = "#8F8F8F";
ctx.lineWidth = 1;
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
);
}
ctx.filter = "none";
}
if (!anyHovered) {
this.pointerDown = null;
this.overIndex = null;
}
} else {
// Draw individual
let w = this.imgs[imageIndex].naturalWidth;
let h = this.imgs[imageIndex].naturalHeight;
const scaleX = dw / w;
const scaleY = dh / h;
const scale = Math.min(scaleX, scaleY, 1);
w *= scale;
h *= scale;
let x = (dw - w) / 2;
let y = (dh - h) / 2 + shiftY;
ctx.drawImage(this.imgs[imageIndex], x, y, w, h);
const drawButton = (x, y, sz, text) => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + this.pos[0],
y + this.pos[1],
sz,
sz
);
let fill = "#333";
let textFill = "#fff";
let isClicking = false;
if (hovered) {
canvas.canvas.style.cursor = "pointer";
if (canvas.pointer_is_down) {
fill = "#1e90ff";
isClicking = true;
} else {
fill = "#eee";
textFill = "#000";
}
} else {
this.pointerWasDown = null;
}
ctx.fillStyle = fill;
ctx.beginPath();
ctx.roundRect(x, y, sz, sz, [4]);
ctx.fill();
ctx.fillStyle = textFill;
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.fillText(text, x + 15, y + 20);
return isClicking;
};
if (numImages > 1) {
if (
drawButton(
dw - 40,
dh + top - 40,
30,
`${this.imageIndex + 1}/${numImages}`
)
) {
let i =
this.imageIndex + 1 >= numImages ? 0 : this.imageIndex + 1;
if (!this.pointerDown || !this.pointerDown.index === i) {
this.pointerDown = { index: i, pos: [...mouse] };
}
}
if (drawButton(dw - 40, top + 10, 30, `x`)) {
if (!this.pointerDown || !this.pointerDown.index === null) {
this.pointerDown = { index: null, pos: [...mouse] };
}
}
}
}
}
}
};
}
/**
* Adds a handler allowing drag+drop of files onto the window to load workflows
*/
#addDropHandler() {
// Get prompt from dropped PNG or json
document.addEventListener("drop", async (event) => {
event.preventDefault();
event.stopPropagation();
const n = this.dragOverNode;
this.dragOverNode = null;
// Node handles file drop, we dont use the built in onDropFile handler as its buggy
// If you drag multiple files it will call it multiple times with the same file
// @ts-ignore This is not a standard event. TODO fix it.
if (n && n.onDragDrop && (await n.onDragDrop(event))) {
return;
}
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
if (
event.dataTransfer.files.length &&
event.dataTransfer.files[0].type !== "image/bmp"
) {
await this.handleFile(event.dataTransfer.files[0]);
} else {
// Try loading the first URI in the transfer list
const validTypes = ["text/uri-list", "text/x-moz-url"];
const match = [...event.dataTransfer.types].find((t) =>
validTypes.find((v) => t === v)
);
if (match) {
const uri = event.dataTransfer.getData(match)?.split("\n")?.[0];
if (uri) {
await this.handleFile(await (await fetch(uri)).blob());
}
}
}
});
// Always clear over node on drag leave
this.canvasEl.addEventListener("dragleave", async () => {
if (this.dragOverNode) {
this.dragOverNode = null;
this.graph.setDirtyCanvas(false, true);
}
});
// Add handler for dropping onto a specific node
this.canvasEl.addEventListener(
"dragover",
(e) => {
this.canvas.adjustMouseEvent(e);
// @ts-ignore: canvasX and canvasY are added by adjustMouseEvent in litegraph
const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY);
if (node) {
// @ts-ignore This is not a standard event. TODO fix it.
if (node.onDragOver && node.onDragOver(e)) {
this.dragOverNode = node;
// dragover event is fired very frequently, run this on an animation frame
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(false, true);
});
return;
}
}
this.dragOverNode = null;
},
false
);
}
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
#addPasteHandler() {
document.addEventListener("paste", async (e: ClipboardEvent) => {
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph
if (this.shiftDown) return;
// @ts-ignore: Property 'clipboardData' does not exist on type 'Window & typeof globalThis'.
// Did you mean 'Clipboard'?ts(2551)
// TODO: Not sure what the code wants to do.
let data = e.clipboardData || window.clipboardData;
const items = data.items;
// Look for image paste data
for (const item of items) {
if (item.type.startsWith("image/")) {
var imageNode = null;
// If an image node is selected, paste into it
if (
this.canvas.current_node &&
this.canvas.current_node.is_selected &&
ComfyApp.isImageNode(this.canvas.current_node)
) {
imageNode = this.canvas.current_node;
}
// No image node selected: add a new one
if (!imageNode) {
const newNode = LiteGraph.createNode("LoadImage");
newNode.pos = [...this.canvas.graph_mouse];
imageNode = this.graph.add(newNode);
this.graph.change();
}
const blob = item.getAsFile();
imageNode.pasteFile(blob);
return;
}
}
// No image found. Look for node data
data = data.getData("text/plain");
let workflow: ComfyWorkflowJSON;
try {
data = data.slice(data.indexOf("{"));
workflow = await parseComfyWorkflow(data);
} catch (err) {
try {
data = data.slice(data.indexOf("workflow\n"));
data = data.slice(data.indexOf("{"));
workflow = await parseComfyWorkflow(data);
} catch (error) {
console.error(error);
}
}
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
await this.loadGraphData(workflow);
} else {
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === "textarea") ||
(e.target instanceof HTMLInputElement && e.target.type === "text")
) {
return;
}
// Litegraph default paste
this.canvas.pasteFromClipboard();
}
});
}
/**
* Adds a handler on copy that serializes selected nodes to JSON
*/
#addCopyHandler() {
document.addEventListener("copy", (e) => {
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === "textarea") ||
(e.target instanceof HTMLInputElement && e.target.type === "text")
) {
// Default system copy
return;
}
// copy nodes and clear clipboard
if (
e.target instanceof Element &&
e.target.classList.contains("litegraph") &&
this.canvas.selected_nodes
) {
this.canvas.copyToClipboard();
e.clipboardData.setData("text", " "); //clearData doesn't remove images from clipboard
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
});
}
/**
* Handle mouse
*
* Move group by header
*/
#addProcessMouseHandler() {
const self = this;
// @ts-ignore
const origProcessMouseDown = LGraphCanvas.prototype.processMouseDown;
// @ts-ignore
LGraphCanvas.prototype.processMouseDown = function (e) {
// prepare for ctrl+shift drag: zoom start
if (e.ctrlKey && e.shiftKey && e.buttons) {
self.zoom_drag_start = [e.x, e.y, this.ds.scale];
return;
}
const res = origProcessMouseDown.apply(this, arguments);
this.selected_group_moving = false;
if (this.selected_group && !this.selected_group_resizing) {
var font_size =
this.selected_group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE;
var height = font_size * 1.4;
// Move group by header
if (
LiteGraph.isInsideRectangle(
// @ts-ignore
e.canvasX,
// @ts-ignore
e.canvasY,
this.selected_group.pos[0],
this.selected_group.pos[1],
this.selected_group.size[0],
height
)
) {
this.selected_group_moving = true;
}
}
return res;
};
// @ts-ignore
const origProcessMouseMove = LGraphCanvas.prototype.processMouseMove;
// @ts-ignore
LGraphCanvas.prototype.processMouseMove = function (e) {
// handle ctrl+shift drag
if (e.ctrlKey && e.shiftKey && self.zoom_drag_start) {
// stop canvas zoom action
if (!e.buttons) {
self.zoom_drag_start = null;
return;
}
// calculate delta
let deltaY = e.y - self.zoom_drag_start[1];
let startScale = self.zoom_drag_start[2];
let scale = startScale - deltaY / 100;
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2,
]);
this.graph.change();
return;
}
const orig_selected_group = this.selected_group;
if (
this.selected_group &&
!this.selected_group_resizing &&
!this.selected_group_moving
) {
this.selected_group = null;
}
const res = origProcessMouseMove.apply(this, arguments);
if (
orig_selected_group &&
!this.selected_group_resizing &&
!this.selected_group_moving
) {
this.selected_group = orig_selected_group;
}
return res;
};
}
/**
* Handle keypress
*
* Ctrl + M mute/unmute selected nodes
*/
#addProcessKeyHandler() {
const self = this;
// @ts-ignore
const origProcessKey = LGraphCanvas.prototype.processKey;
// @ts-ignore
LGraphCanvas.prototype.processKey = function (e) {
if (!this.graph) {
return;
}
var block_default = false;
if (e.target instanceof Element && e.target.localName == "input") {
return;
}
if (e.type == "keydown" && !e.repeat) {
// Ctrl + M mute/unmute
if (e.key === "m" && e.ctrlKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 2) {
// never
this.selected_nodes[i].mode = 0; // always
} else {
this.selected_nodes[i].mode = 2; // never
}
}
}
block_default = true;
}
// Ctrl + B bypass
if (e.key === "b" && e.ctrlKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 4) {
// never
this.selected_nodes[i].mode = 0; // always
} else {
this.selected_nodes[i].mode = 4; // never
}
}
}
block_default = true;
}
// Alt + C collapse/uncollapse
if (e.key === "c" && e.altKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
this.selected_nodes[i].collapse();
}
}
block_default = true;
}
// Ctrl+C Copy
if (e.key === "c" && (e.metaKey || e.ctrlKey)) {
// Trigger onCopy
return true;
}
// Ctrl+V Paste
if (
(e.key === "v" || e.key == "V") &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey
) {
// Trigger onPaste
return true;
}
if (e.key === "+" && e.altKey) {
block_default = true;
let scale = this.ds.scale * 1.1;
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2,
]);
this.graph.change();
}
if (e.key === "-" && e.altKey) {
block_default = true;
let scale = (this.ds.scale * 1) / 1.1;
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2,
]);
this.graph.change();
}
}
this.graph.change();
if (block_default) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// Fall through to Litegraph defaults
return origProcessKey.apply(this, arguments);
};
}
/**
* Draws group header bar
*/
#addDrawGroupsHandler() {
const self = this;
// @ts-ignore
const origDrawGroups = LGraphCanvas.prototype.drawGroups;
// @ts-ignore
LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
if (!this.graph) {
return;
}
var groups = this.graph._groups;
ctx.save();
ctx.globalAlpha = 0.7 * this.editor_alpha;
for (var i = 0; i < groups.length; ++i) {
var group = groups[i];
if (!LiteGraph.overlapBounding(this.visible_area, group._bounding)) {
continue;
} //out of the visible area
ctx.fillStyle = group.color || "#335";
ctx.strokeStyle = group.color || "#335";
var pos = group._pos;
var size = group._size;
ctx.globalAlpha = 0.25 * this.editor_alpha;
ctx.beginPath();
var font_size = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE;
ctx.rect(pos[0] + 0.5, pos[1] + 0.5, size[0], font_size * 1.4);
ctx.fill();
ctx.globalAlpha = this.editor_alpha;
}
ctx.restore();
const res = origDrawGroups.apply(this, arguments);
return res;
};
}
/**
* Draws node highlights (executing, drag drop) and progress bar
*/
#addDrawNodeHandler() {
// @ts-ignore
const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape;
const self = this;
// @ts-ignore
LGraphCanvas.prototype.drawNodeShape = function (
node,
ctx,
size,
fgcolor,
bgcolor,
selected,
mouse_over
) {
const res = origDrawNodeShape.apply(this, arguments);
const nodeErrors = self.lastNodeErrors?.[node.id];
let color = null;
let lineWidth = 1;
if (node.id === +self.runningNodeId) {
color = "#0f0";
} else if (self.dragOverNode && node.id === self.dragOverNode.id) {
color = "dodgerblue";
} else if (nodeErrors?.errors) {
color = "red";
lineWidth = 2;
} else if (
self.lastExecutionError &&
+self.lastExecutionError.node_id === node.id
) {
color = "#f0f";
lineWidth = 2;
}
if (color) {
const shape =
// @ts-ignore
node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE;
ctx.lineWidth = lineWidth;
ctx.globalAlpha = 0.8;
ctx.beginPath();
if (shape == LiteGraph.BOX_SHAPE)
ctx.rect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT
);
else if (
shape == LiteGraph.ROUND_SHAPE ||
(shape == LiteGraph.CARD_SHAPE && node.flags.collapsed)
)
ctx.roundRect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
this.round_radius * 2
);
else if (shape == LiteGraph.CARD_SHAPE)
ctx.roundRect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
[this.round_radius * 2, this.round_radius * 2, 2, 2]
);
else if (shape == LiteGraph.CIRCLE_SHAPE)
ctx.arc(
size[0] * 0.5,
size[1] * 0.5,
size[0] * 0.5 + 6,
0,
Math.PI * 2
);
ctx.strokeStyle = color;
ctx.stroke();
ctx.strokeStyle = fgcolor;
ctx.globalAlpha = 1;
}
if (self.progress && node.id === +self.runningNodeId) {
ctx.fillStyle = "green";
ctx.fillRect(
0,
0,
size[0] * (self.progress.value / self.progress.max),
6
);
ctx.fillStyle = bgcolor;
}
// Highlight inputs that failed validation
if (nodeErrors) {
ctx.lineWidth = 2;
ctx.strokeStyle = "red";
for (const error of nodeErrors.errors) {
if (error.extra_info && error.extra_info.input_name) {
const inputIndex = node.findInputSlot(error.extra_info.input_name);
if (inputIndex !== -1) {
let pos = node.getConnectionPos(true, inputIndex);
ctx.beginPath();
ctx.arc(
pos[0] - node.pos[0],
pos[1] - node.pos[1],
12,
0,
2 * Math.PI,
false
);
ctx.stroke();
}
}
}
}
return res;
};
// @ts-ignore
const origDrawNode = LGraphCanvas.prototype.drawNode;
// @ts-ignore
LGraphCanvas.prototype.drawNode = function (node, ctx) {
var editor_alpha = this.editor_alpha;
var old_color = node.color;
var old_bgcolor = node.bgcolor;
if (node.mode === 2) {
// never
this.editor_alpha = 0.4;
}
// ComfyUI's custom node mode enum value 4 => bypass/never.
// @ts-ignore
if (node.mode === 4) {
// never
node.bgcolor = "#FF00FF";
this.editor_alpha = 0.2;
}
const adjustColor = (color?: string) => {
return color ? lightenColor(color, 0.5) : color;
};
if (app.ui.settings.getSettingValue("Comfy.ColorPalette") === "light") {
node.bgcolor = adjustColor(node.bgcolor);
node.color = adjustColor(node.color);
}
const res = origDrawNode.apply(this, arguments);
this.editor_alpha = editor_alpha;
node.color = old_color;
node.bgcolor = old_bgcolor;
return res;
};
}
/**
* Handles updates from the API socket
*/
#addApiUpdateHandlers() {
api.addEventListener("status", ({ detail }) => {
this.ui.setStatus(detail);
});
api.addEventListener("reconnecting", () => {
this.ui.dialog.show("Reconnecting...");
});
api.addEventListener("reconnected", () => {
this.ui.dialog.close();
});
api.addEventListener("progress", ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
this.workflowManager.activePrompt.workflow !==
this.workflowManager.activeWorkflow
)
return;
this.progress = detail;
this.graph.setDirtyCanvas(true, false);
});
api.addEventListener("executing", ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
this.workflowManager.activePrompt.workflow !==
this.workflowManager.activeWorkflow
)
return;
this.progress = null;
this.runningNodeId = detail;
this.graph.setDirtyCanvas(true, false);
delete this.nodePreviewImages[this.runningNodeId];
});
api.addEventListener("executed", ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
this.workflowManager.activePrompt.workflow !==
this.workflowManager.activeWorkflow
)
return;
const output = this.nodeOutputs[detail.node];
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k];
if (v instanceof Array) {
output[k] = v.concat(detail.output[k]);
} else {
output[k] = detail.output[k];
}
}
} else {
this.nodeOutputs[detail.node] = detail.output;
}
const node = this.graph.getNodeById(detail.node);
if (node) {
// @ts-ignore
if (node.onExecuted)
// @ts-ignore
node.onExecuted(detail.output);
}
});
api.addEventListener("execution_start", ({ detail }) => {
this.runningNodeId = null;
this.lastExecutionError = null;
// @ts-ignore
this.graph._nodes.forEach((node) => {
// @ts-ignore
if (node.onExecutionStart)
// @ts-ignore
node.onExecutionStart();
});
});
api.addEventListener("execution_error", ({ detail }) => {
this.lastExecutionError = detail;
const formattedError = this.#formatExecutionError(detail);
this.ui.dialog.show(formattedError);
this.canvas.draw(true, true);
});
api.addEventListener("b_preview", ({ detail }) => {
const id = this.runningNodeId;
if (id == null) return;
const blob = detail;
const blobUrl = URL.createObjectURL(blob);
// @ts-ignore
this.nodePreviewImages[id] = [blobUrl];
});
api.init();
}
#addKeyboardHandler() {
window.addEventListener("keydown", (e) => {
this.shiftDown = e.shiftKey;
});
window.addEventListener("keyup", (e) => {
this.shiftDown = e.shiftKey;
});
}
#addConfigureHandler() {
const app = this;
// @ts-ignore
const configure = LGraph.prototype.configure;
// Flag that the graph is configuring to prevent nodes from running checks while its still loading
// @ts-ignore
LGraph.prototype.configure = function () {
app.configuringGraph = true;
try {
return configure.apply(this, arguments);
} finally {
app.configuringGraph = false;
}
};
}
#addAfterConfigureHandler() {
const app = this;
// @ts-ignore
const onConfigure = app.graph.onConfigure;
// @ts-ignore
app.graph.onConfigure = function () {
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
// @ts-ignore
for (const node of app.graph._nodes) {
// @ts-ignore
node.onGraphConfigured?.();
}
const r = onConfigure?.apply(this, arguments);
// Fire after onConfigure, used by primitves to generate widget using input nodes config
// @ts-ignore _nodes is private.
for (const node of app.graph._nodes) {
// @ts-ignore
node.onAfterGraphConfigured?.();
}
return r;
};
}
/**
* Loads all extensions from the API into the window in parallel
*/
async #loadExtensions() {
const extensions = await api.getExtensions();
this.logging.addEntry("Comfy.App", "debug", { Extensions: extensions });
// Need to load core extensions first as some custom extensions
// may depend on them.
await import("../extensions/core/index");
await Promise.all(
extensions
.filter((extension) => !extension.includes("extensions/core"))
.map(async (ext) => {
try {
await import(/* @vite-ignore */ api.fileURL(ext));
} catch (error) {
console.error("Error loading extension", ext, error);
}
})
);
try {
this.menu.workflows.registerExtension(this);
} catch (error) {
console.error(error);
}
}
async #migrateSettings() {
this.isNewUserSession = true;
// Store all current settings
const settings = Object.keys(this.ui.settings).reduce((p, n) => {
const v = localStorage[`Comfy.Settings.${n}`];
if (v) {
try {
p[n] = JSON.parse(v);
} catch (error) {}
}
return p;
}, {});
await api.storeSettings(settings);
}
async #setUser() {
const userConfig = await api.getUserConfig();
this.storageLocation = userConfig.storage;
if (typeof userConfig.migrated == "boolean") {
// Single user mode migrated true/false for if the default user is created
if (!userConfig.migrated && this.storageLocation === "server") {
// Default user not created yet
await this.#migrateSettings();
}
return;
}
this.multiUserServer = true;
let user = localStorage["Comfy.userId"];
const users = userConfig.users ?? {};
if (!user || !users[user]) {
// This will rarely be hit so move the loading to on demand
const { UserSelectionScreen } = await import("./ui/userSelection");
this.ui.menuContainer.style.display = "none";
const { userId, username, created } =
await new UserSelectionScreen().show(users, user);
this.ui.menuContainer.style.display = "";
user = userId;
localStorage["Comfy.userName"] = username;
localStorage["Comfy.userId"] = user;
if (created) {
api.user = user;
await this.#migrateSettings();
}
}
api.user = user;
this.ui.settings.addSetting({
id: "Comfy.SwitchUser",
name: "Switch User",
type: (name) => {
let currentUser = localStorage["Comfy.userName"];
if (currentUser) {
currentUser = ` (${currentUser})`;
}
return $el("tr", [
$el("td", [
$el("label", {
textContent: name,
}),
]),
$el("td", [
$el("button", {
textContent: name + (currentUser ?? ""),
onclick: () => {
delete localStorage["Comfy.userId"];
delete localStorage["Comfy.userName"];
window.location.reload();
},
}),
]),
]);
},
// TODO: Is that the correct default value?
defaultValue: undefined,
});
}
/**
* Set up the app on the page
*/
async setup() {
await this.#setUser();
// Create and mount the LiteGraph in the DOM
const canvasContainer = document.createElement("div");
canvasContainer.id = "graph-canvas-container";
const mainCanvas = document.createElement("canvas");
mainCanvas.style.touchAction = "none";
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, {
id: "graph-canvas",
}));
canvasEl.tabIndex = 1;
canvasContainer.prepend(canvasEl);
document.body.prepend(canvasContainer);
this.resizeCanvas();
await Promise.all([
this.workflowManager.loadWorkflows(),
this.ui.settings.load(),
]);
await this.#loadExtensions();
addDomClippingSetting();
this.#addProcessMouseHandler();
this.#addProcessKeyHandler();
this.#addConfigureHandler();
this.#addApiUpdateHandlers();
this.#addRestoreWorkflowView();
this.graph = new LGraph();
this.#addAfterConfigureHandler();
this.canvas = new LGraphCanvas(canvasEl, this.graph);
this.ctx = canvasEl.getContext("2d");
LiteGraph.release_link_on_empty_shows_menu = true;
LiteGraph.alt_drag_do_clone_nodes = true;
this.graph.start();
// Ensure the canvas fills the window
this.resizeCanvas();
window.addEventListener("resize", () => this.resizeCanvas());
const ro = new ResizeObserver(() => this.resizeCanvas());
ro.observe(this.bodyTop);
ro.observe(this.bodyLeft);
ro.observe(this.bodyRight);
ro.observe(this.bodyBottom);
await this.#invokeExtensionsAsync("init");
await this.registerNodes();
initWidgets(this);
// Load previous workflow
let restored = false;
try {
const loadWorkflow = async (json) => {
if (json) {
const workflow = await parseComfyWorkflow(json);
const workflowName = getStorageValue("Comfy.PreviousWorkflow");
await this.loadGraphData(workflow, true, true, workflowName);
return true;
}
};
const clientId = api.initialClientId ?? api.clientId;
restored =
(clientId &&
(await loadWorkflow(
sessionStorage.getItem(`workflow:${clientId}`)
))) ||
(await loadWorkflow(localStorage.getItem("workflow")));
} catch (err) {
console.error("Error loading previous workflow", err);
}
// We failed to restore a workflow so load the default
if (!restored) {
await this.loadGraphData();
}
// Save current workflow automatically
setInterval(() => {
const workflow = JSON.stringify(this.graph.serialize());
localStorage.setItem("workflow", workflow);
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow);
}
}, 1000);
this.#addDrawNodeHandler();
this.#addDrawGroupsHandler();
this.#addDropHandler();
this.#addCopyHandler();
this.#addPasteHandler();
this.#addKeyboardHandler();
await this.#invokeExtensionsAsync("setup");
}
resizeCanvas() {
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
const scale = Math.max(window.devicePixelRatio, 1);
// Clear fixed width and height while calculating rect so it uses 100% instead
this.canvasEl.height = this.canvasEl.width = NaN;
const { width, height } = this.canvasEl.getBoundingClientRect();
this.canvasEl.width = Math.round(width * scale);
this.canvasEl.height = Math.round(height * scale);
this.canvasEl.getContext("2d").scale(scale, scale);
this.canvas?.draw(true, true);
}
/**
* Registers nodes with the graph
*/
async registerNodes() {
// Load node definitions from the backend
const defs = await api.getNodeDefs();
this.nodeDefs = defs;
await this.registerNodesFromDefs(defs);
await this.#invokeExtensionsAsync("registerCustomNodes");
}
getWidgetType(inputData, inputName) {
const type = inputData[0];
if (Array.isArray(type)) {
return "COMBO";
} else if (`${type}:${inputName}` in this.widgets) {
return `${type}:${inputName}`;
} else if (type in this.widgets) {
return type;
} else {
return null;
}
}
async registerNodeDef(nodeId: string, nodeData: ComfyNodeDef) {
const self = this;
const node = Object.assign(
function ComfyNode() {
var inputs = nodeData["input"]["required"];
if (nodeData["input"]["optional"] != undefined) {
inputs = Object.assign(
{},
nodeData["input"]["required"],
nodeData["input"]["optional"]
);
}
const config = { minWidth: 1, minHeight: 1 };
for (const inputName in inputs) {
const inputData = inputs[inputName];
const type = inputData[0];
let widgetCreated = true;
const widgetType = self.getWidgetType(inputData, inputName);
if (widgetType) {
if (widgetType === "COMBO") {
Object.assign(
config,
self.widgets.COMBO(this, inputName, inputData, app) || {}
);
} else {
Object.assign(
config,
self.widgets[widgetType](this, inputName, inputData, app) || {}
);
}
} else {
// Node connection inputs
this.addInput(inputName, type);
widgetCreated = false;
}
// @ts-ignore
if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
// @ts-ignore
if (!config.widget.options) config.widget.options = {};
// @ts-ignore
config.widget.options.forceInput = inputData[1].forceInput;
}
// @ts-ignore
if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
// @ts-ignore
if (!config.widget.options) config.widget.options = {};
// @ts-ignore
config.widget.options.defaultInput = inputData[1].defaultInput;
}
}
for (const o in nodeData["output"]) {
let output = nodeData["output"][o];
if (output instanceof Array) output = "COMBO";
const outputName = nodeData["output_name"][o] || output;
const outputShape = nodeData["output_is_list"][o]
? LiteGraph.GRID_SHAPE
: LiteGraph.CIRCLE_SHAPE;
this.addOutput(outputName, output, { shape: outputShape });
}
const s = this.computeSize();
s[0] = Math.max(config.minWidth, s[0] * 1.5);
s[1] = Math.max(config.minHeight, s[1]);
this.size = s;
this.serialize_widgets = true;
app.#invokeExtensionsAsync("nodeCreated", this);
},
{
title: nodeData.display_name || nodeData.name,
comfyClass: nodeData.name,
nodeData,
}
);
node.prototype.comfyClass = nodeData.name;
this.#addNodeContextMenuHandler(node);
this.#addDrawBackgroundHandler(node);
this.#addNodeKeyHandler(node);
await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
// @ts-ignore
LiteGraph.registerNodeType(nodeId, node);
// @ts-ignore
node.category = nodeData.category;
}
async registerNodesFromDefs(defs: Record<string, ComfyNodeDef>) {
await this.#invokeExtensionsAsync("addCustomNodeDefs", defs);
// Generate list of known widgets
this.widgets = Object.assign(
{},
ComfyWidgets,
...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean)
);
// Register a node for each definition
for (const nodeId in defs) {
this.registerNodeDef(nodeId, defs[nodeId]);
}
}
loadTemplateData(templateData) {
if (!templateData?.templates) {
return;
}
const old = localStorage.getItem("litegrapheditor_clipboard");
var maxY, nodeBottom, node;
for (const template of templateData.templates) {
if (!template?.data) {
continue;
}
localStorage.setItem("litegrapheditor_clipboard", template.data);
app.canvas.pasteFromClipboard();
// Move mouse position down to paste the next template below
maxY = false;
for (const i in app.canvas.selected_nodes) {
node = app.canvas.selected_nodes[i];
nodeBottom = node.pos[1] + node.size[1];
if (maxY === false || nodeBottom > maxY) {
maxY = nodeBottom;
}
}
app.canvas.graph_mouse[1] = maxY + 50;
}
localStorage.setItem("litegrapheditor_clipboard", old);
}
showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
let seenTypes = new Set();
this.ui.dialog.show(
$el("div.comfy-missing-nodes", [
$el("span", {
textContent:
"When loading the graph, the following node types were not found: ",
}),
$el(
"ul",
Array.from(new Set(missingNodeTypes))
.map((t) => {
let children = [];
if (typeof t === "object") {
// @ts-ignore
if (seenTypes.has(t.type)) return null;
// @ts-ignore
seenTypes.add(t.type);
// @ts-ignore
children.push($el("span", { textContent: t.type }));
// @ts-ignore
if (t.hint) {
// @ts-ignore
children.push($el("span", { textContent: t.hint }));
}
// @ts-ignore
if (t.action) {
children.push(
$el("button", {
// @ts-ignore
onclick: t.action.callback,
// @ts-ignore
textContent: t.action.text,
})
);
}
} else {
if (seenTypes.has(t)) return null;
seenTypes.add(t);
// @ts-ignore
children.push($el("span", { textContent: t }));
}
return $el("li", children);
})
.filter(Boolean)
),
...(hasAddedNodes
? [
$el("span", {
textContent:
"Nodes that have failed to load will show as red on the graph.",
}),
]
: []),
])
);
this.logging.addEntry("Comfy.App", "warn", {
MissingNodes: missingNodeTypes,
});
}
async changeWorkflow(callback, workflow = null) {
try {
this.workflowManager.activeWorkflow?.changeTracker?.store();
} catch (error) {
console.error(error);
}
await callback();
try {
this.workflowManager.setWorkflow(workflow);
this.workflowManager.activeWorkflow?.track();
} catch (error) {
console.error(error);
}
}
async loadGraphData(
graphData?: ComfyWorkflowJSON,
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null
) {
if (clean !== false) {
this.clean();
}
let reset_invalid_values = false;
if (!graphData) {
graphData = defaultGraph;
reset_invalid_values = true;
}
if (typeof structuredClone === "undefined") {
graphData = JSON.parse(JSON.stringify(graphData));
} else {
graphData = structuredClone(graphData);
}
try {
this.workflowManager.setWorkflow(workflow);
} catch (error) {
console.error(error);
}
const missingNodeTypes = [];
await this.#invokeExtensionsAsync(
"beforeConfigureGraph",
graphData,
missingNodeTypes
);
for (let n of graphData.nodes) {
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
if (n.type == "ConditioningAverage ") n.type = "ConditioningAverage"; //typo fix
if (n.type == "SDV_img2vid_Conditioning")
n.type = "SVD_img2vid_Conditioning"; //typo fix
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
missingNodeTypes.push(n.type);
n.type = sanitizeNodeName(n.type);
}
}
try {
this.graph.configure(graphData);
if (
restore_view &&
this.enableWorkflowViewRestore.value &&
graphData.extra?.ds
) {
// @ts-ignore
// Need to set strict: true for zod to match the type [number, number]
// https://github.com/colinhacks/zod/issues/3056
this.canvas.ds.offset = graphData.extra.ds.offset;
this.canvas.ds.scale = graphData.extra.ds.scale;
}
try {
this.workflowManager.activeWorkflow?.track();
} catch (error) {
// TODO: Do we want silently fail here?
}
} catch (error) {
let errorHint = [];
// Try extracting filename to see if it was caused by an extension script
const filename =
error.fileName ||
(error.stack || "").match(/(\/extensions\/.*\.js)/)?.[1];
const pos = (filename || "").indexOf("/extensions/");
if (pos > -1) {
errorHint.push(
$el("span", {
textContent: "This may be due to the following script:",
}),
$el("br"),
$el("span", {
style: {
fontWeight: "bold",
},
textContent: filename.substring(pos),
})
);
}
// Show dialog to let the user know something went wrong loading the data
this.ui.dialog.show(
$el("div", [
$el("p", {
textContent: "Loading aborted due to error reloading workflow data",
}),
$el("pre", {
style: { padding: "5px", backgroundColor: "rgba(255,0,0,0.2)" },
textContent: error.toString(),
}),
$el("pre", {
style: {
padding: "5px",
color: "#ccc",
fontSize: "10px",
maxHeight: "50vh",
overflow: "auto",
backgroundColor: "rgba(0,0,0,0.2)",
},
textContent: error.stack || "No stacktrace available",
}),
...errorHint,
]).outerHTML
);
return;
}
// @ts-ignore
for (const node of this.graph._nodes) {
const size = node.computeSize();
size[0] = Math.max(node.size[0], size[0]);
size[1] = Math.max(node.size[1], size[1]);
node.size = size;
// @ts-ignore
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend
// This is the place to do this
// @ts-ignore
for (let widget of node.widgets) {
if (node.type == "KSampler" || node.type == "KSamplerAdvanced") {
if (widget.name == "sampler_name") {
if (widget.value.startsWith("sample_")) {
widget.value = widget.value.slice(7);
}
}
}
if (
node.type == "KSampler" ||
node.type == "KSamplerAdvanced" ||
node.type == "PrimitiveNode"
) {
if (widget.name == "control_after_generate") {
if (widget.value === true) {
widget.value = "randomize";
} else if (widget.value === false) {
widget.value = "fixed";
}
}
}
if (reset_invalid_values) {
if (widget.type == "combo") {
if (
!widget.options.values.includes(widget.value) &&
widget.options.values.length > 0
) {
widget.value = widget.options.values[0];
}
}
}
}
}
this.#invokeExtensions("loadedGraphNode", node);
}
if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes);
}
await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true);
});
}
/**
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
async graphToPrompt(graph = this.graph, clean = true) {
for (const outerNode of this.graph.computeExecutionOrder(false)) {
if (outerNode.widgets) {
for (const widget of outerNode.widgets) {
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
widget.beforeQueued?.();
}
}
const innerNodes = outerNode.getInnerNodes
? outerNode.getInnerNodes()
: [outerNode];
for (const node of innerNodes) {
if (node.isVirtualNode) {
// Don't serialize frontend only nodes but let them make changes
if (node.applyToGraph) {
node.applyToGraph();
}
}
}
}
const workflow = graph.serialize();
const output = {};
// Process nodes in order of execution
for (const outerNode of graph.computeExecutionOrder(false)) {
const skipNode = outerNode.mode === 2 || outerNode.mode === 4;
const innerNodes =
!skipNode && outerNode.getInnerNodes
? outerNode.getInnerNodes()
: [outerNode];
for (const node of innerNodes) {
if (node.isVirtualNode) {
continue;
}
if (node.mode === 2 || node.mode === 4) {
// Don't serialize muted nodes
continue;
}
const inputs = {};
const widgets = node.widgets;
// Store all widget values
if (widgets) {
for (const i in widgets) {
const widget = widgets[i];
if (!widget.options || widget.options.serialize !== false) {
inputs[widget.name] = widget.serializeValue
? await widget.serializeValue(node, i)
: widget.value;
}
}
}
// Store all node links
for (let i in node.inputs) {
let parent = node.getInputNode(i);
if (parent) {
let link = node.getInputLink(i);
while (parent.mode === 4 || parent.isVirtualNode) {
let found = false;
if (parent.isVirtualNode) {
link = parent.getInputLink(link.origin_slot);
if (link) {
parent = parent.getInputNode(link.target_slot);
if (parent) {
found = true;
}
}
} else if (link && parent.mode === 4) {
let all_inputs = [link.origin_slot];
if (parent.inputs) {
all_inputs = all_inputs.concat(Object.keys(parent.inputs));
for (let parent_input in all_inputs) {
parent_input = all_inputs[parent_input];
if (
parent.inputs[parent_input]?.type === node.inputs[i].type
) {
link = parent.getInputLink(parent_input);
if (link) {
parent = parent.getInputNode(parent_input);
}
found = true;
break;
}
}
}
}
if (!found) {
break;
}
}
if (link) {
if (parent?.updateLink) {
link = parent.updateLink(link);
}
if (link) {
inputs[node.inputs[i].name] = [
String(link.origin_id),
parseInt(link.origin_slot),
];
}
}
}
}
let node_data = {
inputs,
class_type: node.comfyClass,
};
if (this.ui.settings.getSettingValue("Comfy.DevMode")) {
// Ignored by the backend.
node_data["_meta"] = {
title: node.title,
};
}
output[String(node.id)] = node_data;
}
}
// Remove inputs connected to removed nodes
if (clean) {
for (const o in output) {
for (const i in output[o].inputs) {
if (
Array.isArray(output[o].inputs[i]) &&
output[o].inputs[i].length === 2 &&
!output[output[o].inputs[i][0]]
) {
delete output[o].inputs[i];
}
}
}
}
return { workflow, output };
}
#formatPromptError(error) {
if (error == null) {
return "(unknown error)";
} else if (typeof error === "string") {
return error;
} else if (error.stack && error.message) {
return error.toString();
} else if (error.response) {
let message = error.response.error.message;
if (error.response.error.details)
message += ": " + error.response.error.details;
for (const [nodeID, nodeError] of Object.entries(
error.response.node_errors
)) {
// @ts-ignore
message += "\n" + nodeError.class_type + ":";
// @ts-ignore
for (const errorReason of nodeError.errors) {
message +=
"\n - " + errorReason.message + ": " + errorReason.details;
}
}
return message;
}
return "(unknown error)";
}
#formatExecutionError(error) {
if (error == null) {
return "(unknown error)";
}
const traceback = error.traceback.join("");
const nodeId = error.node_id;
const nodeType = error.node_type;
return `Error occurred when executing ${nodeType}:\n\n${error.exception_message}\n\n${traceback}`;
}
async queuePrompt(number, batchCount = 1) {
this.#queueItems.push({ number, batchCount });
// Only have one action process the items so each one gets a unique seed correctly
if (this.#processingQueue) {
return;
}
this.#processingQueue = true;
this.lastNodeErrors = null;
try {
while (this.#queueItems.length) {
({ number, batchCount } = this.#queueItems.pop());
for (let i = 0; i < batchCount; i++) {
const p = await this.graphToPrompt();
try {
const res = await api.queuePrompt(number, p);
this.lastNodeErrors = res.node_errors;
if (this.lastNodeErrors.length > 0) {
this.canvas.draw(true, true);
} else {
try {
this.workflowManager.storePrompt({
id: res.prompt_id,
nodes: Object.keys(p.output),
});
} catch (error) {}
}
} catch (error) {
const formattedError = this.#formatPromptError(error);
this.ui.dialog.show(formattedError);
if (error.response) {
this.lastNodeErrors = error.response.node_errors;
this.canvas.draw(true, true);
}
break;
}
for (const n of p.workflow.nodes) {
const node = this.graph.getNodeById(n.id);
if (node.widgets) {
for (const widget of node.widgets) {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
// @ts-ignore
if (widget.afterQueued) {
// @ts-ignore
widget.afterQueued();
}
}
}
}
this.canvas.draw(true, true);
await this.ui.queue.update();
}
}
} finally {
this.#processingQueue = false;
}
api.dispatchEvent(
new CustomEvent("promptQueued", { detail: { number, batchCount } })
);
return !this.lastNodeErrors;
}
showErrorOnFileLoad(file) {
this.ui.dialog.show(
$el("div", [
$el("p", { textContent: `Unable to find workflow in ${file.name}` }),
]).outerHTML
);
}
/**
* Loads workflow data from the specified file
* @param {File} file
*/
async handleFile(file) {
const removeExt = (f) => {
if (!f) return f;
const p = f.lastIndexOf(".");
if (p === -1) return f;
return f.substring(0, p);
};
const fileName = removeExt(file.name);
if (file.type === "image/png") {
const pngInfo = await getPngMetadata(file);
if (pngInfo?.workflow) {
await this.loadGraphData(
await parseComfyWorkflow(pngInfo.workflow),
true,
true,
fileName
);
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName);
} else if (pngInfo?.parameters) {
this.changeWorkflow(() => {
importA1111(this.graph, pngInfo.parameters);
}, fileName);
} else {
this.showErrorOnFileLoad(file);
}
} else if (file.type === "image/webp") {
const pngInfo = await getWebpMetadata(file);
// Support loading workflows from that webp custom node.
const workflow = pngInfo?.workflow || pngInfo?.Workflow;
const prompt = pngInfo?.prompt || pngInfo?.Prompt;
if (workflow) {
this.loadGraphData(
await parseComfyWorkflow(workflow),
true,
true,
fileName
);
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName);
} else {
this.showErrorOnFileLoad(file);
}
} else if (file.type === "audio/flac" || file.type === "audio/x-flac") {
const pngInfo = await getFlacMetadata(file);
const workflow = pngInfo?.workflow || pngInfo?.Workflow;
const prompt = pngInfo?.prompt || pngInfo?.Prompt;
if (workflow) {
this.loadGraphData(
await parseComfyWorkflow(workflow),
true,
true,
fileName
);
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName);
} else {
this.showErrorOnFileLoad(file);
}
} else if (
file.type === "application/json" ||
file.name?.endsWith(".json")
) {
const reader = new FileReader();
reader.onload = async () => {
const readerResult = reader.result as string;
const jsonContent = JSON.parse(readerResult);
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent);
} else if (this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent, fileName);
} else {
await this.loadGraphData(
await parseComfyWorkflow(readerResult),
true,
fileName
);
}
};
reader.readAsText(file);
} else if (
file.name?.endsWith(".latent") ||
file.name?.endsWith(".safetensors")
) {
const info = await getLatentMetadata(file);
// TODO define schema to LatentMetadata
// @ts-ignore
if (info.workflow) {
await this.loadGraphData(
// @ts-ignore
await parseComfyWorkflow(info.workflow),
true,
true,
fileName
);
// @ts-ignore
} else if (info.prompt) {
// @ts-ignore
this.loadApiJson(JSON.parse(info.prompt));
} else {
this.showErrorOnFileLoad(file);
}
} else {
this.showErrorOnFileLoad(file);
}
}
isApiJson(data) {
// @ts-ignore
return Object.values(data).every((v) => v.class_type);
}
loadApiJson(apiData, fileName: string) {
const missingNodeTypes = Object.values(apiData).filter(
// @ts-ignore
(n) => !LiteGraph.registered_node_types[n.class_type]
);
if (missingNodeTypes.length) {
this.showMissingNodesError(
// @ts-ignore
missingNodeTypes.map((t) => t.class_type),
false
);
return;
}
const ids = Object.keys(apiData);
app.graph.clear();
for (const id of ids) {
const data = apiData[id];
const node = LiteGraph.createNode(data.class_type);
// @ts-ignore
node.id = isNaN(+id) ? id : +id;
node.title = data._meta?.title ?? node.title;
app.graph.add(node);
}
this.changeWorkflow(() => {
for (const id of ids) {
const data = apiData[id];
const node = app.graph.getNodeById(Number.parseInt(id));
for (const input in data.inputs ?? {}) {
const value = data.inputs[input];
if (value instanceof Array) {
const [fromId, fromSlot] = value;
const fromNode = app.graph.getNodeById(fromId);
let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
if (toSlot == null || toSlot === -1) {
try {
// Target has no matching input, most likely a converted widget
const widget = node.widgets?.find((w) => w.name === input);
// @ts-ignore
if (widget && node.convertWidgetToInput?.(widget)) {
toSlot = node.inputs?.length - 1;
}
} catch (error) {}
}
if (toSlot != null || toSlot !== -1) {
fromNode.connect(fromSlot, node, toSlot);
}
} else {
const widget = node.widgets?.find((w) => w.name === input);
if (widget) {
widget.value = value;
// @ts-ignore
widget.callback?.(value);
}
}
}
}
app.graph.arrange();
}, fileName);
for (const id of ids) {
const data = apiData[id];
const node = app.graph.getNodeById(Number.parseInt(id));
for (const input in data.inputs ?? {}) {
const value = data.inputs[input];
if (value instanceof Array) {
const [fromId, fromSlot] = value;
const fromNode = app.graph.getNodeById(fromId);
let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
if (toSlot == null || toSlot === -1) {
try {
// Target has no matching input, most likely a converted widget
const widget = node.widgets?.find((w) => w.name === input);
// @ts-ignore
if (widget && node.convertWidgetToInput?.(widget)) {
toSlot = node.inputs?.length - 1;
}
} catch (error) {}
}
if (toSlot != null || toSlot !== -1) {
fromNode.connect(fromSlot, node, toSlot);
}
} else {
const widget = node.widgets?.find((w) => w.name === input);
if (widget) {
widget.value = value;
// @ts-ignore
widget.callback?.(value);
}
}
}
}
app.graph.arrange();
}
/**
* Registers a Comfy web extension with the app
* @param {ComfyExtension} extension
*/
registerExtension(extension) {
if (!extension.name) {
throw new Error("Extensions must have a 'name' property.");
}
if (this.extensions.find((ext) => ext.name === extension.name)) {
throw new Error(
`Extension named '${extension.name}' already registered.`
);
}
this.extensions.push(extension);
}
/**
* Refresh combo list on whole nodes
*/
async refreshComboInNodes() {
const defs = await api.getNodeDefs();
for (const nodeId in defs) {
this.registerNodeDef(nodeId, defs[nodeId]);
}
// @ts-ignore
for (let nodeNum in this.graph._nodes) {
// @ts-ignore
const node = this.graph._nodes[nodeNum];
const def = defs[node.type];
// @ts-ignore
// Allow primitive nodes to handle refresh
node.refreshComboInNode?.(defs);
if (!def) continue;
for (const widgetNum in node.widgets) {
const widget = node.widgets[widgetNum];
if (
widget.type == "combo" &&
def["input"]["required"][widget.name] !== undefined
) {
widget.options.values = def["input"]["required"][widget.name][0];
if (
widget.name != "image" &&
!widget.options.values.includes(widget.value)
) {
widget.value = widget.options.values[0];
// @ts-ignore
widget.callback(widget.value);
}
}
}
}
await this.#invokeExtensionsAsync("refreshComboInNodes", defs);
}
resetView() {
app.canvas.ds.scale = 1;
app.canvas.ds.offset = [0, 0];
app.graph.setDirtyCanvas(true, true);
}
/**
* Clean current state
*/
clean() {
this.nodeOutputs = {};
this.nodePreviewImages = {};
this.lastNodeErrors = null;
this.lastExecutionError = null;
this.runningNodeId = null;
}
}
export const app = new ComfyApp();