Compare commits

...

7 Commits

Author SHA1 Message Date
Austin Mroz
4b141f9f2d Add test for serialization 2026-03-30 12:06:35 -07:00
Austin Mroz
bd1e40b325 Rework minsize code to better support tests 2026-03-30 11:24:02 -07:00
Austin Mroz
13a30f9d3b Review feedback 2026-03-23 16:46:59 -07:00
Austin Mroz
7ed9616a26 Add tests 2026-03-23 12:17:10 -07:00
Austin Mroz
460f52e3b3 Guard load by named values behind setting 2026-03-23 10:35:45 -07:00
Austin Mroz
fb785e5178 Trigger widget.callback when loading 2026-03-23 09:56:47 -07:00
Austin Mroz
c204fa30d6 Implement widgets_values_named for serialization 2026-03-21 21:11:27 -07:00
12 changed files with 227 additions and 56 deletions

View 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
})
)
}

View File

@@ -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'>>
}

View File

@@ -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
}
}

View File

@@ -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', () => {

View File

@@ -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:

View File

@@ -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,

View File

@@ -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. */

View File

@@ -170,6 +170,11 @@ export const useLitegraphSettings = () => {
'Comfy.EnableWorkflowViewRestore'
)
})
watchEffect(() => {
LiteGraph.namedValuesRestore = settingStore.get(
'Comfy.Workflow.NamedValuesRestore'
)
})
watchEffect(() => {
const selectChildren = settingStore.get(

View File

@@ -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'],

View File

@@ -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(),

View File

@@ -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({

View File

@@ -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)
}