Skip to content

Commit bc553f1

Browse files
AustinMrozDrJKL
andauthored
Add support for dynamic widgets (#6661)
Adds support for "dynamic combo" widgets where selecting a value on a combo widget can cause other widgets or inputs to be created. ![dynamic-widgets_00001](https://github.com/user-attachments/assets/c797d008-f335-4d4e-9b2e-6fe4a7187ba7) Includes a fairly large refactoring in litegraphService to remove `#private` methods and cleanup some duplication in constructors for subgraphNodes. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6661-Add-support-for-dynamic-widgets-2a96d73d3650817aa570c7babbaca2f3) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org>
1 parent 6bb35d4 commit bc553f1

File tree

7 files changed

+425
-353
lines changed

7 files changed

+425
-353
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
2+
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
3+
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
4+
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
5+
import { useLitegraphService } from '@/services/litegraphService'
6+
import { app } from '@/scripts/app'
7+
import type { ComfyApp } from '@/scripts/app'
8+
9+
function dynamicComboWidget(
10+
node: LGraphNode,
11+
inputName: string,
12+
untypedInputData: InputSpec,
13+
appArg: ComfyApp,
14+
widgetName?: string
15+
) {
16+
const { addNodeInput } = useLitegraphService()
17+
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
18+
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
19+
const inputData = parseResult.data
20+
const options = Object.fromEntries(
21+
inputData[1].options.map(({ key, inputs }) => [key, inputs])
22+
)
23+
const subSpec: ComboInputSpec = [Object.keys(options), {}]
24+
const { widget, minWidth, minHeight } = app.widgets['COMBO'](
25+
node,
26+
inputName,
27+
subSpec,
28+
appArg,
29+
widgetName
30+
)
31+
let currentDynamicNames: string[] = []
32+
const updateWidgets = (value?: string) => {
33+
if (!node.widgets) throw new Error('Not Reachable')
34+
const newSpec = value ? options[value] : undefined
35+
//TODO: Calculate intersection for widgets that persist across options
36+
//This would potentially allow links to be retained
37+
for (const name of currentDynamicNames) {
38+
const inputIndex = node.inputs.findIndex((input) => input.name === name)
39+
if (inputIndex !== -1) node.removeInput(inputIndex)
40+
const widgetIndex = node.widgets.findIndex(
41+
(widget) => widget.name === name
42+
)
43+
if (widgetIndex === -1) continue
44+
node.widgets[widgetIndex].value = undefined
45+
node.widgets.splice(widgetIndex, 1)
46+
}
47+
currentDynamicNames = []
48+
if (!newSpec) return
49+
50+
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
51+
const startingLength = node.widgets.length
52+
const inputInsertionPoint =
53+
node.inputs.findIndex((i) => i.name === widget.name) + 1
54+
const startingInputLength = node.inputs.length
55+
if (insertionPoint === 0)
56+
throw new Error("Dynamic widget doesn't exist on node")
57+
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
58+
[newSpec.required, false],
59+
[newSpec.optional, true]
60+
]
61+
for (const [inputType, isOptional] of inputTypes)
62+
for (const name in inputType ?? {}) {
63+
addNodeInput(
64+
node,
65+
transformInputSpecV1ToV2(inputType![name], {
66+
name,
67+
isOptional
68+
})
69+
)
70+
currentDynamicNames.push(name)
71+
}
72+
73+
const addedWidgets = node.widgets.splice(startingLength)
74+
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
75+
if (inputInsertionPoint === 0) {
76+
if (
77+
addedWidgets.length === 0 &&
78+
node.inputs.length !== startingInputLength
79+
)
80+
//input is inputOnly, but lacks an insertion point
81+
throw new Error('Failed to find input socket for ' + widget.name)
82+
return
83+
}
84+
const addedInputs = node
85+
.spliceInputs(startingInputLength)
86+
.map((addedInput) => {
87+
const existingInput = node.inputs.findIndex(
88+
(existingInput) => addedInput.name === existingInput.name
89+
)
90+
return existingInput === -1
91+
? addedInput
92+
: node.spliceInputs(existingInput, 1)[0]
93+
})
94+
//assume existing inputs are in correct order
95+
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
96+
node.size[1] = node.computeSize([...node.size])[1]
97+
}
98+
//A little hacky, but onConfigure won't work.
99+
//It fires too late and is overly disruptive
100+
let widgetValue = widget.value
101+
Object.defineProperty(widget, 'value', {
102+
get() {
103+
return widgetValue
104+
},
105+
set(value) {
106+
widgetValue = value
107+
updateWidgets(value)
108+
}
109+
})
110+
widget.value = widgetValue
111+
return { widget, minWidth, minHeight }
112+
}
113+
114+
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }

src/lib/litegraph/src/LGraphNode.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -848,15 +848,13 @@ export class LGraphNode
848848
}
849849

850850
if (info.widgets_values) {
851-
const widgetsWithValue = this.widgets.filter(
852-
(w) => w.serialize !== false
851+
const widgetsWithValue = this.widgets
852+
.values()
853+
.filter((w) => w.serialize !== false)
854+
.filter((_w, idx) => idx < info.widgets_values!.length)
855+
widgetsWithValue.forEach(
856+
(widget, i) => (widget.value = info.widgets_values![i])
853857
)
854-
for (let i = 0; i < info.widgets_values.length; ++i) {
855-
const widget = widgetsWithValue[i]
856-
if (widget) {
857-
widget.value = info.widgets_values[i]
858-
}
859-
}
860858
}
861859
}
862860

@@ -1652,6 +1650,19 @@ export class LGraphNode
16521650
this.onInputRemoved?.(slot, slot_info[0])
16531651
this.setDirtyCanvas(true, true)
16541652
}
1653+
spliceInputs(
1654+
startIndex: number,
1655+
deleteCount = -1,
1656+
...toAdd: INodeInputSlot[]
1657+
): INodeInputSlot[] {
1658+
if (deleteCount < 0) return this.inputs.splice(startIndex)
1659+
const ret = this.inputs.splice(startIndex, deleteCount, ...toAdd)
1660+
this.inputs.slice(startIndex).forEach((input, index) => {
1661+
const link = input.link && this.graph?.links?.get(input.link)
1662+
if (link) link.target_slot = startIndex + index
1663+
})
1664+
return ret
1665+
}
16551666

16561667
/**
16571668
* computes the minimum size of a node according to its inputs and output slots

src/schemas/nodeDefSchema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,18 @@ export const zComfyNodeDef = z.object({
230230
input_order: z.record(z.array(z.string())).optional()
231231
})
232232

233+
export const zDynamicComboInputSpec = z.tuple([
234+
z.literal('COMFY_DYNAMICCOMBO_V3'),
235+
zComboInputOptions.extend({
236+
options: z.array(
237+
z.object({
238+
inputs: zComfyInputsSpec,
239+
key: z.string()
240+
})
241+
)
242+
})
243+
])
244+
233245
// `/object_info`
234246
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
235247
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>

src/scripts/widgets.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
IStringWidget
77
} from '@/lib/litegraph/src/types/widgets'
88
import { useSettingStore } from '@/platform/settings/settingStore'
9+
import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets'
910
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
1011
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
1112
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -296,5 +297,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
296297
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
297298
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
298299
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
299-
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
300+
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
301+
...dynamicWidgets
300302
}

0 commit comments

Comments
 (0)