mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 08:30:06 +00:00
Fixes a bug where swapping to a different workflow from the inside of a subgraph would cause nodes to be in an incorrect position after swapping back. in vue mode Prior to an unknown-but-recent PR, all nodes would would stack on the origin. This PR instead solves the remaining issue where having `ComfyEnableWorkflowViewRestore` would cause incorrect node positions. This is done by not delaying the fitView by a frame (which causes it to occur after the graph is no longer in the configuring state). In order to accomplish this, the code in LGraphNode has been updated to allow measuring node bounds without requiring a ctx argument. This arg is only used to ensure sufficient width for a node's title and is irrelevant when loading an existing graph. | Before | After | | ------ | ----- | | <img width="360" alt="before" src="https://github.com/user-attachments/assets/7f73817b-36e9-4400-8342-9e660cb36628"/> | <img width="360" alt="after" src="https://github.com/user-attachments/assets/c7ab4b99-2797-4276-9703-58d489cc3eaf" />| See also #7591, which solves similar issues, but does not resolve this bug. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7645-Do-not-delay-fit-to-view-on-graph-restore-2ce6d73d36508153972cc7b5948ce375) by [Unito](https://www.unito.io)
914 lines
30 KiB
TypeScript
914 lines
30 KiB
TypeScript
import _ from 'es-toolkit/compat'
|
|
|
|
import { downloadFile } from '@/base/common/downloadUtil'
|
|
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
|
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
|
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
|
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
|
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
|
import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtils'
|
|
import { applyDynamicInputs } from '@/core/graph/widgets/dynamicWidgets'
|
|
import { st, t } from '@/i18n'
|
|
import {
|
|
LGraphCanvas,
|
|
LGraphEventMode,
|
|
LGraphNode,
|
|
LiteGraph,
|
|
RenderShape,
|
|
SubgraphNode,
|
|
createBounds
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import type {
|
|
IContextMenuValue,
|
|
Point,
|
|
Subgraph
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import type {
|
|
ExportedSubgraphInstance,
|
|
ISerialisableNodeInput,
|
|
ISerialisableNodeOutput,
|
|
ISerialisedNode
|
|
} from '@/lib/litegraph/src/types/serialisation'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
|
import type {
|
|
ComfyNodeDef as ComfyNodeDefV2,
|
|
InputSpec,
|
|
OutputSpec
|
|
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
|
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
|
import { ComfyApp, app } from '@/scripts/app'
|
|
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
|
|
import { $el } from '@/scripts/ui'
|
|
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
|
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
|
import { useSubgraphStore } from '@/stores/subgraphStore'
|
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
|
import { useWidgetStore } from '@/stores/widgetStore'
|
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
|
import {
|
|
isImageNode,
|
|
isVideoNode,
|
|
migrateWidgetsValues
|
|
} from '@/utils/litegraphUtil'
|
|
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
|
|
|
import { useExtensionService } from './extensionService'
|
|
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
|
|
|
export interface HasInitialMinSize {
|
|
_initialMinSize: { width: number; height: number }
|
|
}
|
|
|
|
export const CONFIG = Symbol()
|
|
export const GET_CONFIG = Symbol()
|
|
|
|
/**
|
|
* Service that augments litegraph with ComfyUI specific functionality.
|
|
*/
|
|
export const useLitegraphService = () => {
|
|
const extensionService = useExtensionService()
|
|
const toastStore = useToastStore()
|
|
const widgetStore = useWidgetStore()
|
|
const canvasStore = useCanvasStore()
|
|
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
|
|
|
/**
|
|
* @internal The key for the node definition in the i18n file.
|
|
*/
|
|
function nodeKey(node: LGraphNode): string {
|
|
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData!.name)}`
|
|
}
|
|
/**
|
|
* @internal Add input sockets to the node. (No widget)
|
|
*/
|
|
function addInputSocket(node: LGraphNode, inputSpec: InputSpec) {
|
|
const inputName = inputSpec.name
|
|
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
|
|
const widgetConstructor = widgetStore.widgets.get(
|
|
inputSpec.widgetType ?? inputSpec.type
|
|
)
|
|
if (
|
|
(widgetConstructor && !inputSpec.forceInput) ||
|
|
applyDynamicInputs(node, inputSpec)
|
|
)
|
|
return
|
|
|
|
const input = node.addInput(inputName, inputSpec.type, {
|
|
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
|
localized_name: st(nameKey, inputName)
|
|
})
|
|
input.label ??= inputSpec.display_name
|
|
}
|
|
/**
|
|
* @internal Setup stroke styles for the node under various conditions.
|
|
*/
|
|
function setupStrokeStyles(node: LGraphNode) {
|
|
node.strokeStyles['running'] = function (this: LGraphNode) {
|
|
const nodeId = String(this.id)
|
|
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
|
const state =
|
|
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
|
if (state === 'running') {
|
|
return { color: '#0f0' }
|
|
}
|
|
}
|
|
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
|
if (app.dragOverNode?.id == this.id) {
|
|
return { color: 'dodgerblue' }
|
|
}
|
|
}
|
|
node.strokeStyles['executionError'] = function (this: LGraphNode) {
|
|
if (app.lastExecutionError?.node_id == this.id) {
|
|
return { color: '#f0f', lineWidth: 2 }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utility function. Implemented for use with dynamic widgets
|
|
*/
|
|
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
|
|
addInputSocket(node, inputSpec)
|
|
addInputWidget(node, inputSpec)
|
|
}
|
|
|
|
/**
|
|
* @internal Add a widget to the node. For both primitive types and custom widgets
|
|
* (unless `socketless`), an input socket is also added.
|
|
*/
|
|
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
|
|
const widgetInputSpec = { ...inputSpec }
|
|
if (inputSpec.widgetType) {
|
|
widgetInputSpec.type = inputSpec.widgetType
|
|
}
|
|
const inputName = inputSpec.name
|
|
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
|
|
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
|
if (!widgetConstructor || inputSpec.forceInput) return
|
|
|
|
const {
|
|
widget,
|
|
minWidth = 1,
|
|
minHeight = 1
|
|
} = widgetConstructor(
|
|
node,
|
|
inputName,
|
|
transformInputSpecV2ToV1(widgetInputSpec),
|
|
app
|
|
) ?? {}
|
|
|
|
if (widget) {
|
|
widget.label = st(
|
|
nameKey,
|
|
widget.label ?? widgetInputSpec.display_name ?? inputName
|
|
)
|
|
widget.options ??= {}
|
|
Object.assign(widget.options, {
|
|
advanced: inputSpec.advanced,
|
|
hidden: inputSpec.hidden
|
|
})
|
|
}
|
|
|
|
if (!widget?.options?.socketless) {
|
|
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
|
node.addInput(inputName, inputSpec.type, {
|
|
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
|
localized_name: st(nameKey, inputName),
|
|
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
|
})
|
|
}
|
|
const castedNode = node as LGraphNode & HasInitialMinSize
|
|
castedNode._initialMinSize.width = Math.max(
|
|
castedNode._initialMinSize.width,
|
|
minWidth
|
|
)
|
|
castedNode._initialMinSize.height = Math.max(
|
|
castedNode._initialMinSize.height,
|
|
minHeight
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @internal Add inputs to the node.
|
|
*/
|
|
function addInputs(node: LGraphNode, inputs: Record<string, InputSpec>) {
|
|
// Use input_order if available to ensure consistent widget ordering
|
|
//@ts-expect-error was ComfyNode.nodeData as ComfyNodeDefImpl
|
|
const nodeDefImpl = node.constructor.nodeData as ComfyNodeDefImpl
|
|
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
|
|
|
// Create sockets and widgets in the determined order
|
|
for (const inputSpec of orderedInputSpecs) addInputSocket(node, inputSpec)
|
|
for (const inputSpec of orderedInputSpecs) addInputWidget(node, inputSpec)
|
|
}
|
|
|
|
/**
|
|
* @internal Add outputs to the node.
|
|
*/
|
|
function addOutputs(node: LGraphNode, outputs: OutputSpec[]) {
|
|
for (const output of outputs) {
|
|
const { name, is_list } = output
|
|
// TODO: Fix the typing at the node spec level
|
|
const type = output.type === 'COMFY_MATCHTYPE_V3' ? '*' : output.type
|
|
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
|
const nameKey = `${nodeKey(node)}.outputs.${output.index}.name`
|
|
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
|
const outputOptions = {
|
|
...shapeOptions,
|
|
// If the output name is different from the output type, use the output name.
|
|
// e.g.
|
|
// - type ("INT"); name ("Positive") => translate name
|
|
// - type ("FLOAT"); name ("FLOAT") => translate type
|
|
localized_name: type !== name ? st(nameKey, name) : st(typeKey, name)
|
|
}
|
|
node.addOutput(name, type, outputOptions)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal Set the initial size of the node.
|
|
*/
|
|
function setInitialSize(node: LGraphNode) {
|
|
const s = node.computeSize()
|
|
// Expand the width a little to fit widget values on screen.
|
|
const pad =
|
|
node.widgets?.length &&
|
|
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
|
const castedNode = node as LGraphNode & HasInitialMinSize
|
|
s[0] = Math.max(castedNode._initialMinSize.width, s[0] + (pad ? 60 : 0))
|
|
s[1] = Math.max(castedNode._initialMinSize.height, s[1])
|
|
node.setSize(s)
|
|
}
|
|
|
|
function registerSubgraphNodeDef(
|
|
nodeDefV1: ComfyNodeDefV1,
|
|
subgraph: Subgraph,
|
|
instanceData: ExportedSubgraphInstance
|
|
) {
|
|
const node = class ComfyNode
|
|
extends SubgraphNode
|
|
implements HasInitialMinSize
|
|
{
|
|
static comfyClass: string
|
|
static override title: string
|
|
static override category: string
|
|
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
|
|
|
_initialMinSize = { width: 1, height: 1 }
|
|
|
|
constructor() {
|
|
super(app.rootGraph, subgraph, instanceData)
|
|
|
|
// Set up event listener for promoted widget registration
|
|
subgraph.events.addEventListener('widget-promoted', (event) => {
|
|
const { widget } = event.detail
|
|
// Only handle DOM widgets
|
|
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
|
|
|
|
const domWidgetStore = useDomWidgetStore()
|
|
if (!domWidgetStore.widgetStates.has(widget.id)) {
|
|
domWidgetStore.registerWidget(widget)
|
|
// Set initial visibility based on whether the widget's node is in the current graph
|
|
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
|
if (widgetState) {
|
|
const currentGraph = canvasStore.getCanvas().graph
|
|
widgetState.visible =
|
|
currentGraph?.nodes.includes(widget.node) ?? false
|
|
}
|
|
}
|
|
})
|
|
|
|
// Set up event listener for promoted widget removal
|
|
subgraph.events.addEventListener('widget-demoted', (event) => {
|
|
const { widget } = event.detail
|
|
// Only handle DOM widgets
|
|
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
|
|
|
|
const domWidgetStore = useDomWidgetStore()
|
|
if (domWidgetStore.widgetStates.has(widget.id)) {
|
|
domWidgetStore.unregisterWidget(widget.id)
|
|
}
|
|
})
|
|
|
|
setupStrokeStyles(this)
|
|
addInputs(this, ComfyNode.nodeData.inputs)
|
|
addOutputs(this, ComfyNode.nodeData.outputs)
|
|
setInitialSize(this)
|
|
this.serialize_widgets = true
|
|
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
|
}
|
|
|
|
/**
|
|
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
|
* and 'localized_name' information from the original node definition.
|
|
*/
|
|
override configure(data: ISerialisedNode): void {
|
|
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
|
|
|
|
// Note: input name is unique in a node definition, so we can lookup
|
|
// input by name.
|
|
const inputByName = new Map<string, ISerialisableNodeInput>(
|
|
data.inputs?.map((input) => [input.name, input]) ?? []
|
|
)
|
|
// Inputs defined by the node definition.
|
|
const definedInputNames = new Set(
|
|
this.inputs.map((input) => input.name)
|
|
)
|
|
const definedInputs = this.inputs.map((input) => {
|
|
const inputData = inputByName.get(input.name)
|
|
return inputData
|
|
? {
|
|
...inputData,
|
|
// Whether the input has associated widget follows the
|
|
// original node definition.
|
|
..._.pick(input, RESERVED_KEYS.concat('widget'))
|
|
}
|
|
: input
|
|
})
|
|
// Extra inputs that potentially dynamically added by custom js logic.
|
|
const extraInputs = data.inputs?.filter(
|
|
(input) => !definedInputNames.has(input.name)
|
|
)
|
|
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
|
|
|
|
// Note: output name is not unique, so we cannot lookup output by name.
|
|
// Use index instead.
|
|
data.outputs = _.zip(this.outputs, data.outputs).map(
|
|
([output, outputData]) => {
|
|
// If there are extra outputs in the serialised node, use them directly.
|
|
// There are currently custom nodes that dynamically add outputs via
|
|
// js logic.
|
|
if (!output) return outputData as ISerialisableNodeOutput
|
|
|
|
return outputData
|
|
? {
|
|
...outputData,
|
|
..._.pick(output, RESERVED_KEYS)
|
|
}
|
|
: output
|
|
}
|
|
)
|
|
|
|
data.widgets_values = migrateWidgetsValues(
|
|
ComfyNode.nodeData.inputs,
|
|
this.widgets ?? [],
|
|
data.widgets_values ?? []
|
|
)
|
|
|
|
super.configure(data)
|
|
}
|
|
}
|
|
|
|
addNodeContextMenuHandler(node)
|
|
addDrawBackgroundHandler(node)
|
|
addNodeKeyHandler(node)
|
|
// Note: Some extensions expects node.comfyClass to be set in
|
|
// `beforeRegisterNodeDef`.
|
|
node.prototype.comfyClass = nodeDefV1.name
|
|
node.comfyClass = nodeDefV1.name
|
|
|
|
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
|
|
node.nodeData = nodeDef
|
|
LiteGraph.registerNodeType(subgraph.id, node)
|
|
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
|
// because `registerNodeType` will overwrite the assignments.
|
|
node.category = nodeDef.category
|
|
node.skip_list = true
|
|
node.title = nodeDef.display_name || nodeDef.name
|
|
}
|
|
|
|
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
|
|
const node = class ComfyNode
|
|
extends LGraphNode
|
|
implements HasInitialMinSize
|
|
{
|
|
static comfyClass: string
|
|
static override title: string
|
|
static override category: string
|
|
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
|
|
|
_initialMinSize = { width: 1, height: 1 }
|
|
|
|
constructor(title: string) {
|
|
super(title)
|
|
setupStrokeStyles(this)
|
|
addInputs(this, ComfyNode.nodeData.inputs)
|
|
addOutputs(this, ComfyNode.nodeData.outputs)
|
|
setInitialSize(this)
|
|
this.serialize_widgets = true
|
|
|
|
// Mark API Nodes yellow by default to distinguish with other nodes.
|
|
if (ComfyNode.nodeData.api_node) {
|
|
this.color = LGraphCanvas.node_colors.yellow.color
|
|
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
|
|
}
|
|
|
|
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
|
}
|
|
|
|
/**
|
|
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
|
* and 'localized_name' information from the original node definition.
|
|
*/
|
|
override configure(data: ISerialisedNode): void {
|
|
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
|
|
|
|
// Note: input name is unique in a node definition, so we can lookup
|
|
// input by name.
|
|
const inputByName = new Map<string, ISerialisableNodeInput>(
|
|
data.inputs?.map((input) => [input.name, input]) ?? []
|
|
)
|
|
// Inputs defined by the node definition.
|
|
const definedInputNames = new Set(
|
|
this.inputs.map((input) => input.name)
|
|
)
|
|
const definedInputs = this.inputs.map((input) => {
|
|
const inputData = inputByName.get(input.name)
|
|
return inputData
|
|
? {
|
|
...inputData,
|
|
// Whether the input has associated widget follows the
|
|
// original node definition.
|
|
..._.pick(input, RESERVED_KEYS.concat('widget'))
|
|
}
|
|
: input
|
|
})
|
|
// Extra inputs that potentially dynamically added by custom js logic.
|
|
const extraInputs = data.inputs?.filter(
|
|
(input) => !definedInputNames.has(input.name)
|
|
)
|
|
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
|
|
|
|
// Note: output name is not unique, so we cannot lookup output by name.
|
|
// Use index instead.
|
|
data.outputs = _.zip(this.outputs, data.outputs).map(
|
|
([output, outputData]) => {
|
|
// If there are extra outputs in the serialised node, use them directly.
|
|
// There are currently custom nodes that dynamically add outputs via
|
|
// js logic.
|
|
if (!output) return outputData as ISerialisableNodeOutput
|
|
|
|
return outputData
|
|
? {
|
|
...outputData,
|
|
..._.pick(output, RESERVED_KEYS)
|
|
}
|
|
: output
|
|
}
|
|
)
|
|
|
|
data.widgets_values = migrateWidgetsValues(
|
|
ComfyNode.nodeData.inputs,
|
|
this.widgets ?? [],
|
|
data.widgets_values ?? []
|
|
)
|
|
|
|
super.configure(data)
|
|
}
|
|
}
|
|
|
|
addNodeContextMenuHandler(node)
|
|
addDrawBackgroundHandler(node)
|
|
addNodeKeyHandler(node)
|
|
// Note: Some extensions expects node.comfyClass to be set in
|
|
// `beforeRegisterNodeDef`.
|
|
node.prototype.comfyClass = nodeDefV1.name
|
|
node.comfyClass = nodeDefV1.name
|
|
await extensionService.invokeExtensionsAsync(
|
|
'beforeRegisterNodeDef',
|
|
node,
|
|
nodeDefV1 // Receives V1 NodeDef, and potentially make modifications to it
|
|
)
|
|
|
|
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
|
|
node.nodeData = nodeDef
|
|
LiteGraph.registerNodeType(nodeId, node)
|
|
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
|
// because `registerNodeType` will overwrite the assignments.
|
|
node.category = nodeDef.category
|
|
node.title = nodeDef.display_name || nodeDef.name
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
function addNodeContextMenuHandler(node: typeof LGraphNode) {
|
|
function getCopyImageOption(img: HTMLImageElement): IContextMenuValue[] {
|
|
if (typeof window.ClipboardItem === 'undefined') return []
|
|
return [
|
|
{
|
|
content: 'Copy Image',
|
|
callback: async () => {
|
|
const url = new URL(img.src)
|
|
url.searchParams.delete('preview')
|
|
|
|
// @ts-expect-error fixme ts strict error
|
|
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
|
|
}) as HTMLCanvasElement
|
|
const ctx = canvas.getContext('2d')
|
|
// @ts-expect-error fixme ts strict error
|
|
let image
|
|
if (typeof window.createImageBitmap === 'undefined') {
|
|
image = new Image()
|
|
const p = new Promise((resolve, reject) => {
|
|
// @ts-expect-error fixme ts strict error
|
|
image.onload = resolve
|
|
// @ts-expect-error fixme ts strict error
|
|
image.onerror = reject
|
|
}).finally(() => {
|
|
// @ts-expect-error fixme ts strict error
|
|
URL.revokeObjectURL(image.src)
|
|
})
|
|
image.src = URL.createObjectURL(blob)
|
|
await p
|
|
} else {
|
|
image = await createImageBitmap(blob)
|
|
}
|
|
try {
|
|
// @ts-expect-error fixme ts strict error
|
|
ctx.drawImage(image, 0, 0)
|
|
canvas.toBlob(writeImage, 'image/png')
|
|
} finally {
|
|
// @ts-expect-error fixme ts strict error
|
|
if (typeof image.close === 'function') {
|
|
// @ts-expect-error fixme ts strict error
|
|
image.close()
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
} catch (error) {
|
|
toastStore.addAlert(
|
|
t('toastMessages.errorCopyImage', {
|
|
// @ts-expect-error fixme ts strict error
|
|
error: error.message ?? error
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
node.prototype.getExtraMenuOptions = function (canvas, 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: () => {
|
|
const url = new URL(img.src)
|
|
url.searchParams.delete('preview')
|
|
window.open(url, '_blank')
|
|
}
|
|
},
|
|
...getCopyImageOption(img),
|
|
{
|
|
content: 'Save Image',
|
|
callback: () => {
|
|
const url = new URL(img.src)
|
|
url.searchParams.delete('preview')
|
|
const filename = new URLSearchParams(url.search).get('filename')
|
|
downloadFile(url.toString(), filename ?? undefined)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
options.push({
|
|
content: 'Bypass',
|
|
callback: () => {
|
|
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
|
canvas.setDirty(true, true)
|
|
}
|
|
})
|
|
|
|
// prevent conflict of clipspace content
|
|
if (!ComfyApp.clipspace_return_node) {
|
|
options.push({
|
|
content: 'Copy (Clipspace)',
|
|
callback: () => {
|
|
ComfyApp.copyToClipspace(this)
|
|
}
|
|
})
|
|
|
|
if (ComfyApp.clipspace != null) {
|
|
options.push({
|
|
content: 'Paste (Clipspace)',
|
|
callback: () => {
|
|
ComfyApp.pasteFromClipspace(this)
|
|
}
|
|
})
|
|
}
|
|
|
|
if (isImageNode(this)) {
|
|
options.push({
|
|
content: 'Open in MaskEditor | Image Canvas',
|
|
callback: () => {
|
|
useMaskEditor().openMaskEditor(this)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
if (this instanceof SubgraphNode) {
|
|
options.unshift(
|
|
{
|
|
content: 'Edit Subgraph Widgets',
|
|
callback: () => {
|
|
useRightSidePanelStore().openPanel('subgraph')
|
|
}
|
|
},
|
|
{
|
|
content: 'Unpack Subgraph',
|
|
callback: () => {
|
|
const { unpackSubgraph } = useSubgraphOperations()
|
|
unpackSubgraph()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
const [x, y] = canvas.graph_mouse
|
|
const overWidget = this.getWidgetOnPos(x, y, true)
|
|
if (overWidget) {
|
|
const input = this.inputs.find(
|
|
(inp) => inp.widget?.name === overWidget.name
|
|
)
|
|
if (input)
|
|
options.unshift({
|
|
content: `${t('contextMenu.RenameWidget')}: ${overWidget.label ?? overWidget.name}`,
|
|
callback: async () => {
|
|
const newLabel = await useDialogService().prompt({
|
|
title: t('g.rename'),
|
|
message: t('g.enterNewName') + ':',
|
|
defaultValue: overWidget.label,
|
|
placeholder: overWidget.name
|
|
})
|
|
if (newLabel === null) return
|
|
overWidget.label = newLabel || undefined
|
|
input.label = newLabel || undefined
|
|
useCanvasStore().canvas?.setDirty(true)
|
|
}
|
|
})
|
|
if (this.graph && !this.graph.isRootGraph) {
|
|
addWidgetPromotionOptions(options, overWidget, this)
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
}
|
|
function updatePreviews(node: LGraphNode, callback?: () => void) {
|
|
try {
|
|
unsafeUpdatePreviews.call(node, callback)
|
|
} catch (error) {
|
|
console.error('Error drawing node background', error)
|
|
}
|
|
}
|
|
function unsafeUpdatePreviews(this: LGraphNode, callback?: () => void) {
|
|
if (this.flags.collapsed) return
|
|
|
|
const nodeOutputStore = useNodeOutputStore()
|
|
const { showAnimatedPreview, removeAnimatedPreview } =
|
|
useNodeAnimatedImage()
|
|
const { showCanvasImagePreview, removeCanvasImagePreview } =
|
|
useNodeCanvasImagePreview()
|
|
|
|
const output = nodeOutputStore.getNodeOutputs(this)
|
|
const preview = nodeOutputStore.getNodePreviews(this)
|
|
|
|
const isNewOutput = output && this.images !== output.images
|
|
const isNewPreview = preview && this.preview !== preview
|
|
|
|
if (isNewPreview) this.preview = preview
|
|
if (isNewOutput) this.images = output.images
|
|
|
|
if (isNewOutput || isNewPreview) {
|
|
this.animatedImages = output?.animated?.find(Boolean)
|
|
|
|
const isAnimatedWebp =
|
|
this.animatedImages &&
|
|
output?.images?.some((img) => img.filename?.includes('webp'))
|
|
const isAnimatedPng =
|
|
this.animatedImages &&
|
|
output?.images?.some((img) => img.filename?.includes('png'))
|
|
const isVideo =
|
|
(this.animatedImages && !isAnimatedWebp && !isAnimatedPng) ||
|
|
isVideoNode(this)
|
|
if (isVideo) {
|
|
useNodeVideo(this, callback).showPreview()
|
|
} else {
|
|
useNodeImage(this, callback).showPreview()
|
|
}
|
|
}
|
|
|
|
// Nothing to do
|
|
if (!this.imgs?.length) return
|
|
|
|
if (this.animatedImages) {
|
|
removeCanvasImagePreview(this)
|
|
showAnimatedPreview(this)
|
|
} else {
|
|
removeAnimatedPreview(this)
|
|
showCanvasImagePreview(this)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
function addDrawBackgroundHandler(node: typeof LGraphNode) {
|
|
/**
|
|
* @deprecated No longer needed as we use {@link useImagePreviewWidget}
|
|
*/
|
|
node.prototype.setSizeForImage = function (this: LGraphNode) {
|
|
console.warn(
|
|
'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.'
|
|
)
|
|
}
|
|
node.prototype.onDrawBackground = function () {
|
|
updatePreviews(this)
|
|
}
|
|
}
|
|
|
|
function addNodeKeyHandler(node: typeof LGraphNode) {
|
|
const origNodeOnKeyDown = node.prototype.onKeyDown
|
|
|
|
node.prototype.onKeyDown = function (e) {
|
|
// @ts-expect-error fixme ts strict error
|
|
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') {
|
|
// @ts-expect-error fixme ts strict error
|
|
this.imageIndex -= 1
|
|
} else if (e.key === 'ArrowRight') {
|
|
// @ts-expect-error fixme ts strict error
|
|
this.imageIndex += 1
|
|
}
|
|
// @ts-expect-error fixme ts strict error
|
|
this.imageIndex %= this.imgs.length
|
|
|
|
// @ts-expect-error fixme ts strict error
|
|
if (this.imageIndex < 0) {
|
|
// @ts-expect-error fixme ts strict error
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
function addNodeOnGraph(
|
|
nodeDef: ComfyNodeDefV1 | ComfyNodeDefV2,
|
|
options: Record<string, any> = {}
|
|
): LGraphNode {
|
|
options.pos ??= getCanvasCenter()
|
|
|
|
if (nodeDef.name.startsWith(useSubgraphStore().typePrefix)) {
|
|
const canvas = canvasStore.getCanvas()
|
|
const bp = useSubgraphStore().getBlueprint(nodeDef.name)
|
|
const items: object = {
|
|
nodes: bp.nodes,
|
|
subgraphs: bp.definitions?.subgraphs
|
|
}
|
|
const results = canvas._deserializeItems(items, {
|
|
position: options.pos
|
|
})
|
|
if (!results) throw new Error('Failed to add subgraph blueprint')
|
|
const node = results.nodes.values().next().value
|
|
if (!node)
|
|
throw new Error(
|
|
'Subgraph blueprint was added, but failed to resolve a subgraph Node'
|
|
)
|
|
return node
|
|
}
|
|
|
|
const node = LiteGraph.createNode(
|
|
nodeDef.name,
|
|
nodeDef.display_name,
|
|
options
|
|
)
|
|
|
|
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
|
|
|
// @ts-expect-error fixme ts strict error
|
|
graph.add(node)
|
|
// @ts-expect-error fixme ts strict error
|
|
return node
|
|
}
|
|
|
|
function getCanvasCenter(): Point {
|
|
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
|
const [x, y, w, h] = app.canvas.ds.visible_area
|
|
return [x + w / dpi / 2, y + h / dpi / 2]
|
|
}
|
|
|
|
function goToNode(nodeId: NodeId) {
|
|
const graphNode = app.canvas.graph?.getNodeById(nodeId)
|
|
if (!graphNode) return
|
|
app.canvas.animateToBounds(graphNode.boundingRect)
|
|
}
|
|
|
|
function ensureBounds(nodes: LGraphNode[]) {
|
|
for (const node of nodes) {
|
|
if (!node.boundingRect.every((i) => i === 0)) continue
|
|
node.updateArea()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resets the canvas view to the default
|
|
*/
|
|
function resetView() {
|
|
const canvas = canvasStore.canvas
|
|
if (!canvas) return
|
|
|
|
canvas.ds.scale = 1
|
|
canvas.ds.offset = [0, 0]
|
|
canvas.setDirty(true, true)
|
|
}
|
|
|
|
function fitView() {
|
|
const canvas = canvasStore.getCanvas()
|
|
const nodes = canvas.graph?.nodes
|
|
if (!nodes) return
|
|
ensureBounds(nodes)
|
|
const bounds = createBounds(nodes)
|
|
if (!bounds) return
|
|
|
|
canvas.ds.fitToBounds(bounds)
|
|
canvas.setDirty(true, true)
|
|
}
|
|
|
|
return {
|
|
registerNodeDef,
|
|
registerSubgraphNodeDef,
|
|
addNodeOnGraph,
|
|
addNodeInput,
|
|
getCanvasCenter,
|
|
goToNode,
|
|
resetView,
|
|
fitView,
|
|
updatePreviews
|
|
}
|
|
}
|