new list utility nodes (#13)

* add List-related nodes for Checkpoints / LoRAs / Samplers/ Schedulers
* wiki: reorg XY Plot 3rd tutorial and add example
* bump version
This commit is contained in:
Bug, Ltd.
2025-01-29 11:18:14 +01:00
committed by GitHub
parent 1c6034bbcb
commit 9dcf6660de
45 changed files with 1613 additions and 400 deletions
+143 -51
View File
@@ -1,6 +1,10 @@
import hashlib
import math
import random
from pathlib import Path
import folder_paths # type: ignore
from comfy.samplers import KSampler # type: ignore
from ..collection.register_nodes import register_node
@@ -165,56 +169,6 @@ class ListFromMultiline:
return (output, len(output))
# @register_node('Input Multiline To Batch', 'list')
# class BatchFromMultiline:
# def __init__(self):
# pass
# @classmethod
# def INPUT_TYPES(s):
# return {
# 'required': {
# 'separator': (
# 'STRING',
# {'default': ',', 'multiline': False},
# ),
# 'index': (
# 'INT',
# {
# 'min': 0,
# 'default': 0,
# },
# ),
# 'multiline': (
# 'STRING',
# {'default': '', 'multiline': True},
# ),
# }
# }
# RETURN_TYPES = ('LIST', 'INT', 'INT')
# RETURN_NAMES = ('list', 'batch index', 'batch total')
# FUNCTION = 'run'
# def run(
# self,
# separator: str,
# index: int,
# multiline: str,
# ):
# lines = multiline.split('\n')
# output = []
# for line in lines:
# tokens = line.split(separator)
# trimmed = []
# for token in tokens:
# trimmed.append(token.strip())
# output.append(trimmed)
# return (output[index], index, len(output))
@register_node('List: from File (backend)', 'list')
class ListFromFile:
def __init__(self):
@@ -410,7 +364,7 @@ class ListRandomSeeds:
'list of random seeds',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Return a list of random seeds.'
DESCRIPTION = 'Create a list of random seeds.'
# ensure the list is regenerated
@classmethod
@@ -422,3 +376,141 @@ class ListRandomSeeds:
for i in range(0, count):
values.append(math.floor(random.randrange(0, 0xFFFFFFFFFFFFFFFF)))
return (values, len(values))
@register_node('List: Checkpoints', 'list')
class ListCheckpoints:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'selection': (
'SELECTION_LIST',
{'all': folder_paths.get_filename_list('checkpoints')},
),
'with_extension': (
'BOOLEAN',
{'default': True, 'tooltip': 'keep file extension?'},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'list of checkpoint file names',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Create a list from selected checkpoint file names.'
def run(self, with_extension, selection, **kwargs):
output = []
for file in selection['selected']:
if not with_extension:
output.append(str(Path(file).stem))
else:
output.append(file)
return (output, len(output))
@register_node('List: LoRAs', 'list')
class ListLoras:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'selection': (
'SELECTION_LIST',
{'all': folder_paths.get_filename_list('loras')},
),
'with_extension': (
'BOOLEAN',
{'default': True, 'tooltip': 'keep file extension?'},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'list of LoRA file names',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Create a list from selected LoRA file names.'
def run(self, with_extension, selection, **kwargs):
output = []
for file in selection['selected']:
if not with_extension:
output.append(str(Path(file).stem))
else:
output.append(file)
return (output, len(output))
@register_node('List: Samplers', 'list')
class ListSamplers:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'selection': (
'SELECTION_LIST',
{'all': KSampler.SAMPLERS},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'list of sampler names',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Create a list from selected sampler names.'
def run(self, selection, **kwargs):
output = selection['selected']
return (output, len(output))
@register_node('List: Schedulers', 'list')
class ListSchedulers:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'selection': (
'SELECTION_LIST',
{'all': KSampler.SCHEDULERS},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'list of scheduler names',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Create a list from selected scheduler names.'
def run(self, selection, **kwargs):
output = selection['selected']
return (output, len(output))
+30 -25
View File
@@ -107,6 +107,16 @@ const customLabels = {
nb_elements: 'nb of elements',
},
},
ListCheckpoints: {
widget: {
with_extension: 'with file extension?',
},
},
ListLoras: {
widget: {
with_extension: 'with file extension?',
},
},
// CONFIG
OutputConfigBackend: {
widget: {
@@ -121,31 +131,6 @@ const customLabels = {
show_config: 'show config',
},
},
OutputConfigAuto: {
widget: {
auto_connect: 'auto-connect?',
multi_nodes: 'if multiple nodes found',
error_display: 'display errors',
show_config: 'show config',
},
},
NodeConfigApplyLocal: {
widget: {
error_display: 'display errors',
check_level: 'reject on',
},
},
MultiConfigFetch: {
widget: {
node_by: 'identify nodes by',
},
},
MultiConfigApplyLocal: {
widget: {
error_display: 'display errors',
check_level: 'reject on',
},
},
// QUEUE
GenericQueue: {
input: {
@@ -270,6 +255,26 @@ const customLooks = {
list: { shape: 'grid' },
},
},
ListCheckpoints: {
output: {
list: { shape: 'grid' },
},
},
ListLoras: {
output: {
list: { shape: 'grid' },
},
},
ListSamplers: {
output: {
list: { shape: 'grid' },
},
},
ListSchedulers: {
output: {
list: { shape: 'grid' },
},
},
} as const as Record<string, CustomLook>
function setNodeLabels(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+106 -1
View File
@@ -1,6 +1,7 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { LGraphNode, IWidget } from '@comfyorg/litegraph'
import { findWidget, loadTextFile } from '~/shared/utils.js'
import { makeComboWidget } from '~/widgets/factories.js'
export function ListFromMultiline(
nodeType: LGraphNode,
@@ -23,3 +24,107 @@ export function ListFromMultiline(
}
}
}
export function ListFromSelection(
nodeType: LGraphNode,
selection_type: string,
) {
const isCombo = (widget: IWidget) =>
widget.name.startsWith(`${selection_type}_`.toLowerCase())
const findCombos = (node: LGraphNode) =>
node.widgets.filter((w: IWidget) => isCombo(w))
const addCombo = (node: LGraphNode, selectionWidget: IWidget) => {
const index = findCombos(node).length
const width = node.size[0]
const widget = makeComboWidget(
node,
`${selection_type}_${index}`.toLowerCase(),
['none', ...selectionWidget.all],
0,
)
// keep node width
node.setSize([width, node.size[1]])
widget.label = `${selection_type} #${index + 1}`
widget.options.serialize = false
return widget
}
const refreshSelection = (node: LGraphNode, selectionWidget: IWidget) => {
const selected = []
let widgetIndex = 0
let comboIndex = 0
while (widgetIndex < node.widgets.length) {
const widget = node.widgets[widgetIndex]
if (!isCombo(widget)) {
widgetIndex += 1
continue
}
if (widget.selected === 0) {
node.widgets.splice(widgetIndex, 1)
continue
}
// rename widget
widget.name = `${selection_type}_${comboIndex}`.toLowerCase()
widget.label = `${selection_type} #${comboIndex + 1}`
// add to collected selected
selected.push(widget.value)
widgetIndex += 1
comboIndex += 1
}
// add a new combo at the end
const last = addCombo(node, selectionWidget)
last.onSelected = () => {
refreshSelection(node, selectionWidget)
}
selectionWidget.value = { selected } // wrap into dict to avoid issues on validation
}
// recreate widgets from selectionWidget
const refreshFromSelection = (node: LGraphNode, selectionWidget: IWidget) => {
// delete all combos widgets
let widgetIndex = 0
while (widgetIndex < node.widgets.length) {
const widget = node.widgets[widgetIndex]
if (isCombo(widget)) {
node.widgets.splice(widgetIndex, 1)
continue
}
widgetIndex += 1
}
// create combo widgets for selectionWidget
const selected = selectionWidget.value.selected
for (const value of selected) {
const widget = addCombo(node, selectionWidget)
widget.value = value
// ensure widget.selected is valid
widget.callback(widget.value)
// only set onSelected now, to avoid having it called by prebious line
widget.onSelected = () => {
refreshSelection(node, selectionWidget)
}
}
// add a new combo at the end
const last = addCombo(node, selectionWidget)
last.onSelected = () => {
refreshSelection(node, selectionWidget)
}
}
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
const selectionWidget = findWidget(this, 'selection')
refreshFromSelection(this, selectionWidget)
}
const original_onConfigure = nodeType.prototype.onConfigure
nodeType.prototype.onConfigure = function (...args: unknown[]) {
original_onConfigure?.apply(this, ...args)
const selectionWidget = findWidget(this, 'selection')
refreshFromSelection(this, selectionWidget)
}
}
+18 -5
View File
@@ -7,9 +7,9 @@ import { CATEGORY, type GlobalThisWithCategory } from '~/shared/common.js'
import { customizeNode } from '~/custom.js'
import { setupDynamicConnections } from '~/shared/dynamic_connections.js'
import { QUEUE_STATUS } from '~/widgets/queue.js'
import { HIDDEN, BTN, ERROR_DISPLAY } from '~/widgets/misc.js'
import { HIDDEN, BTN, ERROR_DISPLAY, SELECTION_LIST } from '~/widgets/misc.js'
import { OutputConfigBackend, OutputConfigLocal } from '~/nodes/config.js'
import { ListFromMultiline } from '~/nodes/list.js'
import { ListFromMultiline, ListFromSelection } from '~/nodes/list.js'
import { InputMultiline } from '~/nodes/input.js'
import { DebugJSONYAML, DebugWidgetVisibility } from '~/nodes/debug.js'
@@ -42,6 +42,21 @@ app.registerExtension({
start_index: 1,
})
break
case 'ListFromMultiline':
ListFromMultiline(nodeType)
break
case 'ListCheckpoints':
ListFromSelection(nodeType, 'checkpoint')
break
case 'ListLoras':
ListFromSelection(nodeType, 'LoRA')
break
case 'ListSamplers':
ListFromSelection(nodeType, 'sampler')
break
case 'ListSchedulers':
ListFromSelection(nodeType, 'scheduler')
break
// INPUT
case 'InputMultiline':
InputMultiline(nodeType)
@@ -54,9 +69,6 @@ app.registerExtension({
start_index: 0,
})
break
case 'ListFromMultiline':
ListFromMultiline(nodeType)
break
// DEBUG
case 'DebugJSONYAML':
DebugJSONYAML(nodeType)
@@ -84,6 +96,7 @@ app.registerExtension({
HIDDEN,
BTN,
ERROR_DISPLAY,
SELECTION_LIST,
}
},
})
+20 -3
View File
@@ -1,5 +1,4 @@
import { LGraphNode, IWidget } from '@comfyorg/litegraph'
import type { ComfyApp } from '@comfyorg/comfyui-frontend-types'
import { InputSpec } from '~/.d.ts/comfyui-frontend-types_alt.js'
import {
@@ -8,6 +7,7 @@ import {
makeComboWidget,
} from '~/widgets/factories.js'
import { makeWidgetAsync } from '~/shared/utils.js'
import { log } from '~/shared/common.js'
/**
* Create an hidden widget. To be used in `getCustomWidgets(app)`
@@ -21,8 +21,8 @@ export function HIDDEN(
node: LGraphNode,
inputName: string,
inputData: InputSpec,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_app: ComfyApp | undefined,
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
// _app: ComfyApp | undefined,
) {
// the value in inputData is only used when creating a new workdlow
// otherwise, the cached value will be used by Comfy
@@ -132,3 +132,20 @@ export function ERROR_DISPLAY(
)
return widget as ErrorDisplayWidget
}
export function SELECTION_LIST(
node: LGraphNode,
inputName: string,
inputData: InputSpec,
) {
inputData[1].value = { selected: [] } // wrap into dict to avoid issues on validation
if (!Array.isArray(inputData[1].all) || !inputData[1].all) {
log.error("SELECTION_LIST: 'all is invalid")
// overwrite `all`
inputData[1].all = []
}
const widget = HIDDEN(node, inputName, inputData)
widget.all = inputData[1].all
return widget
}