mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2025-01-11 10:25:16 +00:00
5cfe38f41c
* Execution Model Inversion This PR inverts the execution model -- from recursively calling nodes to using a topological sort of the nodes. This change allows for modification of the node graph during execution. This allows for two major advantages: 1. The implementation of lazy evaluation in nodes. For example, if a "Mix Images" node has a mix factor of exactly 0.0, the second image input doesn't even need to be evaluated (and visa-versa if the mix factor is 1.0). 2. Dynamic expansion of nodes. This allows for the creation of dynamic "node groups". Specifically, custom nodes can return subgraphs that replace the original node in the graph. This is an incredibly powerful concept. Using this functionality, it was easy to implement: a. Components (a.k.a. node groups) b. Flow control (i.e. while loops) via tail recursion c. All-in-one nodes that replicate the WebUI functionality d. and more All of those were able to be implemented entirely via custom nodes, so those features are *not* a part of this PR. (There are some front-end changes that should occur before that functionality is made widely available, particularly around variant sockets.) The custom nodes associated with this PR can be found at: https://github.com/BadCafeCode/execution-inversion-demo-comfyui Note that some of them require that variant socket types ("*") be enabled. * Allow `input_info` to be of type `None` * Handle errors (like OOM) more gracefully * Add a command-line argument to enable variants This allows the use of nodes that have sockets of type '*' without applying a patch to the code. * Fix an overly aggressive assertion. This could happen when attempting to evaluate `IS_CHANGED` for a node during the creation of the cache (in order to create the cache key). * Fix Pyright warnings * Add execution model unit tests * Fix issue with unused literals Behavior should now match the master branch with regard to undeclared inputs. Undeclared inputs that are socket connections will be used while undeclared inputs that are literals will be ignored. * Make custom VALIDATE_INPUTS skip normal validation Additionally, if `VALIDATE_INPUTS` takes an argument named `input_types`, that variable will be a dictionary of the socket type of all incoming connections. If that argument exists, normal socket type validation will not occur. This removes the last hurdle for enabling variant types entirely from custom nodes, so I've removed that command-line option. I've added appropriate unit tests for these changes. * Fix example in unit test This wouldn't have caused any issues in the unit test, but it would have bugged the UI if someone copy+pasted it into their own node pack. * Use fstrings instead of '%' formatting syntax * Use custom exception types. * Display an error for dependency cycles Previously, dependency cycles that were created during node expansion would cause the application to quit (due to an uncaught exception). Now, we'll throw a proper error to the UI. We also make an attempt to 'blame' the most relevant node in the UI. * Add docs on when ExecutionBlocker should be used * Remove unused functionality * Rename ExecutionResult.SLEEPING to PENDING * Remove superfluous function parameter * Pass None for uneval inputs instead of default This applies to `VALIDATE_INPUTS`, `check_lazy_status`, and lazy values in evaluation functions. * Add a test for mixed node expansion This test ensures that a node that returns a combination of expanded subgraphs and literal values functions correctly. * Raise exception for bad get_node calls. * Minor refactor of IsChangedCache.get * Refactor `map_node_over_list` function * Fix ui output for duplicated nodes * Add documentation on `check_lazy_status` * Add file for execution model unit tests * Clean up Javascript code as per review * Improve documentation Converted some comments to docstrings as per review * Add a new unit test for mixed lazy results This test validates that when an output list is fed to a lazy node, the node will properly evaluate previous nodes that are needed by any inputs to the lazy node. No code in the execution model has been changed. The test already passes. * Allow kwargs in VALIDATE_INPUTS functions When kwargs are used, validation is skipped for all inputs as if they had been mentioned explicitly. * List cached nodes in `execution_cached` message This was previously just bugged in this PR.
2461 lines
67 KiB
JavaScript
2461 lines
67 KiB
JavaScript
import { ComfyLogging } from "./logging.js";
|
|
import { ComfyWidgets, initWidgets } from "./widgets.js";
|
|
import { ComfyUI, $el } from "./ui.js";
|
|
import { api } from "./api.js";
|
|
import { defaultGraph } from "./defaultGraph.js";
|
|
import { getPngMetadata, getWebpMetadata, getFlacMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
|
|
import { addDomClippingSetting } from "./domWidget.js";
|
|
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js";
|
|
import { ComfyAppMenu } from "./ui/menu/index.js";
|
|
import { getStorageValue, setStorageValue } from "./utils.js";
|
|
import { ComfyWorkflowManager } from "./workflows.js";
|
|
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;
|
|
|
|
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(obj => obj.type === type && obj.name === name);
|
|
if (prop && prop.type != 'button') {
|
|
if(prop.type != 'image' && typeof prop.value == "string" && value.filename) {
|
|
prop.value = (value.subfolder?value.subfolder+'/':'') + value.filename + (value.type?` [${value.type}]`:'');
|
|
}
|
|
else {
|
|
prop.value = value;
|
|
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() {
|
|
const serialize = LGraph.prototype.serialize;
|
|
const self = this;
|
|
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,
|
|
});
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
|
|
function 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};
|
|
}
|
|
|
|
function 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
|
|
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);
|
|
const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY);
|
|
if (node) {
|
|
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) => {
|
|
// ctrl+shift+v is used to paste nodes with connections
|
|
// this is handled by litegraph
|
|
if(this.shiftDown) return;
|
|
|
|
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;
|
|
try {
|
|
data = data.slice(data.indexOf("{"));
|
|
workflow = JSON.parse(data);
|
|
} catch (err) {
|
|
try {
|
|
data = data.slice(data.indexOf("workflow\n"));
|
|
data = data.slice(data.indexOf("{"));
|
|
workflow = JSON.parse(data);
|
|
} catch (error) {}
|
|
}
|
|
|
|
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
|
|
await this.loadGraphData(workflow);
|
|
}
|
|
else {
|
|
if (e.target.type === "text" || e.target.type === "textarea") {
|
|
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.type === "text" || e.target.type === "textarea") {
|
|
// Default system copy
|
|
return;
|
|
}
|
|
|
|
// copy nodes and clear clipboard
|
|
if (e.target.className === "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;
|
|
|
|
const origProcessMouseDown = LGraphCanvas.prototype.processMouseDown;
|
|
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(e.canvasX, 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;
|
|
}
|
|
|
|
const origProcessMouseMove = LGraphCanvas.prototype.processMouseMove;
|
|
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;
|
|
const origProcessKey = LGraphCanvas.prototype.processKey;
|
|
LGraphCanvas.prototype.processKey = function(e) {
|
|
if (!this.graph) {
|
|
return;
|
|
}
|
|
|
|
var block_default = false;
|
|
|
|
if (e.target.localName == "input") {
|
|
return;
|
|
}
|
|
|
|
if (e.type == "keydown" && !e.repeat) {
|
|
|
|
// Ctrl + M mute/unmute
|
|
if (e.key === 'm' && (e.metaKey || 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.metaKey || 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;
|
|
|
|
const origDrawGroups = LGraphCanvas.prototype.drawGroups;
|
|
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() {
|
|
const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape;
|
|
const self = this;
|
|
|
|
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 = 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;
|
|
};
|
|
|
|
const origDrawNode = LGraphCanvas.prototype.drawNode;
|
|
LGraphCanvas.prototype.drawNode = function (node, ctx) {
|
|
var editor_alpha = this.editor_alpha;
|
|
var old_color = node.bgcolor;
|
|
|
|
if (node.mode === 2) { // never
|
|
this.editor_alpha = 0.4;
|
|
}
|
|
|
|
if (node.mode === 4) { // never
|
|
node.bgcolor = "#FF00FF";
|
|
this.editor_alpha = 0.2;
|
|
}
|
|
|
|
const res = origDrawNode.apply(this, arguments);
|
|
|
|
this.editor_alpha = editor_alpha;
|
|
node.bgcolor = old_color;
|
|
|
|
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.display_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.display_node] = detail.output;
|
|
}
|
|
const node = this.graph.getNodeById(detail.display_node);
|
|
if (node) {
|
|
if (node.onExecuted)
|
|
node.onExecuted(detail.output);
|
|
}
|
|
});
|
|
|
|
api.addEventListener("execution_start", ({ detail }) => {
|
|
this.runningNodeId = null;
|
|
this.lastExecutionError = null
|
|
this.graph._nodes.forEach((node) => {
|
|
if (node.onExecutionStart)
|
|
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)
|
|
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;
|
|
const configure = LGraph.prototype.configure;
|
|
// Flag that the graph is configuring to prevent nodes from running checks while its still loading
|
|
LGraph.prototype.configure = function () {
|
|
app.configuringGraph = true;
|
|
try {
|
|
return configure.apply(this, arguments);
|
|
} finally {
|
|
app.configuringGraph = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
#addAfterConfigureHandler() {
|
|
const app = this;
|
|
const onConfigure = app.graph.onConfigure;
|
|
app.graph.onConfigure = function () {
|
|
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
|
for (const node of app.graph._nodes) {
|
|
node.onGraphConfigured?.();
|
|
}
|
|
|
|
const r = onConfigure?.apply(this, arguments);
|
|
|
|
// Fire after onConfigure, used by primitves to generate widget using input nodes config
|
|
for (const node of app.graph._nodes) {
|
|
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 });
|
|
|
|
const extensionPromises = extensions.map(async ext => {
|
|
try {
|
|
await import(api.apiURL(ext));
|
|
} catch (error) {
|
|
console.error("Error loading extension", ext, error);
|
|
}
|
|
});
|
|
|
|
await Promise.all(extensionPromises);
|
|
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.js");
|
|
|
|
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();
|
|
},
|
|
}),
|
|
]),
|
|
]);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up the app on the page
|
|
*/
|
|
async setup() {
|
|
await this.#setUser();
|
|
|
|
// Create and mount the LiteGraph in the DOM
|
|
const mainCanvas = document.createElement("canvas")
|
|
mainCanvas.style.touchAction = "none"
|
|
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, { id: "graph-canvas" }));
|
|
canvasEl.tabIndex = "1";
|
|
document.body.append(canvasEl);
|
|
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 = JSON.parse(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 = "";
|
|
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() {
|
|
const app = this;
|
|
// Load node definitions from the backend
|
|
const defs = await api.getNodeDefs();
|
|
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, nodeData) {
|
|
const self = this;
|
|
const node = Object.assign(
|
|
function ComfyNode() {
|
|
var inputs = nodeData["input"]["required"];
|
|
if (nodeData["input"]["optional"] != undefined) {
|
|
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"]);
|
|
}
|
|
const config = { minWidth: 1, minHeight: 1 };
|
|
for (const inputName in inputs) {
|
|
const inputData = inputs[inputName];
|
|
const type = inputData[0];
|
|
|
|
let widgetCreated = true;
|
|
const widgetType = self.getWidgetType(inputData, inputName);
|
|
if(widgetType) {
|
|
if(widgetType === "COMBO") {
|
|
Object.assign(config, self.widgets.COMBO(this, inputName, inputData, app) || {});
|
|
} else {
|
|
Object.assign(config, self.widgets[widgetType](this, inputName, inputData, app) || {});
|
|
}
|
|
} else {
|
|
// Node connection inputs
|
|
this.addInput(inputName, type);
|
|
widgetCreated = false;
|
|
}
|
|
|
|
if(widgetCreated && inputData[1]?.forceInput && config?.widget) {
|
|
if (!config.widget.options) config.widget.options = {};
|
|
config.widget.options.forceInput = inputData[1].forceInput;
|
|
}
|
|
if(widgetCreated && inputData[1]?.defaultInput && config?.widget) {
|
|
if (!config.widget.options) config.widget.options = {};
|
|
config.widget.options.defaultInput = inputData[1].defaultInput;
|
|
}
|
|
}
|
|
|
|
for (const o in nodeData["output"]) {
|
|
let output = nodeData["output"][o];
|
|
if(output instanceof Array) output = "COMBO";
|
|
const outputTooltip = nodeData["output_tooltips"]?.[o];
|
|
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, tooltip: outputTooltip });
|
|
}
|
|
|
|
const s = this.computeSize();
|
|
s[0] = Math.max(config.minWidth, s[0] * 1.5);
|
|
s[1] = Math.max(config.minHeight, s[1]);
|
|
this.size = s;
|
|
this.serialize_widgets = true;
|
|
|
|
app.#invokeExtensionsAsync("nodeCreated", this);
|
|
},
|
|
{
|
|
title: nodeData.display_name || nodeData.name,
|
|
comfyClass: nodeData.name,
|
|
nodeData
|
|
}
|
|
);
|
|
node.prototype.comfyClass = nodeData.name;
|
|
|
|
this.#addNodeContextMenuHandler(node);
|
|
this.#addDrawBackgroundHandler(node, app);
|
|
this.#addNodeKeyHandler(node);
|
|
|
|
await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
|
|
LiteGraph.registerNodeType(nodeId, node);
|
|
node.category = nodeData.category;
|
|
}
|
|
|
|
async registerNodesFromDefs(defs) {
|
|
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") {
|
|
if(seenTypes.has(t.type)) return null;
|
|
seenTypes.add(t.type);
|
|
children.push($el("span", { textContent: t.type }));
|
|
if (t.hint) {
|
|
children.push($el("span", { textContent: t.hint }));
|
|
}
|
|
if (t.action) {
|
|
children.push($el("button", { onclick: t.action.callback, textContent: t.action.text }));
|
|
}
|
|
} else {
|
|
if(seenTypes.has(t)) return null;
|
|
seenTypes.add(t);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Populates the graph with the specified workflow data
|
|
* @param {*} graphData A serialized graph object
|
|
* @param { boolean } clean If the graph state, e.g. images, should be cleared
|
|
* @param { boolean } restore_view If the graph position should be restored
|
|
* @param { import("./workflows.js").ComfyWorkflowInstance | null } workflow The workflow
|
|
*/
|
|
async loadGraphData(graphData, clean = true, restore_view = true, workflow = 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) {
|
|
this.canvas.ds.offset = graphData.extra.ds.offset;
|
|
this.canvas.ds.scale = graphData.extra.ds.scale;
|
|
}
|
|
|
|
try {
|
|
this.workflowManager.activeWorkflow?.track()
|
|
} catch (error) {
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
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;
|
|
|
|
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
|
|
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 (widget.value === "euler_pp" || widget.value === "euler_ancestral_pp") {
|
|
widget.value = widget.value.slice(0, -3);
|
|
for (let w of node.widgets) {
|
|
if (w.name == "cfg") {
|
|
w.value *= 2.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 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)) {
|
|
message += "\n" + nodeError.class_type + ":"
|
|
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 = 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
|
|
if (widget.afterQueued) {
|
|
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(JSON.parse(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(JSON.parse(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);
|
|
// Support loading workflows from that webp custom node.
|
|
const workflow = pngInfo?.workflow;
|
|
const prompt = pngInfo?.prompt;
|
|
|
|
if (workflow) {
|
|
this.loadGraphData(JSON.parse(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 jsonContent = JSON.parse(reader.result);
|
|
if (jsonContent?.templates) {
|
|
this.loadTemplateData(jsonContent);
|
|
} else if(this.isApiJson(jsonContent)) {
|
|
this.loadApiJson(jsonContent, fileName);
|
|
} else {
|
|
await this.loadGraphData(jsonContent, true, true, fileName);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
|
|
const info = await getLatentMetadata(file);
|
|
if (info.workflow) {
|
|
await this.loadGraphData(JSON.parse(info.workflow), true, true, fileName);
|
|
} else if (info.prompt) {
|
|
this.loadApiJson(JSON.parse(info.prompt));
|
|
} else {
|
|
this.showErrorOnFileLoad(file);
|
|
}
|
|
} else {
|
|
this.showErrorOnFileLoad(file);
|
|
}
|
|
}
|
|
|
|
isApiJson(data) {
|
|
return Object.values(data).every((v) => v.class_type);
|
|
}
|
|
|
|
loadApiJson(apiData, fileName) {
|
|
const missingNodeTypes = Object.values(apiData).filter((n) => !LiteGraph.registered_node_types[n.class_type]);
|
|
if (missingNodeTypes.length) {
|
|
this.showMissingNodesError(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);
|
|
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(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);
|
|
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;
|
|
widget.callback?.(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
app.graph.arrange();
|
|
}, fileName);
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
|
|
for(let nodeNum in this.graph._nodes) {
|
|
const node = this.graph._nodes[nodeNum];
|
|
const def = defs[node.type];
|
|
|
|
// 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];
|
|
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();
|