Support associated socket for widgets (#3326)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chenlei Hu
2025-04-06 11:50:21 -04:00
committed by GitHub
parent 6eb2b76621
commit ac53296b2e
41 changed files with 212 additions and 534 deletions

View File

@@ -81,7 +81,7 @@ export class NodeWidgetReference {
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvas.ds.convertOffsetToCanvas([
return window['app'].canvasPosToClientPos([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
@@ -94,6 +94,36 @@ export class NodeWidgetReference {
}
}
/**
* @returns The position of the widget's associated socket
*/
async getSocketPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const slot = node.inputs.find(
(slot) => slot.widget?.name === widget.name
)
if (!slot) throw new Error(`Socket ${widget.name} not found.`)
const [x, y] = node.getBounding()
return window['app'].canvasPosToClientPos([
x + slot.pos[0],
y + slot.pos[1] + window['LiteGraph']['NODE_TITLE_HEIGHT']
])
},
[this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async click() {
await this.node.comfyPage.canvas.click({
position: await this.getPosition()
@@ -250,7 +280,7 @@ export class NodeReference {
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getPosition()
await targetWidget.getSocketPosition()
)
return originSlot
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -88,63 +88,6 @@ test.describe('Node Right Click Menu', () => {
)
})
test.describe('Widget conversion', () => {
const convertibleWidgetTypes = ['text', 'string', 'number', 'toggle']
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
// The submenu has an identical entry as the base menu - use last
await comfyPage.page.getByText('Convert width to input').last().click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
test('Can convert widget without submenu', async ({ comfyPage }) => {
// Right-click the width widget
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
convertibleWidgetTypes.forEach((widgetType) => {
test(`Can convert ${widgetType} widget to input`, async ({
comfyPage
}) => {
const nodeType = 'KSampler'
// To avoid needing multiple clicks, disable nesting of conversion options
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', false)
// Add the widget using the node's `addWidget` method
await comfyPage.page.evaluate(
([nodeType, widgetType]) => {
const node = window['app'].graph.nodes.find(
(n) => n.type === nodeType
)
node.addWidget(widgetType, widgetType, 'defaultValue', () => {}, {})
},
[nodeType, widgetType]
)
// Verify the context menu includes the conversion option
const node = (await comfyPage.getNodeRefsByType(nodeType))[0]
const menuOptions = await node.getContextMenuOptionNames()
expect(menuOptions.includes(`Convert ${widgetType} to input`)).toBe(
true
)
})
})
})
test('Can pin and unpin', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

8
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.12.0",
"@comfyorg/litegraph": "^0.13.0-0",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -478,9 +478,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.12.0.tgz",
"integrity": "sha512-2LK1tNHIPAGmalloJxVtWXndG4vWNAEBX2RuQE7Fvtj2UuMFFpV/tWq+ofFJjj8sk2K/S5CfVsx+RFnREpV3RQ==",
"version": "0.13.0-0",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.13.0-0.tgz",
"integrity": "sha512-jkrk3d+riU7LpiV2CUXcVyropIa8W+FvTK48cSVqzJh+/BB7kHiYtYRfzwp3fgln5/LHsfUf1TWd6OqZWFTXrA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -71,7 +71,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.12.0",
"@comfyorg/litegraph": "^0.13.0-0",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -49,7 +49,9 @@ const style = computed<CSSProperties>(() => ({
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly ? 'none' : 'auto'
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
}))
const canvasStore = useCanvasStore()

View File

@@ -1,6 +1,5 @@
import { LGraphNode, LiteGraph, RenderShape } from '@comfyorg/litegraph'
import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
IFoundSlot,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
@@ -14,13 +13,11 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { ComfyWidgets, addValueControlWidgets } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/utils/typeGuardUtil'
const CONVERTED_TYPE = 'converted-widget'
const VALID_TYPES = [
'STRING',
'combo',
@@ -30,8 +27,6 @@ const VALID_TYPES = [
'text',
'string'
]
const CONFIG = Symbol()
const GET_CONFIG = Symbol()
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
@@ -107,18 +102,14 @@ export class PrimitiveNode extends LGraphNode {
onAfterGraphConfigured() {
if (this.outputs[0].links?.length && !this.widgets?.length) {
// TODO: Review this check
// @ts-expect-error
if (!this.#onFirstConnection()) return
this.#onFirstConnection()
// Populate widget values from config data
if (this.widgets) {
// @ts-expect-error fixme ts strict error
if (this.widgets && this.widgets_values) {
for (let i = 0; i < this.widgets_values.length; i++) {
const w = this.widgets[i]
if (w) {
// @ts-expect-error change widget type from string to unknown
w.value = this.widgets_values[i]
w.value = this.widgets_values[i] as any
}
}
}
@@ -447,109 +438,21 @@ function isConvertibleWidget(widget: IWidget, config: InputSpec): boolean {
)
}
function hideWidget(
node: LGraphNode,
widget: IWidget,
options: { suffix?: string; holdSpace?: boolean } = {}
) {
const { suffix = '', holdSpace = true } = options
if (widget.type?.startsWith(CONVERTED_TYPE)) return
widget.origType = widget.type
widget.origComputeSize = widget.computeSize
widget.origSerializeValue = widget.serializeValue
// @ts-expect-error custom widget type
widget.type = CONVERTED_TYPE + suffix
if (holdSpace) {
widget.computeSize = () => [0, LiteGraph.NODE_WIDGET_HEIGHT]
} else {
// -4 is due to the gap litegraph adds between widgets automatically
widget.computeSize = () => [0, -4]
}
widget.serializeValue = (node: LGraphNode, index: number) => {
// Prevent serializing the widget if we have no input linked
if (!node.inputs) {
return undefined
}
let node_input = node.inputs.find((i) => i.widget?.name === widget.name)
if (!node_input || !node_input.link) {
return undefined
}
return widget.origSerializeValue
? widget.origSerializeValue(node, index)
: widget.value
}
// Hide any linked widgets, e.g. seed+seedControl
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) {
hideWidget(node, w, { suffix: ':' + widget.name, holdSpace: false })
}
}
}
function showWidget(widget: IWidget) {
// @ts-expect-error custom widget type
widget.type = widget.origType
widget.computeSize = widget.origComputeSize
widget.serializeValue = widget.origSerializeValue
delete widget.origType
delete widget.origComputeSize
delete widget.origSerializeValue
// Hide any linked widgets, e.g. seed+seedControl
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) {
showWidget(w)
}
}
}
/**
* Convert a widget to an input slot.
* @deprecated Widget to socket conversion is no longer necessary, as they co-exist now.
* @param node The node to convert the widget to an input slot for.
* @param widget The widget to convert to an input slot.
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
*/
export function convertToInput(
node: LGraphNode,
widget: IWidget,
config: InputSpec
): INodeInputSlot {
hideWidget(node, widget)
const { type } = getWidgetType(config)
// Add input and store widget config for creating on primitive node
const [oldWidth, oldHeight] = node.size
const inputIsOptional = !!widget.options?.inputIsOptional
const input = node.addInput(widget.name, type, {
widget: { name: widget.name, [GET_CONFIG]: () => config },
...(inputIsOptional ? { shape: RenderShape.HollowCircle } : {})
})
for (const widget of node.widgets ?? []) {
widget.last_y = (widget.last_y ?? 0) + LiteGraph.NODE_SLOT_HEIGHT
}
// Restore original size but grow if needed
node.setSize([
Math.max(oldWidth, node.size[0]),
Math.max(oldHeight, node.size[1])
])
return input
}
function convertToWidget(node: LGraphNode, widget: IWidget) {
showWidget(widget)
const [oldWidth, oldHeight] = node.size
node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name))
for (const widget of node.widgets ?? []) {
widget.last_y = (widget.last_y ?? 0) - LiteGraph.NODE_SLOT_HEIGHT
}
// Restore original size but grow if needed
node.setSize([
Math.max(oldWidth, node.size[0]),
Math.max(oldHeight, node.size[1])
])
widget: IWidget
): INodeInputSlot | undefined {
console.warn(
'Please remove call to convertToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
)
return node.inputs.find((slot) => slot.widget?.name === widget.name)
}
function getWidgetType(config: InputSpec) {
@@ -631,167 +534,13 @@ export function mergeIfValid(
app.registerExtension({
name: 'Comfy.WidgetInputs',
settings: [
{
id: 'Comfy.NodeInputConversionSubmenus',
name: 'In the node context menu, place the entries that convert between input/widget in sub-menus.',
type: 'boolean',
defaultValue: true
}
],
setup() {
app.canvas.getWidgetLinkType = function (widget, node) {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const input = nodeDef.inputs[widget.name]
return input?.type
}
app.canvas.linkConnector.events.addEventListener(
'dropped-on-widget',
(e) => {
const { node, link, widget } = e.detail
if (!node || !link || !widget) return
const nodeData = node.constructor.nodeData
if (!nodeData) return
const all = {
...nodeData?.input?.required,
...nodeData?.input?.optional
}
const inputSpec = all[widget.name]
if (!inputSpec) return
const input = convertToInput(node, widget, inputSpec)
link.node.connectSlots(link.fromSlot, node, input, link.fromReroute?.id)
}
)
},
async beforeRegisterNodeDef(nodeType, _nodeData, app) {
// Add menu options to convert to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (
this: LGraphNode,
widget: IWidget
) {
const config = getConfig.call(this, widget.name) ?? [
widget.type,
widget.options || {}
]
if (!isConvertibleWidget(widget, config)) return false
if (widget.type?.startsWith(CONVERTED_TYPE)) return false
convertToInput(this, widget, config)
return true
}
nodeType.prototype.getExtraSlotMenuOptions = function (
this: LGraphNode,
slot: IFoundSlot
) {
if (!slot.input || !slot.input.widget) return []
const widget = this.widgets?.find(
(w) => w.name === slot.input?.widget?.name
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
console.warn(
'Please remove call to convertWidgetToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
)
if (!widget) return []
return [
{
content: `Convert to widget`,
callback: () => convertToWidget(this, widget)
}
]
}
// @ts-expect-error fixme ts strict error
nodeType.prototype.getExtraMenuOptions = function (
this: LGraphNode,
_,
options
) {
const r = origGetExtraMenuOptions
? // @ts-expect-error fixme ts strict error
origGetExtraMenuOptions.apply(this, arguments)
: undefined
const getPointerCanvasPos = () => {
const pos = this.graph?.list_of_graphcanvas?.at(0)?.graph_mouse
return pos ? { canvasX: pos[0], canvasY: pos[1] } : undefined
}
if (this.widgets) {
const { canvasX = 0, canvasY = 0 } = getPointerCanvasPos() ?? {}
const widget = this.getWidgetOnPos(canvasX, canvasY)
// @ts-expect-error custom widget type
if (widget && widget.type !== CONVERTED_TYPE) {
const config = getConfig.call(this, widget.name) ?? [
widget.type,
widget.options || {}
]
if (isConvertibleWidget(widget, config)) {
options.push({
content: `Convert ${widget.name} to input`,
callback: () => convertToInput(this, widget, config) && false
})
}
}
let toInput = []
let toWidget = []
for (const w of this.widgets) {
if (w.options?.forceInput) {
continue
}
// @ts-expect-error custom widget type
if (w.type === CONVERTED_TYPE) {
toWidget.push({
// @ts-expect-error never
content: `Convert ${w.name} to widget`,
callback: () => convertToWidget(this, w)
})
} else {
const config = getConfig.call(this, w.name) ?? [
w.type,
w.options || {}
]
if (isConvertibleWidget(w, config)) {
toInput.push({
content: `Convert ${w.name} to input`,
callback: () => convertToInput(this, w, config)
})
}
}
}
//Convert.. main menu
if (toInput.length) {
if (useSettingStore().get('Comfy.NodeInputConversionSubmenus')) {
options.push({
content: 'Convert Widget to Input',
submenu: {
// @ts-expect-error fixme ts strict error
options: toInput
}
})
} else {
// @ts-expect-error fixme ts strict error
options.push(...toInput, null)
}
}
if (toWidget.length) {
if (useSettingStore().get('Comfy.NodeInputConversionSubmenus')) {
options.push({
content: 'Convert Input to Widget',
submenu: {
options: toWidget
}
})
} else {
options.push(...toWidget, null)
}
}
}
return r
return false
}
nodeType.prototype.onGraphConfigured = useChainCallback(
@@ -808,9 +557,7 @@ app.registerExtension({
}
const w = this.widgets?.find((w) => w.name === name)
if (w) {
hideWidget(this, w)
} else {
if (!w) {
this.removeInput(this.inputs.findIndex((i) => i === input))
}
}
@@ -818,24 +565,6 @@ app.registerExtension({
}
)
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,
function (this: LGraphNode) {
// When node is created, convert any force/default inputs
if (!app.configuringGraph && this.widgets) {
for (const w of this.widgets) {
if (w?.options?.forceInput || w?.options?.defaultInput) {
const config = getConfig.call(this, w.name) ?? [
w.type,
w.options || {}
]
convertToInput(this, w, config)
}
}
}
}
)
nodeType.prototype.onConfigure = useChainCallback(
nodeType.prototype.onConfigure,
function (this: LGraphNode) {
@@ -845,10 +574,6 @@ app.registerExtension({
if (input.widget && !input.widget[GET_CONFIG]) {
const name = input.widget.name
input.widget[GET_CONFIG] = () => getConfig.call(this, name)
const w = this.widgets?.find((w) => w.name === name)
if (w) {
hideWidget(this, w)
}
}
}
}

View File

@@ -209,8 +209,6 @@
"Shapes": "Shapes",
"Bypass": "Bypass",
"Copy (Clipspace)": "Copy (Clipspace)",
"Convert Widget to Input": "Convert Widget to Input",
"Convert Input to Widget": "Convert Input to Widget",
"Add Node": "Add Node",
"Add Group": "Add Group",
"Convert to Group Node": "Convert to Group Node",
@@ -219,9 +217,6 @@
"Save Selected as Template": "Save Selected as Template",
"Node Templates": "Node Templates",
"Manage": "Manage",
"Convert ": "Convert ",
" to input": " to input",
" to widget": " to widget",
"Search": "Search"
},
"icon": {
@@ -695,7 +690,6 @@
"BrushAdjustment": "Brush Adjustment",
"NewEditor": "New Editor",
"ModelLibrary": "Model Library",
"NodeInputConversionSubmenus": "Node Input Conversion Submenus",
"NodeLibrary": "Node Library",
"Node Search Box": "Node Search Box",
"Pointer": "Pointer",

View File

@@ -189,9 +189,6 @@
"Hide built-in": "Hide built-in"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "In the node context menu, place the entries that convert between input/widget in sub-menus."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",
"options": {

View File

@@ -20,8 +20,6 @@
"yellow": "Amarillo"
},
"contextMenu": {
" to input": " a entrada",
" to widget": " a widget",
"Add Group": "Agregar Grupo",
"Add Group For Selected Nodes": "Agregar Grupo para Nodos Seleccionados",
"Add Node": "Agregar Nodo",
@@ -29,9 +27,6 @@
"Clone": "Clonar",
"Collapse": "Colapsar",
"Colors": "Colores",
"Convert ": "Convertir ",
"Convert Input to Widget": "Convertir Entrada a Widget",
"Convert Widget to Input": "Convertir Widget a Entrada",
"Convert to Group Node": "Convertir en Nodo de Grupo",
"Copy (Clipspace)": "Copiar (Espacio de Clip)",
"Expand": "Expandir",
@@ -836,7 +831,6 @@
"Node": "Nodo",
"Node Search Box": "Caja de Búsqueda de Nodo",
"Node Widget": "Widget de Nodo",
"NodeInputConversionSubmenus": "Submenús de Conversión de Entrada de Nodo",
"NodeLibrary": "Biblioteca de Nodos",
"Pointer": "Puntero",
"Queue": "Cola",

View File

@@ -157,9 +157,6 @@
"Show all": "Mostrar todo"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "En el menú contextual del nodo, coloque las entradas que convierten entre entrada/widget en submenús."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Implementación de la caja de búsqueda de nodos",
"options": {

View File

@@ -20,8 +20,6 @@
"yellow": "Jaune"
},
"contextMenu": {
" to input": " en entrée",
" to widget": " en widget",
"Add Group": "Ajouter un Groupe",
"Add Group For Selected Nodes": "Ajouter un Groupe pour les Nœuds Sélectionnés",
"Add Node": "Ajouter un Nœud",
@@ -29,9 +27,6 @@
"Clone": "Cloner",
"Collapse": "Réduire",
"Colors": "Couleurs",
"Convert ": "Convertir ",
"Convert Input to Widget": "Convertir l'Entrée en Widget",
"Convert Widget to Input": "Convertir le Widget en Entrée",
"Convert to Group Node": "Convertir en Nœud de Groupe",
"Copy (Clipspace)": "Copier (Clipspace)",
"Expand": "Développer",
@@ -836,7 +831,6 @@
"Node": "Nœud",
"Node Search Box": "Boîte de Recherche de Nœud",
"Node Widget": "Widget de Nœud",
"NodeInputConversionSubmenus": "Sous-menus de Conversion d'Entrée de Nœud",
"NodeLibrary": "Bibliothèque de Nœuds",
"Pointer": "Pointeur",
"Queue": "File d'Attente",

View File

@@ -157,9 +157,6 @@
"Show all": "Afficher tout"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "Dans le menu contextuel du nœud, placez les entrées qui convertissent entre l'entrée/widget dans des sous-menus."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Implémentation de la boîte de recherche de nœud",
"options": {

View File

@@ -20,8 +20,6 @@
"yellow": "黄色"
},
"contextMenu": {
" to input": " 入力へ",
" to widget": " ウィジェットへ",
"Add Group": "グループを追加",
"Add Group For Selected Nodes": "選択したノードのグループを追加",
"Add Node": "ノードを追加",
@@ -29,9 +27,6 @@
"Clone": "クローン",
"Collapse": "折りたたむ",
"Colors": "色",
"Convert ": "変換 ",
"Convert Input to Widget": "入力をウィジェットに変換",
"Convert Widget to Input": "ウィジェットを入力に変換",
"Convert to Group Node": "グループノードに変換",
"Copy (Clipspace)": "コピー (Clipspace)",
"Expand": "展開",
@@ -836,7 +831,6 @@
"Node": "ノード",
"Node Search Box": "ノード検索ボックス",
"Node Widget": "ノードウィジェット",
"NodeInputConversionSubmenus": "ノード入力変換サブメニュー",
"NodeLibrary": "ノードライブラリ",
"Pointer": "ポインタ",
"Queue": "キュー",

View File

@@ -157,9 +157,6 @@
"Show all": "すべて表示"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "ノードのコンテキストメニューに、入力/ウィジェット間の変換を行うエントリをサブメニューに配置します。"
},
"Comfy_NodeSearchBoxImpl": {
"name": "ノード検索ボックスの実装",
"options": {

View File

@@ -20,8 +20,6 @@
"yellow": "노란색"
},
"contextMenu": {
" to input": " 위젯을 입력으로",
" to widget": " 입력을 위젯으로",
"Add Group": "그룹 추가",
"Add Group For Selected Nodes": "선택한 노드 그룹 추가",
"Add Node": "노드 추가",
@@ -29,9 +27,6 @@
"Clone": "복제",
"Collapse": "접기",
"Colors": "색상",
"Convert ": "[변환] ",
"Convert Input to Widget": "입력을 위젯으로 변환",
"Convert Widget to Input": "위젯을 입력으로 변환",
"Convert to Group Node": "그룹 노드로 변환",
"Copy (Clipspace)": "복사 (Clipspace)",
"Expand": "확장",
@@ -836,7 +831,6 @@
"Node": "노드",
"Node Search Box": "노드 검색 상자",
"Node Widget": "노드 위젯",
"NodeInputConversionSubmenus": "노드 입력 변환 하위 메뉴",
"NodeLibrary": "노드 라이브러리",
"Pointer": "포인터",
"Queue": "실행 큐",

View File

@@ -157,9 +157,6 @@
"Show all": "모두 표시"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "노드 컨텍스트 메뉴에서 입력/위젯 간 변환 항목을 하위 메뉴에 배치합니다."
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",
"options": {

View File

@@ -20,8 +20,6 @@
"yellow": "Жёлтый"
},
"contextMenu": {
" to input": " во вход",
" to widget": " в виджет",
"Add Group": "Добавить группу",
"Add Group For Selected Nodes": "Добавить группу для выбранных узлов",
"Add Node": "Добавить узел",
@@ -29,9 +27,6 @@
"Clone": "Клонировать",
"Collapse": "Свернуть",
"Colors": "Цвета",
"Convert ": "Преобразовать ",
"Convert Input to Widget": "Преобразовать вход в виджет",
"Convert Widget to Input": "Преобразовать виджет во вход",
"Convert to Group Node": "Преобразовать в групповой узел",
"Copy (Clipspace)": "Копировать (Clipspace)",
"Expand": "Развернуть",
@@ -836,7 +831,6 @@
"Node": "Нода",
"Node Search Box": "Поисковая строка нод",
"Node Widget": "Виджет ноды",
"NodeInputConversionSubmenus": "Подменю преобразования ввода ноды",
"NodeLibrary": "Библиотека нод",
"Pointer": "Указатель",
"Queue": "Очередь",

View File

@@ -157,9 +157,6 @@
"Show all": "Показать все"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "В контекстном меню ноды разместите элементы, которые конвертируют между вводом/виджетом в подменю."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Реализация поискового поля нод",
"options": {

View File

@@ -20,8 +20,6 @@
"yellow": "黄色"
},
"contextMenu": {
" to input": " 为输入",
" to widget": " 为控件",
"Add Group": "添加组",
"Add Group For Selected Nodes": "为选定节点添加组",
"Add Node": "添加节点",
@@ -29,9 +27,6 @@
"Clone": "克隆",
"Collapse": "折叠",
"Colors": "颜色",
"Convert ": "转换 ",
"Convert Input to Widget": "将输入转换为控件",
"Convert Widget to Input": "将控件转换为输入",
"Convert to Group Node": "转换为组节点",
"Copy (Clipspace)": "复制 (Clipspace)",
"Expand": "展开",
@@ -836,7 +831,6 @@
"Node": "节点",
"Node Search Box": "节点搜索框",
"Node Widget": "节点组件",
"NodeInputConversionSubmenus": "节点输入转换子菜单",
"NodeLibrary": "节点库",
"Pointer": "指针",
"Queue": "队列",

View File

@@ -157,9 +157,6 @@
"Show all": "显示全部"
}
},
"Comfy_NodeInputConversionSubmenus": {
"name": "在节点上下文菜单中,将输入/组件之间转换的条目放置在子菜单中。"
},
"Comfy_NodeSearchBoxImpl": {
"name": "节点搜索框",
"options": {

View File

@@ -364,7 +364,6 @@ const zSettings = z.record(z.any()).and(
z.string(),
zBookmarkCustomization
),
'Comfy.NodeInputConversionSubmenus': z.boolean(),
'Comfy.LinkRelease.Action': zLinkReleaseTriggerAction,
'Comfy.LinkRelease.ActionShift': zLinkReleaseTriggerAction,
'Comfy.ModelLibrary.AutoLoadAll': z.boolean(),

View File

@@ -66,7 +66,7 @@ import {
import { $el, ComfyUI } from './ui'
import { ComfyAppMenu } from './ui/menu/index'
import { clone } from './utils'
import { type ComfyWidgetConstructor, ComfyWidgets } from './widgets'
import { type ComfyWidgetConstructor } from './widgets'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -180,10 +180,7 @@ export class ComfyApp {
* @deprecated Use useWidgetStore().widgets instead
*/
get widgets(): Record<string, ComfyWidgetConstructor> {
if (this.vueAppReady) {
return useWidgetStore().widgets
}
return ComfyWidgets
return Object.fromEntries(useWidgetStore().widgets.entries())
}
/**

View File

@@ -132,12 +132,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
}
isVisible(): boolean {
return (
!_.isNil(this.computedHeight) &&
this.computedHeight > 0 &&
!['converted-widget', 'hidden'].includes(this.type) &&
!this.node.collapsed
)
return !['hidden'].includes(this.type) && this.node.isWidgetVisible(this)
}
draw(

View File

@@ -4,9 +4,14 @@ import {
LGraphEventMode,
LGraphNode,
LiteGraph,
RenderShape
RenderShape,
type Vector2
} from '@comfyorg/litegraph'
import { Vector2 } from '@comfyorg/litegraph'
import type {
ISerialisableNodeOutput,
ISerialisedNode
} from '@comfyorg/litegraph/dist/types/serialisation'
import _ from 'lodash'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
@@ -32,6 +37,10 @@ import { isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { useExtensionService } from './extensionService'
const PRIMITIVE_TYPES = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
export const CONFIG = Symbol()
export const GET_CONFIG = Symbol()
/**
* Service that augments litegraph with ComfyUI specific functionality.
*/
@@ -95,59 +104,79 @@ export const useLitegraphService = () => {
}
}
/**
* @internal Add input sockets to the node. (No widget)
*/
#addInputSocket(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(inputSpec.type)
if (widgetConstructor && !inputSpec.forceInput) return
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Add a widget to the node. For primitive types, an input socket is also added.
*/
#addInputWidget(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(inputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(inputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
inputIsOptional: inputSpec.isOptional,
forceInput: inputSpec.forceInput,
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (PRIMITIVE_TYPES.has(inputSpec.type)) {
const inputSpecV1 = transformInputSpecV2ToV1(inputSpec)
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const [inputName, inputSpec] of Object.entries(inputs)) {
const inputType = inputSpec.type
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets[inputType]
if (widgetConstructor) {
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(inputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
inputIsOptional: inputSpec.isOptional,
forceInput: inputSpec.forceInput,
defaultInput: inputSpec.defaultInput,
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
} else {
// Node connection inputs
const shapeOptions = inputSpec.isOptional
? { shape: RenderShape.HollowCircle }
: {}
this.addInput(inputName, inputType, {
...shapeOptions,
localized_name: st(nameKey, inputName)
})
}
}
for (const inputSpec of Object.values(inputs))
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
this.#addInputWidget(inputSpec)
}
/**
@@ -182,33 +211,57 @@ export const useLitegraphService = () => {
this.setSize(s)
}
configure(data: any) {
// Keep 'name', 'type', 'shape', and 'localized_name' information from the original node definition.
const merge = (
current: Record<string, any>,
incoming: Record<string, any>
) => {
const result = { ...incoming }
if (current.widget === undefined && incoming.widget !== undefined) {
// Field must be input as only inputs can be converted
this.inputs.push(current as INodeInputSlot)
return incoming
/**
* 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, INodeInputSlot>(
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
}
for (const key of ['name', 'type', 'shape', 'localized_name']) {
if (current[key] !== undefined) {
result[key] = current[key]
}
}
return result
}
for (const field of ['inputs', 'outputs']) {
const slots = data[field] ?? []
// @ts-expect-error fixme ts strict error
data[field] = slots.map((slot, i) =>
// @ts-expect-error fixme ts strict error
merge(this[field][i] ?? {}, slot)
)
}
)
super.configure(data)
}
}

View File

@@ -10,23 +10,21 @@ import { ComfyWidgetConstructor, ComfyWidgets } from '@/scripts/widgets'
export const useWidgetStore = defineStore('widget', () => {
const coreWidgets = ComfyWidgets
const customWidgets = ref<Record<string, ComfyWidgetConstructor>>({})
const widgets = computed(() => ({
...customWidgets.value,
...coreWidgets
}))
const customWidgets = ref<Map<string, ComfyWidgetConstructor>>(new Map())
const widgets = computed<Map<string, ComfyWidgetConstructor>>(
() => new Map([...customWidgets.value, ...Object.entries(coreWidgets)])
)
function inputIsWidget(spec: InputSpecV2 | InputSpecV1) {
const type = Array.isArray(spec) ? getInputSpecType(spec) : spec.type
return type in widgets.value
return widgets.value.has(type)
}
function registerCustomWidgets(
newWidgets: Record<string, ComfyWidgetConstructor>
) {
customWidgets.value = {
...customWidgets.value,
...newWidgets
for (const [type, widget] of Object.entries(newWidgets)) {
customWidgets.value.set(type, widget)
}
}

View File

@@ -37,6 +37,8 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
/**
* Whether the widget defaults to input state. Can still be converted back
* to widget state.
* @deprecated Widget to input conversion is no longer necessary, as they co-exist now.
* This option no longer has any effect.
*/
defaultInput?: boolean
}
@@ -60,16 +62,6 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
* See extensions/core/dynamicPrompts.ts
*/
dynamicPrompts?: boolean
/**
* Widget conversion fields
*/
origType?: string
origComputeSize?: (width: number) => Size
origSerializeValue?: (
node: LGraphNode,
index: number
) => Promise<unknown> | unknown
}
}

View File

@@ -38,6 +38,13 @@ export const graphToPrompt = async (
}
}
// Remove all unconnected widget input slots
for (const node of workflow.nodes) {
node.inputs = node.inputs?.filter(
(input) => !(input.widget && input.link === null)
)
}
const output: ComfyApiWorkflow = {}
// Process nodes in order of execution
for (const outerNode of graph.computeExecutionOrder(false)) {