Files
ComfyUI_frontend/src/scripts/app.ts

2072 lines
61 KiB
TypeScript

// @ts-strict-ignore
import { type ComfyWidgetConstructor, ComfyWidgets } from './widgets'
import { ComfyUI, $el } from './ui'
import { api, type ComfyApi } from './api'
import { defaultGraph } from './defaultGraph'
import {
getPngMetadata,
getWebpMetadata,
getFlacMetadata,
importA1111,
getLatentMetadata
} from './pnginfo'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import {
type ComfyWorkflowJSON,
type NodeId,
validateComfyWorkflow
} from '@/types/comfyWorkflow'
import type { ComfyNodeDef } from '@/types/apiTypes'
import { adjustColor, ColorAdjustOptions } from '@/utils/colorUtil'
import { ComfyAppMenu } from './ui/menu/index'
import { getStorageValue } from './utils'
import { ComfyWorkflow } from '@/stores/workflowStore'
import {
LGraphCanvas,
LGraph,
LGraphNode,
LiteGraph,
LGraphEventMode
} from '@comfyorg/litegraph'
import { ExtensionManager } from '@/types/extensionTypes'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { Vector2 } from '@comfyorg/litegraph'
import _ from 'lodash'
import { useDialogService } from '@/services/dialogService'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useModelStore } from '@/stores/modelStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { shallowReactive } from 'vue'
import { useWorkflowService } from '@/services/workflowService'
import { useWidgetStore } from '@/stores/widgetStore'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { st } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
function sanitizeNodeName(string) {
let entityMap = {
'&': '',
'<': '',
'>': '',
'"': '',
"'": '',
'`': '',
'=': ''
}
return String(string).replace(/[&<>"'`=]/g, function fromEntityMap(s) {
return entityMap[s]
})
}
type Clipspace = {
widgets?: { type?: string; name?: string; value?: any }[] | null
imgs?: HTMLImageElement[] | null
original_imgs?: HTMLImageElement[] | null
images?: any[] | null
selectedIndex: number
img_paste_mode: string
}
/**
* @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: Clipspace | null = null
static clipspace_invalidate_handler: (() => void) | null = null
static open_maskeditor = null
static clipspace_return_node = null
vueAppReady: boolean
api: ComfyApi
ui: ComfyUI
extensionManager: ExtensionManager
_nodeOutputs: Record<string, any>
nodePreviewImages: Record<string, string[]>
graph: LGraph
canvas: LGraphCanvas
dragOverNode: LGraphNode | null
canvasEl: HTMLCanvasElement
// x, y, scale
zoom_drag_start: [number, number, number] | null
lastNodeErrors: any[] | null
/** @type {ExecutionErrorWsMessage} */
lastExecutionError: { node_id?: NodeId } | null
configuringGraph: boolean
ctx: CanvasRenderingContext2D
bodyTop: HTMLElement
bodyLeft: HTMLElement
bodyRight: HTMLElement
bodyBottom: HTMLElement
canvasContainer: HTMLElement
menu: ComfyAppMenu
bypassBgColor: string
// Set by Comfy.Clipspace extension
openClipspace: () => void = () => {}
/**
* @deprecated Use useExecutionStore().executingNodeId instead
*/
get runningNodeId(): string | null {
return useExecutionStore().executingNodeId
}
/**
* @deprecated Use useWorkspaceStore().shiftDown instead
*/
get shiftDown(): boolean {
return useWorkspaceStore().shiftDown
}
/**
* @deprecated Use useWidgetStore().widgets instead
*/
get widgets(): Record<string, ComfyWidgetConstructor> {
if (this.vueAppReady) {
return useWidgetStore().widgets
}
return ComfyWidgets
}
/**
* @deprecated storageLocation is always 'server' since
* https://github.com/comfyanonymous/ComfyUI/commit/53c8a99e6c00b5e20425100f6680cd9ea2652218
*/
get storageLocation() {
return 'server'
}
/**
* @deprecated storage migration is no longer needed.
*/
get isNewUserSession() {
return false
}
/**
* @deprecated Use useExtensionStore().extensions instead
*/
get extensions(): ComfyExtension[] {
return useExtensionStore().extensions
}
/**
* The progress on the current executing node, if the node reports any.
* @deprecated Use useExecutionStore().executingNodeProgress instead
*/
get progress() {
return useExecutionStore()._executingNodeProgress
}
constructor() {
this.vueAppReady = false
this.ui = new ComfyUI(this)
this.api = api
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.canvasContainer = $el('div.graph-canvas-container', {
parent: document.body
})
this.menu = new ComfyAppMenu(this)
this.bypassBgColor = '#FF00FF'
/**
* 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 = {}
}
get nodeOutputs() {
return this._nodeOutputs
}
set nodeOutputs(value) {
this._nodeOutputs = value
if (this.vueAppReady)
useExtensionService().invokeExtensions('onNodeOutputsUpdated', value)
}
getPreviewFormatParam() {
let preview_format = this.ui.settings.getSettingValue('Comfy.PreviewFormat')
if (preview_format) return `&preview=${preview_format}`
else return ''
}
getRandParam() {
return '&rand=' + Math.random()
}
static isImageNode(node) {
return (
node.imgs ||
(node &&
node.widgets &&
node.widgets.findIndex((obj) => obj.name === 'image') >= 0)
)
}
static onClipspaceEditorSave() {
if (ComfyApp.clipspace_return_node) {
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)
}
}
static onClipspaceEditorClosed() {
ComfyApp.clipspace_return_node = null
}
static copyToClipspace(node) {
var widgets = null
if (node.widgets) {
widgets = node.widgets.map(({ type, name, value }) => ({
type,
name,
value
}))
}
var imgs = undefined
var orig_imgs = undefined
if (node.imgs != undefined) {
imgs = []
orig_imgs = []
for (let i = 0; i < node.imgs.length; i++) {
imgs[i] = new Image()
imgs[i].src = node.imgs[i].src
orig_imgs[i] = imgs[i]
}
}
var selectedIndex = 0
if (node.imageIndex) {
selectedIndex = node.imageIndex
}
ComfyApp.clipspace = {
widgets: widgets,
imgs: imgs,
original_imgs: orig_imgs,
images: node.images,
selectedIndex: selectedIndex,
img_paste_mode: 'selected' // reset to default im_paste_mode state on copy action
}
ComfyApp.clipspace_return_node = null
if (ComfyApp.clipspace_invalidate_handler) {
ComfyApp.clipspace_invalidate_handler()
}
}
static pasteFromClipspace(node) {
if (ComfyApp.clipspace) {
// image paste
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
node.images = [
ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']]
]
} else {
node.images = ComfyApp.clipspace.images
}
if (app.nodeOutputs[node.id + ''])
app.nodeOutputs[node.id + ''].images = node.images
}
if (ComfyApp.clipspace.imgs) {
// deep-copy to cut link with clipspace
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
const img = new Image()
img.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
node.imgs = [img]
node.imageIndex = 0
} else {
const imgs = []
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
imgs[i] = new Image()
imgs[i].src = ComfyApp.clipspace.imgs[i].src
node.imgs = imgs
}
}
}
}
if (node.widgets) {
if (ComfyApp.clipspace.images) {
const clip_image =
ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']]
const index = node.widgets.findIndex((obj) => obj.name === 'image')
if (index >= 0) {
if (
node.widgets[index].type != 'image' &&
typeof node.widgets[index].value == 'string' &&
clip_image.filename
) {
node.widgets[index].value =
(clip_image.subfolder ? clip_image.subfolder + '/' : '') +
clip_image.filename +
(clip_image.type ? ` [${clip_image.type}]` : '')
} else {
node.widgets[index].value = clip_image
}
}
}
if (ComfyApp.clipspace.widgets) {
ComfyApp.clipspace.widgets.forEach(({ type, name, value }) => {
const prop = Object.values(node.widgets).find(
// @ts-expect-errorg
(obj) => obj.type === type && obj.name === name
)
// @ts-expect-error
if (prop && prop.type != 'button') {
if (
// @ts-expect-error
prop.type != 'image' &&
// @ts-expect-error
typeof prop.value == 'string' &&
value.filename
) {
// @ts-expect-error
prop.value =
(value.subfolder ? value.subfolder + '/' : '') +
value.filename +
(value.type ? ` [${value.type}]` : '')
} else {
// @ts-expect-error
prop.value = value
// @ts-expect-error
prop.callback(value)
}
}
})
}
}
app.graph.setDirtyCanvas(true)
}
}
#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 (useSettingStore().get('Comfy.EnableWorkflowViewRestore')) {
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
}
}
/**
* Adds a handler allowing drag+drop of files onto the window to load workflows
*/
#addDropHandler() {
// Get prompt from dropped PNG or json
document.addEventListener('drop', async (event) => {
event.preventDefault()
event.stopPropagation()
const n = this.dragOverNode
this.dragOverNode = null
// Node handles file drop, we dont use the built in onDropFile handler as its buggy
// If you drag multiple files it will call it multiple times with the same file
// @ts-expect-error This is not a standard event. TODO fix it.
if (n && n.onDragDrop && (await n.onDragDrop(event))) {
return
}
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
if (
event.dataTransfer.files.length &&
event.dataTransfer.files[0].type !== 'image/bmp'
) {
await this.handleFile(event.dataTransfer.files[0])
} else {
// Try loading the first URI in the transfer list
const validTypes = ['text/uri-list', 'text/x-moz-url']
const match = [...event.dataTransfer.types].find((t) =>
validTypes.find((v) => t === v)
)
if (match) {
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
if (uri) {
await this.handleFile(await (await fetch(uri)).blob())
}
}
}
})
// Always clear over node on drag leave
this.canvasEl.addEventListener('dragleave', async () => {
if (this.dragOverNode) {
this.dragOverNode = null
this.graph.setDirtyCanvas(false, true)
}
})
// Add handler for dropping onto a specific node
this.canvasEl.addEventListener(
'dragover',
(e) => {
this.canvas.adjustMouseEvent(e)
const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY)
if (node) {
// @ts-expect-error This is not a standard event. TODO fix it.
if (node.onDragOver && node.onDragOver(e)) {
this.dragOverNode = node
// dragover event is fired very frequently, run this on an animation frame
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(false, true)
})
return
}
}
this.dragOverNode = null
},
false
)
}
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
#addPasteHandler() {
document.addEventListener('paste', async (e: ClipboardEvent) => {
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph
if (this.shiftDown) return
// @ts-expect-error: Property 'clipboardData' does not exist on type 'Window & typeof globalThis'.
// Did you mean 'Clipboard'?ts(2551)
// TODO: Not sure what the code wants to do.
let data = e.clipboardData || window.clipboardData
const items = data.items
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
var imageNode = null
// If an image node is selected, paste into it
if (
this.canvas.current_node &&
this.canvas.current_node.is_selected &&
ComfyApp.isImageNode(this.canvas.current_node)
) {
imageNode = this.canvas.current_node
}
// No image node selected: add a new one
if (!imageNode) {
const newNode = LiteGraph.createNode('LoadImage')
// @ts-expect-error array to Float32Array
newNode.pos = [...this.canvas.graph_mouse]
imageNode = this.graph.add(newNode)
this.graph.change()
}
const blob = item.getAsFile()
imageNode.pasteFile(blob)
return
}
}
// No image found. Look for node data
data = data.getData('text/plain')
let workflow: ComfyWorkflowJSON | null = null
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) {
workflow = null
}
}
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
await this.loadGraphData(workflow)
} else {
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === 'textarea') ||
(e.target instanceof HTMLInputElement && e.target.type === 'text')
) {
return
}
// Litegraph default paste
this.canvas.pasteFromClipboard()
}
})
}
/**
* Adds a handler on copy that serializes selected nodes to JSON
*/
#addCopyHandler() {
document.addEventListener('copy', (e) => {
if (!(e.target instanceof Element)) {
return
}
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === 'textarea') ||
(e.target instanceof HTMLInputElement && e.target.type === 'text')
) {
// Default system copy
return
}
const isTargetInGraph =
e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container')
// copy nodes and clear clipboard
if (isTargetInGraph && 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
const useFastZoom = useSettingStore().get('Comfy.Graph.CtrlShiftZoom')
if (useFastZoom && e.ctrlKey && e.shiftKey && !e.altKey && e.buttons) {
self.zoom_drag_start = [e.x, e.y, this.ds.scale]
return
}
const res = origProcessMouseDown.apply(this, arguments)
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, [
self.zoom_drag_start[0],
self.zoom_drag_start[1]
])
this.graph.change()
return
}
return origProcessMouseMove.apply(this, arguments)
}
}
/**
* Handle keypress
*/
#addProcessKeyHandler() {
const origProcessKey = LGraphCanvas.prototype.processKey
LGraphCanvas.prototype.processKey = function (e: KeyboardEvent) {
if (!this.graph) {
return
}
var block_default = false
if (e.target instanceof Element && e.target.localName == 'input') {
return
}
if (e.type == 'keydown' && !e.repeat) {
const keyCombo = KeyComboImpl.fromEvent(e)
const keybindingStore = useKeybindingStore()
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetSelector === '#graph-canvas') {
useCommandStore().execute(keybinding.commandId)
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
}
}
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
) {
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) {
const editor_alpha = this.editor_alpha
const old_color = node.color
const old_bgcolor = node.bgcolor
if (node.mode === LGraphEventMode.NEVER) {
this.editor_alpha = 0.4
}
let bgColor: string
if (node.mode === LGraphEventMode.BYPASS) {
bgColor = app.bypassBgColor
this.editor_alpha = 0.2
} else {
bgColor = old_bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR
}
const adjustments: ColorAdjustOptions = {}
const opacity = useSettingStore().get('Comfy.Node.Opacity')
if (opacity) adjustments.opacity = opacity
if (useSettingStore().get('Comfy.ColorPalette') === 'light') {
adjustments.lightness = 0.5
// Lighten title bar of colored nodes on light theme
if (old_color) {
node.color = adjustColor(old_color, { lightness: 0.5 })
}
}
node.bgcolor = adjustColor(bgColor, adjustments)
const res = origDrawNode.apply(this, arguments)
this.editor_alpha = editor_alpha
node.color = old_color
node.bgcolor = old_bgcolor
return res
}
}
/**
* Handles updates from the API socket
*/
#addApiUpdateHandlers() {
api.addEventListener('status', ({ detail }) => {
this.ui.setStatus(detail)
})
api.addEventListener('progress', ({ detail }) => {
this.graph.setDirtyCanvas(true, false)
})
api.addEventListener('executing', ({ detail }) => {
this.graph.setDirtyCanvas(true, false)
this.revokePreviews(this.runningNodeId)
delete this.nodePreviewImages[this.runningNodeId]
})
api.addEventListener('executed', ({ detail }) => {
const output = this.nodeOutputs[detail.display_node || 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.display_node || detail.node] = detail.output
}
const node = this.graph.getNodeById(detail.display_node || detail.node)
if (node) {
if (node.onExecuted) node.onExecuted(detail.output)
}
})
api.addEventListener('execution_start', ({ detail }) => {
this.lastExecutionError = null
this.graph.nodes.forEach((node) => {
if (node.onExecutionStart) node.onExecutionStart()
})
})
api.addEventListener('execution_error', ({ detail }) => {
this.lastExecutionError = detail
useDialogService().showExecutionErrorDialog(detail)
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)
// Ensure clean up if `executing` event is missed.
this.revokePreviews(id)
this.nodePreviewImages[id] = [blobUrl]
})
api.init()
}
#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 primitives to generate widget using input nodes config
for (const node of app.graph.nodes) {
node.onAfterGraphConfigured?.()
}
return r
}
}
/**
* Set up the app on the page
*/
async setup(canvasEl: HTMLCanvasElement) {
this.canvasEl = canvasEl
this.resizeCanvas()
await useWorkspaceStore().workflow.syncWorkflows()
await useExtensionService().loadExtensions()
this.#addProcessMouseHandler()
this.#addProcessKeyHandler()
this.#addConfigureHandler()
this.#addApiUpdateHandlers()
this.#addRestoreWorkflowView()
this.graph = new LGraph()
this.#addAfterConfigureHandler()
// Make LGraphCanvas.state shallow reactive so that any change on the root
// object triggers reactivity.
this.canvas = new LGraphCanvas(canvasEl, this.graph)
this.canvas.state = shallowReactive(this.canvas.state)
this.ctx = canvasEl.getContext('2d')
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 useExtensionService().invokeExtensionsAsync('init')
await this.registerNodes()
// 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()
}
this.#addDrawNodeHandler()
this.#addDrawGroupsHandler()
this.#addDropHandler()
this.#addCopyHandler()
this.#addPasteHandler()
await useExtensionService().invokeExtensionsAsync('setup')
}
resizeCanvas() {
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
const scale = Math.max(window.devicePixelRatio, 1)
// Clear fixed width and height while calculating rect so it uses 100% instead
this.canvasEl.height = this.canvasEl.width = NaN
const { width, height } = this.canvasEl.getBoundingClientRect()
this.canvasEl.width = Math.round(width * scale)
this.canvasEl.height = Math.round(height * scale)
this.canvasEl.getContext('2d').scale(scale, scale)
this.canvas?.draw(true, true)
}
private updateVueAppNodeDefs(defs: Record<string, ComfyNodeDef>) {
// Frontend only nodes registered by custom nodes.
// Example: https://github.com/rgthree/rgthree-comfy/blob/dd534e5384be8cf0c0fa35865afe2126ba75ac55/src_web/comfyui/fast_groups_bypasser.ts#L10
const rawDefs = Object.fromEntries(
Object.entries(LiteGraph.registered_node_types).map(([name, node]) => [
name,
{
name,
display_name: name,
category: node.category || '__frontend_only__',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: 'custom_nodes.frontend_only',
description: `Frontend only node for ${name}`
}
])
)
const allNodeDefs = {
...rawDefs,
...defs,
...SYSTEM_NODE_DEFS
}
const nodeDefStore = useNodeDefStore()
const nodeDefArray: ComfyNodeDef[] = Object.values(allNodeDefs)
useExtensionService().invokeExtensions(
'beforeRegisterVueAppNodeDefs',
nodeDefArray,
this
)
nodeDefStore.updateNodeDefs(nodeDefArray)
}
#translateNodeDefs(defs: Record<string, ComfyNodeDef>) {
return Object.fromEntries(
Object.entries(defs).map(([name, def]) => [
name,
{
...def,
display_name: st(
`nodeDefs.${name}.display_name`,
def.display_name ?? def.name
),
description: def.description
? st(`nodeDefs.${name}.description`, def.description)
: undefined,
category: def.category
.split('/')
.map((category) => st(`nodeCategories.${category}`, category))
.join('/')
}
])
)
}
async #getNodeDefs() {
return this.#translateNodeDefs(
await api.getNodeDefs({
validate: useSettingStore().get('Comfy.Validation.NodeDefs')
})
)
}
/**
* Registers nodes with the graph
*/
async registerNodes() {
// Load node definitions from the backend
const defs = await this.#getNodeDefs()
await this.registerNodesFromDefs(defs)
await useExtensionService().invokeExtensionsAsync('registerCustomNodes')
if (this.vueAppReady) {
this.updateVueAppNodeDefs(defs)
}
}
/**
* Remove the impl after groupNode jest tests are removed.
* @deprecated Use useWidgetStore().getWidgetType instead
*/
getWidgetType(inputData, inputName: string) {
const type = inputData[0]
if (Array.isArray(type)) {
return 'COMBO'
} else if (`${type}:${inputName}` in this.widgets) {
return `${type}:${inputName}`
} else if (type in this.widgets) {
return type
} else {
return null
}
}
async registerNodeDef(nodeId: string, nodeData: ComfyNodeDef) {
return await useLitegraphService().registerNodeDef(nodeId, nodeData)
}
async registerNodesFromDefs(defs: Record<string, ComfyNodeDef>) {
await useExtensionService().invokeExtensionsAsync('addCustomNodeDefs', defs)
// 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
}
// Check for old clipboard format
const data = JSON.parse(template.data)
if (!data.reroutes) {
deserialiseAndCreate(template.data, app.canvas)
} else {
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: MissingNodeType[]) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
useDialogService().showLoadWorkflowWarning({ missingNodeTypes })
}
}
#showMissingModelsError(missingModels, paths) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
useDialogService().showMissingModelsWarning({
missingModels,
paths
})
}
}
async loadGraphData(
graphData?: ComfyWorkflowJSON,
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,
{ showMissingNodesDialog = true, showMissingModelsDialog = true } = {}
) {
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)
}
if (useSettingStore().get('Comfy.Validation.Workflows')) {
// TODO: Show validation error in a dialog.
const validatedGraphData = await validateComfyWorkflow(
graphData,
/* onError=*/ (err) => {
useToastStore().addAlert(err)
}
)
// If the validation failed, use the original graph data.
// Ideally we should not block users from loading the workflow.
graphData = validatedGraphData ?? graphData
}
useWorkflowService().beforeLoadNewGraph()
const missingNodeTypes: MissingNodeType[] = []
const missingModels = []
await useExtensionService().invokeExtensionsAsync(
'beforeConfigureGraph',
graphData,
missingNodeTypes
// TODO: missingModels
)
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)
}
}
if (
graphData.models &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
for (const m of graphData.models) {
const models_available = await useModelStore().getLoadedModelFolder(
m.directory
)
if (models_available === null) {
// @ts-expect-error
m.directory_invalid = true
missingModels.push(m)
} else if (!(m.name in models_available.models)) {
missingModels.push(m)
}
}
}
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
// @ts-expect-error
// Need to set strict: true for zod to match the type [number, number]
// https://github.com/colinhacks/zod/issues/3056
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
}
} 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 (
typeof widget.value === 'string' &&
widget.value.startsWith('sample_')
) {
widget.value = widget.value.slice(7)
}
}
}
if (
node.type == 'KSampler' ||
node.type == 'KSamplerAdvanced' ||
node.type == 'PrimitiveNode'
) {
if (widget.name == 'control_after_generate') {
if (widget.value === true) {
// @ts-expect-error change widget type from boolean to string
widget.value = 'randomize'
} else if (widget.value === false) {
// @ts-expect-error change widget type from boolean to string
widget.value = 'fixed'
}
}
}
if (reset_invalid_values) {
if (widget.type == 'combo') {
if (
!widget.options.values.includes(widget.value as string) &&
widget.options.values.length > 0
) {
widget.value = widget.options.values[0]
}
}
}
}
}
useExtensionService().invokeExtensions('loadedGraphNode', node)
}
// TODO: Properly handle if both nodes and models are missing (sequential dialogs?)
if (missingNodeTypes.length && showMissingNodesDialog) {
this.#showMissingNodesError(missingNodeTypes)
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
this.#showMissingModelsError(missingModels, paths)
}
await useExtensionService().invokeExtensionsAsync(
'afterConfigureGraph',
missingNodeTypes
)
await useWorkflowService().afterLoadNewGraph(
workflow,
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
this.graph.serialize()
)
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true)
})
}
/**
* Serializes a graph using preferred user settings.
* @param graph The litegraph to serialize.
* @returns A serialized graph (aka workflow) with preferred user settings.
*/
serializeGraph(graph: LGraph = this.graph) {
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
return graph.serialize({ sortNodes })
}
/**
* Converts the current graph workflow for sending to the API.
* Note: Node widgets are updated before serialization to prepare queueing.
* @returns The workflow and node links
*/
async graphToPrompt(graph = this.graph, clean = true) {
for (const outerNode of this.graph.computeExecutionOrder(false)) {
if (outerNode.widgets) {
for (const widget of outerNode.widgets) {
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
widget.beforeQueued?.()
}
}
const innerNodes = outerNode.getInnerNodes
? outerNode.getInnerNodes()
: [outerNode]
for (const node of innerNodes) {
if (node.isVirtualNode) {
// Don't serialize frontend only nodes but let them make changes
if (node.applyToGraph) {
node.applyToGraph()
}
}
}
}
const workflow = this.serializeGraph(graph)
// Remove localized_name from the workflow
for (const node of workflow.nodes) {
for (const slot of node.inputs) {
delete slot.localized_name
}
for (const slot of node.outputs) {
delete slot.localized_name
}
}
const output = {}
// Process nodes in order of execution
for (const outerNode of graph.computeExecutionOrder(false)) {
const skipNode =
outerNode.mode === LGraphEventMode.NEVER ||
outerNode.mode === LGraphEventMode.BYPASS
const innerNodes =
!skipNode && outerNode.getInnerNodes
? outerNode.getInnerNodes()
: [outerNode]
for (const node of innerNodes) {
if (node.isVirtualNode) {
continue
}
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
) {
// 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 === LGraphEventMode.BYPASS ||
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 === LGraphEventMode.BYPASS) {
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)
]
}
}
}
}
const node_data = {
inputs,
class_type: node.comfyClass
}
// Ignored by the backend.
node_data['_meta'] = {
title: node.title
}
output[String(node.id)] = node_data
}
}
// Remove inputs connected to removed nodes
if (clean) {
for (const o in output) {
for (const i in output[o].inputs) {
if (
Array.isArray(output[o].inputs[i]) &&
output[o].inputs[i].length === 2 &&
!output[output[o].inputs[i][0]]
) {
delete output[o].inputs[i]
}
}
}
}
return { workflow, output }
}
#formatPromptError(error) {
if (error == null) {
return '(unknown error)'
} else if (typeof error === 'string') {
return error
} else if (error.stack && error.message) {
return error.toString()
} else if (error.response) {
let message = error.response.error.message
if (error.response.error.details)
message += ': ' + error.response.error.details
for (const [nodeID, nodeError] of Object.entries(
error.response.node_errors
)) {
// @ts-expect-error
message += '\n' + nodeError.class_type + ':'
// @ts-expect-error
for (const errorReason of nodeError.errors) {
message +=
'\n - ' + errorReason.message + ': ' + errorReason.details
}
}
return message
}
return '(unknown error)'
}
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 {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
const res = await api.queuePrompt(number, p)
this.lastNodeErrors = res.node_errors
if (this.lastNodeErrors.length > 0) {
this.canvas.draw(true, true)
} else {
try {
useExecutionStore().storePrompt({
id: res.prompt_id,
nodes: Object.keys(p.output),
workflow: useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
})
} catch (error) {}
}
} catch (error) {
const formattedError = this.#formatPromptError(error)
this.ui.dialog.show(formattedError)
if (error.response) {
this.lastNodeErrors = error.response.node_errors
this.canvas.draw(true, true)
}
break
}
for (const n of p.workflow.nodes) {
const node = this.graph.getNodeById(n.id)
if (node.widgets) {
for (const widget of node.widgets) {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
// @ts-expect-error
if (widget.afterQueued) {
// @ts-expect-error
widget.afterQueued()
}
}
}
}
this.canvas.draw(true, true)
await this.ui.queue.update()
}
}
} finally {
this.#processingQueue = false
}
api.dispatchCustomEvent('promptQueued', { 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) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
useWorkflowService().beforeLoadNewGraph()
importA1111(this.graph, pngInfo.parameters)
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
useWorkflowService().afterLoadNewGraph(fileName, this.serializeGraph())
} 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)
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 === 'application/json' ||
file.name?.endsWith('.json')
) {
const reader = new FileReader()
reader.onload = async () => {
const readerResult = reader.result as string
const jsonContent = JSON.parse(readerResult)
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent)
} else if (this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent, fileName)
} else {
await this.loadGraphData(
JSON.parse(readerResult),
true,
false,
fileName
)
}
}
reader.readAsText(file)
} else if (
file.name?.endsWith('.latent') ||
file.name?.endsWith('.safetensors')
) {
const info = await getLatentMetadata(file)
// TODO define schema to LatentMetadata
// @ts-expect-error
if (info.workflow) {
await this.loadGraphData(
// @ts-expect-error
JSON.parse(info.workflow),
true,
true,
fileName
)
// @ts-expect-error
} else if (info.prompt) {
// @ts-expect-error
this.loadApiJson(JSON.parse(info.prompt))
} else {
this.showErrorOnFileLoad(file)
}
} else {
this.showErrorOnFileLoad(file)
}
}
isApiJson(data) {
// @ts-expect-error
return Object.values(data).every((v) => v.class_type)
}
loadApiJson(apiData, fileName: string) {
useWorkflowService().beforeLoadNewGraph()
const missingNodeTypes = Object.values(apiData).filter(
// @ts-expect-error
(n) => !LiteGraph.registered_node_types[n.class_type]
)
if (missingNodeTypes.length) {
this.#showMissingNodesError(
// @ts-expect-error
missingNodeTypes.map((t) => t.class_type)
)
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)
}
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)
// @ts-expect-error
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()
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)
// @ts-expect-error
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()
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
useWorkflowService().afterLoadNewGraph(fileName, this.serializeGraph())
}
/**
* Registers a Comfy web extension with the app
* @param {ComfyExtension} extension
* @deprecated Use useExtensionService().registerExtension instead
*/
registerExtension(extension: ComfyExtension) {
useExtensionService().registerExtension(extension)
}
/**
* Refresh combo list on whole nodes
*/
async refreshComboInNodes() {
const requestToastMessage: ToastMessageOptions = {
severity: 'info',
summary: 'Update',
detail: 'Update requested'
}
if (this.vueAppReady) {
useToastStore().add(requestToastMessage)
}
const defs = await this.#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]
}
}
}
await useExtensionService().invokeExtensionsAsync(
'refreshComboInNodes',
defs
)
if (this.vueAppReady) {
this.updateVueAppNodeDefs(defs)
useToastStore().remove(requestToastMessage)
useToastStore().add({
severity: 'success',
summary: 'Updated',
detail: 'Node definitions updated',
life: 1000
})
}
}
resetView() {
app.canvas.ds.scale = 1
app.canvas.ds.offset = [0, 0]
app.graph.setDirtyCanvas(true, true)
}
/**
* Frees memory allocated to image preview blobs for a specific node, by revoking the URLs associated with them.
* @param nodeId ID of the node to revoke all preview images of
*/
revokePreviews(nodeId: NodeId) {
if (!this.nodePreviewImages[nodeId]?.[Symbol.iterator]) return
for (const url of this.nodePreviewImages[nodeId]) {
URL.revokeObjectURL(url)
}
}
/**
* Clean current state
*/
clean() {
this.nodeOutputs = {}
for (const id of Object.keys(this.nodePreviewImages)) {
this.revokePreviews(id)
}
this.nodePreviewImages = {}
this.lastNodeErrors = null
this.lastExecutionError = null
}
clientPosToCanvasPos(pos: Vector2): Vector2 {
const rect = this.canvasContainer.getBoundingClientRect()
const containerOffsets = [rect.left, rect.top]
return _.zip(pos, this.canvas.ds.offset, containerOffsets).map(
([p, o1, o2]) => (p - o2) / this.canvas.ds.scale - o1
) as Vector2
}
canvasPosToClientPos(pos: Vector2): Vector2 {
const rect = this.canvasContainer.getBoundingClientRect()
const containerOffsets = [rect.left, rect.top]
return _.zip(pos, this.canvas.ds.offset, containerOffsets).map(
([p, o1, o2]) => (p + o1) * this.canvas.ds.scale + o2
) as Vector2
}
public goToNode(nodeId: NodeId) {
const graphNode = this.graph.getNodeById(nodeId)
if (!graphNode) return
this.canvas.animateToBounds(graphNode.boundingRect)
}
}
export const app = new ComfyApp()