Spaces:
Running
on
Zero
Running
on
Zero
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(); | |