+203
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
coverage
|
||||
public
|
||||
dist
|
||||
.yarn
|
||||
yarn.lock
|
||||
|
||||
web
|
||||
src/py
|
||||
# src/schema
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"git.followTagsWhenSync": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
Vendored
+27
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -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']
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+287
@@ -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);
|
||||
}
|
||||
Vendored
+212
@@ -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);
|
||||
};
|
||||
}
|
||||
Vendored
+86
@@ -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);
|
||||
};
|
||||
};
|
||||
}
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
Vendored
+68
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Vendored
+55
@@ -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;
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import { fetchApi } from './utils.js';
|
||||
export async function parseConfig(raw) {
|
||||
return await fetchApi('config_parse', 'POST', { raw });
|
||||
}
|
||||
Vendored
+144
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
Vendored
+77
@@ -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:
|
||||
}
|
||||
}
|
||||
Vendored
+28
@@ -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;
|
||||
}
|
||||
Vendored
+240
@@ -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:';
|
||||
};
|
||||
Vendored
+205
@@ -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;
|
||||
}
|
||||
Vendored
+71
@@ -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;
|
||||
}
|
||||
Vendored
+64
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
jsonschema
|
||||
json5
|
||||
python-dotenv
|
||||
pyyaml
|
||||
pyaml
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
@@ -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 ()
|
||||
@@ -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),)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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']},
|
||||
}
|
||||
@@ -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,)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+97734
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
import type { ComfyApi } from '@comfyorg/comfyui-frontend-types'
|
||||
|
||||
export declare const api: ComfyApi
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComfyApp } from '@comfyorg/comfyui-frontend-types'
|
||||
|
||||
export declare const app: ComfyApp
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": ["**/test/", "**/*.test.ts", "**/*.mock.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user