Compare commits

...

3 Commits

Author SHA1 Message Date
Talmaj Marinc
10d2030de0 Fix dynamic resizing of the node when removing a DynamicGroup. 2026-06-27 12:50:07 +02:00
Talmaj Marinc
20c238fdc5 Use Nodes 2.0 only 2026-06-27 12:27:12 +02:00
Talmaj Marinc
8d397c61fa Initial commit for DynamiGroupSupport. 2026-06-27 12:27:12 +02:00
10 changed files with 568 additions and 4 deletions

View File

@@ -344,6 +344,16 @@ export const zDynamicComboInputSpec = z.tuple([
})
])
export const zDynamicGroupInputSpec = z.tuple([
z.literal('COMFY_DYNAMICGROUP_V3'),
zBaseInputOptions.extend({
template: zComfyInputsSpec,
min: z.number().int().nonnegative().optional().default(0),
max: z.number().int().positive().max(100).optional().default(50),
group_name: z.string().optional()
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

View File

@@ -79,6 +79,7 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -206,7 +207,8 @@ function extractWidgetDisplayOptions(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
read_only: widget.options.read_only,
removable: widget.options.removable
}
}

View File

@@ -1,5 +1,9 @@
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
import {
zAutogrowOptions,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
(inputSpec: InputSpecV2) => string[]
> = {
COMFY_AUTOGROW_V3: resolveAutogrowType,
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
COMFY_MATCHTYPE_V3: (input) =>
zMatchTypeOptions
.safeParse(input)
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
: input.type.split(',')
}
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
const template = parsed.data?.[1]?.template
if (!template) return []
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
template.required,
template.optional
]
return inputTypes.flatMap((inputType) =>
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
resolveInputType(transformInputSpecV1ToV2(v, { name }))
)
)
}
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}

View File

@@ -1,6 +1,6 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import { describe, expect, test } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -47,6 +47,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addDynamicGroup(
node: LGraphNode,
template: object,
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
) {
const options: Record<string, unknown> = { template }
if (min !== undefined) options.min = min
if (max !== undefined) options.max = max
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
name,
isOptional: false
})
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
@@ -287,3 +303,85 @@ describe('Autogrow', () => {
])
})
})
describe('Dynamic Groups', () => {
const stringTemplate = { required: { a: ['STRING', {}] } }
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
const widgetNamed = (node: LGraphNode, name: string) =>
node.widgets!.find((w) => w.name === name)!
test('renders min rows on creation', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
expect(inputNames(node)).toStrictEqual([])
})
test('add row appends a new row up to max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
expect(widgetNames(node)).toStrictEqual(['g'])
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
// At max, further adds are ignored.
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
})
test('controller disabled option set at max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 1 })
expect(widgetNamed(node, 'g').options?.disabled).toBe(false)
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNamed(node, 'g').options?.disabled).toBe(true)
})
test('remove row renumbers later rows', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
const state = (
node as Parameters<typeof widgetNamed>[0] & {
comfyDynamic: {
dynamicGroup: Record<
string,
{ addRow: () => void; removeRow: (r: number) => void }
>
}
}
).comfyDynamic.dynamicGroup['g']
state.addRow()
state.addRow()
state.addRow()
const row0Field = widgetNamed(node, 'g.0.a')
const row2Field = widgetNamed(node, 'g.2.a')
state.removeRow(1)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
// Row 0 is untouched; the former row 2 shifts down into row 1.
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
})
test('rows below min cannot be removed', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
const state = (
node as Parameters<typeof widgetNamed>[0] & {
comfyDynamic: {
dynamicGroup: Record<string, { removeRow: (r: number) => void }>
}
}
).comfyDynamic.dynamicGroup['g']
// Row 0 is at the min boundary — removing it is a no-op.
state.removeRow(0)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
})
})

View File

@@ -13,11 +13,13 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
zAutogrowOptions,
zDynamicComboInputSpec,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -28,6 +30,18 @@ import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
type DynamicGroupState = {
min: number
max: number
groupName?: string
inputSpecs: InputSpecV2[]
addRow: () => void
removeRow: (row: number) => void
}
export type DynamicGroupNode = LGraphNode & {
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
}
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
@@ -214,7 +228,217 @@ function dynamicComboWidget(
return { widget, minWidth, minHeight }
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
function withComfyDynamicGroup(
node: LGraphNode
): asserts node is DynamicGroupNode {
if (node.comfyDynamic?.dynamicGroup) return
node.comfyDynamic ??= {}
node.comfyDynamic.dynamicGroup = {}
}
const fieldName = (group: string, row: number, field: string) =>
`${group}.${row}.${field}`
/** Rename a field that sits above the removed row, shifting its index down. */
function shiftedFieldName(
group: string,
name: string,
removedRow: number
): string | undefined {
const prefix = `${group}.`
if (!name.startsWith(prefix)) return undefined
const rest = name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) return undefined
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row) || row <= removedRow) return undefined
return fieldName(group, row - 1, rest.slice(dot + 1))
}
const isGroupField = (group: string, name: string) =>
name.startsWith(`${group}.`)
const belongsToRow = (group: string, name: string, row: number): boolean =>
name.startsWith(`${group}.${row}.`)
function countGroupRows(group: string, node: LGraphNode): number {
const rows = new Set<number>()
for (const w of node.widgets ?? []) {
if (!isGroupField(group, w.name)) continue
const rest = w.name.slice(group.length + 1)
const dot = rest.indexOf('.')
if (dot !== -1) {
const row = Number(rest.slice(0, dot))
if (Number.isInteger(row)) rows.add(row)
}
}
return rows.size
}
/** Build field widgets for a single row, returning them detached from the node. */
function createRow(
group: string,
row: number,
state: DynamicGroupState,
node: DynamicGroupNode
): IBaseWidget[] {
const { addNodeInput } = useLitegraphService()
const startLen = node.widgets!.length
for (const spec of state.inputSpecs)
addNodeInput(node, {
...spec,
name: fieldName(group, row, spec.name),
display_name: spec.display_name ?? spec.name,
hidden: true,
socketless: true
})
return node.widgets!.splice(startLen)
}
function insertRowAfterGroup(
group: string,
node: LGraphNode,
rowWidgets: IBaseWidget[]
): void {
const lastIdx = node.widgets!.findLastIndex(
(w) => w.name === group || isGroupField(group, w.name)
)
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
}
function syncController(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
const controller = node.widgets?.find((w) => w.name === group)
if (!state || !controller) return
controller.options ??= {}
controller.options.disabled = countGroupRows(group, node) >= state.max
// Route through setSize (not `size[1] = …`) so the layout store and the Vue
// node's min-height floor are updated; a direct buffer write bypasses the
// size setter and leaves the node unable to shrink after rows are removed.
node.setSize([node.size[0], node.computeSize()[1]])
}
function addRow(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const row = countGroupRows(group, node)
if (row >= state.max) return
insertRowAfterGroup(group, node, createRow(group, row, state, node))
syncController(group, node)
app.canvas?.setDirty(true, true)
}
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state || row < state.min) return
for (const w of remove(node.widgets!, (w) =>
belongsToRow(group, w.name, row)
))
w.onRemove?.()
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
for (const w of node.widgets ?? []) {
const shifted = shiftedFieldName(group, w.name, row)
if (shifted !== undefined) w.name = shifted
}
for (const inp of node.inputs) {
const shifted = shiftedFieldName(group, inp.name, row)
if (shifted === undefined) continue
inp.name = shifted
if (inp.widget) inp.widget.name = shifted
}
syncController(group, node)
app.canvas?.setDirty(true, true)
}
/** Rebuild the group from scratch to hold exactly `count` rows. */
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const isRowMember = (name: string) => isGroupField(group, name)
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
w.onRemove?.()
remove(node.inputs, (inp) => isRowMember(inp.name))
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
const rowWidgets: IBaseWidget[] = []
for (let row = 0; row < count; row++)
rowWidgets.push(...createRow(group, row, state, node))
node.widgets.splice(insertAt, 0, ...rowWidgets)
}
function dynamicGroupWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
_appArg: ComfyApp
) {
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
const [, { template, min, max, group_name: groupName }] = parseResult.data
const toSpecs = (
inputs: Record<string, InputSpec> | undefined,
isOptional: boolean
) =>
Object.entries(inputs ?? {}).map(([name, spec]) =>
transformInputSpecV1ToV2(spec, { name, isOptional })
)
const inputSpecs = [
...toSpecs(template.required, false),
...toSpecs(template.optional, true)
]
withComfyDynamicGroup(node)
const typedNode = node as DynamicGroupNode
typedNode.comfyDynamic.dynamicGroup[inputName] = {
min,
max,
groupName,
inputSpecs,
addRow: () => addRow(inputName, typedNode),
removeRow: (row: number) => removeRow(inputName, row, typedNode)
}
node.widgets ??= []
const controller = node.addCustomWidget({
name: inputName,
type: 'dynamic_group',
value: min,
y: 0,
serialize: true,
callback: () => addRow(inputName, typedNode),
options: { socketless: true, disabled: false, min, max }
})
Object.defineProperty(controller, 'value', {
get() {
return countGroupRows(inputName, typedNode)
},
set(count: unknown) {
if (typeof count !== 'number') return
rebuildRows(inputName, count, typedNode)
syncController(inputName, typedNode)
},
configurable: true
})
controller.value = min
return { widget: controller }
}
export const dynamicWidgets = {
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
}
const dynamicInputs: Record<
string,
(node: LGraphNode, inputSpec: InputSpecV2) => void

View File

@@ -75,6 +75,7 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean

View File

@@ -2248,6 +2248,12 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"dynamicGroup": {
"addGroup": "Add {group_name}",
"removeGroup": "Remove {group_name}",
"group": "{group_name} #{index}",
"defaultGroupName": "Group"
},
"oauth": {
"consent": {
"allow": "Continue",

View File

@@ -0,0 +1,190 @@
<template>
<div
class="col-span-2 grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-x-2 gap-y-1"
>
<template v-for="row in rowIndices" :key="row">
<div
class="col-span-2 mt-1 flex items-center justify-between border-t border-node-component-surface pt-1"
>
<span
class="truncate text-xs font-medium text-node-component-slot-text"
>
{{
t('dynamicGroup.group', { group_name: groupName, index: row + 1 })
}}
</span>
<button
v-if="row >= minRows"
v-tooltip.top="
t('dynamicGroup.removeGroup', { group_name: groupName })
"
type="button"
class="mr-1.75 flex cursor-pointer appearance-none border-0 bg-transparent p-0 text-node-component-slot-text/40 transition-colors duration-150 hover:text-danger-100 focus-visible:outline-none"
:aria-label="t('dynamicGroup.removeGroup', { group_name: groupName })"
@click="onRemoveRow(row)"
>
<span
class="icon-[material-symbols--close] size-4"
aria-hidden="true"
/>
</button>
</div>
<component
:is="fw.component"
v-for="fw in rowWidgets(row)"
:key="fw.name"
:model-value="fw.value"
:widget="fw.simplified"
:node-id="nodeId"
:node-type="nodeType"
class="col-span-2"
@update:model-value="fw.onUpdate"
/>
</template>
<Button
:disabled="addDisabled"
class="col-span-2 mt-1 border-0 bg-component-node-widget-background text-node-component-slot-text"
size="sm"
variant="textonly"
@click="onAddRow"
>
<span
class="mr-1 icon-[material-symbols--add] size-4"
aria-hidden="true"
/>
{{ t('dynamicGroup.addGroup', { group_name: groupName }) }}
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetState } from '@/types/widgetState'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
const { widget, nodeId, nodeType } = defineProps<{
widget: SimplifiedWidget<number>
nodeId: string
nodeType?: string
}>()
const { t } = useI18n()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const group = widget.name
const node = computed(
() => app.graph?.getNodeById(toNodeId(nodeId)) as DynamicGroupNode | undefined
)
const groupState = computed(
() => node.value?.comfyDynamic?.dynamicGroup?.[group]
)
const minRows = computed(() => groupState.value?.min ?? 0)
const groupName = computed(
() => groupState.value?.groupName ?? t('dynamicGroup.defaultGroupName')
)
interface FieldWidgetView {
name: string
row: number
component: Component
simplified: SimplifiedWidget
value: WidgetValue
onUpdate: (value: WidgetValue) => void
}
function resolveWidgetState(w: IBaseWidget): WidgetState | undefined {
if (w.widgetId) return widgetValueStore.getWidget(w.widgetId)
const graphId = node.value?.graph?.rootGraph?.id
if (!graphId) return undefined
const localId = stripGraphPrefix(String(nodeId))
if (!localId) return undefined
return widgetValueStore.getWidget(widgetId(graphId, localId, w.name))
}
function toFieldView(
n: DynamicGroupNode,
w: IBaseWidget,
row: number,
fieldName: string
): FieldWidgetView {
const state = resolveWidgetState(w)
const value = state?.value ?? w.value
const simplified: SimplifiedWidget = {
name: w.name,
type: state?.type ?? w.type,
value,
label: state?.label ?? w.label ?? fieldName,
options: state?.options ?? w.options,
spec: nodeDefStore.getInputSpecForWidget(n, w.name)
}
return {
name: w.name,
row,
component: getComponent(w.type) ?? WidgetLegacy,
simplified,
value,
onUpdate: (next: WidgetValue) => {
if (state) state.value = next
w.value = next ?? undefined
w.callback?.(next)
}
}
}
const fieldWidgets = computed<FieldWidgetView[]>(() => {
const n = node.value
if (!n?.widgets) return []
const prefix = `${group}.`
const views: FieldWidgetView[] = []
for (const w of n.widgets) {
if (!w.name.startsWith(prefix)) continue
const rest = w.name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) continue
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row)) continue
views.push(toFieldView(n, w, row, rest.slice(dot + 1)))
}
return views
})
const rowIndices = computed(() =>
[...new Set(fieldWidgets.value.map((fw) => fw.row))].sort((a, b) => a - b)
)
const addDisabled = computed(
() => rowIndices.value.length >= (groupState.value?.max ?? Infinity)
)
function rowWidgets(row: number): FieldWidgetView[] {
return fieldWidgets.value.filter((fw) => fw.row === row)
}
function onAddRow() {
groupState.value?.addRow()
}
function onRemoveRow(row: number) {
groupState.value?.removeRow(row)
}
</script>

View File

@@ -75,6 +75,10 @@ const WidgetBoundingBoxes = defineAsyncComponent(
const WidgetColors = defineAsyncComponent(
() => import('@/components/palette/WidgetColors.vue')
)
const WidgetDynamicGroup = defineAsyncComponent(
() =>
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroup.vue')
)
export const FOR_TESTING = {
WidgetButton,
@@ -241,6 +245,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
aliases: ['COLORS'],
essential: false
}
],
[
'dynamic_group',
{
component: WidgetDynamicGroup,
aliases: [],
essential: false
}
]
]

View File

@@ -304,6 +304,7 @@ export const useLitegraphService = () => {
hidden: inputSpec.hidden
})
if (inputSpec.hidden !== undefined) widget.hidden = inputSpec.hidden
if (inputSpec.socketless) widget.options.socketless = true
if (dynamic) widget.tooltip = inputSpec.tooltip
}