mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-31 09:45:46 +00:00
Compare commits
7 Commits
coderabbit
...
austin/nam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b141f9f2d | ||
|
|
bd1e40b325 | ||
|
|
13a30f9d3b | ||
|
|
7ed9616a26 | ||
|
|
460f52e3b3 | ||
|
|
fb785e5178 | ||
|
|
c204fa30d6 |
45
src/core/graph/widgets/__fixtures__/dynamicInputHelpers.ts
Normal file
45
src/core/graph/widgets/__fixtures__/dynamicInputHelpers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
|
||||
export 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, { tooltip: `${groupIndex}` }]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
inputs: { required: Object.fromEntries(inputs) }
|
||||
}
|
||||
})
|
||||
}
|
||||
const inputSpec: Required<InputSpec> = [
|
||||
'COMFY_DYNAMICCOMBO_V3',
|
||||
{ options: getSpec(inputs) }
|
||||
]
|
||||
useLitegraphService().addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
|
||||
export function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
useLitegraphService().addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_AUTOGROW_V3', { template }], {
|
||||
name: `${node.inputs.length}`,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
addAutogrow,
|
||||
addDynamicCombo
|
||||
} from '@/core/graph/widgets/__fixtures__/dynamicInputHelpers'
|
||||
import { LGraph, 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()
|
||||
|
||||
@@ -16,43 +17,6 @@ function nextTick() {
|
||||
return new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
|
||||
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, { tooltip: `${groupIndex}` }]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
inputs: { required: Object.fromEntries(inputs) }
|
||||
}
|
||||
})
|
||||
}
|
||||
const inputSpec: Required<InputSpec> = [
|
||||
'COMFY_DYNAMICCOMBO_V3',
|
||||
{ options: getSpec(inputs) }
|
||||
]
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_AUTOGROW_V3', { template }], {
|
||||
name: `${node.inputs.length}`,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
const node2 = testNode()
|
||||
node2.addOutput('out', '*')
|
||||
@@ -60,12 +24,8 @@ function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
node2.connect(0, node, inputIndex)
|
||||
}
|
||||
function testNode() {
|
||||
const node: LGraphNode & Partial<HasInitialMinSize> = new LGraphNode('test')
|
||||
const node = 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<Pick<LGraphNode, 'widgets'>>
|
||||
}
|
||||
|
||||
|
||||
@@ -907,7 +907,26 @@ export class LGraphNode
|
||||
)
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
const getNamedValues = () => {
|
||||
if (info.widgets_values_named) return info.widgets_values_named
|
||||
|
||||
const map = this.constructor.nodeData?.fallbackWidgetsValuesNames
|
||||
if (!info.widgets_values || !map) return
|
||||
const namedValues: Record<string, TWidgetValue> = {}
|
||||
info.widgets_values.forEach((val, i) => {
|
||||
if (map[i]) namedValues[map[i]] = val
|
||||
})
|
||||
return namedValues
|
||||
}
|
||||
const namedValues = getNamedValues()
|
||||
if (namedValues && LiteGraph.namedValuesRestore) {
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false || !(widget.name in namedValues))
|
||||
continue
|
||||
|
||||
widget.value = namedValues[widget.name]
|
||||
}
|
||||
} else if (info.widgets_values) {
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
@@ -962,16 +981,19 @@ export class LGraphNode
|
||||
if (this.properties) o.properties = LiteGraph.cloneObject(this.properties)
|
||||
|
||||
const { widgets } = this
|
||||
if (widgets && this.serialize_widgets) {
|
||||
if (widgets?.length && this.serialize_widgets) {
|
||||
o.widgets_values = []
|
||||
o.widgets_values_named = {}
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
if (widget.serialize === false) continue
|
||||
const val = widget?.value
|
||||
const val = widget.value
|
||||
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
|
||||
o.widgets_values[i] =
|
||||
const serialisedVal =
|
||||
val != null && typeof val === 'object'
|
||||
? JSON.parse(JSON.stringify(val))
|
||||
: (val ?? null)
|
||||
o.widgets_values[i] = serialisedVal
|
||||
o.widgets_values_named[widget.name] = serialisedVal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { addDynamicCombo } from '@/core/graph/widgets/__fixtures__/dynamicInputHelpers'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
|
||||
describe('LGraphNode widget ordering', () => {
|
||||
@@ -15,6 +17,9 @@ describe('LGraphNode widget ordering', () => {
|
||||
})
|
||||
|
||||
describe('configure with widgets_values', () => {
|
||||
beforeEach(() => {
|
||||
LiteGraph.namedValuesRestore = false
|
||||
})
|
||||
it('should apply widget values in correct order when widgets order matches input_order', () => {
|
||||
// Create node with widgets
|
||||
node.addWidget('number', 'steps', 20, null, {})
|
||||
@@ -98,6 +103,120 @@ describe('LGraphNode widget ordering', () => {
|
||||
expect(node.widgets![2].value).toBe(12345) // seed
|
||||
})
|
||||
})
|
||||
|
||||
describe('configure with widgets_values_named', () => {
|
||||
beforeEach(() => {
|
||||
LiteGraph.namedValuesRestore = true
|
||||
})
|
||||
it('should apply widget values from widgets_values_named', () => {
|
||||
// Create node with widgets
|
||||
node.addWidget('number', 'steps', 20, null, {})
|
||||
node.addWidget('number', 'seed', 0, null, {})
|
||||
node.addWidget('text', 'prompt', '', null, {})
|
||||
|
||||
// Configure with widget values
|
||||
const info: ISerialisedNode = {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
widgets_values: [30, 12345, 'test prompt'],
|
||||
widgets_values_named: { steps: 15, prompt: 'prompt', seed: 54321 }
|
||||
}
|
||||
|
||||
node.configure(info)
|
||||
|
||||
// Check widget values are applied correctly
|
||||
expect(node.widgets![0].value).toBe(15) // steps
|
||||
expect(node.widgets![1].value).toBe(54321) // seed
|
||||
expect(node.widgets![2].value).toBe('prompt') // prompt
|
||||
})
|
||||
it('should skip widgets with serialize: false', () => {
|
||||
node.addWidget('number', 'steps', 20, null, {})
|
||||
node.addWidget('button', 'action', 'Click', null, {})
|
||||
node.widgets![1].serialize = false // button should not serialize
|
||||
node.addWidget('number', 'seed', 0, null, {})
|
||||
|
||||
const info: ISerialisedNode = {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
widgets_values: [30, 12345], // Only serializable widgets
|
||||
widgets_values_named: { steps: 30, seed: 12345 }
|
||||
}
|
||||
|
||||
node.configure(info)
|
||||
|
||||
expect(node.widgets![0].value).toBe(30) // steps
|
||||
expect(node.widgets![1].value).toBe('Click') // button unchanged
|
||||
expect(node.widgets![2].value).toBe(12345) // seed
|
||||
})
|
||||
it('should restore widgets which are dynamically added', () => {
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
|
||||
const info: ISerialisedNode = {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
widgets_values_named: { '0': 1, '0.0.0.0': 5, '0.0.0.1': 'test' }
|
||||
}
|
||||
|
||||
node.configure(info)
|
||||
|
||||
expect(node.widgets![0].value).toBe(1)
|
||||
expect(node.widgets![1].value).toBe(5)
|
||||
expect(node.widgets![2].value).toBe('test')
|
||||
})
|
||||
it('should support restoration even when order has changed', () => {
|
||||
node.addWidget('number', 'steps', 20, null, {})
|
||||
node.addWidget('number', 'seed', 5, null, {})
|
||||
node.serialize_widgets = true
|
||||
|
||||
const node2 = new LGraphNode('TestNode2')
|
||||
node2.addWidget('number', 'seed', 0, null, {})
|
||||
node2.addWidget('number', 'steps', 0, null, {})
|
||||
|
||||
node2.configure(node.serialize())
|
||||
|
||||
expect(node2.widgets![0].value).toBe(5) // steps
|
||||
expect(node2.widgets![1].value).toBe(20) // seed
|
||||
})
|
||||
it('should support specifying order for legacy workflows', () => {
|
||||
node.addWidget('number', 'steps', 0, null, {})
|
||||
node.addWidget('number', 'seed', 0, null, {})
|
||||
const nodeData = {
|
||||
fallbackWidgetsValuesNames: ['seed', 'steps']
|
||||
} satisfies Partial<ComfyNodeDef> as unknown as ComfyNodeDef
|
||||
node.constructor = Object.assign({}, node.constructor, { nodeData })
|
||||
|
||||
const info: ISerialisedNode = {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
widgets_values: [20, 5] // Only serializable widgets
|
||||
}
|
||||
|
||||
node.configure(info)
|
||||
|
||||
expect(node.widgets![0].value).toBe(5) // steps
|
||||
expect(node.widgets![1].value).toBe(20) // seed
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortWidgetValuesByInputOrder', () => {
|
||||
|
||||
@@ -344,6 +344,13 @@ export class LiteGraphGlobal {
|
||||
*/
|
||||
saveViewportWithGraph: boolean = true
|
||||
|
||||
/**
|
||||
* If `true`, widgets values are deserialised using by a map of widget names to values instead of an list
|
||||
* This is intended as a temporary setting. It is planned to be made the default and eventually removed.
|
||||
* @default false
|
||||
*/
|
||||
namedValuesRestore: boolean = false
|
||||
|
||||
/**
|
||||
* Enable Vue nodes mode for rendering and positioning.
|
||||
* When true:
|
||||
|
||||
@@ -163,6 +163,7 @@ LiteGraphGlobal {
|
||||
"macTrackpadGestures": false,
|
||||
"middle_click_slot_add_default_node": false,
|
||||
"mouseWheelScroll": "panning",
|
||||
"namedValuesRestore": false,
|
||||
"nodeLightness": undefined,
|
||||
"nodeOpacity": 1,
|
||||
"node_box_coloured_by_mode": false,
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface ISerialisedNode {
|
||||
* See example in https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/8629188458dc6cb832f871ece3bd273507e8a766/web/js/VHS.core.js#L59-L84
|
||||
*/
|
||||
widgets_values?: TWidgetValue[]
|
||||
widgets_values_named?: Record<string, TWidgetValue>
|
||||
}
|
||||
|
||||
/** Properties of nodes that are used by subgraph instances. */
|
||||
|
||||
@@ -170,6 +170,11 @@ export const useLitegraphSettings = () => {
|
||||
'Comfy.EnableWorkflowViewRestore'
|
||||
)
|
||||
})
|
||||
watchEffect(() => {
|
||||
LiteGraph.namedValuesRestore = settingStore.get(
|
||||
'Comfy.Workflow.NamedValuesRestore'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const selectChildren = settingStore.get(
|
||||
|
||||
@@ -149,6 +149,13 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Workflow.NamedValuesRestore',
|
||||
name: 'Restore widget values by name',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.NavigationMode',
|
||||
category: ['LiteGraph', 'Canvas Navigation', 'NavigationMode'],
|
||||
|
||||
@@ -366,6 +366,7 @@ const zSettings = z.object({
|
||||
'Comfy.TreeExplorer.ItemPadding': z.number(),
|
||||
'Comfy.Validation.Workflows': z.boolean(),
|
||||
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
|
||||
'Comfy.Workflow.NamedValuesRestore': z.boolean(),
|
||||
'Comfy.Execution.PreviewMethod': zPreviewMethod,
|
||||
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
|
||||
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
|
||||
|
||||
@@ -314,7 +314,8 @@ export const zComfyNodeDef = z.object({
|
||||
/** Category for the Essentials tab. If set, the node appears in Essentials. */
|
||||
essentials_category: z.string().optional(),
|
||||
/** Whether the blueprint is a global/installed blueprint (not user-created). */
|
||||
isGlobal: z.boolean().optional()
|
||||
isGlobal: z.boolean().optional(),
|
||||
fallbackWidgetsValuesNames: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zAutogrowOptions = z.object({
|
||||
|
||||
@@ -76,7 +76,7 @@ import { useExtensionService } from './extensionService'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
export interface HasInitialMinSize {
|
||||
_initialMinSize: { width: number; height: number }
|
||||
_initialMinSize?: { width: number; height: number }
|
||||
}
|
||||
|
||||
export const CONFIG = Symbol()
|
||||
@@ -163,7 +163,7 @@ export const useLitegraphService = () => {
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
function nodeKey(node: LGraphNode): string {
|
||||
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData!.name)}`
|
||||
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData?.name ?? '')}`
|
||||
}
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
@@ -270,6 +270,7 @@ export const useLitegraphService = () => {
|
||||
})
|
||||
}
|
||||
const castedNode = node as LGraphNode & HasInitialMinSize
|
||||
castedNode._initialMinSize ??= { width: 1, height: 1 }
|
||||
castedNode._initialMinSize.width = Math.max(
|
||||
castedNode._initialMinSize.width,
|
||||
minWidth
|
||||
@@ -327,8 +328,9 @@ export const useLitegraphService = () => {
|
||||
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])
|
||||
const minSize = castedNode._initialMinSize ?? { width: 1, height: 1 }
|
||||
s[0] = Math.max(minSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(minSize.height, s[1])
|
||||
node.setSize(s)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user