Bootstrap (#1)

bootstrap repo with files from local
This commit is contained in:
Bug, Ltd.
2025-01-14 16:15:47 +01:00
committed by GitHub
parent e4bb585a14
commit 17380c173f
88 changed files with 107297 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
#build/
develop-eggs/
# dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# ---> JS / TS
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
# dist
dist-ssr
*.local
coverage
# Editor directories and files
#.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# yarn 2, see: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# cooker folders
plugins
+9
View File
@@ -0,0 +1,9 @@
coverage
public
dist
.yarn
yarn.lock
web
src/py
# src/schema
+22
View File
@@ -0,0 +1,22 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": false,
"useTabs": true,
"tabWidth": 2,
"overrides": [
{
"files": ["*.json", "*.jsonc"],
"options": {
"useTabs": false,
"trailingComma": "none"
}
},
{
"files": ["*.json5"],
"options": {
"useTabs": false
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"git.followTagsWhenSync": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "watch",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [],
"label": "npm: watch",
"detail": "tsc-watch -p tsconfig.json --onSuccess 'tsc-alias -p tsconfig.json -r build/replacers.js/mock.js'",
"runOptions": {
"runOn": "folderOpen"
}
},
{
"type": "npm",
"script": "build",
"group": "build",
"problemMatcher": [],
"label": "npm: build",
"detail": "rimraf ./dist/js && tsc -p tsconfig.prod.json && tsc-alias -p tsconfig.prod.json -r build/replacers.js/mock.js"
}
]
}
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+28
View File
@@ -0,0 +1,28 @@
from pathlib import Path
from .src.python.collection.collect_nodes import (
import_all,
make_class_mappings,
make_display_name_mappings,
make_node_list,
)
from .src.python.collection.register_nodes import CATEGORY
from .src.python.shared.api import load_api
import_all()
NODE_CLASS_MAPPINGS = make_class_mappings()
NODE_DISPLAY_NAME_MAPPINGS = make_display_name_mappings()
make_node_list(Path(__file__).parent / 'node_list.json')
load_api()
print(
' 🧬 {}: {} hybrid nodes successfully cloned'.format(
CATEGORY, len(NODE_CLASS_MAPPINGS)
)
)
WEB_DIRECTORY = './dist/js'
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
+20
View File
@@ -0,0 +1,20 @@
/**
* Replace the paths within the '.mock' folder into the relative path expected at runtime.
* We use a RegExp to achieve this, could be another approach
*/
const re = /[^'"`]*\/\.mock\//
export default function mockReplacer({
orig,
// file,
// config,
}) {
if (re.test(orig)) {
// console.log({ orig })
// count the number of '..' before '.mock' folder
const nbParents = (orig.match(/\.\.\//g) || []).length
const replaced = orig.replace(re, '../'.repeat(3 + nbParents))
// console.log({ replaced })
return replaced
}
return orig
}
+23
View File
@@ -0,0 +1,23 @@
import { AliasReplacerArguments } from 'tsc-alias'
/**
* Replace the paths within the '.mock' folder into the relative path expected at runtime.
* We use a RegExp to achieve this, could be another approach
*/
const re = /[^'"`]*\/\.mock\//
export default function mockReplacer({
orig,
// file,
// config,
}: AliasReplacerArguments): string {
if (re.test(orig)) {
// console.log({ orig })
// count the number of '..' before '.mock' folder
const nbParents = (orig.match(/\.\.\//g) || []).length
const replaced = orig.replace(re, '../'.repeat(3 + nbParents))
// console.log({ replaced })
return replaced
}
return orig
}
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "NodeNext",
"lib": ["ES2017", "DOM"],
"skipLibCheck": true,
"moduleResolution": "nodenext",
"removeComments": false,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "./"
},
"include": ["./*.ts"],
"exclude": []
}
+1
View File
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
+287
View File
@@ -0,0 +1,287 @@
import { slotShapes } from './shared/utils.js';
const customLabels = {
XYPlotQueue: {
widget: {
max_dim1_per_page: 'dim1: max per page',
max_dim2_per_page: 'dim2: max per page',
},
input: {
dim2: 'dim2 (opt)',
},
output: {
dim1_value: 'dim1 value',
dim2_value: 'dim2 value',
xy_plot_data: 'XY plot data',
},
},
XYPlotRender: {
input: {
xy_plot_data: 'XY plot data',
plot_config_grid: 'config: grid (opt)',
plot_config_header: 'config: header (opt)',
plot_config_footer: 'config: footer (opt)',
},
widget: {
dim1_header_format: 'dim1: header format',
dim2_header_format: 'dim2: header format',
},
},
PlotConfigGrid: {
widget: {
gap: 'gap (between cells)',
background_color: 'background color',
font_size: 'font size',
font_color: 'font color',
pad_col_headers: 'col headers: padding',
pad_row_headers: 'row headers: padding',
wrap_col_headers: 'col headers: wrap',
wrap_row_headers: 'row headers: wrap',
},
output: {
plot_config_grid: 'config: grid',
},
},
PlotConfigHF: {
widget: {
text_left: 'text (left)',
text_center: 'text (center)',
text_right: 'text (right)',
background_color: 'background color',
font: 'font',
font_size: 'font size',
font_color: 'font color',
padding: 'padding',
},
output: {
plot_config_hf: 'config: header / footer',
},
},
XYPlotDataSplit: {
input: {
xy_plot_data: 'XY plot data',
},
output: {
current_page: 'current page',
total_pages: 'total pages',
},
},
ListFromMultiline: {
widget: {
strip_comments: 'strip comments',
},
},
ListFromFile: {
widget: {
file_path: 'path',
strip_comments: 'strip comments',
},
},
ListLimit: {
widget: {
nb_elements: 'nb of elements',
},
},
OutputConfigBackend: {
widget: {
file_path: 'JSON or YAML file (backend)',
show_config: 'show config',
error_display: 'display errors',
},
},
OutputConfigLocal: {
widget: {
error_display: 'display errors',
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',
},
},
GenericQueue: {
input: {
input_list: 'input list',
},
},
FileQueue: {
widget: {
pattern: 'pattern(s)',
recursive: 'recursive?',
with_extension: 'with extension?',
},
output: {
full_path: 'full path',
relative_path: 'relative path',
},
},
ImageQueue: {
widget: {
pattern: 'pattern(s)',
recursive: 'recursive?',
with_extension: 'with extension?',
},
output: {
full_path: 'full path',
relative_path: 'relative path',
},
},
InputFolder: {
widget: {
path: 'folder path',
check_exists: 'check folder existence',
},
},
InputMultiline: {
widget: {
strip_comments: 'strip comments',
},
},
LoadImageRGBA: {
widget: {
with_extension: 'with extension?',
mask_precision: 'mask method',
},
},
};
const customLooks = {
XYPlotQueue: {
input: {
dim1: {
shape: 'grid',
},
dim2: {
shape: 'grid',
},
},
output: {
xy_plot_data: {
color_off: '#52395B',
color_on: '#9521c3',
},
},
},
XYPlotRender: {
input: {
xy_plot_data: {
color_off: '#52395B',
color_on: '#9521c3',
},
},
},
ListFromString: {
output: {
list: { shape: 'grid' },
},
},
ListFromMultiline: {
output: {
list: { shape: 'grid' },
},
},
ListFromFile: {
output: {
list: { shape: 'grid' },
},
},
ListFromElements: {
output: {
list: { shape: 'grid' },
},
},
ListMerge: {
output: {
list: { shape: 'grid' },
},
},
ListLimit: {
output: {
list: { shape: 'grid' },
},
},
};
function setNodeLabels(nodeType, nodeData) {
const nodeName = nodeData.name;
if (!nodeName || !Object.keys(customLabels).includes(nodeName))
return;
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
const r = original_onNodeCreated.apply(this, args);
for (const [scope, scope_entry] of Object.entries(customLabels[nodeName])) {
for (const [name, label] of Object.entries(scope_entry)) {
let nodeItems = undefined;
switch (scope) {
case 'input':
nodeItems = this.inputs;
break;
case 'output':
nodeItems = this.outputs;
break;
case 'widget':
nodeItems = this.widgets;
break;
}
const item = nodeItems?.find((i) => i.name === name);
if (item)
item.label = label;
}
}
return r;
};
}
function setNodeLooks(nodeType, nodeData) {
const nodeName = nodeData.name;
if (!nodeName || !Object.keys(customLooks).includes(nodeName))
return;
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
const r = original_onNodeCreated.apply(this, args);
for (const [scope, scope_entry] of Object.entries(customLooks[nodeName])) {
for (const [name, lookEntry] of Object.entries(scope_entry)) {
let nodeItems = undefined;
switch (scope) {
case 'input':
nodeItems = this.inputs;
break;
case 'output':
nodeItems = this.outputs;
break;
}
const item = nodeItems?.find((i) => i.name === name);
if (item) {
for (const [lookName, lookValue] of Object.entries(lookEntry)) {
if (lookName === 'shape')
item[lookName] = slotShapes[lookValue];
else
item[lookName] = lookValue;
}
}
}
}
return r;
};
}
export function customizeNode(nodeType, nodeData) {
setNodeLabels(nodeType, nodeData);
setNodeLooks(nodeType, nodeData);
}
+212
View File
@@ -0,0 +1,212 @@
import { findWidget, ALL_CONFIG_FILE_TYPES, humanFileType, guessFileType, loadTextFile, retrievefile, forceMouseEvent, removeAllOutputs, } from '../shared/utils.js';
import { makeTextWidget, makeButtonWidget, makeToggleWidget, makeSwitchWidget, } from '../widgets/factories.js';
import { ERROR_DISPLAY } from '../widgets/misc.js';
import { processOutputConfigLocal, } from '../shared/config.output.js';
import { displaySingleError, displayErrorDict } from '../shared/error.js';
async function processOutputResult(node, result, dataWidget, statusWidget, errorDisplay) {
if (!result)
return;
const title = node.title || node.type;
switch (result.success) {
case false:
dataWidget.value = {};
statusWidget.label = result.error;
await displaySingleError(title, errorDisplay, result.error);
break;
case true:
dataWidget.value = result.data;
statusWidget.label = 'All outputs generated';
break;
case 'partial': {
dataWidget.value = result.data;
const message = `${result.nbErrors} error${result.nbErrors > 1 ? 's' : ''} detected`;
statusWidget.label = message;
await displayErrorDict(title, errorDisplay, result.errors, message);
}
}
}
async function processOutputConfigUI(node, switchWidget, configWidget, dataWidget, statusWidget, errorDisplayWidget) {
let res;
await switchWidget.guard(async () => {
res = await processOutputConfigLocal(node, configWidget.value || undefined);
});
const result = res;
if (!result || result.success === false)
configWidget.value = '';
await processOutputResult(node, result, dataWidget, statusWidget, errorDisplayWidget.selected);
statusWidget.value = statusWidget.label;
}
export function OutputConfigBackend(nodeType) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
original_onNodeCreated?.apply(this, ...args);
const pathWidget = makeTextWidget(this, 'file_path', {
multiline: false,
placeholder: 'path to file (backend)',
default: '',
tooltip: 'full path to file (backend)',
});
const retrieveButtonWidget = makeButtonWidget(this, 'retrieve', 'Retrieve output config', {
disabled: true,
tooltip: 'retrieve output config from a backend JSON or YAML file',
});
const errorDisplayWidget = ERROR_DISPLAY(this);
const showConfigWidget = makeToggleWidget(this, 'show_config', {
default: true,
});
const configWidget = makeTextWidget(this, 'config', {
multiline: true,
placeholder: 'output config',
default: '',
tooltip: 'output config as JSON or YAML',
disabled: true,
});
const filenameWidget = makeTextWidget(this, 'filename', {
multiline: false,
disabled: true,
label: 'no file',
});
const statusWidget = makeButtonWidget(this, 'status', 'No file selected', {
tooltip: 'status',
disabled: true,
});
const switchWidget = makeSwitchWidget(this, 'switch', [configWidget]);
const dataWidget = findWidget(this, 'data');
pathWidget.callback = (text) => {
retrieveButtonWidget.disabled = !text;
};
showConfigWidget.callback = (value) => {
switchWidget.switch(value);
};
retrieveButtonWidget.callback = async () => {
filenameWidget.label = 'no file';
filenameWidget.value = '';
configWidget.value = '';
statusWidget.label = 'Retrieving file...';
try {
const retrieved = await retrievefile(pathWidget.value);
filenameWidget.label = `file: (${humanFileType(retrieved.mime)})`;
filenameWidget.value = retrieved.filename;
configWidget.value = retrieved.content;
await processOutputConfigUI(this, switchWidget, configWidget, dataWidget, statusWidget, errorDisplayWidget);
}
catch (e) {
statusWidget.label = e.message;
dataWidget.value = {};
await switchWidget.guard(async () => {
removeAllOutputs(this);
for (const w of this.widgets) {
delete w.y;
delete w.last_y;
}
});
await displaySingleError(this.title || this.type, errorDisplayWidget.selected, e.message);
}
finally {
forceMouseEvent();
}
};
};
const original_onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (...args) {
original_onConfigure?.apply(this, ...args);
console.log('OutputConfigBackend / onConfigure');
const statusWidget = findWidget(this, 'status');
if (statusWidget.value)
statusWidget.label = statusWidget.value;
const filenameWidget = findWidget(this, 'filename');
filenameWidget.label = 'no file';
if (filenameWidget.value) {
const ext = filenameWidget.value.split('.').pop();
if (ext === 'json')
filenameWidget.label = 'file: (json)';
else if (['yml', 'yaml'].includes(ext))
filenameWidget.label = 'file: (yaml)';
else
filenameWidget.label = 'file:';
}
const retrieveButtonWidget = findWidget(this, 'retrieve');
const pathWidget = findWidget(this, 'file_path');
retrieveButtonWidget.disabled = !pathWidget.value;
const showConfigWidget = findWidget(this, 'show_config');
if (!showConfigWidget.value)
showConfigWidget.callback(false);
};
}
export function OutputConfigLocal(nodeType) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
original_onNodeCreated?.apply(this, ...args);
const loadButtonWidget = makeButtonWidget(this, 'load', 'Load output config', {
tooltip: 'load output config from a local JSON or YAML file',
});
const errorDisplayWidget = ERROR_DISPLAY(this);
const showConfigWidget = makeToggleWidget(this, 'show_config', {
default: true,
});
const configWidget = makeTextWidget(this, 'config', {
multiline: true,
placeholder: 'output config',
default: '',
tooltip: 'output config as JSON or YAML',
disabled: true,
});
const filenameWidget = makeTextWidget(this, 'filename', {
multiline: false,
disabled: true,
label: 'no file',
});
const statusWidget = makeButtonWidget(this, 'status', 'No file selected', {
tooltip: 'status',
disabled: true,
});
const switchWidget = makeSwitchWidget(this, 'switch', [configWidget]);
const dataWidget = findWidget(this, 'data');
showConfigWidget.callback = (value) => {
switchWidget.switch(value);
};
loadButtonWidget.callback = async () => {
try {
const loaded = await loadTextFile(ALL_CONFIG_FILE_TYPES);
if (loaded) {
filenameWidget.label = `file: (${humanFileType(loaded.type)})`;
filenameWidget.value = loaded.name;
configWidget.value = loaded.content;
}
else {
return;
}
}
catch (e) {
statusWidget.label = e.message;
configWidget.value = '';
dataWidget.value = {};
await switchWidget.guard(async () => {
removeAllOutputs(this);
for (const w of this.widgets) {
delete w.y;
delete w.last_y;
}
});
await displaySingleError(this.title || this.type, errorDisplayWidget.selected, 'Content is not JSON or YAML');
return;
}
await processOutputConfigUI(this, switchWidget, configWidget, dataWidget, statusWidget, errorDisplayWidget);
};
};
const original_onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (...args) {
original_onConfigure?.apply(this, ...args);
const statusWidget = findWidget(this, 'status');
if (statusWidget.value)
statusWidget.label = statusWidget.value;
const filenameWidget = findWidget(this, 'filename');
filenameWidget.label = 'no file';
if (filenameWidget.value) {
filenameWidget.label = guessFileType(filenameWidget.value);
}
const showConfigWidget = findWidget(this, 'show_config');
if (!showConfigWidget.value)
showConfigWidget.callback(false);
};
}
+86
View File
@@ -0,0 +1,86 @@
import { loadTextFile } from '../shared/utils.js';
import { makeTextWidget, makeButtonWidget, makeToggleWidget, makeSwitchWidget, } from '../widgets/factories.js';
import { parseConfig } from '../shared/config.common.js';
import { log } from '../shared/common.js';
export function DebugJSONYAML(nodeType) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
original_onNodeCreated?.apply(this, ...args);
const loadButtonWidget = makeButtonWidget(this, 'load', 'Load file', {
tooltip: 'load a JSON or YAML file',
});
const fileTypeWidget = makeTextWidget(this, 'file_type', {
multiline: false,
placeholder: '',
default: 'unknown',
tooltip: 'content type as detected on file load',
});
fileTypeWidget.disabled = true;
const contentWidget = makeTextWidget(this, 'content', {
multiline: true,
placeholder: 'loaded content',
default: '{}',
tooltip: 'loaded content',
});
const parsedTypeWidget = makeTextWidget(this, 'parsed_type', {
multiline: false,
placeholder: '',
default: 'unknown',
tooltip: 'content type as detected on parse',
});
parsedTypeWidget.disabled = true;
loadButtonWidget.callback = async () => {
fileTypeWidget.value = 'unknown';
parsedTypeWidget.value = 'unknown';
const loaded = await loadTextFile('.json,.yaml,.yml,application/JSON,application/yaml');
if (loaded) {
fileTypeWidget.value = loaded.type;
try {
const parsed = await parseConfig(loaded.content);
parsedTypeWidget.value = parsed.type;
contentWidget.value = parsed.prettified;
}
catch (e) {
parsedTypeWidget.value = e.message;
contentWidget.value = '';
}
}
this.setDirtyCanvas(true);
};
};
}
export function DebugWidgetVisibility(nodeType) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
original_onNodeCreated?.apply(this, ...args);
makeButtonWidget(this, 'button1', 'a button', {});
const showWidget = makeToggleWidget(this, 'show', {
default: true,
});
const multi1Widget = makeTextWidget(this, 'multiline1', {
multiline: true,
default: 'multiline1',
});
const text1Widget = makeTextWidget(this, 'text1', {
multiline: false,
default: 'string #1',
});
const text2Widget = makeTextWidget(this, 'text2', {
multiline: false,
default: 'string #2',
});
const multi2Widget = makeTextWidget(this, 'multiline2', {
multiline: true,
default: 'multiline2',
});
makeTextWidget(this, 'footer', {
multiline: false,
default: 'footer',
});
const switchWidget = makeSwitchWidget(this, 'switch', [multi1Widget, text1Widget], [text2Widget, multi2Widget]);
showWidget.callback = (value) => {
switchWidget.switch(value);
log.debug('switch value', switchWidget.value);
};
};
}
+15
View File
@@ -0,0 +1,15 @@
import { findWidget, loadTextFile } from '../shared/utils.js';
export function InputMultiline(nodeType) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
original_onNodeCreated?.apply(this, ...args);
const loadButtonWidget = findWidget(this, 'load');
const textWidget = findWidget(this, 'multiline');
loadButtonWidget.callback = async () => {
const loaded = await loadTextFile('text/*');
if (loaded) {
textWidget.value = loaded.content;
}
};
};
}
+15
View File
@@ -0,0 +1,15 @@
import { findWidget, loadTextFile } from '../shared/utils.js';
export function ListFromMultiline(nodeType) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
original_onNodeCreated?.apply(this, ...args);
const loadButtonWidget = findWidget(this, 'load');
const textWidget = findWidget(this, 'multiline');
loadButtonWidget.callback = async () => {
const loaded = await loadTextFile('text/*');
if (loaded) {
textWidget.value = loaded.content;
}
};
};
}
+68
View File
@@ -0,0 +1,68 @@
import { app } from '../../../scripts/app.js';
import { CATEGORY } 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 { OutputConfigBackend, OutputConfigLocal } from './nodes/config.js';
import { ListFromMultiline } from './nodes/list.js';
import { InputMultiline } from './nodes/input.js';
import { DebugJSONYAML, DebugWidgetVisibility } from './nodes/debug.js';
globalThis[CATEGORY] = { log: 'debug' };
app.registerExtension({
name: 'ComfyLab.pack',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.category?.split('/')[0] !== CATEGORY) {
return;
}
switch (nodeData.name) {
case 'ListMerge':
setupDynamicConnections(nodeType, 'list', 'LIST', {
separator: ' ',
start_index: 1,
});
break;
case 'ListFromElements':
setupDynamicConnections(nodeType, 'element', '*', {
separator: ' ',
start_index: 1,
});
break;
case 'InputMultiline':
InputMultiline(nodeType);
break;
case 'FormatString':
case 'FormatMultiline':
setupDynamicConnections(nodeType, 'arg', 'STRING,INT,FLOAT,BOOLEAN', {
separator: '',
start_index: 0,
});
break;
case 'ListFromMultiline':
ListFromMultiline(nodeType);
break;
case 'DebugJSONYAML':
DebugJSONYAML(nodeType);
break;
case 'DebugWidgetVisibility':
DebugWidgetVisibility(nodeType);
break;
case 'OutputConfigBackend':
OutputConfigBackend(nodeType);
break;
case 'OutputConfigLocal':
OutputConfigLocal(nodeType);
break;
}
customizeNode(nodeType, nodeData);
return nodeType;
},
getCustomWidgets() {
return {
QUEUE_STATUS,
HIDDEN,
BTN,
ERROR_DISPLAY,
};
},
});
+55
View File
@@ -0,0 +1,55 @@
export const CATEGORY = 'ComfyLab';
const logLevels = {
success: { level: 0, console: 'info', emoji: '👍' },
error: { level: 1, console: 'error', emoji: undefined },
warn: { level: 2, console: 'warn', emoji: undefined },
info: { level: 3, console: 'info', emoji: 'ℹ️' },
debug: { level: 4, console: 'debug', emoji: undefined },
};
const logger = (levelType, ...args) => {
const gt = globalThis;
if (gt[CATEGORY]?.debug ||
(gt[CATEGORY]?.log &&
logLevels[gt[CATEGORY].log].level >= logLevels[levelType].level)) {
const level = gt[CATEGORY]?.debug
? logLevels['debug']
: logLevels[levelType];
if (level.emoji)
console[level.console](level.emoji, ...args);
else
console[level.console](...args);
}
};
export const isObject = (variable) => {
const check = Object(variable);
return (check &&
variable === check &&
typeof variable !== 'function' &&
!Array.isArray(check));
};
export const inspect = (variable) => {
if (isObject(variable)) {
for (const [name, val] of Object.entries(variable)) {
console.log(`${name}: `, val);
}
}
else {
console.log(variable);
}
};
export const log = {
success: (...args) => logger('success', `${CATEGORY}:`, ...args),
error: (...args) => logger('error', `${CATEGORY}:`, ...args),
warn: (...args) => logger('warn', `${CATEGORY}:`, ...args),
info: (...args) => logger('info', `${CATEGORY}:`, ...args),
debug: (...args) => logger('debug', ...args),
sep: () => {
console.log('---------------------------------');
},
inspect,
};
export const success = log.success;
export const error = log.error;
export const warn = log.warn;
export const info = log.info;
export const debug = log.debug;
+4
View File
@@ -0,0 +1,4 @@
import { fetchApi } from './utils.js';
export async function parseConfig(raw) {
return await fetchApi('config_parse', 'POST', { raw });
}
+144
View File
@@ -0,0 +1,144 @@
import { isObject } from './common.js';
import { guessOutputType, compatibleTypes } from './types.js';
import { fetchApi, slotShapes, removeAllOutputs } from './utils.js';
export async function processOutputConfigLocal(node, config) {
let body = {};
let data = {};
if (config) {
try {
body = await fetchApi('config_validate', 'POST', {
raw: config,
schema: 'config.output',
});
}
catch (error) {
removeAllOutputs(node);
return {
success: false,
error: error.message,
};
}
data = body.data;
}
const apiErrors = body.errors || [];
const errors = {};
for (const [errorScope, errorType] of apiErrors) {
if (!Object.keys(errors).includes(errorScope[0]))
errors[errorScope[0]] = {};
errors[errorScope[0]][errorScope[1]] = errorType;
}
if (Object.keys(data).length === 0) {
removeAllOutputs(node);
return {
success: false,
error: 'No data',
};
}
const parsed = new Map();
for (const [k, entry] of Object.entries(data)) {
if (Object.keys(errors).includes(k))
continue;
const key = String(k);
let value, type;
const extra_info = {};
if (!isObject(entry)) {
value = entry;
type = guessOutputType(value);
}
else {
const entryObj = entry;
value = entryObj['value'];
if (Object.keys(entryObj).includes('type')) {
type = entryObj['type'];
}
else {
type = guessOutputType(value);
}
for (const extra of ['label', 'tooltip', 'color_on', 'color_off']) {
if (Object.keys(entryObj).includes(extra))
extra_info[extra] = entryObj[extra];
}
if (entryObj.shape && Object.keys(slotShapes).includes(entryObj.shape))
extra_info.shape = slotShapes[entryObj.shape];
}
if (!extra_info.shape && type === 'LIST')
extra_info.shape = slotShapes['grid'];
parsed.set(key, {
value,
type,
extra_info,
});
}
const existingLinks = {};
while (node.outputs?.length > 0) {
const output = node.outputs[0];
const entry = parsed.get(output.name);
if (entry &&
compatibleTypes(entry.type, output.type) &&
entry.extra_info.label === output.label &&
output.links &&
output.links.length) {
const links = [];
for (const l of output.links) {
const link = node.graph.links.get(l);
if (link)
links.push(link);
}
if (links.length)
existingLinks[output.name] = links;
}
node.removeOutput(0);
}
for (const [name, entry] of parsed.entries()) {
node.addOutput(name, entry.type, entry.extra_info);
const links = existingLinks[name];
if (links) {
for (const link of links) {
const targetNode = node.graph._nodes_by_id[link.target_id];
node.connect(name, targetNode, link.target_slot);
}
}
}
const errorLabels = {};
let nbErrors = 0;
for (const [output, props] of Object.entries(errors)) {
errorLabels[output] = [];
for (const [prop, errorType] of Object.entries(props)) {
nbErrors++;
if (errorType === 'required') {
errorLabels[output].push(`'${prop}' is required`);
continue;
}
let label = `'${prop}' `;
switch (prop) {
case 'type':
label += 'should be a string, or a list of strings';
break;
case 'label':
case 'tooltip':
case 'color_off':
case 'color_on':
label += 'should be a string';
break;
case 'shape':
label += "should be one of: 'box', 'circle', 'arrow', 'grid'";
break;
}
errorLabels[output].push(label);
}
}
if (nbErrors === 0)
return {
success: true,
data: Object.fromEntries(parsed),
applied: true,
};
else
return {
success: 'partial',
data: Object.fromEntries(parsed),
nbErrors,
errors: errorLabels,
applied: true,
};
}
+153
View File
@@ -0,0 +1,153 @@
import { app } from '../../../../scripts/app.js';
export const nodesFromLink = (node, link) => {
const fromNode = app.graph.getNodeById(link.origin_id);
const toNode = app.graph.getNodeById(link.target_id);
let tp = 'error';
if (fromNode.id === node.id) {
tp = 'outgoing';
}
else if (toNode.id === node.id) {
tp = 'incoming';
}
return { to: toNode, from: fromNode, type: tp };
};
export const setupDynamicConnections = (nodeType, prefix, inputType, opts = undefined) => {
const options = Object.assign({
separator: '_',
start_index: 1,
}, opts || {});
const onNodeCreated = nodeType.prototype.onNodeCreated;
const inputList = typeof inputType === 'object';
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
this.addInput(`${prefix}${options.separator}${options.start_index}`, inputList ? '*' : inputType);
return r;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (...args) {
const [type, slotIndex, isConnected, link, ioSlot] = args;
options.link = link;
options.ioSlot = ioSlot;
const r = onConnectionsChange
? onConnectionsChange.apply(this, [
type,
slotIndex,
isConnected,
link,
ioSlot,
])
: undefined;
options.DEBUG = {
node: this,
type,
slotIndex,
isConnected,
link,
ioSlot,
};
const oldSize = [this.size[0], this.size[1]];
const oldComputedSize = this.computeSize();
dynamicConnection(this, slotIndex, isConnected, `${prefix}${options.separator}`, inputType, options);
for (const widget of this.widgets || []) {
delete widget.y;
delete widget.last_y;
delete widget.computedHeight;
}
const newComputedSize = this.computeSize();
this.setSize([
oldSize[0],
oldSize[1] + newComputedSize[1] - oldComputedSize[1],
]);
return r;
};
};
export const dynamicConnection = (node, index, connected, connectionPrefix = 'input_', connectionType = '*', opts = undefined) => {
const options = Object.assign({
start_index: 1,
}, opts || {});
const isDynamicInput = (inputName) => inputName.startsWith(connectionPrefix);
if (node.inputs.length > 0 && !isDynamicInput(node.inputs[index].name)) {
return;
}
const listConnection = typeof connectionType === 'object';
const conType = listConnection ? '*' : connectionType;
const nameArray = options.nameArray || [];
const clean_inputs = () => {
if (node.inputs.length === 0)
return;
let i_count = node.inputs?.length || 0;
const to_remove = [];
for (let n = 1; n < node.inputs.length; n++) {
const element = node.inputs[n];
if (!element.link && isDynamicInput(element.name)) {
if (node.widgets) {
const w = node.widgets.find((w) => w.name === element.name);
if (w) {
w.onRemoved?.();
node.widgets.length = node.widgets.length - 1;
}
}
to_remove.push(n);
}
}
for (let i = 0; i < to_remove.length; i++) {
const id = to_remove[i];
node.removeInput(id);
i_count -= 1;
}
node.inputs.length = i_count;
i_count = node.inputs?.length || 0;
let prefixed_idx = options.start_index;
for (let i = 0; i < node.inputs.length; i++) {
let name = '';
if (isDynamicInput(node.inputs[i].name)) {
name = `${connectionPrefix}${prefixed_idx}`;
prefixed_idx += 1;
}
else {
name = node.inputs[i].name;
}
if (nameArray.length > 0) {
name = i < nameArray.length ? nameArray[i] : name;
}
node.inputs[i].label = node.inputs[i].label || name;
node.inputs[i].name = name;
}
};
if (!connected) {
if (!options.link) {
clean_inputs();
}
else {
if (!options.ioSlot.link) {
node.connectionTransit = true;
}
else {
node.connectionTransit = false;
clean_inputs();
}
}
}
if (connected) {
if (options.link) {
const link = nodesFromLink(node, options.link);
if (link.type === 'outgoing')
return;
}
else {
}
if (node.connectionTransit) {
node.connectionTransit = false;
}
clean_inputs();
if (node.inputs.length === 0)
return;
if (node.inputs[node.inputs.length - 1].link !== null) {
const nextIndex = node.inputs.reduce((acc, cur) => isDynamicInput(cur.name) ? ++acc : acc, 0);
const name = nextIndex < nameArray.length
? nameArray[nextIndex]
: `${connectionPrefix}${nextIndex + options.start_index}`;
node.addInput(name, conType);
}
}
};
+77
View File
@@ -0,0 +1,77 @@
import { $el } from '../../../../scripts/ui.js';
import { log } from '../shared/common.js';
import { showErrorDialog } from '../shared/utils.js';
export async function displaySingleError(nodeTitle, errorDisplay, token1, token2 = '', displayTitle = true, displayCategory = true) {
let message = '';
switch (errorDisplay) {
case 'browser':
message = token2 ? `${token1}: ${token2}` : token1;
if (displayTitle)
log.warn(`${nodeTitle}: ${message}`);
else if (displayCategory)
log.warn(message);
else
console.warn(message);
break;
case 'dialog':
message = token2 ? `${token1}: ${token2}` : token1;
if (displayTitle)
await showErrorDialog({
title: nodeTitle,
subtitle: '⚠️ ' + message,
centerSubtitle: true,
});
else
await showErrorDialog({
subtitle: '⚠️ ' + message,
centerSubtitle: true,
});
break;
default:
}
}
export async function displayErrorDict(nodeTitle, errorDisplay, errorDict, message = '') {
switch (errorDisplay) {
case 'browser':
if (message) {
await displaySingleError(nodeTitle, errorDisplay, message + ':');
}
for (const [key, err] of Object.entries(errorDict)) {
const errors = Array.isArray(err) ? err : [err];
for (const error of errors) {
if (message)
await displaySingleError(nodeTitle, errorDisplay, ` - '${key}'`, String(error), false, false);
else
await displaySingleError(nodeTitle, errorDisplay, `Error in '${key}'`, String(error));
}
}
break;
case 'dialog': {
const allErrors = [];
for (const [key, errors] of Object.entries(errorDict)) {
if (Array.isArray(errors)) {
const keyErrors = [];
for (const error of errors) {
keyErrors.push($el('li', String(error)));
}
allErrors.push($el('li', [
key + ':',
$el('ul', { style: { paddingLeft: '20px' } }, keyErrors),
]));
}
else {
allErrors.push($el('li', [`'${key}': ${String(errors)}`]));
}
}
await showErrorDialog({
title: nodeTitle,
subtitle: '⚠️ ' + message,
centerSubtitle: true,
content: $el('ul', { style: { margin: '0px', paddingLeft: '0px' } }, allErrors),
centerContent: true,
});
break;
}
default:
}
}
+28
View File
@@ -0,0 +1,28 @@
export function guessOutputType(value) {
if (Array.isArray(value))
return 'LIST';
else if (typeof value === 'number') {
return Number.isInteger(value) ? ['INT', 'FLOAT'] : 'FLOAT';
}
else if (typeof value === 'bigint')
return 'INT';
else if (typeof value === 'string')
return 'STRING';
else if (typeof value === 'boolean')
return 'BOOLEAN';
else
return '*';
}
export function compatibleTypes(type1, type2) {
if (!Array.isArray(type1) && !Array.isArray(type2))
return type1 === type2;
const t1 = Array.isArray(type1) ? type1 : [type1];
const t2 = Array.isArray(type2) ? type2 : [type2];
if (t1.length !== t2.length)
return false;
for (const val of t1) {
if (!t2.includes(val))
return false;
}
return true;
}
+240
View File
@@ -0,0 +1,240 @@
import { api } from '../../../../scripts/api.js';
import { $el } from '../../../../scripts/ui.js';
import { ComfyAsyncDialog } from '../../../../scripts/ui/components/asyncDialog.js';
export const findWidget = (node, name) => {
return node.widgets?.find((w) => w.name === name);
};
export const findWidgetIndex = (node, name) => {
return node.widgets?.findIndex((w) => w.name === name);
};
export const findInput = (node, name) => {
return node.inputs?.find((i) => i.name === name);
};
export const findWidgetOrInput = (node, name) => {
return findWidget(node, name) || findInput(node, name);
};
export const findOutput = (node, name) => {
return node.outputs?.find((o) => o.name === name);
};
export const removeWidget = (node, name) => {
const index = findWidgetIndex(node, name);
if (index && index >= 0) {
const widget = node.widgets[index];
node.widgets.splice(index, 1);
widget?.onRemove();
node.size = node.computeSize();
node.setDirtyCanvas(true, true);
return true;
}
else
return false;
};
export function removeAllOutputs(node) {
while (node.outputs?.length > 0) {
node.removeOutput(0);
}
}
export function lookupNodes(key, by, except) {
let modes = [];
if (!by)
modes = ['id', 'title', 'type'];
else if (Array.isArray(by))
modes = by;
else
modes = [by];
const strKey = String(key);
const found = new Set();
for (const mode of modes) {
if (mode === 'id' && !isNaN(+strKey) && Number.isInteger(+strKey)) {
const node = app.graph._nodes_by_id[+strKey];
if (node && node !== except)
found.add(node);
}
else {
for (const node of app.graph._nodes) {
if (node[mode] === strKey && node !== except)
found.add(node);
}
}
}
return Array.from(found);
}
export const slotShapes = {
box: 1,
circle: 3,
arrow: 5,
grid: 6,
};
class ConfigError extends Error {
}
export async function fetchApi(entry, method, params) {
const response = await api.fetchApi('/comfylab/' + entry, {
method,
body: JSON.stringify(params),
});
if (!response.ok) {
switch (response.status) {
case 404:
throw new ConfigError('File not found');
case 415:
throw new ConfigError(response.statusText);
case 500:
throw new ConfigError(`Internal error: ${response.statusText}`);
default:
throw new ConfigError(response.statusText);
}
}
return await response.json();
}
export async function retrievefile(path) {
const body = await fetchApi('load_file', 'POST', { path });
return {
filename: body.filename,
content: body.content,
mime: body.mime,
encoding: body.encoding,
};
}
export function saveToTextFile(filename, content, accept = 'text/plain') {
const blob = new Blob([content], { type: accept + ';charset=utf-8' }), anchor = document.createElement('a');
anchor.download = filename;
anchor.href = (window.webkitURL || window.URL).createObjectURL(blob);
anchor.dataset.downloadurl = [accept, anchor.download, anchor.href].join(':');
anchor.click();
anchor.remove();
}
export async function loadTextFile(accept = 'text/plain') {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept;
input.style.display = 'none';
const reader = new FileReader();
let name = undefined;
let contentType = undefined;
reader.addEventListener('load', () => {
resolve({
content: reader.result,
name: name,
type: contentType,
});
}, { once: true });
reader.addEventListener('abort', () => {
resolve(undefined);
}, { once: true });
reader.addEventListener('error', () => {
resolve(undefined);
}, { once: true });
input.addEventListener('change', (e) => {
const target = e.target;
if (target.files) {
const file = target.files[0];
name = file.name;
contentType = file.type;
reader.readAsText(file);
}
else
resolve(undefined);
}, { once: true });
input.addEventListener('cancel', () => {
resolve(undefined);
}, { once: true });
input.click();
input.remove();
});
}
export async function showErrorDialog({ title = '', subtitle = '', centerSubtitle = false, content = [], centerContent = false, }) {
const elements = [];
if (title) {
elements.push($el('div', {
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: '#8c8c8c',
},
}, [$el('h3', title)]));
}
if (subtitle) {
let subtitleEl = $el('h4', subtitle);
if (centerSubtitle) {
subtitleEl = $el('div', {
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
}, [subtitleEl]);
}
elements.push(subtitleEl);
}
if (content) {
if (!Array.isArray(content))
content = [content];
if (centerContent) {
content = [
$el('div', {
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
}, content),
];
}
elements.push(...content);
}
const dialog = new ComfyAsyncDialog();
const res = await dialog.showModal(elements);
dialog.element.remove();
return res;
}
export function forceMouseEvent() {
app.canvas.processMouseUp(new MouseEvent('mouseup'));
}
export function makeWidgetAsync(widget) {
if (widget._event)
return;
widget._event = {
target: new EventTarget(),
callback: widget.callback,
};
delete widget.callback;
widget._event.target.addEventListener('change', async (ev) => {
if (widget._event.callback) {
await widget._event.callback(ev.detail);
}
});
Object.defineProperty(widget, 'callback', {
get() {
return (value) => {
widget._event.target.dispatchEvent(new CustomEvent('change', { detail: value }));
};
},
set(cb) {
widget._event.callback = cb;
},
enumerable: true,
});
}
export const ALL_CONFIG_FILE_TYPES = '.json,.jsonc,.json5,.yaml,.yml,application/JSON,application/json5,application/yaml';
export const humanFileType = (mimeType) => {
mimeType = mimeType.toLowerCase();
if (mimeType.includes('json5'))
return 'json5';
if (mimeType.includes('json'))
return 'json';
if (mimeType.includes('yaml'))
return 'yaml';
return '';
};
export const guessFileType = (filename) => {
const ext = filename.split('.').pop() || '';
if (ext === 'json5')
return 'file: (json5)';
if (ext === 'json' || ext === 'jsonc')
return 'file: (json)';
else if (['yml', 'yaml'].includes(ext))
return 'file: (yaml)';
return 'file:';
};
+205
View File
@@ -0,0 +1,205 @@
import { app } from '../../../../scripts/app.js';
import { ComfyWidgets } from '../../../../scripts/widgets.js';
import { findWidget, makeWidgetAsync } from '../shared/utils.js';
export function makeTextWidget(node, name, options = {}) {
const widgetOptions = ['multiline', 'placeholder'].reduce((obj, optKey) => {
if (optKey in options)
obj[optKey] = options[optKey];
return obj;
}, {});
const widget = ComfyWidgets['STRING'](node, name, ['STRING', widgetOptions], app).widget;
if (options.default)
widget.value = options.default;
if (options.label)
widget.label = options.label;
if (options.tooltip) {
if (options.multiline)
widget.inputEl.title = options.tooltip;
else
widget.tooltip = options.tooltip;
}
if (options.disabled) {
if (!options.multiline)
widget.disabled = true;
else {
widget.inputEl.readOnly = true;
widget.inputEl.style.border = 'none';
widget.inputEl.style.opacity = 0.6;
}
}
if (options.multiline && options.domStyle)
for (const [styleKey, styleValue] of Object.entries(options.domStyle)) {
widget.inputEl.style[styleKey] = styleValue;
}
return widget;
}
export function makeButtonWidget(node, name, label, options = {}) {
options = Object.assign({
async: true,
}, options);
const widget = node.addWidget('button', name, 0, () => { });
widget.label = label;
if (options.tooltip)
widget.tooltip = options.tooltip;
if (options.disabled)
widget.disabled = options.disabled;
if (options.async)
makeWidgetAsync(widget);
if (options.callback)
widget.callback = options.callback;
return widget;
}
export function makeToggleWidget(node, name, options = {}) {
const widgetOptions = ['default', 'label_on', 'label_off'].reduce((obj, optKey) => {
if (optKey in options)
obj[optKey] = options[optKey];
return obj;
}, {});
const widget = ComfyWidgets['BOOLEAN'](node, name, ['STRING', widgetOptions], app).widget;
if (options.label)
widget.label = options.label;
if (options.tooltip)
widget.tooltip = options.tooltip;
if (options.disabled)
widget.disabled = options.disabled;
if (options.callback)
widget.callback = options.callback;
return widget;
}
export function makeComboWidget(node, name, choices, defaultSelected, onSelected, tooltip) {
const getSelected = (label) => {
if (Array.isArray(choices))
return choices.indexOf(label);
else {
for (const [key, val] of Object.entries(choices)) {
if (val === label)
return key;
}
}
};
const labels = Array.isArray(choices) ? choices : Object.values(choices);
const widget = node.addWidget('combo', name, choices[defaultSelected], () => { }, { values: labels });
widget.choices = choices;
widget.selected = defaultSelected;
widget.onSelected = onSelected;
makeWidgetAsync(widget);
widget.callback = async (label) => {
widget.selected = getSelected(label);
if (widget.onSelected)
await widget.onSelected(widget.selected, label);
};
if (tooltip)
widget.tooltip = tooltip;
const original_onNodeConfigure = node.onConfigure;
node.onConfigure = function (...args) {
widget.selected = getSelected(widget.value);
original_onNodeConfigure?.apply(this, ...args);
};
return widget;
}
function setWidgetVisible(widget, type) {
if (type === 'hidden') {
widget.hidden = true;
widget.type = 'hidden';
widget.computedHeight = null;
if (widget.element) {
widget.element.style.display = 'none';
widget.element.dataset.shouldHide = true;
}
widget.computeSize = () => [0, -4];
}
else {
delete widget.hidden;
delete widget.computeSize;
widget.type = type;
if (widget.element) {
widget.element.style.display = null;
widget.element.dataset.shouldHide = false;
}
}
}
export function makeSwitchWidget(node, name, widgetGroup1, widgetGroup2 = []) {
const serializeWidgets = (widgetGroup) => {
return widgetGroup
.filter((w) => w.name !== name)
.reduce((acc, w) => {
acc[w.name] = w.type;
return acc;
}, {});
};
const initialValue = {
current: true,
states: {
true: {
size: undefined,
members: serializeWidgets(widgetGroup1),
},
false: {
size: undefined,
members: serializeWidgets(widgetGroup2),
},
},
};
const widget = node.addWidget('hidden', name, initialValue, () => { });
widget.computeSize = () => [0, -4];
widget.switch = (value) => {
widget.value.current = value;
const states = widget.value.states;
for (const [widgetName, widgetType] of Object.entries(states[value].members)) {
const w = findWidget(node, widgetName);
if (w) {
setWidgetVisible(w, widgetType);
}
}
for (const widgetName of Object.keys(states[!value].members)) {
const w = findWidget(node, widgetName);
if (w) {
setWidgetVisible(w, 'hidden');
}
}
for (const w of node.widgets) {
delete w.y;
delete w.last_y;
}
const height = states[value].size?.[1] || node.computeSize()[1];
node.setSize([node.size[0], height]);
};
widget.saveSize = () => {
widget.value.states[widget.value.current].size = [
node.size[0],
node.size[1],
];
};
const original_onResize = node.onResize;
node.onResize = function (size) {
original_onResize?.apply(this, size);
for (const w of this.widgets) {
delete w.y;
delete w.last_y;
delete w.computedHeight;
}
widget.saveSize();
};
const original_onConfigure = node.onConfigure;
node.onConfigure = function (...args) {
original_onConfigure?.apply(this, ...args);
widget.switch(widget.value.current);
};
widget.guard = async (fct) => {
const contentVisible = widget.value.current;
const oldSize = [node.size[0], node.size[1]];
const oldComputedSize = node.computeSize();
if (contentVisible)
widget.switch(false);
await fct();
if (contentVisible)
widget.switch(true);
const newComputedSize = node.computeSize();
node.setSize([
oldSize[0],
oldSize[1] + newComputedSize[1] - oldComputedSize[1],
]);
};
widget.switch(widget.value.current);
return widget;
}
+71
View File
@@ -0,0 +1,71 @@
import { makeButtonWidget, makeComboWidget, } from '../widgets/factories.js';
import { makeWidgetAsync } from '../shared/utils.js';
export function HIDDEN(node, inputName, inputData, _app) {
const initialValue = inputData[1].value;
const widget = {
type: 'HIDDEN',
name: inputName,
value: initialValue,
size: [0, -4],
computeSize: () => {
return [0, -4];
},
};
node.addCustomWidget(widget);
return widget;
}
export function BTN(node, inputName, inputData) {
const label = inputData[1].label || inputName;
return makeButtonWidget(node, inputName, label, inputData[1]);
}
export function NODE_COMBO(node, name) {
const getNodeList = (_widget, thisNode) => {
const nodes = [''];
if (thisNode)
nodes.push(...thisNode.graph._nodes
.filter((otherNode) => otherNode.id !== thisNode.id)
.map((otherNode) => {
return `#${otherNode.id}: ${otherNode.title}`;
}));
return nodes;
};
const getSelected = (label) => {
if (!label) {
return [undefined, undefined];
}
else {
const selected = parseInt(label.split(':')[0].substring(1));
return [selected, node.graph._nodes_by_id[selected]];
}
};
const widget = node.addWidget('combo', name, '', async function (label) {
;
[this.selected, this.selectedNode] = getSelected(label);
if (this.onSelected)
await this.onSelected(this.selected, this.selectedNode);
}, { values: getNodeList });
makeWidgetAsync(widget);
widget.callback = async (label) => {
;
[widget.selected, widget.selectedNode] = getSelected(label);
if (widget.onSelected)
await widget.onSelected(widget.selected, widget.selectedNode);
};
widget.tooltip = 'select node from list';
const original_onNodeConfigure = node.onConfigure;
node.onConfigure = function (...args) {
;
[widget.selected, widget.selectedNode] = getSelected(widget.value);
original_onNodeConfigure?.apply(this, ...args);
};
return widget;
}
export const ERROR_DISPLAY_CHOICES = {
browser: 'in browser console (F12)',
dialog: 'in a popup',
none: 'do not display',
};
export function ERROR_DISPLAY(node, name = 'error_display') {
const widget = makeComboWidget(node, name, ERROR_DISPLAY_CHOICES, 'dialog', () => { }, 'how to display detected errors');
return widget;
}
+64
View File
@@ -0,0 +1,64 @@
import { api } from '../../../../scripts/api.js';
function isQueueMessage(message) {
const msg = message;
return (Array.isArray(msg.index) &&
msg.index.length === 1 &&
typeof msg.index[0] === 'number' &&
Array.isArray(msg.total) &&
msg.total.length === 1 &&
typeof msg.total[0] === 'number');
}
function queueMessageToData(message) {
return {
index: message.index[0],
total: message.total[0],
};
}
export function QUEUE_STATUS(node, inputName, _inputData, app) {
if (!app)
throw new Error('QUEUE_STATUS: app is undefined');
const widget = node.addWidget('button', inputName, 0, () => { });
const reset = () => {
widget.value = 0;
widget.total = undefined;
};
reset();
widget.label = 'Ready';
const originalOnConfigure = node.onConfigure;
node.onConfigure = function () {
originalOnConfigure?.apply(this);
reset();
};
const originalOnExecuted = node.onExecuted;
node.onExecuted = function (message) {
originalOnExecuted?.apply(this, message);
if (isQueueMessage(message)) {
const data = queueMessageToData(message);
if (!widget.total)
widget.total = data.total;
if (data.index < data.total - 1) {
widget.value = data.index + 1;
widget.label = `Processing: ${widget.value} / ${widget.total}`;
app.queuePrompt(0, 1);
}
else {
widget.label = `Processing: ${widget.value + 1} / ${widget.total}`;
api.addEventListener('execution_success', () => {
widget.label = `Complete: ${widget.total} / ${widget.total}`;
reset();
}, { once: true });
}
}
};
const original_api_interrupt = api.interrupt;
api.interrupt = async function (...args) {
await original_api_interrupt.apply(this, ...args);
widget.label = 'Interrupted';
reset();
};
api.addEventListener('execution_error', () => {
widget.label = 'Error detected';
reset();
});
return widget;
}
+24
View File
@@ -0,0 +1,24 @@
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
/** @type {import('eslint').Linter.Config[]} */
export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
eslintPluginPrettierRecommended,
{ files: ['**/*.{js,mjs,cjs,ts}'] },
{ languageOptions: { globals: globals.browser } },
{
ignores: [
'coverage',
'public',
'dist',
'.yarn',
'yarn.lock',
'build/replacers.js/*.js',
'web',
],
},
]
+29
View File
@@ -0,0 +1,29 @@
{
"XY Plot: Queue (lab)": "Loop through all values of dim1, optionally combined with dim2 values, and send them to outputs",
"XY Plot: Render (lab)": "Render the generated images as grids",
"Plot Config: Grid (lab)": "Various options to customize the grid appearance",
"Plot Config: Header/Footer (lab)": "Various options to customize the appearance of the page header or footer",
"XY Plot: Split Data (lab)": "Split the queue processing data into individual values",
"List: from String (lab)": "Split a single string into elements delimited by a separator",
"List: from Multiline (lab)": "Split a multiline string, each line being a value",
"List: from File (backend) (lab)": "Read a multiline text file and send its content as a list",
"List: from Elements (lab)": "Create a list from any number of individual elements",
"List: Merge (lab)": "Merge any number of lists into one",
"List: Limit (lab)": "Limit list to a given size",
"Output Config: Load (local) (lab)": "Load a local JSON or YAML config file, and dynamically generate outputs",
"Output Config: Retrieve (backend) (lab)": "Retrieve a JSON or YAML config file from backend, and dynamically generate outputs",
"Generic Queue (lab)": "Loop through all values of input list, and send them to output",
"File Queue (lab)": "Loop through all files in folder matching the pattern(s)",
"Image Queue (lab)": "Loop through all image files in folder matching the pattern(s)",
"Input: Folder (lab)": "Simple input for a folder, optionally checking it exists",
"Input: Integer (lab)": "Simple node to provide an integer",
"Input: Float (lab)": "Simple node to provide a float",
"Input: Boolean (lab)": "Simple node to provide a boolean",
"Input: String (lab)": "Simple node to provide a string",
"Input: Multiline (lab)": "Input a multiline string, with comments optionally stripped",
"Format: String (lab)": "Format a string, with any number of inputs",
"Format: Multiline (lab)": "Format a multiline string, with any number of inputs",
"Load Image (RGBA) (lab)": "Load an image as RGBA, with 2 different available methods to generate the mask",
"Save Text File (lab)": "Save text content to a file",
"Sleep (lab)": "Delay execution for the given amount of seconds"
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "confylab-pack",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"watch": "tsc-watch -p tsconfig.json --onSuccess 'tsc-alias -p tsconfig.json -r build/replacers.js/mock.js'",
"build": "rimraf ./dist/js && tsc -p tsconfig.prod.json && tsc-alias -p tsconfig.prod.json -r build/replacers.js/mock.js",
"build:replacers": "tsc -p build/replacers.js/tsconfig.replacers.json",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.6.8",
"@comfyorg/litegraph": "^0.8.50",
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
"tsc-alias": "^1.8.10",
"tsc-watch": "6.2.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.2"
},
"packageManager": "yarn@4.5.3+sha512.3003a14012e2987072d244c720506549c1aab73ee728208f1b2580a9fd67b92d61ba6b08fe93f6dce68fd771e3af1e59a0afa28dd242dd0940d73b95fedd4e90"
}
+5
View File
@@ -0,0 +1,5 @@
jsonschema
json5
python-dotenv
pyyaml
pyaml
View File
View File
+50
View File
@@ -0,0 +1,50 @@
import importlib
import os
from dotenv import load_dotenv # type: ignore
import json
from .register_nodes import ALL_NODES
def import_all(load_env=True):
DEBUG = False
if load_env:
load_dotenv()
DEBUG = bool(os.getenv('DEBUG'))
MAD_SCIENTIST = bool(os.getenv('MAD_SCIENTIST'))
MODULES = ['plot', 'list', 'config', 'queue', 'input', 'utils']
if DEBUG:
MODULES.append('debug')
if MAD_SCIENTIST:
pass
for mod in MODULES:
importlib.import_module('...nodes.' + mod, package=__name__)
def make_class_mappings():
return {cls.__name__: cls for cls in ALL_NODES.keys()}
def make_display_name_mappings():
return {cls.__name__: display_name for cls, display_name in ALL_NODES.items()}
def make_node_list(path: str):
# node_list = {
# display_name: cls.DESCRIPTION for cls, display_name in ALL_NODES.items()
# }
node_list = {}
for cls, display_name in ALL_NODES.items():
subcat = cls.CATEGORY.split('/')[-1].lower()
if subcat in ['debug', 'experimental']:
continue
descp = cls.DESCRIPTION.split('\n')[0]
# descp = cls.DESCRIPTION
if descp[-1] == '.':
descp = descp[:-1]
node_list[display_name] = descp
with open(path, 'w') as f:
f.write(json.dumps(node_list, indent=2))
+12
View File
@@ -0,0 +1,12 @@
CATEGORY = 'ComfyLab'
CATSHORT = 'lab'
ALL_NODES = {}
def register_node(display_name: str, subcat: str = ''):
def decorator(cls):
ALL_NODES[cls] = '{} ({})'.format(display_name, CATSHORT)
cls.CATEGORY = '{}/{}'.format(CATEGORY, subcat) if subcat else CATEGORY
return cls
return decorator
View File
+50
View File
@@ -0,0 +1,50 @@
from ..collection.register_nodes import register_node
from ..shared.utils import AnyReturnTypes
@register_node('Output Config: Load (local)', 'config')
class OutputConfigLocal:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'data': ('HIDDEN', {}),
}
}
FUNCTION = 'run'
RETURN_TYPES = AnyReturnTypes()
DESCRIPTION = (
'Load a local JSON or YAML config file, and dynamically generate outputs.'
)
def run(self, data):
values = [entry['value'] for entry in data.values()]
return (*values,)
@register_node('Output Config: Retrieve (backend)', 'config')
class OutputConfigBackend:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'data': ('HIDDEN', {}),
}
}
FUNCTION = 'run'
RETURN_TYPES = AnyReturnTypes()
DESCRIPTION = 'Retrieve a JSON or YAML config file from backend, and dynamically generate outputs.'
def run(self, data):
values = [entry['value'] for entry in data.values()]
return (*values,)
+82
View File
@@ -0,0 +1,82 @@
from ..collection.register_nodes import register_node
@register_node('Debug: Widget Types', 'debug')
class DebugWidgetTypes:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'a_boolean': (
'BOOLEAN',
{'default': True},
),
'a_combo': (
['value1', 'value2'],
{'default': 'value2'},
),
'a_string': (
'STRING',
{'default': 'default text', 'multiline': False},
),
'a_multiline': (
'STRING',
{'default': 'default multline text', 'multiline': True},
),
'a_int': (
'INT',
{'default': 65},
),
'a_float': (
'FLOAT',
{'default': 65.76},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ()
RETURN_NAMES = ()
DESCRIPTION = 'Debug node to test various widget types.'
def run(self):
return ()
@register_node('Debug: JSON / YAML parse', 'debug')
class DebugJSONYAML:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {}}
FUNCTION = 'run'
RETURN_TYPES = ()
RETURN_NAMES = ()
DESCRIPTION = 'Debug node to test JSON / YAML parse.'
def run(self):
return ()
@register_node('Debug: Widget Visibility', 'debug')
class DebugWidgetVisibility:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {}}
FUNCTION = 'run'
RETURN_TYPES = ()
RETURN_NAMES = ()
DESCRIPTION = 'Debug node to widget visibility.'
def run(self):
return ()
+170
View File
@@ -0,0 +1,170 @@
import os
from ..collection.register_nodes import register_node
@register_node('Input: Folder', 'input')
class InputFolder:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'path': ('STRING',),
'check_exists': (
'BOOLEAN',
{
'default': True,
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('STRING',)
RETURN_NAMES = ('path',)
OUTPUT_TOOLTIPS = ('folder path',)
DESCRIPTION = 'Simple input for a folder, optionally checking it exists'
def run(self, path: str, check_exists: bool):
if check_exists and not os.path.isdir(path):
raise AssertionError("Folder '{0}' does not exist".format(path))
return (path,)
@register_node('Input: Integer', 'input')
class InputInteger:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {'value': ('INT', {'default': 0})}}
FUNCTION = 'run'
RETURN_TYPES = ('INT',)
RETURN_NAMES = ('integer',)
DESCRIPTION = 'Simple node to provide an integer.'
def run(self, value: int):
return (value,)
@register_node('Input: Float', 'input')
class InputFloat:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {'value': ('FLOAT', {'default': 0, 'step': 0.1})}}
FUNCTION = 'run'
RETURN_TYPES = ('FLOAT',)
RETURN_NAMES = ('float',)
DESCRIPTION = 'Simple node to provide a float.'
def run(self, value: float):
return (value,)
@register_node('Input: Boolean', 'input')
class InputBoolean:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {'value': ('BOOLEAN', {'default': True})}}
FUNCTION = 'run'
RETURN_TYPES = ('BOOLEAN',)
RETURN_NAMES = ('boolean',)
DESCRIPTION = 'Simple node to provide a boolean.'
def run(self, value: bool):
return (value,)
@register_node('Input: String', 'input')
class InputString:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'string': (
'STRING',
{'default': '', 'multiline': False},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('STRING',)
RETURN_NAMES = ('string',)
DESCRIPTION = 'Simple node to provide a string.'
def run(self, string: str):
return (string,)
@register_node('Input: Multiline', 'input')
class InputMultiline:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'load': (
'BTN',
{
'label': 'Load file',
'tooltip': 'load content from file\nor you can just paste / edit the content below',
},
),
'strip_comments': (
'BOOLEAN',
{
'default': True,
'tooltip': "ignore lines starting with '#'",
},
),
'multiline': (
'STRING',
{
'default': '',
'multiline': True,
'placeholder': "multiline text\noptionally lines starting with '#' can be ignored",
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('STRING',)
RETURN_NAMES = ('multiline',)
OUTPUT_TOOLTIPS = ('multiline text, with comments optionally stripped',)
DESCRIPTION = 'Input a multiline string, with comments optionally stripped.'
def run(
self,
load,
strip_comments: bool,
multiline: str,
):
lines = multiline.split('\n')
output = []
for line in lines:
line = line.strip()
if line != '' and (not line.startswith('#') or not strip_comments):
output.append(line)
return ('\n'.join(output),)
+385
View File
@@ -0,0 +1,385 @@
import hashlib
from ..collection.register_nodes import register_node
def convert_value(value: str, output_type: str):
value = value.lower()
if value == 'none':
return None
match output_type:
case 'float':
return float(value)
case 'integer':
return int(value)
case 'boolean':
if value in ('true', 'y', 'yes', 't', 'on', '1'):
return True
elif value in ('false', 'n', 'no', 'f', 'off', '0'):
return False
else:
raise ValueError('invalid boolean value {}'.format(value))
case _:
return value
# common tooltips
TOOLTIP_STRIP_COMMENTS = "ignore lines starting with '#'"
TOOLTIP_CONVERT = 'convert each value to int, float or boolean\nfor booleans, values like true/false, yes/no, on/off, 0/1... are accepted.'
TOOLTIP_OUTPUT_SEPARATED = str(
'list of values, splitted using the separator, optionally converted'
)
TOOLTIP_OUTPUT_MULTILINE = 'list of values (one per line, optionally converted)'
TOOLTIP_OUTPUT_COUNT = 'number of values'
@register_node('List: from String', 'list')
class ListFromString:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'string': (
'STRING',
{
'default': '',
'multiline': False,
'placeholder': 'single string',
'tooltip': 'single string to be splitted by separator',
},
),
'separator': (
'STRING',
{
'default': ',',
'multiline': False,
'tooltip': 'element seperator (spaces surrounding values will be trimmed)',
},
),
'convert': (
['disabled', 'integer', 'float', 'boolean'],
{
'default': 'disabled',
'tooltip': TOOLTIP_CONVERT,
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
TOOLTIP_OUTPUT_SEPARATED,
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Split a single string into elements delimited by a separator\nBy default the list elements are strings, but they can be converted.'
def run(
self,
string: str,
separator: str,
convert: str,
):
lines = string.split(separator)
output = []
for line in lines:
line = line.strip()
if convert != 'disabled':
line = convert_value(line, convert)
output.append(line)
return (output, len(output))
@register_node('List: from Multiline', 'list')
class ListFromMultiline:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'load': (
'BTN',
{
'label': 'Load file',
'tooltip': 'load content from file\nor you can just paste / edit the content below',
},
),
'strip_comments': (
'BOOLEAN',
{
'default': True,
'tooltip': TOOLTIP_STRIP_COMMENTS,
},
),
'convert': (
['disabled', 'integer', 'float', 'boolean'],
{
'default': 'disabled',
'tooltip': TOOLTIP_CONVERT,
},
),
'multiline': (
'STRING',
{
'default': '',
'multiline': True,
'placeholder': "multiline text\noptionally lines starting with '#' can be ignored",
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
TOOLTIP_OUTPUT_MULTILINE,
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = "Split a multiline string, each line being a value\nBy default the list elemenrs are strings, but they can be converted\nOptionally, lines starting with '#' can be ignored."
def run(
self,
load,
strip_comments: bool,
convert: str,
multiline: str,
):
lines = multiline.split('\n')
output = []
for line in lines:
line = line.strip()
if line != '' and (not line.startswith('#') or not strip_comments):
if convert != 'disabled':
line = convert_value(line, convert)
output.append(line)
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):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'file_path': (
'STRING',
{
'default': '',
'placeholder': 'full path to file',
'tooltip': 'full path to file (backend)',
},
),
'strip_comments': (
'BOOLEAN',
{'default': True, 'tooltip': TOOLTIP_STRIP_COMMENTS},
),
'convert': (
['disabled', 'integer', 'float', 'boolean'],
{
'default': 'disabled',
'tooltip': TOOLTIP_CONVERT,
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
TOOLTIP_OUTPUT_MULTILINE,
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Read a multiline text file and send its content as a list\nBy default the list elements are strings, but they can be converted.'
# force reloading in case the file has been changed externally
# we cannot return float('NaN') as usually, otherwise nodes like ListMerge may not work properly
# so we calculate the MD5 checksum, and add it to the returned dict, which is used to determine is the node has changed
@classmethod
def IS_CHANGED(cls, **kwargs):
current = kwargs
# try to open the open and calculate the checksum
try:
with open(current['file_path'], 'rb') as f:
data = f.read()
current['md5'] = hashlib.md5(data).hexdigest()
except:
pass
return current
def run(
self,
file_path: str,
strip_comments: bool,
convert: str,
):
with open(
file_path, 'rt'
) as f: # that's the standard mode, but hey let's be rigorous
lines = f.readlines()
output = []
for line in lines:
line = line.strip()
if line != '' and (not line.startswith('#') or not strip_comments):
if convert != 'disabled':
line = convert_value(line, convert)
output.append(line)
return (output, len(output))
@register_node('List: from Elements', 'list')
class ListFromElements:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {}}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'list',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Create a list from any number of individual elements.'
def run(self, **kw):
output = []
for elt in kw.values():
output.append(elt)
return (output, len(output))
@register_node('List: Merge', 'list')
class ListMerge:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {'required': {}}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'merged list',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Merge any number of lists into one.'
def run(self, **kw):
output = []
for l in kw.values():
output += l
return (output, len(output))
@register_node('List: Limit', 'list')
class ListLimit:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'list': ('LIST', {'tooltip': 'list to extract from'}),
'nb_elements': (
'INT',
{'default': 1, 'min': 1, 'tooltip': 'number of elements'},
),
'method': (
['first', 'last'],
{
'default': 'first',
'tooltip': 'how to extract',
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('LIST', 'INT')
RETURN_NAMES = ('list', 'count')
OUTPUT_TOOLTIPS = (
'limited list',
TOOLTIP_OUTPUT_COUNT,
)
DESCRIPTION = 'Limit list to a given size.'
def run(self, list, nb_elements: int, method: str):
output = []
match method:
case 'first':
output = list[:nb_elements]
case 'last':
output = list[-nb_elements:]
return (output, len(output))
+506
View File
@@ -0,0 +1,506 @@
from comfy_execution.graph import ExecutionBlocker # type: ignore
import torch # type: ignore
from math import ceil
from ..collection.register_nodes import register_node
from ..shared.utils import ANY_TYPE
from ..shared.plot_data import (
DimData,
XYPlotQueueData,
PlotConfigHFData,
PlotConfigGridData,
PlotVars,
)
from ..shared.pager import Pager
# common tooltips
TOOLTIP_XY_PLOT_DATA = (
'data sent by the queue, to indicate the cuttent state of processing'
)
TOOLTIP_COLOR = (
"can be either a color name ('red'), or the RGB hex notation ('#b9b9b9', '#aaa')"
)
TOOLTIP_FONT = "font: either one of 'Roboto-Regular.ttf' / 'Roboto-Bold.ttf' / 'Roboto-Italic.ttf' / 'Roboto-BoldItalic.ttf',\nor the full path to a locally-installed TTF font"
TOOLTIP_FONT_SIZE = 'font size'
TOOLTIP_FONT_COLOR = 'font color, ' + TOOLTIP_COLOR
TOOLTIP_HF_PLACEHOLDERS = (
'the following placeholders are accepted: {current_page}, {total_pages}'
)
TOOLTIP_PLOT_CONFIG_GRID = 'plot configuration for the grid'
@register_node('XY Plot: Queue', 'plot')
class XYPlotQueue:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'dim1': (
'LIST',
{
'tooltip': 'list of values, processed by queue as the 1st dimension\ntip: associate slow operation, like loading checkpoint, to dim1, to ensure better performance'
},
),
},
'optional': {
'dim2': (
'LIST',
{
'tooltip': 'optional: list of values, processed by queue as the 2nd dimension'
},
),
'index': ('QUEUE_STATUS', {'tooltip': 'current queue status'}),
'max_dim1_per_page': (
'INT',
{
'default': 0,
'min': 0,
'tooltip': 'optional: if > 0, max number of dim1 values per page',
},
),
'max_dim2_per_page': (
'INT',
{
'default': 0,
'min': 0,
'tooltip': 'optional: if > 0, max number of dim2 values per page',
},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = (ANY_TYPE, ANY_TYPE, 'XY_PLOT_DATA')
RETURN_NAMES = ('dim1_value', 'dim2_value', 'xy_plot_data')
OUTPUT_TOOLTIPS = (
'dim1 value (not typed)',
'dim2 value (not typed)',
TOOLTIP_XY_PLOT_DATA,
)
DESCRIPTION = 'Loop through all values of dim1, optionally combined with dim2 values, and send them to outputs.\nIMPORTANT: for a given dim1 value, all dim2 values are first iterated, before going to the next dim1 value.\nSo it is advised to associate slow operations (e.g. loading checkpoints) to dim1, to ensure better performance.'
def run(
self,
dim1: list[str],
max_dim1_per_page: int,
max_dim2_per_page: int,
index: int,
dim2: list[str] = [''],
):
if len(dim2) == 0:
dim2 = ['']
# to simplify code below
size = (len(dim1), len(dim2))
dim1_limited = max_dim1_per_page > 0 and max_dim1_per_page < size[0]
dim2_limited = max_dim2_per_page > 0 and max_dim2_per_page < size[1]
# calculate total
total = size[0] * size[1]
# check if we have finished
complete = index == total - 1
# max page dimensions, but not necessarily the current page ones: the last one may be smaller
max_page_dims = (
max_dim1_per_page if dim1_limited else size[0],
max_dim2_per_page if dim2_limited else size[1],
)
# page index and total nb of pages
total_pages = (
ceil(size[0] / max_page_dims[0]),
ceil(size[1] / max_page_dims[1]),
)
# compute the index and dimension of the current page for dim1 first
current_page_index_dim1 = int(index / (size[1] * max_page_dims[0]))
current_page_dims_dim1 = min(
max_page_dims[0], size[0] - current_page_index_dim1 * max_page_dims[0]
)
# compute the index and dimension of the current page for dim2
current_page = (
current_page_index_dim1,
int(
(index - current_page_index_dim1 * size[1] * max_page_dims[0])
/ (current_page_dims_dim1 * max_page_dims[1])
)
% size[1],
)
current_page_dims = (
current_page_dims_dim1,
min(max_page_dims[1], size[1] - current_page[1] * max_page_dims[1]),
)
# sequential index and dim1/dim2 indexes in current page
index_in_page_seq = (
index
- current_page[0] * size[1] * max_page_dims[0]
- current_page[1] * current_page_dims[0] * max_page_dims[1]
)
index_in_page = (
int(index_in_page_seq / current_page_dims[1]),
int(index_in_page_seq % current_page_dims[1]),
)
# get value from each list, given the paged index
values = (
dim1[
int(index_in_page_seq / current_page_dims[1])
+ current_page[0] * max_page_dims[0]
],
dim2[
int(index_in_page_seq % current_page_dims[1])
+ current_page[1] * max_page_dims[1]
],
)
# build data sent to XYPlotRender
xy_plot_data = XYPlotQueueData(
index_in_page_seq,
current_page[0] * total_pages[1] + current_page[1],
total_pages[0] * total_pages[1],
complete,
DimData(index_in_page[0], current_page_dims[0], values[0]),
DimData(index_in_page[1], current_page_dims[1], values[1]),
)
return {
'result': (values[0], values[1], xy_plot_data),
'ui': {'index': [index], 'total': [total]},
}
@register_node('XY Plot: Render', 'plot')
class XYPlotRender:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'xy_plot_data': ('XY_PLOT_DATA', {'tooltip': TOOLTIP_XY_PLOT_DATA}),
'image': ('IMAGE',),
'dim1_header_format': (
'STRING',
{
'default': 'dim1: {dim1}',
'tooltip': "temoplate text to be displayed as dim1 header.\nthe '{dim1'} placeholder will be replaced by the current value.\nUse '\\n' for multiline text.",
},
),
'dim2_header_format': (
'STRING',
{
'default': 'dim2: {dim2}',
'tooltip': "temoplate text to be displayed as dim2 header.\nthe '{dim2'} placeholder will be replaced by the current value.\nUse '\\n' for multiline text.",
},
),
'direction': (
'BOOLEAN',
{
'default': True,
'label_on': 'dim1 as rows',
'label_off': 'dim1 as cols',
'tooltip': 'display dim1 values as rows or columns',
},
),
},
'optional': {
'plot_config_grid': (
'PLOT_CONFIG_GRID',
{'tooltip': 'optional: ' + TOOLTIP_PLOT_CONFIG_GRID},
),
'plot_config_header': (
'PLOT_CONFIG_HF',
{'tooltip': 'optional: plot configuration for the page header'},
),
'plot_config_footer': (
'PLOT_CONFIG_HF',
{'tooltip': 'optional: plot configuration for the page footer'},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('IMAGE', 'IMAGE')
RETURN_NAMES = ('grid', 'image')
OUTPUT_TOOLTIPS = (
'grid page',
'individual image, as received in input',
)
DESCRIPTION = 'Render the generated images as grids.\nOptional configuration is available to customize the look of the grid, and page header / footer.'
# Pager object, to hold each individual images and build the grid
pager = None
def run(
self,
xy_plot_data: XYPlotQueueData,
image: torch.Tensor,
dim1_header_format: str,
dim2_header_format: str,
direction: bool,
plot_config_grid=PlotConfigGridData(),
plot_config_header=None,
plot_config_footer=None,
):
if xy_plot_data.index == 0:
self.pager = Pager(
xy_plot_data, (dim1_header_format, dim2_header_format), direction
)
# add image to pager
self.pager.add(xy_plot_data, image)
# check if page is complete
if not self.pager.complete:
# block downstream nodes for grid output, just send the individual image
return {'result': (ExecutionBlocker(None), image)}
else:
grid = self.pager.make_grid(
PlotVars(xy_plot_data.current_page + 1, xy_plot_data.total_pages),
plot_config_grid,
plot_config_header,
plot_config_footer,
)
# complete, send grid + individual image
return (
grid,
image,
)
@register_node('Plot Config: Grid', 'plot')
class PlotConfigGrid:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'gap': (
'INT',
{'default': 20, 'min': 0, 'tooltip': 'gap between grid cells'},
),
'background_color': (
'STRING',
{
'default': '#b9b9b9',
'tooltip': 'background color, '
+ TOOLTIP_COLOR
+ "\nuse the special value 'transparent' to generate a RGBA image",
},
),
'font': (
'STRING',
{
'default': 'Roboto-Regular.ttf',
'tooltip': TOOLTIP_FONT,
},
),
'font_size': (
'INT',
{'default': 50, 'min': 1, 'tooltip': TOOLTIP_FONT_SIZE},
),
'font_color': (
'STRING',
{'default': '#444', 'tooltip': TOOLTIP_FONT_COLOR},
),
'pad_col_headers': (
'INT',
{
'default': 30,
'min': 0,
'tooltip': 'vertical padding to apply to column headers',
},
),
'pad_row_headers': (
'INT',
{
'default': 50,
'min': 0,
'tooltip': 'horizontal padding to apply to row headers',
},
),
'wrap_col_headers': (
'INT',
{
'default': 0,
'min': 0,
'tooltip': 'if > 0, max number of characters in column headers before wrapping',
},
),
'wrap_row_headers': (
'INT',
{
'default': 0,
'min': 0,
'tooltip': 'if > 0, max number of characters in row headers before wrapping',
},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('PLOT_CONFIG_GRID',)
RETURN_NAMES = ('plot_config_grid',)
OUTPUT_TOOLTIPS = (TOOLTIP_PLOT_CONFIG_GRID,)
DESCRIPTION = 'Various options to customize the grid appearance.'
def run(
self,
gap: int,
background_color: str,
font: str,
font_size: int,
font_color: str,
pad_col_headers: int,
pad_row_headers: int,
wrap_col_headers: int,
wrap_row_headers: int,
):
plot_config_grid = PlotConfigGridData(
gap=gap,
background_color=background_color,
font=font,
font_size=font_size,
font_color=font_color,
pad_col_headers=pad_col_headers,
pad_row_headers=pad_row_headers,
wrap_col_headers=wrap_col_headers,
wrap_row_headers=wrap_row_headers,
)
return (plot_config_grid,)
@register_node('Plot Config: Header/Footer', 'plot')
class PlotConfigHF:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'text_left': (
'STRING',
{
'default': '',
'tooltip': 'text to be displayed on the left of the page header or footer\n'
+ TOOLTIP_HF_PLACEHOLDERS,
},
),
'text_center': (
'STRING',
{
'default': '',
'tooltip': 'text to be displayed at the center of the page header or footer\n'
+ TOOLTIP_HF_PLACEHOLDERS,
},
),
'text_right': (
'STRING',
{
'default': '',
'tooltip': 'text to be displayed on the right of the page header or footer\n'
+ TOOLTIP_HF_PLACEHOLDERS,
},
),
'background_color': (
'STRING',
{
'default': 'transparent',
'tooltip': 'background color for the page header or footer, '
+ TOOLTIP_COLOR
+ "\nuse the special value 'transparent' to keep the grid background",
},
),
'font': (
'STRING',
{'default': 'Roboto-Bold.ttf', 'tooltip': TOOLTIP_FONT},
),
'font_size': (
'INT',
{'default': 60, 'min': 1, 'tooltip': TOOLTIP_FONT_SIZE},
),
'font_color': (
'STRING',
{'default': '#222', 'tooltip': TOOLTIP_FONT_COLOR},
),
'padding': (
'INT',
{
'default': 30,
'min': 0,
'tooltip': 'padding inside the page header or footer',
},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('PLOT_CONFIG_HF',)
RETURN_NAMES = ('plot_config_hf',)
OUTPUT_TOOLTIPS = ('plot configuration for either the page header or footer',)
DESCRIPTION = (
'Various options to customize the appearance of the page header or footer.'
)
def run(
self,
text_left: str,
text_center: str,
text_right: str,
background_color: str,
font: str,
font_size: int,
font_color: str,
padding: int,
):
plot_config_hf = PlotConfigHFData(
text_left=text_left,
text_center=text_center,
text_right=text_right,
font=font,
background_color=background_color,
font_size=font_size,
font_color=font_color,
padding=padding,
)
return (plot_config_hf,)
@register_node('XY Plot: Split Data', 'plot')
class XYPlotDataSplit:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'xy_plot_data': ('XY_PLOT_DATA', {'tooltip': TOOLTIP_XY_PLOT_DATA}),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('INT', 'INT', 'BOOLEAN')
RETURN_NAMES = ('current_page', 'total_pages', 'complete')
OUTPUT_TOOLTIPS = (
'current page',
'total number of pages',
'is processing complete? (BOOLEAN)',
)
DESCRIPTION = 'Split the queue processing data into individual values.\nUseful to customize the filename during saving, for example.'
def run(self, xy_plot_data: XYPlotQueueData):
return (
xy_plot_data.current_page + 1,
xy_plot_data.total_pages,
xy_plot_data.complete,
)
+211
View File
@@ -0,0 +1,211 @@
from typing import Any
from pathlib import Path
from PIL import Image
from ..collection.register_nodes import register_node
from ..shared.utils import ANY_TYPE, pillow_to_tensor
@register_node('Generic Queue', 'queue')
class GenericQueue:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'input_list': (
'LIST',
{'tooltip': 'list of values, processed by queue'},
),
},
'optional': {
'index': ('QUEUE_STATUS', {'tooltip': 'current queue status'}),
},
}
FUNCTION = 'run'
RETURN_TYPES = (ANY_TYPE, 'INT', 'INT')
RETURN_NAMES = ('value', 'index', 'total')
OUTPUT_TOOLTIPS = ('current value (not typed)', 'current index', 'total elements')
DESCRIPTION = 'Loop through all values of input list, and send them to output.'
def run(self, input_list: list[Any], index: int):
total = len(input_list)
if total == 0:
raise Exception('Empty input list')
return {
'result': (input_list[index], index + 1, total),
'ui': {'index': [index], 'total': [total]},
}
@register_node('File Queue', 'queue')
class FileQueue:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'folder': (
'STRING',
{'tooltip': 'folder to scan'},
),
'pattern': (
'STRING',
{
'default': '*',
'tooltip': "file pattern(s) to match, e.g. '*.txt' or 'prefix*'.\nmultiple patterns can be defined, separated by a comma",
},
),
'recursive': (
'BOOLEAN',
{'default': True, 'tooltip': 'scan recursively?'},
),
'with_extension': (
'BOOLEAN',
{'default': True, 'tooltip': 'keep or remove file extension'},
),
},
'optional': {
'index': ('QUEUE_STATUS', {'tooltip': 'current queue status'}),
},
}
FUNCTION = 'run'
RETURN_TYPES = ('STRING', 'STRING', 'STRING', 'INT', 'INT')
RETURN_NAMES = ('filename', 'full_path', 'relative_path', 'index', 'total')
OUTPUT_TOOLTIPS = (
'filename',
'full path',
'relative path',
'current index',
'total files',
)
DESCRIPTION = 'Loop through all files in folder matching the pattern(s).'
# stored scanned files
root = ''
files = []
total = -1
def scan_folder(
self, folder: str, pattern: str, recursive: bool
) -> tuple[Path, list[Path], int]:
self.root = Path(folder)
self.files = []
self.total = -1
if not self.root.exists():
raise Exception("Path '{}' does not exist".format(self.root))
if not self.root.is_dir():
raise Exception("'{}' is not a directory".format(self.root))
patterns = pattern.split(',')
self.files = []
for p in patterns:
it = self.root.rglob(p.strip()) if recursive else self.root.glob(p.strip())
self.files += [file for file in it if file.is_file()]
# remove duplicates
self.files = list(set(self.files))
# list files in current folder first
self.files.sort(key=lambda f: [f.parent, f.name])
self.total = len(self.files)
if len(self.files) == 0:
raise Exception('No file found')
def run(
self,
folder: str,
pattern: str,
recursive: bool,
with_extension: bool,
index: int,
):
# only scan files at the beginning
if index == 0:
self.scan_folder(folder, pattern, recursive)
file = self.files[index]
return {
'result': (
str(file.name) if with_extension else file.stem,
str(file.resolve()),
str(file.relative_to(self.root)),
index + 1,
self.total,
),
'ui': {'index': [index], 'total': [self.total]},
}
@register_node('Image Queue', 'queue')
class ImageQueue(FileQueue):
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'folder': (
'STRING',
{'tooltip': 'folder to scan'},
),
'pattern': (
'STRING',
{
'default': '*.png, *.jpg',
'tooltip': "file pattern(s) to match, e.g. '*.jpg' or 'prefix*'.\nmultiple patterns can be defined, separated by a comma",
},
),
'recursive': (
'BOOLEAN',
{'default': True, 'tooltip': 'scan recursively?'},
),
'with_extension': (
'BOOLEAN',
{'default': True, 'tooltip': 'keep or remove file extension'},
),
},
'optional': {
'index': ('QUEUE_STATUS', {'tooltip': 'current queue status'}),
},
}
RETURN_TYPES = ('IMAGE', 'STRING', 'STRING', 'STRING', 'INT', 'INT')
RETURN_NAMES = ('image', 'filename', 'full_path', 'relative_path', 'index', 'total')
OUTPUT_TOOLTIPS = (
'image',
'filename',
'full path',
'relative path',
'current index',
'total files',
)
DESCRIPTION = 'Loop through all image files in folder matching the pattern(s).'
def run(
self,
folder: str,
pattern: str,
recursive: bool,
with_extension: bool,
index: int,
):
parent_run = super().run(folder, pattern, recursive, with_extension, index)
file = parent_run['result'][1]
img = Image.open(file)
return {
'result': (
pillow_to_tensor(img),
*parent_run['result'],
),
'ui': {**parent_run['ui']},
}
+267
View File
@@ -0,0 +1,267 @@
from typing import Any
import time
import os
from PIL import Image
import numpy as np
import torch # type: ignore
import folder_paths # type: ignore
from ..collection.register_nodes import register_node
from ..shared.utils import ANY_TYPE, pillow_to_tensor
@register_node('Format: String', 'utils')
class FormatString:
"""
Format a string using any number of inputs
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'format': (
'STRING',
{
'default': 'first arg by name: {arg0}, second by index: {1}',
'multiline': False,
'tooltip': "placeholders can be either '{arg0}', '{arg1}, ... or '{0}', '{1}', ...",
},
),
}
}
@classmethod
def VALIDATE_INPUTS(s, input_types):
for t in input_types.values():
if t not in ['STRING', 'INT', 'FLOAT', 'BOOLEAN']:
return False
return True
FUNCTION = 'run'
RETURN_TYPES = ('STRING',)
RETURN_NAMES = ('formatted',)
DESCRIPTION = "Format a string, with any number of inputs.\nPlaceholders can be either '{arg0}', '{arg1}, ... or '{0}', '{1}', ...\nInputs can be strings, integers, floats, booleans.\nSearch for 'python format' for all configuration options, there are many!"
def run(self, format: str, **kw):
# handle both index and name references
output = format.format(*kw.values(), **kw)
return (output,)
@register_node('Format: Multiline', 'utils')
class FormatMultiline:
"""
Format a multiline string using any number of inputs
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'format': (
'STRING',
{
'default': 'first arg by name: {arg0}\nsecond by index: {1}',
'multiline': True,
'tooltip': "placeholders can be either '{arg0}', '{arg1}, ... or '{0}', '{1}', ...",
},
),
}
}
@classmethod
def VALIDATE_INPUTS(s, input_types):
for t in input_types.values():
if t not in ['STRING', 'INT', 'FLOAT', 'BOOLEAN']:
return False
return True
FUNCTION = 'run'
RETURN_TYPES = ('STRING',)
RETURN_NAMES = ('formatted',)
DESCRIPTION = "Format a multiline string, with any number of inputs.\nPlaceholders can be either '{arg0}', '{arg1}, ... or '{0}', '{1}', ...\nInputs can be strings, integers, floats, booleans."
def run(self, format: str, **kw):
# handle both index and name references
output = format.format(*kw.values(), **kw)
return (output,)
@register_node('Load Image (RGBA)', 'utils')
class LoadImageRGBA:
"""Load an image as RGBA.
- filename_ext: whether to return the filename with or without the extension.
- mask_precision: either 'standard' or 'tensor'. Defines the way the mask is created.
- 'standard' is like standard LoadImage
- 'tensor' is less conservative
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [
f
for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f))
]
return {
'required': {
'image': (sorted(files), {'image_upload': True}),
'with_extension': (
'BOOLEAN',
{
'default': False,
'label_on': 'filename with ext',
'label_off': 'filename without ext',
'tooltip': 'output the filename with or without extension',
},
),
'mask_precision': (
['tensor', 'standard'],
{
'default': 'tensor',
'tooltip': "'standard' is like standard LoadImage, 'tensor' is less conservative",
},
),
}
}
FUNCTION = 'run'
RETURN_TYPES = ('IMAGE', 'MASK', 'STRING')
RETURN_NAMES = ('image', 'mask', 'filename')
DESCRIPTION = 'Load an image as RGBA, with 2 different available methods to generate the mask.'
def run(self, image, with_extension, mask_precision):
img_path = folder_paths.get_annotated_filepath(image)
img_pil = Image.open(img_path)
# filename output
filename = os.path.basename(img_path)
if with_extension == False:
filename = os.path.splitext(filename)[0]
output_images = []
output_masks = []
# convert image to tensor
output_image = pillow_to_tensor(img_pil)
# create mask
if mask_precision == 'standard':
if 'A' in img_pil.getbands():
output_mask = (
np.array(img_pil.getchannel('A')).astype(np.float32) / 255.0
)
output_mask = 1.0 - torch.from_numpy(output_mask)
else:
output_mask = torch.zeros(
(img_pil.size[1], img_pil.size[0]),
dtype=torch.float32,
device='cpu',
)
else:
# another way to create mask, but seems less precise (which may be good)
# https://stackoverflow.com/questions/70786249/pytorch-numpy-create-binary-mask-from-rgb-image
image_sum = output_image.sum(axis=3)
output_mask = torch.where(image_sum > 0, 1.0, 0.0)
output_mask = 1.0 - output_mask
output_images.append(output_image)
output_masks.append(output_mask)
return (output_image, output_mask, filename)
@register_node('Save Text File', 'utils')
class SaveTextFile:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'text': ('STRING',),
'folder': ('STRING',),
'basename': (
'STRING',
{
'tooltip': 'filename without extension, or with extension is no extension is set below'
},
),
'extension': (
'STRING',
{
'default': 'txt',
'tooltip': 'if no extension is set here, basename should include one',
},
),
'overwrite': ('BOOLEAN', {'default': True}),
}
}
FUNCTION = 'run'
RETURN_TYPES = ()
OUTPUT_NODE = True
DESCRIPTION = 'Save text content to a file.\nIf no extension is set, basename is the filename.'
def run(
self, text: str, folder: str, basename: str, extension: str, overwrite: bool
):
if extension and not extension.startswith('.'):
extension = '.' + extension
full_path = os.path.join(folder, basename + extension)
if not os.path.exists(full_path) or overwrite:
with open(full_path, 'w') as f:
f.write(text)
return ()
@register_node('Sleep', 'utils')
class Sleep:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {
'input': (ANY_TYPE, {}),
},
'optional': {
'seconds': (
'FLOAT',
{
'default': 0.5,
'min': 0,
'step': 0.1,
'tooltip': 'nb of seconds to wait',
},
),
},
}
FUNCTION = 'run'
RETURN_TYPES = (ANY_TYPE,)
RETURN_NAMES = ('value',)
DESCRIPTION = 'Delay execution for the given amount of seconds.'
def run(self, input: Any, seconds: float):
time.sleep(seconds)
return (input,)
View File
+158
View File
@@ -0,0 +1,158 @@
import json
import json5 # type: ignore
import yaml
import pyaml # type: ignore
import mimetypes
from pathlib import Path
from aiohttp.web import Request, json_response # type: ignore
from server import PromptServer # type: ignore
from ..shared.json_validation import validate, SchemaError, InvalidStructureError
routes = PromptServer.instance.routes
def send_success(**kwargs):
return json_response({**kwargs}, status=200)
def send_error(status: int, reason: str = None, **kwargs):
return json_response({**kwargs}, status=status, reason=reason)
class ParseError(Exception):
pass
# Parse content, either as JSON or YAML
def parse(raw: str):
try:
parsed = json5.loads(raw)
prettified = json.dumps(parsed, sort_keys=False, indent=2)
return ('json', parsed, prettified)
except Exception:
pass
try:
parsed = yaml.safe_load(raw)
prettified = pyaml.dump(
parsed,
sort_keys=False,
)
return ('yaml', parsed, prettified)
except Exception:
pass
raise ParseError('parse error')
# Convert data, either to JSON or YAML
def convert(data, format: str) -> str:
if format == 'json':
return json.dumps(data, sort_keys=False, indent=2)
elif format == 'yaml':
return pyaml.dump(
data,
sort_keys=False,
)
@routes.post('/comfylab/load_file')
async def load_file(request: Request):
try:
body = await request.json()
if not 'path' in body.keys():
return send_error(
status=500,
reason='Path missing in request',
path='',
)
path = body['path']
with open(path, 'r') as f:
content = f.read()
filename = Path(path).name
mime, encoding = mimetypes.guess_type(path)
return send_success(
path=path, filename=filename, content=content, mime=mime, encoding=encoding
)
except FileNotFoundError:
return send_error(status=404, reason='File not found', path=path)
except Exception as e:
return send_error(status=500, reason=str(e), path=body['path'] or '')
@routes.post('/comfylab/config_parse')
async def config_parse(request: Request):
try:
body = await request.json()
if not 'raw' in body.keys():
return send_error(
status=500,
reason='Content missing in request',
)
raw = body['raw']
(type, data, prettified) = parse(raw)
except ParseError:
return send_error(status=415, reason='Malformed file')
except Exception as e:
return send_error(status=500, reason=str(e))
return send_success(type=type, data=data, prettified=prettified)
@routes.post('/comfylab/config_convert')
async def config_convert(request: Request):
try:
body = await request.json()
if not 'data' in body.keys():
return send_error(
status=500,
reason='Data missing in request',
)
if not 'format' in body.keys():
return send_error(
status=500,
reason='Format missing in request',
)
data = body['data']
format = body['format']
prettified = convert(data, format)
except Exception as e:
return send_error(status=500, reason=str(e))
return send_success(prettified=prettified)
@routes.post('/comfylab/config_validate')
async def config_validate(request: Request):
try:
body = await request.json()
if not 'raw' in body.keys():
return send_error(status=500, reason='Raw data missing in request')
if not 'schema' in body.keys():
return send_error(status=500, reason='Schema missing in request')
raw = body['raw']
schema_file = body['schema']
(_, data, _) = parse(raw)
except ParseError:
return send_error(status=415, reason='Malformed data')
except Exception as e:
return send_error(status=500, reason=str(e), schema=schema_file)
# validate JSON data
try:
(data, errors) = validate(data, schema_file)
return send_success(schema=schema_file, data=data, errors=errors)
except SchemaError as e:
# invalid schema
return send_error(status=500, reason=str(e), schema=schema_file)
except InvalidStructureError as e:
# invalid schema
return send_error(
status=415,
reason='Invalid structure',
schema=schema_file,
message=str(e),
)
def load_api():
# print('### ComfyLab: API loaded')
pass
+295
View File
@@ -0,0 +1,295 @@
from PIL import Image, ImageDraw, ImageFont
from pathlib import Path
import textwrap
import math
from dataclasses import asdict
from .plot_data import PlotConfigGridData, PlotConfigHFData, PlotVars
STATIC_DIR = Path(__file__).parent.parent.parent / 'static'
class Grid:
def __init__(
self,
plot_config_grid: PlotConfigGridData,
plot_config_header: PlotConfigHFData = None,
plot_config_footer: PlotConfigHFData = None,
):
self.config_grid = plot_config_grid
self.config_header = plot_config_header
self.config_footer = plot_config_footer
self.headers = ([], [])
self.dims = (0, 0)
def make(
self,
image_matrix: list[list[Image.Image]],
col_headers: list[str],
row_headers: list[str],
plot_vars: PlotVars,
) -> Image.Image:
# keep a track to reuse later
self.headers = (col_headers, row_headers)
self.dims = (len(col_headers), len(row_headers))
cols, rows = self.dims
self.max_cell_size = self._calc_max_cell_size(image_matrix)
# build the grid image
grid_size = (
self.max_cell_size[0] * cols + (cols - 1) * self.config_grid.gap,
self.max_cell_size[1] * rows + (rows - 1) * self.config_grid.gap,
)
grid_image = self._create_image(grid_size, self.config_grid.background_color)
for r, row in enumerate(image_matrix):
for c, image in enumerate(row):
x = c * (self.max_cell_size[0] + self.config_grid.gap)
y = r * (self.max_cell_size[1] + self.config_grid.gap)
grid_image.paste(image, (x, y))
# add headers; if header format for dim1/dim2 is empty, they will be silently ignored
grid_image = self._add_headers(grid_image)
# add page header / footer
grid_image = self._add_page_hf(grid_image, plot_vars)
return grid_image
def _calc_max_cell_size(
self, image_matrix: list[list[Image.Image]]
) -> tuple[int, int]:
# scan all images to get max width / max height
max_size = (-1, -1)
# TODO: allow different row heights / col widths?
for row in image_matrix:
for image in row:
w, h = image.size
max_size = (max(max_size[0], w), max(max_size[1], h))
return max_size
def _create_image(self, size: tuple[int, int], bg_color: str) -> Image.Image:
if bg_color == 'transparent':
image = Image.new('RGBA', (size[0], size[1]))
else:
image = Image.new(
'RGB',
(size[0], size[1]),
color=bg_color,
)
return image
def _add_headers(self, grid_image: Image.Image):
# normalize the headers, to respect the wrap configs
self.headers = (
self._normalize_headers(self.headers[0], self.config_grid.wrap_col_headers),
self._normalize_headers(self.headers[1], self.config_grid.wrap_row_headers),
)
# load font
font = self._load_font(self.config_grid.font, self.config_grid.font_size)
# calculate height and width of headers at top and left
top_margin, left_margin = self._calc_headers_margins(font)
# build the new image and paste the existing grid
image = self._create_image(
(grid_image.size[0] + left_margin, grid_image.size[1] + top_margin),
self.config_grid.background_color,
)
draw = ImageDraw.Draw(image)
draw.font = font
image.paste(
grid_image,
(image.size[0] - grid_image.size[0], image.size[1] - grid_image.size[1]),
)
# add the headers
for col, header in enumerate(self.headers[0]):
pos_x = (
left_margin
+ col * (self.config_grid.gap + self.max_cell_size[0])
+ self.max_cell_size[0] / 2
)
pos_y = top_margin / 2
self._draw_header(
draw, (pos_x, pos_y), header, font, self.config_grid.font_color
)
for row, header in enumerate(self.headers[1]):
pos_x = left_margin / 2
pos_y = (
top_margin
+ row * (self.config_grid.gap + self.max_cell_size[1])
+ self.max_cell_size[1] / 2
)
self._draw_header(
draw, (pos_x, pos_y), header, font, self.config_grid.font_color
)
return image
def _load_font(self, font_name: str, font_size: int) -> ImageFont:
if not Path(font_name).is_absolute():
font = ImageFont.truetype(
str(STATIC_DIR / font_name),
size=font_size,
)
else:
font = ImageFont.truetype(str(Path(font_name)), size=font_size)
return font
def _normalize_headers(self, headers: list[str], wrap: int) -> list[str]:
normalized = []
for header in headers:
header = header.replace(r'\n', '\n')
if wrap > 0:
header = textwrap.fill(header, wrap, break_on_hyphens=True)
normalized.append(header)
return normalized
def _calc_headers_margins(self, font: ImageFont):
top_margin = 0
for header in self.headers[0]:
if header == '':
continue
lines = header.split('\n')
top_margin = max(top_margin, math.ceil(len(lines) * font.size))
# add padding if there is something to display
if top_margin > 0:
top_margin = top_margin + 2 * self.config_grid.pad_col_headers
left_margin = 0
for header in self.headers[1]:
if header == '':
continue
for line in header.split('\n'):
left_margin = max(left_margin, math.ceil(font.getlength(line)))
# add padding if there is something to display
if left_margin > 0:
left_margin = left_margin + 2 * self.config_grid.pad_row_headers
return (top_margin, left_margin)
def _draw_header(
self,
draw: ImageDraw,
pos: tuple[int, int],
text: str,
font: ImageFont,
font_color,
):
# see: https://github.com/python-pillow/Pillow/discussions/7914#discussioncomment-8950499
draw.multiline_text(
pos, text, font=font, fill=font_color, align='center', anchor='mm'
)
def _add_page_hf(self, grid_image: Image.Image, plot_vars: PlotVars) -> Image.Image:
grid_width, grid_height = grid_image.size
header_height, header_image = self._draw_page_hf(
grid_width, plot_vars, self.config_header
)
footer_height, footer_image = self._draw_page_hf(
grid_width, plot_vars, self.config_footer
)
# early exit
if header_height + footer_height == 0:
return grid_image
# create a new image and paste grid and header / footer if applicable
new_image = self._create_image(
(grid_width, grid_height + header_height + footer_height),
self.config_grid.background_color,
)
if header_image:
new_image.paste(header_image, (0, 0))
new_image.paste(grid_image, (0, header_height))
if footer_image:
new_image.paste(footer_image, (0, header_height + grid_height))
return new_image
def _draw_page_hf(
self, width: int, plot_vars: PlotVars, config: PlotConfigHFData = None
) -> tuple[int, Image.Image]:
# early exit
if not config or (
not config.text_left and not config.text_center and not config.text_right
):
return (0, None)
# calc height
font = self._load_font(config.font, config.font_size)
texts = [
self._apply_template(template, plot_vars)
for template in [config.text_left, config.text_center, config.text_right]
]
height = 0
for text in texts:
lines = text.split('\n')
height = max(height, math.ceil(len(lines) * font.size + 2 * config.padding))
# create the image
# if bg color is 'transparent', then keep the same as grid
image = self._create_image(
(width, height),
self.config_grid.background_color
if config.background_color == 'transparent'
else config.background_color,
)
# calculate the text position
texts_pos = [
self._calc_page_hf_text_pos(align, (width, height), config.padding)
for align in ['left', 'center', 'right']
]
# draw the texts
draw = ImageDraw.Draw(image)
draw.font = font
for i, text in enumerate(texts):
if not text:
continue
# see: https://github.com/python-pillow/Pillow/discussions/7914#discussioncomment-8950499
# text anchors: https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html
draw.multiline_text(
texts_pos[i][0],
text,
font=font,
fill=config.font_color,
align=texts_pos[i][1],
anchor=texts_pos[i][2],
)
return (height, image)
def _apply_template(self, template: str, plot_vars: PlotVars) -> str:
text = template.replace(r'\n', '\n')
try:
text = text.format(**asdict(plot_vars))
except KeyError as e:
text = "Error: unknown variable '{}'".format(e.args[0])
return text
def _calc_page_hf_text_pos(
self, align: str, size: tuple[int, int], padding: int
) -> tuple[tuple[int, int], str, str]:
# calculate the text position
match align:
case 'left':
pos = (padding, size[1] // 2)
anchor = 'lm'
case 'right':
pos = (size[0] - padding, size[1] // 2)
anchor = 'rm'
case 'center':
pos = (size[0] // 2, size[1] // 2)
anchor = 'mm'
case _:
# probably useless
raise ValueError(
"invalid align value '{}': must be 'left', 'right' or 'center".format(
align
)
)
return (pos, align, anchor)
+77
View File
@@ -0,0 +1,77 @@
from pathlib import Path
import json
from jsonschema import Draft202012Validator # type: ignore
SCHEMA_DIR = Path(__file__).parent.parent.parent / 'schema'
class SchemaError(Exception):
def __init__(self, message: str, schema: str):
super().__init__(message)
self.schema = schema
class InvalidStructureError(Exception):
def __init__(self, message: str):
super().__init__(message)
def validate(data, schema_file: str):
# load schema
schema_path = SCHEMA_DIR / '{}.schema.json'.format(schema_file)
try:
with open(schema_path, 'r') as f:
schema = json.load(f)
except FileNotFoundError:
raise SchemaError('Schema not found', schema_file)
except json.JSONDecodeError:
raise SchemaError('Invalid schema', schema_file)
except Exception as e:
raise SchemaError(str(e), schema_file)
# validate each key against schema
# see: https://python-jsonschema.readthedocs.io/en/stable/errors/
validator = Draft202012Validator(schema)
validationErrors = sorted(validator.iter_errors(data), key=lambda e: e.path)
# first check is JSON structure is correct, early exit otherwise
if len(validationErrors) == 1 and len(validationErrors[0].absolute_path) == 0:
error = validationErrors[0]
message = (
"expected type '{}'".format(error.validator_value)
if error.validator == 'type'
else ''
)
raise InvalidStructureError(message)
collected_errors = collect_errors(validationErrors)
return (data, collected_errors)
def collect_errors(errors):
collected = []
for error in errors:
suberrors = (
[]
if not error.context
else sorted(error.context, key=lambda e: e.schema_path)
)
if len(suberrors) > 0:
collected += collect_errors(suberrors)
if error.validator in ['oneOf', 'anyOf']:
continue
if error.validator == 'required':
# collected.append(([error.validator_value[-1]], error.validator))
collected.append(
(
list(error.absolute_path) + [error.validator_value[-1]],
error.validator,
)
)
else:
if len(error.relative_path) > 0: # exclude validator 'not'
collected.append((list(error.absolute_path), error.validator))
return collected
+90
View File
@@ -0,0 +1,90 @@
from types import SimpleNamespace
import torch # type: ignore
from .utils import tensor_to_pillow, pillow_to_tensor
from .plot_data import (
XYPlotQueueData,
PlotConfigGridData,
PlotConfigHFData,
PlotVars,
)
from .grid import Grid
class Pager:
def __init__(
self,
xy_plot_data: XYPlotQueueData,
header_formats: tuple[str, str],
dim1_as_rows: bool = True,
):
self.dim1_as_rows = dim1_as_rows
self.header_formats = header_formats
self.dim1 = SimpleNamespace(
**{'length': xy_plot_data.dim1.length, 'headers': []}
)
self.dim2 = SimpleNamespace(
**{'length': xy_plot_data.dim2.length, 'headers': []}
)
# initialize the 2D list
if dim1_as_rows:
self.image_matrix = [
[None] * self.dim2.length for i in range(self.dim1.length)
]
else:
self.image_matrix = [
[None] * self.dim1.length for i in range(self.dim2.length)
]
self.accumulated = 0
@property
def expected(self) -> int:
return self.dim1.length * self.dim2.length
@property
def complete(self) -> bool:
return self.accumulated >= self.expected
# calculate the coordinates in dim1 and dim2, given a sequential index
def get_coords(self, index: int) -> tuple[int, int]:
if self.dim1_as_rows:
return (int(index / self.dim2.length), int(index % self.dim2.length))
else:
return (int(index % self.dim2.length), int(index / self.dim2.length))
def add(self, xy_plot_data: XYPlotQueueData, tensor: torch.Tensor):
# store dim1 / dim2 headers if not known yet
# TODO: catch exception and set default header?
if xy_plot_data.dim1.index >= len(self.dim1.headers):
self.dim1.headers.append(
self.header_formats[0].format(dim1=xy_plot_data.dim1.value)
)
if xy_plot_data.dim2.index >= len(self.dim2.headers):
self.dim2.headers.append(
self.header_formats[1].format(dim2=xy_plot_data.dim2.value)
)
# store the tensor as image
x, y = self.get_coords(xy_plot_data.index)
self.image_matrix[x][y] = tensor_to_pillow(tensor)
self.accumulated += 1
# return complete status
return self.complete
def make_grid(
self,
plot_vars: PlotVars,
plot_config_grid: PlotConfigGridData = PlotConfigGridData(),
plot_config_header: PlotConfigHFData = None,
plot_config_footer: PlotConfigHFData = None,
) -> torch.Tensor:
grid = Grid(plot_config_grid, plot_config_header, plot_config_footer)
if self.dim1_as_rows:
grid_image = grid.make(
self.image_matrix, self.dim2.headers, self.dim1.headers, plot_vars
)
else:
grid_image = grid.make(
self.image_matrix, self.dim1.headers, self.dim2.headers, plot_vars
)
return pillow_to_tensor(grid_image)
+52
View File
@@ -0,0 +1,52 @@
from typing import Any
from dataclasses import dataclass
# store useful values associated with a dim
@dataclass
class DimData:
index: int
length: int
value: Any
# data sent from XYPlotQueue to XYPlotRender
@dataclass
class XYPlotQueueData:
index: int
current_page: str
total_pages: int
complete: bool
dim1: DimData
dim2: DimData
@dataclass
class PlotConfigGridData:
gap: int = 20
background_color: str = '#b9b9b9'
font: str = 'Roboto-Regular.ttf'
font_size: int = 50
font_color: str = '#444'
pad_col_headers: int = 30
pad_row_headers: int = 50
wrap_col_headers: int = 0
wrap_row_headers: int = 0
@dataclass
class PlotConfigHFData:
text_left: str = ''
text_center: str = ''
text_right: str = ''
background_color: str = 'transparent'
font: str = 'Roboto-Bold.ttf'
font_size: int = 60
font_color: str = '#222'
padding: int = 30
@dataclass
class PlotVars:
current_page: int
total_pages: int
+46
View File
@@ -0,0 +1,46 @@
import torch # type: ignore
from PIL import Image
import numpy as np
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
ANY_TYPE = AnyType('*')
class AnyReturnTypes(tuple):
def __getitem__(self, key):
return ANY_TYPE
def tensor_to_pillow(tensor: torch.tensor) -> Image.Image:
return Image.fromarray(
np.clip(255.0 * tensor.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
)
def pillow_to_tensor(image: Image.Image) -> torch.tensor:
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
class EmptyNode:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
'required': {},
'optional': {},
}
FUNCTION = 'run'
RETURN_TYPES = ()
DESCRIPTION = 'This is an empty node. Not useless though.'
def run(self):
pass
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"additionalProperties": {
"oneOf": [
{ "not": { "type": "object" } },
{
"type": "object",
"properties": {
"value": {},
"type": {
"type": ["string", "array"],
"items": { "type": "string" }
},
"label": { "type": "string" },
"tooltip": { "type": "string" },
"color_off": { "type": "string" },
"color_on": { "type": "string" },
"shape": { "enum": ["box", "circle", "arrow", "grid"] },
"node": { "type": ["integer", "string"] }
},
"required": ["value"],
"additionalProperties": true
}
]
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"additionalProperties": {
"oneOf": [
{ "not": { "type": "object" } },
{
"type": "object",
"properties": {
"value": {},
"type": {
"type": ["string", "array"],
"items": { "type": "string" }
},
"label": { "type": "string" },
"tooltip": { "type": "string" },
"color_off": { "type": "string" },
"color_on": { "type": "string" },
"shape": { "enum": ["box", "circle", "arrow", "grid"] }
},
"required": ["value"],
"additionalProperties": true
}
]
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
import type { ComfyApi } from '@comfyorg/comfyui-frontend-types'
export declare const api: ComfyApi
+3
View File
@@ -0,0 +1,3 @@
import type { ComfyApp } from '@comfyorg/comfyui-frontend-types'
export declare const app: ComfyApp
+3
View File
@@ -0,0 +1,3 @@
import type { $el as $el_type } from '~/.d.ts/comfyui-frontend-types_alt.js'
export declare const $el: typeof $el_type
@@ -0,0 +1,3 @@
import type { ComfyAsyncDialog as ComfyAsyncDialog_type } from '~/.d.ts/comfyui-frontend-types_alt.js'
export declare const ComfyAsyncDialog: typeof ComfyAsyncDialog_type
+3
View File
@@ -0,0 +1,3 @@
import type { ComfyWidgets as ComfyWidgets_type } from '~/.d.ts/comfyui-frontend-types_alt.js'
export declare const ComfyWidgets: typeof ComfyWidgets_type
+332
View File
@@ -0,0 +1,332 @@
import type {
LGraphNode,
IWidget,
INodeInputSlot,
INodeOutputSlot,
} from '@comfyorg/litegraph'
import type { ComfyNodeDef } from '~/.d.ts/comfyui-frontend-types_alt.js'
import { slotShapes } from '~/shared/utils.js'
type Scope = 'widget' | 'input' | 'output'
type Slot = Exclude<Scope, 'widget'>
type NodeItems = IWidget[] | INodeInputSlot[] | INodeOutputSlot[] | undefined
type CustomLook = {
[key in Slot]?: Record<
string,
{
shape?: keyof typeof slotShapes
color_on?: string
color_off?: string
}
>
}
const customLabels = {
// PLOT
XYPlotQueue: {
widget: {
max_dim1_per_page: 'dim1: max per page',
max_dim2_per_page: 'dim2: max per page',
},
input: {
dim2: 'dim2 (opt)',
},
output: {
dim1_value: 'dim1 value',
dim2_value: 'dim2 value',
xy_plot_data: 'XY plot data',
},
},
XYPlotRender: {
input: {
xy_plot_data: 'XY plot data',
plot_config_grid: 'config: grid (opt)',
plot_config_header: 'config: header (opt)',
plot_config_footer: 'config: footer (opt)',
},
widget: {
dim1_header_format: 'dim1: header format',
dim2_header_format: 'dim2: header format',
},
},
PlotConfigGrid: {
widget: {
gap: 'gap (between cells)',
background_color: 'background color',
font_size: 'font size',
font_color: 'font color',
pad_col_headers: 'col headers: padding',
pad_row_headers: 'row headers: padding',
wrap_col_headers: 'col headers: wrap',
wrap_row_headers: 'row headers: wrap',
},
output: {
plot_config_grid: 'config: grid',
},
},
PlotConfigHF: {
widget: {
text_left: 'text (left)',
text_center: 'text (center)',
text_right: 'text (right)',
background_color: 'background color',
font: 'font',
font_size: 'font size',
font_color: 'font color',
padding: 'padding',
},
output: {
plot_config_hf: 'config: header / footer',
},
},
XYPlotDataSplit: {
input: {
xy_plot_data: 'XY plot data',
},
output: {
current_page: 'current page',
total_pages: 'total pages',
},
},
// LIST
ListFromMultiline: {
widget: {
strip_comments: 'strip comments',
},
},
ListFromFile: {
widget: {
file_path: 'path',
strip_comments: 'strip comments',
},
},
ListLimit: {
widget: {
nb_elements: 'nb of elements',
},
},
// CONFIG
OutputConfigBackend: {
widget: {
file_path: 'JSON or YAML file (backend)',
show_config: 'show config',
error_display: 'display errors',
},
},
OutputConfigLocal: {
widget: {
error_display: 'display errors',
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: {
input_list: 'input list',
},
},
FileQueue: {
widget: {
pattern: 'pattern(s)',
recursive: 'recursive?',
with_extension: 'with extension?',
},
output: {
full_path: 'full path',
relative_path: 'relative path',
},
},
ImageQueue: {
widget: {
pattern: 'pattern(s)',
recursive: 'recursive?',
with_extension: 'with extension?',
},
output: {
full_path: 'full path',
relative_path: 'relative path',
},
},
// INPUT
InputFolder: {
widget: {
path: 'folder path',
check_exists: 'check folder existence',
},
},
InputMultiline: {
widget: {
strip_comments: 'strip comments',
},
},
// UTILS
LoadImageRGBA: {
widget: {
with_extension: 'with extension?',
mask_precision: 'mask method',
},
},
} as const as Record<string, { [key in Scope]?: Record<string, string> }>
const customLooks = {
// PLOT
XYPlotQueue: {
input: {
dim1: {
shape: 'grid',
},
dim2: {
shape: 'grid',
},
},
output: {
xy_plot_data: {
color_off: '#52395B',
color_on: '#9521c3',
},
},
},
XYPlotRender: {
input: {
xy_plot_data: {
color_off: '#52395B',
color_on: '#9521c3',
},
},
},
// LIST
ListFromString: {
output: {
list: { shape: 'grid' },
},
},
ListFromMultiline: {
output: {
list: { shape: 'grid' },
},
},
ListFromFile: {
output: {
list: { shape: 'grid' },
},
},
ListFromElements: {
output: {
list: { shape: 'grid' },
},
},
ListMerge: {
output: {
list: { shape: 'grid' },
},
},
ListLimit: {
output: {
list: { shape: 'grid' },
},
},
} as const as Record<string, CustomLook>
function setNodeLabels(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
const nodeName = nodeData.name
// early exit
if (!nodeName || !Object.keys(customLabels).includes(nodeName)) return
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
const r = original_onNodeCreated.apply(this, args)
for (const [scope, scope_entry] of Object.entries(
customLabels[nodeName as keyof typeof customLabels],
)) {
for (const [name, label] of Object.entries(
scope_entry as Record<string, string>,
)) {
// setSingleLabel(this, scope as Scope, name, label)
let nodeItems: NodeItems = undefined
switch (scope as Scope) {
case 'input':
nodeItems = this.inputs
break
case 'output':
nodeItems = this.outputs
break
case 'widget':
nodeItems = this.widgets
break
}
const item = nodeItems?.find((i) => i.name === name)
if (item) item.label = label
}
}
return r
}
}
function setNodeLooks(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
const nodeName = nodeData.name
// early exit
if (!nodeName || !Object.keys(customLooks).includes(nodeName)) return
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
const r = original_onNodeCreated.apply(this, args)
for (const [scope, scope_entry] of Object.entries(
customLooks[nodeName as keyof typeof customLooks],
)) {
for (const [name, lookEntry] of Object.entries(scope_entry)) {
let nodeItems: NodeItems = undefined
switch (scope) {
case 'input':
nodeItems = this.inputs
break
case 'output':
nodeItems = this.outputs
break
}
const item = nodeItems?.find((i) => i.name === name)
if (item) {
for (const [lookName, lookValue] of Object.entries(lookEntry)) {
if (lookName === 'shape')
item[lookName] = slotShapes[lookValue as keyof typeof slotShapes]
else item[lookName] = lookValue
}
}
}
}
return r
}
}
export function customizeNode(
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef,
) {
setNodeLabels(nodeType, nodeData)
setNodeLooks(nodeType, nodeData)
}
+307
View File
@@ -0,0 +1,307 @@
import type { LGraphNode, IWidget } from '@comfyorg/litegraph'
import {
findWidget,
ALL_CONFIG_FILE_TYPES,
humanFileType,
guessFileType,
loadTextFile,
retrievefile,
forceMouseEvent,
removeAllOutputs,
} from '~/shared/utils.js'
import {
makeTextWidget,
makeButtonWidget,
makeToggleWidget,
type ISwitchWidget,
makeSwitchWidget,
} from '~/widgets/factories.js'
import { ERROR_DISPLAY, type ErrorDisplayChoices } from '~/widgets/misc.js'
import { type IConfigValidationResult } from '~/shared/config.common.js'
import {
type IOutputParsed,
type IOutputErrors,
processOutputConfigLocal,
} from '~/shared/config.output.js'
import { displaySingleError, displayErrorDict } from '~/shared/error.js'
async function processOutputResult(
node: LGraphNode,
result: IConfigValidationResult<IOutputParsed, IOutputErrors>,
dataWidget: IWidget,
statusWidget: IWidget,
errorDisplay: ErrorDisplayChoices,
) {
if (!result) return // empty path or config
const title = node.title || node.type
switch (result.success) {
case false:
dataWidget.value = {}
statusWidget.label = result.error
await displaySingleError(title, errorDisplay, result.error)
break
case true:
dataWidget.value = result.data
statusWidget.label = 'All outputs generated'
break
case 'partial': {
dataWidget.value = result.data
const message = `${result.nbErrors} error${result.nbErrors > 1 ? 's' : ''} detected`
statusWidget.label = message
await displayErrorDict(title, errorDisplay, result.errors, message)
}
}
}
async function processOutputConfigUI(
node: LGraphNode,
switchWidget: ISwitchWidget,
configWidget: IWidget<string>,
dataWidget: IWidget,
statusWidget: IWidget,
errorDisplayWidget: IWidget,
) {
let res
await switchWidget.guard(async () => {
// generate outputs
res = await processOutputConfigLocal(node, configWidget.value || undefined)
})
// process result
const result = res as IConfigValidationResult<IOutputParsed, IOutputErrors>
if (!result || result.success === false) configWidget.value = ''
await processOutputResult(
node,
result,
dataWidget,
statusWidget,
errorDisplayWidget.selected,
)
// save statusWidget label to value, to ensure it is serialized and restored on page refresh
statusWidget.value = statusWidget.label
}
export function OutputConfigBackend(
nodeType: LGraphNode,
// _nodeData: ComfyNodeDef,
// app: ComfyApp,
) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
// get existing widgets and add the button
const pathWidget = makeTextWidget(this, 'file_path', {
multiline: false,
placeholder: 'path to file (backend)',
default: '',
tooltip: 'full path to file (backend)',
})
const retrieveButtonWidget = makeButtonWidget(
this,
'retrieve',
'Retrieve output config',
{
disabled: true,
tooltip: 'retrieve output config from a backend JSON or YAML file',
},
)
const errorDisplayWidget = ERROR_DISPLAY(this)
const showConfigWidget = makeToggleWidget(this, 'show_config', {
default: true,
})
const configWidget = makeTextWidget(this, 'config', {
multiline: true,
placeholder: 'output config',
default: '',
tooltip: 'output config as JSON or YAML',
disabled: true,
})
const filenameWidget = makeTextWidget(this, 'filename', {
multiline: false,
disabled: true,
label: 'no file',
})
const statusWidget = makeButtonWidget(this, 'status', 'No file selected', {
tooltip: 'status',
disabled: true,
})
const switchWidget = makeSwitchWidget(this, 'switch', [configWidget])
const dataWidget = findWidget(this, 'data')
// implement logic
pathWidget.callback = (text: string) => {
retrieveButtonWidget.disabled = !text
}
showConfigWidget.callback = (value: boolean) => {
switchWidget.switch(value)
}
retrieveButtonWidget.callback = async () => {
// load file content to config widget
filenameWidget.label = 'no file'
filenameWidget.value = ''
configWidget.value = ''
statusWidget.label = 'Retrieving file...'
try {
const retrieved = await retrievefile(pathWidget.value)
filenameWidget.label = `file: (${humanFileType(retrieved.mime)})`
filenameWidget.value = retrieved.filename
configWidget.value = retrieved.content
await processOutputConfigUI(
this,
switchWidget,
configWidget,
dataWidget,
statusWidget,
errorDisplayWidget,
)
} catch (e) {
statusWidget.label = (e as Error).message
dataWidget.value = {}
await switchWidget.guard(async () => {
removeAllOutputs(this)
for (const w of this.widgets) {
delete w.y
delete w.last_y
}
})
await displaySingleError(
this.title || this.type,
errorDisplayWidget.selected,
(e as Error).message,
)
} finally {
// strange behavior here: the widgets values are not saved, until there is any sort of mouse event.
// that can be select node, resize it, activate a widget, even just click on canvas.
// this doesn't seem to appear on exception catch, but just in case we put that in a finally block.
forceMouseEvent()
}
}
}
const original_onConfigure = nodeType.prototype.onConfigure
nodeType.prototype.onConfigure = function (...args: unknown[]) {
original_onConfigure?.apply(this, ...args)
console.log('OutputConfigBackend / onConfigure')
// restore file and status labels
const statusWidget = findWidget(this, 'status')
if (statusWidget.value) statusWidget.label = statusWidget.value
const filenameWidget = findWidget(this, 'filename')
filenameWidget.label = 'no file'
if (filenameWidget.value) {
// we do not have access to the MIME type anymore, so try to guess
const ext = filenameWidget.value.split('.').pop()
if (ext === 'json') filenameWidget.label = 'file: (json)'
else if (['yml', 'yaml'].includes(ext))
filenameWidget.label = 'file: (yaml)'
else filenameWidget.label = 'file:'
}
// restore button state
const retrieveButtonWidget = findWidget(this, 'retrieve')
const pathWidget = findWidget(this, 'file_path')
retrieveButtonWidget.disabled = !pathWidget.value
// hide preview if needed
const showConfigWidget = findWidget(this, 'show_config')
if (!showConfigWidget.value) showConfigWidget.callback(false)
}
}
export function OutputConfigLocal(
nodeType: LGraphNode,
// nodeData: ComfyNodeDef,
// _app: ComfyApp,
) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
// define widgets here, to prevent them from being converted to inputs
const loadButtonWidget = makeButtonWidget(
this,
'load',
'Load output config',
{
tooltip: 'load output config from a local JSON or YAML file',
},
)
const errorDisplayWidget = ERROR_DISPLAY(this)
const showConfigWidget = makeToggleWidget(this, 'show_config', {
default: true,
})
const configWidget = makeTextWidget(this, 'config', {
multiline: true,
placeholder: 'output config',
default: '',
tooltip: 'output config as JSON or YAML',
disabled: true,
})
const filenameWidget = makeTextWidget(this, 'filename', {
multiline: false,
disabled: true,
label: 'no file',
})
const statusWidget = makeButtonWidget(this, 'status', 'No file selected', {
tooltip: 'status',
disabled: true,
})
const switchWidget = makeSwitchWidget(this, 'switch', [configWidget])
const dataWidget = findWidget(this, 'data')
// implement logic
showConfigWidget.callback = (value: boolean) => {
switchWidget.switch(value)
}
loadButtonWidget.callback = async () => {
try {
const loaded = await loadTextFile(ALL_CONFIG_FILE_TYPES)
if (loaded) {
filenameWidget.label = `file: (${humanFileType(loaded.type)})`
filenameWidget.value = loaded.name
configWidget.value = loaded.content
} else {
return
}
} catch (e) {
statusWidget.label = (e as Error).message
configWidget.value = ''
dataWidget.value = {}
await switchWidget.guard(async () => {
removeAllOutputs(this)
for (const w of this.widgets) {
delete w.y
delete w.last_y
}
})
await displaySingleError(
this.title || this.type,
errorDisplayWidget.selected,
'Content is not JSON or YAML',
)
return
}
await processOutputConfigUI(
this,
switchWidget,
configWidget,
dataWidget,
statusWidget,
errorDisplayWidget,
)
}
}
const original_onConfigure = nodeType.prototype.onConfigure
nodeType.prototype.onConfigure = function (...args: unknown[]) {
original_onConfigure?.apply(this, ...args)
// restore file and status labels
const statusWidget = findWidget(this, 'status')
if (statusWidget.value) statusWidget.label = statusWidget.value
const filenameWidget = findWidget(this, 'filename')
filenameWidget.label = 'no file'
if (filenameWidget.value) {
// we do not have access to the MIME type anymore, so try to guess
filenameWidget.label = guessFileType(filenameWidget.value)
}
// hide preview if needed
const showConfigWidget = findWidget(this, 'show_config')
if (!showConfigWidget.value) showConfigWidget.callback(false)
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { loadTextFile } from '~/shared/utils.js'
import {
makeTextWidget,
makeButtonWidget,
makeToggleWidget,
makeSwitchWidget,
} from '~/widgets/factories.js'
import { parseConfig } from '~/shared/config.common.js'
import { log } from '~/shared/common.js'
export function DebugJSONYAML(
nodeType: LGraphNode,
// nodeData: ComfyNodeDef,
// _app: ComfyApp,
) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
// load file
const loadButtonWidget = makeButtonWidget(this, 'load', 'Load file', {
tooltip: 'load a JSON or YAML file',
})
const fileTypeWidget = makeTextWidget(this, 'file_type', {
multiline: false,
placeholder: '',
default: 'unknown',
tooltip: 'content type as detected on file load',
})
fileTypeWidget.disabled = true
const contentWidget = makeTextWidget(this, 'content', {
multiline: true,
placeholder: 'loaded content',
default: '{}',
tooltip: 'loaded content',
})
const parsedTypeWidget = makeTextWidget(this, 'parsed_type', {
multiline: false,
placeholder: '',
default: 'unknown',
tooltip: 'content type as detected on parse',
})
parsedTypeWidget.disabled = true
// load file to text area
loadButtonWidget.callback = async () => {
fileTypeWidget.value = 'unknown'
parsedTypeWidget.value = 'unknown'
const loaded = await loadTextFile(
'.json,.yaml,.yml,application/JSON,application/yaml',
)
if (loaded) {
fileTypeWidget.value = loaded.type
try {
const parsed = await parseConfig(loaded.content)
parsedTypeWidget.value = parsed.type
contentWidget.value = parsed.prettified
} catch (e) {
parsedTypeWidget.value = (e as Error).message
contentWidget.value = ''
}
}
this.setDirtyCanvas(true)
}
}
}
export function DebugWidgetVisibility(
nodeType: LGraphNode,
// nodeData: ComfyNodeDef,
// _app: ComfyApp,
) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
// add widgets
makeButtonWidget(this, 'button1', 'a button', {})
const showWidget = makeToggleWidget(this, 'show', {
default: true,
})
const multi1Widget = makeTextWidget(this, 'multiline1', {
multiline: true,
default: 'multiline1',
})
const text1Widget = makeTextWidget(this, 'text1', {
multiline: false,
default: 'string #1',
})
const text2Widget = makeTextWidget(this, 'text2', {
multiline: false,
default: 'string #2',
})
const multi2Widget = makeTextWidget(this, 'multiline2', {
multiline: true,
default: 'multiline2',
})
makeTextWidget(this, 'footer', {
multiline: false,
default: 'footer',
})
const switchWidget = makeSwitchWidget(
this,
'switch',
[multi1Widget, text1Widget],
[text2Widget, multi2Widget],
)
// implement logic
showWidget.callback = (value: boolean) => {
switchWidget.switch(value)
log.debug('switch value', switchWidget.value)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { findWidget, loadTextFile } from '~/shared/utils.js'
export function InputMultiline(
nodeType: LGraphNode,
// nodeData: ComfyNodeDef,
// _app: ComfyApp,
) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
// load file
const loadButtonWidget = findWidget(this, 'load')
const textWidget = findWidget(this, 'multiline')
// load file to text area
loadButtonWidget.callback = async () => {
const loaded = await loadTextFile('text/*')
if (loaded) {
textWidget.value = loaded.content
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { findWidget, loadTextFile } from '~/shared/utils.js'
export function ListFromMultiline(
nodeType: LGraphNode,
// nodeData: ComfyNodeDef,
// _app: ComfyApp,
) {
const original_onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (...args: unknown[]) {
original_onNodeCreated?.apply(this, ...args)
// load file
const loadButtonWidget = findWidget(this, 'load')
const textWidget = findWidget(this, 'multiline')
// load file to text area
loadButtonWidget.callback = async () => {
const loaded = await loadTextFile('text/*')
if (loaded) {
textWidget.value = loaded.content
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { app } from '~/.mock/scripts/app.js'
import type { ComfyNodeDef } from '~/.d.ts/comfyui-frontend-types_alt.js'
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 { OutputConfigBackend, OutputConfigLocal } from '~/nodes/config.js'
import { ListFromMultiline } from '~/nodes/list.js'
import { InputMultiline } from '~/nodes/input.js'
import { DebugJSONYAML, DebugWidgetVisibility } from '~/nodes/debug.js'
type GlobalThis = typeof globalThis & GlobalThisWithCategory
;(globalThis as GlobalThis)[CATEGORY] = { log: 'debug' }
app.registerExtension({
name: 'ComfyLab.pack',
async beforeRegisterNodeDef(
nodeType: LGraphNode,
nodeData: ComfyNodeDef,
// _app: ComfyApp,
) {
// skip the node if it's not ours
if (nodeData?.category?.split('/')[0] !== CATEGORY) {
return
}
switch (nodeData.name) {
// LIST
case 'ListMerge':
setupDynamicConnections(nodeType, 'list', 'LIST', {
separator: ' ',
start_index: 1,
})
break
case 'ListFromElements':
setupDynamicConnections(nodeType, 'element', '*', {
separator: ' ',
start_index: 1,
})
break
// INPUT
case 'InputMultiline':
InputMultiline(nodeType)
break
// UTILS
case 'FormatString':
case 'FormatMultiline':
setupDynamicConnections(nodeType, 'arg', 'STRING,INT,FLOAT,BOOLEAN', {
separator: '',
start_index: 0,
})
break
case 'ListFromMultiline':
ListFromMultiline(nodeType)
break
// DEBUG
case 'DebugJSONYAML':
DebugJSONYAML(nodeType)
break
case 'DebugWidgetVisibility':
DebugWidgetVisibility(nodeType)
break
// CONFIG
case 'OutputConfigBackend':
OutputConfigBackend(nodeType)
break
case 'OutputConfigLocal':
OutputConfigLocal(nodeType)
break
}
// customize labels and looks of widgets, input / output slots
customizeNode(nodeType, nodeData)
return nodeType
},
getCustomWidgets() {
return {
QUEUE_STATUS,
HIDDEN,
BTN,
ERROR_DISPLAY,
}
},
})
+75
View File
@@ -0,0 +1,75 @@
export const CATEGORY: string = 'ComfyLab'
const logLevels = {
success: { level: 0, console: 'info', emoji: '👍' },
// error: { level: 1, console: "error", emoji: "⛔" },
error: { level: 1, console: 'error', emoji: undefined },
// warn: { level: 2, console: "warn", emoji: "⚠️" },
warn: { level: 2, console: 'warn', emoji: undefined },
info: { level: 3, console: 'info', emoji: 'ℹ️' },
// debug: { level: 4, console: "debug", emoji: "🐞" },
debug: { level: 4, console: 'debug', emoji: undefined },
} as const
export type LogLevels = keyof typeof logLevels
export type GlobalThisWithCategory = Partial<{
[key: typeof CATEGORY]: {
debug?: boolean
log?: LogLevels
}
}>
const logger = (levelType: LogLevels, ...args: unknown[]) => {
const gt = globalThis as unknown as GlobalThisWithCategory
if (
gt[CATEGORY]?.debug ||
(gt[CATEGORY]?.log &&
logLevels[gt[CATEGORY].log].level >= logLevels[levelType].level)
) {
const level = gt[CATEGORY]?.debug
? logLevels['debug']
: logLevels[levelType]
if (level.emoji) console[level.console](level.emoji, ...args)
else console[level.console](...args)
}
}
export const isObject = (variable: unknown): boolean => {
const check = Object(variable)
return (
check &&
variable === check &&
typeof variable !== 'function' &&
!Array.isArray(check)
)
}
export const inspect = (variable: unknown): void => {
if (isObject(variable)) {
for (const [name, val] of Object.entries(
variable as Record<string | number | symbol, unknown>,
)) {
console.log(`${name}: `, val)
}
} else {
console.log(variable)
}
}
export const log = {
success: (...args: unknown[]) => logger('success', `${CATEGORY}:`, ...args),
error: (...args: unknown[]) => logger('error', `${CATEGORY}:`, ...args),
warn: (...args: unknown[]) => logger('warn', `${CATEGORY}:`, ...args),
info: (...args: unknown[]) => logger('info', `${CATEGORY}:`, ...args),
debug: (...args: unknown[]) => logger('debug', ...args),
sep: () => {
console.log('---------------------------------')
},
inspect,
}
export const success = log.success
export const error = log.error
export const warn = log.warn
export const info = log.info
export const debug = log.debug
+31
View File
@@ -0,0 +1,31 @@
import { fetchApi } from './utils.js'
import { IOutputParsed, IOutputErrors } from './config.output.js'
export async function parseConfig(raw: string) {
return await fetchApi('config_parse', 'POST', { raw })
}
export type ApiValidationError = [string[], string]
export type WidgetValue = string | number | boolean
export type IConfigValidationResult<
TData extends IOutputParsed,
TErrors extends IOutputErrors,
> =
| {
success: true
data: TData
applied: true
}
| {
success: 'partial'
data: TData
nbErrors: number
errors: TErrors
applied: boolean
}
| {
success: false
error: string
}
| void
+190
View File
@@ -0,0 +1,190 @@
import type { LGraphNode, INodeOutputSlot, LLink } from '@comfyorg/litegraph'
import { isObject } from './common.js'
import { guessOutputType, compatibleTypes } from './types.js'
import { fetchApi, slotShapes, removeAllOutputs } from './utils.js'
import { ApiValidationError, IConfigValidationResult } from './config.common.js'
interface IOutputEntry {
value: unknown
type?: string | string[]
label?: string
tooltip?: string
shape?: keyof typeof slotShapes
color_on?: string
color_off?: string
}
type NotObject = Exclude<
unknown,
Record<string, unknown> | { [key in string]: unknown }
>
// raw data before parse
type IOutputData = Record<string, IOutputEntry | NotObject>
// parsed data
export type IOutputParsed = Record<string, unknown>
export type IOutputErrors = Record<string, string[]>
export async function processOutputConfigLocal(
node: LGraphNode,
config: string | undefined,
): Promise<IConfigValidationResult<IOutputParsed, IOutputErrors>> {
let body = {} as { data?: IOutputData; errors?: ApiValidationError[] }
let data: IOutputData = {}
if (config) {
try {
// load data from response
body = await fetchApi('config_validate', 'POST', {
raw: config,
schema: 'config.output',
})
} catch (error) {
removeAllOutputs(node)
return {
success: false,
error: (error as Error).message,
}
}
data = body.data as IOutputData
}
const apiErrors: ApiValidationError[] = body.errors || []
type IError = Partial<Record<keyof IOutputEntry, string>>
const errors: Record<string, IError> = {}
// build errors
for (const [errorScope, errorType] of apiErrors) {
if (!Object.keys(errors).includes(errorScope[0])) errors[errorScope[0]] = {}
errors[errorScope[0]][errorScope[1] as keyof IOutputEntry] = errorType
}
// no data => early exit
if (Object.keys(data).length === 0) {
removeAllOutputs(node)
return {
success: false,
error: 'No data',
}
}
// parse data and create the output slots, filtering the ones in error
const parsed = new Map()
for (const [k, entry] of Object.entries(data)) {
if (Object.keys(errors).includes(k)) continue
const key = String(k)
let value, type
const extra_info: Record<string, unknown> = {} // for label, tooltip, ...
if (!isObject(entry)) {
value = entry
type = guessOutputType(value)
} else {
const entryObj = entry as IOutputEntry
value = entryObj['value']
// try to determine type
if (Object.keys(entryObj).includes('type')) {
type = entryObj['type']
} else {
type = guessOutputType(value)
}
// get optional info: label, color, ...
// TODO: tooltip doesn't work, keep it? Or try to find a way, maybe w/ element.title
for (const extra of ['label', 'tooltip', 'color_on', 'color_off']) {
if (Object.keys(entryObj).includes(extra))
extra_info[extra] = entryObj[extra as keyof IOutputEntry]
}
// special case for slot shape
if (entryObj.shape && Object.keys(slotShapes).includes(entryObj.shape))
extra_info.shape = slotShapes[entryObj.shape]
}
// auto-set shape for lists if not specified
if (!extra_info.shape && type === 'LIST')
extra_info.shape = slotShapes['grid']
parsed.set(key, {
value,
type,
extra_info,
})
}
// create the output slots, trying to preserve the existing ones while keeping the order in the config file
const existingLinks: Record<string, LLink[]> = {}
// remove all outputs, but keep a track of existing links if applicable
while (node.outputs?.length > 0) {
const output: INodeOutputSlot = node.outputs[0]
const entry = parsed.get(output.name)
// only keep a track if name, label and type are identical, and if there are links
if (
entry &&
compatibleTypes(entry.type, output.type) &&
entry.extra_info.label === output.label &&
output.links &&
output.links.length
) {
const links: LLink[] = []
for (const l of output.links) {
const link = node.graph.links.get(l)
if (link) links.push(link)
}
if (links.length) existingLinks[output.name] = links
}
node.removeOutput(0)
}
// add the outputs from parsed, and recreate previous connections
for (const [name, entry] of parsed.entries()) {
node.addOutput(name, entry.type, entry.extra_info)
// recreate links
const links = existingLinks[name]
if (links) {
for (const link of links) {
const targetNode = node.graph._nodes_by_id[link.target_id]
node.connect(name, targetNode, link.target_slot)
}
}
}
// parse the errors to have explicit labels
const errorLabels: Record<string, string[]> = {}
let nbErrors = 0
for (const [output, props] of Object.entries(errors)) {
errorLabels[output] = []
for (const [prop, errorType] of Object.entries(props)) {
nbErrors++
if (errorType === 'required') {
errorLabels[output].push(`'${prop}' is required`)
continue
}
let label = `'${prop}' `
switch (prop) {
case 'type':
label += 'should be a string, or a list of strings'
break
case 'label':
case 'tooltip':
case 'color_off':
case 'color_on':
label += 'should be a string'
break
case 'shape':
label += "should be one of: 'box', 'circle', 'arrow', 'grid'"
break
}
errorLabels[output].push(label)
}
}
if (nbErrors === 0)
return {
success: true,
data: Object.fromEntries(parsed),
applied: true,
}
else
return {
success: 'partial',
data: Object.fromEntries(parsed),
nbErrors,
errors: errorLabels,
applied: true,
}
}
+320
View File
@@ -0,0 +1,320 @@
// See: https://github.com/melMass/comfy_mtb/blob/main/web/comfy_shared.js#L399
import { app } from '~/.mock/scripts/app.js'
import type {
LGraphNode,
LLink,
IWidget,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
} from '@comfyorg/litegraph'
type INodesFromLink = {
to: LGraphNode
from: LGraphNode
type: 'error' | 'incoming' | 'outgoing'
}
/**
* @param {LGraphNode} node
* @param {LLink} link
* @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}}
*/
export const nodesFromLink = (
node: LGraphNode,
link: LLink,
): INodesFromLink => {
const fromNode = app.graph.getNodeById(link.origin_id)
const toNode = app.graph.getNodeById(link.target_id)
let tp: INodesFromLink['type'] = 'error'
if (fromNode.id === node.id) {
tp = 'outgoing'
} else if (toNode.id === node.id) {
tp = 'incoming'
}
return { to: toNode, from: fromNode, type: tp }
}
type IOnConnectionsChangeParams = {
type: ISlotType
slotIndex: number
isConnected: boolean
link: LLink
ioSlot: INodeInputSlot | INodeOutputSlot
}
interface ISetupDynamicConnectionsOpts {
separator?: string
start_index?: number
link?: LLink
ioSlot?: INodeInputSlot | INodeOutputSlot
DEBUG?: {
node: LGraphNode
} & IOnConnectionsChangeParams
}
/**
* @param {NodeType} nodeType The nodetype to attach the documentation to
* @param {str} prefix A prefix added to each dynamic inputs
* @param {str | [str]} inputType The datatype(s) of those dynamic inputs
* @param {{separator?:string, start_index?:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} [opts] Extra options
* @returns
*/
export const setupDynamicConnections = (
nodeType: LGraphNode,
prefix: string,
inputType: string | string[],
opts: ISetupDynamicConnectionsOpts | undefined = undefined,
) => {
// console.log(
// "Setting up dynamic connections for",
// Object.getOwnPropertyDescriptors(nodeType).title.value
// );
/** @type {{separator:string, start_index:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} */
// const options = opts || {};
const options = Object.assign(
{
separator: '_',
start_index: 1,
},
opts || {},
)
const onNodeCreated = nodeType.prototype.onNodeCreated
const inputList = typeof inputType === 'object'
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined
this.addInput(
`${prefix}${options.separator}${options.start_index}`,
inputList ? '*' : inputType,
)
return r
}
const onConnectionsChange = nodeType.prototype.onConnectionsChange
/**
* @param {OnConnectionsChangeParams} args
*/
nodeType.prototype.onConnectionsChange = function (...args: unknown[]) {
const [type, slotIndex, isConnected, link, ioSlot] = args
options.link = link
options.ioSlot = ioSlot
const r = onConnectionsChange
? onConnectionsChange.apply(this, [
type,
slotIndex,
isConnected,
link,
ioSlot,
])
: undefined
options.DEBUG = {
node: this,
type,
slotIndex,
isConnected,
link,
ioSlot,
} as ISetupDynamicConnectionsOpts['DEBUG']
// keep a track of current size, and also the minimum / computed one
const oldSize = [this.size[0], this.size[1]]
const oldComputedSize = this.computeSize()
dynamicConnection(
this,
slotIndex as number,
isConnected as boolean,
`${prefix}${options.separator}`,
inputType as string,
options,
)
// reset positions of all widgets, they will be recalculated
for (const widget of this.widgets || []) {
delete widget.y
delete widget.last_y
// computedHeight is used by the multiline widgets, it must be reset to force it to take all height
delete widget.computedHeight
}
// get the new computed size, after adding / removing inputs
const newComputedSize = this.computeSize()
// recalculate the node size: preserve width, and increase / decrease height
this.setSize([
oldSize[0],
oldSize[1] + newComputedSize[1] - oldComputedSize[1],
])
return r
}
}
interface IDynamicConnectionOpts {
start_index?: number
link?: LLink
ioSlot?: INodeInputSlot | INodeOutputSlot
nameArray?: string[]
}
/**
* Main logic around dynamic inputs
*
* @param {LGraphNode} node - The target node
* @param {number} index - The slot index of the currently changed connection
* @param {bool} connected - Was this event connecting or disconnecting
* @param {string} [connectionPrefix] - The common prefix of the dynamic inputs
* @param {string|[string]} [connectionType] - The type of the dynamic connection
* @param {{start_index?:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options
*/
export const dynamicConnection = (
node: LGraphNode,
index: number,
connected: boolean,
connectionPrefix: string = 'input_',
connectionType: string = '*',
opts: IDynamicConnectionOpts | undefined = undefined,
) => {
/* @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options*/
// const options = opts || {};
const options = Object.assign(
{
start_index: 1,
},
opts || {},
)
// function to test if input is a dynamic one
const isDynamicInput = (inputName: string) =>
inputName.startsWith(connectionPrefix)
if (node.inputs.length > 0 && !isDynamicInput(node.inputs[index].name)) {
return
}
const listConnection = typeof connectionType === 'object'
const conType = listConnection ? '*' : connectionType
const nameArray = options.nameArray || []
const clean_inputs = () => {
if (node.inputs.length === 0) return
// let w_count = node.widgets?.length || 0
let i_count = node.inputs?.length || 0
// console.log(
// `Cleaning inputs: [BEFORE] (w: ${w_count} | inputs: ${i_count})`
// );
const to_remove = []
for (let n = 1; n < node.inputs.length; n++) {
const element = node.inputs[n]
if (!element.link && isDynamicInput(element.name)) {
if (node.widgets) {
const w = node.widgets.find((w: IWidget) => w.name === element.name)
if (w) {
w.onRemoved?.()
node.widgets.length = node.widgets.length - 1
}
}
// console.log(`Removing input ${n}`);
to_remove.push(n)
}
}
for (let i = 0; i < to_remove.length; i++) {
const id = to_remove[i]
node.removeInput(id)
i_count -= 1
}
node.inputs.length = i_count
// w_count = node.widgets?.length || 0
i_count = node.inputs?.length || 0
// console.log(
// `Cleaning inputs: [AFTER] (w: ${w_count} | inputs: ${i_count})`
// );
// console.log("Cleaning inputs: making it sequential again");
// make inputs sequential again
let prefixed_idx = options.start_index
for (let i = 0; i < node.inputs.length; i++) {
// let name = `${connectionPrefix}${i + 1}`;
let name = ''
// rename only prefixed inputs
if (isDynamicInput(node.inputs[i].name)) {
// prefixed => rename and increase index
name = `${connectionPrefix}${prefixed_idx}`
prefixed_idx += 1
} else {
// not prefixed => keep same name
name = node.inputs[i].name
}
if (nameArray.length > 0) {
name = i < nameArray.length ? nameArray[i] : name
}
// preserve label if it exists
node.inputs[i].label = node.inputs[i].label || name
node.inputs[i].name = name
}
}
if (!connected) {
if (!options.link) {
// console.log("Disconnecting", { options });
clean_inputs()
} else {
if (!options.ioSlot.link) {
node.connectionTransit = true
} else {
node.connectionTransit = false
clean_inputs()
}
// console.log("Reconnecting", { options });
}
}
if (connected) {
if (options.link) {
// const { from, to, type } = nodesFromLink(node, options.link)
const link = nodesFromLink(node, options.link)
if (link.type === 'outgoing') return
// console.log("Connecting", { options, from, to, type });
} else {
// console.log("Connecting", { options });
}
if (node.connectionTransit) {
// console.log("In Transit");
node.connectionTransit = false
}
// Remove inputs and their widget if not linked.
clean_inputs()
if (node.inputs.length === 0) return
// add an extra input
if (node.inputs[node.inputs.length - 1].link !== null) {
// const nextIndex = node.inputs.length;
// count only the prefixed inputs
const nextIndex = node.inputs.reduce(
(acc: number, cur: INodeInputSlot) =>
isDynamicInput(cur.name) ? ++acc : acc,
0,
)
const name =
nextIndex < nameArray.length
? nameArray[nextIndex]
: `${connectionPrefix}${nextIndex + options.start_index}`
// console.log(`Adding input ${nextIndex + options.start_index} (${name})`);
node.addInput(name, conType)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { $el } from '~/.mock/scripts/ui.js'
import { log } from '~/shared/common.js'
import { type ErrorDisplayChoices } from '~/widgets/misc.js'
import { showErrorDialog } from '~/shared/utils.js'
export async function displaySingleError(
nodeTitle: string,
errorDisplay: ErrorDisplayChoices,
token1: string,
token2 = '',
displayTitle = true,
displayCategory = true,
) {
let message = ''
switch (errorDisplay) {
case 'browser':
message = token2 ? `${token1}: ${token2}` : token1
if (displayTitle) log.warn(`${nodeTitle}: ${message}`)
else if (displayCategory) log.warn(message)
else console.warn(message) // useful for indented content
break
case 'dialog':
message = token2 ? `${token1}: ${token2}` : token1
if (displayTitle)
await showErrorDialog({
title: nodeTitle,
subtitle: '⚠️ ' + message,
centerSubtitle: true,
})
else
await showErrorDialog({
subtitle: '⚠️ ' + message,
centerSubtitle: true,
})
break
default:
}
}
export async function displayErrorDict(
nodeTitle: string,
errorDisplay: ErrorDisplayChoices,
errorDict: Record<string, string | string[]>,
message = '',
) {
switch (errorDisplay) {
case 'browser':
if (message) {
await displaySingleError(nodeTitle, errorDisplay, message + ':')
}
for (const [key, err] of Object.entries(errorDict)) {
const errors = Array.isArray(err) ? err : [err]
for (const error of errors) {
// if message is displayed, do no repeat prefix for each, indent them instead
if (message)
await displaySingleError(
nodeTitle,
errorDisplay,
` - '${key}'`,
String(error),
false,
false,
)
else
await displaySingleError(
nodeTitle,
errorDisplay,
`Error in '${key}'`,
String(error),
)
}
}
break
case 'dialog': {
const allErrors = []
for (const [key, errors] of Object.entries(errorDict)) {
if (Array.isArray(errors)) {
const keyErrors = []
for (const error of errors) {
keyErrors.push($el('li', String(error)))
}
allErrors.push(
$el('li', [
key + ':',
$el('ul', { style: { paddingLeft: '20px' } }, keyErrors),
]),
)
} else {
allErrors.push($el('li', [`'${key}': ${String(errors)}`]))
}
}
await showErrorDialog({
title: nodeTitle,
subtitle: '⚠️ ' + message,
centerSubtitle: true,
content: $el(
'ul',
{ style: { margin: '0px', paddingLeft: '0px' } },
allErrors,
),
centerContent: true,
})
break
}
default:
}
}
+27
View File
@@ -0,0 +1,27 @@
export function guessOutputType(value: unknown) {
if (Array.isArray(value)) return 'LIST'
else if (typeof value === 'number') {
// if it's an integer, return both for better compatibility
// CHECK: use NUMBER here?
return Number.isInteger(value) ? ['INT', 'FLOAT'] : 'FLOAT'
} else if (typeof value === 'bigint') return 'INT'
else if (typeof value === 'string') return 'STRING'
else if (typeof value === 'boolean') return 'BOOLEAN'
else return '*'
}
export function compatibleTypes(
type1: string | string[],
type2: string | string[],
) {
if (!Array.isArray(type1) && !Array.isArray(type2)) return type1 === type2
const t1 = Array.isArray(type1) ? type1 : [type1]
const t2 = Array.isArray(type2) ? type2 : [type2]
if (t1.length !== t2.length) return false
for (const val of t1) {
if (!t2.includes(val)) return false
}
return true
}
+338
View File
@@ -0,0 +1,338 @@
import type {
LGraphNode,
IWidget,
INodeInputSlot,
INodeOutputSlot,
} from '@comfyorg/litegraph'
import { api } from '~/.mock/scripts/api.js'
import { $el } from '~/.mock/scripts/ui.js'
import { ComfyAsyncDialog } from '~/.mock/scripts/ui/components/asyncDialog.js'
export const findWidget = (
node: LGraphNode,
name: string,
): IWidget | undefined => {
return node.widgets?.find((w: IWidget) => w.name === name)
}
export const findWidgetIndex = (
node: LGraphNode,
name: string,
): number | undefined => {
return node.widgets?.findIndex((w: IWidget) => w.name === name)
}
export const findInput = (
node: LGraphNode,
name: string,
): INodeInputSlot | undefined => {
return node.inputs?.find((i: INodeInputSlot) => i.name === name)
}
export const findWidgetOrInput = (
node: LGraphNode,
name: string,
): IWidget | INodeInputSlot | undefined => {
return findWidget(node, name) || findInput(node, name)
}
export const findOutput = (
node: LGraphNode,
name: string,
): INodeOutputSlot | undefined => {
return node.outputs?.find((o: INodeOutputSlot) => o.name === name)
}
export const removeWidget = (node: LGraphNode, name: string): boolean => {
const index = findWidgetIndex(node, name)
if (index && index >= 0) {
const widget = node.widgets[index]
node.widgets.splice(index, 1)
widget?.onRemove()
node.size = node.computeSize()
node.setDirtyCanvas(true, true)
return true
} else return false
}
export function removeAllOutputs(node: LGraphNode) {
while (node.outputs?.length > 0) {
node.removeOutput(0)
}
}
type NodeLookupMode = 'id' | 'title' | 'type'
export function lookupNodes(
key: string | number,
by?: NodeLookupMode | NodeLookupMode[],
except?: LGraphNode,
): LGraphNode[] {
let modes: NodeLookupMode[] = []
if (!by) modes = ['id', 'title', 'type']
else if (Array.isArray(by)) modes = by
else modes = [by]
const strKey = String(key)
const found: Set<LGraphNode> = new Set()
for (const mode of modes) {
if (mode === 'id' && !isNaN(+strKey) && Number.isInteger(+strKey)) {
const node: LGraphNode | undefined = app.graph._nodes_by_id[+strKey]
if (node && node !== except) found.add(node)
} else {
for (const node of app.graph._nodes) {
if (node[mode] === strKey && node !== except) found.add(node)
}
}
}
return Array.from(found)
}
export const slotShapes = {
box: 1,
// round: 2,
circle: 3,
// card: 4,s
arrow: 5,
grid: 6,
} as const
class ConfigError extends Error {}
export async function fetchApi(
entry: string,
method: 'GET' | 'POST',
params: Record<string, unknown>,
) {
const response = await api.fetchApi('/comfylab/' + entry, {
method,
body: JSON.stringify(params),
})
if (!response.ok) {
switch (response.status) {
case 404:
throw new ConfigError('File not found')
case 415:
throw new ConfigError(response.statusText)
case 500:
throw new ConfigError(`Internal error: ${response.statusText}`)
default:
throw new ConfigError(response.statusText)
}
}
return await response.json()
}
type IRetrievedFile = {
filename: string
content: string
mime: string
encoding: string
}
export async function retrievefile(path: string): Promise<IRetrievedFile> {
const body = await fetchApi('load_file', 'POST', { path })
return {
filename: body.filename,
content: body.content,
mime: body.mime,
encoding: body.encoding,
}
}
export function saveToTextFile(
filename: string,
content: string,
accept = 'text/plain',
) {
const blob = new Blob([content], { type: accept + ';charset=utf-8' }),
anchor = document.createElement('a')
anchor.download = filename
anchor.href = (window.webkitURL || window.URL).createObjectURL(blob)
anchor.dataset.downloadurl = [accept, anchor.download, anchor.href].join(':')
anchor.click()
anchor.remove()
}
export async function loadTextFile(
accept = 'text/plain',
): Promise<{ content: string; name: string; type: string } | undefined> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.style.display = 'none'
const reader = new FileReader()
let name: string | undefined = undefined
let contentType: string | undefined = undefined
reader.addEventListener(
'load',
() => {
resolve({
content: reader.result as string,
name: name as string,
type: contentType as string,
})
},
{ once: true },
)
reader.addEventListener(
'abort',
() => {
resolve(undefined)
},
{ once: true },
)
reader.addEventListener(
'error',
() => {
resolve(undefined)
},
{ once: true },
)
input.addEventListener(
'change',
(e: Event) => {
const target = e.target as HTMLInputElement
if (target.files) {
const file = target.files[0]
name = file.name
contentType = file.type
reader.readAsText(file)
} else resolve(undefined)
},
{ once: true },
)
input.addEventListener(
'cancel',
() => {
resolve(undefined)
},
{ once: true },
)
input.click()
input.remove()
})
}
export async function showErrorDialog({
title = '',
subtitle = '',
centerSubtitle = false,
content = [] as HTMLElement | HTMLElement[],
centerContent = false,
}) {
const elements = []
if (title) {
elements.push(
$el(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: '#8c8c8c',
},
},
[$el('h3', title)],
),
)
}
if (subtitle) {
let subtitleEl = $el('h4', subtitle)
if (centerSubtitle) {
subtitleEl = $el(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
},
[subtitleEl],
)
}
elements.push(subtitleEl)
}
if (content) {
if (!Array.isArray(content)) content = [content]
if (centerContent) {
content = [
$el(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
},
content,
),
]
}
elements.push(...content)
}
const dialog = new ComfyAsyncDialog()
const res = await dialog.showModal(elements)
dialog.element.remove()
return res
}
/**
* Workaround for a strange behavior (bug?), encountered in OutputConfigBackend,
* where widgets values are not saved until there is a mouse event (anything: click on node / canvas, ...). \
* So here basically we simulate a mouse event...
*/
export function forceMouseEvent() {
app.canvas.processMouseUp(new MouseEvent('mouseup'))
}
/**
* Allow a widget to define an async callback, as it is not possible by default
* @param widget widget
*/
export function makeWidgetAsync<TValue>(widget: IWidget<TValue>) {
if (widget._event) return
// replace callback with events
widget._event = {
target: new EventTarget(),
callback: widget.callback,
}
delete widget.callback
widget._event.target.addEventListener('change', async (ev: CustomEvent) => {
if (widget._event.callback) {
await widget._event.callback(ev.detail)
}
})
Object.defineProperty(widget, 'callback', {
get() {
return (value: TValue) => {
widget._event.target.dispatchEvent(
new CustomEvent('change', { detail: value }),
)
}
},
set(cb: () => void | Promise<void>) {
widget._event.callback = cb
},
enumerable: true,
})
}
export const ALL_CONFIG_FILE_TYPES =
'.json,.jsonc,.json5,.yaml,.yml,application/JSON,application/json5,application/yaml'
export const humanFileType = (mimeType: string) => {
mimeType = mimeType.toLowerCase()
if (mimeType.includes('json5')) return 'json5'
if (mimeType.includes('json')) return 'json'
if (mimeType.includes('yaml')) return 'yaml'
return ''
}
export const guessFileType = (filename: string) => {
const ext: string = filename.split('.').pop() || ''
if (ext === 'json5') return 'file: (json5)'
if (ext === 'json' || ext === 'jsonc') return 'file: (json)'
else if (['yml', 'yaml'].includes(ext)) return 'file: (yaml)'
return 'file:'
}
+367
View File
@@ -0,0 +1,367 @@
import { LGraphNode, IWidget, Vector2 } from '@comfyorg/litegraph'
import { app } from '~/.mock/scripts/app.js'
import { ComfyWidgets } from '~/.mock/scripts/widgets.js'
import { findWidget, makeWidgetAsync } from '~/shared/utils.js'
interface ITextWidgetOptions {
multiline?: boolean
placeholder?: string
default?: string
label?: string
tooltip?: string
disabled?: boolean
domStyle?: Record<string, unknown>
}
export function makeTextWidget(
node: LGraphNode,
name: string,
options: ITextWidgetOptions = {},
): IWidget<string> {
// extract the options sent to ComfyWidgets
const widgetOptions = ['multiline', 'placeholder'].reduce(
(obj: Record<string, unknown>, optKey) => {
if (optKey in options)
obj[optKey] = options[optKey as keyof ITextWidgetOptions]
return obj
},
{},
)
const widget = ComfyWidgets['STRING'](
node,
name,
['STRING', widgetOptions],
// @ts-expect-error: issue with #private in ComfyApp definition
app,
).widget
if (options.default) widget.value = options.default
if (options.label) widget.label = options.label
if (options.tooltip) {
if (options.multiline) widget.inputEl.title = options.tooltip
else widget.tooltip = options.tooltip
}
if (options.disabled) {
if (!options.multiline) widget.disabled = true
else {
widget.inputEl.readOnly = true
widget.inputEl.style.border = 'none'
// widget.inputEl.style.backgroundColor = 'transparent'
widget.inputEl.style.opacity = 0.6
}
}
if (options.multiline && options.domStyle)
for (const [styleKey, styleValue] of Object.entries(options.domStyle)) {
widget.inputEl.style[styleKey] = styleValue
}
return widget
}
interface IButtonWidgetOptions {
tooltip?: string
disabled?: boolean
async?: boolean
callback?: () => void | Promise<void>
}
export function makeButtonWidget(
node: LGraphNode,
name: string,
label: string,
options: IButtonWidgetOptions = {},
): IWidget {
options = Object.assign(
{
async: true,
},
options,
)
const widget = node.addWidget('button', name, 0, () => {})
widget.label = label
if (options.tooltip) widget.tooltip = options.tooltip
if (options.disabled) widget.disabled = options.disabled
if (options.async) makeWidgetAsync(widget)
if (options.callback) widget.callback = options.callback
return widget
}
interface IToggleWidgetOptions {
default?: boolean
label_on?: string
label_off?: string
label?: string
tooltip?: string
disabled?: boolean
callback?: () => void | Promise<void>
}
export function makeToggleWidget(
node: LGraphNode,
name: string,
options: IToggleWidgetOptions = {},
): IWidget {
// extract the options sent to ComfyWidgets
const widgetOptions = ['default', 'label_on', 'label_off'].reduce(
(obj: Record<string, unknown>, optKey) => {
if (optKey in options)
obj[optKey] = options[optKey as keyof IToggleWidgetOptions]
return obj
},
{},
)
const widget = ComfyWidgets['BOOLEAN'](
node,
name,
['STRING', widgetOptions],
// @ts-expect-error: issue with #private in ComfyApp definition
app,
).widget
if (options.label) widget.label = options.label
if (options.tooltip) widget.tooltip = options.tooltip
if (options.disabled) widget.disabled = options.disabled
if (options.callback) widget.callback = options.callback
return widget
}
type ComboChoices = string[] | { [key in string]: string }
type ComboSelected<T extends ComboChoices> = keyof T
type ComboValue<T extends ComboChoices> = T extends string[]
? T[number]
: T[keyof T]
export type IComboWidget<T extends ComboChoices> = IWidget<string> & {
choices: T
selected: ComboSelected<T>
onSelected?: (selected: ComboSelected<T>) => void | Promise<void>
}
export function makeComboWidget<T extends ComboChoices = string[]>(
node: LGraphNode,
name: string,
choices: T,
defaultSelected: ComboSelected<T>,
onSelected?: (
selected: ComboSelected<T>,
label: ComboValue<T>,
) => void | Promise<void>,
tooltip?: string,
): IComboWidget<T> {
const getSelected = (label: ComboValue<T>): ComboSelected<T> | undefined => {
if (Array.isArray(choices))
return choices.indexOf(label) as ComboSelected<T>
else {
for (const [key, val] of Object.entries(choices)) {
if (val === label) return key as ComboSelected<T>
}
}
}
const labels = Array.isArray(choices) ? choices : Object.values(choices)
const widget = node.addWidget(
'combo',
name,
choices[defaultSelected],
() => {}, //replaced below by an async function
{ values: labels },
)
widget.choices = choices
widget.selected = defaultSelected
widget.onSelected = onSelected
// use an EventTarget to allow async callback, and replace default one
makeWidgetAsync(widget)
widget.callback = async (label: ComboValue<T>) => {
widget.selected = getSelected(label)
if (widget.onSelected) await widget.onSelected(widget.selected, label)
}
if (tooltip) widget.tooltip = tooltip
// enssure we reload the values at node.onConfigure
const original_onNodeConfigure = node.onConfigure
node.onConfigure = function (...args: unknown[]) {
widget.selected = getSelected(widget.value)
original_onNodeConfigure?.apply(this, ...args)
}
return widget
}
function setWidgetVisible(widget: IWidget, type: string) {
if (type === 'hidden') {
widget.hidden = true
widget.type = 'hidden'
widget.computedHeight = null
if (widget.element) {
widget.element.style.display = 'none'
widget.element.dataset.shouldHide = true
}
widget.computeSize = () => [0, -4]
} else {
delete widget.hidden
delete widget.computeSize
widget.type = type
if (widget.element) {
widget.element.style.display = null
widget.element.dataset.shouldHide = false
}
}
}
export interface ISwitchState {
current: boolean
states: {
true: {
size: Vector2 | undefined
members: Record<string, string> // widget name: type
}
false: {
size: Vector2 | undefined
members: Record<string, string> // widget name: type
}
}
}
export type ISwitchWidget = IWidget<ISwitchState> & {
switch: (current: boolean, initial?: boolean) => void
saveSize: () => void
guard: (fct: () => void | Promise<void>) => Promise<void>
}
/**
* Create an hidden widget, to switch visibility of one or two groups of widgets. \
* Use the `switch()` method to switch from one group to the other. \
* Note: This widget is meant to be serialized, to ensure proper reload on page refresh.
* @param node node
* @param name widget name
* @param widgetGroup1 array of widgets to be visible, when current is `true`
* @param widgetGroup2 optional: array of widgets to be visible, when current is `false`
* @returns the widget
*/
export function makeSwitchWidget(
node: LGraphNode,
name: string,
widgetGroup1: IWidget[],
widgetGroup2: IWidget[] = [],
): ISwitchWidget {
// save the widget type, as we will override it later
const serializeWidgets = (widgetGroup: IWidget[]) => {
return widgetGroup
.filter((w: IWidget) => w.name !== name)
.reduce((acc, w: IWidget) => {
acc[w.name] = w.type
return acc
}, {})
}
// initialValue: store widget types, useful when making them visible
const initialValue: ISwitchState = {
current: true,
states: {
true: {
size: undefined,
members: serializeWidgets(widgetGroup1),
},
false: {
size: undefined,
members: serializeWidgets(widgetGroup2),
},
},
}
// create the hidden widget
const widget: ISwitchWidget = node.addWidget(
'hidden',
name,
initialValue,
() => {},
)
widget.computeSize = () => [0, -4]
/**
* Toggle visiblity from one group to the other.
* @param value if `true`, group1 is visible, group2 otherwise
* @param initial do not store current size, useful in `node.onConfigure()`
*/
widget.switch = (value: boolean) => {
// save current value
widget.value.current = value
const states = widget.value.states
// toggle groups visibility
// widgets to make visible: restore type
for (const [widgetName, widgetType] of Object.entries(
states[value].members,
)) {
const w = findWidget(node, widgetName)
if (w) {
setWidgetVisible(w, widgetType as string)
}
}
// widget to make invisible
for (const widgetName of Object.keys(states[!value].members)) {
const w = findWidget(node, widgetName)
if (w) {
setWidgetVisible(w, 'hidden')
}
}
// reset position for all widgets, to force recalculation
for (const w of node.widgets) {
delete w.y
delete w.last_y
}
// adjust node height; if not known, let computeSize() do the job
const height = states[value].size?.[1] || node.computeSize()[1]
node.setSize([node.size[0], height] as Vector2) // this triggers onResize()
// we do not save size here, saveSize is called by onResize (see below)
}
widget.saveSize = () => {
widget.value.states[widget.value.current].size = [
node.size[0],
node.size[1],
]
}
// register to node,onResize
const original_onResize = node.onResize
node.onResize = function (size: Vector2) {
original_onResize?.apply(this, size)
// reset position for all widgets, to force recalculation
for (const w of this.widgets) {
delete w.y
delete w.last_y
delete w.computedHeight
}
widget.saveSize()
}
// restore state on page refresh
const original_onConfigure = node.onConfigure
node.onConfigure = function (...args: unknown[]) {
original_onConfigure?.apply(this, ...args)
widget.switch(widget.value.current)
}
/**
* Temporarily switch off then on, to let a function be executed.
* This is useful when creating new outputs for example.
* @param fct function to be executed
*/
widget.guard = async (fct: () => void | Promise<void>) => {
// make content invisible if applicable
const contentVisible = widget.value.current
const oldSize = [node.size[0], node.size[1]]
const oldComputedSize = node.computeSize()
if (contentVisible) widget.switch(false)
await fct()
// restore content visibility if applicable
if (contentVisible) widget.switch(true)
// keep previous width, and increase/decrease height depending on new outputs
const newComputedSize = node.computeSize()
// use setSize(), to ensure saveSize() is called
node.setSize([
oldSize[0],
oldSize[1] + newComputedSize[1] - oldComputedSize[1],
])
}
widget.switch(widget.value.current) // only useful on node creation
return widget as ISwitchWidget
}
+134
View File
@@ -0,0 +1,134 @@
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 {
makeButtonWidget,
type IComboWidget,
makeComboWidget,
} from '~/widgets/factories.js'
import { makeWidgetAsync } from '~/shared/utils.js'
/**
* Create an hidden widget. To be used in `getCustomWidgets(app)`
* @param {*} node
* @param {*} inputName
* @param {*} inputData
* @param {*} _app
* @returns
*/
export function HIDDEN(
node: LGraphNode,
inputName: string,
inputData: InputSpec,
// 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
// TODO: should be default instead? If so, correct XYPlotQueue
const initialValue = inputData[1].value
// there is no callback, as it doesn't get called for non-standard widgets (see LGraphCanvas.prototype.processNodeWidgets)
// if needed, we could replace value w/ getter / setter
const widget: IWidget = {
type: 'HIDDEN',
name: inputName,
value: initialValue,
size: [0, -4],
computeSize: () => {
return [0, -4]
},
}
node.addCustomWidget(widget)
return widget
}
export function BTN(node: LGraphNode, inputName: string, inputData: InputSpec) {
const label = inputData[1].label || inputName
return makeButtonWidget(node, inputName, label, inputData[1])
}
export function NODE_COMBO(node: LGraphNode, name: string) {
type NodeListWidget = IWidget<string> & {
selected?: number
selectedNode?: LGraphNode
onSelected?: (
selectedId: number | undefined,
selectedNode: LGraphNode | undefined,
) => void | Promise<void>
}
const getNodeList = (_widget: IWidget, thisNode: LGraphNode | undefined) => {
const nodes = ['']
if (thisNode)
nodes.push(
...thisNode.graph._nodes
.filter((otherNode: LGraphNode) => otherNode.id !== thisNode.id)
.map((otherNode: LGraphNode) => {
return `#${otherNode.id}: ${otherNode.title}`
}),
)
return nodes
}
const getSelected = (label: string) => {
if (!label) {
return [undefined, undefined]
} else {
const selected = parseInt(label.split(':')[0].substring(1))
return [selected, node.graph._nodes_by_id[selected]]
}
}
const widget = node.addWidget(
'combo',
name,
'',
async function (this: NodeListWidget, label: string) {
;[this.selected, this.selectedNode] = getSelected(label)
if (this.onSelected)
await this.onSelected(this.selected, this.selectedNode)
},
{ values: getNodeList },
)
// use an EventTarget to allow async callback, and replace default one
makeWidgetAsync(widget)
widget.callback = async (label: string) => {
;[widget.selected, widget.selectedNode] = getSelected(label)
if (widget.onSelected)
await widget.onSelected(widget.selected, widget.selectedNode)
}
widget.tooltip = 'select node from list'
// enssure we reload the values at node.onConfigure
const original_onNodeConfigure = node.onConfigure
node.onConfigure = function (...args: unknown[]) {
;[widget.selected, widget.selectedNode] = getSelected(widget.value)
original_onNodeConfigure?.apply(this, ...args)
}
return widget
}
export const ERROR_DISPLAY_CHOICES = {
browser: 'in browser console (F12)',
dialog: 'in a popup',
none: 'do not display',
} as const
export type ErrorDisplayChoices = keyof typeof ERROR_DISPLAY_CHOICES
export function ERROR_DISPLAY(
node: LGraphNode,
name: string = 'error_display',
) {
type ErrorDisplayWidget = IComboWidget<typeof ERROR_DISPLAY_CHOICES>
const widget = makeComboWidget(
node,
name,
ERROR_DISPLAY_CHOICES,
'dialog',
() => {},
'how to display detected errors',
)
return widget as ErrorDisplayWidget
}
+96
View File
@@ -0,0 +1,96 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { ComfyApp } from '@comfyorg/comfyui-frontend-types'
import { api } from '~/.mock/scripts/api.js'
import type { InputSpec } from '~/.d.ts/comfyui-frontend-types_alt.js'
interface IQueueData {
index: number
total: number
}
interface IQueueMessage {
index: [number]
total: [number]
}
function isQueueMessage(message: unknown): message is IQueueMessage {
const msg = message as IQueueMessage
return (
Array.isArray(msg.index) &&
msg.index.length === 1 &&
typeof msg.index[0] === 'number' &&
Array.isArray(msg.total) &&
msg.total.length === 1 &&
typeof msg.total[0] === 'number'
)
}
function queueMessageToData(message: IQueueMessage): IQueueData {
return {
index: message.index[0],
total: message.total[0],
}
}
export function QUEUE_STATUS(
node: LGraphNode,
inputName: string,
_inputData: InputSpec,
app: ComfyApp | undefined, // forced to accept undefined as per ComfyWidgetConstructor
) {
if (!app) throw new Error('QUEUE_STATUS: app is undefined')
const widget = node.addWidget('button', inputName, 0, () => {})
const reset = () => {
widget.value = 0
widget.total = undefined
}
// set initial value
reset()
widget.label = 'Ready'
// handle page refresh: onConfigure is called after onNodeCreated, after applying serialized values, so we force reset here
const originalOnConfigure = node.onConfigure
node.onConfigure = function () {
originalOnConfigure?.apply(this)
reset()
}
const originalOnExecuted = node.onExecuted
node.onExecuted = function (message: unknown) {
originalOnExecuted?.apply(this, message)
if (isQueueMessage(message)) {
const data = queueMessageToData(message as IQueueMessage)
if (!widget.total) widget.total = data.total
if (data.index < data.total - 1) {
widget.value = data.index + 1
widget.label = `Processing: ${widget.value} / ${widget.total}`
app.queuePrompt(0, 1)
} else {
widget.label = `Processing: ${widget.value + 1} / ${widget.total}`
// wait for the execution to end before displaying the "Complete" label; note: the listener will be automatically deleted after use
api.addEventListener(
'execution_success',
() => {
widget.label = `Complete: ${widget.total} / ${widget.total}`
reset()
},
{ once: true },
)
}
}
}
// handle API interruptions and errors
const original_api_interrupt = api.interrupt
api.interrupt = async function (...args) {
await original_api_interrupt.apply(this, ...args)
widget.label = 'Interrupted'
reset()
}
api.addEventListener('execution_error', () => {
widget.label = 'Error detected'
reset()
})
return widget
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "nodenext",
"removeComments": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "./dist/js",
"paths": {
"~/*": ["./src/ts/*"]
}
},
"include": [
"src/ts/**/*.ts",
"src/ts/.d.ts/**/*.d.ts",
"src/ts/.mock/**/*.ts"
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig",
"exclude": ["**/test/", "**/*.test.ts", "**/*.mock.ts"]
}
+2232
View File
File diff suppressed because it is too large Load Diff