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} */ this._nodeOutputs = {}; /** * Stores the preview image data for each node * @type {Record} */ 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 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 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 outputTooltip = nodeData["output_tooltips"]?.[o]; const outputName = nodeData["output_name"][o] || output; const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE ; this.addOutput(outputName, output, { shape: outputShape, tooltip: outputTooltip }); } const s = this.computeSize(); s[0] = Math.max(config.minWidth, s[0] * 1.5); s[1] = Math.max(config.minHeight, s[1]); this.size = s; this.serialize_widgets = true; app.#invokeExtensionsAsync("nodeCreated", this); }, { title: nodeData.display_name || nodeData.name, comfyClass: nodeData.name, nodeData } ); node.prototype.comfyClass = nodeData.name; this.#addNodeContextMenuHandler(node); this.#addDrawBackgroundHandler(node, app); this.#addNodeKeyHandler(node); await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData); LiteGraph.registerNodeType(nodeId, node); node.category = nodeData.category; } async registerNodesFromDefs(defs) { await this.#invokeExtensionsAsync("addCustomNodeDefs", defs); // Generate list of known widgets this.widgets = Object.assign( {}, ComfyWidgets, ...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean) ); // Register a node for each definition for (const nodeId in defs) { this.registerNodeDef(nodeId, defs[nodeId]); } } loadTemplateData(templateData) { if (!templateData?.templates) { return; } const old = localStorage.getItem("litegrapheditor_clipboard"); var maxY, nodeBottom, node; for (const template of templateData.templates) { if (!template?.data) { continue; } localStorage.setItem("litegrapheditor_clipboard", template.data); app.canvas.pasteFromClipboard(); // Move mouse position down to paste the next template below maxY = false; for (const i in app.canvas.selected_nodes) { node = app.canvas.selected_nodes[i]; nodeBottom = node.pos[1] + node.size[1]; if (maxY === false || nodeBottom > maxY) { maxY = nodeBottom; } } app.canvas.graph_mouse[1] = maxY + 50; } localStorage.setItem("litegrapheditor_clipboard", old); } showMissingNodesError(missingNodeTypes, hasAddedNodes = true) { let seenTypes = new Set(); this.ui.dialog.show( $el("div.comfy-missing-nodes", [ $el("span", { textContent: "When loading the graph, the following node types were not found: " }), $el( "ul", Array.from(new Set(missingNodeTypes)).map((t) => { let children = []; if (typeof t === "object") { if(seenTypes.has(t.type)) return null; seenTypes.add(t.type); children.push($el("span", { textContent: t.type })); if (t.hint) { children.push($el("span", { textContent: t.hint })); } if (t.action) { children.push($el("button", { onclick: t.action.callback, textContent: t.action.text })); } } else { if(seenTypes.has(t)) return null; seenTypes.add(t); children.push($el("span", { textContent: t })); } return $el("li", children); }).filter(Boolean) ), ...(hasAddedNodes ? [$el("span", { textContent: "Nodes that have failed to load will show as red on the graph." })] : []), ]) ); this.logging.addEntry("Comfy.App", "warn", { MissingNodes: missingNodeTypes, }); } async changeWorkflow(callback, workflow = null) { try { this.workflowManager.activeWorkflow?.changeTracker?.store() } catch (error) { console.error(error); } await callback(); try { this.workflowManager.setWorkflow(workflow); this.workflowManager.activeWorkflow?.track() } catch (error) { console.error(error); } } /** * Populates the graph with the specified workflow data * @param {*} graphData A serialized graph object * @param { boolean } clean If the graph state, e.g. images, should be cleared * @param { boolean } restore_view If the graph position should be restored * @param { import("./workflows.js").ComfyWorkflowInstance | null } workflow The workflow */ async loadGraphData(graphData, clean = true, restore_view = true, workflow = null) { if (clean !== false) { this.clean(); } let reset_invalid_values = false; if (!graphData) { graphData = defaultGraph; reset_invalid_values = true; } if (typeof structuredClone === "undefined") { graphData = JSON.parse(JSON.stringify(graphData)); }else { graphData = structuredClone(graphData); } try { this.workflowManager.setWorkflow(workflow); } catch (error) { console.error(error); } const missingNodeTypes = []; await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes); for (let n of graphData.nodes) { // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader"; if (n.type == "ConditioningAverage ") n.type = "ConditioningAverage"; //typo fix if (n.type == "SDV_img2vid_Conditioning") n.type = "SVD_img2vid_Conditioning"; //typo fix // Find missing node types if (!(n.type in LiteGraph.registered_node_types)) { missingNodeTypes.push(n.type); n.type = sanitizeNodeName(n.type); } } try { this.graph.configure(graphData); if (restore_view && this.enableWorkflowViewRestore.value && graphData.extra?.ds) { this.canvas.ds.offset = graphData.extra.ds.offset; this.canvas.ds.scale = graphData.extra.ds.scale; } try { this.workflowManager.activeWorkflow?.track() } catch (error) { } } catch (error) { let errorHint = []; // Try extracting filename to see if it was caused by an extension script const filename = error.fileName || (error.stack || "").match(/(\/extensions\/.*\.js)/)?.[1]; const pos = (filename || "").indexOf("/extensions/"); if (pos > -1) { errorHint.push( $el("span", { textContent: "This may be due to the following script:" }), $el("br"), $el("span", { style: { fontWeight: "bold", }, textContent: filename.substring(pos), }) ); } // Show dialog to let the user know something went wrong loading the data this.ui.dialog.show( $el("div", [ $el("p", { textContent: "Loading aborted due to error reloading workflow data" }), $el("pre", { style: { padding: "5px", backgroundColor: "rgba(255,0,0,0.2)" }, textContent: error.toString(), }), $el("pre", { style: { padding: "5px", color: "#ccc", fontSize: "10px", maxHeight: "50vh", overflow: "auto", backgroundColor: "rgba(0,0,0,0.2)", }, textContent: error.stack || "No stacktrace available", }), ...errorHint, ]).outerHTML ); return; } for (const node of this.graph._nodes) { const size = node.computeSize(); size[0] = Math.max(node.size[0], size[0]); size[1] = Math.max(node.size[1], size[1]); node.size = size; if (node.widgets) { // If you break something in the backend and want to patch workflows in the frontend // This is the place to do this for (let widget of node.widgets) { if (node.type == "KSampler" || node.type == "KSamplerAdvanced") { if (widget.name == "sampler_name") { if (widget.value.startsWith("sample_")) { widget.value = widget.value.slice(7); } if (widget.value === "euler_pp" || widget.value === "euler_ancestral_pp") { widget.value = widget.value.slice(0, -3); for (let w of node.widgets) { if (w.name == "cfg") { w.value *= 2.0; } } } } } if (node.type == "KSampler" || node.type == "KSamplerAdvanced" || node.type == "PrimitiveNode") { if (widget.name == "control_after_generate") { if (widget.value === true) { widget.value = "randomize"; } else if (widget.value === false) { widget.value = "fixed"; } } } if (reset_invalid_values) { if (widget.type == "combo") { if (!widget.options.values.includes(widget.value) && widget.options.values.length > 0) { widget.value = widget.options.values[0]; } } } } } this.#invokeExtensions("loadedGraphNode", node); } if (missingNodeTypes.length) { this.showMissingNodesError(missingNodeTypes); } await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes); requestAnimationFrame(() => { this.graph.setDirtyCanvas(true, true); }); } /** * Converts the current graph workflow for sending to the API * @returns The workflow and node links */ async graphToPrompt(graph = this.graph, clean = true) { for (const outerNode of graph.computeExecutionOrder(false)) { if (outerNode.widgets) { for (const widget of outerNode.widgets) { // Allow widgets to run callbacks before a prompt has been queued // e.g. random seed before every gen widget.beforeQueued?.(); } } const innerNodes = outerNode.getInnerNodes ? outerNode.getInnerNodes() : [outerNode]; for (const node of innerNodes) { if (node.isVirtualNode) { // Don't serialize frontend only nodes but let them make changes if (node.applyToGraph) { node.applyToGraph(); } } } } const workflow = graph.serialize(); const output = {}; // Process nodes in order of execution for (const outerNode of graph.computeExecutionOrder(false)) { const skipNode = outerNode.mode === 2 || outerNode.mode === 4; const innerNodes = (!skipNode && outerNode.getInnerNodes) ? outerNode.getInnerNodes() : [outerNode]; for (const node of innerNodes) { if (node.isVirtualNode) { continue; } if (node.mode === 2 || node.mode === 4) { // Don't serialize muted nodes continue; } const inputs = {}; const widgets = node.widgets; // Store all widget values if (widgets) { for (const i in widgets) { const widget = widgets[i]; if (!widget.options || widget.options.serialize !== false) { inputs[widget.name] = widget.serializeValue ? await widget.serializeValue(node, i) : widget.value; } } } // Store all node links for (let i in node.inputs) { let parent = node.getInputNode(i); if (parent) { let link = node.getInputLink(i); while (parent.mode === 4 || parent.isVirtualNode) { let found = false; if (parent.isVirtualNode) { link = parent.getInputLink(link.origin_slot); if (link) { parent = parent.getInputNode(link.target_slot); if (parent) { found = true; } } } else if (link && parent.mode === 4) { let all_inputs = [link.origin_slot]; if (parent.inputs) { all_inputs = all_inputs.concat(Object.keys(parent.inputs)) for (let parent_input in all_inputs) { parent_input = all_inputs[parent_input]; if (parent.inputs[parent_input]?.type === node.inputs[i].type) { link = parent.getInputLink(parent_input); if (link) { parent = parent.getInputNode(parent_input); } found = true; break; } } } } if (!found) { break; } } if (link) { if (parent?.updateLink) { link = parent.updateLink(link); } if (link) { inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)]; } } } } let node_data = { inputs, class_type: node.comfyClass, }; if (this.ui.settings.getSettingValue("Comfy.DevMode")) { // Ignored by the backend. node_data["_meta"] = { title: node.title, } } output[String(node.id)] = node_data; } } // Remove inputs connected to removed nodes if(clean) { for (const o in output) { for (const i in output[o].inputs) { if (Array.isArray(output[o].inputs[i]) && output[o].inputs[i].length === 2 && !output[output[o].inputs[i][0]]) { delete output[o].inputs[i]; } } } } return { workflow, output }; } #formatPromptError(error) { if (error == null) { return "(unknown error)" } else if (typeof error === "string") { return error; } else if (error.stack && error.message) { return error.toString() } else if (error.response) { let message = error.response.error.message; if (error.response.error.details) message += ": " + error.response.error.details; for (const [nodeID, nodeError] of Object.entries(error.response.node_errors)) { message += "\n" + nodeError.class_type + ":" for (const errorReason of nodeError.errors) { message += "\n - " + errorReason.message + ": " + errorReason.details } } return message } return "(unknown error)" } #formatExecutionError(error) { if (error == null) { return "(unknown error)" } const traceback = error.traceback.join("") const nodeId = error.node_id const nodeType = error.node_type return `Error occurred when executing ${nodeType}:\n\n${error.exception_message}\n\n${traceback}` } async queuePrompt(number, batchCount = 1) { this.#queueItems.push({ number, batchCount }); // Only have one action process the items so each one gets a unique seed correctly if (this.#processingQueue) { return; } this.#processingQueue = true; this.lastNodeErrors = null; try { while (this.#queueItems.length) { ({ number, batchCount } = this.#queueItems.pop()); for (let i = 0; i < batchCount; i++) { const p = await this.graphToPrompt(); try { const res = await api.queuePrompt(number, p); this.lastNodeErrors = res.node_errors; if (this.lastNodeErrors.length > 0) { this.canvas.draw(true, true); } else { try { this.workflowManager.storePrompt({ id: res.prompt_id, nodes: Object.keys(p.output) }); } catch (error) { } } } catch (error) { const formattedError = this.#formatPromptError(error) this.ui.dialog.show(formattedError); if (error.response) { this.lastNodeErrors = error.response.node_errors; this.canvas.draw(true, true); } break; } for (const n of p.workflow.nodes) { const node = graph.getNodeById(n.id); if (node.widgets) { for (const widget of node.widgets) { // Allow widgets to run callbacks after a prompt has been queued // e.g. random seed after every gen if (widget.afterQueued) { widget.afterQueued(); } } } } this.canvas.draw(true, true); await this.ui.queue.update(); } } } finally { this.#processingQueue = false; } api.dispatchEvent(new CustomEvent("promptQueued", { detail: { number, batchCount } })); return !this.lastNodeErrors; } showErrorOnFileLoad(file) { this.ui.dialog.show( $el("div", [ $el("p", {textContent: `Unable to find workflow in ${file.name}`}) ]).outerHTML ); } /** * Loads workflow data from the specified file * @param {File} file */ async handleFile(file) { const removeExt = f => { if(!f) return f; const p = f.lastIndexOf("."); if(p === -1) return f; return f.substring(0, p); }; const fileName = removeExt(file.name); if (file.type === "image/png") { const pngInfo = await getPngMetadata(file); if (pngInfo?.workflow) { await this.loadGraphData(JSON.parse(pngInfo.workflow), true, true, fileName); } else if (pngInfo?.prompt) { this.loadApiJson(JSON.parse(pngInfo.prompt), fileName); } else if (pngInfo?.parameters) { this.changeWorkflow(() => { importA1111(this.graph, pngInfo.parameters); }, fileName) } else { this.showErrorOnFileLoad(file); } } else if (file.type === "image/webp") { const pngInfo = await getWebpMetadata(file); // Support loading workflows from that webp custom node. const workflow = pngInfo?.workflow || pngInfo?.Workflow; const prompt = pngInfo?.prompt || pngInfo?.Prompt; if (workflow) { this.loadGraphData(JSON.parse(workflow), true, true, fileName); } else if (prompt) { this.loadApiJson(JSON.parse(prompt), fileName); } else { this.showErrorOnFileLoad(file); } } else if (file.type === "audio/flac" || file.type === "audio/x-flac") { const pngInfo = await getFlacMetadata(file); // Support loading workflows from that webp custom node. const workflow = pngInfo?.workflow; const prompt = pngInfo?.prompt; if (workflow) { this.loadGraphData(JSON.parse(workflow), true, true, fileName); } else if (prompt) { this.loadApiJson(JSON.parse(prompt), fileName); } else { this.showErrorOnFileLoad(file); } } else if (file.type === "application/json" || file.name?.endsWith(".json")) { const reader = new FileReader(); reader.onload = async () => { const jsonContent = JSON.parse(reader.result); if (jsonContent?.templates) { this.loadTemplateData(jsonContent); } else if(this.isApiJson(jsonContent)) { this.loadApiJson(jsonContent, fileName); } else { await this.loadGraphData(jsonContent, true, true, fileName); } }; reader.readAsText(file); } else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) { const info = await getLatentMetadata(file); if (info.workflow) { await this.loadGraphData(JSON.parse(info.workflow), true, true, fileName); } else if (info.prompt) { this.loadApiJson(JSON.parse(info.prompt)); } else { this.showErrorOnFileLoad(file); } } else { this.showErrorOnFileLoad(file); } } isApiJson(data) { return Object.values(data).every((v) => v.class_type); } loadApiJson(apiData, fileName) { const missingNodeTypes = Object.values(apiData).filter((n) => !LiteGraph.registered_node_types[n.class_type]); if (missingNodeTypes.length) { this.showMissingNodesError(missingNodeTypes.map(t => t.class_type), false); return; } const ids = Object.keys(apiData); app.graph.clear(); for (const id of ids) { const data = apiData[id]; const node = LiteGraph.createNode(data.class_type); node.id = isNaN(+id) ? id : +id; node.title = data._meta?.title ?? node.title app.graph.add(node); } this.changeWorkflow(() => { for (const id of ids) { const data = apiData[id]; const node = app.graph.getNodeById(id); for (const input in data.inputs ?? {}) { const value = data.inputs[input]; if (value instanceof Array) { const [fromId, fromSlot] = value; const fromNode = app.graph.getNodeById(fromId); let toSlot = node.inputs?.findIndex((inp) => inp.name === input); if (toSlot == null || toSlot === -1) { try { // Target has no matching input, most likely a converted widget const widget = node.widgets?.find((w) => w.name === input); if (widget && node.convertWidgetToInput?.(widget)) { toSlot = node.inputs?.length - 1; } } catch (error) {} } if (toSlot != null || toSlot !== -1) { fromNode.connect(fromSlot, node, toSlot); } } else { const widget = node.widgets?.find((w) => w.name === input); if (widget) { widget.value = value; widget.callback?.(value); } } } } app.graph.arrange(); }, fileName); } /** * Registers a Comfy web extension with the app * @param {ComfyExtension} extension */ registerExtension(extension) { if (!extension.name) { throw new Error("Extensions must have a 'name' property."); } if (this.extensions.find((ext) => ext.name === extension.name)) { throw new Error(`Extension named '${extension.name}' already registered.`); } this.extensions.push(extension); } /** * Refresh combo list on whole nodes */ async refreshComboInNodes() { const defs = await api.getNodeDefs(); for (const nodeId in defs) { this.registerNodeDef(nodeId, defs[nodeId]); } for(let nodeNum in this.graph._nodes) { const node = this.graph._nodes[nodeNum]; const def = defs[node.type]; // Allow primitive nodes to handle refresh node.refreshComboInNode?.(defs); if(!def) continue; for(const widgetNum in node.widgets) { const widget = node.widgets[widgetNum] if(widget.type == "combo" && def["input"]["required"][widget.name] !== undefined) { widget.options.values = def["input"]["required"][widget.name][0]; if(widget.name != 'image' && !widget.options.values.includes(widget.value)) { widget.value = widget.options.values[0]; widget.callback(widget.value); } } } } await this.#invokeExtensionsAsync("refreshComboInNodes", defs); } resetView() { app.canvas.ds.scale = 1; app.canvas.ds.offset = [0, 0] app.graph.setDirtyCanvas(true, true); } /** * Clean current state */ clean() { this.nodeOutputs = {}; this.nodePreviewImages = {} this.lastNodeErrors = null; this.lastExecutionError = null; this.runningNodeId = null; } } export const app = new ComfyApp();