2460 lines
67 KiB
JavaScript
2460 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.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) {
|
|
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 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, 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();
|