diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts new file mode 100644 index 0000000000..a113881ebf --- /dev/null +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -0,0 +1,114 @@ +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration' +import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema' +import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema' +import { useLitegraphService } from '@/services/litegraphService' +import { app } from '@/scripts/app' +import type { ComfyApp } from '@/scripts/app' + +function dynamicComboWidget( + node: LGraphNode, + inputName: string, + untypedInputData: InputSpec, + appArg: ComfyApp, + widgetName?: string +) { + const { addNodeInput } = useLitegraphService() + const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData) + if (!parseResult.success) throw new Error('invalid DynamicCombo spec') + const inputData = parseResult.data + const options = Object.fromEntries( + inputData[1].options.map(({ key, inputs }) => [key, inputs]) + ) + const subSpec: ComboInputSpec = [Object.keys(options), {}] + const { widget, minWidth, minHeight } = app.widgets['COMBO']( + node, + inputName, + subSpec, + appArg, + widgetName + ) + let currentDynamicNames: string[] = [] + const updateWidgets = (value?: string) => { + if (!node.widgets) throw new Error('Not Reachable') + const newSpec = value ? options[value] : undefined + //TODO: Calculate intersection for widgets that persist across options + //This would potentially allow links to be retained + for (const name of currentDynamicNames) { + const inputIndex = node.inputs.findIndex((input) => input.name === name) + if (inputIndex !== -1) node.removeInput(inputIndex) + const widgetIndex = node.widgets.findIndex( + (widget) => widget.name === name + ) + if (widgetIndex === -1) continue + node.widgets[widgetIndex].value = undefined + node.widgets.splice(widgetIndex, 1) + } + currentDynamicNames = [] + if (!newSpec) return + + const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1 + const startingLength = node.widgets.length + const inputInsertionPoint = + node.inputs.findIndex((i) => i.name === widget.name) + 1 + const startingInputLength = node.inputs.length + if (insertionPoint === 0) + throw new Error("Dynamic widget doesn't exist on node") + const inputTypes: [Record | undefined, boolean][] = [ + [newSpec.required, false], + [newSpec.optional, true] + ] + for (const [inputType, isOptional] of inputTypes) + for (const name in inputType ?? {}) { + addNodeInput( + node, + transformInputSpecV1ToV2(inputType![name], { + name, + isOptional + }) + ) + currentDynamicNames.push(name) + } + + const addedWidgets = node.widgets.splice(startingLength) + node.widgets.splice(insertionPoint, 0, ...addedWidgets) + if (inputInsertionPoint === 0) { + if ( + addedWidgets.length === 0 && + node.inputs.length !== startingInputLength + ) + //input is inputOnly, but lacks an insertion point + throw new Error('Failed to find input socket for ' + widget.name) + return + } + const addedInputs = node + .spliceInputs(startingInputLength) + .map((addedInput) => { + const existingInput = node.inputs.findIndex( + (existingInput) => addedInput.name === existingInput.name + ) + return existingInput === -1 + ? addedInput + : node.spliceInputs(existingInput, 1)[0] + }) + //assume existing inputs are in correct order + node.spliceInputs(inputInsertionPoint, 0, ...addedInputs) + node.size[1] = node.computeSize([...node.size])[1] + } + //A little hacky, but onConfigure won't work. + //It fires too late and is overly disruptive + let widgetValue = widget.value + Object.defineProperty(widget, 'value', { + get() { + return widgetValue + }, + set(value) { + widgetValue = value + updateWidgets(value) + } + }) + widget.value = widgetValue + return { widget, minWidth, minHeight } +} + +export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget } diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 94c0310b1e..ace5b0bbab 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -845,15 +845,13 @@ export class LGraphNode } if (info.widgets_values) { - const widgetsWithValue = this.widgets.filter( - (w) => w.serialize !== false + const widgetsWithValue = this.widgets + .values() + .filter((w) => w.serialize !== false) + .filter((_w, idx) => idx < info.widgets_values!.length) + widgetsWithValue.forEach( + (widget, i) => (widget.value = info.widgets_values![i]) ) - for (let i = 0; i < info.widgets_values.length; ++i) { - const widget = widgetsWithValue[i] - if (widget) { - widget.value = info.widgets_values[i] - } - } } } @@ -1649,6 +1647,19 @@ export class LGraphNode this.onInputRemoved?.(slot, slot_info[0]) this.setDirtyCanvas(true, true) } + spliceInputs( + startIndex: number, + deleteCount = -1, + ...toAdd: INodeInputSlot[] + ): INodeInputSlot[] { + if (deleteCount < 0) return this.inputs.splice(startIndex) + const ret = this.inputs.splice(startIndex, deleteCount, ...toAdd) + this.inputs.slice(startIndex).forEach((input, index) => { + const link = input.link && this.graph?.links?.get(input.link) + if (link) link.target_slot = startIndex + index + }) + return ret + } /** * computes the minimum size of a node according to its inputs and output slots diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 73401b14af..dd311fbc91 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -230,6 +230,18 @@ export const zComfyNodeDef = z.object({ input_order: z.record(z.array(z.string())).optional() }) +export const zDynamicComboInputSpec = z.tuple([ + z.literal('COMFY_DYNAMICCOMBO_V3'), + zComboInputOptions.extend({ + options: z.array( + z.object({ + inputs: zComfyInputsSpec, + key: z.string() + }) + ) + }) +]) + // `/object_info` export type ComfyInputsSpec = z.infer export type ComfyOutputTypesSpec = z.infer diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index f2bd9e12d5..c07a9c20a1 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -6,6 +6,7 @@ import type { IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useSettingStore } from '@/platform/settings/settingStore' +import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets' import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget' import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget' import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget' @@ -296,5 +297,6 @@ export const ComfyWidgets: Record = { IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()), CHART: transformWidgetConstructorV2ToV1(useChartWidget()), GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()), - TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()) + TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()), + ...dynamicWidgets } diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 35562533b2..ff9bcadfe2 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -59,6 +59,10 @@ import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil' import { useExtensionService } from './extensionService' +export interface HasInitialMinSize { + _initialMinSize: { width: number; height: number } +} + export const CONFIG = Symbol() export const GET_CONFIG = Symbol() @@ -72,28 +76,184 @@ export const useLitegraphService = () => { const canvasStore = useCanvasStore() const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() - // TODO: Dedupe `registerNodeDef`; this should remain synchronous. + /** + * @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) return + + node.addInput(inputName, inputSpec.type, { + shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined, + localized_name: st(nameKey, inputName) + }) + } + /** + * @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['nodeError'] = function (this: LGraphNode) { + if (app.lastNodeErrors?.[this.id]?.errors) { + return { color: 'red' } + } + } + 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 ?? 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) { + // 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, type, is_list } = output + 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 { + const node = class ComfyNode + extends SubgraphNode + implements HasInitialMinSize + { static comfyClass: string static override title: string static override category: string static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2 - /** - * @internal The initial minimum size of the node. - */ - #initialMinSize = { width: 1, height: 1 } - /** - * @internal The key for the node definition in the i18n file. - */ - get #nodeKey(): string { - return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}` - } + _initialMinSize = { width: 1, height: 1 } constructor() { super(app.graph, subgraph, instanceData) @@ -129,165 +289,14 @@ export const useLitegraphService = () => { } }) - this.#setupStrokeStyles() - this.#addInputs(ComfyNode.nodeData.inputs) - this.#addOutputs(ComfyNode.nodeData.outputs) - this.#setInitialSize() + setupStrokeStyles(this) + addInputs(this, ComfyNode.nodeData.inputs) + addOutputs(this, ComfyNode.nodeData.outputs) + setInitialSize(this) this.serialize_widgets = true void extensionService.invokeExtensionsAsync('nodeCreated', this) } - /** - * @internal Setup stroke styles for the node under various conditions. - */ - #setupStrokeStyles() { - this.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' } - } - } - this.strokeStyles['nodeError'] = function (this: LGraphNode) { - if (app.lastNodeErrors?.[this.id]?.errors) { - return { color: 'red' } - } - } - this.strokeStyles['dragOver'] = function (this: LGraphNode) { - if (app.dragOverNode?.id == this.id) { - return { color: 'dodgerblue' } - } - } - this.strokeStyles['executionError'] = function (this: LGraphNode) { - if (app.lastExecutionError?.node_id == this.id) { - return { color: '#f0f', lineWidth: 2 } - } - } - } - - /** - * @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.widgetType ?? 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 both primitive types and custom widgets - * (unless `socketless`), an input socket is also added. - */ - #addInputWidget(inputSpec: InputSpec) { - const widgetInputSpec = { ...inputSpec } - if (inputSpec.widgetType) { - widgetInputSpec.type = inputSpec.widgetType - } - const inputName = inputSpec.name - const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name` - const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type) - if (!widgetConstructor || inputSpec.forceInput) return - - const { - widget, - minWidth = 1, - minHeight = 1 - } = widgetConstructor( - this, - inputName, - transformInputSpecV2ToV1(widgetInputSpec), - app - ) ?? {} - - if (widget) { - widget.label = st(nameKey, widget.label ?? inputName) - widget.options ??= {} - Object.assign(widget.options, { - advanced: inputSpec.advanced, - hidden: inputSpec.hidden - }) - } - - if (!widget?.options?.socketless) { - const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec) - 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) { - // Use input_order if available to ensure consistent widget ordering - const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl - const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs) - - // Create sockets and widgets in the determined order - for (const inputSpec of orderedInputSpecs) - this.#addInputSocket(inputSpec) - for (const inputSpec of orderedInputSpecs) - this.#addInputWidget(inputSpec) - } - - /** - * @internal Add outputs to the node. - */ - #addOutputs(outputs: OutputSpec[]) { - for (const output of outputs) { - const { name, type, is_list } = output - const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {} - const nameKey = `${this.#nodeKey}.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) - } - this.addOutput(name, type, outputOptions) - } - } - - /** - * @internal Set the initial size of the node. - */ - #setInitialSize() { - const s = this.computeSize() - // Expand the width a little to fit widget values on screen. - const pad = - this.widgets?.length && - !useSettingStore().get('LiteGraph.Node.DefaultPadding') - s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0)) - s[1] = Math.max(this.#initialMinSize.height, s[1]) - this.setSize(s) - } - /** * Configure the node from a serialised node. Keep 'name', 'type', 'shape', * and 'localized_name' information from the original node definition. @@ -368,29 +377,23 @@ export const useLitegraphService = () => { } async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) { - const node = class ComfyNode extends LGraphNode { + const node = class ComfyNode + extends LGraphNode + implements HasInitialMinSize + { static comfyClass: string static override title: string static override category: string static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2 - /** - * @internal The initial minimum size of the node. - */ - #initialMinSize = { width: 1, height: 1 } - /** - * @internal The key for the node definition in the i18n file. - */ - get #nodeKey(): string { - return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}` - } + _initialMinSize = { width: 1, height: 1 } constructor(title: string) { super(title) - this.#setupStrokeStyles() - this.#addInputs(ComfyNode.nodeData.inputs) - this.#addOutputs(ComfyNode.nodeData.outputs) - this.#setInitialSize() + 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. @@ -402,168 +405,6 @@ export const useLitegraphService = () => { void extensionService.invokeExtensionsAsync('nodeCreated', this) } - /** - * @internal Setup stroke styles for the node under various conditions. - */ - #setupStrokeStyles() { - this.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' } - } - } - this.strokeStyles['nodeError'] = function (this: LGraphNode) { - if (app.lastNodeErrors?.[this.id]?.errors) { - return { color: 'red' } - } - } - this.strokeStyles['dragOver'] = function (this: LGraphNode) { - if (app.dragOverNode?.id == this.id) { - return { color: 'dodgerblue' } - } - } - this.strokeStyles['executionError'] = function (this: LGraphNode) { - if (app.lastExecutionError?.node_id == this.id) { - return { color: '#f0f', lineWidth: 2 } - } - } - } - - /** - * @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.widgetType ?? 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 both primitive types and custom widgets - * (unless `socketless`), an input socket is also added. - */ - #addInputWidget(inputSpec: InputSpec) { - const widgetInputSpec = { ...inputSpec } - if (inputSpec.widgetType) { - widgetInputSpec.type = inputSpec.widgetType - } - const inputName = inputSpec.name - const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name` - const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type) - if (!widgetConstructor || inputSpec.forceInput) return - - const { - widget, - minWidth = 1, - minHeight = 1 - } = widgetConstructor( - this, - inputName, - transformInputSpecV2ToV1(widgetInputSpec), - app - ) ?? {} - - if (widget) { - // Check if this is an Asset Browser button widget - const isAssetBrowserButton = - widget.type === 'button' && widget.value === 'Select model' - - if (isAssetBrowserButton) { - // Preserve Asset Browser button label (don't translate) - widget.label = String(widget.value) - } else { - // Apply normal translation for other widgets - widget.label = st(nameKey, widget.label ?? inputName) - } - - widget.options ??= {} - Object.assign(widget.options, { - advanced: inputSpec.advanced, - hidden: inputSpec.hidden - }) - } - - if (!widget?.options?.socketless) { - const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec) - 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) { - // Use input_order if available to ensure consistent widget ordering - const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl - const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs) - - // Create sockets and widgets in the determined order - for (const inputSpec of orderedInputSpecs) - this.#addInputSocket(inputSpec) - for (const inputSpec of orderedInputSpecs) - this.#addInputWidget(inputSpec) - } - - /** - * @internal Add outputs to the node. - */ - #addOutputs(outputs: OutputSpec[]) { - for (const output of outputs) { - const { name, type, is_list } = output - const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {} - const nameKey = `${this.#nodeKey}.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) - } - this.addOutput(name, type, outputOptions) - } - } - - /** - * @internal Set the initial size of the node. - */ - #setInitialSize() { - const s = this.computeSize() - // Expand the width a little to fit widget values on screen. - const pad = - this.widgets?.length && - !useSettingStore().get('LiteGraph.Node.DefaultPadding') - s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0)) - s[1] = Math.max(this.#initialMinSize.height, s[1]) - this.setSize(s) - } - /** * Configure the node from a serialised node. Keep 'name', 'type', 'shape', * and 'localized_name' information from the original node definition. @@ -1031,6 +872,7 @@ export const useLitegraphService = () => { registerNodeDef, registerSubgraphNodeDef, addNodeOnGraph, + addNodeInput, getCanvasCenter, goToNode, resetView, diff --git a/tests-ui/tests/widgets/dynamicCombo.test.ts b/tests-ui/tests/widgets/dynamicCombo.test.ts new file mode 100644 index 0000000000..3e9c539e15 --- /dev/null +++ b/tests-ui/tests/widgets/dynamicCombo.test.ts @@ -0,0 +1,90 @@ +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { describe, expect, test } from 'vitest' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration' +import type { InputSpec } from '@/schemas/nodeDefSchema' +import { useLitegraphService } from '@/services/litegraphService' +import type { HasInitialMinSize } from '@/services/litegraphService' + +setActivePinia(createTestingPinia()) +type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][] + +const { addNodeInput } = useLitegraphService() + +function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) { + const namePrefix = `${node.widgets?.length ?? 0}` + function getSpec( + inputs: DynamicInputs, + depth: number = 0 + ): { key: string; inputs: object }[] { + return inputs.map((group, groupIndex) => { + const inputs = group.map((input, inputIndex) => [ + `${namePrefix}.${depth}.${inputIndex}`, + Array.isArray(input) + ? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }] + : [input, {}] + ]) + return { + key: `${groupIndex}`, + inputs: { required: Object.fromEntries(inputs) } + } + }) + } + const inputSpec: Required = [ + 'COMFY_DYNAMICCOMBO_V3', + { options: getSpec(inputs) } + ] + addNodeInput( + node, + transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false }) + ) +} +function testNode() { + const node: LGraphNode & Partial = new LGraphNode('test') + node.widgets = [] + node._initialMinSize = { width: 1, height: 1 } + node.constructor.nodeData = { + name: 'testnode' + } as typeof node.constructor.nodeData + return node as LGraphNode & Required> +} + +describe('Dynamic Combos', () => { + test('Can add widget on selection', () => { + const node = testNode() + addDynamicCombo(node, [['INT'], ['INT', 'STRING']]) + expect(node.widgets.length).toBe(2) + node.widgets[0].value = '1' + expect(node.widgets.length).toBe(3) + }) + test('Can add nested widgets', () => { + const node = testNode() + addDynamicCombo(node, [['INT'], [[[], ['STRING']]]]) + expect(node.widgets.length).toBe(2) + node.widgets[0].value = '1' + expect(node.widgets.length).toBe(2) + node.widgets[1].value = '1' + expect(node.widgets.length).toBe(3) + }) + test('Can add input', () => { + const node = testNode() + addDynamicCombo(node, [['INT'], ['IMAGE']]) + expect(node.widgets.length).toBe(2) + node.widgets[0].value = '1' + expect(node.widgets.length).toBe(1) + expect(node.inputs.length).toBe(2) + expect(node.inputs[1].type).toBe('IMAGE') + }) + test('Dynamically added inputs are well ordered', () => { + const node = testNode() + addDynamicCombo(node, [['INT'], ['IMAGE']]) + addDynamicCombo(node, [['INT'], ['IMAGE']]) + node.widgets[2].value = '1' + node.widgets[0].value = '1' + expect(node.widgets.length).toBe(2) + expect(node.inputs.length).toBe(4) + expect(node.inputs[1].name).toBe('0.0.0') + expect(node.inputs[3].name).toBe('2.0.0') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 5470e2c2df..6bb37354c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "lib": [ "ES2023", "ES2023.Array", + "ESNext.Iterator", "DOM", "DOM.Iterable" ],