diff --git a/.gitattributes b/.gitattributes
index 24540ca10487d666cc448014cb19b669d4212a9c..e2c738d902c7596d56808b332396401c3a8eae2d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -38,3 +38,7 @@ custom_nodes/ComfyUI-KJNodes/fonts/FreeMono.ttf filter=lfs diff=lfs merge=lfs -t
custom_nodes/ComfyUI-KJNodes/fonts/FreeMonoBoldOblique.otf filter=lfs diff=lfs merge=lfs -text
custom_nodes/ComfyUI-KJNodes/fonts/TTNorms-Black.otf filter=lfs diff=lfs merge=lfs -text
custom_nodes/ComfyUI-LTXVideo/gemma_configs/tokenizer.json filter=lfs diff=lfs merge=lfs -text
+custom_nodes/rgthree-comfy/docs/rgthree_advanced.png filter=lfs diff=lfs merge=lfs -text
+custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png filter=lfs diff=lfs merge=lfs -text
+custom_nodes/rgthree-comfy/docs/rgthree_context.png filter=lfs diff=lfs merge=lfs -text
+custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png filter=lfs diff=lfs merge=lfs -text
diff --git a/custom_nodes/rgthree-comfy/.github/workflows/publish_comfy_registry_action.yml b/custom_nodes/rgthree-comfy/.github/workflows/publish_comfy_registry_action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d52a3bba8f75308fa0d74dfc211f443c8ebb8bb2
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/.github/workflows/publish_comfy_registry_action.yml
@@ -0,0 +1,20 @@
+name: Publish to Comfy registry
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - "pyproject.toml"
+
+jobs:
+ publish-node:
+ name: Publish Custom Node to registry
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+ - name: Publish Custom Node
+ uses: Comfy-Org/publish-node-action@main
+ with:
+ personal_access_token: ${{ secrets.COMFY_REGISTRY_ACCESS_TOKEN }}
diff --git a/custom_nodes/rgthree-comfy/.gitignore b/custom_nodes/rgthree-comfy/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..1526efa2a13afec03677a73c3fe07ebdfb37e197
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/.gitignore
@@ -0,0 +1,13 @@
+__pycache__
+*.ini
+wildcards/**
+.vscode/
+.idea/
+node_modules/
+rgthree_config.json
+web/rgthree_config.js
+web/comfyui/rgthree_config.js
+userdata/
+userdata/**
+web/comfyui/testing/
+web/comfyui/tests/
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/.prettierrc.json b/custom_nodes/rgthree-comfy/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..582f76b20736717b28dced7aa429721f5f6d0261
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "printWidth": 100,
+ "bracketSpacing": false,
+ "bracketSameLine": true
+}
diff --git a/custom_nodes/rgthree-comfy/.pylintrc b/custom_nodes/rgthree-comfy/.pylintrc
new file mode 100644
index 0000000000000000000000000000000000000000..8a082e8efeab523b735b15b0d4b3491a1f4a0c99
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/.pylintrc
@@ -0,0 +1,633 @@
+[MAIN]
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Clear in-memory caches upon conclusion of linting. Useful if running pylint
+# in a server-like mode.
+clear-cache-post-run=no
+
+# Load and enable all available extensions. Use --list-extensions to see a list
+# all available extensions.
+#enable-all-extensions=
+
+# In error mode, messages with a category besides ERROR or FATAL are
+# suppressed, and no reports are done by default. Error mode is compatible with
+# disabling specific errors.
+#errors-only=
+
+# Always return a 0 (non-error) status code, even if lint errors are found.
+# This is primarily useful in continuous integration scripts.
+#exit-zero=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
+# for backward compatibility.)
+extension-pkg-whitelist=
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold under which the program will exit with error.
+fail-under=10
+
+# Interpret the stdin as a python script, whose filename needs to be passed as
+# the module_or_package argument.
+#from-stdin=
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regular expressions patterns to the
+# ignore-list. The regex matches against paths and can be in Posix or Windows
+# format. Because '\\' represents the directory delimiter on Windows systems,
+# it can't be used as an escape character.
+ignore-paths=
+
+# Files or directories matching the regular expression patterns are skipped.
+# The regex matches against base names, not paths. The default value ignores
+# Emacs file locks
+ignore-patterns=^\.#
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use, and will cap the count on Windows to
+# avoid hangs.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.10
+
+# Discover python modules and packages in the file system subtree.
+recursive=no
+
+# Add paths to the list of the source roots. Supports globbing patterns. The
+# source root is an absolute path or a path relative to the current working
+# directory used to determine a package namespace for modules located under the
+# source root.
+source-roots=
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+# In verbose mode, extra non-checker-related info will be displayed.
+#verbose=
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style. If left empty, attribute names will be checked with the set naming
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+ bar,
+ baz,
+ toto,
+ tutu,
+ tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style. If left empty, class attribute names will be checked
+# with the set naming style.
+#class-attribute-rgx=
+
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
+
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style. If left empty, class constant names will be checked with
+# the set naming style.
+#class-const-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style. If left empty, class names will be checked with the set naming style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style. If left empty, constant names will be checked with the set naming
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style. If left empty, function names will be checked with the set
+# naming style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+ j,
+ k,
+ ex,
+ Run,
+ _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style. If left empty, inline iteration names will be checked
+# with the set naming style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style. If left empty, method names will be checked with the set naming style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style. If left empty, module names will be checked with the set naming style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Regular expression matching correct type alias names. If left empty, type
+# alias names will be checked with the set naming style.
+#typealias-rgx=
+
+# Regular expression matching correct type variable names. If left empty, type
+# variable names will be checked with the set naming style.
+#typevar-rgx=
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style. If left empty, variable names will be checked with the set
+# naming style.
+#variable-rgx=
+
+
+[CLASSES]
+
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+ __new__,
+ setUp,
+ asyncSetUp,
+ __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=mcs
+
+
+[DESIGN]
+
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
+
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when caught.
+overgeneral-exceptions=builtins.BaseException,builtins.Exception
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )??$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string=' '
+
+# Maximum number of characters on a single line.
+max-line-length=100
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow explicit reexports by alias from a package __init__.
+allow-reexport-from-package=no
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
+ext-import-graph=
+
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
+# UNDEFINED.
+confidence=HIGH,
+ CONTROL_FLOW,
+ INFERENCE,
+ INFERENCE_FAILURE,
+ UNDEFINED
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+ bad-inline-option,
+ locally-disabled,
+ file-ignored,
+ suppressed-message,
+ useless-suppression,
+ deprecated-pragma,
+ use-symbolic-message-instead,
+ C0103, # invalid-name :: constant case
+ W0603 # global-statements
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[METHOD_ARGS]
+
+# List of qualified names (i.e., library.method) which require a timeout
+# parameter e.g. 'requests.api.get,requests.api.post'
+timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+ XXX,
+ TODO
+
+# Regular expression of note tags to take in consideration.
+notes-rgx=
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'fatal', 'error', 'warning', 'refactor',
+# 'convention', and 'info' which contain the number of messages in each
+# category, as well as 'statement' which is the total number of statements
+# analyzed. This score is used by the global evaluation report (RP0004).
+evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+#output-format=
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=yes
+
+# Signatures are removed from the similarity computation
+ignore-signatures=yes
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. No available dictionaries : You need to install
+# both the python package and the system dependency for enchant to work..
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear at the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of symbolic message names to ignore for Mixin members.
+ignored-checks-for-mixins=no-member,
+ not-async-context-manager,
+ not-context-manager,
+ attribute-defined-outside-init
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins.
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+ _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
diff --git a/custom_nodes/rgthree-comfy/.style.yapf b/custom_nodes/rgthree-comfy/.style.yapf
new file mode 100644
index 0000000000000000000000000000000000000000..9657de3ba37be527405ffcf40e4ed5546ddf79ea
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/.style.yapf
@@ -0,0 +1,11 @@
+# https://github.com/google/yapf
+[style]
+based_on_style = google
+INDENT_WIDTH = 2
+CONTINUATION_INDENT_WIDTH = 2
+COLUMN_LIMIT = 100
+DISABLE_ENDING_COMMA_HEURISTIC = true
+DEDENT_CLOSING_BRACKETS = true
+FORCE_MULTILINE_DICT = false
+coalesce_brackets=True
+ALLOW_SPLIT_BEFORE_DICT_VALUE = false
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/LICENSE b/custom_nodes/rgthree-comfy/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..5a4eb87c0791b74af3db40c2a4d362469a8b6b43
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Regis Gaughan, III (rgthree)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/custom_nodes/rgthree-comfy/README.md b/custom_nodes/rgthree-comfy/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..89034746925113a4d217c66949b519e1586f274f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/README.md
@@ -0,0 +1,434 @@
+
+ rgthree-comfy
+
+ Making ComfyUI more comfortable!
+
+
+
+ The Nodes | Improvements & Features | Link Fixer
+
+
+
+A collection of nodes and improvements created while messing around with ComfyUI. I made them for myself to make my workflow cleaner, easier, and faster. You're welcome to try them out. But remember, I made them for my own use cases :)
+
+
+
+# Get Started
+
+## Install
+
+1. Install the great [ComfyUi](https://github.com/comfyanonymous/ComfyUI).
+2. Clone this repo into `custom_modules`:
+ ```
+ cd ComfyUI/custom_nodes
+ git clone https://github.com/rgthree/rgthree-comfy.git
+ ```
+3. Start up ComfyUI.
+
+## Settings
+
+You can configure certain aspect of rgthree-comfy. For instance, perhaps a future ComfyUI change breaks rgthree-comfy, or you already have another extension that does something similar and you want to turn it off for rgthree-comfy.
+
+You can get to rgthree-settings by right-clicking on the empty part of the graph, and selecting `rgthree-comfy > Settings (rgthree-comfy)` or by clicking the `rgthree-comfy settings` in the ComfyUI settings dialog.
+
+_(Note, settings are stored in an `rgthree_config.json` in the `rgthree-comfy` directory. There are other advanced settings that can only be configured there; You can copy default settings from `rgthree_config.json.default` before `rgthree_config.json` before modifying)_.
+
+
+
+# ✴️ The Nodes
+
+Note, you can right-click on a bunch of the rgthree-comfy nodes and select `🛟 Node Help` menu item for in-app help when available.
+
+## Seed
+> An intuitive seed control node for ComfyUI that works very much like Automatic1111's seed control.
+>
+> ℹ️ See More Information
+>
+> - Set the seed value to "-1" to use a random seed every time
+> - Set any other number in there to use as a static/fixed seed
+> - Quick actions to randomize, or (re-)use the last queued seed.
+> - Images metadata will store the seed value _(so dragging an image in, will have the seed field already fixed to its seed)_.
+> - _Secret Features_: You can manually set the seed value to "-2" or "-3" to increment or decrement the last seed value. If there was not last seed value, it will randomly use on first.
+>
+> 
+>
+
+
+## Reroute
+> Keep your workflow neat with this much improved Reroute node with, like, actual rerouting with multiple directions and sizes.
+>
+> ℹ️ More Information
+>
+> - Use the right-click context menu to change the width, height and connection layout
+> - Also toggle resizability (min size is 40x43 if resizing though), and title/type display.
+>
+> 
+>
+
+## Bookmark (🔖)
+> Place the bookmark node anywhere on screen to quickly navigate to that with a shortcut key.
+>
+> ℹ️ See More Information
+>
+> - Define the `shortcut_key` to press to go right to that bookmark node, anchored in the top left.
+> - You can also define the zoom level as well!
+> - Pro tip: `shortcut_key` can be multiple keys. For instance "alt + shift + !" would require
+> pressing the alt key, the shift key, and the "!" (as in the "1" key, but with shift pressed)
+> in order to trigger.
+>
+
+
+## Context / Context Big
+> Pass along in general flow properties, and merge in new data. Similar to some other node suites "pipes" but easier merging, is more easily interoperable with standard nodes by both combining and exploding all in a single node.
+>
+> ℹ️ More Information
+>
+> - Context and Context Big are backwards compatible with each other. That is, an input connected to a Context Big will be passed through the CONTEXT outputs through normal Context nodes and available as an output on either (or, Context Big if the output is only on that node, like "steps").
+> - Pro Tip: When dragging a Context output over a nother node, hold down "ctrl" and release to automatically connect the other Context outputs to the hovered node.
+> - Pro Tip: You can change between Context and Context Big nodes from the menu.
+>
+> 
+>
+
+## Image Comparer
+> The Image Comparer node compares two images on top of each other.
+>
+> ℹ️ More Information
+>
+> - **Note:** The right-click menu may show image options (Open Image, Save Image, etc.) which will correspond to the first image (image_a) if clicked on the left-half of the node, or the second image if on the right half of the node.
+> - **Inputs:**
+> - `image_a` _Required._ The first image to use to compare. If image_b is not supplied and image_a is a batch, the comparer will use the first two images of image_a.
+> - `image_b` _Optional._ The second image to use to compare. Optional only if image_a is a batch with two images.
+> - **Properties:** You can change the following properties (by right-clicking on the node, and select "Properties" or "Properties Panel" from the menu):
+> - `comparer_mode` - Choose between "Slide" and "Click". Defaults to "Slide".
+
+
+## Image Inset Crop
+> The node that lets you crop an input image by either pixel value, or percentage value.
+
+
+## Display Any
+> Displays most any piece of text data from the backend _after execution_.
+
+## Power Lora Loader
+> A super-simply Lora Loader node that can load multiple Loras at once, and quick toggle each, all in an ultra-condensed node.
+>
+> ℹ️ More Information
+>
+> - Add as many Lora's as you would like by clicking the "+ Add Lora" button. There's no real limit!
+> - Right-click on a Lora widget for special options to move the lora up or down
+> _(no affect on image, just presentation)_, toggle it on/off, or delete the row all together.
+> - from the properties, change the `Show Strengths` to choose between showing a single, simple
+> strength value (which will be used for both model and clip), or a more advanced view with
+> both model and clip strengths being modifiable.
+>
+
+
+## ~~Lora Loader Stack~~
+> _**Deprecated.** Used the `Power Lora Loader` instead._
+>
+> A simplified Lora Loader stack. Much like other suites, but more interoperable with standard inputs/outputs.
+
+
+## Power Prompt
+> Power up your prompt and get drop downs for adding your embeddings, loras, and even have saved prompt snippets.
+>
+> ℹ️ More Information
+>
+> - At the core, you can use Power Prompt almost as a String Primitive node with additional features of dropdowns for choosing your embeddings, and even loras, with no further processing. This will output just the raw `TEXT` to another node for any lora processing, CLIP Encoding, etc.
+> - Connect a `CLIP` to the input to encode the text, with both the `CLIP` and `CONDITIONING` output right from the node.
+> - Connect a `MODEL` to the input to parse and load any `` tags in the text automatically, without
+> needing a separate Lora Loaders
+>
+
+## Power Prompt - Simple
+> Same as Power Prompt above, but without LORA support; made for a slightly cleaner negative prompt _(since negative prompts do not support loras)_.
+
+## SDXL Power Prompt - Positive
+> The SDXL sibling to the Power Prompt above. It contains the text_g and text_l as separate text inputs, as well a couple more input slots necessary to ensure proper clipe encoding. Combine with
+
+## SDXL Power Prompt - Simple
+> Like the non-SDXL `Power Prompt - Simple` node, this one is essentially the same as the SDXL Power Prompt but without lora support for either non-lora positive prompts or SDXL negative prompts _(since negative prompts do not support loras)_.
+
+## SDXL Config
+> Just some configuration fields for SDXL prompting. Honestly, could be used for non SDXL too.
+
+## Context Switch / Context Switch Big
+> A powerful node to branch your workflow. Works by choosing the first Context input that is not null/empty.
+>
+> ℹ️ More Information
+>
+> - Pass in several context nodes and the Context Switch will automatically choose the first non-null context to continue onward with.
+> - Wondering how to toggle contexts to null? Use in conjuction with the **Fast Muter** or **Fast Groups Muter**
+>
+>
+
+## Any Switch
+> A powerful node to similar to the Context Switch above, that chooses the first input that is not null/empty.
+>
+> ℹ️ More Information
+>
+> - Pass in several inmputs of the same type and the Any Switch will automatically choose the first non-null value to continue onward with.
+> - Wondering how to toggle contexts to null? Use in conjuction with the **Fast Muter** or **Fast Groups Muter**
+>
+>
+
+
+## Power Primitive
+> A single node that can output primitives (STRING, INT, FLOAT, BOOLEAN). If connecting an input, it will cast/convert the primitive input to the desired output.
+>
+> ℹ️ More Information
+>
+> - You can hide the type selector input from the right-click menu or properties.
+> - You can also fast-switch the output type from the right-click menu as well.
+>
+>
+
+
+## Power Puter
+> A powerful and versatile node that opens the door for a wide range of utility by offering mult-line code parsing for output. This node can be used for simple string concatenation, or math operations; to an image dimension or a node's widgets with advanced list comprehension. If you want to output something in your workflow, this is the node to do it.
+>
+> Additional documentation available in the [wiki](https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter)
+>
+> ℹ️ More Information
+>
+> - Evaluate almost any kind of input and more, and choose your output from INT, FLOAT, STRING, or BOOLEAN.
+> - Connect some nodes and do simply math operations like `a + b` or `ceil(1 / 2)`.
+> - Or do more advanced things, like input an image, and get the width like `a.shape[2]`.
+> - Even more powerful, you can target nodes in the prompt that's sent to the backend. For instance; if you have a Power Lora Loader node at id #5, and want to get a comma-delimited list of the enabled loras, you could enter:
+>
+> ```
+> loras = [v.lora for v in node(5).inputs.values() if 'lora' in v and v.on]
+> ', '.join(loras)
+> ```
+>
+>
+
+## Fast Groups Muter
+> The Fast Groups Muter is an input-less node that automatically collects all groups in your current workflow and allows you to quickly mute and unmute all nodes within the group.
+>
+> ℹ️ More Information
+>
+> - Groups will automatically be shown, though you can filter, sort and more from the **node Properties** _(by right-clicking on the node, and select "Properties" or "Properties Panel" from the menu)_. Properties include:
+> - `matchColors` - Only add groups that match the provided colors. Can be ComfyUI colors (red, pale_blue) or hex codes (#a4d399). Multiple can be added, comma delimited.
+> - `matchTitle` - Filter the list of toggles by title match (string match, or regular expression).
+> - `showNav` - Add / remove a quick navigation arrow to take you to the group. (default: true)
+> - `showAllGraphs` - Show groups from all [sub]graphs in the workflow. (default: true)
+> - `sort` - Sort the toggles' order by "alphanumeric", graph "position", or "custom alphabet". (default: "position")
+> - `customSortAlphabet` - When the sort property is "custom alphabet" you can define the alphabet to use here, which will match the beginning of each group name and sort against it. If group titles do not match any custom alphabet entry, then they will be put after groups that do, ordered alphanumerically.
+>
+> This can be a list of single characters, like "zyxw..." or comma delimited strings for more control, like "sdxl,pro,sd,n,p".
+>
+> Note, when two group title match the same custom alphabet entry, the normal alphanumeric alphabet breaks the tie. For instance, a custom alphabet of "e,s,d" will order groups names like "SDXL, SEGS, Detailer" eventhough the custom alphabet has an "e" before "d" (where one may expect "SE" to be before "SD").
+>
+> To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the custom alphabet value of "se,s,f" would work here.
+> - `toggleRestriction` - Optionally, attempt to restrict the number of widgets that can be enabled to a maximum of one, or always one.
+>
+> _Note: If using "max one" or "always one" then this is only enforced when clicking a toggle on this node; if nodes within groups are changed outside of the initial toggle click, then these restriction will not be enforced, and could result in a state where more than one toggle is enabled. This could also happen if nodes are overlapped with multiple groups._
+>
+
+## Fast Groups Bypasser
+> _Same as **Fast Groups Muter** above, but sets the connected nodes to "Bypass" instead of "Mute"_
+
+
+## Fast Muter
+> A powerful 'control panel' node to quickly toggle connected nodes allowing them to quickly be muted or enabled
+>
+> ℹ️ More Information
+>
+> - Add a collection of all connected nodes allowing a single-spot as a "dashboard" to quickly enable and disable nodes. Two distinct nodes; one for "Muting" connected nodes, and one for "Bypassing" connected nodes.
+>
+
+
+## Fast Bypasser
+> Same as Fast Muter but sets the connected nodes to "Bypass"
+
+## Fast Actions Button
+> Oh boy, this node allows you to semi-automate connected nodes and/or ConfyUI.
+>
+> ℹ️ More Information
+>
+> - Connect nodes and, at the least, mute, bypass or enable them when the button is pressed.
+> - Certain nodes expose additional actions. For instance, the `Seed` node you can set `Randomize Each Time` or `Use Last Queued Seed` when the button is pressed.
+> - Also, from the node properties, set a shortcut key to toggle the button actions, without needing a click!
+>
+
+
+## Node Collector
+> Used to cleanup noodles, this will accept any number of input nodes and passes it along to another node.
+>
+> ⚠️ *Currently, this should really only be connected to **Fast Muter**, **Fast Bypasser**, or **Mute / Bypass Relay**.*
+
+
+## Mute / Bypass Repeater
+> A powerful node that will dispatch its Mute/Bypass/Active mode to all connected input nodes or, if in a group w/o any connected inputs, will dispatch its Mute/Bypass/Active mode to all nodes in that group.
+>
+> ℹ️ More Information
+>
+> - 💡 Pro Tip #1: Connect this node's output to a **Fast Muter** or **Fast Bypasser** to have a single toggle there that can mute/bypass/enable many nodes with one click.
+>
+> - 💡 Pro Tip #2: Connect a **Mute / Bypass Relay** node to this node's inputs to have the relay automatically dispatch a mute/bypass/enable change to the repeater.
+>
+
+
+## Mute / Bypass Relay
+> An advanced node that, when working with a **Mute / Bypass Repeater**, will relay its input nodes'
+> modes (Mute, Bypass, or Active) to a connected repeater (which would then repeat that mode change
+> to all of its inputs).
+>
+> ℹ️ More Information
+>
+> - When all connected input nodes are muted, the relay will set a connected repeater to mute (by
+> default).
+> - When all connected input nodes are bypassed, the relay will set a connected repeater to
+> bypass (by default).
+> - When _any_ connected input nodes are active, the relay will set a connected repeater to
+> active (by default).
+> - **Note:** If no inputs are connected, the relay will set a connected repeater to its mode
+> _when its own mode is changed_. **Note**, if any inputs are connected, then the above bullets
+> will occur and the Relay's mode does not matter.
+> - **Pro Tip:** You can change which signals get sent on the above in the `Properties`.
+> For instance, you could configure an inverse relay which will send a MUTE when any of its
+> inputs are active (instead of sending an ACTIVE signal), and send an ACTIVE signal when all
+> of its inputs are muted (instead of sending a MUTE signal), etc.
+>
+
+
+## Random Unmuter
+> An advanced node used to unmute one of its inputs randomly when the graph is queued (and, immediately mute it back).
+>
+> ℹ️ More Information
+>
+> - **Note:** All input nodes MUST be muted to start; if not this node will not randomly unmute another. (This is powerful, as the generated image can be dragged in and the chosen input will already by unmuted and work w/o any further action.)
+> - **Tip:** Connect a Repeater's output to this nodes input and place that Repeater on a group without any other inputs, and it will mute/unmute the entire group.
+>
+
+
+## Label
+> A purely visual node, this allows you to add a floating label to your workflow.
+>
+> ℹ️ More Information
+>
+> - The text shown is the "Title" of the node and you can adjust the the font size, font family,
+> font color, text alignment as well as a background color, padding, background border
+> radius, and angle (in degrees) from the node's properties. You can double-click the node to
+> open the properties panel.
+> - The Title also supports the literal sequence "\\n" to insert a newline when drawing the label.
+> - ~**Pro Tip #1:** You can add multiline text from the properties panel _(because ComfyUI let's
+> you shift + enter there, only)._~
+> - **Pro Tip #2:** You can use ComfyUI's native "pin" option in the right-click menu to make the
+> label stick to the workflow and clicks to "go through". You can right-click at any time to
+> unpin.
+> - **Pro Tip #3:** Color values are hexidecimal strings, like "#FFFFFF" for white, or "#660000"
+> for dark red. You can supply a 7th & 8th value (or 5th if using shorthand) to create a
+> transluscent color. For instance, "#FFFFFF88" is semi-transparent white.
+>
+
+
+# Advanced Techniques
+
+## First, a word on muting
+
+A lot of the power of these nodes comes from *Muting*. Muting is the basis of correctly implementing multiple paths for a workflow utlizing the Context Switch node.
+
+While other extensions may provide switches, they often get it wrong causing your workflow to do more work than is needed. While other switches may have a selector to choose which input to pass along, they don't stop the execution of the other inputs, which will result in wasted work. Instead, Context Switch works by choosing the first non-empty context to pass along and correctly Muting is one way to make a previous node empty, and causes no extra work to be done when set up correctly.
+
+### To understand muting, is to understand the graph flow
+
+Muting, and therefore using Switches, can often confuse people at first because it _feels_ like muting a node, or using a switch, should be able to stop or direct the _forward_ flow of the graph. However, this is not the case and, in fact, the graph actually starts working backwards.
+
+If you have a workflow that has a path like `... > Context > KSampler > VAE Decode > Save Image` it may initially _feel_ like you should be able to mute that first Context node and the graph would stop there when moving forward and skip the rest of that workflow.
+
+But you'll quickly find that will cause an error, becase the graph doesn't actually move forward. When a workflow is processed, it _first moves backwards_ starting at each "Output Node" (Preview Image, Save Image, even "Display String" etc.) and then walking backwards to all possible paths to get there.
+
+So, with that `... > Context > KSampler > VAE Decode > Save Image` example from above, we actually want to mute the `Save Image` node to stop this path. Once we do, since the output node is gone, none of these nodes will be run.
+
+Let's take a look at an example.
+
+### A powerful combination: Using Context, Context Switch, & Fast Muter
+
+
+
+1. Using the **Context Switch** (aqua colored in screenshot) feed context inputs in order of preference. In the workflow above, the `Upscale Out` context is first so, if that one is enabled, it will be chosen for the output. If not, the second input slot which comes from the context rerouted from above (before the Upscaler booth) will be chosen.
+
+ - Notice the `Upscale Preview` is _after_ the `Upscale Out` context node, using the image from it instead of the image from the upscale `VAE Decoder`. This is on purpose so, when we disable the `Upscale Out` context, none of the Upscaler nodes will run, saving precious GPU cycles. If we had the preview hooked up directly to the `VAE Decoder` the upscaler would always run to generate the preview, even if we had the `Upscale Out` context node disabled.
+
+2. We can now disable the `Upscale Out` context node by _muting_ it. Highlighting it and pressing `ctrl + m` will work. By doing so, it's output will be None, and it will not pass anthing onto the further nodes. In the diagram you can see the `Upscale Preview` is red, but that's OK; there are no actual errors to stop execution.
+
+3. Now, let's hook it up to the `Fast Muter` node. `The Fast Muter` node works as dashboard by adding quick toggles for any connected node (ignoring reroutes). In the diagram, we have both the `Upscaler Out` context node, and the `Save File` context node hooked up. So, we can quickly enable and disable those.
+
+ - The workflow seen here would be a common one where we can generate a handful of base previews cheaply with a random seed, and then choose one to upscale and save to disk.
+
+4. Lastly, and optionally, you can see the `Node Collector`. Use it to clean up noodles if you want and connect it to the muter. You can connect anything to it, but doing so may break your workflow's execution.
+
+
+
+# ⚡ Improvements & Features
+
+rgthree-comfy adds several improvements, features, and optimizations to ComfyUI that are not directly tied to nodes.
+
+## Progress Bar
+> A minimal progress bar that run alongs the top of the app window that shows the queue size, the current progress of the a prompt execution (within the same window), and the progress of multi-step nodes as well.
+>
+> You can remove/enable from rgthree-comfy settings, as well as configure the height/size.
+
+
+## ~~ComfyUI Recursive Optimization~~
+> 🎉 The newest version of ComfyUI no longer suffers from poor execution recursion! This feature
+> has been removed from rgthree-comfy.
+
+
+## "Queue Selected Output Nodes" in right-click menu
+> Sometimes you want to just queue one or two paths to specific output node(s) without executing the entire workflow. Well, now you can do just that by right-clicking on an output node and selecting `Queue Selected Output Nodes (rgthree)`.
+>
+>
+> ℹ️ More Information
+>
+> - Select the _output_ nodes you want to execute.
+>
+> - Note: Only output nodes are captured and traversed, not all selected nodes. So if you select an output AND a node from a different path, only the path connected to the output will be executed and not non-output nodes, even if they were selected.
+>
+> - Note: The whole workflow is serialized, and then we trim what we don't want for the backend. So things like all seed random/increment/decrement will run even if that node isn't being sent in the end, etc.
+>
+>
+
+
+## Auto-Nest Subdirectories in long Combos
+> _(Off by default while experimenting, turn on in rgthree-comfy settings)_.
+>
+> Automatically detect top-level subdirectories in long combo lists (like, Load Checkpoint) and break out into sub directories.
+
+
+## Quick Mute/Bypass Toggles in Group Headers
+> _(Off by default while experimenting, turn on in rgthree-comfy settings)_.
+>
+> Adds a mute and/or bypass toggle icons in the top-right of Group Headers for one-click toggling of groups you may be currently looking at.
+
+
+## Import Individual Node Widgets (Drag & Drop)
+> _(Off by default while experimenting, turn on in rgthree-comfy settings)_.
+>
+> Allows dragging and dropping an image/JSON workflow from a previous generation and overriding the same node's widgets
+> (that match with the same id & type). This is useful if you have several generations using the same general workflow
+> and would like to import just some data, like a previous generation's seed, or prompt, etc.
+
+
+
+## "Copy Image" in right-click menu
+> Right clicking on a node that has an image should have a context-menu item of "Copy Image" will allow you to copy the image right to your clipboard
+>
+> 🎓 I believe this has graduated, with ComfyUI recently adding this setting too. You won't get two menu items; my code checks that there isn't already a "Copy Image" item there before adding it.
+
+
+## Other/Smaller Fixes
+- Fixed the width of ultra-wide node chooser on double click.
+- Fixed z-indexes for textareas that would overlap above other elements, like Properties Panel, or @pythongosssss's image viewer.
+- Check for bad links when loading a workflow and log to console, by default. _(See Link Fixer below)._
+
+
+
+# 📄 Link Fixer
+
+If your workflows sometimes have missing connections, or even errors on load, start up ComfyUI and go to http://127.0.0.1:8188/rgthree/link_fixer which will allow you to drop in an image or workflow json file and check for and fix any bad links.
+
+You can also enable a link fixer check in the rgthree-comfy settings to give you an alert if you load a workflow with bad linking data to start.
diff --git a/custom_nodes/rgthree-comfy/__build__.py b/custom_nodes/rgthree-comfy/__build__.py
new file mode 100644
index 0000000000000000000000000000000000000000..32b78d04df69d9f1502b517f5695867628a61c85
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/__build__.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+
+import subprocess
+import os
+from shutil import rmtree, copytree, ignore_patterns
+from glob import glob
+import time
+import re
+import argparse
+
+from py.log import COLORS
+from py.config import RGTHREE_CONFIG
+
+step_msg = ''
+step_start = 0
+step_infos = []
+
+def log_step(msg=None, status=None):
+ """ Logs a step keeping track of timing and initial msg. """
+ global step_msg # pylint: disable=W0601
+ global step_start # pylint: disable=W0601
+ global step_infos # pylint: disable=W0601
+ if msg:
+ tag = f'{COLORS["YELLOW"]}[ Notice ]' if status == 'Notice' else f'{COLORS["RESET"]}[Starting]'
+ step_msg = f'▻ {tag}{COLORS["RESET"]} {msg}...'
+ step_start = time.time()
+ step_infos = []
+ print(step_msg, end="\r")
+ elif status:
+ if status != 'Error':
+ warnings = [w for w in step_infos if w["type"] == 'warn']
+ status = "Warn" if warnings else status
+ step_time = round(time.time() - step_start, 3)
+ if status == 'Error':
+ status_msg = f'{COLORS["RED"]}⤫ {status}{COLORS["RESET"]}'
+ elif status == 'Warn':
+ status_msg = f'{COLORS["YELLOW"]}! {status}{COLORS["RESET"]}'
+ else:
+ status_msg = f'{COLORS["BRIGHT_GREEN"]}🗸 {status}{COLORS["RESET"]}'
+ print(f'{step_msg.ljust(64, ".")} {status_msg} ({step_time}s)')
+ for info in step_infos:
+ print(info["msg"])
+
+def log_step_info(msg:str, status='info'):
+ global step_infos # pylint: disable=W0601
+ step_infos.append({"msg": f' - {msg}', "type": status})
+
+
+def build(without_tests = True, fix = False):
+
+ THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+ DIR_SRC_WEB = os.path.abspath(f'{THIS_DIR}/src_web/')
+ DIR_WEB = os.path.abspath(f'{THIS_DIR}/web/')
+ DIR_WEB_COMFYUI = os.path.abspath(f'{DIR_WEB}/comfyui/')
+
+ if fix:
+ tss = glob(os.path.join(DIR_SRC_WEB, "**", "*.ts"), recursive=True)
+ log_step(msg=f'Fixing {len(tss)} ts files')
+ for ts in tss:
+ with open(ts, 'r', encoding="utf-8") as f:
+ content = f.read()
+ # (\s*from\s*['"](?!.*[.]js['"]).*?)(['"];) in vscode.
+ content, n = re.subn(r'(\s*from [\'"](?!.*[.]js[\'"]).*?)([\'"];)', '\\1.js\\2', content)
+ if n > 0:
+ filename = os.path.basename(ts)
+ log_step_info(
+ f'{filename} has {n} import{"s" if n > 1 else ""} that do not end in ".js"', 'warn')
+ with open(ts, 'w', encoding="utf-8") as f:
+ f.write(content)
+ log_step(status="Done")
+
+ log_step(msg='Copying web directory')
+ rmtree(DIR_WEB)
+ copytree(DIR_SRC_WEB, DIR_WEB, ignore=ignore_patterns("typings*", "*.ts", "*.scss"))
+ log_step(status="Done")
+
+ ts_version_result = subprocess.run(["node", "./node_modules/typescript/bin/tsc", "-v"],
+ capture_output=True,
+ text=True,
+ check=True)
+ ts_version = re.sub(r'^.*Version\s*([\d\.]+).*', 'v\\1', ts_version_result.stdout, flags=re.DOTALL)
+
+ log_step(msg=f'TypeScript ({ts_version})')
+ checked = subprocess.run(["node", "./node_modules/typescript/bin/tsc"], check=True)
+ log_step(status="Done")
+
+ if not without_tests:
+ log_step(msg='Removing directories (KEEPING TESTING)', status="Notice")
+ else:
+ log_step(msg='Removing unneeded directories')
+ test_path = os.path.join(DIR_WEB, 'comfyui', 'tests')
+ if os.path.exists(test_path):
+ rmtree(test_path)
+ rmtree(os.path.join(DIR_WEB, 'comfyui', 'testing'))
+ # Always remove the dummy scripts_comfy directory
+ rmtree(os.path.join(DIR_WEB, 'scripts_comfy'))
+ log_step(status="Done")
+
+ scsss = glob(os.path.join(DIR_SRC_WEB, "**", "*.scss"), recursive=True)
+ log_step(msg=f'SASS for {len(scsss)} files')
+ scsss = [i.replace(THIS_DIR, '.') for i in scsss]
+ cmds = ["node", "./node_modules/sass/sass"]
+ for scss in scsss:
+ out = scss.replace('src_web', 'web').replace('.scss', '.css')
+ cmds.append(f'{scss}:{out}')
+ cmds.append('--no-source-map')
+ checked = subprocess.run(cmds, check=True)
+ log_step(status="Done")
+
+ # Handle the common directories. Because ComfyUI loads under /extensions/rgthree-comfy we can't
+ # easily share sources outside of the `DIR_WEB_COMFYUI` _and_ allow typescript to resolve them in
+ # src view, so we set the path in the tsconfig to map an import of "rgthree/common" to the
+ # "src_web/common" directory, but then need to rewrite the comfyui JS files to load from
+ # "../../rgthree/common" (which we map correctly in rgthree_server.py).
+ log_step(msg='Cleaning Imports')
+ js_files = glob(os.path.join(DIR_WEB, '**', '*.js'), recursive=True)
+ for file in js_files:
+ rel_path = file.replace(f'{DIR_WEB}/', "")
+ with open(file, 'r', encoding="utf-8") as f:
+ filedata = f.read()
+ num = rel_path.count(os.sep)
+ if rel_path.startswith('comfyui'):
+ filedata = re.sub(r'(from\s+["\'])rgthree/', f'\\1{"../" * (num + 1)}rgthree/', filedata)
+ filedata = re.sub(r'(from\s+["\'])scripts/', f'\\1{"../" * (num + 1)}scripts/', filedata)
+ # Dynamic Imports
+ filedata = re.sub(r'(import\(["\'])rgthree/', f'\\1{"../" * (num + 1)}rgthree/', filedata)
+ else:
+ filedata = re.sub(r'(from\s+["\'])rgthree/', f'\\1{"../" * num}', filedata)
+ filedata = re.sub(r'(from\s+["\'])scripts/', f'\\1{"../" * (num + 1)}scripts/', filedata)
+ # Dynamic Imports
+ filedata = re.sub(r'(import\(["\'])rgthree/', f'\\1{"../" * num}', filedata)
+
+ filedata, n = re.subn(r'(\s*from [\'"](?!.*[.]js[\'"]).*?)([\'"];)', '\\1.js\\2', filedata)
+ if n > 0:
+ filename = file.split('rgthree-comfy')[1]
+ log_step_info(
+ f'{filename} has {n} import{"s" if n > 1 else ""} that do not end in ".js"', 'warn')
+ with open(file, 'w', encoding="utf-8") as f:
+ f.write(filedata)
+ log_step(status="Done")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-t", "--no-tests", default=False, action="store_true")
+ parser.add_argument("-f", "--fix", default=False, action="store_true")
+ args = parser.parse_args()
+
+ start = time.time()
+ build(without_tests=args.no_tests, fix=args.fix)
+ print(f'Finished all in {round(time.time() - start, 3)}s')
diff --git a/custom_nodes/rgthree-comfy/__commit__.py b/custom_nodes/rgthree-comfy/__commit__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bab14170261566dca011e247765b4e5c86d8c2cb
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/__commit__.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+
+import subprocess
+import os
+import re
+import datetime
+import time
+import argparse
+
+from __build__ import build, log_step, log_step_info
+
+_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+_FILE_PY_PROJECT = os.path.join(_THIS_DIR, 'pyproject.toml')
+
+parser = argparse.ArgumentParser()
+parser.add_argument(
+ "-m", "--message", help="The git commit message", required=True, action="store", type=str
+)
+args = parser.parse_args()
+
+start = time.time()
+build()
+
+log_step(msg='Updating version in pyproject.toml')
+py_project = ''
+with open(_FILE_PY_PROJECT, "r", encoding='utf-8') as f:
+ py_project = f.read()
+
+version = re.search(r'^\s*version\s*=\s*"(.*?)"', py_project, flags=re.MULTILINE)
+version_old = version[1]
+
+now = datetime.datetime.now()
+version_new = version_old.split('.')
+version_new[-1] = f'{str(now.year)[2:]}{now.month:02}{now.day:02}{now.hour:02}{now.minute:02}'
+version_new = '.'.join(version_new)
+
+log_step_info(f'Updating from v{version_old} to v{version_new}')
+py_project = py_project.replace(version_old, version_new)
+with open(_FILE_PY_PROJECT, "w", encoding='utf-8') as f:
+ f.write(py_project)
+log_step(status="Done")
+
+log_step('Running git add')
+process = subprocess.Popen(['git', 'add', '.'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+stdout, stderr = process.communicate()
+log_step(status="Done")
+
+log_step('Running git commit')
+process = subprocess.Popen(['git', 'commit', '-a', '-v', '-m', args.message],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+stdout, stderr = process.communicate()
+log_step(status="Done")
+
+print(f'Finished all in {round(time.time() - start, 3)}s')
diff --git a/custom_nodes/rgthree-comfy/__init__.py b/custom_nodes/rgthree-comfy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..edf954cbbeafef1f99748e7927aa405ab73e3a1d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/__init__.py
@@ -0,0 +1,126 @@
+"""
+@author: rgthree
+@title: Comfy Nodes
+@nickname: rgthree
+@description: A bunch of nodes I created that I also find useful.
+"""
+
+from glob import glob
+import json
+import os
+import shutil
+import re
+import random
+
+import execution
+
+from .py.log import log
+from .py.config import get_config_value
+from .py.server.rgthree_server import *
+
+from .py.context import RgthreeContext
+from .py.context_switch import RgthreeContextSwitch
+from .py.context_switch_big import RgthreeContextSwitchBig
+from .py.display_any import RgthreeDisplayAny, RgthreeDisplayInt
+from .py.lora_stack import RgthreeLoraLoaderStack
+from .py.seed import RgthreeSeed
+from .py.sdxl_empty_latent_image import RgthreeSDXLEmptyLatentImage
+from .py.power_prompt import RgthreePowerPrompt
+from .py.power_prompt_simple import RgthreePowerPromptSimple
+from .py.image_inset_crop import RgthreeImageInsetCrop
+from .py.context_big import RgthreeBigContext
+from .py.dynamic_context import RgthreeDynamicContext
+from .py.dynamic_context_switch import RgthreeDynamicContextSwitch
+from .py.ksampler_config import RgthreeKSamplerConfig
+from .py.sdxl_power_prompt_postive import RgthreeSDXLPowerPromptPositive
+from .py.sdxl_power_prompt_simple import RgthreeSDXLPowerPromptSimple
+from .py.any_switch import RgthreeAnySwitch
+from .py.context_merge import RgthreeContextMerge
+from .py.context_merge_big import RgthreeContextMergeBig
+from .py.image_comparer import RgthreeImageComparer
+from .py.power_lora_loader import RgthreePowerLoraLoader
+from .py.power_primitive import RgthreePowerPrimitive
+from .py.image_or_latent_size import RgthreeImageOrLatentSize
+from .py.image_resize import RgthreeImageResize
+from .py.power_puter import RgthreePowerPuter
+
+NODE_CLASS_MAPPINGS = {
+ RgthreeBigContext.NAME: RgthreeBigContext,
+ RgthreeContext.NAME: RgthreeContext,
+ RgthreeContextSwitch.NAME: RgthreeContextSwitch,
+ RgthreeContextSwitchBig.NAME: RgthreeContextSwitchBig,
+ RgthreeContextMerge.NAME: RgthreeContextMerge,
+ RgthreeContextMergeBig.NAME: RgthreeContextMergeBig,
+ RgthreeDisplayInt.NAME: RgthreeDisplayInt,
+ RgthreeDisplayAny.NAME: RgthreeDisplayAny,
+ RgthreeLoraLoaderStack.NAME: RgthreeLoraLoaderStack,
+ RgthreeSeed.NAME: RgthreeSeed,
+ RgthreeImageInsetCrop.NAME: RgthreeImageInsetCrop,
+ RgthreePowerPrompt.NAME: RgthreePowerPrompt,
+ RgthreePowerPromptSimple.NAME: RgthreePowerPromptSimple,
+ RgthreeKSamplerConfig.NAME: RgthreeKSamplerConfig,
+ RgthreeSDXLEmptyLatentImage.NAME: RgthreeSDXLEmptyLatentImage,
+ RgthreeSDXLPowerPromptPositive.NAME: RgthreeSDXLPowerPromptPositive,
+ RgthreeSDXLPowerPromptSimple.NAME: RgthreeSDXLPowerPromptSimple,
+ RgthreeAnySwitch.NAME: RgthreeAnySwitch,
+ RgthreeImageComparer.NAME: RgthreeImageComparer,
+ RgthreePowerLoraLoader.NAME: RgthreePowerLoraLoader,
+ RgthreePowerPrimitive.NAME: RgthreePowerPrimitive,
+ RgthreeImageOrLatentSize.NAME: RgthreeImageOrLatentSize,
+ RgthreeImageResize.NAME: RgthreeImageResize,
+ RgthreePowerPuter.NAME: RgthreePowerPuter,
+}
+
+if get_config_value('unreleased.dynamic_context.enabled') is True:
+ NODE_CLASS_MAPPINGS[RgthreeDynamicContext.NAME] = RgthreeDynamicContext
+ NODE_CLASS_MAPPINGS[RgthreeDynamicContextSwitch.NAME] = RgthreeDynamicContextSwitch
+
+# WEB_DIRECTORY is the comfyui nodes directory that ComfyUI will link and auto-load.
+WEB_DIRECTORY = "./web/comfyui"
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+DIR_WEB = os.path.abspath(f'{THIS_DIR}/{WEB_DIRECTORY}')
+DIR_PY = os.path.abspath(f'{THIS_DIR}/py')
+
+# remove old directories
+OLD_DIRS = [
+ os.path.abspath(f'{THIS_DIR}/../../web/extensions/rgthree'),
+ os.path.abspath(f'{THIS_DIR}/../../web/extensions/rgthree-comfy'),
+]
+for old_dir in OLD_DIRS:
+ if os.path.exists(old_dir):
+ shutil.rmtree(old_dir)
+
+__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
+
+NOT_NODES = ['constants', 'log', 'utils', 'rgthree', 'rgthree_server', 'image_clipbaord', 'config']
+
+nodes = []
+for file in glob(os.path.join(DIR_PY, '*.py')) + glob(os.path.join(DIR_WEB, '*.js')):
+ name = os.path.splitext(os.path.basename(file))[0]
+ if name in NOT_NODES or name in nodes:
+ continue
+ if name.startswith('_') or name.startswith('base') or 'utils' in name:
+ continue
+ nodes.append(name)
+ if name == 'display_any':
+ nodes.append('display_int')
+
+print()
+adjs = ['exciting', 'extraordinary', 'epic', 'fantastic', 'magnificent']
+log(f'Loaded {len(nodes)} {random.choice(adjs)} nodes. 🎉', color='BRIGHT_GREEN')
+print()
+
+if get_config_value('announcements.comfy-nodes-20.incompatible', True):
+ message = (
+ "ComfyUI's new Node 2.0 rendering may be incompatible with some rgthree-comfy nodes "
+ "and features, breaking some rendering as well as losing the ability to "
+ "access a node's properties (a vital part of many nodes). It also appears to run MUCH more "
+ "slowly spiking CPU usage and causing jankiness and unresponsiveness, especially with large "
+ "workflows. Personally I am not planning to use the new Nodes 2.0 and, unfortunately, am not "
+ "able to invest the time to investigate and overhaul rgthree-comfy where needed. "
+ "If you have issues when Nodes 2.0 is enabled, I'd urge you to switch it off as well and "
+ "join me in hoping ComfyUI is not planning to deprecate the existing, stable canvas rendering "
+ "all together.\n"
+ )
+ log(message, color='YELLOW', id='announcements.comfy-nodes-20.incompatible', at_most_secs=60)
diff --git a/custom_nodes/rgthree-comfy/__update_comfy__.py b/custom_nodes/rgthree-comfy/__update_comfy__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7c0062dcd26ea18bd6f9beb70aa2f0e7e55aa05
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/__update_comfy__.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+
+# A nicer output for git pulling custom nodes (and ComfyUI).
+# Quick shell version: ls | xargs -I % sh -c 'echo; echo %; git -C % pull'
+
+import os
+from subprocess import Popen, PIPE, STDOUT
+
+
+def pull_path(path, origin = None, restore = False):
+ if restore:
+ p = Popen(["git", "-C", path, "restore", "."], stdout=PIPE, stderr=STDOUT)
+ output, error = p.communicate()
+
+ args = ["git", "-C", path, "pull"]
+ if origin is not None:
+ args.append('origin')
+ args.append(origin)
+ p = Popen(args, stdout=PIPE, stderr=STDOUT)
+ output, error = p.communicate()
+ return output.decode()
+
+THIS_DIR=os.path.dirname(os.path.abspath(__file__))
+
+def show_output(output):
+ if 'Already up to date' in output:
+ print(f' \33[32m🗸 Already up to date\33[0m', end ='')
+ elif output.startswith('error:'):
+ print(f' \33[31m🞫 Error.\33[0m \n {output}')
+ else:
+ print(f' \33[33m🡅 Needs update.\33[0m \n {output}', end='')
+
+
+os.chdir(THIS_DIR)
+os.chdir("../")
+
+# Get the list or custom nodes, so we can format the output a little more nicely.
+custom_extensions = []
+custom_extensions_name_max = 0
+for directory in os.listdir(os.getcwd()):
+ if os.path.isdir(directory) and directory != "__pycache__": #and directory != "rgthree-comfy" :
+ custom_extensions.append({
+ 'directory': directory
+ })
+ if len(directory) > custom_extensions_name_max:
+ custom_extensions_name_max = len(directory)
+
+if len(custom_extensions) == 0:
+ custom_extensions_name_max = 15
+else:
+ custom_extensions_name_max += 6
+
+# Update ComfyUI itself.
+label = "{0:.<{max}}".format('Updating ComfyUI ', max=custom_extensions_name_max)
+print(label, end = '')
+show_output(pull_path('../', origin='master', restore=True))
+
+# If we have custom nodes, update them as well.
+if len(custom_extensions) > 0:
+ print(f'\nUpdating custom_nodes ({len(custom_extensions)}):')
+ for custom_extension in custom_extensions:
+ directory = custom_extension['directory']
+ label = "{0:.<{max}}".format(f'🗀 {directory} ', max=custom_extensions_name_max)
+ print(label, end = '')
+ show_output(pull_path(directory))
diff --git a/custom_nodes/rgthree-comfy/docs/rgthree_advanced.png b/custom_nodes/rgthree-comfy/docs/rgthree_advanced.png
new file mode 100644
index 0000000000000000000000000000000000000000..49d85226499c791db835ddc5b5fd91c6621b8c70
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/docs/rgthree_advanced.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:77d88a50847fa76d95a1470bf0315b035a06e3c87a8d4e8132d49d8d5b8a31ce
+size 455664
diff --git a/custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png b/custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png
new file mode 100644
index 0000000000000000000000000000000000000000..90aedcf8107dcadcd59519896af8bad9ae4399fa
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c33be251bd628225b9e29249b766b45539a62a9f94f2e0085b10c469f5ef0956
+size 491188
diff --git a/custom_nodes/rgthree-comfy/docs/rgthree_context.png b/custom_nodes/rgthree-comfy/docs/rgthree_context.png
new file mode 100644
index 0000000000000000000000000000000000000000..583922074a628160d4d82f667fd0df54961f2a56
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/docs/rgthree_context.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4d7c4401b0f4b6958d75b9eca531b0c16d8eeb121f3ec8092ccf1336966c43cd
+size 544154
diff --git a/custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png b/custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc9ee9116fd3f12848db064e5c4967743752d6ab
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0f89ef9decfee6b65831780a85235f6bfedd99ff1f16eaaf0567a17d94997ba7
+size 1689098
diff --git a/custom_nodes/rgthree-comfy/docs/rgthree_router.png b/custom_nodes/rgthree-comfy/docs/rgthree_router.png
new file mode 100644
index 0000000000000000000000000000000000000000..f3581d708f98c6352dfcc414ac0bd81f2eb2fc59
Binary files /dev/null and b/custom_nodes/rgthree-comfy/docs/rgthree_router.png differ
diff --git a/custom_nodes/rgthree-comfy/docs/rgthree_seed.png b/custom_nodes/rgthree-comfy/docs/rgthree_seed.png
new file mode 100644
index 0000000000000000000000000000000000000000..32c392fbccf38f7e0d150cbeed0cc9066f8ac610
Binary files /dev/null and b/custom_nodes/rgthree-comfy/docs/rgthree_seed.png differ
diff --git a/custom_nodes/rgthree-comfy/package-lock.json b/custom_nodes/rgthree-comfy/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..ccb4fd2758dfd8ea0b2165078a270f3c5bb9b4cb
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/package-lock.json
@@ -0,0 +1,568 @@
+{
+ "name": "rgthree-comfy",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@comfyorg/comfyui-frontend-types": "^1.32.4",
+ "prettier": "^3.3.3",
+ "sass": "^1.77.8",
+ "typescript": "^5.5.4",
+ "web-tree-sitter": "0.25.6"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
+ "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+ "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+ "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@babel/types": "^7.27.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+ "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@comfyorg/comfyui-frontend-types": {
+ "version": "1.32.4",
+ "resolved": "https://registry.npmjs.org/@comfyorg/comfyui-frontend-types/-/comfyui-frontend-types-1.32.4.tgz",
+ "integrity": "sha512-9EBFwQPaSMLI151caWN5nA0qVmY5S9BR9lHP5NboPnTsmkFwnWdrAPadAnafwmZcK40ZWntKJZxO0Uuju0jw/w==",
+ "dev": true,
+ "peerDependencies": {
+ "vue": "^3.5.13",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
+ "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@babel/parser": "^7.25.3",
+ "@vue/shared": "3.5.13",
+ "entities": "^4.5.0",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
+ "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-core": "3.5.13",
+ "@vue/shared": "3.5.13"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
+ "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@babel/parser": "^7.25.3",
+ "@vue/compiler-core": "3.5.13",
+ "@vue/compiler-dom": "3.5.13",
+ "@vue/compiler-ssr": "3.5.13",
+ "@vue/shared": "3.5.13",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.11",
+ "postcss": "^8.4.48",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz",
+ "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.13",
+ "@vue/shared": "3.5.13"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
+ "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/shared": "3.5.13"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz",
+ "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/reactivity": "3.5.13",
+ "@vue/shared": "3.5.13"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz",
+ "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/reactivity": "3.5.13",
+ "@vue/runtime-core": "3.5.13",
+ "@vue/shared": "3.5.13",
+ "csstype": "^3.1.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz",
+ "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.13",
+ "@vue/shared": "3.5.13"
+ },
+ "peerDependencies": {
+ "vue": "3.5.13"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
+ "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/immutable": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+ "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
+ "dev": true
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.17",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
+ "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "peer": true,
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.3",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
+ "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.8",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
+ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/sass": {
+ "version": "1.77.8",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
+ "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
+ "dev": true,
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
+ "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.13",
+ "@vue/compiler-sfc": "3.5.13",
+ "@vue/runtime-dom": "3.5.13",
+ "@vue/server-renderer": "3.5.13",
+ "@vue/shared": "3.5.13"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/web-tree-sitter": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.6.tgz",
+ "integrity": "sha512-WG+/YGbxw8r+rLlzzhV+OvgiOJCWdIpOucG3qBf3RCBFMkGDb1CanUi2BxCxjnkpzU3/hLWPT8VO5EKsMk9Fxg==",
+ "dev": true
+ },
+ "node_modules/zod": {
+ "version": "3.24.2",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
+ "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
+ "dev": true,
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/package.json b/custom_nodes/rgthree-comfy/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..39fc9c3f0ef7f340e4d8bc03eb1153f835728919
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/package.json
@@ -0,0 +1,12 @@
+{
+ "devDependencies": {
+ "prettier": "^3.3.3",
+ "typescript": "^5.5.4",
+ "sass": "^1.77.8",
+ "@comfyorg/comfyui-frontend-types": "^1.32.4",
+ "web-tree-sitter": "0.25.6"
+ },
+ "scripts": {
+ "build": "./__build__.py || python .\\__build__.py"
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/pnpm-lock.yaml b/custom_nodes/rgthree-comfy/pnpm-lock.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d3ca55960ab786477934a5329aa9a9bd662daee5
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/pnpm-lock.yaml
@@ -0,0 +1,507 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@comfyorg/comfyui-frontend-types':
+ specifier: ^1.32.4
+ version: 1.32.4(vue@3.5.24(typescript@5.9.3))(zod@3.25.76)
+ prettier:
+ specifier: ^3.3.3
+ version: 3.6.2
+ sass:
+ specifier: ^1.77.8
+ version: 1.93.3
+ typescript:
+ specifier: ^5.5.4
+ version: 5.9.3
+ web-tree-sitter:
+ specifier: 0.25.6
+ version: 0.25.6
+
+packages:
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.5':
+ resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.28.5':
+ resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ engines: {node: '>=6.9.0'}
+
+ '@comfyorg/comfyui-frontend-types@1.32.4':
+ resolution: {integrity: sha512-9EBFwQPaSMLI151caWN5nA0qVmY5S9BR9lHP5NboPnTsmkFwnWdrAPadAnafwmZcK40ZWntKJZxO0Uuju0jw/w==}
+ peerDependencies:
+ vue: ^3.5.13
+ zod: ^3.23.8
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@parcel/watcher-android-arm64@2.5.1':
+ resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ '@parcel/watcher-darwin-arm64@2.5.1':
+ resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@parcel/watcher-darwin-x64@2.5.1':
+ resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@parcel/watcher-freebsd-x64@2.5.1':
+ resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@parcel/watcher-linux-arm-glibc@2.5.1':
+ resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm-musl@2.5.1':
+ resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.1':
+ resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm64-musl@2.5.1':
+ resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@parcel/watcher-linux-x64-glibc@2.5.1':
+ resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@parcel/watcher-linux-x64-musl@2.5.1':
+ resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@parcel/watcher-win32-arm64@2.5.1':
+ resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@parcel/watcher-win32-ia32@2.5.1':
+ resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@parcel/watcher-win32-x64@2.5.1':
+ resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@parcel/watcher@2.5.1':
+ resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
+ engines: {node: '>= 10.0.0'}
+
+ '@vue/compiler-core@3.5.24':
+ resolution: {integrity: sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==}
+
+ '@vue/compiler-dom@3.5.24':
+ resolution: {integrity: sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==}
+
+ '@vue/compiler-sfc@3.5.24':
+ resolution: {integrity: sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==}
+
+ '@vue/compiler-ssr@3.5.24':
+ resolution: {integrity: sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==}
+
+ '@vue/reactivity@3.5.24':
+ resolution: {integrity: sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==}
+
+ '@vue/runtime-core@3.5.24':
+ resolution: {integrity: sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==}
+
+ '@vue/runtime-dom@3.5.24':
+ resolution: {integrity: sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==}
+
+ '@vue/server-renderer@3.5.24':
+ resolution: {integrity: sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==}
+ peerDependencies:
+ vue: 3.5.24
+
+ '@vue/shared@3.5.24':
+ resolution: {integrity: sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ csstype@3.1.3:
+ resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
+ detect-libc@1.0.3:
+ resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
+ engines: {node: '>=0.10'}
+ hasBin: true
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ immutable@5.1.4:
+ resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-addon-api@7.1.1:
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prettier@3.6.2:
+ resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
+ sass@1.93.3:
+ resolution: {integrity: sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ vue@3.5.24:
+ resolution: {integrity: sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ web-tree-sitter@0.25.6:
+ resolution: {integrity: sha512-WG+/YGbxw8r+rLlzzhV+OvgiOJCWdIpOucG3qBf3RCBFMkGDb1CanUi2BxCxjnkpzU3/hLWPT8VO5EKsMk9Fxg==}
+
+ zod@3.25.76:
+ resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/parser@7.28.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@babel/types@7.28.5':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@comfyorg/comfyui-frontend-types@1.32.4(vue@3.5.24(typescript@5.9.3))(zod@3.25.76)':
+ dependencies:
+ vue: 3.5.24(typescript@5.9.3)
+ zod: 3.25.76
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@parcel/watcher-android-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-darwin-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-darwin-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-freebsd-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-x64-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-x64-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-ia32@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher@2.5.1':
+ dependencies:
+ detect-libc: 1.0.3
+ is-glob: 4.0.3
+ micromatch: 4.0.8
+ node-addon-api: 7.1.1
+ optionalDependencies:
+ '@parcel/watcher-android-arm64': 2.5.1
+ '@parcel/watcher-darwin-arm64': 2.5.1
+ '@parcel/watcher-darwin-x64': 2.5.1
+ '@parcel/watcher-freebsd-x64': 2.5.1
+ '@parcel/watcher-linux-arm-glibc': 2.5.1
+ '@parcel/watcher-linux-arm-musl': 2.5.1
+ '@parcel/watcher-linux-arm64-glibc': 2.5.1
+ '@parcel/watcher-linux-arm64-musl': 2.5.1
+ '@parcel/watcher-linux-x64-glibc': 2.5.1
+ '@parcel/watcher-linux-x64-musl': 2.5.1
+ '@parcel/watcher-win32-arm64': 2.5.1
+ '@parcel/watcher-win32-ia32': 2.5.1
+ '@parcel/watcher-win32-x64': 2.5.1
+ optional: true
+
+ '@vue/compiler-core@3.5.24':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/shared': 3.5.24
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.24':
+ dependencies:
+ '@vue/compiler-core': 3.5.24
+ '@vue/shared': 3.5.24
+
+ '@vue/compiler-sfc@3.5.24':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/compiler-core': 3.5.24
+ '@vue/compiler-dom': 3.5.24
+ '@vue/compiler-ssr': 3.5.24
+ '@vue/shared': 3.5.24
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.6
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.24':
+ dependencies:
+ '@vue/compiler-dom': 3.5.24
+ '@vue/shared': 3.5.24
+
+ '@vue/reactivity@3.5.24':
+ dependencies:
+ '@vue/shared': 3.5.24
+
+ '@vue/runtime-core@3.5.24':
+ dependencies:
+ '@vue/reactivity': 3.5.24
+ '@vue/shared': 3.5.24
+
+ '@vue/runtime-dom@3.5.24':
+ dependencies:
+ '@vue/reactivity': 3.5.24
+ '@vue/runtime-core': 3.5.24
+ '@vue/shared': 3.5.24
+ csstype: 3.1.3
+
+ '@vue/server-renderer@3.5.24(vue@3.5.24(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.24
+ '@vue/shared': 3.5.24
+ vue: 3.5.24(typescript@5.9.3)
+
+ '@vue/shared@3.5.24': {}
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+ optional: true
+
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+
+ csstype@3.1.3: {}
+
+ detect-libc@1.0.3:
+ optional: true
+
+ entities@4.5.0: {}
+
+ estree-walker@2.0.2: {}
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+ optional: true
+
+ immutable@5.1.4: {}
+
+ is-extglob@2.1.1:
+ optional: true
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+ optional: true
+
+ is-number@7.0.0:
+ optional: true
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+ optional: true
+
+ nanoid@3.3.11: {}
+
+ node-addon-api@7.1.1:
+ optional: true
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.1:
+ optional: true
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prettier@3.6.2: {}
+
+ readdirp@4.1.2: {}
+
+ sass@1.93.3:
+ dependencies:
+ chokidar: 4.0.3
+ immutable: 5.1.4
+ source-map-js: 1.2.1
+ optionalDependencies:
+ '@parcel/watcher': 2.5.1
+
+ source-map-js@1.2.1: {}
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+ optional: true
+
+ typescript@5.9.3: {}
+
+ vue@3.5.24(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.24
+ '@vue/compiler-sfc': 3.5.24
+ '@vue/runtime-dom': 3.5.24
+ '@vue/server-renderer': 3.5.24(vue@3.5.24(typescript@5.9.3))
+ '@vue/shared': 3.5.24
+ optionalDependencies:
+ typescript: 5.9.3
+
+ web-tree-sitter@0.25.6: {}
+
+ zod@3.25.76: {}
diff --git a/custom_nodes/rgthree-comfy/prestartup_script.py b/custom_nodes/rgthree-comfy/prestartup_script.py
new file mode 100644
index 0000000000000000000000000000000000000000..81d81b35a80675a13e3d5d279644a96e52658838
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/prestartup_script.py
@@ -0,0 +1,5 @@
+import folder_paths
+
+# Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully.
+# TODO: See if anyone actually used this.
+# folder_paths.folder_names_and_paths['saved_prompts'] = ([], set(['.txt']))
diff --git a/custom_nodes/rgthree-comfy/py/__init__.py b/custom_nodes/rgthree-comfy/py/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/custom_nodes/rgthree-comfy/py/any_switch.py b/custom_nodes/rgthree-comfy/py/any_switch.py
new file mode 100644
index 0000000000000000000000000000000000000000..99713fafd3db1812b384689b7ed70b6e7f96f4d9
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/any_switch.py
@@ -0,0 +1,38 @@
+from .context_utils import is_context_empty
+from .constants import get_category, get_name
+from .utils import FlexibleOptionalInputType, any_type
+
+
+def is_none(value):
+ """Checks if a value is none. Pulled out in case we want to expand what 'None' means."""
+ if value is not None:
+ if isinstance(value, dict) and 'model' in value and 'clip' in value:
+ return is_context_empty(value)
+ return value is None
+
+
+class RgthreeAnySwitch:
+ """The dynamic Any Switch. """
+
+ NAME = get_name("Any Switch")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType(any_type),
+ }
+
+ RETURN_TYPES = (any_type,)
+ RETURN_NAMES = ('*',)
+ FUNCTION = "switch"
+
+ def switch(self, **kwargs):
+ """Chooses the first non-empty item to output."""
+ any_value = None
+ for key, value in kwargs.items():
+ if key.startswith('any_') and not is_none(value):
+ any_value = value
+ break
+ return (any_value,)
diff --git a/custom_nodes/rgthree-comfy/py/config.py b/custom_nodes/rgthree-comfy/py/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..a50c49293e3a574881351a0f04d58b72d638561b
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/config.py
@@ -0,0 +1,111 @@
+import os
+import json
+
+from .utils import get_dict_value, set_dict_value, dict_has_key, load_json_file
+from .pyproject import VERSION
+
+
+def get_config_value(key, default=None):
+ return get_dict_value(RGTHREE_CONFIG, key, default)
+
+
+def extend_config(default_config, user_config):
+ """ Returns a new config dict combining user_config into defined keys for default_config."""
+ cfg = {}
+ for key, value in default_config.items():
+ if key not in user_config:
+ cfg[key] = value
+ elif isinstance(value, dict):
+ cfg[key] = extend_config(value, user_config[key])
+ else:
+ cfg[key] = user_config[key] if key in user_config else value
+ return cfg
+
+
+def set_user_config(data: dict):
+ """ Sets the user configuration."""
+ count = 0
+ for key, value in data.items():
+ if dict_has_key(DEFAULT_CONFIG, key):
+ set_dict_value(USER_CONFIG, key, value)
+ set_dict_value(RGTHREE_CONFIG, key, value)
+ count += 1
+ if count > 0:
+ write_user_config()
+
+
+def get_rgthree_default_config():
+ """ Gets the default configuration."""
+ return load_json_file(DEFAULT_CONFIG_FILE, default={})
+
+
+def get_rgthree_user_config():
+ """ Gets the user configuration."""
+ return load_json_file(USER_CONFIG_FILE, default={})
+
+
+def write_user_config():
+ """ Writes the user configuration."""
+ with open(USER_CONFIG_FILE, 'w+', encoding='UTF-8') as file:
+ json.dump(USER_CONFIG, file, sort_keys=True, indent=2, separators=(",", ": "))
+
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_CONFIG_FILE = os.path.join(THIS_DIR, '..', 'rgthree_config.json.default')
+USER_CONFIG_FILE = os.path.join(THIS_DIR, '..', 'rgthree_config.json')
+
+DEFAULT_CONFIG = {}
+USER_CONFIG = {}
+RGTHREE_CONFIG = {}
+
+
+def refresh_config():
+ """Refreshes the config."""
+ global DEFAULT_CONFIG, USER_CONFIG, RGTHREE_CONFIG
+ DEFAULT_CONFIG = get_rgthree_default_config()
+ USER_CONFIG = get_rgthree_user_config()
+
+ # Migrate old config options into "features"
+ needs_to_write_user_config = False
+ if 'patch_recursive_execution' in USER_CONFIG:
+ del USER_CONFIG['patch_recursive_execution']
+ needs_to_write_user_config = True
+
+ if 'features' in USER_CONFIG and 'patch_recursive_execution' in USER_CONFIG['features']:
+ del USER_CONFIG['features']['patch_recursive_execution']
+ needs_to_write_user_config = True
+
+ if 'show_alerts_for_corrupt_workflows' in USER_CONFIG:
+ if 'features' not in USER_CONFIG:
+ USER_CONFIG['features'] = {}
+ USER_CONFIG['features']['show_alerts_for_corrupt_workflows'] = USER_CONFIG[
+ 'show_alerts_for_corrupt_workflows']
+ del USER_CONFIG['show_alerts_for_corrupt_workflows']
+ needs_to_write_user_config = True
+
+ if 'monitor_for_corrupt_links' in USER_CONFIG:
+ if 'features' not in USER_CONFIG:
+ USER_CONFIG['features'] = {}
+ USER_CONFIG['features']['monitor_for_corrupt_links'] = USER_CONFIG['monitor_for_corrupt_links']
+ del USER_CONFIG['monitor_for_corrupt_links']
+ needs_to_write_user_config = True
+
+ if needs_to_write_user_config is True:
+ print('writing new user config.')
+ write_user_config()
+
+ RGTHREE_CONFIG = {"version": VERSION} | extend_config(DEFAULT_CONFIG, USER_CONFIG)
+
+ if "unreleased" in USER_CONFIG and "unreleased" not in RGTHREE_CONFIG:
+ RGTHREE_CONFIG["unreleased"] = USER_CONFIG["unreleased"]
+
+ if "debug" in USER_CONFIG and "debug" not in RGTHREE_CONFIG:
+ RGTHREE_CONFIG["debug"] = USER_CONFIG["debug"]
+
+
+def get_config():
+ """Returns the congfig."""
+ return RGTHREE_CONFIG
+
+
+refresh_config()
diff --git a/custom_nodes/rgthree-comfy/py/constants.py b/custom_nodes/rgthree-comfy/py/constants.py
new file mode 100644
index 0000000000000000000000000000000000000000..73b7d687ad24999c3b441f2c2ef39e4e8b3f3d54
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/constants.py
@@ -0,0 +1,11 @@
+
+NAMESPACE='rgthree'
+
+def get_name(name):
+ return '{} ({})'.format(name, NAMESPACE)
+
+def get_category(sub_dirs = None):
+ if sub_dirs is None:
+ return NAMESPACE
+ else:
+ return "{}/utils".format(NAMESPACE)
diff --git a/custom_nodes/rgthree-comfy/py/context.py b/custom_nodes/rgthree-comfy/py/context.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2c94f6a687606708cd1399e228c6b6b7d451318
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context.py
@@ -0,0 +1,33 @@
+"""The Context node."""
+from .context_utils import (ORIG_CTX_OPTIONAL_INPUTS, ORIG_CTX_RETURN_NAMES, ORIG_CTX_RETURN_TYPES,
+ get_orig_context_return_tuple, new_context)
+from .constants import get_category, get_name
+
+
+class RgthreeContext:
+ """The initial Context node.
+
+ For now, this nodes' outputs will remain as-is, as they are perfect for most 1.5 application, but
+ is also backwards compatible with other Context nodes.
+ """
+
+ NAME = get_name("Context")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": ORIG_CTX_OPTIONAL_INPUTS,
+ "hidden": {
+ "version": "FLOAT"
+ },
+ }
+
+ RETURN_TYPES = ORIG_CTX_RETURN_TYPES
+ RETURN_NAMES = ORIG_CTX_RETURN_NAMES
+ FUNCTION = "convert"
+
+ def convert(self, base_ctx=None, **kwargs): # pylint: disable = missing-function-docstring
+ ctx = new_context(base_ctx, **kwargs)
+ return get_orig_context_return_tuple(ctx)
diff --git a/custom_nodes/rgthree-comfy/py/context_big.py b/custom_nodes/rgthree-comfy/py/context_big.py
new file mode 100644
index 0000000000000000000000000000000000000000..411cdc8fee86118c0a4201ff7441eab0f5c792d1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context_big.py
@@ -0,0 +1,31 @@
+"""The Conmtext big node."""
+from .constants import get_category, get_name
+from .context_utils import (ALL_CTX_OPTIONAL_INPUTS, ALL_CTX_RETURN_NAMES, ALL_CTX_RETURN_TYPES,
+ new_context, get_context_return_tuple)
+
+
+class RgthreeBigContext:
+ """The Context Big node.
+
+ This context node will expose all context fields as inputs and outputs. It is backwards compatible
+ with other context nodes and can be intertwined with them.
+ """
+
+ NAME = get_name("Context Big")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name,missing-function-docstring
+ return {
+ "required": {},
+ "optional": ALL_CTX_OPTIONAL_INPUTS,
+ "hidden": {},
+ }
+
+ RETURN_TYPES = ALL_CTX_RETURN_TYPES
+ RETURN_NAMES = ALL_CTX_RETURN_NAMES
+ FUNCTION = "convert"
+
+ def convert(self, base_ctx=None, **kwargs): # pylint: disable = missing-function-docstring
+ ctx = new_context(base_ctx, **kwargs)
+ return get_context_return_tuple(ctx)
diff --git a/custom_nodes/rgthree-comfy/py/context_merge.py b/custom_nodes/rgthree-comfy/py/context_merge.py
new file mode 100644
index 0000000000000000000000000000000000000000..02d0afe68554f16e99d99ff969afbd98a646ff8c
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context_merge.py
@@ -0,0 +1,37 @@
+"""The Context Switch (Big)."""
+from .constants import get_category, get_name
+from .context_utils import (ORIG_CTX_RETURN_TYPES, ORIG_CTX_RETURN_NAMES, merge_new_context,
+ get_orig_context_return_tuple, is_context_empty)
+from .utils import FlexibleOptionalInputType
+
+
+class RgthreeContextMerge:
+ """The Context Merge node."""
+
+ NAME = get_name("Context Merge")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType("RGTHREE_CONTEXT"),
+ }
+
+ RETURN_TYPES = ORIG_CTX_RETURN_TYPES
+ RETURN_NAMES = ORIG_CTX_RETURN_NAMES
+ FUNCTION = "merge"
+
+ def get_return_tuple(self, ctx):
+ """Returns the context data. Separated so it can be overridden."""
+ return get_orig_context_return_tuple(ctx)
+
+ def merge(self, **kwargs):
+ """Merges any non-null passed contexts; later ones overriding earlier."""
+ ctxs = [
+ value for key, value in kwargs.items()
+ if key.startswith('ctx_') and not is_context_empty(value)
+ ]
+ ctx = merge_new_context(*ctxs)
+
+ return self.get_return_tuple(ctx)
diff --git a/custom_nodes/rgthree-comfy/py/context_merge_big.py b/custom_nodes/rgthree-comfy/py/context_merge_big.py
new file mode 100644
index 0000000000000000000000000000000000000000..f4039e4eb87a3e6fedae2a871f13bb4537cc12a9
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context_merge_big.py
@@ -0,0 +1,16 @@
+"""The Context Switch (Big)."""
+from .constants import get_category, get_name
+from .context_utils import (ALL_CTX_RETURN_TYPES, ALL_CTX_RETURN_NAMES, get_context_return_tuple)
+from .context_merge import RgthreeContextMerge
+
+
+class RgthreeContextMergeBig(RgthreeContextMerge):
+ """The Context Merge Big node."""
+
+ NAME = get_name("Context Merge Big")
+ RETURN_TYPES = ALL_CTX_RETURN_TYPES
+ RETURN_NAMES = ALL_CTX_RETURN_NAMES
+
+ def get_return_tuple(self, ctx):
+ """Returns the context data. Separated so it can be overridden."""
+ return get_context_return_tuple(ctx)
diff --git a/custom_nodes/rgthree-comfy/py/context_switch.py b/custom_nodes/rgthree-comfy/py/context_switch.py
new file mode 100644
index 0000000000000000000000000000000000000000..67e02e1452da99b4df57f0d00a3fd6963a5d48ce
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context_switch.py
@@ -0,0 +1,36 @@
+"""The original Context Switch."""
+from .constants import get_category, get_name
+from .context_utils import (ORIG_CTX_RETURN_TYPES, ORIG_CTX_RETURN_NAMES, is_context_empty,
+ get_orig_context_return_tuple)
+from .utils import FlexibleOptionalInputType
+
+
+class RgthreeContextSwitch:
+ """The (original) Context Switch node."""
+
+ NAME = get_name("Context Switch")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType("RGTHREE_CONTEXT"),
+ }
+
+ RETURN_TYPES = ORIG_CTX_RETURN_TYPES
+ RETURN_NAMES = ORIG_CTX_RETURN_NAMES
+ FUNCTION = "switch"
+
+ def get_return_tuple(self, ctx):
+ """Returns the context data. Separated so it can be overridden."""
+ return get_orig_context_return_tuple(ctx)
+
+ def switch(self, **kwargs):
+ """Chooses the first non-empty Context to output."""
+ ctx = None
+ for key, value in kwargs.items():
+ if key.startswith('ctx_') and not is_context_empty(value):
+ ctx = value
+ break
+ return self.get_return_tuple(ctx)
diff --git a/custom_nodes/rgthree-comfy/py/context_switch_big.py b/custom_nodes/rgthree-comfy/py/context_switch_big.py
new file mode 100644
index 0000000000000000000000000000000000000000..4382e3027641d8a546ef509be824e18533376551
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context_switch_big.py
@@ -0,0 +1,16 @@
+"""The Context Switch (Big)."""
+from .constants import get_category, get_name
+from .context_utils import (ALL_CTX_RETURN_TYPES, ALL_CTX_RETURN_NAMES, get_context_return_tuple)
+from .context_switch import RgthreeContextSwitch
+
+
+class RgthreeContextSwitchBig(RgthreeContextSwitch):
+ """The Context Switch Big node."""
+
+ NAME = get_name("Context Switch Big")
+ RETURN_TYPES = ALL_CTX_RETURN_TYPES
+ RETURN_NAMES = ALL_CTX_RETURN_NAMES
+
+ def get_return_tuple(self, ctx):
+ """Overrides the RgthreeContextSwitch `get_return_tuple` to return big context data."""
+ return get_context_return_tuple(ctx)
diff --git a/custom_nodes/rgthree-comfy/py/context_utils.py b/custom_nodes/rgthree-comfy/py/context_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..6c2e68478fce2cd97042af8a9aed2ea84ef78b3e
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/context_utils.py
@@ -0,0 +1,118 @@
+"""A set of constants and utilities for handling contexts.
+
+Sets up the inputs and outputs for the Context going forward, with additional functions for
+creating and exporting context objects.
+"""
+import comfy.samplers
+import folder_paths
+
+_all_context_input_output_data = {
+ "base_ctx": ("base_ctx", "RGTHREE_CONTEXT", "CONTEXT"),
+ "model": ("model", "MODEL", "MODEL"),
+ "clip": ("clip", "CLIP", "CLIP"),
+ "vae": ("vae", "VAE", "VAE"),
+ "positive": ("positive", "CONDITIONING", "POSITIVE"),
+ "negative": ("negative", "CONDITIONING", "NEGATIVE"),
+ "latent": ("latent", "LATENT", "LATENT"),
+ "images": ("images", "IMAGE", "IMAGE"),
+ "seed": ("seed", "INT", "SEED"),
+ "steps": ("steps", "INT", "STEPS"),
+ "step_refiner": ("step_refiner", "INT", "STEP_REFINER"),
+ "cfg": ("cfg", "FLOAT", "CFG"),
+ "ckpt_name": ("ckpt_name", folder_paths.get_filename_list("checkpoints"), "CKPT_NAME"),
+ "sampler": ("sampler", comfy.samplers.KSampler.SAMPLERS, "SAMPLER"),
+ "scheduler": ("scheduler", comfy.samplers.KSampler.SCHEDULERS, "SCHEDULER"),
+ "clip_width": ("clip_width", "INT", "CLIP_WIDTH"),
+ "clip_height": ("clip_height", "INT", "CLIP_HEIGHT"),
+ "text_pos_g": ("text_pos_g", "STRING", "TEXT_POS_G"),
+ "text_pos_l": ("text_pos_l", "STRING", "TEXT_POS_L"),
+ "text_neg_g": ("text_neg_g", "STRING", "TEXT_NEG_G"),
+ "text_neg_l": ("text_neg_l", "STRING", "TEXT_NEG_L"),
+ "mask": ("mask", "MASK", "MASK"),
+ "control_net": ("control_net", "CONTROL_NET", "CONTROL_NET"),
+}
+
+force_input_types = ["INT", "STRING", "FLOAT"]
+force_input_names = ["sampler", "scheduler", "ckpt_name"]
+
+
+def _create_context_data(input_list=None):
+ """Returns a tuple of context inputs, return types, and return names to use in a node"s def"""
+ if input_list is None:
+ input_list = _all_context_input_output_data.keys()
+ list_ctx_return_types = []
+ list_ctx_return_names = []
+ ctx_optional_inputs = {}
+ for inp in input_list:
+ data = _all_context_input_output_data[inp]
+ list_ctx_return_types.append(data[1])
+ list_ctx_return_names.append(data[2])
+ ctx_optional_inputs[data[0]] = tuple([data[1]] + ([{
+ "forceInput": True
+ }] if data[1] in force_input_types or data[0] in force_input_names else []))
+
+ ctx_return_types = tuple(list_ctx_return_types)
+ ctx_return_names = tuple(list_ctx_return_names)
+ return (ctx_optional_inputs, ctx_return_types, ctx_return_names)
+
+
+ALL_CTX_OPTIONAL_INPUTS, ALL_CTX_RETURN_TYPES, ALL_CTX_RETURN_NAMES = _create_context_data()
+
+_original_ctx_inputs_list = [
+ "base_ctx", "model", "clip", "vae", "positive", "negative", "latent", "images", "seed"
+]
+ORIG_CTX_OPTIONAL_INPUTS, ORIG_CTX_RETURN_TYPES, ORIG_CTX_RETURN_NAMES = _create_context_data(
+ _original_ctx_inputs_list)
+
+
+def new_context(base_ctx, **kwargs):
+ """Creates a new context from the provided data, with an optional base ctx to start."""
+ context = base_ctx if base_ctx is not None else None
+ new_ctx = {}
+ for key in _all_context_input_output_data:
+ if key == "base_ctx":
+ continue
+ v = kwargs[key] if key in kwargs else None
+ new_ctx[key] = v if v is not None else context[
+ key] if context is not None and key in context else None
+ return new_ctx
+
+
+def merge_new_context(*args):
+ """Creates a new context by merging provided contexts with the latter overriding same fields."""
+ new_ctx = {}
+ for key in _all_context_input_output_data:
+ if key == "base_ctx":
+ continue
+ v = None
+ # Move backwards through the passed contexts until we find a value and use it.
+ for ctx in reversed(args):
+ v = ctx[key] if not is_context_empty(ctx) and key in ctx else None
+ if v is not None:
+ break
+ new_ctx[key] = v
+ return new_ctx
+
+
+def get_context_return_tuple(ctx, inputs_list=None):
+ """Returns a tuple for returning in the order of the inputs list."""
+ if inputs_list is None:
+ inputs_list = _all_context_input_output_data.keys()
+ tup_list = [
+ ctx,
+ ]
+ for key in inputs_list:
+ if key == "base_ctx":
+ continue
+ tup_list.append(ctx[key] if ctx is not None and key in ctx else None)
+ return tuple(tup_list)
+
+
+def get_orig_context_return_tuple(ctx):
+ """Returns a tuple for returning from a node with only the original context keys."""
+ return get_context_return_tuple(ctx, _original_ctx_inputs_list)
+
+
+def is_context_empty(ctx):
+ """Checks if the provided ctx is None or contains just None values."""
+ return not ctx or all(v is None for v in ctx.values())
diff --git a/custom_nodes/rgthree-comfy/py/display_any.py b/custom_nodes/rgthree-comfy/py/display_any.py
new file mode 100644
index 0000000000000000000000000000000000000000..19238dbab0cc886cbde4d0106724cd3bc95ed085
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/display_any.py
@@ -0,0 +1,77 @@
+import json
+from .constants import get_category, get_name
+from .utils import any_type, get_dict_value
+
+
+class RgthreeDisplayAny:
+ """Display any data node."""
+
+ NAME = get_name('Display Any')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "source": (any_type, {}),
+ },
+ "hidden": {
+ "unique_id": "UNIQUE_ID",
+ "extra_pnginfo": "EXTRA_PNGINFO",
+ },
+ }
+
+ RETURN_TYPES = ()
+ FUNCTION = "main"
+ OUTPUT_NODE = True
+
+ def main(self, source=None, unique_id=None, extra_pnginfo=None):
+ value = 'None'
+ if isinstance(source, str):
+ value = source
+ elif isinstance(source, (int, float, bool)):
+ value = str(source)
+ elif source is not None:
+ try:
+ value = json.dumps(source)
+ except Exception:
+ try:
+ value = str(source)
+ except Exception:
+ value = 'source exists, but could not be serialized.'
+
+ # Save the output to the pnginfo so it's pre-filled when loading the data.
+ if extra_pnginfo and unique_id:
+ for node in get_dict_value(extra_pnginfo, 'workflow.nodes', []):
+ if str(node['id']) == str(unique_id):
+ node['widgets_values'] = [value]
+ break
+
+ return {"ui": {"text": (value,)}}
+
+
+class RgthreeDisplayInt:
+ """Old DisplayInt node.
+
+ Can be ported over to DisplayAny if https://github.com/comfyanonymous/ComfyUI/issues/1527 fixed.
+ """
+
+ NAME = get_name('Display Int')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "input": ("INT", {
+ "forceInput": True
+ }),
+ },
+ }
+
+ RETURN_TYPES = ()
+ FUNCTION = "main"
+ OUTPUT_NODE = True
+
+ def main(self, input=None):
+ return {"ui": {"text": (input,)}}
diff --git a/custom_nodes/rgthree-comfy/py/dynamic_context.py b/custom_nodes/rgthree-comfy/py/dynamic_context.py
new file mode 100644
index 0000000000000000000000000000000000000000..411210d0e0cbc3c038b8b9c0822b69658fc63c7d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/dynamic_context.py
@@ -0,0 +1,56 @@
+"""The Dynamic Context node."""
+from mimetypes import add_type
+from .constants import get_category, get_name
+from .utils import ByPassTypeTuple, FlexibleOptionalInputType
+
+
+class RgthreeDynamicContext:
+ """The Dynamic Context node.
+
+ Similar to the static Context and Context Big nodes, this allows users to add any number and
+ variety of inputs to a Dynamic Context node, and return the outputs by key name.
+ """
+
+ NAME = get_name("Dynamic Context")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name,missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType(add_type),
+ "hidden": {},
+ }
+
+ RETURN_TYPES = ByPassTypeTuple(("RGTHREE_DYNAMIC_CONTEXT",))
+ RETURN_NAMES = ByPassTypeTuple(("CONTEXT",))
+ FUNCTION = "main"
+
+ def main(self, **kwargs):
+ """Creates a new context from the provided data, with an optional base ctx to start.
+
+ This node takes a list of named inputs that are the named keys (with an optional "+ " prefix)
+ which are to be stored within the ctx dict as well as a list of keys contained in `output_keys`
+ to determine the list of output data.
+ """
+
+ base_ctx = kwargs.get('base_ctx', None)
+ output_keys = kwargs.get('output_keys', None)
+
+ new_ctx = base_ctx.copy() if base_ctx is not None else {}
+
+ for key_raw, value in kwargs.items():
+ if key_raw in ['base_ctx', 'output_keys']:
+ continue
+ key = key_raw.upper()
+ if key.startswith('+ '):
+ key = key[2:]
+ new_ctx[key] = value
+
+ print(new_ctx)
+
+ res = [new_ctx]
+ output_keys = output_keys.split(',') if output_keys is not None else []
+ for key in output_keys:
+ res.append(new_ctx[key] if key in new_ctx else None)
+ return tuple(res)
diff --git a/custom_nodes/rgthree-comfy/py/dynamic_context_switch.py b/custom_nodes/rgthree-comfy/py/dynamic_context_switch.py
new file mode 100644
index 0000000000000000000000000000000000000000..3d1b9e6a7eb1a592db3941298e57ff0c38dfb5e0
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/dynamic_context_switch.py
@@ -0,0 +1,39 @@
+"""The original Context Switch."""
+from .constants import get_category, get_name
+from .context_utils import is_context_empty
+from .utils import ByPassTypeTuple, FlexibleOptionalInputType
+
+
+class RgthreeDynamicContextSwitch:
+ """The initial Context Switch node."""
+
+ NAME = get_name("Dynamic Context Switch")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType("RGTHREE_DYNAMIC_CONTEXT"),
+ }
+
+ RETURN_TYPES = ByPassTypeTuple(("RGTHREE_DYNAMIC_CONTEXT",))
+ RETURN_NAMES = ByPassTypeTuple(("CONTEXT",))
+ FUNCTION = "switch"
+
+ def switch(self, **kwargs):
+ """Chooses the first non-empty Context to output."""
+
+ output_keys = kwargs.get('output_keys', None)
+
+ ctx = None
+ for key, value in kwargs.items():
+ if key.startswith('ctx_') and not is_context_empty(value):
+ ctx = value
+ break
+
+ res = [ctx]
+ output_keys = output_keys.split(',') if output_keys is not None else []
+ for key in output_keys:
+ res.append(ctx[key] if ctx is not None and key in ctx else None)
+ return tuple(res)
diff --git a/custom_nodes/rgthree-comfy/py/image_comparer.py b/custom_nodes/rgthree-comfy/py/image_comparer.py
new file mode 100644
index 0000000000000000000000000000000000000000..079ebdbf6051fa86cf89f75f55f8502904d0c93f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/image_comparer.py
@@ -0,0 +1,42 @@
+from nodes import PreviewImage
+
+from .constants import get_category, get_name
+
+
+class RgthreeImageComparer(PreviewImage):
+ """A node that compares two images in the UI."""
+
+ NAME = get_name('Image Comparer')
+ CATEGORY = get_category()
+ FUNCTION = "compare_images"
+ DESCRIPTION = "Compares two images with a hover slider, or click from properties."
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": {
+ "image_a": ("IMAGE",),
+ "image_b": ("IMAGE",),
+ },
+ "hidden": {
+ "prompt": "PROMPT",
+ "extra_pnginfo": "EXTRA_PNGINFO"
+ },
+ }
+
+ def compare_images(self,
+ image_a=None,
+ image_b=None,
+ filename_prefix="rgthree.compare.",
+ prompt=None,
+ extra_pnginfo=None):
+
+ result = { "ui": { "a_images":[], "b_images": [] } }
+ if image_a is not None and len(image_a) > 0:
+ result['ui']['a_images'] = self.save_images(image_a, filename_prefix, prompt, extra_pnginfo)['ui']['images']
+
+ if image_b is not None and len(image_b) > 0:
+ result['ui']['b_images'] = self.save_images(image_b, filename_prefix, prompt, extra_pnginfo)['ui']['images']
+
+ return result
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/py/image_inset_crop.py b/custom_nodes/rgthree-comfy/py/image_inset_crop.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f5f29adf825494dc91dd00519d8d2af287cce19
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/image_inset_crop.py
@@ -0,0 +1,93 @@
+"""Image Inset Crop, with percentages."""
+from .log import log_node_info
+from .constants import get_category, get_name
+from nodes import MAX_RESOLUTION
+
+
+def get_new_bounds(width, height, left, right, top, bottom):
+ """Returns the new bounds for an image with inset crop data."""
+ left = 0 + left
+ right = width - right
+ top = 0 + top
+ bottom = height - bottom
+ return (left, right, top, bottom)
+
+
+class RgthreeImageInsetCrop:
+ """Image Inset Crop, with percentages."""
+
+ NAME = get_name('Image Inset Crop')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "measurement": (['Pixels', 'Percentage'],),
+ "left": ("INT", {
+ "default": 0,
+ "min": 0,
+ "max": MAX_RESOLUTION,
+ "step": 8
+ }),
+ "right": ("INT", {
+ "default": 0,
+ "min": 0,
+ "max": MAX_RESOLUTION,
+ "step": 8
+ }),
+ "top": ("INT", {
+ "default": 0,
+ "min": 0,
+ "max": MAX_RESOLUTION,
+ "step": 8
+ }),
+ "bottom": ("INT", {
+ "default": 0,
+ "min": 0,
+ "max": MAX_RESOLUTION,
+ "step": 8
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "crop"
+
+ # pylint: disable = too-many-arguments
+ def crop(self, measurement, left, right, top, bottom, image=None):
+ """Does the crop."""
+
+ _, height, width, _ = image.shape
+
+ if measurement == 'Percentage':
+ left = int(width - (width * (100 - left) / 100))
+ right = int(width - (width * (100 - right) / 100))
+ top = int(height - (height * (100 - top) / 100))
+ bottom = int(height - (height * (100 - bottom) / 100))
+
+ # Snap to 8 pixels
+ left = left // 8 * 8
+ right = right // 8 * 8
+ top = top // 8 * 8
+ bottom = bottom // 8 * 8
+
+ if left == 0 and right == 0 and bottom == 0 and top == 0:
+ return (image,)
+
+ inset_left, inset_right, inset_top, inset_bottom = get_new_bounds(width, height, left, right,
+ top, bottom)
+ if inset_top > inset_bottom:
+ raise ValueError(
+ f"Invalid cropping dimensions top ({inset_top}) exceeds bottom ({inset_bottom})")
+ if inset_left > inset_right:
+ raise ValueError(
+ f"Invalid cropping dimensions left ({inset_left}) exceeds right ({inset_right})")
+
+ log_node_info(
+ self.NAME, f'Cropping image {width}x{height} width inset by {inset_left},{inset_right}, ' +
+ f'and height inset by {inset_top}, {inset_bottom}')
+ image = image[:, inset_top:inset_bottom, inset_left:inset_right, :]
+
+ return (image,)
diff --git a/custom_nodes/rgthree-comfy/py/image_or_latent_size.py b/custom_nodes/rgthree-comfy/py/image_or_latent_size.py
new file mode 100644
index 0000000000000000000000000000000000000000..65f62f86fcfdfc5caedfddf773cb808f796bbef1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/image_or_latent_size.py
@@ -0,0 +1,31 @@
+from .utils import FlexibleOptionalInputType, any_type
+from .constants import get_category, get_name
+
+
+class RgthreeImageOrLatentSize:
+ """The ImageOrLatentSize Node."""
+
+ NAME = get_name('Image or Latent Size')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType(any_type),
+ }
+
+ RETURN_TYPES = ("INT", "INT")
+ RETURN_NAMES = ('WIDTH', 'HEIGHT')
+ FUNCTION = "main"
+
+ def main(self, **kwargs):
+ """Does the node's work."""
+ image_or_latent_or_mask = kwargs.get('input', None)
+
+ if isinstance(image_or_latent_or_mask, dict) and 'samples' in image_or_latent_or_mask:
+ count, _, height, width = image_or_latent_or_mask['samples'].shape
+ return (width * 8, height * 8)
+
+ batch, height, width, channel = image_or_latent_or_mask.shape
+ return (width, height)
diff --git a/custom_nodes/rgthree-comfy/py/image_resize.py b/custom_nodes/rgthree-comfy/py/image_resize.py
new file mode 100644
index 0000000000000000000000000000000000000000..a21001b36ac8f6de616d68c2a9827d791acff2ee
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/image_resize.py
@@ -0,0 +1,117 @@
+import torch
+import comfy.utils
+import nodes
+
+from .constants import get_category, get_name
+
+
+class RgthreeImageResize:
+ """Image Resize."""
+
+ NAME = get_name("Image Resize")
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "measurement": (["pixels", "percentage"],),
+ "width": (
+ "INT", {
+ "default": 0,
+ "min": 0,
+ "max": nodes.MAX_RESOLUTION,
+ "step": 1,
+ "tooltip": (
+ "The width of the desired resize. A pixel value if measurement is 'pixels' or a"
+ " 100% scale percentage value if measurement is 'percentage'. Passing '0' will"
+ " calculate the dimension based on the height."
+ ),
+ },
+ ),
+ "height": ("INT", {
+ "default": 0,
+ "min": 0,
+ "max": nodes.MAX_RESOLUTION,
+ "step": 1
+ }),
+ "fit": (["crop", "pad", "contain"], {
+ "tooltip": (
+ "'crop' resizes so the image covers the desired width and height, and center-crops the"
+ " excess, returning exactly the desired width and height."
+ "\n'pad' resizes so the image fits inside the desired width and height, and fills the"
+ " empty space returning exactly the desired width and height."
+ "\n'contain' resizes so the image fits inside the desired width and height, and"
+ " returns the image with it's new size, with one side liekly smaller than the desired."
+ "\n\nNote, if either width or height is '0', the effective fit is 'contain'."
+ )
+ },
+ ),
+ "method": (nodes.ImageScale.upscale_methods,),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE", "INT", "INT",)
+ RETURN_NAMES = ("IMAGE", "WIDTH", "HEIGHT",)
+ FUNCTION = "main"
+ DESCRIPTION = """Resize the image."""
+
+ def main(self, image, measurement, width, height, method, fit):
+ """Resizes the image."""
+ _, H, W, _ = image.shape
+
+ if measurement == "percentage":
+ width = round(width * W / 100)
+ height = round(height * H / 100)
+
+ if (width == 0 and height == 0) or (width == W and height == H):
+ return (image, W, H)
+
+ # If one dimension is 0, then calculate the desired value from the ratio of the set dimension.
+ # This also implies a 'contain' fit since the width and height will be scaled with a locked
+ # aspect ratio.
+ if width == 0 or height == 0:
+ width = round(height / H * W) if width == 0 else width
+ height = round(width / W * H) if height == 0 else height
+ fit = "contain"
+
+ # At this point, width and height are our output height, but our resize sizes will be different.
+ resized_width = width
+ resized_height = height
+ if fit == "crop":
+ # If we resize against the opposite ratio, then choose the ratio that has the overhang.
+ if (height / H * W) > width:
+ resized_width = round(height / H * W)
+ elif (width / W * H) > height:
+ resized_height = round(width / W * H)
+ elif fit == "contain" or fit == "pad":
+ # If we resize against the opposite ratio, then choose the ratio that has the overhang.
+ if (height / H * W) > width:
+ resized_height = round(width / W * H)
+ elif (width / W * H) > height:
+ resized_width = round(height / H * W)
+
+ out_image = comfy.utils.common_upscale(
+ image.clone().movedim(-1, 1), resized_width, resized_height, method, crop="disabled"
+ ).movedim(1, -1)
+ OB, OH, OW, OC = out_image.shape
+
+ if fit != "contain":
+ # First, we crop, then we pad; no need to check fit (other than not 'contain') since the size
+ # should already be correct.
+ if OW > width:
+ out_image = out_image.narrow(-2, (OW - width) // 2, width)
+ if OH > height:
+ out_image = out_image.narrow(-3, (OH - height) // 2, height)
+
+ OB, OH, OW, OC = out_image.shape
+ if width != OW or height != OH:
+ padded_image = torch.zeros((OB, height, width, OC), dtype=image.dtype, device=image.device)
+ x = (width - OW) // 2
+ y = (height - OH) // 2
+ for b in range(OB):
+ padded_image[b, y:y + OH, x:x + OW, :] = out_image[b]
+ out_image = padded_image
+
+ return (out_image, out_image.shape[2], out_image.shape[1])
diff --git a/custom_nodes/rgthree-comfy/py/ksampler_config.py b/custom_nodes/rgthree-comfy/py/ksampler_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..043ea49e4d09fe1b8fdae92d2d95bb6ee324c717
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/ksampler_config.py
@@ -0,0 +1,56 @@
+"""Some basic config stuff I use for SDXL."""
+
+from .constants import get_category, get_name
+from nodes import MAX_RESOLUTION
+import comfy.samplers
+
+
+class RgthreeKSamplerConfig:
+ """Some basic config stuff I started using for SDXL, but useful in other spots too."""
+
+ NAME = get_name('KSampler Config')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "steps_total": ("INT", {
+ "default": 30,
+ "min": 1,
+ "max": MAX_RESOLUTION,
+ "step": 1,
+ }),
+ "refiner_step": ("INT", {
+ "default": 24,
+ "min": 1,
+ "max": MAX_RESOLUTION,
+ "step": 1,
+ }),
+ "cfg": ("FLOAT", {
+ "default": 8.0,
+ "min": 0.0,
+ "max": 100.0,
+ "step": 0.5,
+ }),
+ "sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
+ "scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
+ #"refiner_ascore_pos": ("FLOAT", {"default": 6.0, "min": 0.0, "max": 1000.0, "step": 0.01}),
+ #"refiner_ascore_neg": ("FLOAT", {"default": 6.0, "min": 0.0, "max": 1000.0, "step": 0.01}),
+ },
+ }
+
+ RETURN_TYPES = ("INT", "INT", "FLOAT", comfy.samplers.KSampler.SAMPLERS,
+ comfy.samplers.KSampler.SCHEDULERS)
+ RETURN_NAMES = ("STEPS", "REFINER_STEP", "CFG", "SAMPLER", "SCHEDULER")
+ FUNCTION = "main"
+
+ def main(self, steps_total, refiner_step, cfg, sampler_name, scheduler):
+ """main"""
+ return (
+ steps_total,
+ refiner_step,
+ cfg,
+ sampler_name,
+ scheduler,
+ )
diff --git a/custom_nodes/rgthree-comfy/py/log.py b/custom_nodes/rgthree-comfy/py/log.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f41675ef6ccdb610821f147cb860c7b720f3eab
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/log.py
@@ -0,0 +1,100 @@
+import datetime
+import time
+from .pyproject import NAME
+
+# https://stackoverflow.com/questions/4842424/list-of-ansi-color-escape-sequences
+# https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
+COLORS = {
+ 'BLACK': '\33[30m',
+ 'RED': '\33[31m',
+ 'GREEN': '\33[32m',
+ 'YELLOW': '\33[33m',
+ 'BLUE': '\33[34m',
+ 'MAGENTA': '\33[35m',
+ 'CYAN': '\33[36m',
+ 'WHITE': '\33[37m',
+ 'GREY': '\33[90m',
+ 'BRIGHT_RED': '\33[91m',
+ 'BRIGHT_GREEN': '\33[92m',
+ 'BRIGHT_YELLOW': '\33[93m',
+ 'BRIGHT_BLUE': '\33[94m',
+ 'BRIGHT_MAGENTA': '\33[95m',
+ 'BRIGHT_CYAN': '\33[96m',
+ 'BRIGHT_WHITE': '\33[97m',
+ # Styles.
+ 'RESET': '\33[0m', # Note, Portainer doesn't like 00 here, so we'll use 0. Should be fine...
+ 'BOLD': '\33[01m',
+ 'NORMAL': '\33[22m',
+ 'ITALIC': '\33[03m',
+ 'UNDERLINE': '\33[04m',
+ 'BLINK': '\33[05m',
+ 'BLINK2': '\33[06m',
+ 'SELECTED': '\33[07m',
+ # Backgrounds
+ 'BG_BLACK': '\33[40m',
+ 'BG_RED': '\33[41m',
+ 'BG_GREEN': '\33[42m',
+ 'BG_YELLOW': '\33[43m',
+ 'BG_BLUE': '\33[44m',
+ 'BG_MAGENTA': '\33[45m',
+ 'BG_CYAN': '\33[46m',
+ 'BG_WHITE': '\33[47m',
+ 'BG_GREY': '\33[100m',
+ 'BG_BRIGHT_RED': '\33[101m',
+ 'BG_BRIGHT_GREEN': '\33[102m',
+ 'BG_BRIGHT_YELLOW': '\33[103m',
+ 'BG_BRIGHT_BLUE': '\33[104m',
+ 'BG_BRIGHT_MAGENTA': '\33[105m',
+ 'BG_BRIGHT_CYAN': '\33[106m',
+ 'BG_BRIGHT_WHITE': '\33[107m',
+}
+
+
+def log_node_success(node_name, message, msg_color='RESET'):
+ """Logs a success message."""
+ _log_node("BRIGHT_GREEN", node_name, message, msg_color=msg_color)
+
+
+def log_node_info(node_name, message, msg_color='RESET'):
+ """Logs an info message."""
+ _log_node("CYAN", node_name, message, msg_color=msg_color)
+
+
+def log_node_error(node_name, message, msg_color='RESET'):
+ """Logs an info message."""
+ _log_node("RED", node_name, message, msg_color=msg_color)
+
+
+def log_node_warn(node_name, message, msg_color='RESET'):
+ """Logs an warn message."""
+ _log_node("YELLOW", node_name, message, msg_color=msg_color)
+
+
+def log_node(node_name, message, msg_color='RESET'):
+ """Logs a message."""
+ _log_node("CYAN", node_name, message, msg_color=msg_color)
+
+
+def _log_node(color, node_name, message, msg_color='RESET'):
+ """Logs for a node message."""
+ log(message, color=color, prefix=node_name.replace(" (rgthree)", ""), msg_color=msg_color)
+
+LOGGED = {}
+
+def log(message, color=None, msg_color=None, prefix=None, id=None, at_most_secs=None):
+ """Basic logging."""
+ now = int(time.time())
+ if id:
+ if at_most_secs is None:
+ raise ValueError('at_most_secs should be set if an id is set.')
+ if id in LOGGED:
+ last_logged = LOGGED[id]
+ if now < last_logged + at_most_secs:
+ return
+ LOGGED[id] = now
+ color = COLORS[color] if color is not None and color in COLORS else COLORS["BRIGHT_GREEN"]
+ msg_color = COLORS[msg_color] if msg_color is not None and msg_color in COLORS else ''
+ prefix = f'[{prefix}]' if prefix is not None else ''
+ msg = f'{color}[{NAME}]{prefix}'
+ msg += f'{msg_color} {message}{COLORS["RESET"]}'
+ print(msg)
diff --git a/custom_nodes/rgthree-comfy/py/lora_stack.py b/custom_nodes/rgthree-comfy/py/lora_stack.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6d947aadcd7fcd3a49564695275041c8ac9e223
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/lora_stack.py
@@ -0,0 +1,46 @@
+from .constants import get_category, get_name
+from nodes import LoraLoader
+import folder_paths
+
+
+class RgthreeLoraLoaderStack:
+
+ NAME = get_name('Lora Loader Stack')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "model": ("MODEL",),
+ "clip": ("CLIP", ),
+
+ "lora_01": (['None'] + folder_paths.get_filename_list("loras"), ),
+ "strength_01":("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
+
+ "lora_02": (['None'] + folder_paths.get_filename_list("loras"), ),
+ "strength_02":("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
+
+ "lora_03": (['None'] + folder_paths.get_filename_list("loras"), ),
+ "strength_03":("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
+
+ "lora_04": (['None'] + folder_paths.get_filename_list("loras"), ),
+ "strength_04":("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
+ }
+ }
+
+ RETURN_TYPES = ("MODEL", "CLIP")
+ FUNCTION = "load_lora"
+
+ def load_lora(self, model, clip, lora_01, strength_01, lora_02, strength_02, lora_03, strength_03, lora_04, strength_04):
+ if lora_01 != "None" and strength_01 != 0:
+ model, clip = LoraLoader().load_lora(model, clip, lora_01, strength_01, strength_01)
+ if lora_02 != "None" and strength_02 != 0:
+ model, clip = LoraLoader().load_lora(model, clip, lora_02, strength_02, strength_02)
+ if lora_03 != "None" and strength_03 != 0:
+ model, clip = LoraLoader().load_lora(model, clip, lora_03, strength_03, strength_03)
+ if lora_04 != "None" and strength_04 != 0:
+ model, clip = LoraLoader().load_lora(model, clip, lora_04, strength_04, strength_04)
+
+ return (model, clip)
+
diff --git a/custom_nodes/rgthree-comfy/py/power_lora_loader.py b/custom_nodes/rgthree-comfy/py/power_lora_loader.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d11ac547205a95aa6b100cc90816954146abd44
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/power_lora_loader.py
@@ -0,0 +1,101 @@
+import folder_paths
+
+from typing import Union
+
+from nodes import LoraLoader
+from .constants import get_category, get_name
+from .power_prompt_utils import get_lora_by_filename
+from .utils import FlexibleOptionalInputType, any_type
+from .server.utils_info import get_model_info_file_data
+from .log import log_node_warn
+
+NODE_NAME = get_name('Power Lora Loader')
+
+
+class RgthreePowerLoraLoader:
+ """ The Power Lora Loader is a powerful, flexible node to add multiple loras to a model/clip."""
+
+ NAME = NODE_NAME
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ },
+ # Since we will pass any number of loras in from the UI, this needs to always allow an
+ "optional": FlexibleOptionalInputType(type=any_type, data={
+ "model": ("MODEL",),
+ "clip": ("CLIP",),
+ }),
+ "hidden": {},
+ }
+
+ RETURN_TYPES = ("MODEL", "CLIP")
+ RETURN_NAMES = ("MODEL", "CLIP")
+ FUNCTION = "load_loras"
+
+ def load_loras(self, model=None, clip=None, **kwargs):
+ """Loops over the provided loras in kwargs and applies valid ones."""
+ for key, value in kwargs.items():
+ key = key.upper()
+ if key.startswith('LORA_') and 'on' in value and 'lora' in value and 'strength' in value:
+ strength_model = value['strength']
+ # If we just passed one strength value, then use it for both, if we passed a strengthTwo
+ # as well, then our `strength` will be for the model, and `strengthTwo` for clip.
+ strength_clip = value['strengthTwo'] if 'strengthTwo' in value else None
+ if clip is None:
+ if strength_clip is not None and strength_clip != 0:
+ log_node_warn(NODE_NAME, 'Recieved clip strength eventhough no clip supplied!')
+ strength_clip = 0
+ else:
+ strength_clip = strength_clip if strength_clip is not None else strength_model
+ if value['on'] and (strength_model != 0 or strength_clip != 0):
+ lora = get_lora_by_filename(value['lora'], log_node=self.NAME)
+ if model is not None and lora is not None:
+ model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip)
+
+ return (model, clip)
+
+ @classmethod
+ def get_enabled_loras_from_prompt_node(cls,
+ prompt_node: dict) -> list[dict[str, Union[str, float]]]:
+ """Gets enabled loras of a node within a server prompt."""
+ result = []
+ for name, lora in prompt_node['inputs'].items():
+ if name.startswith('lora_') and lora['on']:
+ lora_file = get_lora_by_filename(lora['lora'], log_node=cls.NAME)
+ if lora_file is not None: # Add the same safety check
+ lora_dict = {
+ 'name': lora['lora'],
+ 'strength': lora['strength'],
+ 'path': folder_paths.get_full_path("loras", lora_file)
+ }
+ if 'strengthTwo' in lora:
+ lora_dict['strength_clip'] = lora['strengthTwo']
+ result.append(lora_dict)
+ return result
+
+ @classmethod
+ def get_enabled_triggers_from_prompt_node(cls, prompt_node: dict, max_each: int = 1):
+ """Gets trigger words up to the max for enabled loras of a node within a server prompt."""
+ loras = [l['name'] for l in cls.get_enabled_loras_from_prompt_node(prompt_node)]
+ trained_words = []
+ for lora in loras:
+ info = get_model_info_file_data(lora, 'loras', default={})
+ if not info or not info.keys():
+ log_node_warn(
+ NODE_NAME,
+ f'No info found for lora {lora} when grabbing triggers. Have you generated an info file'
+ ' from the Power Lora Loader "Show Info" dialog?'
+ )
+ continue
+ if 'trainedWords' not in info or not info['trainedWords']:
+ log_node_warn(
+ NODE_NAME,
+ f'No trained words for lora {lora} when grabbing triggers. Have you fetched data from'
+ 'civitai or manually added words?'
+ )
+ continue
+ trained_words += [w for wi in info['trainedWords'][:max_each] if (wi and (w := wi['word']))]
+ return trained_words
diff --git a/custom_nodes/rgthree-comfy/py/power_primitive.py b/custom_nodes/rgthree-comfy/py/power_primitive.py
new file mode 100644
index 0000000000000000000000000000000000000000..c8deea1c8bbe4d0e48c16b9b535f0478f3cbae04
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/power_primitive.py
@@ -0,0 +1,83 @@
+import re
+
+from .utils import FlexibleOptionalInputType, any_type
+from .constants import get_category, get_name
+
+
+def cast_to_str(x):
+ """Handles our cast to a string."""
+ if x is None:
+ return ''
+ try:
+ return str(x)
+ except (ValueError, TypeError):
+ return ''
+
+
+def cast_to_float(x):
+ """Handles our cast to a float."""
+ try:
+ return float(x)
+ except (ValueError, TypeError):
+ return 0.0
+
+
+def cast_to_bool(x):
+ """Handles our cast to a bool."""
+ try:
+ return bool(float(x))
+ except (ValueError, TypeError):
+ return str(x).lower() not in ['0', 'false', 'null', 'none', '']
+
+
+output_to_type = {
+ 'STRING': {
+ 'cast': cast_to_str,
+ 'null': '',
+ },
+ 'FLOAT': {
+ 'cast': cast_to_float,
+ 'null': 0.0,
+ },
+ 'INT': {
+ 'cast': lambda x: int(cast_to_float(x)),
+ 'null': 0,
+ },
+ 'BOOLEAN': {
+ 'cast': cast_to_bool,
+ 'null': False,
+ },
+ # This can be removed soon, there was a bug where this should have been BOOLEAN
+ 'BOOL': {
+ 'cast': cast_to_bool,
+ 'null': False,
+ },
+}
+
+
+class RgthreePowerPrimitive:
+ """The Power Primitive Node."""
+
+ NAME = get_name('Power Primitive')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {},
+ "optional": FlexibleOptionalInputType(any_type),
+ }
+
+ RETURN_TYPES = (any_type,)
+ RETURN_NAMES = ('*',)
+ FUNCTION = "main"
+
+ def main(self, **kwargs):
+ """Outputs the expected type."""
+ output = kwargs.get('value', None)
+ output_type = re.sub(r'\s*\([^\)]*\)\s*$', '', kwargs.get('type', ''))
+ output_type = output_to_type[output_type]
+ cast = output_type['cast']
+ output = cast(output)
+
+ return (output,)
diff --git a/custom_nodes/rgthree-comfy/py/power_prompt.py b/custom_nodes/rgthree-comfy/py/power_prompt.py
new file mode 100644
index 0000000000000000000000000000000000000000..5fa75bf084f6a6b089adcec549886d5942004af7
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/power_prompt.py
@@ -0,0 +1,95 @@
+import os
+
+from .log import log_node_warn, log_node_info, log_node_success
+
+from .constants import get_category, get_name
+from .power_prompt_utils import get_and_strip_loras
+from nodes import LoraLoader, CLIPTextEncode
+import folder_paths
+
+NODE_NAME = get_name('Power Prompt')
+
+
+class RgthreePowerPrompt:
+
+ NAME = NODE_NAME
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ # Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
+ # TODO: Rip out saved prompt input data
+ SAVED_PROMPTS_FILES=[]
+ SAVED_PROMPTS_CONTENT=[]
+ return {
+ 'required': {
+ 'prompt': ('STRING', {
+ 'multiline': True,
+ 'dynamicPrompts': True
+ }),
+ },
+ 'optional': {
+ "opt_model": ("MODEL",),
+ "opt_clip": ("CLIP",),
+ 'insert_lora': (['CHOOSE', 'DISABLE LORAS'] +
+ [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('loras')],),
+ 'insert_embedding': ([
+ 'CHOOSE',
+ ] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
+ 'insert_saved': ([
+ 'CHOOSE',
+ ] + SAVED_PROMPTS_FILES,),
+ },
+ 'hidden': {
+ 'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
+ }
+ }
+
+ RETURN_TYPES = (
+ 'CONDITIONING',
+ 'MODEL',
+ 'CLIP',
+ 'STRING',
+ )
+ RETURN_NAMES = (
+ 'CONDITIONING',
+ 'MODEL',
+ 'CLIP',
+ 'TEXT',
+ )
+ FUNCTION = 'main'
+
+ def main(self,
+ prompt,
+ opt_model=None,
+ opt_clip=None,
+ insert_lora=None,
+ insert_embedding=None,
+ insert_saved=None,
+ values_insert_saved=None):
+ if insert_lora == 'DISABLE LORAS':
+ prompt, loras, skipped, unfound = get_and_strip_loras(prompt, log_node=NODE_NAME, silent=True)
+ log_node_info(
+ NODE_NAME,
+ f'Disabling all found loras ({len(loras)}) and stripping lora tags for TEXT output.')
+ elif opt_model is not None and opt_clip is not None:
+ prompt, loras, skipped, unfound = get_and_strip_loras(prompt, log_node=NODE_NAME)
+ if len(loras) > 0:
+ for lora in loras:
+ opt_model, opt_clip = LoraLoader().load_lora(opt_model, opt_clip, lora['lora'],
+ lora['strength'], lora['strength'])
+ log_node_success(NODE_NAME, f'Loaded "{lora["lora"]}" from prompt')
+ log_node_info(NODE_NAME, f'{len(loras)} Loras processed; stripping tags for TEXT output.')
+ elif ']*?)(?::(-?\d*(?:\.\d*)?))?>'
+ lora_paths = folder_paths.get_filename_list('loras')
+
+ matches = re.findall(pattern, prompt)
+
+ loras = []
+ unfound_loras = []
+ skipped_loras = []
+ for match in matches:
+ tag_path = match[0]
+
+ strength = float(match[1] if len(match) > 1 and len(match[1]) else 1.0)
+ if strength == 0:
+ if not silent:
+ log_node_info(log_node, f'Skipping "{tag_path}" with strength of zero')
+ skipped_loras.append({'lora': tag_path, 'strength': strength})
+ continue
+
+ lora_path = get_lora_by_filename(tag_path, lora_paths, log_node=None if silent else log_node)
+ if lora_path is None:
+ unfound_loras.append({'lora': tag_path, 'strength': strength})
+ continue
+
+ loras.append({'lora': lora_path, 'strength': strength})
+
+ return (re.sub(pattern, '', prompt), loras, skipped_loras, unfound_loras)
+
+
+# pylint: disable = too-many-return-statements, too-many-branches
+def get_lora_by_filename(file_path, lora_paths=None, log_node=None):
+ """Returns a lora by filename, looking for exactl paths and then fuzzier matching."""
+ lora_paths = lora_paths if lora_paths is not None else folder_paths.get_filename_list('loras')
+
+ if file_path in lora_paths:
+ return file_path
+
+ lora_paths_no_ext = [os.path.splitext(x)[0] for x in lora_paths]
+
+ # See if we've entered the exact path, but without the extension
+ if file_path in lora_paths_no_ext:
+ found = lora_paths[lora_paths_no_ext.index(file_path)]
+ return found
+
+ # Same check, but ensure file_path is without extension.
+ file_path_force_no_ext = os.path.splitext(file_path)[0]
+ if file_path_force_no_ext in lora_paths_no_ext:
+ found = lora_paths[lora_paths_no_ext.index(file_path_force_no_ext)]
+ return found
+
+ # See if we passed just the name, without paths.
+ lora_filenames_only = [os.path.basename(x) for x in lora_paths]
+ if file_path in lora_filenames_only:
+ found = lora_paths[lora_filenames_only.index(file_path)]
+ if log_node is not None:
+ log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
+ return found
+
+ # Same, but force the input to be without paths
+ file_path_force_filename = os.path.basename(file_path)
+ lora_filenames_only = [os.path.basename(x) for x in lora_paths]
+ if file_path_force_filename in lora_filenames_only:
+ found = lora_paths[lora_filenames_only.index(file_path_force_filename)]
+ if log_node is not None:
+ log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
+ return found
+
+ # Check the filenames and without extension.
+ lora_filenames_and_no_ext = [os.path.splitext(os.path.basename(x))[0] for x in lora_paths]
+ if file_path in lora_filenames_and_no_ext:
+ found = lora_paths[lora_filenames_and_no_ext.index(file_path)]
+ if log_node is not None:
+ log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
+ return found
+
+ # And, one last forcing the input to be the same
+ file_path_force_filename_and_no_ext = os.path.splitext(os.path.basename(file_path))[0]
+ if file_path_force_filename_and_no_ext in lora_filenames_and_no_ext:
+ found = lora_paths[lora_filenames_and_no_ext.index(file_path_force_filename_and_no_ext)]
+ if log_node is not None:
+ log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
+ return found
+
+ # Finally, super fuzzy, we'll just check if the input exists in the path at all.
+ for index, lora_path in enumerate(lora_paths):
+ if file_path in lora_path:
+ found = lora_paths[index]
+ if log_node is not None:
+ log_node_warn(log_node, f'Fuzzy-matched Lora input "{file_path}" to "{found}".')
+ return found
+
+ if log_node is not None:
+ log_node_warn(log_node, f'Lora "{file_path}" not found, skipping.')
+
+ return None
diff --git a/custom_nodes/rgthree-comfy/py/power_puter.py b/custom_nodes/rgthree-comfy/py/power_puter.py
new file mode 100644
index 0000000000000000000000000000000000000000..80cd7b016f4265d1784e961eaa2b9cf7477572cf
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/power_puter.py
@@ -0,0 +1,848 @@
+"""The Power Puter is a powerful node that can compute and evaluate Python-like code safely allowing
+for complex operations for primitives and workflow items for output. From string concatenation, to
+math operations, list comprehension, and node value output.
+
+Originally based off https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/aac13aa7ce35b07d43633c3bbe654a38c00d74f5/py/math_expression.py
+under an MIT License https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/aac13aa7ce35b07d43633c3bbe654a38c00d74f5/LICENSE
+"""
+
+import math
+import ast
+import json
+import random
+import dataclasses
+import re
+import time
+import operator as op
+import datetime
+import numpy as np
+import hashlib
+
+from typing import Any, Callable, Iterable, Optional, Union
+from types import MappingProxyType
+
+from .constants import get_category, get_name
+from .utils import ByPassTypeTuple, FlexibleOptionalInputType, any_type, get_dict_value
+from .log import log_node_error, log_node_warn, log_node_info
+
+from .power_lora_loader import RgthreePowerLoraLoader
+
+from nodes import ImageBatch
+from comfy_extras.nodes_latent import LatentBatch
+
+
+class LoopBreak(Exception):
+ """A special error type that is caught in a loop for correct breaking behavior."""
+
+ def __init__(self):
+ super().__init__('Cannot use "break" outside of a loop.')
+
+
+class LoopContinue(Exception):
+ """A special error type that is caught in a loop for correct continue behavior."""
+
+ def __init__(self):
+ super().__init__('Cannot use "continue" outside of a loop.')
+
+
+@dataclasses.dataclass(frozen=True) # Note, kw_only=True is only python 3.10+
+class Function():
+ """Function data.
+
+ Attributes:
+ name: The name of the function as called from the node.
+ call: The callable (reference, lambda, etc), or a string if on _Puter instance.
+ args: A tuple that represents the minimum and maximum number of args (or arg for no limit).
+ """
+
+ name: str
+ call: Union[Callable, str]
+ args: tuple[int, Optional[int]]
+
+
+def purge_vram(purge_models=True):
+ """Purges vram and, optionally, unloads models."""
+ import gc
+ import torch
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ if purge_models:
+ import comfy
+ comfy.model_management.unload_all_models()
+ comfy.model_management.soft_empty_cache()
+
+
+def batch(*args):
+ """Batches multiple image or latents together."""
+
+ def check_is_latent(item) -> bool:
+ return isinstance(item, dict) and 'samples' in item
+
+ args = list(args)
+ result = args.pop(0)
+ is_latent = check_is_latent(result)
+ node = LatentBatch() if is_latent else ImageBatch()
+
+ for arg in args:
+ if is_latent != check_is_latent(arg):
+ raise ValueError(
+ f'batch() error: Expecting "{"LATENT" if is_latent else "IMAGE"}"'
+ f' but got "{"IMAGE" if is_latent else "LATENT"}".'
+ )
+ result = node.batch(result, arg)[0]
+ return result
+
+
+_BUILTIN_FN_PREFIX = '__rgthreefn.'
+
+
+def _get_built_in_fn_key(fn: Function) -> str:
+ """Returns a key for a built-in function."""
+ return f'{_BUILTIN_FN_PREFIX}{hash(fn.name)}'
+
+
+def _get_built_in_fn_by_key(fn_key: str):
+ """Returns the `Function` for the provided key (purposefully, not name)."""
+ if not fn_key.startswith(_BUILTIN_FN_PREFIX) or fn_key not in _BUILT_INS_BY_NAME_AND_KEY:
+ raise ValueError('No built in function found.')
+ return _BUILT_INS_BY_NAME_AND_KEY[fn_key]
+
+def sha264(message: str):
+ if isinstance(message, str):
+ return hashlib.sha256(message.encode()).hexdigest()
+ return None
+
+_BUILT_IN_FNS_LIST = [
+ Function(name="round", call=round, args=(1, 2)),
+ Function(name="ceil", call=math.ceil, args=(1, 1)),
+ Function(name="floor", call=math.floor, args=(1, 1)),
+ Function(name="sqrt", call=math.sqrt, args=(1, 1)),
+ Function(name="min", call=min, args=(2, None)),
+ Function(name="max", call=max, args=(2, None)),
+ Function(name=".random_int", call=random.randint, args=(2, 2)),
+ Function(name=".random_choice", call=random.choice, args=(1, 1)),
+ Function(name=".random_seed", call=random.seed, args=(1, 1)),
+ Function(name="re", call=re.compile, args=(1, 1)),
+ Function(name="len", call=len, args=(1, 1)),
+ Function(name="enumerate", call=enumerate, args=(1, 1)),
+ Function(name="range", call=range, args=(1, 3)),
+ # Casts
+ Function(name="int", call=int, args=(1, 1)),
+ Function(name="float", call=float, args=(1, 1)),
+ Function(name="str", call=str, args=(1, 1)),
+ Function(name="bool", call=bool, args=(1, 1)),
+ Function(name="list", call=list, args=(1, 1)),
+ Function(name="tuple", call=tuple, args=(1, 1)),
+ # Special
+ Function(name="dir", call=dir, args=(1, 1)),
+ Function(name="type", call=type, args=(1, 1)),
+ Function(name="print", call=print, args=(0, None)),
+ Function(name="sha264", call=sha264, args=(1, 1)),
+ # Comfy Specials
+ Function(name="node", call='_get_node', args=(0, 1)),
+ Function(name="nodes", call='_get_nodes', args=(0, 1)),
+ Function(name="input_node", call='_get_input_node', args=(0, 1)),
+ Function(name="purge_vram", call=purge_vram, args=(0, 1)),
+ Function(name="batch", call=batch, args=(2, None)),
+]
+
+_BUILT_INS_BY_NAME_AND_KEY = {
+ fn.name: fn for fn in _BUILT_IN_FNS_LIST
+} | {
+ key: fn for fn in _BUILT_IN_FNS_LIST if (key := _get_built_in_fn_key(fn))
+}
+
+_BUILT_INS = MappingProxyType(
+ {fn.name: key for fn in _BUILT_IN_FNS_LIST if (key := _get_built_in_fn_key(fn))} | {
+ 'random':
+ MappingProxyType({
+ 'int': _get_built_in_fn_key(_BUILT_INS_BY_NAME_AND_KEY['.random_int']),
+ 'choice': _get_built_in_fn_key(_BUILT_INS_BY_NAME_AND_KEY['.random_choice']),
+ 'seed': _get_built_in_fn_key(_BUILT_INS_BY_NAME_AND_KEY['.random_seed']),
+ }),
+ }
+)
+
+# A dict of types to blocked attributes/methods. Used to disallow file system access or other
+# invocations we may want to block. Necessary for any instance type that is possible to create from
+# the code or standard ComfyUI inputs.
+#
+# For instance, a user does not have access to the numpy module directly, so they cannot invoke
+# `numpy.save`. However, a user can access a numpy.ndarray instance from a tensor and, from there,
+# an attempt to call `tofile` or `dump` etc. would need to be blocked.
+_BLOCKED_METHODS_OR_ATTRS = MappingProxyType({np.ndarray: ['tofile', 'dump']})
+
+# Special functions by class type (called from the Attrs.)
+_SPECIAL_FUNCTIONS = {
+ RgthreePowerLoraLoader.NAME: {
+ # Get a list of the enabled loras from a power lora loader.
+ "loras": RgthreePowerLoraLoader.get_enabled_loras_from_prompt_node,
+ "triggers": RgthreePowerLoraLoader.get_enabled_triggers_from_prompt_node,
+ }
+}
+
+# Series of regex checks for usage of a non-deterministic function. Using these is fine, but means
+# the output can't be cached because it's either random, or is associated with another node that is
+# not connected to ours (like looking up a node in the prompt). Using these means downstream nodes
+# would always be run; that is fine for something like a final JSON output, but less so for a prompt
+# text.
+_NON_DETERMINISTIC_FUNCTION_CHECKS = [r'(? has_rand_int_or_choice.span()[0]:
+ log_node_warn(
+ _NODE_NAME,
+ f"Note, Power Puter (node #{kwargs['unique_id']}) cannot be cached b/c it's using a"
+ " non-deterministic function call. Matches function call for"
+ f" `{has_rand_int_or_choice.group(1)}`."
+ )
+ return time.time()
+ if has_rand_seed:
+ log_node_info(
+ _NODE_NAME,
+ f"Power Puter node #{kwargs['unique_id']} WILL be cached eventhough it's using"
+ f" a non-deterministic random call `{has_rand_int_or_choice.group(1)}` because it also"
+ f" calls `random.seed` first. NOTE: Please ensure that the seed value is deterministic."
+ )
+
+ return 42
+
+ def main(self, **kwargs):
+ """Does the nodes' work."""
+ code = kwargs['code']
+ unique_id = kwargs['unique_id']
+ pnginfo = kwargs['extra_pnginfo']
+ workflow = pnginfo["workflow"] if "workflow" in pnginfo else {"nodes": []}
+ prompt = kwargs['prompt']
+ dynprompt = kwargs['dynprompt']
+
+ outputs = get_dict_value(kwargs, 'outputs.outputs', None)
+ if not outputs:
+ output = kwargs.get('output', None)
+ if not output:
+ output = 'STRING'
+ outputs = [output]
+
+ ctx = {}
+ # Set variable names, defaulting to None instead of KeyErrors
+ for c in list('abcdefghijklmnopqrstuvwxyz'):
+ ctx[c] = kwargs[c] if c in kwargs else None
+
+ code = _update_code(kwargs['code'], unique_id=kwargs['unique_id'], log=True)
+
+ eva = _Puter(
+ code=code,
+ ctx=ctx,
+ workflow=workflow,
+ prompt=prompt,
+ dynprompt=dynprompt,
+ unique_id=unique_id
+ )
+ values = eva.execute()
+
+ # Check if we have multiple outputs that the returned value is a tuple and raise if not.
+ if len(outputs) > 1 and not isinstance(values, tuple):
+ t = re.sub(r'^<[a-z]*\s(.*?)>$', r'\1', str(type(values)))
+ msg = (
+ f"When using multiple node outputs, the value from the code should be a 'tuple' with the"
+ f" number of items equal to the number of outputs. But value from code was of type {t}."
+ )
+ log_node_error(_NODE_NAME, f'{msg}\n')
+ raise ValueError(msg)
+
+ if len(outputs) == 1:
+ values = (values,)
+
+ if len(values) > len(outputs):
+ log_node_warn(
+ _NODE_NAME,
+ f"Expected value from code to be tuple with {len(outputs)} items, but value from code had"
+ f" {len(values)} items. Extra values will be dropped."
+ )
+ elif len(values) < len(outputs):
+ log_node_warn(
+ _NODE_NAME,
+ f"Expected value from code to be tuple with {len(outputs)} items, but value from code had"
+ f" {len(values)} items. Extra outputs will be null."
+ )
+
+ # Now, we'll go over out return tuple, and cast as the output types.
+ response = []
+ for i, output in enumerate(outputs):
+ value = values[i] if len(values) > i else None
+ if value is not None:
+ if output == 'INT':
+ value = int(value)
+ elif output == 'FLOAT':
+ value = float(value)
+ # Accidentally defined "BOOL" when should have been "BOOLEAN."
+ # TODO: Can prob get rid of BOOl after a bit when UIs would be updated from sending
+ # BOOL incorrectly.
+ elif output in ('BOOL', 'BOOLEAN'):
+ value = bool(value)
+ elif output == 'STRING':
+ if isinstance(value, (dict, list)):
+ value = json.dumps(value, indent=2)
+ else:
+ value = str(value)
+ elif output == '*':
+ # Do nothing, the output will be passed as-is. This could be anything and it's up to the
+ # user to control the intended output, like passing through an input value, etc.
+ pass
+ response.append(value)
+ return tuple(response)
+
+
+class _Puter:
+ """The main computation evaluator, using ast.parse the code.
+
+ See https://www.basicexamples.com/example/python/ast for examples.
+ """
+
+ def __init__(self, *, code: str, ctx: dict[str, Any], workflow, prompt, dynprompt, unique_id):
+ ctx = ctx or {}
+ self._ctx = {**ctx}
+ self._code = code
+ self._workflow = workflow
+ self._prompt = prompt
+ self._unique_id = unique_id
+ self._dynprompt = dynprompt
+ # These are now expanded lazily when needed.
+ self._prompt_nodes = None
+ self._prompt_node = None
+
+ def execute(self, code=Optional[str]) -> Any:
+ """Evaluates a the code block."""
+
+ # Always store random state and initialize a new seed. We'll restore the state later.
+ initial_random_state = random.getstate()
+ random.seed(datetime.datetime.now().timestamp())
+ last_value = None
+ try:
+ code = code or self._code
+ node = ast.parse(self._code)
+ ctx = {**self._ctx}
+ for body in node.body:
+ last_value = self._eval_statement(body, ctx)
+ # If we got a return, then that's it folks.
+ if isinstance(body, ast.Return):
+ break
+ except:
+ random.setstate(initial_random_state)
+ raise
+ random.setstate(initial_random_state)
+ return last_value
+
+ def _get_prompt_nodes(self):
+ """Expands the prompt nodes lazily from the dynamic prompt.
+
+ https://github.com/comfyanonymous/ComfyUI/blob/fc657f471a29d07696ca16b566000e8e555d67d1/comfy_execution/graph.py#L22
+ """
+ if self._prompt_nodes is None:
+ self._prompt_nodes = []
+ if self._dynprompt:
+ all_ids = self._dynprompt.all_node_ids()
+ self._prompt_nodes = [{'id': k} | {**self._dynprompt.get_node(k)} for k in all_ids]
+ return self._prompt_nodes
+
+ def _get_prompt_node(self):
+ if self._prompt_nodes is None:
+ self._prompt_node = [n for n in self._get_prompt_nodes() if n['id'] == self._unique_id][0]
+ return self._prompt_node
+
+ def _get_nodes(self, node_id: Union[int, str, re.Pattern, None] = None) -> list[Any]:
+ """Get a list of the nodes that match the node_id, or all the nodes in the prompt."""
+ nodes = self._get_prompt_nodes().copy()
+ if not node_id:
+ return nodes
+
+ if isinstance(node_id, re.Pattern):
+ found = [n for n in nodes if re.search(node_id, get_dict_value(n, '_meta.title', ''))]
+ else:
+ node_id = str(node_id)
+ found = None
+ if re.match(r'\d+$', node_id):
+ found = [n for n in nodes if node_id == n['id']]
+ if not found:
+ found = [n for n in nodes if node_id == get_dict_value(n, '_meta.title', '')]
+ return found
+
+ def _get_node(self, node_id: Union[int, str, re.Pattern, None] = None) -> Union[Any, None]:
+ """Returns a prompt-node from the hidden prompt."""
+ if node_id is None:
+ return self._get_prompt_node()
+ nodes = self._get_nodes(node_id)
+ if nodes and len(nodes) > 1:
+ log_node_warn(_NODE_NAME, f"More than one node found for '{node_id}'. Returning first.")
+ return nodes[0] if nodes else None
+
+ def _get_input_node(self, input_name, node=None):
+ """Gets the (non-muted) node of an input connection from a node (default to the power puter)."""
+ node = node if node else self._get_prompt_node()
+ try:
+ connected_node_id = node['inputs'][input_name][0]
+ return [n for n in self._get_prompt_nodes() if n['id'] == connected_node_id][0]
+ except (TypeError, IndexError, KeyError):
+ log_node_warn(_NODE_NAME, f'No input node found for "{input_name}". ')
+ return None
+
+ def _eval_statement(self, stmt: ast.AST, ctx: dict, prev_stmt: Union[ast.AST, None] = None):
+ """Evaluates an ast.stmt."""
+
+ if '__returned__' in ctx:
+ return ctx['__returned__']
+
+ # print('\n\n----: _eval_statement')
+ # print(type(stmt))
+ # print(ctx)
+
+ if isinstance(stmt, (ast.FormattedValue, ast.Expr)):
+ return self._eval_statement(stmt.value, ctx=ctx)
+
+ if isinstance(stmt, (ast.Constant, ast.Num)):
+ return stmt.n
+
+ if isinstance(stmt, ast.BinOp):
+ left = self._eval_statement(stmt.left, ctx=ctx)
+ right = self._eval_statement(stmt.right, ctx=ctx)
+ return _OPERATORS[type(stmt.op)](left, right)
+
+ if isinstance(stmt, ast.BoolOp):
+ is_and = isinstance(stmt.op, ast.And)
+ is_or = isinstance(stmt.op, ast.Or)
+ stmt_value_eval = None
+ for stmt_value in stmt.values:
+ stmt_value_eval = self._eval_statement(stmt_value, ctx=ctx)
+ # If we're an and operator and have a falsyt value, then we stop and return. Likewise, if
+ # we're an or operator and have a truthy value, we can stop and return.
+ if (is_and and not stmt_value_eval) or (is_or and stmt_value_eval):
+ return stmt_value_eval
+ # Always return the last if we made it here w/o success.
+ return stmt_value_eval
+
+ if isinstance(stmt, ast.UnaryOp):
+ return _OPERATORS[type(stmt.op)](self._eval_statement(stmt.operand, ctx=ctx))
+
+ if isinstance(stmt, (ast.Attribute, ast.Subscript)):
+ # Like: node(14).inputs.sampler_name (Attribute)
+ # Like: node(14)['inputs']['sampler_name'] (Subscript)
+ item = self._eval_statement(stmt.value, ctx=ctx)
+ attr = None
+ # if hasattr(stmt, 'attr'):
+ if isinstance(stmt, ast.Attribute):
+ attr = stmt.attr
+ else:
+ # Slice could be a name or a constant; evaluate it
+ attr = self._eval_statement(stmt.slice, ctx=ctx)
+ # Check if we're blocking access to this attribute/method on this item type.
+ for typ, names in _BLOCKED_METHODS_OR_ATTRS.items():
+ if isinstance(item, typ) and isinstance(attr, str) and attr in names:
+ raise ValueError(f'Disallowed access to "{attr}" for type {typ}.')
+ try:
+ val = item[attr]
+ except (TypeError, IndexError, KeyError):
+ try:
+ val = getattr(item, attr)
+ except AttributeError:
+ # If we're a dict, then just return None instead of error; saves time.
+ if isinstance(item, dict):
+ # Any special cases in the _SPECIAL_FUNCTIONS
+ class_type = get_dict_value(item, "class_type")
+ if class_type in _SPECIAL_FUNCTIONS and attr in _SPECIAL_FUNCTIONS[class_type]:
+ val = _SPECIAL_FUNCTIONS[class_type][attr]
+ # If our previous statment was a Call, then send back a tuple of the callable and
+ # the evaluated item, and it will make the call; perhaps also adding other arguments
+ # only it knows about.
+ if isinstance(prev_stmt, ast.Call):
+ return (val, item)
+ val = val(item)
+ else:
+ val = None
+ else:
+ raise
+ return val
+
+ if isinstance(stmt, (ast.List, ast.Tuple)):
+ value = []
+ for elt in stmt.elts:
+ value.append(self._eval_statement(elt, ctx=ctx))
+ return tuple(value) if isinstance(stmt, ast.Tuple) else value
+
+ if isinstance(stmt, ast.Dict):
+ the_dict = {}
+ if stmt.keys:
+ if len(stmt.keys) != len(stmt.values):
+ raise ValueError('Expected same number of keys as values for dict.')
+ for i, k in enumerate(stmt.keys):
+ item_key = self._eval_statement(k, ctx=ctx)
+ item_value = self._eval_statement(stmt.values[i], ctx=ctx)
+ the_dict[item_key] = item_value
+ return the_dict
+
+ # f-strings: https://www.basicexamples.com/example/python/ast-JoinedStr
+ # Note, this will str() all evaluated items in the fstrings, and doesn't handle f-string
+ # directives, like padding, etc.
+ if isinstance(stmt, ast.JoinedStr):
+ vals = [str(self._eval_statement(v, ctx=ctx)) for v in stmt.values]
+ val = ''.join(vals)
+ return val
+
+ if isinstance(stmt, ast.Slice):
+ if not stmt.lower or not stmt.upper:
+ raise ValueError('Unhandled Slice w/o lower or upper.')
+ slice_lower = self._eval_statement(stmt.lower, ctx=ctx)
+ slice_upper = self._eval_statement(stmt.upper, ctx=ctx)
+ if stmt.step:
+ slice_step = self._eval_statement(stmt.step, ctx=ctx)
+ return slice(slice_lower, slice_upper, slice_step)
+ return slice(slice_lower, slice_upper)
+
+ if isinstance(stmt, ast.Name):
+ if stmt.id in ctx:
+ val = ctx[stmt.id]
+ return val
+ if stmt.id in _BUILT_INS:
+ val = _BUILT_INS[stmt.id]
+ return val
+ raise NameError(f"Name not found: {stmt.id}")
+
+ if isinstance(stmt, ast.For):
+ for_iter = self._eval_statement(stmt.iter, ctx=ctx)
+ for item in for_iter:
+ # Set the for var(s)
+ if isinstance(stmt.target, ast.Name):
+ ctx[stmt.target.id] = item
+ elif isinstance(stmt.target, ast.Tuple): # dict, like `for k, v in d.entries()`
+ for i, elt in enumerate(stmt.target.elts):
+ ctx[elt.id] = item[i]
+ bodies = stmt.body if isinstance(stmt.body, list) else [stmt.body]
+ breaked = False
+ for body in bodies:
+ # Catch any breaks or continues and handle inside the loop normally.
+ try:
+ value = self._eval_statement(body, ctx=ctx)
+ except (LoopBreak, LoopContinue) as e:
+ breaked = isinstance(e, LoopBreak)
+ break
+ if breaked:
+ break
+ return None
+
+ if isinstance(stmt, ast.While):
+ while self._eval_statement(stmt.test, ctx=ctx):
+ bodies = stmt.body if isinstance(stmt.body, list) else [stmt.body]
+ breaked = False
+ for body in bodies:
+ # Catch any breaks or continues and handle inside the loop normally.
+ try:
+ value = self._eval_statement(body, ctx=ctx)
+ except (LoopBreak, LoopContinue) as e:
+ breaked = isinstance(e, LoopBreak)
+ break
+ if breaked:
+ break
+ return None
+
+ if isinstance(stmt, ast.ListComp):
+ # Like: [v.lora for name, v in node(19).inputs.items() if name.startswith('lora_')]
+ # Like: [v.lower() for v in lora_list]
+ # Like: [v for v in l if v.startswith('B')]
+ # Like: [v.lower() for v in l if v.startswith('B') or v.startswith('F')]
+ # ---
+ # Like: [l for n in nodes(re('Loras')).values() if (l := n.loras)]
+ final_list = []
+
+ gen_ctx = {**ctx}
+
+ generators = [*stmt.generators]
+
+ def handle_gen(generators: list[ast.comprehension]):
+ gen = generators.pop(0)
+ if isinstance(gen.target, ast.Name):
+ gen_ctx[gen.target.id] = None
+ elif isinstance(gen.target, ast.Tuple): # dict, like `for k, v in d.entries()`
+ for elt in gen.target.elts:
+ gen_ctx[elt.id] = None
+ else:
+ raise ValueError('Na')
+
+ gen_iters = None
+ # A call, like my_dct.items(), or a named ctx list
+ if isinstance(gen.iter, ast.Call):
+ gen_iters = self._eval_statement(gen.iter, ctx=gen_ctx)
+ elif isinstance(gen.iter, (ast.Name, ast.Attribute, ast.List, ast.Tuple)):
+ gen_iters = self._eval_statement(gen.iter, ctx=gen_ctx)
+
+ if not isinstance(gen_iters, Iterable):
+ raise ValueError('No iteraors found for list comprehension')
+
+ for gen_iter in gen_iters:
+ if_ctx = {**gen_ctx}
+ if isinstance(gen.target, ast.Tuple): # dict, like `for k, v in d.entries()`
+ for i, elt in enumerate(gen.target.elts):
+ if_ctx[elt.id] = gen_iter[i]
+ else:
+ if_ctx[gen.target.id] = gen_iter
+ good = True
+ for ifcall in gen.ifs:
+ if not self._eval_statement(ifcall, ctx=if_ctx):
+ good = False
+ break
+ if not good:
+ continue
+ gen_ctx.update(if_ctx)
+ if len(generators):
+ handle_gen(generators)
+ else:
+ final_list.append(self._eval_statement(stmt.elt, gen_ctx))
+ generators.insert(0, gen)
+
+ handle_gen(generators)
+ return final_list
+
+ if isinstance(stmt, ast.Call):
+ call = None
+ args = []
+ kwargs = {}
+ if isinstance(stmt.func, ast.Attribute):
+ call = self._eval_statement(stmt.func, prev_stmt=stmt, ctx=ctx)
+ if isinstance(call, tuple):
+ args.append(call[1])
+ call = call[0]
+ if not call:
+ raise ValueError(f'No call for ast.Call {stmt.func}')
+
+ name = ''
+ if isinstance(stmt.func, ast.Name):
+ name = stmt.func.id
+ if name in _BUILT_INS:
+ call = _BUILT_INS[name]
+
+ if isinstance(call, str) and call.startswith(_BUILTIN_FN_PREFIX):
+ fn = _get_built_in_fn_by_key(call)
+ call = fn.call
+ if isinstance(call, str):
+ call = getattr(self, call)
+ num_args = len(stmt.args)
+ if num_args < fn.args[0] or (fn.args[1] is not None and num_args > fn.args[1]):
+ toErr = " or more" if fn.args[1] is None else f" to {fn.args[1]}"
+ raise SyntaxError(f"Invalid function call: {fn.name} requires {fn.args[0]}{toErr} args")
+
+ if not call:
+ raise ValueError(f'No call for ast.Call {name}')
+
+ for arg in stmt.args:
+ args.append(self._eval_statement(arg, ctx=ctx))
+ for kwarg in stmt.keywords:
+ kwargs[kwarg.arg] = self._eval_statement(kwarg.value, ctx=ctx)
+ return call(*args, **kwargs)
+
+ if isinstance(stmt, ast.Compare):
+ l = self._eval_statement(stmt.left, ctx=ctx)
+ r = self._eval_statement(stmt.comparators[0], ctx=ctx)
+ if isinstance(stmt.ops[0], ast.Eq):
+ return 1 if l == r else 0
+ if isinstance(stmt.ops[0], ast.NotEq):
+ return 1 if l != r else 0
+ if isinstance(stmt.ops[0], ast.Gt):
+ return 1 if l > r else 0
+ if isinstance(stmt.ops[0], ast.GtE):
+ return 1 if l >= r else 0
+ if isinstance(stmt.ops[0], ast.Lt):
+ return 1 if l < r else 0
+ if isinstance(stmt.ops[0], ast.LtE):
+ return 1 if l <= r else 0
+ if isinstance(stmt.ops[0], ast.In):
+ return 1 if l in r else 0
+ if isinstance(stmt.ops[0], ast.Is):
+ return 1 if l is r else 0
+ if isinstance(stmt.ops[0], ast.IsNot):
+ return 1 if l is not r else 0
+ raise NotImplementedError("Operator " + stmt.ops[0].__class__.__name__ + " not supported.")
+
+ if isinstance(stmt, (ast.If, ast.IfExp)):
+ value = self._eval_statement(stmt.test, ctx=ctx)
+ if value:
+ # ast.If is a list, ast.IfExp is an object.
+ bodies = stmt.body if isinstance(stmt.body, list) else [stmt.body]
+ for body in bodies:
+ value = self._eval_statement(body, ctx=ctx)
+ elif stmt.orelse:
+ # ast.If is a list, ast.IfExp is an object. TBH, I don't know why the If is a list, it's
+ # only ever one item AFAICT.
+ orelses = stmt.orelse if isinstance(stmt.orelse, list) else [stmt.orelse]
+ for orelse in orelses:
+ value = self._eval_statement(orelse, ctx=ctx)
+ return value
+
+ # Assign a variable and add it to our ctx.
+ if isinstance(stmt, (ast.Assign, ast.AugAssign)):
+ if isinstance(stmt, ast.AugAssign):
+ left = self._eval_statement(stmt.target, ctx=ctx)
+ right = self._eval_statement(stmt.value, ctx=ctx)
+ value = _OPERATORS[type(stmt.op)](left, right)
+ target = stmt.target
+ else:
+ value = self._eval_statement(stmt.value, ctx=ctx)
+ if len(stmt.targets) != 1:
+ raise ValueError('Expected length of assign targets to be 1')
+ target = stmt.targets[0]
+
+ if isinstance(target, ast.Tuple): # like `a, z = (1,2)` (ast.Assign only)
+ for i, elt in enumerate(target.elts):
+ ctx[elt.id] = value[i]
+ elif isinstance(target, ast.Name): # like `a = 1``
+ ctx[target.id] = value
+ elif isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name): # `a[0] = 1`
+ ctx[target.value.id][self._eval_statement(target.slice, ctx=ctx)] = value
+ else:
+ raise ValueError('Unhandled target type for Assign.')
+ return value
+
+ # For assigning a var in a list comprehension.
+ # Like [name for node in node_list if (name := node.name)]
+ if isinstance(stmt, ast.NamedExpr):
+ value = self._eval_statement(stmt.value, ctx=ctx)
+ ctx[stmt.target.id] = value
+ return value
+
+ if isinstance(stmt, ast.Return):
+ if stmt.value is None:
+ value = None
+ else:
+ value = self._eval_statement(stmt.value, ctx=ctx)
+ # Mark that we have a return value, as we may be deeper in evaluation, like going through an
+ # if condition's body.
+ ctx['__returned__'] = value
+ return value
+
+ # Raise an error for break or continue, which should be caught and handled inside of loops,
+ # otherwise the error will be raised (which is desired when used outside of a loop).
+ if isinstance(stmt, ast.Break):
+ raise LoopBreak()
+ if isinstance(stmt, ast.Continue):
+ raise LoopContinue()
+
+ # Literally nothing.
+ if isinstance(stmt, ast.Pass):
+ return None
+
+ raise TypeError(stmt)
diff --git a/custom_nodes/rgthree-comfy/py/pyproject.py b/custom_nodes/rgthree-comfy/py/pyproject.py
new file mode 100644
index 0000000000000000000000000000000000000000..26eab343a142079580af83d990c8e0d2cd28057f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/pyproject.py
@@ -0,0 +1,70 @@
+import os
+import re
+import json
+
+from .utils import set_dict_value
+
+_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+_FILE_PY_PROJECT = os.path.join(_THIS_DIR, '..', 'pyproject.toml')
+
+
+def read_pyproject():
+ """Reads the pyproject.toml file"""
+ data = {}
+ last_key = ''
+ lines = []
+ # I'd like to use tomllib/tomli, but I'd much rather not introduce dependencies since I've yet to
+ # need to and not everyone may have 3.11. We've got a controlled config file anyway.
+ with open(_FILE_PY_PROJECT, "r", encoding='utf-8') as f:
+ lines = f.readlines()
+ for line in lines:
+ line = line.strip()
+ if re.match(r'\[([^\]]+)\]$', line):
+ last_key = line[1:-1]
+ set_dict_value(data, last_key, data[last_key] if last_key in data else {})
+ continue
+ value_matches = re.match(r'^([^\s\=]+)\s*=\s*(.*)$', line)
+ if value_matches:
+ try:
+ set_dict_value(data, f'{last_key}.{value_matches[1]}', json.loads(value_matches[2]))
+ except json.decoder.JSONDecodeError:
+ # We don't handle multiline arrays or curly brackets; that's ok, we know the file.
+ pass
+
+ return data
+
+
+_DATA = read_pyproject()
+
+# We would want these to fail if they don't exist, so assume they do.
+VERSION: str = _DATA['project']['version']
+NAME: str = _DATA['project']['name']
+LOGO_URL: str = _DATA['tool']['comfy']['Icon']
+
+if not LOGO_URL.endswith('.svg'):
+ raise ValueError('Bad logo url.')
+
+LOGO_SVG = None
+async def get_logo_svg():
+ import aiohttp
+ global LOGO_SVG
+ if LOGO_SVG is not None:
+ return LOGO_SVG
+ # Fetch the logo so we have any updated markup.
+ try:
+ async with aiohttp.ClientSession(
+ trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=True)
+ ) as session:
+ headers = {
+ "user-agent": f"rgthree-comfy/{VERSION}",
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ }
+ async with session.get(LOGO_URL, headers=headers) as resp:
+ LOGO_SVG = await resp.text()
+ LOGO_SVG = re.sub(r'(id="bg".*fill=)"[^\"]+"', r'\1"{bg}"', LOGO_SVG)
+ LOGO_SVG = re.sub(r'(id="fg".*fill=)"[^\"]+"', r'\1"{fg}"', LOGO_SVG)
+ except Exception:
+ LOGO_SVG = ' '
+ return LOGO_SVG
diff --git a/custom_nodes/rgthree-comfy/py/sdxl_empty_latent_image.py b/custom_nodes/rgthree-comfy/py/sdxl_empty_latent_image.py
new file mode 100644
index 0000000000000000000000000000000000000000..c367aaa6931b32060be2032c19a668e02469c941
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/sdxl_empty_latent_image.py
@@ -0,0 +1,63 @@
+from nodes import EmptyLatentImage
+from .constants import get_category, get_name
+
+
+class RgthreeSDXLEmptyLatentImage:
+
+ NAME = get_name('SDXL Empty Latent Image')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "dimensions": (
+ [
+ # 'Custom',
+ '1536 x 640 (landscape)',
+ '1344 x 768 (landscape)',
+ '1216 x 832 (landscape)',
+ '1152 x 896 (landscape)',
+ '1024 x 1024 (square)',
+ ' 896 x 1152 (portrait)',
+ ' 832 x 1216 (portrait)',
+ ' 768 x 1344 (portrait)',
+ ' 640 x 1536 (portrait)',
+ ],
+ {
+ "default": '1024 x 1024 (square)'
+ }),
+ "clip_scale": ("FLOAT", {
+ "default": 2.0,
+ "min": 1.0,
+ "max": 10.0,
+ "step": .5
+ }),
+ "batch_size": ("INT", {
+ "default": 1,
+ "min": 1,
+ "max": 64
+ }),
+ },
+ # "optional": {
+ # "custom_width": ("INT", {"min": 1, "max": MAX_RESOLUTION, "step": 64}),
+ # "custom_height": ("INT", {"min": 1, "max": MAX_RESOLUTION, "step": 64}),
+ # }
+ }
+
+ RETURN_TYPES = ("LATENT", "INT", "INT")
+ RETURN_NAMES = ("LATENT", "CLIP_WIDTH", "CLIP_HEIGHT")
+ FUNCTION = "generate"
+
+ def generate(self, dimensions, clip_scale, batch_size):
+ """Generates the latent and exposes the clip_width and clip_height"""
+ if True:
+ result = [x.strip() for x in dimensions.split('x')]
+ width = int(result[0])
+ height = int(result[1].split(' ')[0])
+ latent = EmptyLatentImage().generate(width, height, batch_size)[0]
+ return (
+ latent,
+ int(width * clip_scale),
+ int(height * clip_scale),
+ )
diff --git a/custom_nodes/rgthree-comfy/py/sdxl_power_prompt_postive.py b/custom_nodes/rgthree-comfy/py/sdxl_power_prompt_postive.py
new file mode 100644
index 0000000000000000000000000000000000000000..d070da03be21354517cc283eca199f0510b0e6f8
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/sdxl_power_prompt_postive.py
@@ -0,0 +1,178 @@
+import os
+import re
+from nodes import MAX_RESOLUTION
+from comfy_extras.nodes_clip_sdxl import CLIPTextEncodeSDXL
+
+from .log import log_node_warn, log_node_info, log_node_success
+from .constants import get_category, get_name
+from .power_prompt_utils import get_and_strip_loras
+from nodes import LoraLoader, CLIPTextEncode
+import folder_paths
+
+NODE_NAME = get_name('SDXL Power Prompt - Positive')
+
+
+class RgthreeSDXLPowerPromptPositive:
+ """The Power Prompt for positive conditioning."""
+
+ NAME = NODE_NAME
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ # Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
+ # TODO: Rip out saved prompt input data
+ SAVED_PROMPTS_FILES=[]
+ SAVED_PROMPTS_CONTENT=[]
+ return {
+ 'required': {
+ 'prompt_g': ('STRING', {
+ 'multiline': True,
+ 'dynamicPrompts': True
+ }),
+ 'prompt_l': ('STRING', {
+ 'multiline': True,
+ 'dynamicPrompts': True
+ }),
+ },
+ 'optional': {
+ "opt_model": ("MODEL",),
+ "opt_clip": ("CLIP",),
+ "opt_clip_width": ("INT", {
+ "forceInput": True,
+ "default": 1024.0,
+ "min": 0,
+ "max": MAX_RESOLUTION
+ }),
+ "opt_clip_height": ("INT", {
+ "forceInput": True,
+ "default": 1024.0,
+ "min": 0,
+ "max": MAX_RESOLUTION
+ }),
+ 'insert_lora': (['CHOOSE', 'DISABLE LORAS'] +
+ [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('loras')],),
+ 'insert_embedding': ([
+ 'CHOOSE',
+ ] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
+ 'insert_saved': ([
+ 'CHOOSE',
+ ] + SAVED_PROMPTS_FILES,),
+ # We'll hide these in the UI for now.
+ "target_width": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ "target_height": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ "crop_width": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ "crop_height": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ },
+ 'hidden': {
+ 'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
+ }
+ }
+
+ RETURN_TYPES = ('CONDITIONING', 'MODEL', 'CLIP', 'STRING', 'STRING')
+ RETURN_NAMES = ('CONDITIONING', 'MODEL', 'CLIP', 'TEXT_G', 'TEXT_L')
+ FUNCTION = 'main'
+
+ def main(self,
+ prompt_g,
+ prompt_l,
+ opt_model=None,
+ opt_clip=None,
+ opt_clip_width=None,
+ opt_clip_height=None,
+ insert_lora=None,
+ insert_embedding=None,
+ insert_saved=None,
+ target_width=-1,
+ target_height=-1,
+ crop_width=-1,
+ crop_height=-1,
+ values_insert_saved=None):
+
+ if insert_lora == 'DISABLE LORAS':
+ prompt_g, loras_g, _skipped, _unfound = get_and_strip_loras(prompt_g,
+ True,
+ log_node=self.NAME)
+ prompt_l, loras_l, _skipped, _unfound = get_and_strip_loras(prompt_l,
+ True,
+ log_node=self.NAME)
+ loras = loras_g + loras_l
+ log_node_info(
+ NODE_NAME,
+ f'Disabling all found loras ({len(loras)}) and stripping lora tags for TEXT output.')
+ elif opt_model is not None and opt_clip is not None:
+ prompt_g, loras_g, _skipped, _unfound = get_and_strip_loras(prompt_g, log_node=self.NAME)
+ prompt_l, loras_l, _skipped, _unfound = get_and_strip_loras(prompt_l, log_node=self.NAME)
+ loras = loras_g + loras_l
+ if len(loras) > 0:
+ for lora in loras:
+ opt_model, opt_clip = LoraLoader().load_lora(opt_model, opt_clip, lora['lora'],
+ lora['strength'], lora['strength'])
+ log_node_success(NODE_NAME, f'Loaded "{lora["lora"]}" from prompt')
+ log_node_info(NODE_NAME, f'{len(loras)} Loras processed; stripping tags for TEXT output.')
+ elif ' 0 else opt_clip_width
+ target_height = target_height if target_height and target_height > 0 else opt_clip_height
+ crop_width = crop_width if crop_width and crop_width > 0 else 0
+ crop_height = crop_height if crop_height and crop_height > 0 else 0
+ try:
+ conditioning = CLIPTextEncodeSDXL().encode(opt_clip, opt_clip_width, opt_clip_height,
+ crop_width, crop_height, target_width,
+ target_height, prompt_g, prompt_l)[0]
+ except Exception:
+ do_regular_clip_text_encode = True
+ log_node_info(
+ self.NAME,
+ 'Exception while attempting to CLIPTextEncodeSDXL, will fall back to standard encoding.'
+ )
+ else:
+ log_node_info(
+ self.NAME,
+ 'CLIP supplied, but not CLIP_WIDTH and CLIP_HEIGHT. Text encoding will use standard ' +
+ 'encoding with prompt_g and prompt_l concatenated.')
+
+ if not do_regular_clip_text_encode:
+ conditioning = CLIPTextEncode().encode(
+ opt_clip, f'{prompt_g if prompt_g else ""}\n{prompt_l if prompt_l else ""}')[0]
+ return conditioning
diff --git a/custom_nodes/rgthree-comfy/py/sdxl_power_prompt_simple.py b/custom_nodes/rgthree-comfy/py/sdxl_power_prompt_simple.py
new file mode 100644
index 0000000000000000000000000000000000000000..9774d878359a52a313d7ec5f42a5c795989cc7cd
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/sdxl_power_prompt_simple.py
@@ -0,0 +1,106 @@
+"""A simpler SDXL Power Prompt that doesn't load Loras, like for negative."""
+import os
+import re
+import folder_paths
+from nodes import MAX_RESOLUTION, LoraLoader
+from comfy_extras.nodes_clip_sdxl import CLIPTextEncodeSDXL
+from .sdxl_power_prompt_postive import RgthreeSDXLPowerPromptPositive
+
+from .log import log_node_warn, log_node_info, log_node_success
+
+from .constants import get_category, get_name
+
+NODE_NAME = get_name('SDXL Power Prompt - Simple / Negative')
+
+
+class RgthreeSDXLPowerPromptSimple(RgthreeSDXLPowerPromptPositive):
+ """A simpler SDXL Power Prompt that doesn't handle Loras."""
+
+ NAME = NODE_NAME
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ # Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
+ # TODO: Rip out saved prompt input data
+ SAVED_PROMPTS_FILES=[]
+ SAVED_PROMPTS_CONTENT=[]
+ return {
+ 'required': {
+ 'prompt_g': ('STRING', {
+ 'multiline': True,
+ 'dynamicPrompts': True
+ }),
+ 'prompt_l': ('STRING', {
+ 'multiline': True,
+ 'dynamicPrompts': True
+ }),
+ },
+ 'optional': {
+ "opt_clip": ("CLIP",),
+ "opt_clip_width": ("INT", {
+ "forceInput": True,
+ "default": 1024.0,
+ "min": 0,
+ "max": MAX_RESOLUTION
+ }),
+ "opt_clip_height": ("INT", {
+ "forceInput": True,
+ "default": 1024.0,
+ "min": 0,
+ "max": MAX_RESOLUTION
+ }),
+ 'insert_embedding': ([
+ 'CHOOSE',
+ ] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
+ 'insert_saved': ([
+ 'CHOOSE',
+ ] + SAVED_PROMPTS_FILES,),
+ # We'll hide these in the UI for now.
+ "target_width": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ "target_height": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ "crop_width": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ "crop_height": ("INT", {
+ "default": -1,
+ "min": -1,
+ "max": MAX_RESOLUTION
+ }),
+ },
+ 'hidden': {
+ 'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
+ }
+ }
+
+ RETURN_TYPES = ('CONDITIONING', 'STRING', 'STRING')
+ RETURN_NAMES = ('CONDITIONING', 'TEXT_G', 'TEXT_L')
+ FUNCTION = 'main'
+
+ def main(self,
+ prompt_g,
+ prompt_l,
+ opt_clip=None,
+ opt_clip_width=None,
+ opt_clip_height=None,
+ insert_embedding=None,
+ insert_saved=None,
+ target_width=-1,
+ target_height=-1,
+ crop_width=-1,
+ crop_height=-1,
+ values_insert_saved=None):
+
+ conditioning = self.get_conditioning(prompt_g, prompt_l, opt_clip, opt_clip_width,
+ opt_clip_height, target_width, target_height, crop_width, crop_height)
+ return (conditioning, prompt_g, prompt_l)
diff --git a/custom_nodes/rgthree-comfy/py/seed.py b/custom_nodes/rgthree-comfy/py/seed.py
new file mode 100644
index 0000000000000000000000000000000000000000..b470f213357e12567296c9d9d191174944c0451f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/seed.py
@@ -0,0 +1,135 @@
+"""See node."""
+import random
+from datetime import datetime
+
+from .utils_graph import get_worflow_node
+
+from .constants import get_category, get_name
+from .log import log_node_warn, log_node_info
+
+# Some extension must be setting a seed as server-generated seeds were not random. We'll set a new
+# seed and use that state going forward.
+initial_random_state = random.getstate()
+random.seed(datetime.now().timestamp())
+rgthree_seed_random_state = random.getstate()
+random.setstate(initial_random_state)
+
+
+def new_random_seed():
+ """ Gets a new random seed from the rgthree_seed_random_state and resetting the previous state."""
+ global rgthree_seed_random_state
+ prev_random_state = random.getstate()
+ random.setstate(rgthree_seed_random_state)
+ seed = random.randint(1, 1125899906842624)
+ rgthree_seed_random_state = random.getstate()
+ random.setstate(prev_random_state)
+ return seed
+
+
+class RgthreeSeed:
+ """Seed node."""
+
+ NAME = get_name('Seed')
+ CATEGORY = get_category()
+
+ @classmethod
+ def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
+ return {
+ "required": {
+ "seed": ("INT", {
+ "default": 0,
+ "min": -1125899906842624,
+ "max": 1125899906842624
+ }),
+ },
+ "hidden": {
+ "prompt": "PROMPT",
+ "extra_pnginfo": "EXTRA_PNGINFO",
+ "unique_id": "UNIQUE_ID",
+ },
+ }
+
+ RETURN_TYPES = ("INT",)
+ RETURN_NAMES = ("SEED",)
+ FUNCTION = "main"
+
+ @classmethod
+ def IS_CHANGED(cls, seed, prompt=None, extra_pnginfo=None, unique_id=None):
+ """Forces a changed state if we happen to get a special seed, as if from the API directly."""
+ if seed in (-1, -2, -3):
+ # This isn't used, but a different value than previous will force it to be "changed"
+ return new_random_seed()
+ return seed
+
+ def main(self, seed=0, prompt=None, extra_pnginfo=None, unique_id=None):
+ """Returns the passed seed on execution."""
+
+ # We generate random seeds on the frontend in the seed node before sending the workflow in for
+ # many reasons. However, if we want to use this in an API call without changing the seed before
+ # sending, then users _could_ pass in "-1" and get a random seed used and added to the metadata.
+ # Though, this should likely be discouraged for several reasons (thus, a lot of logging).
+ if seed in (-1, -2, -3):
+ log_node_warn(
+ self.NAME,
+ f'Got "{seed}" as passed seed. ' +
+ 'This shouldn\'t happen when queueing from the ComfyUI frontend.',
+ msg_color="YELLOW"
+ )
+ if seed in (-2, -3):
+ log_node_warn(
+ self.NAME,
+ f'Cannot {"increment" if seed == -2 else "decrement"} seed from ' +
+ 'server, but will generate a new random seed.',
+ msg_color="YELLOW"
+ )
+
+ original_seed = seed
+ seed = new_random_seed()
+ log_node_info(self.NAME, f'Server-generated random seed {seed} and saving to workflow.')
+ log_node_warn(
+ self.NAME,
+ f'NOTE: Re-queues passing in "{seed}" and server-generated random seed won\'t be cached.',
+ msg_color="YELLOW"
+ )
+
+ if unique_id is None:
+ log_node_warn(
+ self.NAME, 'Cannot save server-generated seed to image metadata because ' +
+ 'the node\'s id was not provided.'
+ )
+ else:
+ if extra_pnginfo is None:
+ log_node_warn(
+ self.NAME, 'Cannot save server-generated seed to image workflow ' +
+ 'metadata because workflow was not provided.'
+ )
+ else:
+ log_node_info(self.NAME, f'Looking for Seed node with id "{unique_id}"')
+ workflow_node = get_worflow_node(extra_pnginfo, str(unique_id))
+ if workflow_node is None or 'widgets_values' not in workflow_node:
+ log_node_warn(
+ self.NAME, 'Cannot save server-generated seed to image workflow ' +
+ 'metadata because node was not found in the provided workflow.'
+ )
+ else:
+ for index, widget_value in enumerate(workflow_node['widgets_values']):
+ if widget_value == original_seed:
+ workflow_node['widgets_values'][index] = seed
+
+ if prompt is None:
+ log_node_warn(
+ self.NAME, 'Cannot save server-generated seed to image API prompt ' +
+ 'metadata because prompt was not provided.'
+ )
+ else:
+ prompt_node = prompt[str(unique_id)]
+ if prompt_node is None or 'inputs' not in prompt_node or 'seed' not in prompt_node[
+ 'inputs']:
+ log_node_warn(
+ self.NAME, 'Cannot save server-generated seed to image workflow ' +
+ 'metadata because node was not found in the provided workflow.'
+ )
+ else:
+ prompt_node['inputs']['seed'] = seed
+
+ return (seed,)
diff --git a/custom_nodes/rgthree-comfy/py/server/rgthree_server.py b/custom_nodes/rgthree-comfy/py/server/rgthree_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe42165e463429cd34a898b7a118ec7ebad03ded
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/server/rgthree_server.py
@@ -0,0 +1,48 @@
+import os
+from aiohttp import web
+from server import PromptServer
+
+from ..config import get_config_value
+from ..log import log
+from .utils_server import set_default_page_resources, set_default_page_routes, get_param
+from .routes_config import *
+from .routes_model_info import *
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+DIR_WEB = os.path.abspath(f'{THIS_DIR}/../../web/')
+
+routes = PromptServer.instance.routes
+
+# Sometimes other pages (link_fixer, etc.) may want to import JS from the comfyui
+# directory. To allows TS to resolve like '../comfyui/file.js', we'll also resolve any module HTTP
+# to these routes.
+set_default_page_resources("comfyui", routes)
+set_default_page_resources("common", routes)
+set_default_page_resources("lib", routes)
+
+set_default_page_routes("link_fixer", routes)
+if get_config_value('unreleased.models_page.enabled') is True:
+ set_default_page_routes("models", routes)
+
+
+@routes.get('/rgthree/api/print')
+async def api_print(request):
+ """Logs a user message to the terminal."""
+
+ message_type = get_param(request, 'type')
+ if message_type == 'PRIMITIVE_REROUTE':
+ log(
+ "You are using rgthree-comfy reroutes with a ComfyUI Primitive node. Unfortunately, ComfyUI "
+ "has removed support for this. While rgthree-comfy has a best-effort support fallback for "
+ "now, it may no longer work as expected and is strongly recommended you either replace the "
+ "Reroute node using ComfyUI's reroute node, or refrain from using the Primitive node "
+ "(you can always use the rgthree-comfy \"Power Primitive\" for non-combo primitives).",
+ prefix="Reroute",
+ color="YELLOW",
+ id=message_type,
+ at_most_secs=20
+ )
+ else:
+ log("Unknown log type from api", prefix="rgthree-comfy",color ="YELLOW")
+
+ return web.json_response({})
diff --git a/custom_nodes/rgthree-comfy/py/server/routes_config.py b/custom_nodes/rgthree-comfy/py/server/routes_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..74e7e1e29688ad52c018906321af8e7dc1e67321
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/server/routes_config.py
@@ -0,0 +1,67 @@
+import json
+import re
+from aiohttp import web
+
+from server import PromptServer
+
+from ..pyproject import get_logo_svg
+from .utils_server import is_param_truthy, get_param
+from ..config import get_config, set_user_config, refresh_config
+
+routes = PromptServer.instance.routes
+
+
+@routes.get('/rgthree/config.js')
+def api_get_user_config_file(request):
+ """ Returns the user configuration as a javascript file. """
+ data_str = json.dumps(get_config(), sort_keys=True, indent=2, separators=(",", ": "))
+ text = f'export const rgthreeConfig = {data_str}'
+ return web.Response(text=text, content_type='application/javascript')
+
+
+@routes.get('/rgthree/api/config')
+def api_get_user_config(request):
+ """ Returns the user configuration. """
+ if is_param_truthy(request, 'refresh'):
+ refresh_config()
+ return web.json_response(get_config())
+
+
+@routes.post('/rgthree/api/config')
+async def api_set_user_config(request):
+ """ Returns the user configuration. """
+ post = await request.post()
+ data = json.loads(post.get("json"))
+ set_user_config(data)
+ return web.json_response({"status": "ok"})
+
+
+@routes.get('/rgthree/logo.svg')
+async def get_logo(request, as_markup=False):
+ """ Returns the rgthree logo with color config. """
+ bg = get_param(request, 'bg', 'transparent')
+ fg = get_param(request, 'fg', '#111111')
+ w = get_param(request, 'w')
+ h = get_param(request, 'h')
+ css_class = get_param(request, 'cssClass')
+ svg = await get_logo_svg()
+ resp = svg.format(bg=bg, fg=fg)
+ if w is not None:
+ resp = re.sub(r'(]*?)width="[^\"]+"', r'\1', resp)
+ if str(w).isnumeric():
+ resp = re.sub(r']*?)height="[^\"]+"', r'\1', resp)
+ if str(h).isnumeric():
+ resp = re.sub(r' 1 else '.'} "
+ "ComfyUI thinks they exist, but they were not found on the filesystem.",
+ prefix="Power Lora Loader",
+ color="YELLOW",
+ id=f'no_file_details_{model_type}',
+ at_most_secs=30
+ )
+ return web.json_response(response)
+
+ return web.json_response(list(files))
+
+
+@routes.get('/rgthree/api/{type}/info')
+async def api_get_models_info(request):
+ """Returns a list model info; either all or a specific ones if provided a 'files' param.
+
+ If a `light` param is specified and not falsy, no metadata will be fetched.
+ """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ files_param = get_param(request, 'files')
+ maybe_fetch_metadata = files_param is not None
+ if not is_param_falsy(request, 'light'):
+ maybe_fetch_metadata = False
+ api_response = await models_info_response(
+ request, model_type, maybe_fetch_metadata=maybe_fetch_metadata
+ )
+ return web.json_response(api_response)
+
+
+@routes.get('/rgthree/api/{type}/info/refresh')
+async def api_get_refresh_get_models_info(request):
+ """Refreshes model info; either all or specific ones if provided a 'files' param. """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ api_response = await models_info_response(
+ request, model_type, maybe_fetch_civitai=True, maybe_fetch_metadata=True
+ )
+ return web.json_response(api_response)
+
+
+@routes.get('/rgthree/api/{type}/info/clear')
+async def api_get_delete_model_info(request):
+ """Clears model info from the filesystem for the provided file."""
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ api_response = {'status': 200}
+ model_type = request.match_info['type']
+ files_param = get_param(request, 'files')
+ if files_param is not None:
+ files_param = files_param.split(',')
+ del_info = not is_param_falsy(request, 'del_info')
+ del_metadata = not is_param_falsy(request, 'del_metadata')
+ del_civitai = not is_param_falsy(request, 'del_civitai')
+ if not files_param:
+ api_response['status'] = '404'
+ api_response['error'] = f'No file provided. Please pass files=ALL to clear {model_type} info.'
+ else:
+ if len(files_param) == 1 and files_param[
+ 0] == "ALL": # Force the user to supply files=ALL to trigger all clearing.
+ files_param = folder_paths.get_filename_list(model_type)
+ for file_param in files_param:
+ await delete_model_info(
+ file_param,
+ model_type,
+ del_info=del_info,
+ del_metadata=del_metadata,
+ del_civitai=del_civitai
+ )
+ return web.json_response(api_response)
+
+
+@routes.post('/rgthree/api/{type}/info')
+async def api_post_save_model_data(request):
+ """Saves data to a model by name. """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ api_response = {'status': 200}
+ file_param = get_param(request, 'file')
+ if file_param is None:
+ api_response['status'] = '404'
+ api_response['error'] = 'No model found at path'
+ else:
+ post = await request.post()
+ await set_model_info_partial(file_param, model_type, json.loads(post.get("json")))
+ info_data = await get_model_info(file_param, model_type)
+ api_response['data'] = info_data
+ return web.json_response(api_response)
+
+
+@routes.get('/rgthree/api/{type}/img')
+async def api_get_models_info_img(request):
+ """ Returns an image response if one exists for the model. """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ file_param = get_param(request, 'file')
+ file_path = folder_paths.get_full_path(model_type, file_param)
+ if not path_exists(file_path):
+ file_path = abspath(file_path)
+ img_path = None
+ for ext in ['jpg', 'png', 'jpeg']:
+ try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
+ if path_exists(try_path):
+ img_path = try_path
+ break
+
+ if not path_exists(img_path):
+ api_response = {}
+ api_response['status'] = '404'
+ api_response['error'] = 'No model found at path'
+ return web.json_response(api_response)
+
+ return web.FileResponse(img_path)
+
+
+async def models_info_response(
+ request, model_type, maybe_fetch_civitai=False, maybe_fetch_metadata=False
+):
+ """Gets model info for all or a single model type."""
+ api_response = {'status': 200, 'data': []}
+ light = not is_param_falsy(request, 'light')
+ files_param = get_param(request, 'files')
+ if files_param is not None:
+ files_param = files_param.split(',')
+ else:
+ files_param = folder_paths.get_filename_list(model_type)
+ for file_param in files_param:
+ info_data = await get_model_info(
+ file_param,
+ model_type,
+ maybe_fetch_civitai=maybe_fetch_civitai,
+ maybe_fetch_metadata=maybe_fetch_metadata,
+ light=light
+ )
+ api_response['data'].append(info_data)
+ return api_response
diff --git a/custom_nodes/rgthree-comfy/py/server/utils_info.py b/custom_nodes/rgthree-comfy/py/server/utils_info.py
new file mode 100644
index 0000000000000000000000000000000000000000..4723a664540235fa04f18c1460c2eef6f8fc88f5
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/server/utils_info.py
@@ -0,0 +1,452 @@
+import hashlib
+import json
+import os
+import re
+from datetime import datetime
+
+import requests
+
+from server import PromptServer
+import folder_paths
+
+from ..utils import abspath, get_dict_value, load_json_file, file_exists, remove_path, save_json_file
+from ..utils_userdata import read_userdata_json, save_userdata_json, delete_userdata_file
+
+
+def _get_info_cache_file(data_type: str, file_hash: str):
+ return f'info/{file_hash}.{data_type}.json'
+
+
+async def delete_model_info(
+ file: str, model_type, del_info=True, del_metadata=True, del_civitai=True
+):
+ """Delete the info json, and the civitai & metadata caches."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return
+ if del_info:
+ remove_path(get_info_file(file_path))
+ if del_civitai or del_metadata:
+ file_hash = _get_sha256_hash(file_path)
+ if del_civitai:
+ json_file_path = _get_info_cache_file(file_hash, 'civitai')
+ delete_userdata_file(json_file_path)
+ if del_metadata:
+ json_file_path = _get_info_cache_file(file_hash, 'metadata')
+ delete_userdata_file(json_file_path)
+
+
+def get_file_info(file: str, model_type):
+ """Gets basic file info, like created or modified date."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return None
+ return {
+ 'file': file,
+ 'path': file_path,
+ 'modified': os.path.getmtime(file_path) * 1000, # millis
+ 'imageLocal': f'/rgthree/api/{model_type}/img?file={file}' if get_img_file(file_path) else None,
+ 'hasInfoFile': get_info_file(file_path) is not None,
+ }
+
+
+def get_info_file(file_path: str, force=False):
+ # Try to load a rgthree-info.json file next to the file.
+ info_path = f'{file_path}.rgthree-info.json'
+ return info_path if file_exists(info_path) or force else None
+
+
+def get_img_file(file_path: str, force=False):
+ for ext in ['jpg', 'png', 'jpeg', 'webp']:
+ try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
+ if file_exists(try_path):
+ return try_path
+
+
+def get_model_info_file_data(file: str, model_type, default=None):
+ """Returns the data from the info file, or a default value if it doesn't exist."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return default
+ return load_json_file(get_info_file(file_path), default=default)
+
+
+async def get_model_info(
+ file: str,
+ model_type,
+ default=None,
+ maybe_fetch_civitai=False,
+ force_fetch_civitai=False,
+ maybe_fetch_metadata=False,
+ force_fetch_metadata=False,
+ light=False
+):
+ """Compiles a model info given a stored file next to the model, and/or metadata/civitai."""
+
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return default
+
+ should_save = False
+ # basic data
+ basic_data = get_file_info(file, model_type)
+ # Try to load a rgthree-info.json file next to the file.
+ info_data = get_model_info_file_data(file, model_type, default={})
+
+ for key in ['file', 'path', 'modified', 'imageLocal', 'hasInfoFile']:
+ if key in basic_data and basic_data[key] and (
+ key not in info_data or info_data[key] != basic_data[key]
+ ):
+ info_data[key] = basic_data[key]
+ should_save = True
+
+ # Check if we have an image next to the file and, if so, add it to the front of the images
+ # (if it isn't already).
+ img_next_to_file = basic_data['imageLocal']
+
+ if 'images' not in info_data:
+ info_data['images'] = []
+ should_save = True
+
+ if img_next_to_file:
+ if len(info_data['images']) == 0 or info_data['images'][0]['url'] != img_next_to_file:
+ info_data['images'].insert(0, {'url': img_next_to_file})
+ should_save = True
+
+ # If we just want light data then bail now with just existing data, plus file, path and img if
+ # next to the file.
+ if light and not maybe_fetch_metadata and not force_fetch_metadata and not maybe_fetch_civitai and not force_fetch_civitai:
+ return info_data
+
+ if 'raw' not in info_data:
+ info_data['raw'] = {}
+ should_save = True
+
+ should_save = _update_data(info_data) or should_save
+
+ should_fetch_civitai = force_fetch_civitai is True or (
+ maybe_fetch_civitai is True and 'civitai' not in info_data['raw']
+ )
+ should_fetch_metadata = force_fetch_metadata is True or (
+ maybe_fetch_metadata is True and 'metadata' not in info_data['raw']
+ )
+
+ if should_fetch_metadata:
+ data_meta = _get_model_metadata(file, model_type, default={}, refresh=force_fetch_metadata)
+ should_save = _merge_metadata(info_data, data_meta) or should_save
+
+ if should_fetch_civitai:
+ data_civitai = _get_model_civitai_data(
+ file, model_type, default={}, refresh=force_fetch_civitai
+ )
+ should_save = _merge_civitai_data(info_data, data_civitai) or should_save
+
+ if 'sha256' not in info_data:
+ file_hash = _get_sha256_hash(file_path)
+ if file_hash is not None:
+ info_data['sha256'] = file_hash
+ should_save = True
+
+ if should_save:
+ if 'trainedWords' in info_data:
+ # Sort by count; if it doesn't exist, then assume it's a top item from civitai or elsewhere.
+ info_data['trainedWords'] = sorted(
+ info_data['trainedWords'],
+ key=lambda w: w['count'] if 'count' in w else 99999,
+ reverse=True
+ )
+ save_model_info(file, info_data, model_type)
+
+ # If we're saving, then the UI is likely waiting to see if the refreshed data is coming in.
+ await PromptServer.instance.send(f"rgthree-refreshed-{model_type}-info", {"data": info_data})
+
+ return info_data
+
+
+def _update_data(info_data: dict) -> bool:
+ """Ports old data to new data if necessary."""
+ should_save = False
+ # If we have "triggerWords" then move them over to "trainedWords"
+ if 'triggerWords' in info_data and len(info_data['triggerWords']) > 0:
+ civitai_words = ','.join((
+ get_dict_value(info_data, 'raw.civitai.triggerWords', default=[]) +
+ get_dict_value(info_data, 'raw.civitai.trainedWords', default=[])
+ ))
+ if 'trainedWords' not in info_data:
+ info_data['trainedWords'] = []
+ for trigger_word in info_data['triggerWords']:
+ word_data = next((data for data in info_data['trainedWords'] if data['word'] == trigger_word),
+ None)
+ if word_data is None:
+ word_data = {'word': trigger_word}
+ info_data['trainedWords'].append(word_data)
+ if trigger_word in civitai_words:
+ word_data['civitai'] = True
+ else:
+ word_data['user'] = True
+
+ del info_data['triggerWords']
+ should_save = True
+ return should_save
+
+
+def _merge_metadata(info_data: dict, data_meta: dict) -> bool:
+ """Returns true if data was saved."""
+ should_save = False
+
+ base_model_file = get_dict_value(data_meta, 'ss_sd_model_name', None)
+ if base_model_file:
+ info_data['baseModelFile'] = base_model_file
+
+ # Loop over metadata tags
+ trained_words = {}
+ if 'ss_tag_frequency' in data_meta and isinstance(data_meta['ss_tag_frequency'], dict):
+ for bucket_value in data_meta['ss_tag_frequency'].values():
+ if isinstance(bucket_value, dict):
+ for tag, count in bucket_value.items():
+ if tag not in trained_words:
+ trained_words[tag] = {'word': tag, 'count': 0, 'metadata': True}
+ trained_words[tag]['count'] = trained_words[tag]['count'] + count
+
+ if 'trainedWords' not in info_data:
+ info_data['trainedWords'] = list(trained_words.values())
+ should_save = True
+ else:
+ # We can't merge, because the list may have other data, like it's part of civitaidata.
+ merged_dict = {}
+ for existing_word_data in info_data['trainedWords']:
+ merged_dict[existing_word_data['word']] = existing_word_data
+ for new_key, new_word_data in trained_words.items():
+ if new_key not in merged_dict:
+ merged_dict[new_key] = {}
+ merged_dict[new_key] = {**merged_dict[new_key], **new_word_data}
+ info_data['trainedWords'] = list(merged_dict.values())
+ should_save = True
+
+ # trained_words = list(trained_words.values())
+ # info_data['meta_trained_words'] = trained_words
+ info_data['raw']['metadata'] = data_meta
+ should_save = True
+
+ if 'sha256' not in info_data and '_sha256' in data_meta:
+ info_data['sha256'] = data_meta['_sha256']
+ should_save = True
+
+ return should_save
+
+
+def _merge_civitai_data(info_data: dict, data_civitai: dict) -> bool:
+ """Returns true if data was saved."""
+ should_save = False
+
+ if 'name' not in info_data:
+ info_data['name'] = get_dict_value(data_civitai, 'model.name', '')
+ should_save = True
+ version_name = get_dict_value(data_civitai, 'name')
+ if version_name is not None:
+ info_data['name'] += f' - {version_name}'
+
+ if 'type' not in info_data:
+ info_data['type'] = get_dict_value(data_civitai, 'model.type')
+ should_save = True
+ if 'baseModel' not in info_data:
+ info_data['baseModel'] = get_dict_value(data_civitai, 'baseModel')
+ should_save = True
+
+ # We always want to merge triggerword.
+ civitai_trigger = get_dict_value(data_civitai, 'triggerWords', default=[])
+ civitai_trained = get_dict_value(data_civitai, 'trainedWords', default=[])
+ civitai_words = ','.join(civitai_trigger + civitai_trained)
+ if civitai_words:
+ civitai_words = re.sub(r"\s*,\s*", ",", civitai_words)
+ civitai_words = re.sub(r",+", ",", civitai_words)
+ civitai_words = re.sub(r"^,", "", civitai_words)
+ civitai_words = re.sub(r",$", "", civitai_words)
+ if civitai_words:
+ civitai_words = civitai_words.split(',')
+ if 'trainedWords' not in info_data:
+ info_data['trainedWords'] = []
+ for trigger_word in civitai_words:
+ word_data = next(
+ (data for data in info_data['trainedWords'] if data['word'] == trigger_word), None
+ )
+ if word_data is None:
+ word_data = {'word': trigger_word}
+ info_data['trainedWords'].append(word_data)
+ word_data['civitai'] = True
+
+ if 'sha256' not in info_data:
+ info_data['sha256'] = data_civitai['_sha256']
+ should_save = True
+
+ if 'modelId' in data_civitai:
+ info_data['links'] = info_data['links'] if 'links' in info_data else []
+ civitai_link = f'https://civitai.com/models/{get_dict_value(data_civitai, "modelId")}'
+ if get_dict_value(data_civitai, "id"):
+ civitai_link += f'?modelVersionId={get_dict_value(data_civitai, "id")}'
+ info_data['links'].append(civitai_link)
+ info_data['links'].append(data_civitai['_civitai_api'])
+ should_save = True
+
+ # Take images from civitai
+ if 'images' in data_civitai:
+ info_data_image_urls = list(
+ map(lambda i: i['url'] if 'url' in i else None, info_data['images'])
+ )
+ for img in data_civitai['images']:
+ img_url = get_dict_value(img, 'url')
+ if img_url is not None and img_url not in info_data_image_urls:
+ img_id = os.path.splitext(os.path.basename(img_url))[0] if img_url is not None else None
+ img_data = {
+ 'url': img_url,
+ 'civitaiUrl': f'https://civitai.com/images/{img_id}' if img_id is not None else None,
+ 'width': get_dict_value(img, 'width'),
+ 'height': get_dict_value(img, 'height'),
+ 'type': get_dict_value(img, 'type'),
+ 'nsfwLevel': get_dict_value(img, 'nsfwLevel'),
+ 'seed': get_dict_value(img, 'meta.seed'),
+ 'positive': get_dict_value(img, 'meta.prompt'),
+ 'negative': get_dict_value(img, 'meta.negativePrompt'),
+ 'steps': get_dict_value(img, 'meta.steps'),
+ 'sampler': get_dict_value(img, 'meta.sampler'),
+ 'cfg': get_dict_value(img, 'meta.cfgScale'),
+ 'model': get_dict_value(img, 'meta.Model'),
+ 'resources': get_dict_value(img, 'meta.resources'),
+ }
+ info_data['images'].append(img_data)
+ should_save = True
+
+ # The raw data
+ if 'civitai' not in info_data['raw']:
+ info_data['raw']['civitai'] = data_civitai
+ should_save = True
+
+ return should_save
+
+
+def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
+ """Gets the civitai data, either cached from the user directory, or from civitai api."""
+ file_hash = _get_sha256_hash(get_folder_path(file, model_type))
+ if file_hash is None:
+ return None
+
+ json_file_path = _get_info_cache_file(file_hash, 'civitai')
+
+ api_url = f'https://civitai.com/api/v1/model-versions/by-hash/{file_hash}'
+ file_data = read_userdata_json(json_file_path)
+ if file_data is None or refresh is True:
+ try:
+ response = requests.get(api_url, timeout=5000)
+ data = response.json()
+ save_userdata_json(
+ json_file_path, {
+ 'url': api_url,
+ 'timestamp': datetime.now().timestamp(),
+ 'response': data
+ }
+ )
+ file_data = read_userdata_json(json_file_path)
+ except requests.exceptions.RequestException as e: # This is the correct syntax
+ print(e)
+ response = file_data['response'] if file_data is not None and 'response' in file_data else None
+ if response is not None:
+ response['_sha256'] = file_hash
+ response['_civitai_api'] = api_url
+ return response if response is not None else default
+
+
+def _get_model_metadata(file: str, model_type, default=None, refresh=False):
+ """Gets the metadata from the file itself."""
+ file_path = get_folder_path(file, model_type)
+ file_hash = _get_sha256_hash(file_path)
+ if file_hash is None:
+ return default
+
+ json_file_path = _get_info_cache_file(file_hash, 'metadata')
+
+ file_data = read_userdata_json(json_file_path)
+ if file_data is None or refresh is True:
+ data = _read_file_metadata_from_header(file_path)
+ if data is not None:
+ file_data = {'url': file, 'timestamp': datetime.now().timestamp(), 'response': data}
+ save_userdata_json(json_file_path, file_data)
+ response = file_data['response'] if file_data is not None and 'response' in file_data else None
+ if response is not None:
+ response['_sha256'] = file_hash
+ return response if response is not None else default
+
+
+def _read_file_metadata_from_header(file_path: str) -> dict:
+ """Reads the file's header and returns a JSON dict metdata if available."""
+ data = None
+ try:
+ if file_path.endswith('.safetensors'):
+ with open(file_path, "rb") as file:
+ # https://github.com/huggingface/safetensors#format
+ # 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header
+ header_size = int.from_bytes(file.read(8), "little", signed=False)
+
+ if header_size <= 0:
+ raise BufferError("Invalid header size")
+
+ header = file.read(header_size)
+ if header is None:
+ raise BufferError("Invalid header")
+
+ header_json = json.loads(header)
+ data = header_json["__metadata__"] if "__metadata__" in header_json else None
+
+ if data is not None:
+ for key, value in data.items():
+ if isinstance(value, str) and value.startswith('{') and value.endswith('}'):
+ try:
+ value_as_json = json.loads(value)
+ data[key] = value_as_json
+ except Exception:
+ print(f'metdata for field {key} did not parse as json')
+ except requests.exceptions.RequestException as e:
+ print(e)
+ data = None
+
+ return data
+
+
+def get_folder_path(file: str, model_type) -> str | None:
+ """Gets the file path ensuring it exists."""
+ file_path = folder_paths.get_full_path(model_type, file)
+ if not file_exists(file_path):
+ file_path = abspath(file_path)
+ if not file_exists(file_path):
+ file_path = None
+ return file_path
+
+
+def _get_sha256_hash(file_path: str | None):
+ """Returns the hash for the file."""
+ if not file_path or not file_exists(file_path):
+ return None
+ BUF_SIZE = 1024 * 128 # lets read stuff in 64kb chunks!
+ file_hash = None
+ sha256_hash = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ # Read and update hash string value in blocks of BUF_SIZE
+ for byte_block in iter(lambda: f.read(BUF_SIZE), b""):
+ sha256_hash.update(byte_block)
+ file_hash = sha256_hash.hexdigest()
+ return file_hash
+
+
+async def set_model_info_partial(file: str, model_type: str, info_data_partial):
+ """Sets partial data into the existing model info data."""
+ info_data = await get_model_info(file, model_type, default={})
+ info_data = {**info_data, **info_data_partial}
+ save_model_info(file, info_data, model_type)
+
+
+def save_model_info(file: str, info_data, model_type):
+ """Saves the model info alongside the model itself."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return
+ info_path = get_info_file(file_path, force=True)
+ save_json_file(info_path, info_data)
diff --git a/custom_nodes/rgthree-comfy/py/server/utils_server.py b/custom_nodes/rgthree-comfy/py/server/utils_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2fa5d741964a767daf45ed7918c7b371348d222
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/server/utils_server.py
@@ -0,0 +1,65 @@
+import os
+from aiohttp import web
+
+from ..utils import sub_abspath
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+DIR_WEB = os.path.abspath(f'{THIS_DIR}/../../web/')
+
+
+def get_param(request, param, default=None):
+ """Gets a param from a request."""
+ return request.rel_url.query[param] if param in request.rel_url.query else default
+
+
+def is_param_falsy(request, param):
+ """Determines if a param is explicitly 0 or false."""
+ val = get_param(request, param)
+ return val is not None and (val == "0" or val.upper() == "FALSE")
+
+
+def is_param_truthy(request, param):
+ """Determines if a param is explicitly 0 or false."""
+ val = get_param(request, param)
+ return val is not None and not is_param_falsy(request, param)
+
+
+def set_default_page_resources(path, routes):
+ """Sets up routes for handling static files under a path."""
+
+ @routes.get(f'/rgthree/{path}/{{file}}')
+ async def get_resource(request):
+ """Returns a resource file."""
+ filepath = request.match_info['file']
+ abspath = sub_abspath(os.path.join(DIR_WEB, path), filepath)
+ if abspath is None:
+ return web.HTTPNotFound()
+ return web.FileResponse(abspath)
+
+ @routes.get(f'/rgthree/{path}/{{subdir}}/{{file}}')
+ async def get_resource_subdir(request):
+ """Returns a resource file."""
+ filepath = os.path.join(request.match_info['subdir'], request.match_info['file'])
+ abspath = sub_abspath(os.path.join(DIR_WEB, path), filepath)
+ if abspath is None:
+ return web.HTTPNotFound()
+ return web.FileResponse(abspath)
+
+
+def set_default_page_routes(path, routes):
+ """ Sets default path handling for a hosted rgthree page. """
+
+ @routes.get(f'/rgthree/{path}')
+ async def get_path_redir(request):
+ """ Redirects to the path adding a trailing slash. """
+ raise web.HTTPFound(f'{request.path}/')
+
+ @routes.get(f'/rgthree/{path}/')
+ async def get_path_index(request):
+ """ Handles the page's index loading. """
+ html = ''
+ with open(os.path.join(DIR_WEB, path, 'index.html'), 'r', encoding='UTF-8') as file:
+ html = file.read()
+ return web.Response(text=html, content_type='text/html')
+
+ set_default_page_resources(path, routes)
diff --git a/custom_nodes/rgthree-comfy/py/utils.py b/custom_nodes/rgthree-comfy/py/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..e86949e904e12c53bb1c02750f16776028edce82
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/utils.py
@@ -0,0 +1,177 @@
+import json
+import os
+import re
+
+from typing import Union
+
+
+class AnyType(str):
+ """A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
+
+ def __ne__(self, __value: object) -> bool:
+ return False
+
+
+class FlexibleOptionalInputType(dict):
+ """A special class to make flexible nodes that pass data to our python handlers.
+
+ Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
+ (like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
+
+ Initially, ComfyUI only needed to return True for `__contains__` below, which told ComfyUI that
+ our node will handle the input, regardless of what it is.
+
+ However, after https://github.com/comfyanonymous/ComfyUI/pull/2666 ComdyUI's execution changed
+ also checking the data for the key; specifcially, the type which is the first tuple entry. This
+ type is supplied to our FlexibleOptionalInputType and returned for any non-data key. This can be a
+ real type, or use the AnyType for additional flexibility.
+ """
+
+ def __init__(self, type, data: Union[dict, None] = None):
+ """Initializes the FlexibleOptionalInputType.
+
+ Args:
+ type: The flexible type to use when ComfyUI retrieves an unknown key (via `__getitem__`).
+ data: An optional dict to use as the basis. This is stored both in a `data` attribute, so we
+ can look it up without hitting our overrides, as well as iterated over and adding its key
+ and values to our `self` keys. This way, when looked at, we will appear to represent this
+ data. When used in an "optional" INPUT_TYPES, these are the starting optional node types.
+ """
+ self.type = type
+ self.data = data
+ if self.data is not None:
+ for k, v in self.data.items():
+ self[k] = v
+
+ def __getitem__(self, key):
+ # If we have this key in the initial data, then return it. Otherwise return the tuple with our
+ # flexible type.
+ if self.data is not None and key in self.data:
+ val = self.data[key]
+ return val
+ return (self.type,)
+
+ def __contains__(self, key):
+ """Always contain a key, and we'll always return the tuple above when asked for it."""
+ return True
+
+
+any_type = AnyType("*")
+
+
+def is_dict_value_falsy(data: dict, dict_key: str):
+ """Checks if a dict value is falsy."""
+ val = get_dict_value(data, dict_key)
+ return not val
+
+
+def get_dict_value(data: dict, dict_key: str, default=None):
+ """Gets a deeply nested value given a dot-delimited key."""
+ keys = dict_key.split('.')
+ key = keys.pop(0) if len(keys) > 0 else None
+ found = data[key] if key in data else None
+ if found is not None and len(keys) > 0:
+ return get_dict_value(found, '.'.join(keys), default)
+ return found if found is not None else default
+
+
+def set_dict_value(data: dict, dict_key: str, value, create_missing_objects=True):
+ """Sets a deeply nested value given a dot-delimited key."""
+ keys = dict_key.split('.')
+ key = keys.pop(0) if len(keys) > 0 else None
+ if key not in data:
+ if create_missing_objects is False:
+ return data
+ data[key] = {}
+ if len(keys) == 0:
+ data[key] = value
+ else:
+ set_dict_value(data[key], '.'.join(keys), value, create_missing_objects)
+
+ return data
+
+
+def dict_has_key(data: dict, dict_key):
+ """Checks if a dict has a deeply nested dot-delimited key."""
+ keys = dict_key.split('.')
+ key = keys.pop(0) if len(keys) > 0 else None
+ if key is None or key not in data:
+ return False
+ if len(keys) == 0:
+ return True
+ return dict_has_key(data[key], '.'.join(keys))
+
+
+def load_json_file(file: str, default=None):
+ """Reads a json file and returns the json dict, stripping out "//" comments first."""
+ if path_exists(file):
+ with open(file, 'r', encoding='UTF-8') as file:
+ config = file.read()
+ try:
+ return json.loads(config)
+ except json.decoder.JSONDecodeError:
+ try:
+ config = re.sub(r"^\s*//\s.*", "", config, flags=re.MULTILINE)
+ return json.loads(config)
+ except json.decoder.JSONDecodeError:
+ try:
+ config = re.sub(r"(?:^|\s)//.*", "", config, flags=re.MULTILINE)
+ return json.loads(config)
+ except json.decoder.JSONDecodeError:
+ pass
+ return default
+
+
+def save_json_file(file_path: str, data: dict):
+ """Saves a json file."""
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+ with open(file_path, 'w+', encoding='UTF-8') as file:
+ json.dump(data, file, sort_keys=False, indent=2, separators=(",", ": "))
+
+
+def path_exists(path):
+ """Checks if a path exists, accepting None type."""
+ if path is not None:
+ return os.path.exists(path)
+ return False
+
+
+def file_exists(path):
+ """Checks if a file exists, accepting None type."""
+ if path is not None:
+ return os.path.isfile(path)
+ return False
+
+
+def remove_path(path):
+ """Removes a path, if it exists."""
+ if path_exists(path):
+ os.remove(path)
+ return True
+ return False
+
+def abspath(file_path: str):
+ """Resolves the abspath of a file, resolving symlinks and user dirs."""
+ abs_path = os.path.abspath(file_path) if file_path else file_path
+ if abs_path and not path_exists(abs_path):
+ maybe_path = os.path.abspath(os.path.realpath(os.path.expanduser(file_path)))
+ abs_path = maybe_path if path_exists(maybe_path) else abs_path
+ return abs_path
+
+def sub_abspath(parent_dir: str, rel_path: str):
+ """Resolves the abspath under a parent directory ensuring it exists and is contained within."""
+ rel_path = os.path.join(parent_dir, rel_path)
+ abs_path = abspath(rel_path)
+ if not path_exists(abs_path) or not abs_path.startswith(parent_dir):
+ return None
+ return abs_path
+
+class ByPassTypeTuple(tuple):
+ """A special class that will return additional "AnyType" strings beyond defined values.
+ Credit to Trung0246
+ """
+
+ def __getitem__(self, index):
+ if index > len(self) - 1:
+ return AnyType("*")
+ return super().__getitem__(index)
diff --git a/custom_nodes/rgthree-comfy/py/utils_graph.py b/custom_nodes/rgthree-comfy/py/utils_graph.py
new file mode 100644
index 0000000000000000000000000000000000000000..0982a55cbc105b297c970770227d7da9be1f7abf
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/utils_graph.py
@@ -0,0 +1,20 @@
+from .utils import get_dict_value
+
+
+def get_worflow_node(extra_pnginfo, node_id: str, default=None):
+ # First, break out of any subgraphs
+ node_ids = str(node_id).split(':')
+ workflow_nodes = get_dict_value(extra_pnginfo, 'workflow.nodes', default=[])
+ workflow_subgraphs = get_dict_value(extra_pnginfo, 'workflow.definitions.subgraphs', default=[])
+ nodes_list = workflow_nodes
+ found = None
+ for individual_node_id in node_ids:
+ found = next((n for n in nodes_list if str(n['id']) == individual_node_id), None)
+ if isinstance(found, dict) and 'type' in found:
+ # Are we a subgraph? Right now, subgraph types are a UUID that exists as an id in the
+ # aubgraphs list. But, rather than check if we're a UUID, let's just check if it exists
+ # anyway, that when if (when) Comfy changes the id structure we'll keep working.
+ subgraph = next((n for n in workflow_subgraphs if str(n['id']) == found['type']), None)
+ if isinstance(subgraph, dict) and 'nodes' in subgraph:
+ nodes_list = subgraph['nodes']
+ return found if found is not None else default
diff --git a/custom_nodes/rgthree-comfy/py/utils_userdata.py b/custom_nodes/rgthree-comfy/py/utils_userdata.py
new file mode 100644
index 0000000000000000000000000000000000000000..b10b757ab503908fb03ae6df97ef775809a3f40d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/py/utils_userdata.py
@@ -0,0 +1,50 @@
+import os
+
+from .utils import load_json_file, path_exists, save_json_file
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+USERDATA = os.path.join(THIS_DIR, '..', 'userdata')
+
+
+def read_userdata_file(rel_path: str):
+ """Reads a file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ if path_exists(file_path):
+ with open(file_path, 'r', encoding='UTF-8') as file:
+ return file.read()
+ return None
+
+
+def save_userdata_file(rel_path: str, content: str):
+ """Saves a file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ with open(file_path, 'w+', encoding='UTF-8') as file:
+ file.write(content)
+
+
+def delete_userdata_file(rel_path: str):
+ """Deletes a file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ if os.path.isfile(file_path):
+ os.remove(file_path)
+
+
+def read_userdata_json(rel_path: str):
+ """Reads a json file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ return load_json_file(file_path)
+
+
+def save_userdata_json(rel_path: str, data: dict):
+ """Saves a json file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ return save_json_file(file_path, data)
+
+
+def clean_path(rel_path: str):
+ """Cleans a relative path by splitting on forward slash and os.path.joining."""
+ cleaned = USERDATA
+ paths = rel_path.split('/')
+ for path in paths:
+ cleaned = os.path.join(cleaned, path)
+ return cleaned
diff --git a/custom_nodes/rgthree-comfy/pyproject.toml b/custom_nodes/rgthree-comfy/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..35d16ec6b5465f7ad207bf94d445ec206035d53a
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/pyproject.toml
@@ -0,0 +1,14 @@
+[project]
+name = "rgthree-comfy"
+description = "Making ComfyUI more comfortable."
+version = "1.0.2605082257"
+license = { file = "LICENSE" }
+dependencies = []
+
+[project.urls]
+Repository = "https://github.com/rgthree/rgthree-comfy"
+
+[tool.comfy]
+PublisherId = "rgthree"
+DisplayName = "rgthree-comfy"
+Icon = "https://comfy.rgthree.com/media/rgthree.svg"
diff --git a/custom_nodes/rgthree-comfy/requirements.txt b/custom_nodes/rgthree-comfy/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/custom_nodes/rgthree-comfy/rgthree_config.json.default b/custom_nodes/rgthree-comfy/rgthree_config.json.default
new file mode 100644
index 0000000000000000000000000000000000000000..e8da96f086e4055f8965ea9710c3d2b3da17cb08
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/rgthree_config.json.default
@@ -0,0 +1,68 @@
+// COPY THIS FILE BEFORE MAKING CHANGES TO: rgthree_config.json
+{
+ "log_level": "WARN",
+ "features": {
+ "show_alerts_for_corrupt_workflows": false,
+ "monitor_for_corrupt_links": false,
+ "menu_queue_selected_nodes": true,
+ "menu_auto_nest": {
+ "subdirs": null,
+ "threshold": 20
+ },
+ "menu_bookmarks": {
+ "enabled": true
+ },
+ "group_header_fast_toggle": {
+ "enabled": null,
+ "toggles": ["queue", "bypass", "mute"],
+ "show": "hover"
+ },
+ "progress_bar": {
+ "enabled": true,
+ "height": 16,
+ "position": "top"
+ },
+ "comfy_top_bar_menu": {
+ "enabled": true,
+ "button_bookmarks": {
+ "enabled": true
+ }
+ },
+ // Allows for dragging and dropping a workflow (image, json) onto an individual node to import
+ // that specific node's widgets if it also exists in the dropped workflow (same id, type).
+ "import_individual_nodes": {
+ "enabled": null
+ },
+ // Enables invokeExtensionsAsync for rgthree-nodes allowing other extensions to hook into the
+ // nodes like the default ComfyNodes. This was not possible before Apr 2024, so it's a config
+ // entry in case it causes issues. This is only for the nodeCreated event/function as of now.
+ "invoke_extensions_async": {
+ "node_created": true
+ }
+ },
+ "nodes": {
+ "reroute": {
+ "default_width": 40,
+ "default_height": 30,
+ "default_resizable": false,
+ "default_layout": ["Left", "Right"],
+ "fast_reroute": {
+ "enabled": true,
+ "key_create_while_dragging_link" : "Shift + R",
+ "key_rotate": "Shift + A",
+ "key_resize": "Shift + X",
+ "key_move": "Shift + Z",
+ "key_connections_input": "Shift + S",
+ "key_connections_output": "Shift + D"
+ }
+ },
+ "power_lora_loader": {
+ "show_info_badge": true
+ }
+ },
+ "announcements": {
+ "comfy-nodes-20": {
+ "incompatible": true
+ }
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9e72642b60436236eccd3177cb8e2573f00ac33c
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts
@@ -0,0 +1,103 @@
+import type {
+ ComfyApp,
+ INodeInputSlot,
+ INodeOutputSlot,
+ LGraphNode,
+ LLink,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {IoDirection, addConnectionLayoutSupport, followConnectionUntilType} from "./utils.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
+import {debounce} from "rgthree/common/shared_utils.js";
+
+class RgthreeAnySwitch extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.ANY_SWITCH;
+ static override type = NodeTypesString.ANY_SWITCH;
+ static comfyClass = NodeTypesString.ANY_SWITCH;
+
+ private stabilizeBound = this.stabilize.bind(this);
+ private nodeType: string | string[] | null = null;
+
+ constructor(title = RgthreeAnySwitch.title) {
+ super(title);
+ // Adding five. Note, configure will add as many as was in the stored workflow automatically.
+ this.addAnyInput(5);
+ }
+
+ override onConnectionsChange(
+ type: number,
+ slotIndex: number,
+ isConnected: boolean,
+ linkInfo: LLink,
+ ioSlot: INodeOutputSlot | INodeInputSlot,
+ ) {
+ super.onConnectionsChange?.(type, slotIndex, isConnected, linkInfo, ioSlot);
+ this.scheduleStabilize();
+ }
+
+ onConnectionsChainChange() {
+ this.scheduleStabilize();
+ }
+
+ scheduleStabilize(ms = 64) {
+ return debounce(this.stabilizeBound, ms);
+ }
+
+ private addAnyInput(num = 1) {
+ for (let i = 0; i < num; i++) {
+ this.addInput(
+ `any_${String(this.inputs.length + 1).padStart(2, "0")}`,
+ (this.nodeType || "*") as string,
+ );
+ }
+ }
+
+ stabilize() {
+ // First, clean up the dynamic number of inputs.
+ removeUnusedInputsFromEnd(this, 4);
+ this.addAnyInput();
+
+ // We prefer the inputs, then the output.
+ let connectedType = followConnectionUntilType(this, IoDirection.INPUT, undefined, true);
+ if (!connectedType) {
+ connectedType = followConnectionUntilType(this, IoDirection.OUTPUT, undefined, true);
+ }
+ // TODO: What this doesn't do is broadcast to other nodes when its type changes. Reroute node
+ // does, but, for now, if this was connected to another Any Switch, say, the second one wouldn't
+ // change its type when the first does. The user would need to change the connections.
+ this.nodeType = connectedType?.type || "*";
+ for (const input of this.inputs) {
+ input.type = this.nodeType as string; // So, types can indeed be arrays,,
+ }
+ for (const output of this.outputs) {
+ output.type = this.nodeType as string; // So, types can indeed be arrays,,
+ output.label =
+ output.type === "RGTHREE_CONTEXT"
+ ? "CONTEXT"
+ : Array.isArray(this.nodeType) || this.nodeType.includes(",")
+ ? connectedType?.label || String(this.nodeType)
+ : String(this.nodeType);
+ }
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeAnySwitch);
+ addConnectionLayoutSupport(RgthreeAnySwitch, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.AnySwitch",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: any, app: ComfyApp) {
+ if (nodeData.name === "Any Switch (rgthree)") {
+ RgthreeAnySwitch.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/base_any_input_connected_node.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/base_any_input_connected_node.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d54279a949528162111c57c0c732943cb53f30fc
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/base_any_input_connected_node.ts
@@ -0,0 +1,354 @@
+import type {
+ Vector2,
+ LLink,
+ INodeInputSlot,
+ INodeOutputSlot,
+ LGraphNode as TLGraphNode,
+ ISlotType,
+ ConnectByTypeOptions,
+ TWidgetType,
+ IWidgetOptions,
+ IWidget,
+ IBaseWidget,
+ WidgetTypeMap,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseVirtualNode} from "./base_node.js";
+import {rgthree} from "./rgthree.js";
+import {
+ PassThroughFollowing,
+ addConnectionLayoutSupport,
+ addMenuItem,
+ getConnectedInputNodes,
+ getConnectedInputNodesAndFilterPassThroughs,
+ getConnectedOutputNodes,
+ getConnectedOutputNodesAndFilterPassThroughs,
+} from "./utils.js";
+
+/**
+ * A Virtual Node that allows any node's output to connect to it.
+ */
+export class BaseAnyInputConnectedNode extends RgthreeBaseVirtualNode {
+ override isVirtualNode = true;
+
+ /**
+ * Whether inputs show the immediate nodes, or follow and show connected nodes through
+ * passthrough nodes.
+ */
+ readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.NONE;
+
+ debouncerTempWidth: number = 0;
+ schedulePromise: Promise | null = null;
+
+ constructor(title = BaseAnyInputConnectedNode.title) {
+ super(title);
+ }
+
+ override onConstructed() {
+ this.addInput("", "*");
+ return super.onConstructed();
+ }
+
+ override clone() {
+ const cloned = super.clone()!;
+ // Copying to clipboard (and also, creating node templates) work by cloning nodes and, for some
+ // reason, it manually manipulates the cloned data. So, we want to keep the present input slots
+ // so if it's pasted/templatized the data is correct. Otherwise, clear the inputs and so the new
+ // node is ready to go, fresh.
+ if (!rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes) {
+ while (cloned.inputs.length > 1) {
+ cloned.removeInput(cloned.inputs.length - 1);
+ }
+ if (cloned.inputs[0]) {
+ cloned.inputs[0].label = "";
+ }
+ }
+ return cloned;
+ }
+
+ /**
+ * Schedules a promise to run a stabilization, debouncing duplicate requests.
+ */
+ scheduleStabilizeWidgets(ms = 100) {
+ if (!this.schedulePromise) {
+ this.schedulePromise = new Promise((resolve) => {
+ setTimeout(() => {
+ this.schedulePromise = null;
+ this.doStablization();
+ resolve();
+ }, ms);
+ });
+ }
+ return this.schedulePromise;
+ }
+
+ /**
+ * Ensures we have at least one empty input at the end, returns true if changes were made, or false
+ * if no changes were needed.
+ */
+ private stabilizeInputsOutputs(): boolean {
+ let changed = false;
+ const hasEmptyInput = !this.inputs[this.inputs.length - 1]?.link;
+ if (!hasEmptyInput) {
+ this.addInput("", "*");
+ changed = true;
+ }
+ for (let index = this.inputs.length - 2; index >= 0; index--) {
+ const input = this.inputs[index]!;
+ if (!input.link) {
+ this.removeInput(index);
+ changed = true;
+ } else {
+ const node = getConnectedInputNodesAndFilterPassThroughs(
+ this,
+ this,
+ index,
+ this.inputsPassThroughFollowing,
+ )[0];
+ const newName = node?.title || "";
+ if (input.name !== newName) {
+ input.name = node?.title || "";
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+
+ /**
+ * Stabilizes the node's inputs and widgets.
+ */
+ private doStablization() {
+ if (!this.graph) {
+ return;
+ }
+ let dirty = false;
+
+ // When we add/remove widgets, litegraph is going to mess up the size, so we
+ // store it so we can retrieve it in computeSize. Hacky..
+ (this as any)._tempWidth = this.size[0];
+
+ dirty = this.stabilizeInputsOutputs();
+ const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
+ dirty = this.handleLinkedNodesStabilization(linkedNodes) || dirty;
+
+ // Only mark dirty if something's changed.
+ if (dirty) {
+ this.graph.setDirtyCanvas(true, true);
+ }
+
+ // Schedule another stabilization in the future.
+ this.scheduleStabilizeWidgets(500);
+ }
+
+ /**
+ * Handles stabilization of linked nodes. To be overridden. Should return true if changes were
+ * made, or false if no changes were needed.
+ */
+ handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]): boolean {
+ linkedNodes; // No-op, but makes overridding in VSCode cleaner.
+ throw new Error("handleLinkedNodesStabilization should be overridden.");
+ }
+
+ onConnectionsChainChange() {
+ this.scheduleStabilizeWidgets();
+ }
+
+ override onConnectionsChange(
+ type: number,
+ index: number,
+ connected: boolean,
+ linkInfo: LLink,
+ ioSlot: INodeOutputSlot | INodeInputSlot,
+ ) {
+ super.onConnectionsChange &&
+ super.onConnectionsChange(type, index, connected, linkInfo, ioSlot);
+ if (!linkInfo) return;
+ // Follow outputs to see if we need to trigger an onConnectionChange.
+ const connectedNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
+ for (const node of connectedNodes) {
+ if ((node as BaseAnyInputConnectedNode).onConnectionsChainChange) {
+ (node as BaseAnyInputConnectedNode).onConnectionsChainChange();
+ }
+ }
+ this.scheduleStabilizeWidgets();
+ }
+
+ override removeInput(slot: number) {
+ (this as any)._tempWidth = this.size[0];
+ return super.removeInput(slot);
+ }
+
+ override addInput>(
+ name: string,
+ type: ISlotType,
+ extra_info?: TProperties | undefined,
+ ): INodeInputSlot & TProperties {
+ (this as any)._tempWidth = this.size[0];
+ return super.addInput(name, type, extra_info);
+ }
+
+ override addWidget(
+ type: Type,
+ name: string,
+ value: TValue,
+ callback: IBaseWidget["callback"] | string | null,
+ options?: IWidgetOptions | string,
+ ):
+ | IBaseWidget>
+ | WidgetTypeMap[Type] {
+ (this as any)._tempWidth = this.size[0];
+ return super.addWidget(type, name, value, callback, options);
+ }
+
+ override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
+ (this as any)._tempWidth = this.size[0];
+ super.removeWidget(widget);
+ }
+
+ override computeSize(out: Vector2) {
+ let size = super.computeSize(out);
+ if ((this as any)._tempWidth) {
+ size[0] = (this as any)._tempWidth;
+ // We sometimes get repeated calls to compute size, so debounce before clearing.
+ this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
+ this.debouncerTempWidth = setTimeout(() => {
+ (this as any)._tempWidth = null;
+ }, 32);
+ }
+ // If we're collapsed, then subtract the total calculated height of the other input slots.
+ if (this.properties["collapse_connections"]) {
+ const rows = Math.max(this.inputs?.length || 0, this.outputs?.length || 0, 1) - 1;
+ size[1] = size[1] - rows * LiteGraph.NODE_SLOT_HEIGHT;
+ }
+ setTimeout(() => {
+ this.graph?.setDirtyCanvas(true, true);
+ }, 16);
+ return size;
+ }
+
+ /**
+ * When we connect our output, check our inputs and make sure we're not trying to connect a loop.
+ */
+ override onConnectOutput(
+ outputIndex: number,
+ inputType: string | -1,
+ inputSlot: INodeInputSlot,
+ inputNode: TLGraphNode,
+ inputIndex: number,
+ ): boolean {
+ let canConnect = true;
+ if (super.onConnectOutput) {
+ canConnect = super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
+ }
+ if (canConnect) {
+ const nodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
+ if (nodes.includes(inputNode)) {
+ alert(
+ `Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
+ `a situation that could create a time paradox, the results of which could cause a ` +
+ `chain reaction that would unravel the very fabric of the space time continuum, ` +
+ `and destroy the entire universe!`,
+ );
+ canConnect = false;
+ }
+ }
+ return canConnect;
+ }
+
+ override onConnectInput(
+ inputIndex: number,
+ outputType: string | -1,
+ outputSlot: INodeOutputSlot,
+ outputNode: TLGraphNode,
+ outputIndex: number,
+ ): boolean {
+ let canConnect = true;
+ if (super.onConnectInput) {
+ canConnect = super.onConnectInput(
+ inputIndex,
+ outputType,
+ outputSlot,
+ outputNode,
+ outputIndex,
+ );
+ }
+ if (canConnect) {
+ const nodes = getConnectedOutputNodes(this); // We want passthrough nodes, since they will loop.
+ if (nodes.includes(outputNode)) {
+ alert(
+ `Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
+ `a situation that could create a time paradox, the results of which could cause a ` +
+ `chain reaction that would unravel the very fabric of the space time continuum, ` +
+ `and destroy the entire universe!`,
+ );
+ canConnect = false;
+ }
+ }
+ return canConnect;
+ }
+
+ /**
+ * If something is dropped on us, just add it to the bottom. onConnectInput should already cancel
+ * if it's disallowed.
+ */
+ override connectByTypeOutput(
+ slot: number | string,
+ sourceNode: TLGraphNode,
+ sourceSlotType: ISlotType,
+ optsIn?: ConnectByTypeOptions,
+ ): LLink | null {
+ const lastInput = this.inputs[this.inputs.length - 1];
+ if (!lastInput?.link && lastInput?.type === "*") {
+ var sourceSlot = sourceNode.findOutputSlotByType(sourceSlotType, false, true);
+ return sourceNode.connect(sourceSlot, this, slot);
+ }
+ return super.connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn);
+ }
+
+ static override setUp() {
+ super.setUp();
+ addConnectionLayoutSupport(this, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ addMenuItem(this, app, {
+ name: (node) =>
+ `${node.properties?.["collapse_connections"] ? "Show" : "Collapse"} Connections`,
+ property: "collapse_connections",
+ prepareValue: (_value, node) => !node.properties?.["collapse_connections"],
+ callback: (_node) => {
+ app.canvas.getCurrentGraph()?.setDirtyCanvas(true, true);
+ },
+ });
+ }
+}
+
+// Ok, hack time! LGraphNode's connectByType is powerful, but for our nodes, that have multiple "*"
+// input types, it seems it just takes the first one, and disconnects it. I'd rather we don't do
+// that and instead take the next free one. If that doesn't work, then we'll give it to the old
+// method.
+const oldLGraphNodeConnectByType = LGraphNode.prototype.connectByType;
+LGraphNode.prototype.connectByType = function connectByType(
+ slot: number | string,
+ targetNode: TLGraphNode,
+ targetSlotType: ISlotType,
+ optsIn?: ConnectByTypeOptions,
+): LLink | null {
+ // If we're dropping on a node, and the last input is free and an "*" type, then connect there
+ // first...
+ if (targetNode.inputs) {
+ for (const [index, input] of targetNode.inputs.entries()) {
+ if (!input.link && input.type === "*") {
+ this.connect(slot, targetNode, index);
+ return null;
+ }
+ }
+ }
+ return (
+ (oldLGraphNodeConnectByType &&
+ oldLGraphNodeConnectByType.call(this, slot, targetNode, targetSlotType, optsIn)) ||
+ null
+ );
+};
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts
new file mode 100644
index 0000000000000000000000000000000000000000..be4e1be533355e24b47acb73cb5d03b988532150
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts
@@ -0,0 +1,504 @@
+import type {
+ IWidget,
+ LGraphCanvas,
+ IContextMenuValue,
+ IFoundSlot,
+ LGraphEventMode,
+ LGraphNodeConstructor,
+ ISerialisedNode,
+ IBaseWidget,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+import type {RgthreeBaseServerNodeConstructor} from "typings/rgthree.js";
+
+import {app} from "scripts/app.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
+import {LogLevel, rgthree} from "./rgthree.js";
+import {addHelpMenuItem} from "./utils.js";
+import {RgthreeHelpDialog} from "rgthree/common/dialog.js";
+import {
+ importIndividualNodesInnerOnDragDrop,
+ importIndividualNodesInnerOnDragOver,
+} from "./feature_import_individual_nodes.js";
+import {defineProperty, moveArrayItem} from "rgthree/common/shared_utils.js";
+
+/**
+ * A base node with standard methods, directly extending the LGraphNode.
+ * This can be used for ui-nodes and a further base for server nodes.
+ */
+export abstract class RgthreeBaseNode extends LGraphNode {
+ /**
+ * Action strings that can be exposed and triggered from other nodes, like Fast Actions Button.
+ */
+ static exposedActions: string[] = [];
+
+ static override title: string = "__NEED_CLASS_TITLE__";
+ static override type: string = "__NEED_CLASS_TYPE__";
+ static override category = "rgthree";
+ static _category = "rgthree"; // `category` seems to get reset by comfy, so reset to this after.
+
+ /** Our constructor ensures there's a widget array, so we get rid of the nullability. */
+ override widgets!: IWidget[];
+
+ /**
+ * The comfyClass is property ComfyUI and extensions may care about, even through it is only for
+ * server nodes. RgthreeBaseServerNode below overrides this with the expected value and we just
+ * set it here so extensions that are none the wiser don't break on some unchecked string method
+ * call on an undefined calue.
+ */
+ override comfyClass: string = "__NEED_COMFY_CLASS__";
+
+ /** Used by the ComfyUI-Manager badge. */
+ readonly nickname = "rgthree";
+ /** Are we a virtual node? */
+ override readonly isVirtualNode: boolean = false;
+ /** Are we able to be dropped on (if config is enabled too). */
+ isDropEnabled = false;
+ /** A state member determining if we're currently removed. */
+ removed = false;
+ /** A state member determining if we're currently "configuring."" */
+ configuring = false;
+ /** A temporary width value that can be used to ensure compute size operates correctly. */
+ _tempWidth = 0;
+
+ /** Private Mode member so we can override the setter/getter and call an `onModeChange`. */
+ private rgthree_mode?: LGraphEventMode;
+
+ /** An internal bool set when `onConstructed` is run. */
+ private __constructed__ = false;
+ /** The help dialog. */
+ private helpDialog: RgthreeHelpDialog | null = null;
+
+ constructor(title = RgthreeBaseNode.title, skipOnConstructedCall = true) {
+ super(title);
+ if (title == "__NEED_CLASS_TITLE__") {
+ throw new Error("RgthreeBaseNode needs overrides.");
+ }
+ // Ensure these exist since some other extensions will break in their onNodeCreated.
+ this.widgets = this.widgets || [];
+ this.properties = this.properties || {};
+
+ // Some checks we want to do after we're constructed, looking that data is set correctly and
+ // that our base's `onConstructed` was called (if not, set a DEV warning).
+ setTimeout(() => {
+ // Check we have a comfyClass defined.
+ if (this.comfyClass == "__NEED_COMFY_CLASS__") {
+ throw new Error("RgthreeBaseNode needs a comfy class override.");
+ }
+ if (this.constructor.type == "__NEED_CLASS_TYPE__") {
+ throw new Error("RgthreeBaseNode needs overrides.");
+ }
+ // Ensure we've called onConstructed before we got here.
+ this.checkAndRunOnConstructed();
+ });
+
+ defineProperty(this, "mode", {
+ get: () => {
+ return this.rgthree_mode;
+ },
+ set: (mode: LGraphEventMode) => {
+ if (this.rgthree_mode != mode) {
+ const oldMode = this.rgthree_mode;
+ this.rgthree_mode = mode;
+ this.onModeChange(oldMode, mode);
+ }
+ },
+ });
+ }
+
+ private checkAndRunOnConstructed() {
+ if (!this.__constructed__) {
+ this.onConstructed();
+ const [n, v] = rgthree.logger.logParts(
+ LogLevel.DEV,
+ `[RgthreeBaseNode] Child class did not call onConstructed for "${this.type}.`,
+ );
+ console[n]?.(...v);
+ }
+ return this.__constructed__;
+ }
+
+ override onDragOver(e: DragEvent): boolean {
+ if (!this.isDropEnabled) return false;
+ return importIndividualNodesInnerOnDragOver(this, e);
+ }
+
+ override async onDragDrop(e: DragEvent): Promise {
+ if (!this.isDropEnabled) return false;
+ return importIndividualNodesInnerOnDragDrop(this, e);
+ }
+
+ /**
+ * When a node is finished with construction, we must call this. Failure to do so will result in
+ * an error message from the timeout in this base class. This is broken out and becomes the
+ * responsibility of the child class because
+ */
+ onConstructed() {
+ if (this.__constructed__) return false;
+ // This is kinda a hack, but if this.type is still null, then set it to undefined to match.
+ this.type = this.type ?? undefined;
+ this.__constructed__ = true;
+ rgthree.invokeExtensionsAsync("nodeCreated", this);
+ return this.__constructed__;
+ }
+
+ override configure(info: ISerialisedNode): void {
+ this.configuring = true;
+ super.configure(info);
+ // Fix https://github.com/comfyanonymous/ComfyUI/issues/1448 locally.
+ // Can removed when fixed and adopted.
+ for (const w of this.widgets || []) {
+ w.last_y = w.last_y || 0;
+ }
+ this.configuring = false;
+ }
+
+ /**
+ * Override clone for, at the least, deep-copying properties.
+ */
+ override clone() {
+ const cloned = super.clone()!;
+ // This is wild, but LiteGraph doesn't deep clone data, so we will. We'll use structured clone,
+ // which most browsers in 2022 support, but but we'll check.
+ if (cloned?.properties && !!window.structuredClone) {
+ cloned.properties = structuredClone(cloned.properties);
+ }
+ // [🤮] https://github.com/Comfy-Org/ComfyUI_frontend/issues/5037
+ // ComfyUI started throwing errors when some of our nodes wanted to remove inputs when cloning
+ // (like our dynamic inputs) because the disconnect method that's automatically called assumes
+ // there should be a graph. For now, I _think_ we can simply assign the current graph to avoid
+ // the error, which would then be overwritten when placed...
+ cloned.graph = this.graph;
+ return cloned;
+ }
+
+ /** When a mode change, we want all connected nodes to match. */
+ onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
+ // Override
+ }
+
+ /**
+ * Given a string, do something. At the least, handle any `exposedActions` that may be called and
+ * passed into from other nodes, like Fast Actions Button
+ */
+ async handleAction(action: string) {
+ action; // No-op. Should be overridden but OK if not.
+ }
+
+ /**
+ * This didn't exist in LiteGraph/Comfy, but now it's added. Ours was a bit more flexible, though.
+ */
+ override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
+ if (typeof widget === "number") {
+ widget = this.widgets[widget];
+ }
+ if (!widget) return;
+
+ // Comfy added their own removeWidget, but it's not fully rolled out to stable, so keep our
+ // original implementation.
+ // TODO: Actually, scratch that. The ComfyUI impl doesn't call widtget.onRemove?.() and so
+ // we shouldn't switch to it yet. See: https://github.com/Comfy-Org/ComfyUI_frontend/issues/5090
+ const canUseComfyUIRemoveWidget = false;
+ if (canUseComfyUIRemoveWidget && typeof super.removeWidget === 'function') {
+ super.removeWidget(widget as IBaseWidget);
+ } else {
+ const index = this.widgets.indexOf(widget as IWidget);
+ if (index > -1) {
+ this.widgets.splice(index, 1);
+ }
+ widget.onRemove?.();
+ }
+ }
+
+ /**
+ * Replaces an existing widget.
+ */
+ replaceWidget(widgetOrSlot: IWidget | number | undefined, newWidget: IWidget) {
+ let index = null;
+ if (widgetOrSlot) {
+ index = typeof widgetOrSlot === "number" ? widgetOrSlot : this.widgets.indexOf(widgetOrSlot);
+ this.removeWidget(this.widgets[index]!);
+ }
+ index = index != null ? index : this.widgets.length - 1;
+ if (this.widgets.includes(newWidget)) {
+ moveArrayItem(this.widgets, newWidget, index);
+ } else {
+ this.widgets.splice(index, 0, newWidget);
+ }
+ }
+
+ /**
+ * A default version of the logive when a node does not set `getSlotMenuOptions`. This is
+ * necessary because child nodes may want to define getSlotMenuOptions but LiteGraph then won't do
+ * it's default logic. This bakes it so child nodes can call this instead (and this doesn't set
+ * getSlotMenuOptions for all child nodes in case it doesn't exist).
+ */
+ defaultGetSlotMenuOptions(slot: IFoundSlot): IContextMenuValue[] {
+ const menu_info: IContextMenuValue[] = [];
+ if (slot?.output?.links?.length) {
+ menu_info.push({content: "Disconnect Links", slot});
+ }
+ let inputOrOutput = slot.input || slot.output;
+ if (inputOrOutput) {
+ if (inputOrOutput.removable) {
+ menu_info.push(
+ inputOrOutput.locked ? {content: "Cannot remove"} : {content: "Remove Slot", slot},
+ );
+ }
+ if (!inputOrOutput.nameLocked) {
+ menu_info.push({content: "Rename Slot", slot});
+ }
+ }
+ return menu_info;
+ }
+
+ override onRemoved(): void {
+ super.onRemoved?.();
+ this.removed = true;
+ }
+
+ static setUp(...args: any[]) {
+ // No-op.
+ }
+
+ /**
+ * A function to provide help text to be overridden.
+ */
+ getHelp() {
+ return "";
+ }
+
+ showHelp() {
+ const help = this.getHelp() || (this.constructor as any).help;
+ if (help) {
+ this.helpDialog = new RgthreeHelpDialog(this, help).show();
+ this.helpDialog.addEventListener("close", (e) => {
+ this.helpDialog = null;
+ });
+ }
+ }
+
+ override onKeyDown(event: KeyboardEvent): void {
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
+ if (event.key == "?" && !this.helpDialog) {
+ this.showHelp();
+ }
+ }
+
+ override onKeyUp(event: KeyboardEvent): void {
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
+ }
+
+ override getExtraMenuOptions(
+ canvas: LGraphCanvas,
+ options: (IContextMenuValue | null)[],
+ ): (IContextMenuValue | null)[] {
+ // Some other extensions override getExtraMenuOptions on the nodeType as it comes through from
+ // the server, so we can call out to that if we don't have our own.
+ if (super.getExtraMenuOptions) {
+ super.getExtraMenuOptions?.apply(this, [canvas, options]);
+ } else if (this.constructor.nodeType?.prototype?.getExtraMenuOptions) {
+ this.constructor.nodeType?.prototype?.getExtraMenuOptions?.apply(this, [canvas, options]);
+ }
+ // If we have help content, then add a menu item.
+ const help = this.getHelp() || (this.constructor as any).help;
+ if (help) {
+ addHelpMenuItem(this, help, options);
+ }
+ return [];
+ }
+}
+
+/**
+ * A virtual node. Right now, this is just a wrapper for RgthreeBaseNode (which was the initial
+ * base virtual node).
+ */
+export class RgthreeBaseVirtualNode extends RgthreeBaseNode {
+ override isVirtualNode = true;
+
+ constructor(title = RgthreeBaseNode.title) {
+ super(title, false);
+ }
+
+ static override setUp() {
+ if (!this.type) {
+ throw new Error(`Missing type for RgthreeBaseVirtualNode: ${this.title}`);
+ }
+ LiteGraph.registerNodeType(this.type, this);
+ if (this._category) {
+ this.category = this._category;
+ }
+ }
+}
+
+/**
+ * A base node with standard methods, extending the LGraphNode.
+ * This is somewhat experimental, but if comfyui is going to keep breaking widgets and inputs, it
+ * seems safer than NOT overriding.
+ */
+export class RgthreeBaseServerNode extends RgthreeBaseNode {
+ static nodeType: LGraphNodeConstructor | null = null;
+ static nodeData: ComfyNodeDef | null = null;
+
+ // Drop is enabled by default for server nodes.
+ override isDropEnabled = true;
+
+ constructor(title: string) {
+ super(title, true);
+ this.serialize_widgets = true;
+ this.setupFromServerNodeData();
+ this.onConstructed();
+ }
+
+ getWidgets() {
+ return ComfyWidgets;
+ }
+
+ /**
+ * This takes the server data and builds out the inputs, outputs and widgets. It's similar to the
+ * ComfyNode constructor in registerNodes in ComfyUI's app.js, but is more stable and thus
+ * shouldn't break as often when it modifyies widgets and types.
+ */
+ async setupFromServerNodeData() {
+ const nodeData = (this.constructor as any).nodeData;
+ if (!nodeData) {
+ throw Error("No node data");
+ }
+
+ // Necessary for serialization so Comfy backend can check types.
+ // Serialized as `class_type`. See app.js#graphToPrompt
+ this.comfyClass = nodeData.name;
+
+ let inputs = nodeData["input"]["required"];
+ if (nodeData["input"]["optional"] != undefined) {
+ inputs = Object.assign({}, inputs, nodeData["input"]["optional"]);
+ }
+
+ const WIDGETS = this.getWidgets();
+
+ const config: {minWidth: number; minHeight: number; widget?: null | {options: any}} = {
+ minWidth: 1,
+ minHeight: 1,
+ widget: null,
+ };
+ for (const inputName in inputs) {
+ const inputData = inputs[inputName];
+ const type = inputData[0];
+ // If we're forcing the input, just do it now and forget all that widget stuff.
+ // This is one of the differences from ComfyNode and provides smoother experience for inputs
+ // that are going to remain inputs anyway.
+ // Also, it fixes https://github.com/comfyanonymous/ComfyUI/issues/1404 (for rgthree nodes)
+ if (inputData[1]?.forceInput) {
+ this.addInput(inputName, type);
+ } else {
+ let widgetCreated = true;
+ if (Array.isArray(type)) {
+ // Enums
+ Object.assign(config, WIDGETS.COMBO(this, inputName, inputData, app) || {});
+ } else if (`${type}:${inputName}` in WIDGETS) {
+ // Support custom widgets by Type:Name
+ Object.assign(
+ config,
+ WIDGETS[`${type}:${inputName}`]!(this, inputName, inputData, app) || {},
+ );
+ } else if (type in WIDGETS) {
+ // Standard type widgets
+ Object.assign(config, WIDGETS[type]!(this, inputName, inputData, app) || {});
+ } else {
+ // Node connection inputs
+ this.addInput(inputName, type);
+ widgetCreated = false;
+ }
+
+ // Don't actually need this right now, but ported it over from ComfyWidget.
+ if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
+ if (!config.widget.options) config.widget.options = {};
+ config.widget.options.forceInput = inputData[1].forceInput;
+ }
+ if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
+ if (!config.widget.options) config.widget.options = {};
+ config.widget.options.defaultInput = inputData[1].defaultInput;
+ }
+ }
+ }
+
+ for (const o in nodeData["output"]) {
+ let output = nodeData["output"][o];
+ if (output instanceof Array) output = "COMBO";
+ const outputName = nodeData["output_name"][o] || output;
+ const outputShape = nodeData["output_is_list"][o]
+ ? LiteGraph.GRID_SHAPE
+ : LiteGraph.CIRCLE_SHAPE;
+ this.addOutput(outputName, output, {shape: outputShape});
+ }
+
+ const s = this.computeSize();
+ // Sometime around v1.12.6 this broke as `minWidth` and `minHeight` were being explicitly set
+ // to `undefined` in the above Object.assign call (specifically for `WIDGETS[INT]`. We can avoid
+ // that by ensureing we're at a number in that case.
+ // See https://github.com/Comfy-Org/ComfyUI_frontend/issues/3045
+ s[0] = Math.max(config.minWidth ?? 1, s[0] * 1.5);
+ s[1] = Math.max(config.minHeight ?? 1, s[1]);
+ this.size = s;
+ this.serialize_widgets = true;
+ }
+
+ static __registeredForOverride__: boolean = false;
+ static registerForOverride(
+ comfyClass: typeof LGraphNode,
+ nodeData: ComfyNodeDef,
+ rgthreeClass: RgthreeBaseServerNodeConstructor,
+ ) {
+ if (OVERRIDDEN_SERVER_NODES.has(comfyClass)) {
+ throw Error(
+ `Already have a class to override ${
+ comfyClass.type || comfyClass.name || comfyClass.title
+ }`,
+ );
+ }
+ OVERRIDDEN_SERVER_NODES.set(comfyClass, rgthreeClass);
+ // Mark the rgthreeClass as `__registeredForOverride__` because ComfyUI will repeatedly call
+ // this and certain setups will only want to setup once (like adding context menus, etc).
+ if (!rgthreeClass.__registeredForOverride__) {
+ rgthreeClass.__registeredForOverride__ = true;
+ rgthreeClass.nodeType = comfyClass;
+ rgthreeClass.nodeData = nodeData;
+ rgthreeClass.onRegisteredForOverride(comfyClass, rgthreeClass);
+ }
+ }
+
+ static onRegisteredForOverride(comfyClass: any, rgthreeClass: any) {
+ // To be overridden
+ }
+}
+
+/**
+ * Keeps track of the rgthree-comfy nodes that come from the server (and want to be ComfyNodes) that
+ * we override into a own, more flexible and cleaner nodes.
+ */
+const OVERRIDDEN_SERVER_NODES = new Map();
+
+const oldregisterNodeType = LiteGraph.registerNodeType;
+/**
+ * ComfyUI calls registerNodeType with its ComfyNode, but we don't trust that will remain stable, so
+ * we need to identify it, intercept it, and supply our own class for the node.
+ */
+LiteGraph.registerNodeType = async function (nodeId: string, baseClass: any) {
+ const clazz = OVERRIDDEN_SERVER_NODES.get(baseClass) || baseClass;
+ if (clazz !== baseClass) {
+ const classLabel = clazz.type || clazz.name || clazz.title;
+ const [n, v] = rgthree.logger.logParts(
+ LogLevel.DEBUG,
+ `${nodeId}: replacing default ComfyNode implementation with custom ${classLabel} class.`,
+ );
+ console[n]?.(...v);
+ // Note, we don't currently call our rgthree.invokeExtensionsAsync w/ beforeRegisterNodeDef as
+ // this runs right after that. However, this does mean that extensions cannot actually change
+ // anything about overriden server rgthree nodes in their beforeRegisterNodeDef (as when comfy
+ // calls it, it's for the wrong ComfyNode class). Calling it here, however, would re-run
+ // everything causing more issues than not. If we wanted to support beforeRegisterNodeDef then
+ // it would mean rewriting ComfyUI's registerNodeDef which, frankly, is not worth it.
+ }
+ return oldregisterNodeType.call(LiteGraph, nodeId, clazz);
+};
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/base_node_collector.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/base_node_collector.ts
new file mode 100644
index 0000000000000000000000000000000000000000..30076301e30ee179ebd566bfd6e6b4f3ac54c122
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/base_node_collector.ts
@@ -0,0 +1,99 @@
+import type {INodeOutputSlot, LGraphNode} from "@comfyorg/frontend";
+
+import {rgthree} from "./rgthree.js";
+import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
+import {
+ PassThroughFollowing,
+ getConnectedInputNodes,
+ getConnectedInputNodesAndFilterPassThroughs,
+ shouldPassThrough,
+} from "./utils.js";
+
+/**
+ * Base collector node that monitors changing inputs and outputs.
+ */
+export class BaseCollectorNode extends BaseAnyInputConnectedNode {
+ /**
+ * We only want to show nodes through re_route nodes, other pass through nodes show each input.
+ */
+ override readonly inputsPassThroughFollowing: PassThroughFollowing =
+ PassThroughFollowing.REROUTE_ONLY;
+
+ readonly logger = rgthree.newLogSession("[BaseCollectorNode]");
+
+ constructor(title?: string) {
+ super(title);
+ }
+
+ override clone() {
+ const cloned = super.clone()!;
+ return cloned;
+ }
+
+ override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
+ return false; // No-op, no widgets.
+ }
+
+ /**
+ * When we connect an input, check to see if it's already connected and cancel it.
+ */
+ override onConnectInput(
+ inputIndex: number,
+ outputType: string | -1,
+ outputSlot: INodeOutputSlot,
+ outputNode: LGraphNode,
+ outputIndex: number,
+ ): boolean {
+ let canConnect = super.onConnectInput(
+ inputIndex,
+ outputType,
+ outputSlot,
+ outputNode,
+ outputIndex,
+ );
+ if (canConnect) {
+ const allConnectedNodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
+ const nodesAlreadyInSlot = getConnectedInputNodes(this, undefined, inputIndex);
+ if (allConnectedNodes.includes(outputNode)) {
+ // If we're connecting to the same slot, then allow it by replacing the one we have.
+ // const slotsOriginNode = getOriginNodeByLink(this.inputs[inputIndex]?.link);
+ const [n, v] = this.logger.debugParts(
+ `${outputNode.title} is already connected to ${this.title}.`,
+ );
+ console[n]?.(...v);
+ if (nodesAlreadyInSlot.includes(outputNode)) {
+ const [n, v] = this.logger.debugParts(
+ `... but letting it slide since it's for the same slot.`,
+ );
+ console[n]?.(...v);
+ } else {
+ canConnect = false;
+ }
+ }
+ if (canConnect && shouldPassThrough(outputNode, PassThroughFollowing.REROUTE_ONLY)) {
+ const connectedNode = getConnectedInputNodesAndFilterPassThroughs(
+ outputNode,
+ undefined,
+ undefined,
+ PassThroughFollowing.REROUTE_ONLY,
+ )[0];
+ if (connectedNode && allConnectedNodes.includes(connectedNode)) {
+ // If we're connecting to the same slot, then allow it by replacing the one we have.
+ const [n, v] = this.logger.debugParts(
+ `${connectedNode.title} is already connected to ${this.title}.`,
+ );
+ console[n]?.(...v);
+ if (nodesAlreadyInSlot.includes(connectedNode)) {
+ const [n, v] = this.logger.debugParts(
+ `... but letting it slide since it's for the same slot.`,
+ );
+ console[n]?.(...v);
+ } else {
+ canConnect = false;
+ }
+ }
+ }
+ }
+ return canConnect;
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/base_node_mode_changer.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/base_node_mode_changer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae1afeafd900c6143a696c5612fc63edf59327d3
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/base_node_mode_changer.ts
@@ -0,0 +1,106 @@
+import type {LGraphNode, IWidget} from "@comfyorg/frontend";
+
+import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
+import {changeModeOfNodes, PassThroughFollowing} from "./utils.js";
+import {wait} from "rgthree/common/shared_utils.js";
+
+export class BaseNodeModeChanger extends BaseAnyInputConnectedNode {
+ override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
+
+ static collapsible = false;
+ override isVirtualNode = true;
+
+ // These Must be overriden
+ readonly modeOn: number = -1;
+ readonly modeOff: number = -1;
+
+ static "@toggleRestriction" = {
+ type: "combo",
+ values: ["default", "max one", "always one"],
+ };
+
+ constructor(title?: string) {
+ super(title);
+ this.properties["toggleRestriction"] = "default";
+ }
+
+ override onConstructed(): boolean {
+ wait(10).then(() => {
+ if (this.modeOn < 0 || this.modeOff < 0) {
+ throw new Error("modeOn and modeOff must be overridden.");
+ }
+ });
+ this.addOutput("OPT_CONNECTION", "*");
+ return super.onConstructed();
+ }
+
+ override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
+ let changed = false;
+ for (const [index, node] of linkedNodes.entries()) {
+ let widget: IWidget | undefined = this.widgets && this.widgets[index];
+ if (!widget) {
+ // When we add a widget, litegraph is going to mess up the size, so we
+ // store it so we can retrieve it in computeSize. Hacky..
+ (this as any)._tempWidth = this.size[0];
+ widget = this.addWidget("toggle", "", false, "", {on: "yes", off: "no"}) as IWidget;
+ changed = true;
+ }
+ if (node) {
+ changed = this.setWidget(widget, node) || changed;
+ }
+ }
+ if (this.widgets && this.widgets.length > linkedNodes.length) {
+ this.widgets.length = linkedNodes.length;
+ changed = true;
+ }
+ return changed;
+ }
+
+ private setWidget(widget: IWidget, linkedNode: LGraphNode, forceValue?: boolean) {
+ let changed = false;
+ const value = forceValue == null ? linkedNode.mode === this.modeOn : forceValue;
+ let name = `Enable ${linkedNode.title}`;
+ // Need to set initally
+ if (widget.name !== name) {
+ widget.name = `Enable ${linkedNode.title}`;
+ widget.options = {on: "yes", off: "no"};
+ widget.value = value;
+ (widget as any).doModeChange = (forceValue?: boolean, skipOtherNodeCheck?: boolean) => {
+ let newValue = forceValue == null ? linkedNode.mode === this.modeOff : forceValue;
+ if (skipOtherNodeCheck !== true) {
+ if (newValue && (this.properties?.["toggleRestriction"] as string)?.includes(" one")) {
+ for (const widget of this.widgets) {
+ (widget as any).doModeChange(false, true);
+ }
+ } else if (!newValue && this.properties?.["toggleRestriction"] === "always one") {
+ newValue = this.widgets.every((w) => !w.value || w === widget);
+ }
+ }
+ changeModeOfNodes(linkedNode, (newValue ? this.modeOn : this.modeOff))
+ widget.value = newValue;
+ };
+ widget.callback = () => {
+ (widget as any).doModeChange();
+ };
+ changed = true;
+ }
+ if (forceValue != null) {
+ const newMode = (forceValue ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4;
+ if (linkedNode.mode !== newMode) {
+ changeModeOfNodes(linkedNode, newMode);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ forceWidgetOff(widget: IWidget, skipOtherNodeCheck?: boolean) {
+ (widget as any).doModeChange(false, skipOtherNodeCheck);
+ }
+ forceWidgetOn(widget: IWidget, skipOtherNodeCheck?: boolean) {
+ (widget as any).doModeChange(true, skipOtherNodeCheck);
+ }
+ forceWidgetToggle(widget: IWidget, skipOtherNodeCheck?: boolean) {
+ (widget as any).doModeChange(!widget.value, skipOtherNodeCheck);
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..02a161b7e88be4f6184578578b14d0f0c869373d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts
@@ -0,0 +1,366 @@
+import type {
+ LLink,
+ LGraphNode,
+ INodeOutputSlot,
+ INodeInputSlot,
+ ISerialisedNode,
+ IComboWidget,
+ IBaseWidget,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {api} from "scripts/api.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {rgthree} from "./rgthree.js";
+
+/** Wraps a node instance keeping closure without mucking the finicky types. */
+export class PowerPrompt {
+ readonly isSimple: boolean;
+ readonly node: LGraphNode;
+ readonly promptEl: HTMLTextAreaElement;
+ nodeData: ComfyNodeDef;
+ readonly combos: {[key: string]: IComboWidget} = {};
+ readonly combosValues: {[key: string]: string[]} = {};
+ boundOnFreshNodeDefs!: (event: CustomEvent) => void;
+
+ private configuring = false;
+
+ constructor(node: LGraphNode, nodeData: ComfyNodeDef) {
+ this.node = node;
+ this.node.properties = this.node.properties || {};
+
+ this.node.properties["combos_filter"] = "";
+
+ this.nodeData = nodeData;
+ this.isSimple = this.nodeData.name.includes("Simple");
+
+ this.promptEl = (node.widgets![0]! as any).inputEl;
+ this.addAndHandleKeyboardLoraEditWeight();
+
+ this.patchNodeRefresh();
+
+ const oldConfigure = this.node.configure;
+ this.node.configure = (info: ISerialisedNode) => {
+ this.configuring = true;
+ oldConfigure?.apply(this.node, [info]);
+ this.configuring = false;
+ };
+
+ const oldOnConnectionsChange = this.node.onConnectionsChange;
+ this.node.onConnectionsChange = (
+ type: number,
+ slotIndex: number,
+ isConnected: boolean,
+ link_info: LLink,
+ _ioSlot: INodeOutputSlot | INodeInputSlot,
+ ) => {
+ oldOnConnectionsChange?.apply(this.node, [type, slotIndex, isConnected, link_info, _ioSlot]);
+ this.onNodeConnectionsChange(type, slotIndex, isConnected, link_info, _ioSlot);
+ };
+
+ const oldOnConnectInput = this.node.onConnectInput;
+ this.node.onConnectInput = (
+ inputIndex: number,
+ outputType: INodeOutputSlot["type"],
+ outputSlot: INodeOutputSlot,
+ outputNode: LGraphNode,
+ outputIndex: number,
+ ) => {
+ let canConnect = true;
+ if (oldOnConnectInput) {
+ canConnect = oldOnConnectInput.apply(this.node, [
+ inputIndex,
+ outputType,
+ outputSlot,
+ outputNode,
+ outputIndex,
+ ]);
+ }
+ return (
+ this.configuring ||
+ !!rgthree.loadingApiJson ||
+ (canConnect && !this.node.inputs[inputIndex]!.disabled)
+ );
+ };
+
+ const oldOnConnectOutput = this.node.onConnectOutput;
+ this.node.onConnectOutput = (
+ outputIndex: number,
+ inputType: INodeInputSlot["type"],
+ inputSlot: INodeInputSlot,
+ inputNode: LGraphNode,
+ inputIndex: number,
+ ) => {
+ let canConnect = true;
+ if (oldOnConnectOutput) {
+ canConnect = oldOnConnectOutput?.apply(this.node, [
+ outputIndex,
+ inputType,
+ inputSlot,
+ inputNode,
+ inputIndex,
+ ]);
+ }
+ return (
+ this.configuring ||
+ !!rgthree.loadingApiJson ||
+ (canConnect && !this.node.outputs[outputIndex]!.disabled)
+ );
+ };
+
+ const onPropertyChanged = this.node.onPropertyChanged;
+ this.node.onPropertyChanged = (property: string, value: any, prevValue: any) => {
+ const v = onPropertyChanged && onPropertyChanged.call(this.node, property, value, prevValue);
+ if (property === "combos_filter") {
+ this.refreshCombos(this.nodeData);
+ }
+ return v ?? true;
+ };
+
+ // Strip all widgets but prompt (we'll re-add them in refreshCombos)
+ // this.node.widgets.splice(1);
+ for (let i = this.node.widgets!.length - 1; i >= 0; i--) {
+ if (this.shouldRemoveServerWidget(this.node.widgets![i]!)) {
+ this.node.widgets!.splice(i, 1);
+ }
+ }
+
+ this.refreshCombos(nodeData);
+ setTimeout(() => {
+ this.stabilizeInputsOutputs();
+ }, 32);
+ }
+
+ /**
+ * Cleans up optional out puts when we don't have the optional input. Purely a vanity function.
+ */
+ onNodeConnectionsChange(
+ _type: number,
+ _slotIndex: number,
+ _isConnected: boolean,
+ _linkInfo: LLink,
+ _ioSlot: INodeOutputSlot | INodeInputSlot,
+ ) {
+ this.stabilizeInputsOutputs();
+ }
+
+ private stabilizeInputsOutputs() {
+ // If we are currently "configuring" then skip this stabilization. The connected nodes may
+ // not yet be configured.
+ if (this.configuring || rgthree.loadingApiJson) {
+ return;
+ }
+ // If our first input is connected, then we can show the proper output.
+ const clipLinked = this.node.inputs.some((i) => i.name.includes("clip") && !!i.link);
+ const modelLinked = this.node.inputs.some((i) => i.name.includes("model") && !!i.link);
+ for (const output of this.node.outputs) {
+ const type = (output.type as string).toLowerCase();
+ if (type.includes("model")) {
+ output.disabled = !modelLinked;
+ } else if (type.includes("conditioning")) {
+ output.disabled = !clipLinked;
+ } else if (type.includes("clip")) {
+ output.disabled = !clipLinked;
+ } else if (type.includes("string")) {
+ // Our text prompt is always enabled, but let's color it so it stands out
+ // if the others are disabled. #7F7 is Litegraph's default.
+ output.color_off = "#7F7";
+ output.color_on = "#7F7";
+ }
+ if (output.disabled) {
+ // this.node.disconnectOutput(index);
+ }
+ }
+ }
+
+ onFreshNodeDefs(event: CustomEvent) {
+ this.refreshCombos(event.detail[this.nodeData.name]);
+ }
+
+ shouldRemoveServerWidget(widget: IBaseWidget) {
+ return (
+ widget.name?.startsWith("insert_") ||
+ widget.name?.startsWith("target_") ||
+ widget.name?.startsWith("crop_") ||
+ widget.name?.startsWith("values_")
+ );
+ }
+
+ refreshCombos(nodeData: ComfyNodeDef) {
+ this.nodeData = nodeData;
+ let filter: RegExp | null = null;
+ if ((this.node.properties["combos_filter"] as string)?.trim()) {
+ try {
+ filter = new RegExp((this.node.properties["combos_filter"] as string).trim(), "i");
+ } catch (e) {
+ console.error(`Could not parse "${filter}" for Regular Expression`, e);
+ filter = null;
+ }
+ }
+
+ // Add the combo for hidden inputs of nodeData
+ let data = Object.assign(
+ {},
+ this.nodeData.input?.optional || {},
+ this.nodeData.input?.hidden || {},
+ );
+
+ for (const [key, value] of Object.entries(data)) {
+ //Object.entries(this.nodeData.input?.hidden || {})) {
+ if (Array.isArray(value[0])) {
+ let values = value[0] as string[];
+ if (key.startsWith("insert")) {
+ values = filter
+ ? values.filter(
+ (v, i) => i < 1 || (i == 1 && v.match(/^disable\s[a-z]/i)) || filter?.test(v),
+ )
+ : values;
+ const shouldShow =
+ values.length > 2 || (values.length > 1 && !values[1]!.match(/^disable\s[a-z]/i));
+ if (shouldShow) {
+ if (!this.combos[key]) {
+ this.combos[key] = this.node.addWidget(
+ "combo",
+ key,
+ values[0]!,
+ (selected) => {
+ if (selected !== values[0] && !selected.match(/^disable\s[a-z]/i)) {
+ // We wait a frame because if we use a keydown event to call, it'll wipe out
+ // the selection.
+ wait().then(() => {
+ if (key.includes("embedding")) {
+ this.insertSelectionText(`embedding:${selected}`);
+ } else if (key.includes("saved")) {
+ this.insertSelectionText(
+ this.combosValues[`values_${key}`]![values.indexOf(selected)]!,
+ );
+ } else if (key.includes("lora")) {
+ this.insertSelectionText(``);
+ }
+ this.combos[key]!.value = values[0]!;
+ });
+ }
+ },
+ {
+ values,
+ serialize: true, // Don't include this in prompt.
+ },
+ ) as IComboWidget;
+ (this.combos[key]! as any).oldComputeSize = this.combos[key]!.computeSize;
+ let node = this.node;
+ this.combos[key]!.computeSize = function (width: number) {
+ const size = (this as any).oldComputeSize?.(width) || [
+ width,
+ LiteGraph.NODE_WIDGET_HEIGHT,
+ ];
+ if (this === node.widgets![node.widgets!.length - 1]) {
+ size[1] += 10;
+ }
+ return size;
+ };
+ }
+ this.combos[key]!.options!.values = values;
+ this.combos[key]!.value = values[0]!;
+ } else if (!shouldShow && this.combos[key]) {
+ this.node.widgets!.splice(this.node.widgets!.indexOf(this.combos[key]!), 1);
+ delete this.combos[key];
+ }
+ } else if (key.startsWith("values")) {
+ this.combosValues[key] = values;
+ }
+ }
+ }
+ }
+
+ insertSelectionText(text: string) {
+ if (!this.promptEl) {
+ console.error("Asked to insert text, but no textbox found.");
+ return;
+ }
+ let prompt = this.promptEl.value;
+ // Use selectionEnd as the split; if we have highlighted text, then we likely don't want to
+ // overwrite it (we could have just deleted it more easily).
+ let first = prompt.substring(0, this.promptEl.selectionEnd).replace(/ +$/, "");
+ first = first + (["\n"].includes(first[first.length - 1]!) ? "" : first.length ? " " : "");
+ let second = prompt.substring(this.promptEl.selectionEnd).replace(/^ +/, "");
+ second = (["\n"].includes(second[0]!) ? "" : second.length ? " " : "") + second;
+ this.promptEl.value = first + text + second;
+ this.promptEl.focus();
+ this.promptEl.selectionStart = first.length;
+ this.promptEl.selectionEnd = first.length + text.length;
+ }
+
+ /**
+ * Adds a keydown event listener to our prompt so we can see if we're using the
+ * ctrl/cmd + up/down arrows shortcut. This kind of competes with the core extension
+ * "Comfy.EditAttention" but since that only handles parenthesis and listens on window, we should
+ * be able to intercept and cancel the bubble if we're doing the same action within the lora tag.
+ */
+ addAndHandleKeyboardLoraEditWeight() {
+ this.promptEl.addEventListener("keydown", (event: KeyboardEvent) => {
+ // If we're not doing a ctrl/cmd + arrow key, then bail.
+ if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
+ if (!event.ctrlKey && !event.metaKey) return;
+ // Unfortunately, we can't see Comfy.EditAttention delta in settings, so we hardcode to 0.01.
+ // We can acutally do better too, let's make it .1 by default, and .01 if also holding shift.
+ const delta = event.shiftKey ? 0.01 : 0.1;
+
+ let start = this.promptEl.selectionStart;
+ let end = this.promptEl.selectionEnd;
+ let fullText = this.promptEl.value;
+ let selectedText = fullText.substring(start, end);
+
+ // We don't care about fully rewriting Comfy.EditAttention, we just want to see if our
+ // selected text is a lora, which will always start with "") {
+ start -= 2;
+ end -= 2;
+ }
+ if (fullText[end - 1] == "<") {
+ start += 2;
+ end += 2;
+ }
+ while (!stopOn.includes(fullText[start]!) && start > 0) {
+ start--;
+ }
+ while (!stopOn.includes(fullText[end - 1]!) && end < fullText.length) {
+ end++;
+ }
+ selectedText = fullText.substring(start, end);
+ }
+
+ // Bail if this isn't a lora.
+ if (!selectedText.startsWith("")) {
+ return;
+ }
+
+ let weight = Number(selectedText.match(/:(-?\d*(\.\d*)?)>$/)?.[1]) ?? 1;
+ weight += event.key === "ArrowUp" ? delta : -delta;
+ const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`);
+
+ // Handle the new value and cancel the bubble so Comfy.EditAttention doesn't also try.
+ this.promptEl.setRangeText(updatedText, start, end, "select");
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ }
+
+ /**
+ * Patches over api.getNodeDefs in comfy's api.js to fire a custom event that we can listen to
+ * here and manually refresh our combos when a request comes in to fetch the node data; which
+ * only happens once at startup (but before custom nodes js runs), and then after clicking
+ * the "Refresh" button in the floating menu, which is what we care about.
+ */
+ patchNodeRefresh() {
+ this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this);
+ api.addEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
+ const oldNodeRemoved = this.node.onRemoved;
+ this.node.onRemoved = () => {
+ oldNodeRemoved?.call(this.node);
+ api.removeEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
+ };
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8eb742302b56d958b35e0c3e30c2e48b9c1f5188
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts
@@ -0,0 +1,163 @@
+import type {
+ LGraph,
+ LGraphCanvas,
+ LGraphNode,
+ Point,
+ CanvasMouseEvent,
+ Subgraph,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseVirtualNode} from "./base_node.js";
+import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
+import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
+import {NodeTypesString} from "./constants.js";
+import {getClosestOrSelf, query} from "rgthree/common/utils_dom.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {findFromNodeForSubgraph} from "./utils.js";
+
+/**
+ * A bookmark node. Can be placed anywhere in the workflow, and given a shortcut key that will
+ * navigate to that node, with it in the top-left corner.
+ */
+export class Bookmark extends RgthreeBaseVirtualNode {
+ static override type = NodeTypesString.BOOKMARK;
+ static override title = NodeTypesString.BOOKMARK;
+ override comfyClass = NodeTypesString.BOOKMARK;
+
+ // Really silly, but Litegraph assumes we have at least one input/output... so we need to
+ // counteract it's computeSize calculation by offsetting the start.
+ static slot_start_y = -20;
+
+ // LiteGraph adds mroe spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
+ // override it with a setter and re-set it measured exactly as we want.
+ ___collapsed_width: number = 0;
+
+ override isVirtualNode = true;
+ override serialize_widgets = true;
+
+ //@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
+ override get _collapsed_width() {
+ return this.___collapsed_width;
+ }
+
+ override set _collapsed_width(width: number) {
+ const canvas = app.canvas as LGraphCanvas;
+ const ctx = canvas.canvas.getContext("2d")!;
+ const oldFont = ctx.font;
+ ctx.font = canvas.title_text_font;
+ this.___collapsed_width = 40 + ctx.measureText(this.title).width;
+ ctx.font = oldFont;
+ }
+
+ readonly keypressBound;
+
+ constructor(title = Bookmark.title) {
+ super(title);
+ const nextShortcutChar = BOOKMARKS_SERVICE.getNextShortcut();
+ this.addWidget(
+ "text",
+ "shortcut_key",
+ nextShortcutChar,
+ (value: string, ...args) => {
+ value = value.trim()[0] || "1";
+ },
+ {
+ y: 8,
+ },
+ );
+ this.addWidget("number", "zoom", 1, (value: number) => {}, {
+ y: 8 + LiteGraph.NODE_WIDGET_HEIGHT + 4,
+ max: 2,
+ min: 0.5,
+ precision: 2,
+ });
+ this.keypressBound = this.onKeypress.bind(this);
+ this.title = "🔖";
+ this.onConstructed();
+ }
+
+ // override computeSize(out?: Vector2 | undefined): Vector2 {
+ // super.computeSize(out);
+ // const minHeight = (this.widgets?.length || 0) * (LiteGraph.NODE_WIDGET_HEIGHT + 4) + 16;
+ // this.size[1] = Math.max(minHeight, this.size[1]);
+ // }
+
+ get shortcutKey(): string {
+ return (this.widgets[0]?.value as string)?.toLocaleLowerCase() ?? "";
+ }
+
+ override onAdded(graph: LGraph): void {
+ KEY_EVENT_SERVICE.addEventListener("keydown", this.keypressBound as EventListener);
+ }
+
+ override onRemoved(): void {
+ KEY_EVENT_SERVICE.removeEventListener("keydown", this.keypressBound as EventListener);
+ }
+
+ onKeypress(event: CustomEvent<{originalEvent: KeyboardEvent}>) {
+ const originalEvent = event.detail.originalEvent;
+ const target = (originalEvent.target as HTMLElement)!;
+ if (getClosestOrSelf(target, 'input,textarea,[contenteditable="true"]')) {
+ return;
+ }
+
+ // Only the shortcut keys are held down, optionally including "shift".
+ if (KEY_EVENT_SERVICE.areOnlyKeysDown(this.widgets[0]!.value as string, true)) {
+ this.canvasToBookmark();
+ originalEvent.preventDefault();
+ originalEvent.stopPropagation();
+ }
+ }
+
+ /**
+ * Called from LiteGraph's `processMouseDown` after it would invoke the input box for the
+ * shortcut_key, so we check if it exists and then add our own event listener so we can track the
+ * keys down for the user. Note, blocks drag if the return is truthy.
+ */
+ override onMouseDown(event: CanvasMouseEvent, pos: Point, graphCanvas: LGraphCanvas): boolean {
+ const input = query(".graphdialog > input.value");
+ if (input && input.value === this.widgets[0]?.value) {
+ input.addEventListener("keydown", (e) => {
+ // ComfyUI swallows keydown on inputs, so we need to call out to rgthree to use downkeys.
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(e);
+ e.preventDefault();
+ e.stopPropagation();
+ input.value = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" + ");
+ });
+ }
+ return false;
+ }
+
+ async canvasToBookmark() {
+ const canvas = app.canvas as LGraphCanvas;
+ if (this.graph !== app.canvas.getCurrentGraph()) {
+ const subgraph = this.graph as Subgraph;
+ // At some point, ComfyUI made a second param for openSubgraph which appears to be the node
+ // that id double-clicked on to open the subgraph. We don't have that in the bookmark, so
+ // we'll look for it. Note, that when opening the root graph, this will be null (since there's
+ // no such node). It seems to still navigate fine, though there's a console error about
+ // proxyWidgets or something..
+ const fromNode = findFromNodeForSubgraph(subgraph.id);
+ canvas.openSubgraph(subgraph, fromNode!);
+ await wait(16);
+ }
+ // ComfyUI seemed to break us again, but couldn't repro. No reason to not check, I guess.
+ // https://github.com/rgthree/rgthree-comfy/issues/71
+ if (canvas?.ds?.offset) {
+ canvas.ds.offset[0] = -this.pos[0] + 16;
+ canvas.ds.offset[1] = -this.pos[1] + 40;
+ }
+ if (canvas?.ds?.scale != null) {
+ canvas.ds.scale = Number(this.widgets[1]!.value || 1);
+ }
+ canvas.setDirty(true, true);
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.Bookmark",
+ registerCustomNodes() {
+ Bookmark.setUp();
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0cfb51f83a0ed7f221cc9e66392d9b58f2c6e331
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts
@@ -0,0 +1,52 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {BaseNodeModeChanger} from "./base_node_mode_changer.js";
+import {NodeTypesString} from "./constants.js";
+
+const MODE_BYPASS = 4;
+const MODE_ALWAYS = 0;
+
+class BypasserNode extends BaseNodeModeChanger {
+ static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
+
+ static override type = NodeTypesString.FAST_BYPASSER;
+ static override title = NodeTypesString.FAST_BYPASSER;
+ override comfyClass = NodeTypesString.FAST_BYPASSER;
+
+ override readonly modeOn = MODE_ALWAYS;
+ override readonly modeOff = MODE_BYPASS;
+
+ constructor(title = BypasserNode.title) {
+ super(title);
+ this.onConstructed();
+ }
+
+ override async handleAction(action: string) {
+ if (action === "Bypass all") {
+ for (const widget of this.widgets || []) {
+ this.forceWidgetOff(widget, true);
+ }
+ } else if (action === "Enable all") {
+ for (const widget of this.widgets || []) {
+ this.forceWidgetOn(widget, true);
+ }
+ } else if (action === "Toggle all") {
+ for (const widget of this.widgets || []) {
+ this.forceWidgetToggle(widget, true);
+ }
+ }
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.Bypasser",
+ registerCustomNodes() {
+ BypasserNode.setUp();
+ },
+ loadedGraphNode(node: LGraphNode) {
+ if (node.type == BypasserNode.title) {
+ (node as any)._tempWidth = node.size[0];
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts
new file mode 100644
index 0000000000000000000000000000000000000000..af79cc9571286a4601d5db77e6f28ef9e2df3071
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts
@@ -0,0 +1,264 @@
+import {app} from "scripts/app.js";
+import {iconGear, iconStarFilled, logoRgthreeAsync} from "rgthree/common/media/svgs.js";
+import {$el, empty} from "rgthree/common/utils_dom.js";
+import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+import {RgthreeConfigDialog} from "./config.js";
+import {wait} from "rgthree/common/shared_utils.js";
+
+let rgthreeButtonGroup: RgthreeComfyButtonGroup | null = null;
+
+function addRgthreeTopBarButtons() {
+ if (!CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.enabled")) {
+ if (rgthreeButtonGroup?.element?.parentElement) {
+ rgthreeButtonGroup.element.parentElement.removeChild(rgthreeButtonGroup.element);
+ }
+ return;
+ } else if (rgthreeButtonGroup) {
+ app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
+ return;
+ }
+
+ const buttons = [];
+
+ const rgthreeButton = new RgthreeComfyButton({
+ icon: " ",
+ tooltip: "rgthree-comfy",
+ primary: true,
+ // content: 'rgthree-comfy',
+ // app,
+ enabled: true,
+ classList: "comfyui-button comfyui-menu-mobile-collapse primary",
+ });
+ buttons.push(rgthreeButton);
+ logoRgthreeAsync().then((t) => {
+ rgthreeButton.setIcon(t);
+ });
+
+ rgthreeButton.withPopup(
+ new RgthreeComfyPopup(
+ {target: rgthreeButton.element},
+ $el("menu.rgthree-menu.rgthree-top-menu", {
+ children: [
+ $el("li", {
+ child: $el("button.rgthree-button-reset", {
+ html: iconGear + "Settings (rgthree-comfy)",
+ onclick: () => new RgthreeConfigDialog().show(),
+ }),
+ }),
+ $el("li", {
+ child: $el("button.rgthree-button-reset", {
+ html: iconStarFilled + "Star on Github",
+ onclick: () => window.open("https://github.com/rgthree/rgthree-comfy", "_blank"),
+ }),
+ }),
+ ],
+ }),
+ ),
+ "click",
+ );
+
+ if (CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.button_bookmarks.enabled")) {
+ const bookmarksListEl = $el("menu.rgthree-menu.rgthree-top-menu");
+ bookmarksListEl.appendChild(
+ $el("li.rgthree-message", {
+ child: $el("span", {text: "No bookmarks in current workflow."}),
+ }),
+ );
+ const bookmarksButton = new RgthreeComfyButton({
+ icon: "bookmark",
+ tooltip: "Workflow Bookmarks (rgthree-comfy)",
+ // app,
+ });
+ const bookmarksPopup = new RgthreeComfyPopup(
+ {target: bookmarksButton.element, modal: false},
+ bookmarksListEl,
+ );
+ bookmarksPopup.onOpen(() => {
+ const bookmarks = BOOKMARKS_SERVICE.getCurrentBookmarks();
+ empty(bookmarksListEl);
+ if (bookmarks.length) {
+ for (const b of bookmarks) {
+ bookmarksListEl.appendChild(
+ $el("li", {
+ child: $el("button.rgthree-button-reset", {
+ text: `[${b.shortcutKey}] ${b.title}`,
+ onclick: () => {
+ b.canvasToBookmark();
+ },
+ }),
+ }),
+ );
+ }
+ } else {
+ bookmarksListEl.appendChild(
+ $el("li.rgthree-message", {
+ child: $el("span", {text: "No bookmarks in current workflow."}),
+ }),
+ );
+ }
+ // bookmarksPopup.update();
+ });
+ bookmarksButton.withPopup(bookmarksPopup, "hover");
+ buttons.push(bookmarksButton);
+ }
+
+ rgthreeButtonGroup = new RgthreeComfyButtonGroup(...buttons);
+ app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
+}
+
+app.registerExtension({
+ name: "rgthree.TopMenu",
+ async setup() {
+ addRgthreeTopBarButtons();
+
+ CONFIG_SERVICE.addEventListener("config-change", ((e: CustomEvent) => {
+ if (e.detail?.key?.includes("features.comfy_top_bar_menu")) {
+ addRgthreeTopBarButtons();
+ }
+ }) as EventListener);
+ },
+});
+
+// The following are rough hacks since ComfyUI took away their button/buttongroup/popup
+// functionality. TODO: Find a better spot to add rgthree controls to the UI, I suppose.
+
+class RgthreeComfyButtonGroup {
+ element = $el("div.rgthree-comfybar-top-button-group");
+ buttons: RgthreeComfyButton[];
+
+ constructor(...buttons: RgthreeComfyButton[]) {
+ this.buttons = buttons;
+ this.update();
+ }
+
+ insert(button: RgthreeComfyButton, index: number) {
+ this.buttons.splice(index, 0, button);
+ this.update();
+ }
+
+ append(button: RgthreeComfyButton) {
+ this.buttons.push(button);
+ this.update();
+ }
+
+ remove(indexOrButton: RgthreeComfyButton | number) {
+ if (typeof indexOrButton !== "number") {
+ indexOrButton = this.buttons.indexOf(indexOrButton);
+ }
+ if (indexOrButton > -1) {
+ const btn = this.buttons.splice(indexOrButton, 1);
+ this.update();
+ return btn;
+ }
+ return null;
+ }
+
+ update() {
+ this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
+ }
+}
+
+interface RgthreeComfyButtonOptions {
+ icon?: string;
+ primary?: boolean;
+ overIcon?: string;
+ iconSize?: number;
+ content?: string | HTMLElement;
+ tooltip?: string;
+ enabled?: boolean;
+ action?: (e: Event, btn: RgthreeComfyButton) => void;
+ classList?: string;
+ visibilitySetting?: {id: string; showValue: any};
+ // app?: ComfyApp;
+}
+
+class RgthreeComfyButton {
+ element = $el("button.rgthree-comfybar-top-button.rgthree-button-reset.rgthree-button");
+ iconElement = $el("span.rgthree-button-icon");
+ constructor(opts: RgthreeComfyButtonOptions) {
+ opts.icon && this.setIcon(opts.icon);
+ opts.tooltip && this.element.setAttribute("title", opts.tooltip);
+ opts.primary && this.element.classList.add("-primary");
+ }
+
+ setIcon(iconOrMarkup: string) {
+ const markup = iconOrMarkup.startsWith("<")
+ ? iconOrMarkup
+ : ` `;
+ this.iconElement.innerHTML = markup;
+ if (!this.iconElement.parentElement) {
+ this.element.appendChild(this.iconElement);
+ }
+ }
+
+ withPopup(popup: RgthreeComfyPopup, trigger: "click" | "hover") {
+ if (trigger === "click") {
+ this.element.addEventListener("click", () => {
+ popup.open();
+ });
+ }
+ if (trigger === "hover") {
+ this.element.addEventListener("pointerenter", () => {
+ popup.open();
+ });
+ }
+ }
+}
+
+interface RgthreeComfyPopupOptions {
+ target: HTMLElement;
+ classList?: string;
+ modal?: boolean;
+}
+
+class RgthreeComfyPopup {
+ element: HTMLElement;
+ target?: HTMLElement;
+ onOpenFn: (() => Promise | void) | null = null;
+ opts: RgthreeComfyPopupOptions;
+
+ onWindowClickBound = this.onWindowClick.bind(this);
+
+ constructor(opts: RgthreeComfyPopupOptions, element: HTMLElement) {
+ this.element = element;
+ this.opts = opts;
+ opts.target && (this.target = opts.target);
+ opts.modal && this.element.classList.add("-modal");
+ }
+
+ async open() {
+ if (!this.target) {
+ throw new Error("No target for RgthreeComfyPopup");
+ }
+ if (this.onOpenFn) {
+ await this.onOpenFn();
+ }
+ await wait(16);
+ const rect = this.target.getBoundingClientRect();
+ this.element.setAttribute("state", "measuring");
+ document.body.appendChild(this.element);
+ this.element.style.position = "fixed";
+ this.element.style.left = `${rect.left}px`;
+ this.element.style.top = `${rect.top + rect.height}px`;
+ this.element.setAttribute("state", "open");
+ if (this.opts.modal) {
+ document.body.classList.add("rgthree-modal-menu-open");
+ }
+ window.addEventListener("click", this.onWindowClickBound);
+ }
+
+ close() {
+ this.element.remove();
+ document.body.classList.remove("rgthree-modal-menu-open");
+ window.removeEventListener("click", this.onWindowClickBound);
+ }
+
+ onOpen(fn: (() => void) | null) {
+ this.onOpenFn = fn;
+ }
+
+ onWindowClick() {
+ this.close();
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/config.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6593d0fe85bdc675217a62e55b8dd82f6988af04
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/config.ts
@@ -0,0 +1,416 @@
+import {app} from "scripts/app.js";
+import { RgthreeDialog, RgthreeDialogOptions } from "rgthree/common/dialog.js";
+import { createElement as $el, queryAll as $$ } from "rgthree/common/utils_dom.js";
+import { checkmark, logoRgthree } from "rgthree/common/media/svgs.js";
+import { LogLevel, rgthree } from "./rgthree.js";
+import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
+
+/** Types of config used as a hint for the form handling. */
+enum ConfigType {
+ UNKNOWN,
+ BOOLEAN,
+ STRING,
+ NUMBER,
+ ARRAY,
+}
+
+enum ConfigInputType {
+ UNKNOWN,
+ CHECKLIST, // Which is a multiselect array.
+}
+
+const TYPE_TO_STRING = {
+ [ConfigType.UNKNOWN]: "unknown",
+ [ConfigType.BOOLEAN]: "boolean",
+ [ConfigType.STRING]: "string",
+ [ConfigType.NUMBER]: "number",
+ [ConfigType.ARRAY]: "array",
+};
+
+type ConfigurationSchema = {
+ key: string;
+ type: ConfigType;
+ label: string;
+ inputType?: ConfigInputType,
+ options?: string[] | number[] | ConfigurationSchemaOption[];
+ description?: string;
+ subconfig?: ConfigurationSchema[];
+ isDevOnly?: boolean;
+ onSave?: (value: any) => void;
+};
+
+type ConfigurationSchemaOption = { value: any; label: string };
+
+/**
+ * A static schema of sorts to layout options found in the config.
+ */
+const CONFIGURABLE: { [key: string]: ConfigurationSchema[] } = {
+ features: [
+ {
+ key: "features.progress_bar.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Prompt Progress Bar",
+ description: `Shows a minimal progress bar for nodes and steps at the top of the app.`,
+ subconfig: [
+ {
+ key: "features.progress_bar.height",
+ type: ConfigType.NUMBER,
+ label: "Height of the bar",
+ },
+ {
+ key: "features.progress_bar.position",
+ type: ConfigType.STRING,
+ label: "Position at top or bottom of window",
+ options: ["top", "bottom"],
+ },
+ ],
+ },
+ {
+ key: "features.import_individual_nodes.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Import Individual Nodes Widgets",
+ description:
+ "Dragging & Dropping a similar image/JSON workflow onto (most) current workflow nodes" +
+ "will allow you to import that workflow's node's widgets when it has the same " +
+ "id and type. This is useful when you have several images and you'd like to import just " +
+ "one part of a previous iteration, like a seed, or prompt.",
+ },
+ ],
+ menus: [
+ {
+ key: "features.comfy_top_bar_menu.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Enable Top Bar Menu",
+ description:
+ "Have quick access from ComfyUI's new top bar to rgthree-comfy bookmarks, settings " +
+ "(and more to come).",
+ },
+ {
+ key: "features.menu_queue_selected_nodes",
+ type: ConfigType.BOOLEAN,
+ label: "Show 'Queue Selected Output Nodes'",
+ description:
+ "Will show a menu item in the right-click context menus to queue (only) the selected " +
+ "output nodes.",
+ },
+ {
+ key: "features.menu_auto_nest.subdirs",
+ type: ConfigType.BOOLEAN,
+ label: "Auto Nest Subdirectories in Menus",
+ description:
+ "When a large, flat list of values contain sub-directories, auto nest them. (Like, for " +
+ "a large list of checkpoints).",
+ subconfig: [
+ {
+ key: "features.menu_auto_nest.threshold",
+ type: ConfigType.NUMBER,
+ label: "Number of items needed to trigger nesting.",
+ },
+ ],
+ },
+ {
+ key: "features.menu_bookmarks.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Show Bookmarks in context menu",
+ description: "Will list bookmarks in the rgthree-comfy right-click context menu.",
+ },
+ ],
+ groups: [
+ {
+ key: "features.group_header_fast_toggle.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Show fast toggles in Group Headers",
+ description: "Show quick toggles in Groups' Headers to quickly mute, bypass or queue.",
+ subconfig: [
+ {
+ key: "features.group_header_fast_toggle.toggles",
+ type: ConfigType.ARRAY,
+ label: "Which toggles to show.",
+ inputType: ConfigInputType.CHECKLIST,
+ options: [
+ { value: "queue", label: "queue" },
+ { value: "bypass", label: "bypass" },
+ { value: "mute", label: "mute" },
+ ],
+ },
+ {
+ key: "features.group_header_fast_toggle.show",
+ type: ConfigType.STRING,
+ label: "When to show them.",
+ options: [
+ { value: "hover", label: "on hover" },
+ { value: "always", label: "always" },
+ ],
+ },
+ ],
+ },
+ ],
+ power_lora_loader: [
+ {
+ key: "nodes.power_lora_loader.show_info_badge",
+ type: ConfigType.BOOLEAN,
+ label: "Show info badge/button",
+ description: "Show an info badge/button on each lora row to signal and open lora details.",
+ },
+ ],
+ advanced: [
+ {
+ key: "features.show_alerts_for_corrupt_workflows",
+ type: ConfigType.BOOLEAN,
+ label: "Detect Corrupt Workflows",
+ description:
+ "Will show a message at the top of the screen when loading a workflow that has " +
+ "corrupt linking data.",
+ },
+ {
+ key: "log_level",
+ type: ConfigType.STRING,
+ label: "Log level for browser dev console.",
+ description:
+ "Further down the list, the more verbose logs to the console will be. For instance, " +
+ "selecting 'IMPORTANT' means only important message will be logged to the browser " +
+ "console, while selecting 'WARN' will log all messages at or higher than WARN, including " +
+ "'ERROR' and 'IMPORTANT' etc.",
+ options: ["IMPORTANT", "ERROR", "WARN", "INFO", "DEBUG", "DEV"],
+ isDevOnly: true,
+ onSave: function (value: LogLevel) {
+ rgthree.setLogLevel(value);
+ },
+ },
+ {
+ key: "features.invoke_extensions_async.node_created",
+ type: ConfigType.BOOLEAN,
+ label: "Allow other extensions to call nodeCreated on rgthree-nodes.",
+ isDevOnly: true,
+ description:
+ "Do not disable unless you are having trouble (and then file an issue at rgthree-comfy)." +
+ "Prior to Apr 2024 it was not possible for other extensions to invoke their nodeCreated " +
+ "event on some rgthree-comfy nodes. Now it's possible and this option is only here in " +
+ "for easy if something is wrong.",
+ },
+ ],
+};
+
+/**
+ * Creates a new fieldrow for main or sub configuration items.
+ */
+function fieldrow(item: ConfigurationSchema) {
+ const initialValue = CONFIG_SERVICE.getConfigValue(item.key);
+ const container = $el(`div.fieldrow.-type-${TYPE_TO_STRING[item.type]}`, {
+ dataset: {
+ name: item.key,
+ initial: initialValue,
+ type: item.type,
+ },
+ });
+
+ $el(`label[for="${item.key}"]`, {
+ children: [
+ $el(`span[text="${item.label}"]`),
+ item.description ? $el("small", { html: item.description }) : null,
+ ],
+ parent: container,
+ });
+
+ let input;
+ if (item.options?.length) {
+ if (item.inputType === ConfigInputType.CHECKLIST) {
+ const initialValueList = initialValue || [];
+ input = $el(`fieldset.rgthree-checklist-group[id="${item.key}"]`, {
+ parent: container,
+ children: item.options.map((o) => {
+ const label = (o as ConfigurationSchemaOption).label || String(o);
+ const value = (o as ConfigurationSchemaOption).value || o;
+ const id = `${item.key}_${value}`;
+ return $el(`span.rgthree-checklist-item`, {
+ children: [
+ $el(`input[type="checkbox"][value="${value}"]`, {
+ id,
+ checked: initialValueList.includes(value),
+ }),
+ $el(`label`, {
+ for: id,
+ text: label,
+ })
+ ]
+ });
+ }),
+ });
+ } else {
+ input = $el(`select[id="${item.key}"]`, {
+ parent: container,
+ children: item.options.map((o) => {
+ const label = (o as ConfigurationSchemaOption).label || String(o);
+ const value = (o as ConfigurationSchemaOption).value || o;
+ const valueSerialized = JSON.stringify({ value: value });
+ return $el(`option[value="${valueSerialized}"]`, {
+ text: label,
+ selected: valueSerialized === JSON.stringify({ value: initialValue }),
+ });
+ }),
+ });
+ }
+ } else if (item.type === ConfigType.BOOLEAN) {
+ container.classList.toggle("-checked", !!initialValue);
+ input = $el(`input[type="checkbox"][id="${item.key}"]`, {
+ parent: container,
+ checked: initialValue,
+ });
+ } else {
+ input = $el(`input[id="${item.key}"]`, {
+ parent: container,
+ value: initialValue,
+ });
+ }
+ $el("div.fieldrow-value", { children: [input], parent: container });
+ return container;
+}
+
+/**
+ * A dialog to edit rgthree-comfy settings and config.
+ */
+export class RgthreeConfigDialog extends RgthreeDialog {
+ constructor() {
+ const content = $el("div");
+
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["features"]!, "Features"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["menus"]!, "Menus"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["groups"]!, "Groups"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["power_lora_loader"]!, "Power Lora Loader"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["advanced"]!, "Advanced"));
+
+ content.addEventListener("input", (e) => {
+ const changed = this.getChangedFormData();
+ ($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
+ !Object.keys(changed).length;
+ });
+ content.addEventListener("change", (e) => {
+ const changed = this.getChangedFormData();
+ ($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
+ !Object.keys(changed).length;
+ });
+
+ const dialogOptions: RgthreeDialogOptions = {
+ class: "-iconed -settings",
+ title: logoRgthree + `Settings - rgthree-comfy `,
+ content,
+ onBeforeClose: () => {
+ const changed = this.getChangedFormData();
+ if (Object.keys(changed).length) {
+ return confirm("Looks like there are unsaved changes. Are you sure you want close?");
+ }
+ return true;
+ },
+ buttons: [
+ {
+ label: "Save",
+ disabled: true,
+ className: "rgthree-button save-button -blue",
+ callback: async (e) => {
+ const changed = this.getChangedFormData();
+ if (!Object.keys(changed).length) {
+ this.close();
+ return;
+ }
+ const success = await CONFIG_SERVICE.setConfigValues(changed);
+ if (success) {
+ for (const key of Object.keys(changed)) {
+ Object.values(CONFIGURABLE)
+ .flat()
+ .find((f) => f.key === key)
+ ?.onSave?.(changed[key]);
+ }
+ this.close();
+ rgthree.showMessage({
+ id: "config-success",
+ message: `${checkmark} Successfully saved rgthree-comfy settings!`,
+ timeout: 4000,
+ });
+ ($$(".save-button", this.element)[0] as HTMLButtonElement).disabled = true;
+ } else {
+ alert("There was an error saving rgthree-comfy configuration.");
+ }
+ },
+ },
+ ],
+ };
+ super(dialogOptions);
+ }
+
+ private static buildFieldset(datas: ConfigurationSchema[], label: string) {
+ const fieldset = $el(`fieldset`, { children: [$el(`legend[text="${label}"]`)] });
+ for (const data of datas) {
+ if (data.isDevOnly && !rgthree.isDevMode()) {
+ continue;
+ }
+ const container = $el("div.formrow");
+ container.appendChild(fieldrow(data));
+
+ if (data.subconfig) {
+ for (const subfeature of data.subconfig) {
+ container.appendChild(fieldrow(subfeature));
+ }
+ }
+ fieldset.appendChild(container);
+ }
+ return fieldset;
+ }
+
+ getChangedFormData() {
+ return $$("[data-name]", this.contentElement).reduce((acc: { [key: string]: any }, el) => {
+ const name = el.dataset["name"]!;
+ const type = el.dataset["type"]!;
+ const initialValue = CONFIG_SERVICE.getConfigValue(name);
+ let currentValueEl = $$("fieldset.rgthree-checklist-group, input, textarea, select", el)[0] as HTMLInputElement;
+ let currentValue: any = null;
+ if (type === String(ConfigType.BOOLEAN)) {
+ currentValue = currentValueEl.checked;
+ // Not sure I like this side effect in here, but it's easy to just do it now.
+ el.classList.toggle("-checked", currentValue);
+ } else {
+ currentValue = currentValueEl?.value;
+ if (currentValueEl.nodeName === "SELECT") {
+ currentValue = JSON.parse(currentValue).value;
+ } else if (currentValueEl.classList.contains('rgthree-checklist-group')) {
+ currentValue = [];
+ for (const check of $$('input[type="checkbox"]', currentValueEl)) {
+ if (check.checked) {
+ currentValue.push(check.value);
+ }
+ }
+ } else if (type === String(ConfigType.NUMBER)) {
+ currentValue = Number(currentValue) || initialValue;
+ }
+ }
+ if (JSON.stringify(currentValue) !== JSON.stringify(initialValue)) {
+ acc[name] = currentValue;
+ }
+ return acc;
+ }, {});
+ }
+}
+
+app.ui.settings.addSetting({
+ id: "rgthree.config",
+ defaultValue: null,
+ name: "Open rgthree-comfy config",
+ type: () => {
+ // Adds a row to open the dialog from the ComfyUI settings.
+ return $el("tr.rgthree-comfyui-settings-row", {
+ children: [
+ $el("td", {
+ child: `${logoRgthree} [rgthree-comfy] configuration / settings
`,
+ }),
+ $el("td", {
+ child: $el('button.rgthree-button.-blue[text="rgthree-comfy settings"]', {
+ events: {
+ click: (e: PointerEvent) => {
+ new RgthreeConfigDialog().show();
+ },
+ },
+ }),
+ }),
+ ],
+ });
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4e6a13f8ed895fdee59da922bd91ed90245741f7
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts
@@ -0,0 +1,72 @@
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+
+export function addRgthree(str: string) {
+ return str + " (rgthree)";
+}
+
+export function stripRgthree(str: string) {
+ return str.replace(/\s*\(rgthree\)$/, "");
+}
+
+export const NodeTypesString = {
+ ANY_SWITCH: addRgthree("Any Switch"),
+ CONTEXT: addRgthree("Context"),
+ CONTEXT_BIG: addRgthree("Context Big"),
+ CONTEXT_SWITCH: addRgthree("Context Switch"),
+ CONTEXT_SWITCH_BIG: addRgthree("Context Switch Big"),
+ CONTEXT_MERGE: addRgthree("Context Merge"),
+ CONTEXT_MERGE_BIG: addRgthree("Context Merge Big"),
+ DYNAMIC_CONTEXT: addRgthree("Dynamic Context"),
+ DYNAMIC_CONTEXT_SWITCH: addRgthree("Dynamic Context Switch"),
+ DISPLAY_ANY: addRgthree("Display Any"),
+ IMAGE_OR_LATENT_SIZE: addRgthree("Image or Latent Size"),
+
+ NODE_MODE_RELAY: addRgthree("Mute / Bypass Relay"),
+ NODE_MODE_REPEATER: addRgthree("Mute / Bypass Repeater"),
+ FAST_MUTER: addRgthree("Fast Muter"),
+ FAST_BYPASSER: addRgthree("Fast Bypasser"),
+ FAST_GROUPS_MUTER: addRgthree("Fast Groups Muter"),
+ FAST_GROUPS_BYPASSER: addRgthree("Fast Groups Bypasser"),
+ FAST_ACTIONS_BUTTON: addRgthree("Fast Actions Button"),
+ LABEL: addRgthree("Label"),
+ POWER_PRIMITIVE: addRgthree("Power Primitive"),
+ POWER_PROMPT: addRgthree("Power Prompt"),
+ POWER_PROMPT_SIMPLE: addRgthree("Power Prompt - Simple"),
+ POWER_PUTER: addRgthree("Power Puter"),
+ POWER_CONDUCTOR: addRgthree("Power Conductor"),
+ SDXL_EMPTY_LATENT_IMAGE: addRgthree("SDXL Empty Latent Image"),
+ SDXL_POWER_PROMPT_POSITIVE: addRgthree("SDXL Power Prompt - Positive"),
+ SDXL_POWER_PROMPT_NEGATIVE: addRgthree("SDXL Power Prompt - Simple / Negative"),
+ POWER_LORA_LOADER: addRgthree("Power Lora Loader"),
+ KSAMPLER_CONFIG: addRgthree("KSampler Config"),
+ NODE_COLLECTOR: addRgthree("Node Collector"),
+ REROUTE: addRgthree("Reroute"),
+ RANDOM_UNMUTER: addRgthree("Random Unmuter"),
+ SEED: addRgthree("Seed"),
+ BOOKMARK: addRgthree("Bookmark"),
+ IMAGE_COMPARER: addRgthree("Image Comparer"),
+ IMAGE_INSET_CROP: addRgthree("Image Inset Crop"),
+};
+
+const UNRELEASED_KEYS = {
+ [NodeTypesString.DYNAMIC_CONTEXT]: "dynamic_context",
+ [NodeTypesString.DYNAMIC_CONTEXT_SWITCH]: "dynamic_context",
+ [NodeTypesString.POWER_CONDUCTOR]: "power_conductor",
+};
+
+
+/**
+ * Gets the list of nodes from NoteTypeString above, filtering any that are not applicable.
+ */
+export function getNodeTypeStrings() {
+ const unreleasedKeys = Object.keys(UNRELEASED_KEYS);
+ return Object.values(NodeTypesString)
+ .map((i) => stripRgthree(i))
+ .filter((i) => {
+ if (unreleasedKeys.includes(i)) {
+ return !!CONFIG_SERVICE.getConfigValue(`unreleased.${UNRELEASED_KEYS[i]}.enabled`)
+ }
+ return true;
+ })
+ .sort();
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/context.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/context.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a49b7a9ed6bd779b2e2a9b8368659441731b2911
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/context.ts
@@ -0,0 +1,488 @@
+import type {
+ INodeInputSlot,
+ INodeOutputSlot,
+ LGraphCanvas as TLGraphCanvas,
+ LGraphNode as TLGraphNode,
+ LLink,
+ ISlotType,
+ ConnectByTypeOptions,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {
+ IoDirection,
+ addConnectionLayoutSupport,
+ addMenuItem,
+ matchLocalSlotsToServer,
+ replaceNode,
+} from "./utils.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
+import {RgthreeBaseServerNodeConstructor} from "typings/rgthree.js";
+import {debounce, wait} from "rgthree/common/shared_utils.js";
+import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
+import {NodeTypesString} from "./constants.js";
+
+/**
+ * Takes a non-context node and determins for its input or output slot, if there is a valid
+ * connection for an opposite context output or input slot.
+ */
+function findMatchingIndexByTypeOrName(
+ otherNode: TLGraphNode,
+ otherSlot: INodeInputSlot | INodeOutputSlot,
+ ctxSlots: INodeInputSlot[] | INodeOutputSlot[],
+) {
+ const otherNodeType = (otherNode.type || "").toUpperCase();
+ const otherNodeName = (otherNode.title || "").toUpperCase();
+ let otherSlotType = otherSlot.type as string;
+ if (Array.isArray(otherSlotType) || otherSlotType.includes(",")) {
+ otherSlotType = "COMBO";
+ }
+ const otherSlotName = otherSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
+ let ctxSlotIndex = -1;
+ if (["CONDITIONING", "INT", "STRING", "FLOAT", "COMBO"].includes(otherSlotType)) {
+ ctxSlotIndex = ctxSlots.findIndex((ctxSlot) => {
+ const ctxSlotName = ctxSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
+ let ctxSlotType = ctxSlot.type as string;
+ if (Array.isArray(ctxSlotType) || ctxSlotType.includes(",")) {
+ ctxSlotType = "COMBO";
+ }
+ if (ctxSlotType !== otherSlotType) {
+ return false;
+ }
+ // Straightforward matches.
+ if (
+ ctxSlotName === otherSlotName ||
+ (ctxSlotName === "SEED" && otherSlotName.includes("SEED")) ||
+ (ctxSlotName === "STEP_REFINER" && otherSlotName.includes("AT_STEP")) ||
+ (ctxSlotName === "STEP_REFINER" && otherSlotName.includes("REFINER_STEP"))
+ ) {
+ return true;
+ }
+ // If postive other node, try to match conditining and text.
+ if (
+ (otherNodeType.includes("POSITIVE") || otherNodeName.includes("POSITIVE")) &&
+ ((ctxSlotName === "POSITIVE" && otherSlotType === "CONDITIONING") ||
+ (ctxSlotName === "TEXT_POS_G" && otherSlotName.includes("TEXT_G")) ||
+ (ctxSlotName === "TEXT_POS_L" && otherSlotName.includes("TEXT_L")))
+ ) {
+ return true;
+ }
+ if (
+ (otherNodeType.includes("NEGATIVE") || otherNodeName.includes("NEGATIVE")) &&
+ ((ctxSlotName === "NEGATIVE" && otherSlotType === "CONDITIONING") ||
+ (ctxSlotName === "TEXT_NEG_G" && otherSlotName.includes("TEXT_G")) ||
+ (ctxSlotName === "TEXT_NEG_L" && otherSlotName.includes("TEXT_L")))
+ ) {
+ return true;
+ }
+ return false;
+ });
+ } else {
+ ctxSlotIndex = ctxSlots.map((s) => s.type).indexOf(otherSlotType);
+ }
+ return ctxSlotIndex;
+}
+
+/**
+ * A Base Context node for other context based nodes to extend.
+ */
+export class BaseContextNode extends RgthreeBaseServerNode {
+ constructor(title: string) {
+ super(title);
+ }
+
+ // LiteGraph adds more spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
+ // override it with a setter and re-set it measured exactly as we want.
+ ___collapsed_width: number = 0;
+
+ //@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
+ override get _collapsed_width() {
+ return this.___collapsed_width;
+ }
+
+ override set _collapsed_width(width: number) {
+ const canvas = app.canvas as TLGraphCanvas;
+ const ctx = canvas.canvas.getContext("2d")!;
+ const oldFont = ctx.font;
+ ctx.font = canvas.title_text_font;
+ let title = this.title.trim();
+ this.___collapsed_width = 30 + (title ? 10 + ctx.measureText(title).width : 0);
+ ctx.font = oldFont;
+ }
+
+ override connectByType(
+ slot: number | string,
+ targetNode: TLGraphNode,
+ targetSlotType: ISlotType,
+ optsIn?: ConnectByTypeOptions,
+ ): LLink | null {
+ let canConnect = super.connectByType?.call(this, slot, targetNode, targetSlotType, optsIn);
+ if (!super.connectByType) {
+ canConnect = LGraphNode.prototype.connectByType.call(
+ this,
+ slot,
+ targetNode,
+ targetSlotType,
+ optsIn,
+ );
+ }
+ if (!canConnect && slot === 0) {
+ const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
+ // Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
+ // Unfortunately, we don't know which are null now, so we'll just connect any that are
+ // not already connected.
+ for (const [index, input] of (targetNode.inputs || []).entries()) {
+ if (input.link && !ctrlKey) {
+ continue;
+ }
+ const thisOutputSlot = findMatchingIndexByTypeOrName(targetNode, input, this.outputs);
+ if (thisOutputSlot > -1) {
+ this.connect(thisOutputSlot, targetNode, index);
+ }
+ }
+ }
+ return null;
+ }
+
+ override connectByTypeOutput(
+ slot: number | string,
+ sourceNode: TLGraphNode,
+ sourceSlotType: ISlotType,
+ optsIn?: ConnectByTypeOptions,
+ ): LLink | null {
+ let canConnect = super.connectByTypeOutput?.call(
+ this,
+ slot,
+ sourceNode,
+ sourceSlotType,
+ optsIn,
+ );
+ if (!super.connectByType) {
+ canConnect = LGraphNode.prototype.connectByTypeOutput.call(
+ this,
+ slot,
+ sourceNode,
+ sourceSlotType,
+ optsIn,
+ );
+ }
+ if (!canConnect && slot === 0) {
+ const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
+ // Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
+ // Unfortunately, we don't know which are null now, so we'll just connect any that are
+ // not already connected.
+ for (const [index, output] of (sourceNode.outputs || []).entries()) {
+ if (output.links?.length && !ctrlKey) {
+ continue;
+ }
+ const thisInputSlot = findMatchingIndexByTypeOrName(sourceNode, output, this.inputs);
+ if (thisInputSlot > -1) {
+ sourceNode.connect(index, this, thisInputSlot);
+ }
+ }
+ }
+ return null;
+ }
+
+ static override setUp(
+ comfyClass: typeof LGraphNode,
+ nodeData: ComfyNodeDef,
+ ctxClass: RgthreeBaseServerNodeConstructor,
+ ) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ctxClass);
+ // [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
+ // empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
+ // ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
+ // because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
+ // extension and we need to wait for that to happen.
+ wait(500).then(() => {
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] =
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] || [];
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"].push((comfyClass as any).comfyClass);
+ });
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ addConnectionLayoutSupport(ctxClass, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ setTimeout(() => {
+ ctxClass.category = comfyClass.category;
+ });
+ }
+}
+
+/**
+ * The original Context node.
+ */
+class ContextNode extends BaseContextNode {
+ static override title = NodeTypesString.CONTEXT;
+ static override type = NodeTypesString.CONTEXT;
+ static comfyClass = NodeTypesString.CONTEXT;
+
+ constructor(title = ContextNode.title) {
+ super(title);
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextNode);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextNode, app, {
+ name: "Convert To Context Big",
+ callback: (node) => {
+ replaceNode(node, ContextBigNode.type);
+ },
+ });
+ }
+}
+
+/**
+ * The Context Big node.
+ */
+class ContextBigNode extends BaseContextNode {
+ static override title = NodeTypesString.CONTEXT_BIG;
+ static override type = NodeTypesString.CONTEXT_BIG;
+ static comfyClass = NodeTypesString.CONTEXT_BIG;
+
+ constructor(title = ContextBigNode.title) {
+ super(title);
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextBigNode);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextBigNode, app, {
+ name: "Convert To Context (Original)",
+ callback: (node) => {
+ replaceNode(node, ContextNode.type);
+ },
+ });
+ }
+}
+
+/**
+ * A base node for Context Switche nodes and Context Merges nodes that will always add another empty
+ * ctx input, no less than five.
+ */
+class BaseContextMultiCtxInputNode extends BaseContextNode {
+ private stabilizeBound = this.stabilize.bind(this);
+
+ constructor(title: string) {
+ super(title);
+ // Adding five. Note, configure will add as many as was in the stored workflow automatically.
+ this.addContextInput(5);
+ }
+
+ private addContextInput(num = 1) {
+ for (let i = 0; i < num; i++) {
+ this.addInput(`ctx_${String(this.inputs.length + 1).padStart(2, "0")}`, "RGTHREE_CONTEXT");
+ }
+ }
+
+ override onConnectionsChange(
+ type: number,
+ slotIndex: number,
+ isConnected: boolean,
+ link: LLink,
+ ioSlot: INodeInputSlot | INodeOutputSlot,
+ ): void {
+ super.onConnectionsChange?.apply(this, [...arguments] as any);
+ if (type === LiteGraph.INPUT) {
+ this.scheduleStabilize();
+ }
+ }
+
+ private scheduleStabilize(ms = 64) {
+ return debounce(this.stabilizeBound, 64);
+ }
+
+ /**
+ * Stabilizes the inputs; removing any disconnected ones from the bottom, then adding an empty
+ * one to the end so we always have one empty one to expand.
+ */
+ private stabilize() {
+ removeUnusedInputsFromEnd(this, 4);
+ this.addContextInput();
+ }
+}
+
+/**
+ * The Context Switch (original) node.
+ */
+class ContextSwitchNode extends BaseContextMultiCtxInputNode {
+ static override title = NodeTypesString.CONTEXT_SWITCH;
+ static override type = NodeTypesString.CONTEXT_SWITCH;
+ static comfyClass = NodeTypesString.CONTEXT_SWITCH;
+
+ constructor(title = ContextSwitchNode.title) {
+ super(title);
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchNode);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextSwitchNode, app, {
+ name: "Convert To Context Switch Big",
+ callback: (node) => {
+ replaceNode(node, ContextSwitchBigNode.type);
+ },
+ });
+ }
+}
+
+/**
+ * The Context Switch Big node.
+ */
+class ContextSwitchBigNode extends BaseContextMultiCtxInputNode {
+ static override title = NodeTypesString.CONTEXT_SWITCH_BIG;
+ static override type = NodeTypesString.CONTEXT_SWITCH_BIG;
+ static comfyClass = NodeTypesString.CONTEXT_SWITCH_BIG;
+
+ constructor(title = ContextSwitchBigNode.title) {
+ super(title);
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchBigNode);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextSwitchBigNode, app, {
+ name: "Convert To Context Switch",
+ callback: (node) => {
+ replaceNode(node, ContextSwitchNode.type);
+ },
+ });
+ }
+}
+
+/**
+ * The Context Merge (original) node.
+ */
+class ContextMergeNode extends BaseContextMultiCtxInputNode {
+ static override title = NodeTypesString.CONTEXT_MERGE;
+ static override type = NodeTypesString.CONTEXT_MERGE;
+ static comfyClass = NodeTypesString.CONTEXT_MERGE;
+
+ constructor(title = ContextMergeNode.title) {
+ super(title);
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextMergeNode);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextMergeNode, app, {
+ name: "Convert To Context Merge Big",
+ callback: (node) => {
+ replaceNode(node, ContextMergeBigNode.type);
+ },
+ });
+ }
+}
+
+/**
+ * The Context Switch Big node.
+ */
+class ContextMergeBigNode extends BaseContextMultiCtxInputNode {
+ static override title = NodeTypesString.CONTEXT_MERGE_BIG;
+ static override type = NodeTypesString.CONTEXT_MERGE_BIG;
+ static comfyClass = NodeTypesString.CONTEXT_MERGE_BIG;
+
+ constructor(title = ContextMergeBigNode.title) {
+ super(title);
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextMergeBigNode);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextMergeBigNode, app, {
+ name: "Convert To Context Switch",
+ callback: (node) => {
+ replaceNode(node, ContextMergeNode.type);
+ },
+ });
+ }
+}
+
+const contextNodes = [
+ ContextNode,
+ ContextBigNode,
+ ContextSwitchNode,
+ ContextSwitchBigNode,
+ ContextMergeNode,
+ ContextMergeBigNode,
+];
+const contextTypeToServerDef: {[type: string]: ComfyNodeDef} = {};
+
+function fixBadConfigs(node: ContextNode) {
+ // Dumb mistake, but let's fix our mispelling. This will probably need to stay in perpetuity to
+ // keep any old workflows operating.
+ const wrongName = node.outputs.find((o, i) => o.name === "CLIP_HEIGTH");
+ if (wrongName) {
+ wrongName.name = "CLIP_HEIGHT";
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.Context",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ // Loop over out context nodes and see if any match the server data.
+ for (const ctxClass of contextNodes) {
+ if (nodeData.name === ctxClass.type) {
+ contextTypeToServerDef[ctxClass.type] = nodeData;
+ ctxClass.setUp(nodeType, nodeData);
+ break;
+ }
+ }
+ },
+
+ async nodeCreated(node: TLGraphNode) {
+ const type = node.type || (node.constructor as any).type;
+ const serverDef = type && contextTypeToServerDef[type];
+ if (serverDef) {
+ fixBadConfigs(node as ContextNode);
+ matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
+ // Switches don't need to change inputs, only context outputs
+ if (!type!.includes("Switch") && !type!.includes("Merge")) {
+ matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
+ }
+ // }, 100);
+ }
+ },
+
+ /**
+ * When we're loaded from the server, check if we're using an out of date version and update our
+ * inputs / outputs to match.
+ */
+ async loadedGraphNode(node: TLGraphNode) {
+ const type = node.type || (node.constructor as any).type;
+ const serverDef = type && contextTypeToServerDef[type];
+ if (serverDef) {
+ fixBadConfigs(node as ContextNode);
+ matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
+ // Switches don't need to change inputs, only context outputs
+ if (!type!.includes("Switch") && !type!.includes("Merge")) {
+ matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
+ }
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6c8b02a4e02bab68e44aac56f7789df44a804bc1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts
@@ -0,0 +1,436 @@
+import {RgthreeDialog, RgthreeDialogOptions} from "rgthree/common/dialog.js";
+import {
+ createElement as $el,
+ empty,
+ appendChildren,
+ getClosestOrSelf,
+ query,
+ queryAll,
+ setAttributes,
+} from "rgthree/common/utils_dom.js";
+import {
+ logoCivitai,
+ link,
+ pencilColored,
+ diskColored,
+ dotdotdot,
+} from "rgthree/common/media/svgs.js";
+import {RgthreeModelInfo} from "typings/rgthree.js";
+import {CHECKPOINT_INFO_SERVICE, LORA_INFO_SERVICE} from "rgthree/common/model_info_service.js";
+import {rgthree} from "./rgthree.js";
+import {MenuButton} from "rgthree/common/menu.js";
+import {generateId, injectCss} from "rgthree/common/shared_utils.js";
+import {rgthreeApi} from "rgthree/common/rgthree_api.js";
+
+/**
+ * A dialog that displays information about a model/lora/etc.
+ */
+abstract class RgthreeInfoDialog extends RgthreeDialog {
+ private modifiedModelData = false;
+ private modelInfo: RgthreeModelInfo | null = null;
+
+ constructor(file: string) {
+ const dialogOptions: RgthreeDialogOptions = {
+ class: "rgthree-info-dialog",
+ title: `Loading... `,
+ content: "Loading.. ",
+ onBeforeClose: () => {
+ return true;
+ },
+ };
+ super(dialogOptions);
+ this.init(file);
+ }
+
+ abstract getModelInfo(file: string): Promise;
+ abstract refreshModelInfo(file: string): Promise;
+ abstract clearModelInfo(file: string): Promise;
+
+ private async init(file: string) {
+ const cssPromise = injectCss("rgthree/common/css/dialog_model_info.css");
+ this.modelInfo = await this.getModelInfo(file);
+ await cssPromise;
+ this.setContent(this.getInfoContent());
+ this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
+ this.attachEvents();
+ }
+
+ protected override getCloseEventDetail(): {detail: any} {
+ const detail = {
+ dirty: this.modifiedModelData,
+ };
+ return {detail};
+ }
+
+ private attachEvents() {
+ this.contentElement.addEventListener("click", async (e: MouseEvent) => {
+ const target = getClosestOrSelf(e.target as HTMLElement, "[data-action]");
+ const action = target?.getAttribute("data-action");
+ if (!target || !action) {
+ return;
+ }
+ await this.handleEventAction(action, target, e);
+ });
+ }
+
+ private async handleEventAction(action: string, target: HTMLElement, e?: Event) {
+ const info = this.modelInfo!;
+ if (!info?.file) {
+ return;
+ }
+ if (action === "fetch-civitai") {
+ this.modelInfo = await this.refreshModelInfo(info.file);
+ this.setContent(this.getInfoContent());
+ this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
+ } else if (action === "copy-trained-words") {
+ const selected = queryAll(".-rgthree-is-selected", target.closest("tr")!);
+ const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
+ await navigator.clipboard.writeText(text);
+ rgthree.showMessage({
+ id: "copy-trained-words-" + generateId(4),
+ type: "success",
+ message: `Successfully copied ${selected.length} key word${
+ selected.length === 1 ? "" : "s"
+ }.`,
+ timeout: 4000,
+ });
+ } else if (action === "toggle-trained-word") {
+ target?.classList.toggle("-rgthree-is-selected");
+ const tr = target.closest("tr");
+ if (tr) {
+ const span = query("td:first-child > *", tr)!;
+ let small = query("small", span);
+ if (!small) {
+ small = $el("small", {parent: span});
+ }
+ const num = queryAll(".-rgthree-is-selected", tr).length;
+ small.innerHTML = num
+ ? `${num} selected | Copy `
+ : "";
+ // this.handleEventAction('copy-trained-words', target, e);
+ }
+ } else if (action === "edit-row") {
+ const tr = target!.closest("tr")!;
+ const td = query("td:nth-child(2)", tr)!;
+ const input = td.querySelector("input,textarea");
+ if (!input) {
+ const fieldName = tr.dataset["fieldName"] as string;
+ tr.classList.add("-rgthree-editing");
+ const isTextarea = fieldName === "userNote";
+ const input = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, {
+ value: td.textContent,
+ });
+ input.addEventListener("keydown", (e) => {
+ if (!isTextarea && e.key === "Enter") {
+ const modified = saveEditableRow(info!, tr, true);
+ this.modifiedModelData = this.modifiedModelData || modified;
+ e.stopPropagation();
+ e.preventDefault();
+ } else if (e.key === "Escape") {
+ const modified = saveEditableRow(info!, tr, false);
+ this.modifiedModelData = this.modifiedModelData || modified;
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ });
+ appendChildren(empty(td), [input]);
+ input.focus();
+ } else if (target!.nodeName.toLowerCase() === "button") {
+ const modified = saveEditableRow(info!, tr, true);
+ this.modifiedModelData = this.modifiedModelData || modified;
+ }
+ e?.preventDefault();
+ e?.stopPropagation();
+ }
+ }
+
+ private getInfoContent() {
+ const info = this.modelInfo || {};
+ const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
+ const html = `
+
+ ${info.type || ""}
+ ${info.baseModel || ""}
+
+ ${
+ ""
+ // !civitaiLink
+ // ? ""
+ // : `
+ // Civitai ${link}
+ // `
+ }
+
+
+
+ ${infoTableRow("File", info.file || "")}
+ ${infoTableRow("Hash (sha256)", info.sha256 || "")}
+ ${
+ civitaiLink
+ ? infoTableRow(
+ "Civitai",
+ `${logoCivitai}View on Civitai `,
+ )
+ : info.raw?.civitai?.error === "Model not found"
+ ? infoTableRow(
+ "Civitai",
+ 'Model not found ',
+ )
+ : info.raw?.civitai?.error
+ ? infoTableRow("Civitai", info.raw?.civitai?.error)
+ : !info.raw?.civitai
+ ? infoTableRow(
+ "Civitai",
+ `Fetch info from civitai `,
+ )
+ : ""
+ }
+
+ ${infoTableRow(
+ "Name",
+ info.name || info.raw?.metadata?.ss_output_name || "",
+ "The name for display.",
+ "name",
+ )}
+
+ ${
+ !info.baseModelFile && !info.baseModelFile
+ ? ""
+ : infoTableRow(
+ "Base Model",
+ (info.baseModel || "") + (info.baseModelFile ? ` (${info.baseModelFile})` : ""),
+ )
+ }
+
+
+ ${
+ !info.trainedWords?.length
+ ? ""
+ : infoTableRow(
+ "Trained Words",
+ getTrainedWordsMarkup(info.trainedWords) ?? "",
+ "Trained words from the metadata and/or civitai. Click to select for copy.",
+ )
+ }
+
+ ${
+ !info.raw?.metadata?.ss_clip_skip || info.raw?.metadata?.ss_clip_skip == "None"
+ ? ""
+ : infoTableRow("Clip Skip", info.raw?.metadata?.ss_clip_skip)
+ }
+ ${infoTableRow(
+ "Strength Min",
+ info.strengthMin ?? "",
+ "The recommended minimum strength, In the Power Lora Loader node, strength will signal when it is below this threshold.",
+ "strengthMin",
+ )}
+ ${infoTableRow(
+ "Strength Max",
+ info.strengthMax ?? "",
+ "The recommended maximum strength. In the Power Lora Loader node, strength will signal when it is above this threshold.",
+ "strengthMax",
+ )}
+ ${
+ "" /*infoTableRow(
+ "User Tags",
+ info.userTags?.join(", ") ?? "",
+ "A list of tags to make filtering easier in the Power Lora Chooser.",
+ "userTags",
+ )*/
+ }
+ ${infoTableRow(
+ "Additional Notes",
+ info.userNote ?? "",
+ "Additional notes you'd like to keep and reference in the info dialog.",
+ "userNote",
+ )}
+
+
+
+ ${
+ info.images
+ ?.map(
+ (img) => `
+
+ ${
+ img.type === 'video'
+ ? ` `
+ : ` `
+ }
+ ${imgInfoField(
+ "",
+ img.civitaiUrl
+ ? `civitai${link} `
+ : undefined,
+ )}${imgInfoField("seed", img.seed)}${imgInfoField("steps", img.steps)}${imgInfoField("cfg", img.cfg)}${imgInfoField("sampler", img.sampler)}${imgInfoField("model", img.model)}${imgInfoField("positive", img.positive)}${imgInfoField("negative", img.negative)}
+
+ `,
+ )
+ .join("") ?? ""
+ }
+ `;
+
+ const div = $el("div", {html});
+
+ if (rgthree.isDevMode()) {
+ setAttributes(query('[stub="menu"]', div)!, {
+ children: [
+ new MenuButton({
+ icon: dotdotdot,
+ options: [
+ {label: "More Actions", type: "title"},
+ {
+ label: "Open API JSON",
+ callback: async (e: PointerEvent) => {
+ if (this.modelInfo?.file) {
+ window.open(
+ `rgthree/api/loras/info?file=${encodeURIComponent(this.modelInfo.file)}`,
+ );
+ }
+ },
+ },
+ {
+ label: "Clear all local info",
+ callback: async (e: PointerEvent) => {
+ if (this.modelInfo?.file) {
+ this.modelInfo = await LORA_INFO_SERVICE.clearFetchedInfo(this.modelInfo.file);
+ this.setContent(this.getInfoContent());
+ this.setTitle(
+ this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown",
+ );
+ }
+ },
+ },
+ ],
+ }),
+ ],
+ });
+ }
+
+ return div;
+ }
+}
+
+export class RgthreeLoraInfoDialog extends RgthreeInfoDialog {
+ override async getModelInfo(file: string) {
+ return LORA_INFO_SERVICE.getInfo(file, false, false);
+ }
+ override async refreshModelInfo(file: string) {
+ return LORA_INFO_SERVICE.refreshInfo(file);
+ }
+ override async clearModelInfo(file: string) {
+ return LORA_INFO_SERVICE.clearFetchedInfo(file);
+ }
+}
+
+export class RgthreeCheckpointInfoDialog extends RgthreeInfoDialog {
+ override async getModelInfo(file: string) {
+ return CHECKPOINT_INFO_SERVICE.getInfo(file, false, false);
+ }
+ override async refreshModelInfo(file: string) {
+ return CHECKPOINT_INFO_SERVICE.refreshInfo(file);
+ }
+ override async clearModelInfo(file: string) {
+ return CHECKPOINT_INFO_SERVICE.clearFetchedInfo(file);
+ }
+}
+
+/**
+ * Generates a uniform markup string for a table row.
+ */
+function infoTableRow(
+ name: string,
+ value: string | number,
+ help: string = "",
+ editableFieldName = "",
+) {
+ return `
+
+ ${name} ${help ? ` ` : ""}
+ ${
+ String(value).startsWith("<") ? value : `${value}`
+ }
+ ${
+ editableFieldName
+ ? `${pencilColored}${diskColored} `
+ : ""
+ }
+ `;
+}
+
+function getTrainedWordsMarkup(words: RgthreeModelInfo["trainedWords"]) {
+ let markup = ``;
+ for (const wordData of words || []) {
+ markup += `
+ ${wordData.word}
+ ${wordData.civitai ? logoCivitai : ""}
+ ${wordData.count != null ? `${wordData.count} ` : ""}
+ `;
+ }
+ markup += ` `;
+ return markup;
+}
+
+/**
+ * Saves / cancels an editable row. Returns a boolean if the data was modified.
+ */
+function saveEditableRow(info: RgthreeModelInfo, tr: HTMLElement, saving = true): boolean {
+ const fieldName = tr.dataset["fieldName"] as "file";
+ const input = query("input,textarea", tr)!;
+ let newValue = info[fieldName] ?? "";
+ let modified = false;
+ if (saving) {
+ newValue = input!.value;
+ if (fieldName.startsWith("strength")) {
+ if (Number.isNaN(Number(newValue))) {
+ alert(`You must enter a number into the ${fieldName} field.`);
+ return false;
+ }
+ newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
+ }
+ LORA_INFO_SERVICE.savePartialInfo(info.file!, {[fieldName]: newValue});
+ modified = true;
+ }
+ tr.classList.remove("-rgthree-editing");
+ const td = query("td:nth-child(2)", tr)!;
+ appendChildren(empty(td), [$el("span", {text: newValue})]);
+ return modified;
+}
+
+function imgInfoField(label: string, value?: string | number) {
+ return value != null ? `${label ? `${label} ` : ""}${value} ` : "";
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f2c725a5eef7db3faacf58ceb63ed01c3a341760
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts
@@ -0,0 +1,71 @@
+import type {LGraphNodeConstructor, LGraphNode as TLGraphNode} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+import type {ComfyApp} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {addConnectionLayoutSupport} from "./utils.js";
+import {rgthree} from "./rgthree.js";
+
+let hasShownAlertForUpdatingInt = false;
+
+app.registerExtension({
+ name: "rgthree.DisplayAny",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef, app: ComfyApp) {
+ if (nodeData.name === "Display Any (rgthree)" || nodeData.name === "Display Int (rgthree)") {
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
+
+ (this as any).showValueWidget = ComfyWidgets["STRING"](
+ this,
+ "output",
+ ["STRING", {multiline: true}],
+ app,
+ ).widget;
+ (this as any).showValueWidget.inputEl!.readOnly = true;
+ (this as any).showValueWidget.serializeValue = async (node: TLGraphNode, index: number) => {
+ const n =
+ rgthree.getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff(node);
+ if (n) {
+ // Since we need a round trip to get the value, the serizalized value means nothing, and
+ // saving it to the metadata would just be confusing. So, we clear it here.
+ n.widgets_values![index] = "";
+ } else {
+ console.warn(
+ "No serialized node found in workflow. May be attributed to " +
+ "https://github.com/comfyanonymous/ComfyUI/issues/2193",
+ );
+ }
+ return "";
+ };
+ };
+
+ addConnectionLayoutSupport(nodeType as LGraphNodeConstructor, app, [["Left"], ["Right"]]);
+
+ const onExecuted = nodeType.prototype.onExecuted;
+ nodeType.prototype.onExecuted = function (message: any) {
+ onExecuted?.apply(this, [message]);
+ (this as any).showValueWidget.value = message.text[0];
+ };
+ }
+ },
+
+ // This ports Display Int to DisplayAny, but ComfyUI still shows an error.
+ // If https://github.com/comfyanonymous/ComfyUI/issues/1527 is fixed, this could work.
+ // async loadedGraphNode(node: TLGraphNode) {
+ // if (node.type === "Display Int (rgthree)") {
+ // replaceNode(node, "Display Any (rgthree)", new Map([["input", "source"]]));
+ // if (!hasShownAlertForUpdatingInt) {
+ // hasShownAlertForUpdatingInt = true;
+ // setTimeout(() => {
+ // alert(
+ // "Don't worry, your 'Display Int' nodes have been updated to the new " +
+ // "'Display Any' nodes! You can ignore the error message underneath (for that node)." +
+ // "\n\nThanks.\n- rgthree",
+ // );
+ // }, 128);
+ // }
+ // }
+ // },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a964d6209159e0a7f9e2c112415dabc001a41a70
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts
@@ -0,0 +1,302 @@
+import type {
+ IContextMenuValue,
+ IFoundSlot,
+ INodeInputSlot,
+ INodeOutputSlot,
+ ISlotType,
+ LGraphNode,
+ LLink,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {
+ IoDirection,
+ followConnectionUntilType,
+ getConnectedInputInfosAndFilterPassThroughs,
+} from "./utils.js";
+import {rgthree} from "./rgthree.js";
+import {
+ SERVICE as CONTEXT_SERVICE,
+ InputMutation,
+ InputMutationOperation,
+} from "./services/context_service.js";
+import {NodeTypesString} from "./constants.js";
+import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
+import {DynamicContextNodeBase} from "./dynamic_context_base.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+
+const OWNED_PREFIX = "+";
+const REGEX_OWNED_PREFIX = /^\+\s*/;
+const REGEX_EMPTY_INPUT = /^\+\s*$/;
+
+/**
+ * The Dynamic Context node.
+ */
+export class DynamicContextNode extends DynamicContextNodeBase {
+ static override title = NodeTypesString.DYNAMIC_CONTEXT;
+ static override type = NodeTypesString.DYNAMIC_CONTEXT;
+ static comfyClass = NodeTypesString.DYNAMIC_CONTEXT;
+
+ constructor(title = DynamicContextNode.title) {
+ super(title);
+ }
+
+ override onNodeCreated() {
+ this.addInput("base_ctx", "RGTHREE_DYNAMIC_CONTEXT");
+ this.ensureOneRemainingNewInputSlot();
+ super.onNodeCreated();
+ }
+
+ override onConnectionsChange(
+ type: ISlotType,
+ slotIndex: number,
+ isConnected: boolean,
+ link: LLink | null | undefined,
+ ioSlot: INodeInputSlot | INodeOutputSlot,
+ ): void {
+ super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, ioSlot);
+ if (this.configuring) {
+ return;
+ }
+ if (type === LiteGraph.INPUT) {
+ if (isConnected) {
+ this.handleInputConnected(slotIndex);
+ } else {
+ this.handleInputDisconnected(slotIndex);
+ }
+ }
+ }
+
+ override onConnectInput(
+ inputIndex: number,
+ outputType: INodeOutputSlot["type"],
+ outputSlot: INodeOutputSlot,
+ outputNode: LGraphNode,
+ outputIndex: number,
+ ): boolean {
+ let canConnect = true;
+ if (super.onConnectInput) {
+ canConnect = super.onConnectInput.apply(this, [...arguments] as any);
+ }
+ if (
+ canConnect &&
+ outputNode instanceof DynamicContextNode &&
+ outputIndex === 0 &&
+ inputIndex !== 0
+ ) {
+ const [n, v] = rgthree.logger.warnParts(
+ "Currently, you can only connect a context node in the first slot.",
+ );
+ console[n]?.call(console, ...v);
+ canConnect = false;
+ }
+ return canConnect;
+ }
+
+ handleInputConnected(slotIndex: number) {
+ const ioSlot = this.inputs[slotIndex];
+ const connectedIndexes = [];
+ if (slotIndex === 0) {
+ let baseNodeInfos = getConnectedInputInfosAndFilterPassThroughs(this, this, 0);
+ const baseNodes = baseNodeInfos.map((n) => n.node)!;
+ const baseNodesDynamicCtx = baseNodes[0] as DynamicContextNodeBase;
+ if (baseNodesDynamicCtx?.provideInputsData) {
+ const inputsData = CONTEXT_SERVICE.getDynamicContextInputsData(baseNodesDynamicCtx);
+ console.log("inputsData", inputsData);
+ for (const input of baseNodesDynamicCtx.provideInputsData()) {
+ if (input.name === "base_ctx" || input.name === "+") {
+ continue;
+ }
+ this.addContextInput(input.name, input.type, input.index);
+ this.stabilizeNames();
+ }
+ }
+ } else if (this.isInputSlotForNewInput(slotIndex)) {
+ this.handleNewInputConnected(slotIndex);
+ }
+ }
+
+ isInputSlotForNewInput(slotIndex: number) {
+ const ioSlot = this.inputs[slotIndex];
+ return ioSlot && ioSlot.name === "+" && ioSlot.type === "*";
+ }
+
+ handleNewInputConnected(slotIndex: number) {
+ if (!this.isInputSlotForNewInput(slotIndex)) {
+ throw new Error('Expected the incoming slot index to be the "new input" input.');
+ }
+ const ioSlot = this.inputs[slotIndex]!;
+ let cxn = null;
+ if (ioSlot.link != null) {
+ cxn = followConnectionUntilType(this, IoDirection.INPUT, slotIndex, true);
+ }
+ if (cxn?.type && cxn?.name) {
+ let name = this.addOwnedPrefix(this.getNextUniqueNameForThisNode(cxn.name));
+ if (name.match(/^\+\s*[A-Z_]+(\.\d+)?$/)) {
+ name = name.toLowerCase();
+ }
+ ioSlot.name = name;
+ ioSlot.type = cxn.type as string;
+ ioSlot.removable = true;
+ while (!this.outputs[slotIndex]) {
+ this.addOutput("*", "*");
+ }
+ this.outputs[slotIndex]!.type = cxn.type as string;
+ this.outputs[slotIndex]!.name = this.stripOwnedPrefix(name).toLocaleUpperCase();
+ // This is a dumb override for ComfyUI's widgetinputs issues.
+ if (cxn.type === "COMBO" || cxn.type.includes(",") || Array.isArray(cxn.type)) {
+ (this.outputs[slotIndex] as any).widget = true;
+ }
+ this.inputsMutated({
+ operation: InputMutationOperation.ADDED,
+ node: this,
+ slotIndex,
+ slot: ioSlot,
+ });
+ this.stabilizeNames();
+ this.ensureOneRemainingNewInputSlot();
+ }
+ }
+
+ handleInputDisconnected(slotIndex: number) {
+ const inputs = this.getContextInputsList();
+ if (slotIndex === 0) {
+ for (let index = inputs.length - 1; index > 0; index--) {
+ if (index === 0 || index === inputs.length - 1) {
+ continue;
+ }
+ const input = inputs[index]!;
+ if (!this.isOwnedInput(input.name)) {
+ if (input.link || this.outputs[index]?.links?.length) {
+ this.renameContextInput(index, input.name, true);
+ } else {
+ this.removeContextInput(index);
+ }
+ }
+ }
+ this.setSize(this.computeSize());
+ this.setDirtyCanvas(true, true);
+ }
+ }
+
+ ensureOneRemainingNewInputSlot() {
+ removeUnusedInputsFromEnd(this, 1, REGEX_EMPTY_INPUT);
+ this.addInput(OWNED_PREFIX, "*");
+ }
+
+ getNextUniqueNameForThisNode(desiredName: string) {
+ const inputs = this.getContextInputsList();
+ const allExistingKeys = inputs.map((i) => this.stripOwnedPrefix(i.name).toLocaleUpperCase());
+ desiredName = this.stripOwnedPrefix(desiredName);
+ let newName = desiredName;
+ let n = 0;
+ while (allExistingKeys.includes(newName.toLocaleUpperCase())) {
+ newName = `${desiredName}.${++n}`;
+ }
+ return newName;
+ }
+
+ override removeInput(slotIndex: number) {
+ const slot = this.inputs[slotIndex]!;
+ super.removeInput(slotIndex);
+ if (this.outputs[slotIndex]) {
+ this.removeOutput(slotIndex);
+ }
+ this.inputsMutated({operation: InputMutationOperation.REMOVED, node: this, slotIndex, slot});
+ this.stabilizeNames();
+ }
+
+ stabilizeNames() {
+ const inputs = this.getContextInputsList();
+ const names: string[] = [];
+ for (const [index, input] of inputs.entries()) {
+ if (index === 0 || index === inputs.length - 1) {
+ continue;
+ }
+ input.label = undefined;
+ this.outputs[index]!.label = undefined;
+ let origName = this.stripOwnedPrefix(input.name).replace(/\.\d+$/, "");
+ let name = input.name;
+ if (!this.isOwnedInput(name)) {
+ names.push(name.toLocaleUpperCase());
+ } else {
+ let n = 0;
+ name = this.addOwnedPrefix(origName);
+ while (names.includes(this.stripOwnedPrefix(name).toLocaleUpperCase())) {
+ name = `${this.addOwnedPrefix(origName)}.${++n}`;
+ }
+ names.push(this.stripOwnedPrefix(name).toLocaleUpperCase());
+ if (input.name !== name) {
+ this.renameContextInput(index, name);
+ }
+ }
+ }
+ }
+
+ override getSlotMenuOptions(slot: IFoundSlot): IContextMenuValue[] {
+ const editable = this.isOwnedInput(slot.input!.name) && this.type !== "*";
+ return [
+ {
+ content: "✏️ Rename Input",
+ disabled: !editable,
+ callback: () => {
+ var dialog = app.canvas.createDialog(
+ "Name OK ",
+ {},
+ );
+ var dialogInput = dialog.querySelector("input")!;
+ if (dialogInput) {
+ dialogInput.value = this.stripOwnedPrefix(slot.input!.name || "");
+ }
+ var inner = () => {
+ this.handleContextMenuRenameInputDialog(slot.slot, dialogInput.value);
+ dialog.close();
+ };
+ dialog.querySelector("button")!.addEventListener("click", inner);
+ dialogInput.addEventListener("keydown", (e) => {
+ dialog.is_modified = true;
+ if (e.keyCode == 27) {
+ dialog.close();
+ } else if (e.keyCode == 13) {
+ inner();
+ } else if (e.keyCode != 13 && (e.target as HTMLElement)?.localName != "textarea") {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ dialogInput.focus();
+ },
+ },
+ {
+ content: "🗑️ Delete Input",
+ disabled: !editable,
+ callback: () => {
+ this.removeInput(slot.slot);
+ },
+ },
+ ];
+ }
+
+ handleContextMenuRenameInputDialog(slotIndex: number, value: string) {
+ app.graph.beforeChange();
+ this.renameContextInput(slotIndex, value);
+ this.stabilizeNames();
+ this.setDirtyCanvas(true, true);
+ app.graph.afterChange();
+ }
+}
+
+const contextDynamicNodes = [DynamicContextNode];
+app.registerExtension({
+ name: "rgthree.DynamicContext",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
+ return;
+ }
+ if (nodeData.name === DynamicContextNode.type) {
+ DynamicContextNode.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context_base.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context_base.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a983ac30c9766bfe8ed0fd347f794bdfd8d8a86e
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context_base.ts
@@ -0,0 +1,241 @@
+import type {INodeInputSlot, LGraphNodeConstructor} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {BaseContextNode} from "./context.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {moveArrayItem, wait} from "rgthree/common/shared_utils.js";
+import {RgthreeInvisibleWidget} from "./utils_widgets.js";
+import {
+ getContextOutputName,
+ InputMutation,
+ InputMutationOperation,
+} from "./services/context_service.js";
+import {SERVICE as CONTEXT_SERVICE} from "./services/context_service.js";
+
+const OWNED_PREFIX = "+";
+const REGEX_OWNED_PREFIX = /^\+\s*/;
+const REGEX_EMPTY_INPUT = /^\+\s*$/;
+
+export type InputLike = {
+ name: string;
+ type: number | string;
+ label?: string;
+ link: number | null;
+ removable?: boolean;
+ boundingRect: any;
+};
+
+/**
+ * The base context node that contains some shared between DynamicContext nodes. Not labels
+ * `abstract` so we can reference `this` in static methods.
+ */
+export class DynamicContextNodeBase extends BaseContextNode {
+ protected readonly hasShadowInputs: boolean = false;
+
+ getContextInputsList(): InputLike[] {
+ return this.inputs;
+ }
+
+ provideInputsData() {
+ const inputs = this.getContextInputsList();
+ return inputs
+ .map((input, index) => ({
+ name: this.stripOwnedPrefix(input.name),
+ type: String(input.type),
+ index,
+ }))
+ .filter((i) => i.type !== "*");
+ }
+
+ addOwnedPrefix(name: string) {
+ return `+ ${this.stripOwnedPrefix(name)}`;
+ }
+
+ isOwnedInput(inputOrName: string | null | INodeInputSlot) {
+ const name = typeof inputOrName == "string" ? inputOrName : inputOrName?.name || "";
+ return REGEX_OWNED_PREFIX.test(name);
+ }
+
+ stripOwnedPrefix(name: string) {
+ return name.replace(REGEX_OWNED_PREFIX, "");
+ }
+
+ // handleUpstreamMutation(mutation: InputMutation) {
+ // throw new Error('handleUpstreamMutation not overridden!')
+ // }
+
+ handleUpstreamMutation(mutation: InputMutation) {
+ console.log(`[node ${this.id}] handleUpstreamMutation`, mutation);
+ if (mutation.operation === InputMutationOperation.ADDED) {
+ const slot = mutation.slot;
+ if (!slot) {
+ throw new Error("Cannot have an ADDED mutation without a provided slot data.");
+ }
+ this.addContextInput(
+ this.stripOwnedPrefix(slot.name),
+ slot.type as string,
+ mutation.slotIndex,
+ );
+ return;
+ }
+ if (mutation.operation === InputMutationOperation.REMOVED) {
+ const slot = mutation.slot;
+ if (!slot) {
+ throw new Error("Cannot have an REMOVED mutation without a provided slot data.");
+ }
+ this.removeContextInput(mutation.slotIndex);
+ return;
+ }
+ if (mutation.operation === InputMutationOperation.RENAMED) {
+ const slot = mutation.slot;
+ if (!slot) {
+ throw new Error("Cannot have an RENAMED mutation without a provided slot data.");
+ }
+ this.renameContextInput(mutation.slotIndex, slot.name);
+ return;
+ }
+ }
+ override clone() {
+ const cloned = super.clone()! as DynamicContextNodeBase;
+ while (cloned.inputs.length > 1) {
+ cloned.removeInput(cloned.inputs.length - 1);
+ }
+ while (cloned.widgets.length > 1) {
+ cloned.removeWidget(cloned.widgets.length - 1);
+ }
+ while (cloned.outputs.length > 1) {
+ cloned.removeOutput(cloned.outputs.length - 1);
+ }
+ return cloned;
+ }
+
+ /**
+ * Adds the basic output_keys widget. Should be called _after_ specific nodes setup their inputs
+ * or widgets.
+ */
+ override onNodeCreated() {
+ const node = this;
+ this.addCustomWidget(
+ new RgthreeInvisibleWidget("output_keys", "RGTHREE_DYNAMIC_CONTEXT_OUTPUTS", "", () => {
+ return (node.outputs || [])
+ .map((o, i) => i > 0 && o.name)
+ .filter((n) => n !== false)
+ .join(",");
+ }),
+ );
+ }
+
+ addContextInput(name: string, type: string, slot = -1) {
+ const inputs = this.getContextInputsList();
+ if (this.hasShadowInputs) {
+ inputs.push({name, type, link: null, boundingRect: null});
+ } else {
+ this.addInput(name, type);
+ }
+ if (slot > -1) {
+ moveArrayItem(inputs, inputs.length - 1, slot);
+ } else {
+ slot = inputs.length - 1;
+ }
+ if (type !== "*") {
+ const output = this.addOutput(getContextOutputName(name), type);
+ if (type === "COMBO" || String(type).includes(",") || Array.isArray(type)) {
+ (output as any).widget = true;
+ }
+ if (slot > -1) {
+ moveArrayItem(this.outputs, this.outputs.length - 1, slot);
+ }
+ }
+ this.fixInputsOutputsLinkSlots();
+ this.inputsMutated({
+ operation: InputMutationOperation.ADDED,
+ node: this,
+ slotIndex: slot,
+ slot: inputs[slot]!,
+ });
+ }
+
+ removeContextInput(slotIndex: number) {
+ if (this.hasShadowInputs) {
+ const inputs = this.getContextInputsList();
+ const input = inputs.splice(slotIndex, 1)[0];
+ if (this.outputs[slotIndex]) {
+ this.removeOutput(slotIndex);
+ }
+ } else {
+ this.removeInput(slotIndex);
+ }
+ }
+
+ renameContextInput(index: number, newName: string, forceOwnBool: boolean | null = null) {
+ const inputs = this.getContextInputsList();
+ const input = inputs[index]!;
+ const oldName = input.name;
+ newName = this.stripOwnedPrefix(newName.trim() || this.getSlotDefaultInputLabel(index));
+ if (forceOwnBool === true || (this.isOwnedInput(oldName) && forceOwnBool !== false)) {
+ newName = this.addOwnedPrefix(newName);
+ }
+ if (oldName !== newName) {
+ input.name = newName;
+ input.removable = this.isOwnedInput(newName);
+ this.outputs[index]!.name = getContextOutputName(inputs[index]!.name);
+ this.inputsMutated({
+ node: this,
+ operation: InputMutationOperation.RENAMED,
+ slotIndex: index,
+ slot: input,
+ });
+ }
+ }
+
+ getSlotDefaultInputLabel(slotIndex: number) {
+ const inputs = this.getContextInputsList();
+ const input = inputs[slotIndex]!;
+ let defaultLabel = this.stripOwnedPrefix(input.name).toLowerCase();
+ return defaultLabel.toLocaleLowerCase();
+ }
+
+ inputsMutated(mutation: InputMutation) {
+ CONTEXT_SERVICE.onInputChanges(this, mutation);
+ }
+
+ fixInputsOutputsLinkSlots() {
+ if (!this.hasShadowInputs) {
+ const inputs = this.getContextInputsList();
+ for (let index = inputs.length - 1; index > 0; index--) {
+ const input = inputs[index]!;
+ if ((input === null || input === void 0 ? void 0 : input.link) != null) {
+ app.graph.links[input.link!]!.target_slot = index;
+ }
+ }
+ }
+ const outputs = this.outputs;
+ for (let index = outputs.length - 1; index > 0; index--) {
+ const output = outputs[index];
+ if (output) {
+ output.nameLocked = true;
+ for (const link of output.links || []) {
+ app.graph.links[link!]!.origin_slot = index;
+ }
+ }
+ }
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, this);
+ // [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
+ // empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
+ // ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
+ // because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
+ // extension and we need to wait for that to happen.
+ wait(500).then(() => {
+ LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] =
+ LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] || [];
+ const comfyClassStr = (comfyClass as LGraphNodeConstructor).comfyClass;
+ if (comfyClassStr) {
+ LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"].push(comfyClassStr);
+ }
+ });
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context_switch.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context_switch.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2f090ba56d7b4319c90d3243e7aa8aa59e4b3df
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context_switch.ts
@@ -0,0 +1,214 @@
+import type {
+ LGraphNode,
+ LLink,
+ LGraphCanvas,
+ INodeInputSlot,
+ INodeOutputSlot,
+ ISlotType,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {DynamicContextNodeBase, InputLike} from "./dynamic_context_base.js";
+import {NodeTypesString} from "./constants.js";
+import {
+ InputMutation,
+ SERVICE as CONTEXT_SERVICE,
+ getContextOutputName,
+} from "./services/context_service.js";
+import {getConnectedInputNodesAndFilterPassThroughs} from "./utils.js";
+import {debounce, moveArrayItem} from "rgthree/common/shared_utils.js";
+import {measureText} from "./utils_canvas.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+
+type ShadowInputData = {
+ node: LGraphNode;
+ slot: number;
+ shadowIndex: number;
+ shadowIndexIfShownSingularly: number;
+ shadowIndexFull: number;
+ nodeIndex: number;
+ type: string | -1;
+ name: string;
+ key: string;
+ // isDuplicatedBefore: boolean,
+ duplicatesBefore: number[];
+ duplicatesAfter: number[];
+};
+
+/**
+ * The Context Switch node.
+ */
+class DynamicContextSwitchNode extends DynamicContextNodeBase {
+ static override title = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
+ static override type = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
+ static comfyClass = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
+
+ protected override readonly hasShadowInputs = true;
+
+ // override hasShadowInputs = true;
+
+ /**
+ * We should be able to assume that `lastInputsList` is the input list after the last, major
+ * synchronous change. Which should mean, if we're handling a change that is currently live, but
+ * not represented in our node (like, an upstream node has already removed an input), then we
+ * should be able to compar the current InputList to this `lastInputsList`.
+ */
+ lastInputsList: ShadowInputData[] = [];
+
+ private shadowInputs: (InputLike & {count: number})[] = [
+ {name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0, boundingRect: null},
+ ];
+
+ constructor(title = DynamicContextSwitchNode.title) {
+ super(title);
+ }
+
+ override getContextInputsList() {
+ return this.shadowInputs;
+ }
+ override handleUpstreamMutation(mutation: InputMutation) {
+ this.scheduleHardRefresh();
+ }
+
+ override onConnectionsChange(
+ type: ISlotType,
+ slotIndex: number,
+ isConnected: boolean,
+ link: LLink | null | undefined,
+ inputOrOutput: INodeInputSlot | INodeOutputSlot,
+ ): void {
+ super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, inputOrOutput);
+ if (this.configuring) {
+ return;
+ }
+ if (type === LiteGraph.INPUT) {
+ this.scheduleHardRefresh();
+ }
+ }
+
+ scheduleHardRefresh(ms = 64) {
+ return debounce(() => {
+ this.refreshInputsAndOutputs();
+ }, ms);
+ }
+
+ override onNodeCreated() {
+ this.addInput("ctx_1", "RGTHREE_DYNAMIC_CONTEXT");
+ this.addInput("ctx_2", "RGTHREE_DYNAMIC_CONTEXT");
+ this.addInput("ctx_3", "RGTHREE_DYNAMIC_CONTEXT");
+ this.addInput("ctx_4", "RGTHREE_DYNAMIC_CONTEXT");
+ this.addInput("ctx_5", "RGTHREE_DYNAMIC_CONTEXT");
+ super.onNodeCreated();
+ }
+
+ override addContextInput(name: string, type: string, slot?: number): void {}
+
+ /**
+ * This is a "hard" refresh of the list, but looping over the actual context inputs, and
+ * recompiling the shadowInputs and outputs.
+ */
+ private refreshInputsAndOutputs() {
+ const inputs: (InputLike & {count: number})[] = [
+ {name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0, boundingRect: null},
+ ];
+ let numConnected = 0;
+ for (let i = 0; i < this.inputs.length; i++) {
+ const childCtxs = getConnectedInputNodesAndFilterPassThroughs(
+ this,
+ this,
+ i,
+ ) as DynamicContextNodeBase[];
+ if (childCtxs.length > 1) {
+ throw new Error("How is there more than one input?");
+ }
+ const ctx = childCtxs[0];
+ if (!ctx) continue;
+ numConnected++;
+ const slotsData = CONTEXT_SERVICE.getDynamicContextInputsData(ctx);
+ console.log(slotsData);
+ for (const slotData of slotsData) {
+ const found = inputs.find(
+ (n) => getContextOutputName(slotData.name) === getContextOutputName(n.name),
+ );
+ if (found) {
+ found.count += 1;
+ continue;
+ }
+ inputs.push({
+ name: slotData.name,
+ type: slotData.type,
+ link: null,
+ count: 1,
+ boundingRect: null,
+ });
+ }
+ }
+ this.shadowInputs = inputs;
+ // First output is always CONTEXT, so "p" is the offset.
+ let i = 0;
+ for (i; i < this.shadowInputs.length; i++) {
+ const data = this.shadowInputs[i]!;
+ let existing = this.outputs.find(
+ (o) => getContextOutputName(o.name) === getContextOutputName(data.name),
+ );
+ if (!existing) {
+ existing = this.addOutput(getContextOutputName(data.name), data.type);
+ }
+ moveArrayItem(this.outputs, existing, i);
+ delete existing.rgthree_status;
+ if (data.count !== numConnected) {
+ existing.rgthree_status = "WARN";
+ }
+ }
+ while (this.outputs[i]) {
+ const output = this.outputs[i];
+ if (output?.links?.length) {
+ output.rgthree_status = "ERROR";
+ i++;
+ } else {
+ this.removeOutput(i);
+ }
+ }
+ this.fixInputsOutputsLinkSlots();
+ }
+
+ override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: LGraphCanvas): void {
+ const low_quality = (canvas?.ds?.scale ?? 1) < 0.6;
+ if (low_quality || this.size[0] <= 10) {
+ return;
+ }
+ let y = LiteGraph.NODE_SLOT_HEIGHT - 1;
+ const w = this.size[0];
+ ctx.save();
+ ctx.font = "normal " + LiteGraph.NODE_SUBTEXT_SIZE + "px Arial";
+ ctx.textAlign = "right";
+
+ for (const output of this.outputs) {
+ if (!output.rgthree_status) {
+ y += LiteGraph.NODE_SLOT_HEIGHT;
+ continue;
+ }
+ const x = w - 20 - measureText(ctx, output.name);
+ if (output.rgthree_status === "ERROR") {
+ ctx.fillText("🛑", x, y);
+ } else if (output.rgthree_status === "WARN") {
+ ctx.fillText("⚠️", x, y);
+ }
+ y += LiteGraph.NODE_SLOT_HEIGHT;
+ }
+ ctx.restore();
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.DynamicContextSwitch",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
+ return;
+ }
+ if (nodeData.name === DynamicContextSwitchNode.type) {
+ DynamicContextSwitchNode.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/fast_actions_button.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/fast_actions_button.ts
new file mode 100644
index 0000000000000000000000000000000000000000..890c05aca78783062cc767902afe42f7d9540312
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/fast_actions_button.ts
@@ -0,0 +1,359 @@
+import type {
+ LGraph,
+ LGraphNode,
+ ISerialisedNode,
+ IButtonWidget,
+ IComboWidget,
+ IWidget,
+ IBaseWidget,
+} from "@comfyorg/frontend";
+import type {ComfyApp} from "@comfyorg/frontend";
+import type {RgthreeBaseVirtualNode} from "./base_node.js";
+
+import {app} from "scripts/app.js";
+import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
+import {NodeTypesString} from "./constants.js";
+import {addMenuItem, changeModeOfNodes} from "./utils.js";
+import {rgthree} from "./rgthree.js";
+
+const MODE_ALWAYS = 0;
+const MODE_MUTE = 2;
+const MODE_BYPASS = 4;
+
+/**
+ * The Fast Actions Button.
+ *
+ * This adds a button that the user can connect any node to and then choose an action to take on
+ * that node when the button is pressed. Default actions are "Mute," "Bypass," and "Enable," but
+ * Nodes can expose actions additional actions that can then be called back.
+ */
+class FastActionsButton extends BaseAnyInputConnectedNode {
+ static override type = NodeTypesString.FAST_ACTIONS_BUTTON;
+ static override title = NodeTypesString.FAST_ACTIONS_BUTTON;
+ override comfyClass = NodeTypesString.FAST_ACTIONS_BUTTON;
+
+ readonly logger = rgthree.newLogSession("[FastActionsButton]");
+
+ static "@buttonText" = {type: "string"};
+ static "@shortcutModifier" = {
+ type: "combo",
+ values: ["ctrl", "alt", "shift"],
+ };
+ static "@shortcutKey" = {type: "string"};
+
+ static collapsible = false;
+
+ override readonly isVirtualNode = true;
+
+ override serialize_widgets = true;
+
+ readonly buttonWidget: IButtonWidget;
+
+ readonly widgetToData = new Map();
+ readonly nodeIdtoFunctionCache = new Map();
+
+ readonly keypressBound;
+ readonly keyupBound;
+
+ private executingFromShortcut = false;
+
+ override properties!: BaseAnyInputConnectedNode["properties"] & {
+ buttonText: string;
+ shortcutModifier: string;
+ shortcutKey: string;
+ };
+
+ constructor(title?: string) {
+ super(title);
+ this.properties["buttonText"] = "🎬 Action!";
+ this.properties["shortcutModifier"] = "alt";
+ this.properties["shortcutKey"] = "";
+ this.buttonWidget = this.addWidget(
+ "button",
+ this.properties["buttonText"],
+ "",
+ () => {
+ this.executeConnectedNodes();
+ },
+ {serialize: false},
+ ) as IButtonWidget;
+
+ this.keypressBound = this.onKeypress.bind(this);
+ this.keyupBound = this.onKeyup.bind(this);
+ this.onConstructed();
+ }
+
+ /** When we're given data to configure, like from a PNG or JSON. */
+ override configure(info: ISerialisedNode): void {
+ super.configure(info);
+ // Since we add the widgets dynamically, we need to wait to set their values
+ // with a short timeout.
+ setTimeout(() => {
+ if (info.widgets_values) {
+ for (let [index, value] of info.widgets_values.entries()) {
+ if (index > 0) {
+ if (typeof value === "string" && value.startsWith("comfy_action:")) {
+ value = value.replace("comfy_action:", "");
+ this.addComfyActionWidget(index, value);
+ }
+ if (this.widgets[index]) {
+ this.widgets[index]!.value = value;
+ }
+ }
+ }
+ }
+ }, 100);
+ }
+
+ override clone() {
+ const cloned = super.clone()!;
+ cloned.properties["buttonText"] = "🎬 Action!";
+ cloned.properties["shortcutKey"] = "";
+ return cloned;
+ }
+
+ override onAdded(graph: LGraph): void {
+ window.addEventListener("keydown", this.keypressBound);
+ window.addEventListener("keyup", this.keyupBound);
+ }
+
+ override onRemoved(): void {
+ window.removeEventListener("keydown", this.keypressBound);
+ window.removeEventListener("keyup", this.keyupBound);
+ }
+
+ async onKeypress(event: KeyboardEvent) {
+ const target = (event.target as HTMLElement)!;
+ if (
+ this.executingFromShortcut ||
+ target.localName == "input" ||
+ target.localName == "textarea"
+ ) {
+ return;
+ }
+ if (
+ this.properties["shortcutKey"].trim() &&
+ this.properties["shortcutKey"].toLowerCase() === event.key.toLowerCase()
+ ) {
+ const shortcutModifier = this.properties["shortcutModifier"];
+ let good = shortcutModifier === "ctrl" && event.ctrlKey;
+ good = good || (shortcutModifier === "alt" && event.altKey);
+ good = good || (shortcutModifier === "shift" && event.shiftKey);
+ good = good || (shortcutModifier === "meta" && event.metaKey);
+ if (good) {
+ setTimeout(() => {
+ this.executeConnectedNodes();
+ }, 20);
+ this.executingFromShortcut = true;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ app.canvas.dirty_canvas = true;
+ return false;
+ }
+ }
+ return;
+ }
+
+ onKeyup(event: KeyboardEvent) {
+ const target = (event.target as HTMLElement)!;
+ if (target.localName == "input" || target.localName == "textarea") {
+ return;
+ }
+ this.executingFromShortcut = false;
+ }
+
+ override onPropertyChanged(property: string, value: unknown, prevValue?: unknown) {
+ if (property == "buttonText" && typeof value === "string") {
+ this.buttonWidget.name = value;
+ }
+ if (property == "shortcutKey" && typeof value === "string") {
+ this.properties["shortcutKey"] = value.trim()[0]?.toLowerCase() ?? "";
+ }
+ return true;
+ }
+
+ override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
+ let changed = false;
+ // Remove any widgets and data for widgets that are no longer linked.
+ for (const [widget, data] of this.widgetToData.entries()) {
+ if (!data.node) {
+ continue;
+ }
+ if (!linkedNodes.includes(data.node)) {
+ const index = this.widgets.indexOf(widget);
+ if (index > -1) {
+ this.widgetToData.delete(widget);
+ this.removeWidget(widget);
+ changed = true;
+ } else {
+ const [m, a] = this.logger.debugParts("Connected widget is not in widgets... weird.");
+ console[m]?.(...a);
+ }
+ }
+ }
+
+ const badNodes: LGraphNode[] = []; // Nodes that are deleted elsewhere may not exist in linkedNodes.
+ let indexOffset = 1; // Start with button, increment when we hit a non-node widget (like comfy)
+ for (const [index, node] of linkedNodes.entries()) {
+ // Sometimes linkedNodes is stale.
+ if (!node) {
+ const [m, a] = this.logger.debugParts("linkedNode provided that does not exist. ");
+ console[m]?.(...a);
+ badNodes.push(node);
+ continue;
+ }
+ let widgetAtSlot = this.widgets[index + indexOffset];
+ if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
+ indexOffset++;
+ widgetAtSlot = this.widgets[index + indexOffset];
+ }
+
+ if (!widgetAtSlot || this.widgetToData.get(widgetAtSlot)?.node?.id !== node.id) {
+ // Find the next widget that matches the node.
+ let widget: IWidget | null = null;
+ for (let i = index + indexOffset; i < this.widgets.length; i++) {
+ if (this.widgetToData.get(this.widgets[i]!)?.node?.id === node.id) {
+ widget = this.widgets.splice(i, 1)[0]!;
+ this.widgets.splice(index + indexOffset, 0, widget);
+ changed = true;
+ break;
+ }
+ }
+ if (!widget) {
+ // Add a widget at this spot.
+ const exposedActions: string[] = (node.constructor as any).exposedActions || [];
+ widget = this.addWidget("combo", node.title, "None", "", {
+ values: ["None", "Mute", "Bypass", "Enable", ...exposedActions],
+ }) as IWidget;
+ widget.serializeValue = async (_node: LGraphNode, _index: number) => {
+ return widget?.value;
+ };
+ this.widgetToData.set(widget, {node});
+ changed = true;
+ }
+ }
+ }
+
+ // Go backwards through widgets, and remove any that are not in out widgetToData
+ for (let i = this.widgets.length - 1; i > linkedNodes.length + indexOffset - 1; i--) {
+ const widgetAtSlot = this.widgets[i];
+ if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
+ continue;
+ }
+ this.removeWidget(widgetAtSlot);
+ changed = true;
+ }
+ return changed;
+ }
+
+ override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
+ widget = typeof widget === "number" ? this.widgets[widget] : widget;
+ if (widget && this.widgetToData.has(widget as IWidget)) {
+ this.widgetToData.delete(widget as IWidget);
+ }
+ super.removeWidget(widget);
+ }
+
+ /**
+ * Runs through the widgets, and executes the actions.
+ */
+ async executeConnectedNodes() {
+ for (const widget of this.widgets) {
+ if (widget == this.buttonWidget) {
+ continue;
+ }
+ const action = widget.value;
+ const {comfy, node} = this.widgetToData.get(widget) ?? {};
+ if (comfy) {
+ if (action === "Queue Prompt") {
+ await comfy.queuePrompt(0);
+ }
+ continue;
+ }
+ if (node) {
+ if (action === "Mute") {
+ changeModeOfNodes(node, MODE_MUTE);
+ } else if (action === "Bypass") {
+ changeModeOfNodes(node, MODE_BYPASS);
+ } else if (action === "Enable") {
+ changeModeOfNodes(node, MODE_ALWAYS);
+ }
+ // If there's a handleAction, always call it.
+ if ((node as RgthreeBaseVirtualNode).handleAction) {
+ if (typeof action !== "string") {
+ throw new Error("Fast Actions Button action should be a string: " + action);
+ }
+ await (node as RgthreeBaseVirtualNode).handleAction(action);
+ }
+ this.graph?.change();
+ continue;
+ }
+ console.warn("Fast Actions Button has a widget without correct data.");
+ }
+ }
+
+ /**
+ * Adds a ComfyActionWidget at the provided slot (or end).
+ */
+ addComfyActionWidget(slot?: number, value?: string) {
+ let widget = this.addWidget(
+ "combo",
+ "Comfy Action",
+ "None",
+ () => {
+ if (String(widget.value).startsWith("MOVE ")) {
+ this.widgets.push(this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
+ widget.value = String(widget.rgthree_lastValue);
+ } else if (String(widget.value).startsWith("REMOVE ")) {
+ this.removeWidget(widget);
+ }
+ widget.rgthree_lastValue = widget.value;
+ },
+ {
+ values: ["None", "Queue Prompt", "REMOVE Comfy Action", "MOVE to end"],
+ },
+ ) as IComboWidget;
+ widget.rgthree_lastValue = value;
+
+ widget.serializeValue = async (_node: LGraphNode, _index: number) => {
+ return `comfy_app:${widget?.value}`;
+ };
+ this.widgetToData.set(widget, {comfy: app});
+
+ if (slot != null) {
+ this.widgets.splice(slot, 0, this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
+ }
+ return widget;
+ }
+
+ override onSerialize(serialised: ISerialisedNode) {
+ super.onSerialize?.(serialised);
+ for (let [index, value] of (serialised.widgets_values || []).entries()) {
+ if (this.widgets[index]?.name === "Comfy Action") {
+ serialised.widgets_values![index] = `comfy_action:${value}`;
+ }
+ }
+ }
+
+ static override setUp() {
+ super.setUp();
+ addMenuItem(this, app, {
+ name: "➕ Append a Comfy Action",
+ callback: (nodeArg: LGraphNode) => {
+ (nodeArg as FastActionsButton).addComfyActionWidget();
+ },
+ });
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.FastActionsButton",
+ registerCustomNodes() {
+ FastActionsButton.setUp();
+ },
+ loadedGraphNode(node: LGraphNode) {
+ if (node.type == FastActionsButton.title) {
+ (node as FastActionsButton)._tempWidth = node.size[0];
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_bypasser.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_bypasser.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4e18b07f71c96284356fb454db66e6a1d0a50825
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_bypasser.ts
@@ -0,0 +1,38 @@
+import type {Size} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {NodeTypesString} from "./constants.js";
+import {BaseFastGroupsModeChanger} from "./fast_groups_muter.js";
+
+/**
+ * Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
+ */
+export class FastGroupsBypasser extends BaseFastGroupsModeChanger {
+ static override type = NodeTypesString.FAST_GROUPS_BYPASSER;
+ static override title = NodeTypesString.FAST_GROUPS_BYPASSER;
+ override comfyClass = NodeTypesString.FAST_GROUPS_BYPASSER;
+
+ static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
+
+ protected override helpActions = "bypass and enable";
+
+ override readonly modeOn = LiteGraph.ALWAYS;
+ override readonly modeOff = 4; // Used by Comfy for "bypass"
+
+ constructor(title = FastGroupsBypasser.title) {
+ super(title);
+ this.onConstructed();
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.FastGroupsBypasser",
+ registerCustomNodes() {
+ FastGroupsBypasser.setUp();
+ },
+ loadedGraphNode(node: FastGroupsBypasser) {
+ if (node.type == FastGroupsBypasser.title) {
+ node.tempSize = [...node.size] as Size;
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4eee7f8c9e7d8898072a81655e26a87f75f23ead
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts
@@ -0,0 +1,542 @@
+import type {
+ LGraphNode,
+ LGraph as TLGraph,
+ LGraphCanvas as TLGraphCanvas,
+ Vector2,
+ Size,
+ LGraphGroup,
+ CanvasMouseEvent,
+ Point,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseVirtualNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {SERVICE as FAST_GROUPS_SERVICE} from "./services/fast_groups_service.js";
+import {drawNodeWidget, fitString} from "./utils_canvas.js";
+import {RgthreeBaseWidget} from "./utils_widgets.js";
+import { changeModeOfNodes, getGroupNodes } from "./utils.js";
+
+const PROPERTY_SORT = "sort";
+const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
+const PROPERTY_MATCH_COLORS = "matchColors";
+const PROPERTY_MATCH_TITLE = "matchTitle";
+const PROPERTY_SHOW_NAV = "showNav";
+const PROPERTY_SHOW_ALL_GRAPHS = "showAllGraphs";
+const PROPERTY_RESTRICTION = "toggleRestriction";
+
+/**
+ * Fast Muter implementation that looks for groups in the workflow and adds toggles to mute them.
+ */
+export abstract class BaseFastGroupsModeChanger extends RgthreeBaseVirtualNode {
+ static override type = NodeTypesString.FAST_GROUPS_MUTER;
+ static override title = NodeTypesString.FAST_GROUPS_MUTER;
+
+ static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
+
+ readonly modeOn: number = LiteGraph.ALWAYS;
+ readonly modeOff: number = LiteGraph.NEVER;
+
+ private debouncerTempWidth: number = 0;
+ tempSize: Vector2 | null = null;
+
+ // We don't need to serizalize since we'll just be checking group data on startup anyway
+ override serialize_widgets = false;
+
+ protected helpActions = "mute and unmute";
+
+ static "@matchColors" = {type: "string"};
+ static "@matchTitle" = {type: "string"};
+ static "@showNav" = {type: "boolean"};
+ static "@showAllGraphs" = {type: "boolean"};
+ static "@sort" = {
+ type: "combo",
+ values: ["position", "alphanumeric", "custom alphabet"],
+ };
+ static "@customSortAlphabet" = {type: "string"};
+
+ override properties!: RgthreeBaseVirtualNode["properties"] & {
+ [PROPERTY_MATCH_COLORS]: string;
+ [PROPERTY_MATCH_TITLE]: string;
+ [PROPERTY_SHOW_NAV]: boolean;
+ [PROPERTY_SHOW_ALL_GRAPHS]: boolean;
+ [PROPERTY_SORT]: string;
+ [PROPERTY_SORT_CUSTOM_ALPHA]: string;
+ [PROPERTY_RESTRICTION]: string;
+ };
+
+ static "@toggleRestriction" = {
+ type: "combo",
+ values: ["default", "max one", "always one"],
+ };
+
+ constructor(title = FastGroupsMuter.title) {
+ super(title);
+ this.properties[PROPERTY_MATCH_COLORS] = "";
+ this.properties[PROPERTY_MATCH_TITLE] = "";
+ this.properties[PROPERTY_SHOW_NAV] = true;
+ this.properties[PROPERTY_SHOW_ALL_GRAPHS] = true;
+ this.properties[PROPERTY_SORT] = "position";
+ this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
+ this.properties[PROPERTY_RESTRICTION] = "default";
+ }
+
+ override onConstructed(): boolean {
+ this.addOutput("OPT_CONNECTION", "*");
+ return super.onConstructed();
+ }
+
+ override onAdded(graph: TLGraph): void {
+ FAST_GROUPS_SERVICE.addFastGroupNode(this);
+ }
+
+ override onRemoved(): void {
+ FAST_GROUPS_SERVICE.removeFastGroupNode(this);
+ }
+
+ refreshWidgets() {
+ const canvas = app.canvas as TLGraphCanvas;
+ let sort = this.properties?.[PROPERTY_SORT] || "position";
+ let customAlphabet: string[] | null = null;
+ if (sort === "custom alphabet") {
+ const customAlphaStr = this.properties?.[PROPERTY_SORT_CUSTOM_ALPHA]?.replace(/\n/g, "");
+ if (customAlphaStr && customAlphaStr.trim()) {
+ customAlphabet = customAlphaStr.includes(",")
+ ? customAlphaStr.toLocaleLowerCase().split(",")
+ : customAlphaStr.toLocaleLowerCase().trim().split("");
+ }
+ if (!customAlphabet?.length) {
+ sort = "alphanumeric";
+ customAlphabet = null;
+ }
+ }
+
+ const groups = [...FAST_GROUPS_SERVICE.getGroups(sort)];
+ // The service will return pre-sorted groups for alphanumeric and position. If this node has a
+ // custom sort, then we need to sort it manually.
+ if (customAlphabet?.length) {
+ groups.sort((a, b) => {
+ let aIndex = -1;
+ let bIndex = -1;
+ // Loop and find indexes. As we're finding multiple, a single for loop is more efficient.
+ for (const [index, alpha] of customAlphabet!.entries()) {
+ aIndex =
+ aIndex < 0 ? (a.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : aIndex;
+ bIndex =
+ bIndex < 0 ? (b.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : bIndex;
+ if (aIndex > -1 && bIndex > -1) {
+ break;
+ }
+ }
+ // Now compare.
+ if (aIndex > -1 && bIndex > -1) {
+ const ret = aIndex - bIndex;
+ if (ret === 0) {
+ return a.title.localeCompare(b.title);
+ }
+ return ret;
+ } else if (aIndex > -1) {
+ return -1;
+ } else if (bIndex > -1) {
+ return 1;
+ }
+ return a.title.localeCompare(b.title);
+ });
+ }
+
+ // See if we're filtering by colors, and match against the built-in keywords and actuial hex
+ // values.
+ let filterColors = (
+ (this.properties?.[PROPERTY_MATCH_COLORS] as string)?.split(",") || []
+ ).filter((c) => c.trim());
+ if (filterColors.length) {
+ filterColors = filterColors.map((color) => {
+ color = color.trim().toLocaleLowerCase();
+ if (LGraphCanvas.node_colors[color]) {
+ color = LGraphCanvas.node_colors[color]!.groupcolor;
+ }
+ color = color.replace("#", "").toLocaleLowerCase();
+ if (color.length === 3) {
+ color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
+ }
+ return `#${color}`;
+ });
+ }
+
+ // Go over the groups
+ let index = 0;
+ for (const group of groups) {
+ if (filterColors.length) {
+ let groupColor = group.color?.replace("#", "").trim().toLocaleLowerCase();
+ if (!groupColor) {
+ continue;
+ }
+ if (groupColor.length === 3) {
+ groupColor = groupColor.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
+ }
+ groupColor = `#${groupColor}`;
+ if (!filterColors.includes(groupColor)) {
+ continue;
+ }
+ }
+ if (this.properties?.[PROPERTY_MATCH_TITLE]?.trim()) {
+ try {
+ if (!new RegExp(this.properties[PROPERTY_MATCH_TITLE], "i").exec(group.title)) {
+ continue;
+ }
+ } catch (e) {
+ console.error(e);
+ continue;
+ }
+ }
+ const showAllGraphs = this.properties?.[PROPERTY_SHOW_ALL_GRAPHS];
+ if (!showAllGraphs && group.graph !== app.canvas.getCurrentGraph()) {
+ continue;
+ }
+ let isDirty = false;
+ const widgetLabel = `Enable ${group.title}`;
+ let widget = this.widgets.find((w) => w.label === widgetLabel) as FastGroupsToggleRowWidget;
+ if (!widget) {
+ // When we add a widget, litegraph is going to mess up the size, so we
+ // store it so we can retrieve it in computeSize. Hacky..
+ this.tempSize = [...this.size] as Size;
+ widget = this.addCustomWidget(
+ new FastGroupsToggleRowWidget(group, this),
+ ) as FastGroupsToggleRowWidget;
+ this.setSize(this.computeSize());
+ isDirty = true;
+ }
+ if (widget.label != widgetLabel) {
+ widget.label = widgetLabel;
+ isDirty = true;
+ }
+ if (
+ group.rgthree_hasAnyActiveNode != null &&
+ widget.toggled != group.rgthree_hasAnyActiveNode
+ ) {
+ widget.toggled = group.rgthree_hasAnyActiveNode;
+ isDirty = true;
+ }
+ if (this.widgets[index] !== widget) {
+ const oldIndex = this.widgets.findIndex((w) => w === widget);
+ this.widgets.splice(index, 0, this.widgets.splice(oldIndex, 1)[0]!);
+ isDirty = true;
+ }
+ if (isDirty) {
+ this.setDirtyCanvas(true, false);
+ }
+ index++;
+ }
+
+ // Everything should now be in order, so let's remove all remaining widgets.
+ while ((this.widgets || [])[index]) {
+ this.removeWidget(index++);
+ }
+ }
+
+ override computeSize(out?: Vector2) {
+ let size = super.computeSize(out);
+ if (this.tempSize) {
+ size[0] = Math.max(this.tempSize[0], size[0]);
+ size[1] = Math.max(this.tempSize[1], size[1]);
+ // We sometimes get repeated calls to compute size, so debounce before clearing.
+ this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
+ this.debouncerTempWidth = setTimeout(() => {
+ this.tempSize = null;
+ }, 32);
+ }
+ setTimeout(() => {
+ this.graph?.setDirtyCanvas(true, true);
+ }, 16);
+ return size;
+ }
+
+ override async handleAction(action: string) {
+ if (action === "Mute all" || action === "Bypass all") {
+ const alwaysOne = this.properties?.[PROPERTY_RESTRICTION] === "always one";
+ for (const [index, widget] of this.widgets.entries()) {
+ (widget as any)?.doModeChange(alwaysOne && !index ? true : false, true);
+ }
+ } else if (action === "Enable all") {
+ const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
+ for (const [index, widget] of this.widgets.entries()) {
+ (widget as any)?.doModeChange(onlyOne && index > 0 ? false : true, true);
+ }
+ } else if (action === "Toggle all") {
+ const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
+ let foundOne = false;
+ for (const [index, widget] of this.widgets.entries()) {
+ // If you have only one, then we'll stop at the first.
+ let newValue: boolean = onlyOne && foundOne ? false : !widget.value;
+ foundOne = foundOne || newValue;
+ (widget as any)?.doModeChange(newValue, true);
+ }
+ // And if you have always one, then we'll flip the last
+ if (!foundOne && this.properties?.[PROPERTY_RESTRICTION] === "always one") {
+ (this.widgets[this.widgets.length - 1] as any)?.doModeChange(true, true);
+ }
+ }
+ }
+
+ override getHelp() {
+ return `
+ The ${this.type!.replace(
+ "(rgthree)",
+ "",
+ )} is an input-less node that automatically collects all groups in your current
+ workflow and allows you to quickly ${this.helpActions} all nodes within the group.
+ `;
+ }
+}
+
+/**
+ * Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
+ */
+export class FastGroupsMuter extends BaseFastGroupsModeChanger {
+ static override type = NodeTypesString.FAST_GROUPS_MUTER;
+ static override title = NodeTypesString.FAST_GROUPS_MUTER;
+ override comfyClass = NodeTypesString.FAST_GROUPS_MUTER;
+
+ static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
+
+ protected override helpActions = "mute and unmute";
+
+ override readonly modeOn: number = LiteGraph.ALWAYS;
+ override readonly modeOff: number = LiteGraph.NEVER;
+
+ constructor(title = FastGroupsMuter.title) {
+ super(title);
+ this.onConstructed();
+ }
+}
+
+/**
+ * The PowerLoraLoaderHeaderWidget that renders a toggle all switch, as well as some title info
+ * (more necessary for the double model & clip strengths to label them).
+ */
+class FastGroupsToggleRowWidget extends RgthreeBaseWidget<{toggled: boolean}> {
+ override value = {toggled: false};
+ override options = {on: "yes", off: "no"};
+ override readonly type = "custom";
+
+ label: string = "";
+ group: LGraphGroup;
+ node: BaseFastGroupsModeChanger;
+
+ constructor(group: LGraphGroup, node: BaseFastGroupsModeChanger) {
+ super("RGTHREE_TOGGLE_AND_NAV");
+ this.group = group;
+ this.node = node;
+ }
+
+ doModeChange(force?: boolean, skipOtherNodeCheck?: boolean) {
+ this.group.recomputeInsideNodes();
+ const hasAnyActiveNodes = getGroupNodes(this.group).some((n) => n.mode === LiteGraph.ALWAYS);
+ let newValue = force != null ? force : !hasAnyActiveNodes;
+ if (skipOtherNodeCheck !== true) {
+ // TODO: This work should probably live in BaseFastGroupsModeChanger instead of the widgets.
+ if (newValue && this.node.properties?.[PROPERTY_RESTRICTION]?.includes(" one")) {
+ for (const widget of this.node.widgets) {
+ if (widget instanceof FastGroupsToggleRowWidget) {
+ widget.doModeChange(false, true);
+ }
+ }
+ } else if (!newValue && this.node.properties?.[PROPERTY_RESTRICTION] === "always one") {
+ newValue = this.node.widgets.every((w) => !w.value || w === this);
+ }
+ }
+ changeModeOfNodes(getGroupNodes(this.group), (newValue ? this.node.modeOn : this.node.modeOff));
+ this.group.rgthree_hasAnyActiveNode = newValue;
+ this.toggled = newValue;
+ this.group.graph?.setDirtyCanvas(true, false);
+ }
+
+ get toggled() {
+ return this.value.toggled;
+ }
+ set toggled(value: boolean) {
+ this.value.toggled = value;
+ }
+
+ toggle(value?: boolean) {
+ value = value == null ? !this.toggled : value;
+ if (value !== this.toggled) {
+ this.value.toggled = value;
+ this.doModeChange();
+ }
+ }
+
+ draw(
+ ctx: CanvasRenderingContext2D,
+ node: FastGroupsMuter,
+ width: number,
+ posY: number,
+ height: number,
+ ) {
+ const widgetData = drawNodeWidget(ctx, {size: [width, height], pos: [15, posY]});
+
+ const showNav = node.properties?.[PROPERTY_SHOW_NAV] !== false;
+
+ // Render from right to left, since the text on left will take available space.
+ // `currentX` markes the current x position moving backwards.
+ let currentX = widgetData.width - widgetData.margin;
+
+ // The nav arrow
+ if (!widgetData.lowQuality && showNav) {
+ currentX -= 7; // Arrow space margin
+ const midY = widgetData.posY + widgetData.height * 0.5;
+ ctx.fillStyle = ctx.strokeStyle = "#89A";
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ const arrow = new Path2D(`M${currentX} ${midY} l -7 6 v -3 h -7 v -6 h 7 v -3 z`);
+ ctx.fill(arrow);
+ ctx.stroke(arrow);
+ currentX -= 14;
+
+ currentX -= 7;
+ ctx.strokeStyle = widgetData.colorOutline;
+ ctx.stroke(new Path2D(`M ${currentX} ${widgetData.posY} v ${widgetData.height}`));
+ } else if (widgetData.lowQuality && showNav) {
+ currentX -= 28;
+ }
+
+ // The toggle itself.
+ currentX -= 7;
+ ctx.fillStyle = this.toggled ? "#89A" : "#333";
+ ctx.beginPath();
+ const toggleRadius = height * 0.36;
+ ctx.arc(currentX - toggleRadius, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
+ ctx.fill();
+ currentX -= toggleRadius * 2;
+
+ if (!widgetData.lowQuality) {
+ currentX -= 4;
+ ctx.textAlign = "right";
+ ctx.fillStyle = this.toggled ? widgetData.colorText : widgetData.colorTextSecondary;
+ const label = this.label;
+ const toggleLabelOn = this.options.on || "true";
+ const toggleLabelOff = this.options.off || "false";
+ ctx.fillText(this.toggled ? toggleLabelOn : toggleLabelOff, currentX, posY + height * 0.7);
+ currentX -= Math.max(
+ ctx.measureText(toggleLabelOn).width,
+ ctx.measureText(toggleLabelOff).width,
+ );
+
+ currentX -= 7;
+ ctx.textAlign = "left";
+ let maxLabelWidth = widgetData.width - widgetData.margin - 10 - (widgetData.width - currentX);
+ if (label != null) {
+ ctx.fillText(
+ fitString(ctx, label, maxLabelWidth),
+ widgetData.margin + 10,
+ posY + height * 0.7,
+ );
+ }
+ }
+ }
+
+ override serializeValue(node: LGraphNode, index: number) {
+ return this.value;
+ }
+
+ override mouse(event: CanvasMouseEvent, pos: Vector2, node: LGraphNode): boolean {
+ if (event.type == "pointerdown") {
+ if (node.properties?.[PROPERTY_SHOW_NAV] !== false && pos[0] >= node.size[0] - 15 - 28 - 1) {
+ const canvas = app.canvas as TLGraphCanvas;
+ const lowQuality = (canvas.ds?.scale || 1) <= 0.5;
+ if (!lowQuality) {
+ // Clicked on right half with nav arrow, go to the group, center on group and set
+ // zoom to see it all.
+ canvas.centerOnNode(this.group);
+ const zoomCurrent = canvas.ds?.scale || 1;
+ const zoomX = canvas.canvas.width / this.group._size[0] - 0.02;
+ const zoomY = canvas.canvas.height / this.group._size[1] - 0.02;
+ canvas.setZoom(Math.min(zoomCurrent, zoomX, zoomY), [
+ canvas.canvas.width / 2,
+ canvas.canvas.height / 2,
+ ]);
+ canvas.setDirty(true, true);
+ }
+ } else {
+ this.toggle();
+ }
+ }
+ return true;
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.FastGroupsMuter",
+ registerCustomNodes() {
+ FastGroupsMuter.setUp();
+ },
+ loadedGraphNode(node: LGraphNode) {
+ if (node.type == FastGroupsMuter.title) {
+ (node as FastGroupsMuter).tempSize = [...node.size] as Point;
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/feature_group_fast_toggle.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/feature_group_fast_toggle.ts
new file mode 100644
index 0000000000000000000000000000000000000000..260d7cba41f5b1f30c2f6f83e294dd2b410e9948
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/feature_group_fast_toggle.ts
@@ -0,0 +1,305 @@
+import type {
+ LGraphCanvas as TLGraphCanvas,
+ LGraphGroup as TLGraphGroup,
+ LGraph as TLGraph,
+ Vector2,
+ CanvasMouseEvent,
+} from "@comfyorg/frontend";
+import type {AdjustedMouseCustomEvent} from "typings/rgthree.js";
+
+import {app} from "scripts/app.js";
+import {rgthree} from "./rgthree.js";
+import {changeModeOfNodes, getGroupNodes, getOutputNodes} from "./utils.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+
+const BTN_SIZE = 20;
+const BTN_MARGIN: Vector2 = [6, 6];
+const BTN_SPACING = 8;
+const BTN_GRID = BTN_SIZE / 8;
+
+const TOGGLE_TO_MODE = new Map([
+ ["MUTE", LiteGraph.NEVER],
+ ["BYPASS", 4],
+]);
+
+function getToggles() {
+ return [...CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.toggles", [])].reverse();
+}
+
+/**
+ * Determines if the user clicked on an fast header icon.
+ */
+function clickedOnToggleButton(e: CanvasMouseEvent, group: TLGraphGroup): string | null {
+ const toggles = getToggles();
+ const pos = group.pos;
+ const size = group.size;
+ for (let i = 0; i < toggles.length; i++) {
+ const toggle = toggles[i];
+ if (
+ LiteGraph.isInsideRectangle(
+ e.canvasX,
+ e.canvasY,
+ pos[0] + size[0] - (BTN_SIZE + BTN_MARGIN[0]) * (i + 1),
+ pos[1] + BTN_MARGIN[1],
+ BTN_SIZE,
+ BTN_SIZE,
+ )
+ ) {
+ return toggle;
+ }
+ }
+ return null;
+}
+
+/**
+ * Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
+ * quick, single-click ability to mute/bypass.
+ */
+app.registerExtension({
+ name: "rgthree.GroupHeaderToggles",
+ async setup() {
+ /**
+ * LiteGraph won't call `drawGroups` unless the canvas is dirty. Other nodes will do this, but
+ * in small workflows, we'll want to trigger it dirty so we can be drawn if we're in hover mode.
+ */
+ setInterval(() => {
+ if (
+ CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") &&
+ CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always"
+ ) {
+ app.canvas.setDirty(true, true);
+ }
+ }, 250);
+
+ /**
+ * Handles a click on the icon area if the user has the extension enable from settings.
+ * Hooks into the already overriden mouse down processor from rgthree.
+ */
+ rgthree.addEventListener("on-process-mouse-down", ((e: AdjustedMouseCustomEvent) => {
+ if (!CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled")) return;
+
+ const canvas = app.canvas as TLGraphCanvas;
+ if (canvas.selected_group) {
+ const originalEvent = e.detail.originalEvent;
+ const group = canvas.selected_group;
+ const clickedOnToggle = clickedOnToggleButton(originalEvent, group) || "";
+ const toggleAction = clickedOnToggle?.toLocaleUpperCase();
+ if (toggleAction) {
+ console.log(toggleAction);
+ const nodes = getGroupNodes(group);
+ if (toggleAction === "QUEUE") {
+ const outputNodes = getOutputNodes(nodes);
+ if (!outputNodes?.length) {
+ rgthree.showMessage({
+ id: "no-output-in-group",
+ type: "warn",
+ timeout: 4000,
+ message: "No output nodes for group!",
+ });
+ } else {
+ rgthree.queueOutputNodes(outputNodes);
+ }
+ } else {
+ const toggleMode = TOGGLE_TO_MODE.get(toggleAction);
+ if (toggleMode) {
+ group.recomputeInsideNodes();
+ const hasAnyActiveNodes = nodes.some((n) => n.mode === LiteGraph.ALWAYS);
+ const isAllMuted =
+ !hasAnyActiveNodes && nodes.every((n) => n.mode === LiteGraph.NEVER);
+ const isAllBypassed =
+ !hasAnyActiveNodes && !isAllMuted && nodes.every((n) => n.mode === 4);
+
+ let newMode: 0 | 1 | 2 | 3 | 4 = LiteGraph.ALWAYS;
+ if (toggleMode === LiteGraph.NEVER) {
+ newMode = isAllMuted ? LiteGraph.ALWAYS : LiteGraph.NEVER;
+ } else {
+ newMode = isAllBypassed ? LiteGraph.ALWAYS : 4;
+ }
+ changeModeOfNodes(nodes, newMode);
+ }
+ }
+ // Make it such that we're not then moving the group on drag.
+ canvas.selected_group = null;
+ canvas.dragging_canvas = false;
+ }
+ }
+ }) as EventListener);
+
+ /**
+ * Overrides LiteGraph's Canvas method for drawingGroups and, after calling the original, checks
+ * that the user has enabled fast toggles and draws them on the top-right of the app..
+ */
+ const drawGroups = LGraphCanvas.prototype.drawGroups;
+ LGraphCanvas.prototype.drawGroups = function (
+ canvasEl: HTMLCanvasElement,
+ ctx: CanvasRenderingContext2D,
+ ) {
+ drawGroups.apply(this, [...arguments] as any);
+
+ if (
+ !CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") ||
+ !rgthree.lastCanvasMouseEvent
+ ) {
+ return;
+ }
+
+ const graph = app.canvas.graph as TLGraph;
+
+ let groups: TLGraphGroup[];
+ // Default to hover if not always.
+ if (CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always") {
+ const hoverGroup = graph.getGroupOnPos(
+ rgthree.lastCanvasMouseEvent.canvasX,
+ rgthree.lastCanvasMouseEvent.canvasY,
+ );
+ groups = hoverGroup ? [hoverGroup] : [];
+ } else {
+ groups = graph._groups || [];
+ }
+
+ if (!groups.length) {
+ return;
+ }
+
+ const toggles = getToggles();
+
+ ctx.save();
+ for (const group of groups || []) {
+ const nodes = getGroupNodes(group);
+ let anyActive = false;
+ let allMuted = !!nodes.length;
+ let allBypassed = allMuted;
+
+ // Find the current state of the group's nodes.
+ for (const node of nodes) {
+ if (!(node instanceof LGraphNode)) continue;
+ anyActive = anyActive || node.mode === LiteGraph.ALWAYS;
+ allMuted = allMuted && node.mode === LiteGraph.NEVER;
+ allBypassed = allBypassed && node.mode === 4;
+ if (anyActive || (!allMuted && !allBypassed)) {
+ break;
+ }
+ }
+
+ // Display each toggle.
+ for (let i = 0; i < toggles.length; i++) {
+ const toggle = toggles[i];
+ const pos = group._pos;
+ const size = group._size;
+ ctx.fillStyle = ctx.strokeStyle = group.color || "#335";
+ const x = pos[0] + size[0] - BTN_MARGIN[0] - BTN_SIZE - (BTN_SPACING + BTN_SIZE) * i;
+ const y = pos[1] + BTN_MARGIN[1];
+ const midX = x + BTN_SIZE / 2;
+ const midY = y + BTN_SIZE / 2;
+ if (toggle === "queue") {
+ const outputNodes = getOutputNodes(nodes);
+ const oldGlobalAlpha = ctx.globalAlpha;
+ if (!outputNodes?.length) {
+ ctx.globalAlpha = 0.5;
+ }
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ const arrowSizeX = BTN_SIZE * 0.6;
+ const arrowSizeY = BTN_SIZE * 0.7;
+ const arrow = new Path2D(
+ `M ${x + arrowSizeX / 2} ${midY} l 0 -${arrowSizeY / 2} l ${arrowSizeX} ${arrowSizeY / 2} l -${arrowSizeX} ${arrowSizeY / 2} z`,
+ );
+ ctx.stroke(arrow);
+ if (outputNodes?.length) {
+ ctx.fill(arrow);
+ }
+ ctx.globalAlpha = oldGlobalAlpha;
+ } else {
+ const on = toggle === "bypass" ? allBypassed : allMuted;
+
+ ctx.beginPath();
+ ctx.lineJoin = "round";
+ ctx.rect(x, y, BTN_SIZE, BTN_SIZE);
+
+ ctx.lineWidth = 2;
+ if (toggle === "mute") {
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+
+ if (on) {
+ ctx.stroke(
+ new Path2D(`
+ ${eyeFrame(midX, midY)}
+ ${eyeLashes(midX, midY)}
+ `),
+ );
+ } else {
+ const radius = BTN_GRID * 1.5;
+
+ // Eyeball fill
+ ctx.fill(
+ new Path2D(`
+ ${eyeFrame(midX, midY)}
+ ${eyeFrame(midX, midY, -1)}
+ ${circlePath(midX, midY, radius)}
+ ${circlePath(midX + BTN_GRID / 2, midY - BTN_GRID / 2, BTN_GRID * 0.375)}
+ `),
+ "evenodd",
+ );
+
+ // Eye Outline Stroke
+ ctx.stroke(new Path2D(`${eyeFrame(midX, midY)} ${eyeFrame(midX, midY, -1)}`));
+
+ // Eye lashes (faded)
+ ctx.globalAlpha = this.editor_alpha * 0.5;
+ ctx.stroke(new Path2D(`${eyeLashes(midX, midY)} ${eyeLashes(midX, midY, -1)}`));
+ ctx.globalAlpha = this.editor_alpha;
+ }
+ } else {
+ const lineChanges = on
+ ? `a ${BTN_GRID * 3}, ${BTN_GRID * 3} 0 1, 1 ${BTN_GRID * 3 * 2},0
+ l ${BTN_GRID * 2.0} 0`
+ : `l ${BTN_GRID * 8} 0`;
+
+ ctx.stroke(
+ new Path2D(`
+ M ${x} ${midY}
+ ${lineChanges}
+ M ${x + BTN_SIZE} ${midY} l -2 2
+ M ${x + BTN_SIZE} ${midY} l -2 -2
+ `),
+ );
+ ctx.fill(new Path2D(`${circlePath(x + BTN_GRID * 3, midY, BTN_GRID * 1.8)}`));
+ }
+ }
+ }
+ }
+ ctx.restore();
+ };
+ },
+});
+
+function eyeFrame(midX: number, midY: number, yFlip = 1) {
+ return `
+ M ${midX - BTN_SIZE / 2} ${midY}
+ c ${BTN_GRID * 1.5} ${yFlip * BTN_GRID * 2.5}, ${BTN_GRID * (8 - 1.5)} ${
+ yFlip * BTN_GRID * 2.5
+ }, ${BTN_GRID * 8} 0
+ `;
+}
+
+function eyeLashes(midX: number, midY: number, yFlip = 1) {
+ return `
+ M ${midX - BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l -1.15 ${1.25 * yFlip}
+ M ${midX - BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l -0.90 ${1.5 * yFlip}
+ M ${midX - BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l -0.50 ${1.75 * yFlip}
+ M ${midX + BTN_GRID * 0.0} ${midY + yFlip * BTN_GRID * 2.0} l 0.00 ${2.0 * yFlip}
+ M ${midX + BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l 0.50 ${1.75 * yFlip}
+ M ${midX + BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l 0.90 ${1.5 * yFlip}
+ M ${midX + BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l 1.15 ${1.25 * yFlip}
+`;
+}
+
+function circlePath(cx: number, cy: number, radius: number) {
+ return `
+ M ${cx} ${cy}
+ m ${radius}, 0
+ a ${radius},${radius} 0 1, 1 -${radius * 2},0
+ a ${radius},${radius} 0 1, 1 ${radius * 2},0
+ `;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/feature_import_individual_nodes.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/feature_import_individual_nodes.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f01aa87d402c8d3c9111db2e7823af20050ecf76
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/feature_import_individual_nodes.ts
@@ -0,0 +1,85 @@
+import type {INodeSlot, LGraphNode, LGraphNodeConstructor} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {tryToGetWorkflowDataFromEvent} from "rgthree/common/utils_workflow.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+import {NodeTypesString} from "./constants.js";
+
+/**
+ * Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
+ * quick, single-click ability to mute/bypass.
+ */
+app.registerExtension({
+ name: "rgthree.ImportIndividualNodes",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ const onDragOver = nodeType.prototype.onDragOver;
+ nodeType.prototype.onDragOver = function (e: DragEvent) {
+ let handled = onDragOver?.apply?.(this, [...arguments] as any);
+ if (handled != null) {
+ return handled;
+ }
+ return importIndividualNodesInnerOnDragOver(this, e);
+ };
+
+ const onDragDrop = nodeType.prototype.onDragDrop;
+ nodeType.prototype.onDragDrop = async function (e: DragEvent) {
+ const alreadyHandled = await onDragDrop?.apply?.(this, [...arguments] as any);
+ if (alreadyHandled) {
+ return alreadyHandled;
+ }
+ return importIndividualNodesInnerOnDragDrop(this, e);
+ };
+ },
+});
+
+export function importIndividualNodesInnerOnDragOver(node: LGraphNode, e: DragEvent): boolean {
+ return (
+ (node.widgets?.length && !!CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) ||
+ false
+ );
+}
+
+export async function importIndividualNodesInnerOnDragDrop(node: LGraphNode, e: DragEvent) {
+ if (!node.widgets?.length || !CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) {
+ return false;
+ }
+
+ const dynamicWidgetLengthNodes = [NodeTypesString.POWER_LORA_LOADER];
+
+ let handled = false;
+ const {workflow, prompt} = await tryToGetWorkflowDataFromEvent(e);
+ const exact = (workflow?.nodes || []).find(
+ (n: any) =>
+ n.id === node.id &&
+ n.type === node.type &&
+ (dynamicWidgetLengthNodes.includes(node.type) ||
+ n.widgets_values?.length === node.widgets_values?.length),
+ );
+ if (!exact) {
+ // If we tried, but didn't find an exact match, then allow user to stop the default behavior.
+ handled = !confirm(
+ "[rgthree-comfy] Could not find a matching node (same id & type) in the dropped workflow." +
+ " Would you like to continue with the default drop behaviour instead?",
+ );
+ } else if (!exact.widgets_values?.length) {
+ handled = !confirm(
+ "[rgthree-comfy] Matching node found (same id & type) but there's no widgets to set." +
+ " Would you like to continue with the default drop behaviour instead?",
+ );
+ } else if (
+ confirm(
+ "[rgthree-comfy] Found a matching node (same id & type) in the dropped workflow." +
+ " Would you like to set the widget values?",
+ )
+ ) {
+ node.configure({
+ // Title is overridden if it's not supplied; set it to the current then.
+ title: node.title,
+ widgets_values: [...(exact?.widgets_values || [])],
+ mode: exact.mode,
+ } as any);
+ handled = true;
+ }
+ return handled;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3c648f8f8a36040cd6122ebc510dcc90bfb865fe
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts
@@ -0,0 +1,480 @@
+import {
+ LGraphCanvas,
+ LGraphNode,
+ Vector2,
+ LGraphNodeConstructor,
+ CanvasMouseEvent,
+ ISerialisedNode,
+ Point,
+ CanvasPointerEvent,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {api} from "scripts/api.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {addConnectionLayoutSupport} from "./utils.js";
+import {RgthreeBaseHitAreas, RgthreeBaseWidget, RgthreeBaseWidgetBounds} from "./utils_widgets.js";
+import {measureText} from "./utils_canvas.js";
+
+type ComfyImageServerData = {filename: string; type: string; subfolder: string};
+type ComfyImageData = {name: string; selected: boolean; url: string; img?: HTMLImageElement};
+type OldExecutedPayload = {
+ images: ComfyImageServerData[];
+};
+type ExecutedPayload = {
+ a_images?: ComfyImageServerData[];
+ b_images?: ComfyImageServerData[];
+};
+
+function imageDataToUrl(data: ComfyImageServerData) {
+ return api.apiURL(
+ `/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}&subfolder=${
+ data.subfolder
+ }${app.getPreviewFormatParam()}${app.getRandParam()}`,
+ );
+}
+
+/**
+ * Compares two images in one canvas node.
+ */
+export class RgthreeImageComparer extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.IMAGE_COMPARER;
+ static override type = NodeTypesString.IMAGE_COMPARER;
+ static comfyClass = NodeTypesString.IMAGE_COMPARER;
+
+ // These is what the core preview image node uses to show the context menu. May not be that helpful
+ // since it likely will always be "0" when a context menu is invoked without manually changing
+ // something.
+ override imageIndex: number = 0;
+ override imgs: InstanceType[] = [];
+
+ override serialize_widgets = true;
+
+ isPointerDown = false;
+ isPointerOver = false;
+ pointerOverPos: Vector2 = [0, 0];
+
+ private canvasWidget: RgthreeImageComparerWidget | null = null;
+
+ static "@comparer_mode" = {
+ type: "combo",
+ values: ["Slide", "Click"],
+ };
+
+ constructor(title = RgthreeImageComparer.title) {
+ super(title);
+ this.properties["comparer_mode"] = "Slide";
+ }
+
+ override onExecuted(output: ExecutedPayload | OldExecutedPayload) {
+ super.onExecuted?.(output);
+ if ("images" in output) {
+ this.canvasWidget!.value = {
+ images: (output.images || []).map((d, i) => {
+ return {
+ name: i === 0 ? "A" : "B",
+ selected: true,
+ url: imageDataToUrl(d),
+ };
+ }),
+ };
+ } else {
+ output.a_images = output.a_images || [];
+ output.b_images = output.b_images || [];
+ const imagesToChoose: ComfyImageData[] = [];
+ const multiple = output.a_images.length + output.b_images.length > 2;
+ for (const [i, d] of output.a_images.entries()) {
+ imagesToChoose.push({
+ name: output.a_images.length > 1 || multiple ? `A${i + 1}` : "A",
+ selected: i === 0,
+ url: imageDataToUrl(d),
+ });
+ }
+ for (const [i, d] of output.b_images.entries()) {
+ imagesToChoose.push({
+ name: output.b_images.length > 1 || multiple ? `B${i + 1}` : "B",
+ selected: i === 0,
+ url: imageDataToUrl(d),
+ });
+ }
+ this.canvasWidget!.value = {images: imagesToChoose};
+ }
+ }
+
+ override onSerialize(serialised: ISerialisedNode) {
+ super.onSerialize && super.onSerialize(serialised);
+ for (let [index, widget_value] of (serialised.widgets_values || []).entries()) {
+ if (this.widgets[index]?.name === "rgthree_comparer") {
+ serialised.widgets_values![index] = (
+ this.widgets[index] as unknown as RgthreeImageComparerWidget
+ ).value.images.map((d) => {
+ d = {...d};
+ delete d.img;
+ return d;
+ });
+ }
+ }
+ }
+
+ override onNodeCreated() {
+ this.canvasWidget = this.addCustomWidget(
+ new RgthreeImageComparerWidget("rgthree_comparer", this),
+ ) as RgthreeImageComparerWidget;
+ this.setSize(this.computeSize());
+ this.setDirtyCanvas(true, true);
+ }
+
+ /**
+ * Sets mouse as down or up based on param. If it's down, we also loop to check pointer is still
+ * down. This is because LiteGraph doesn't fire `onMouseUp` every time there's a mouse up, so we
+ * need to manually monitor `pointer_is_down` and, when it's no longer true, set mouse as up here.
+ */
+ private setIsPointerDown(down: boolean = this.isPointerDown) {
+ const newIsDown = down && !!app.canvas.pointer_is_down;
+ if (this.isPointerDown !== newIsDown) {
+ this.isPointerDown = newIsDown;
+ this.setDirtyCanvas(true, false);
+ }
+ this.imageIndex = this.isPointerDown ? 1 : 0;
+ if (this.isPointerDown) {
+ requestAnimationFrame(() => {
+ this.setIsPointerDown();
+ });
+ }
+ }
+
+ override onMouseDown(event: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): boolean {
+ super.onMouseDown?.(event, pos, canvas);
+ this.setIsPointerDown(true);
+ return false;
+ }
+
+ override onMouseEnter(event: CanvasPointerEvent): void {
+ super.onMouseEnter?.(event);
+ this.setIsPointerDown(!!app.canvas.pointer_is_down);
+ this.isPointerOver = true;
+ }
+
+ override onMouseLeave(event: CanvasPointerEvent): void {
+ super.onMouseLeave?.(event);
+ this.setIsPointerDown(false);
+ this.isPointerOver = false;
+ }
+
+ override onMouseMove(event: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): void {
+ super.onMouseMove?.(event, pos, canvas);
+ this.pointerOverPos = [...pos] as Point;
+ this.imageIndex = this.pointerOverPos[0] > this.size[0] / 2 ? 1 : 0;
+ }
+
+ override getHelp(): string {
+ return `
+
+ The ${this.type!.replace("(rgthree)", "")} node compares two images on top of each other.
+
+ `;
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeImageComparer);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any) {
+ addConnectionLayoutSupport(RgthreeImageComparer, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ setTimeout(() => {
+ RgthreeImageComparer.category = comfyClass.category;
+ });
+ }
+}
+
+type RgthreeImageComparerWidgetValue = {
+ images: ComfyImageData[];
+};
+
+class RgthreeImageComparerWidget extends RgthreeBaseWidget {
+ override readonly type = "custom";
+
+ private node: RgthreeImageComparer;
+
+ protected override hitAreas: RgthreeBaseHitAreas = {
+ // We dynamically set this when/if we draw the labels.
+ };
+
+ private selected: [ComfyImageData?, ComfyImageData?] = [];
+
+ constructor(name: string, node: RgthreeImageComparer) {
+ super(name);
+ this.node = node;
+ }
+
+ private _value: RgthreeImageComparerWidgetValue = {images: []};
+
+ set value(v: RgthreeImageComparerWidgetValue) {
+ // Despite `v` typed as RgthreeImageComparerWidgetValue, we may have gotten an array of strings
+ // from previous versions. We can handle that gracefully.
+ let cleanedVal;
+ if (Array.isArray(v)) {
+ cleanedVal = v.map((d, i) => {
+ if (!d || typeof d === "string") {
+ // We usually only have two here, so they're selected.
+ d = {url: d, name: i == 0 ? "A" : "B", selected: true};
+ }
+ return d;
+ });
+ } else {
+ cleanedVal = v.images || [];
+ }
+
+ // If we have multiple items in our sent value but we don't have both an "A" and a "B" then
+ // just simplify it down to the first two in the list.
+ if (cleanedVal.length > 2) {
+ const hasAAndB =
+ cleanedVal.some((i) => i.name.startsWith("A")) &&
+ cleanedVal.some((i) => i.name.startsWith("B"));
+ if (!hasAAndB) {
+ cleanedVal = [cleanedVal[0], cleanedVal[1]];
+ }
+ }
+
+ let selected = cleanedVal.filter((d) => d.selected);
+ // None are selected.
+ if (!selected.length && cleanedVal.length) {
+ cleanedVal[0]!.selected = true;
+ }
+
+ selected = cleanedVal.filter((d) => d.selected);
+ if (selected.length === 1 && cleanedVal.length > 1) {
+ cleanedVal.find((d) => !d.selected)!.selected = true;
+ }
+
+ this._value.images = cleanedVal;
+
+ selected = cleanedVal.filter((d) => d.selected);
+ this.setSelected(selected as [ComfyImageData, ComfyImageData]);
+ }
+
+ get value() {
+ return this._value;
+ }
+
+ setSelected(selected: [ComfyImageData, ComfyImageData]) {
+ this._value.images.forEach((d) => (d.selected = false));
+ this.node.imgs.length = 0;
+ for (const sel of selected) {
+ if (!sel.img) {
+ sel.img = new Image();
+ sel.img.src = sel.url;
+ this.node.imgs.push(sel.img);
+ }
+ sel.selected = true;
+ }
+ this.selected = selected;
+ }
+
+ draw(ctx: CanvasRenderingContext2D, node: RgthreeImageComparer, width: number, y: number) {
+ this.hitAreas = {};
+ if (this.value.images.length > 2) {
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ ctx.font = `14px Arial`;
+ // Let's calculate the widths of all the labels.
+ const drawData: any = [];
+ const spacing = 5;
+ let x = 0;
+ for (const img of this.value.images) {
+ const width = measureText(ctx, img.name);
+ drawData.push({
+ img,
+ text: img.name,
+ x,
+ width: measureText(ctx, img.name),
+ });
+ x += width + spacing;
+ }
+ x = (node.size[0] - (x - spacing)) / 2;
+ for (const d of drawData) {
+ ctx.fillStyle = d.img.selected ? "rgba(180, 180, 180, 1)" : "rgba(180, 180, 180, 0.5)";
+ ctx.fillText(d.text, x, y);
+ this.hitAreas[d.text] = {
+ bounds: [x, y, d.width, 14],
+ data: d.img,
+ onDown: this.onSelectionDown,
+ };
+ x += d.width + spacing;
+ }
+ y += 20;
+ }
+
+ if (node.properties?.["comparer_mode"] === "Click") {
+ this.drawImage(ctx, this.selected[this.node.isPointerDown ? 1 : 0], y);
+ } else {
+ this.drawImage(ctx, this.selected[0], y);
+ if (node.isPointerOver) {
+ this.drawImage(ctx, this.selected[1], y, this.node.pointerOverPos[0]);
+ }
+ }
+ }
+
+ private onSelectionDown(
+ event: CanvasMouseEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds?: RgthreeBaseWidgetBounds,
+ ) {
+ const selected = [...this.selected];
+ if (bounds?.data.name.startsWith("A")) {
+ selected[0] = bounds.data;
+ } else if (bounds?.data.name.startsWith("B")) {
+ selected[1] = bounds.data;
+ }
+ this.setSelected(selected as [ComfyImageData, ComfyImageData]);
+ }
+
+ private drawImage(
+ ctx: CanvasRenderingContext2D,
+ image: ComfyImageData | undefined,
+ y: number,
+ cropX?: number,
+ ) {
+ if (!image?.img?.naturalWidth || !image?.img?.naturalHeight) {
+ return;
+ }
+ let [nodeWidth, nodeHeight] = this.node.size as [number, number];
+ const imageAspect = image?.img.naturalWidth / image?.img.naturalHeight;
+ let height = nodeHeight - y;
+ const widgetAspect = nodeWidth / height;
+ let targetWidth, targetHeight;
+ let offsetX = 0;
+ if (imageAspect > widgetAspect) {
+ targetWidth = nodeWidth;
+ targetHeight = nodeWidth / imageAspect;
+ } else {
+ targetHeight = height;
+ targetWidth = height * imageAspect;
+ offsetX = (nodeWidth - targetWidth) / 2;
+ }
+ const widthMultiplier = image?.img.naturalWidth / targetWidth;
+
+ const sourceX = 0;
+ const sourceY = 0;
+ const sourceWidth =
+ cropX != null ? (cropX - offsetX) * widthMultiplier : image?.img.naturalWidth;
+ const sourceHeight = image?.img.naturalHeight;
+ const destX = (nodeWidth - targetWidth) / 2;
+ const destY = y + (height - targetHeight) / 2;
+ const destWidth = cropX != null ? cropX - offsetX : targetWidth;
+ const destHeight = targetHeight;
+ ctx.save();
+ ctx.beginPath();
+ let globalCompositeOperation = ctx.globalCompositeOperation;
+ if (cropX) {
+ ctx.rect(destX, destY, destWidth, destHeight);
+ ctx.clip();
+ }
+ ctx.drawImage(
+ image?.img,
+ sourceX,
+ sourceY,
+ sourceWidth,
+ sourceHeight,
+ destX,
+ destY,
+ destWidth,
+ destHeight,
+ );
+ // Shows a label overlayed on the image. Not perfect, keeping commented out.
+ // ctx.globalCompositeOperation = "difference";
+ // ctx.fillStyle = "rgba(180, 180, 180, 1)";
+ // ctx.textAlign = "center";
+ // ctx.font = `32px Arial`;
+ // ctx.fillText(image.name, nodeWidth / 2, y + 32);
+ if (cropX != null && cropX >= (nodeWidth - targetWidth) / 2 && cropX <= targetWidth + offsetX) {
+ ctx.beginPath();
+ ctx.moveTo(cropX, destY);
+ ctx.lineTo(cropX, destY + destHeight);
+ ctx.globalCompositeOperation = "difference";
+ ctx.strokeStyle = "rgba(255,255,255, 1)";
+ ctx.stroke();
+ }
+ ctx.globalCompositeOperation = globalCompositeOperation;
+ ctx.restore();
+ }
+
+ computeSize(width: number): Vector2 {
+ return [width, 20];
+ }
+
+ override serializeValue(
+ node: LGraphNode,
+ index: number,
+ ): RgthreeImageComparerWidgetValue | Promise {
+ const v = [];
+ for (const data of this._value.images) {
+ // Remove the img since it can't serialize.
+ const d = {...data};
+ delete d.img;
+ v.push(d);
+ }
+ return {images: v};
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.ImageComparer",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === RgthreeImageComparer.type) {
+ RgthreeImageComparer.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/image_inset_crop.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/image_inset_crop.ts
new file mode 100644
index 0000000000000000000000000000000000000000..631ceaf0cf155649d89fa721cfa2b738b1f4bfc7
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/image_inset_crop.ts
@@ -0,0 +1,70 @@
+import type {LGraph, LGraphNodeConstructor, ISerialisedNode} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+
+class ImageInsetCrop extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.IMAGE_INSET_CROP;
+ static override type = NodeTypesString.IMAGE_INSET_CROP;
+ static comfyClass = NodeTypesString.IMAGE_INSET_CROP;
+
+ static override exposedActions = ["Reset Crop"];
+ static maxResolution = 8192;
+
+ constructor(title = ImageInsetCrop.title) {
+ super(title);
+ }
+
+ override onAdded(graph: LGraph): void {
+ const measurementWidget = this.widgets[0]!;
+ let callback = measurementWidget.callback;
+ measurementWidget.callback = (...args) => {
+ this.setWidgetStep();
+ callback && callback.apply(measurementWidget, [...args]);
+ };
+ this.setWidgetStep();
+ }
+
+ override configure(info: ISerialisedNode): void {
+ super.configure(info);
+ this.setWidgetStep();
+ }
+
+ private setWidgetStep() {
+ const measurementWidget = this.widgets[0]!;
+ for (let i = 1; i <= 4; i++) {
+ if (measurementWidget.value === "Pixels") {
+ this.widgets[i]!.options.step = 80;
+ this.widgets[i]!.options.max = ImageInsetCrop.maxResolution;
+ } else {
+ this.widgets[i]!.options.step = 10;
+ this.widgets[i]!.options.max = 99;
+ }
+ }
+ }
+
+ override async handleAction(action: string): Promise {
+ if (action === "Reset Crop") {
+ for (const widget of this.widgets) {
+ if (["left", "right", "top", "bottom"].includes(widget.name!)) {
+ widget.value = 0;
+ }
+ }
+ }
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ImageInsetCrop);
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.ImageInsetCrop",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === NodeTypesString.IMAGE_INSET_CROP) {
+ ImageInsetCrop.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/image_or_latent_size.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/image_or_latent_size.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d52a1e49cc918f83b38ebaaccca1279398308d7e
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/image_or_latent_size.ts
@@ -0,0 +1,51 @@
+import type {ISerialisedNode} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+
+class RgthreeImageOrLatentSize extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.IMAGE_OR_LATENT_SIZE;
+ static override type = NodeTypesString.IMAGE_OR_LATENT_SIZE;
+ static comfyClass = NodeTypesString.IMAGE_OR_LATENT_SIZE;
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
+ }
+
+ constructor(title = NODE_CLASS.title) {
+ super(title);
+ }
+
+ override onNodeCreated() {
+ super.onNodeCreated?.();
+
+ // Litegraph uses an array of acceptable input types, even though ComfyUI's types don't type
+ // it out that way.
+ this.addInput("input", ["IMAGE", "LATENT", "MASK"] as any);
+ }
+
+ override configure(info: ISerialisedNode): void {
+ super.configure(info);
+
+ if (this.inputs?.length) {
+ // Litegraph uses an array of acceptable input types, even though ComfyUI's types don't type
+ // it out that way.
+ this.inputs[0]!.type = ["IMAGE", "LATENT", "MASK"] as any;
+ }
+ }
+}
+
+/** An uniformed name reference to the node class. */
+const NODE_CLASS = RgthreeImageOrLatentSize;
+
+/** Register the node. */
+app.registerExtension({
+ name: "rgthree.ImageOrLatentSize",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === NODE_CLASS.type) {
+ NODE_CLASS.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/label.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/label.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4d240e08650760c69e47353cc9a8ee66d6d437fc
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/label.ts
@@ -0,0 +1,229 @@
+import type {
+ LGraphCanvas as TLGraphCanvas,
+ LGraphNode,
+ Vector2,
+ CanvasMouseEvent,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseVirtualNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {rgthree} from "./rgthree.js";
+
+/**
+ * A label node that allows you to put floating text anywhere on the graph. The text is the `Title`
+ * and the font size, family, color, alignment as well as a background color, padding, and
+ * background border radius can all be adjusted in the properties. Multiline text can be added from
+ * the properties panel (because ComfyUI let's you shift + enter there, only).
+ */
+export class Label extends RgthreeBaseVirtualNode {
+ static override type = NodeTypesString.LABEL;
+ static override title = NodeTypesString.LABEL;
+ override comfyClass = NodeTypesString.LABEL;
+
+ static readonly title_mode = LiteGraph.NO_TITLE;
+ static collapsable = false;
+
+ static "@fontSize" = {type: "number"};
+ static "@fontFamily" = {type: "string"};
+ static "@fontColor" = {type: "string"};
+ static "@textAlign" = {type: "combo", values: ["left", "center", "right"]};
+ static "@backgroundColor" = {type: "string"};
+ static "@padding" = {type: "number"};
+ static "@borderRadius" = {type: "number"};
+ static "@angle" = {type: "number"};
+
+ override properties!: RgthreeBaseVirtualNode["properties"] & {
+ fontSize: number;
+ fontFamily: string;
+ fontColor: string;
+ textAlign: string;
+ backgroundColor: string;
+ padding: number;
+ borderRadius: number;
+ angle: number;
+ };
+
+ override resizable = false;
+
+ constructor(title = Label.title) {
+ super(title);
+ this.properties["fontSize"] = 12;
+ this.properties["fontFamily"] = "Arial";
+ this.properties["fontColor"] = "#ffffff";
+ this.properties["textAlign"] = "left";
+ this.properties["backgroundColor"] = "transparent";
+ this.properties["padding"] = 0;
+ this.properties["borderRadius"] = 0;
+ this.properties["angle"] = 0;
+ this.color = "#fff0";
+ this.bgcolor = "#fff0";
+
+ this.onConstructed();
+ }
+
+ draw(ctx: CanvasRenderingContext2D) {
+ this.flags = this.flags || {};
+ this.flags.allow_interaction = !this.flags.pinned;
+ ctx.save();
+ this.color = "#fff0";
+ this.bgcolor = "#fff0";
+ const fontColor = this.properties["fontColor"] || "#ffffff";
+ const backgroundColor = this.properties["backgroundColor"] || "";
+ ctx.font = `${Math.max(this.properties["fontSize"] || 0, 1)}px ${
+ this.properties["fontFamily"] ?? "Arial"
+ }`;
+ const padding = Number(this.properties["padding"]) ?? 0;
+
+ // Support literal "\\n" sequences as newlines and trim trailing newlines
+ const processedTitle = (this.title ?? "").replace(/\\n/g, "\n").replace(/\n*$/, "");
+ const lines = processedTitle.split("\n");
+
+ const maxWidth = Math.max(...lines.map((s) => ctx.measureText(s).width));
+ this.size[0] = maxWidth + padding * 2;
+ this.size[1] = this.properties["fontSize"] * lines.length + padding * 2;
+
+ // Apply rotation around the center, if angle provided
+ const angleDeg = parseInt(String(this.properties["angle"] ?? 0)) || 0;
+ if (angleDeg) {
+ const cx = this.size[0] / 2;
+ const cy = this.size[1] / 2;
+ ctx.translate(cx, cy);
+ ctx.rotate((angleDeg * Math.PI) / 180);
+ ctx.translate(-cx, -cy);
+ }
+
+ if (backgroundColor) {
+ ctx.beginPath();
+ const borderRadius = Number(this.properties["borderRadius"]) || 0;
+ ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
+ ctx.fillStyle = backgroundColor;
+ ctx.fill();
+ }
+ ctx.textAlign = "left";
+ let textX = padding;
+ if (this.properties["textAlign"] === "center") {
+ ctx.textAlign = "center";
+ textX = this.size[0] / 2;
+ } else if (this.properties["textAlign"] === "right") {
+ ctx.textAlign = "right";
+ textX = this.size[0] - padding;
+ }
+ ctx.textBaseline = "top";
+ ctx.fillStyle = fontColor;
+ let currentY = padding;
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i] || " ", textX, currentY);
+ currentY += this.properties["fontSize"];
+ }
+ ctx.restore();
+ }
+
+ override onDblClick(event: CanvasMouseEvent, pos: Vector2, canvas: TLGraphCanvas) {
+ // Since everything we can do here is in the properties, let's pop open the properties panel.
+ LGraphCanvas.active_canvas.showShowNodePanel(this);
+ }
+
+ override onShowCustomPanelInfo(panel: HTMLElement) {
+ panel.querySelector('div.property[data-property="Mode"]')?.remove();
+ panel.querySelector('div.property[data-property="Color"]')?.remove();
+ }
+
+ override inResizeCorner(x: number, y: number) {
+ // A little ridiculous there's both a resizable property and this method separately to draw the
+ // resize icon...
+ return this.resizable;
+ }
+
+ override getHelp() {
+ return `
+
+ The rgthree-comfy ${this.type!.replace("(rgthree)", "")} node allows you to add a floating
+ label to your workflow.
+
+
+ The text shown is the "Title" of the node and you can adjust the the font size, font family,
+ font color, text alignment as well as a background color, padding, and background border
+ radius from the node's properties. You can double-click the node to open the properties
+ panel.
+
+
+
+
+ Pro tip #1: You can add multiline text from the properties panel
+ (because ComfyUI let's you shift + enter there, only) .
+
+
+
+
+ Pro tip #2: You can use ComfyUI's native "pin" option in the
+ right-click menu to make the label stick to the workflow and clicks to "go through".
+ You can right-click at any time to unpin.
+
+
+
+
+ Pro tip #3: Color values are hexidecimal strings, like "#FFFFFF" for
+ white, or "#660000" for dark red. You can supply a 7th & 8th value (or 5th if using
+ shorthand) to create a transluscent color. For instance, "#FFFFFF88" is semi-transparent
+ white.
+
+
+ `;
+ }
+}
+
+/**
+ * We override the drawNode to see if we're drawing our label and, if so, hijack it so we can draw
+ * it like we want. We also do call out to oldDrawNode, which takes care of very minimal things,
+ * like a select box.
+ */
+const oldDrawNode = LGraphCanvas.prototype.drawNode;
+LGraphCanvas.prototype.drawNode = function (node: LGraphNode, ctx: CanvasRenderingContext2D) {
+ if (node.constructor === Label.prototype.constructor) {
+ // These get set very aggressively; maybe an extension is doing it. We'll just clear them out
+ // each time.
+ (node as Label).bgcolor = "transparent";
+ (node as Label).color = "transparent";
+ const v = oldDrawNode.apply(this, arguments as any);
+ (node as Label).draw(ctx);
+ return v;
+ }
+
+ const v = oldDrawNode.apply(this, arguments as any);
+ return v;
+};
+
+/**
+ * We override LGraph getNodeOnPos to see if we're being called while also processing a mouse down
+ * and, if so, filter out any label nodes on labels that are pinned. This makes the click go
+ * "through" the label. We still allow right clicking (so you can unpin) and double click for the
+ * properties panel, though that takes two double clicks (one to select, one to actually double
+ * click).
+ */
+const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
+LGraph.prototype.getNodeOnPos = function (x: number, y: number, nodes_list?: LGraphNode[]) {
+ if (
+ // processMouseDown always passes in the nodes_list
+ nodes_list &&
+ rgthree.processingMouseDown &&
+ rgthree.lastCanvasMouseEvent?.type.includes("down") &&
+ rgthree.lastCanvasMouseEvent?.which === 1
+ ) {
+ // Using the same logic from LGraphCanvas processMouseDown, let's see if we consider this a
+ // double click.
+ let isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
+ if (!isDoubleClick) {
+ nodes_list = [...nodes_list].filter((n) => !(n instanceof Label) || !n.flags?.pinned);
+ }
+ }
+ return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
+};
+
+// Register the extension.
+app.registerExtension({
+ name: "rgthree.Label",
+ registerCustomNodes() {
+ Label.setUp();
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts
new file mode 100644
index 0000000000000000000000000000000000000000..79d0d2897caa3dd59dcb43150381b7990fb1dd4e
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts
@@ -0,0 +1,156 @@
+import type {
+ LGraphNode,
+ ContextMenu,
+ IContextMenuOptions,
+ IContextMenuValue,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {rgthree} from "./rgthree.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+
+const SPECIAL_ENTRIES = [/^(CHOOSE|NONE|DISABLE|OPEN)(\s|$)/i, /^\p{Extended_Pictographic}/gu];
+
+/**
+ * Handles a large, flat list of string values given ContextMenu and breaks it up into subfolder, if
+ * they exist. This is experimental and initially built to work for CheckpointLoaderSimple.
+ */
+app.registerExtension({
+ name: "rgthree.ContextMenuAutoNest",
+ async setup() {
+ const logger = rgthree.newLogSession("[ContextMenuAutoNest]");
+
+ const existingContextMenu = LiteGraph.ContextMenu;
+
+ // @ts-ignore: TypeScript doesn't like this override.
+ LiteGraph.ContextMenu = function (
+ values: IContextMenuValue[],
+ options: IContextMenuOptions,
+ ) {
+ const threshold = CONFIG_SERVICE.getConfigValue("features.menu_auto_nest.threshold", 20);
+ const enabled = CONFIG_SERVICE.getConfigValue("features.menu_auto_nest.subdirs", false);
+
+ // If we're not enabled, or are incompatible, then just call out safely.
+ let incompatible: string | boolean = !enabled || !!options?.extra?.rgthree_doNotNest;
+ if (!incompatible) {
+ if (values.length <= threshold) {
+ incompatible = `Skipping context menu auto nesting b/c threshold is not met (${threshold})`;
+ }
+ // If there's a rgthree_originalCallback, then we're nested and don't need to check things
+ // we only expect on the first nesting.
+ if (!(options.parentMenu?.options as any)?.rgthree_originalCallback) {
+ // On first context menu, we require a callback and a flat list of options as strings.
+ if (!options?.callback) {
+ incompatible = `Skipping context menu auto nesting b/c a callback was expected.`;
+ } else if (values.some((i) => typeof i !== "string")) {
+ incompatible = `Skipping context menu auto nesting b/c not all values were strings.`;
+ }
+ }
+ }
+ if (incompatible) {
+ if (enabled) {
+ const [n, v] = logger.infoParts(
+ "Skipping context menu auto nesting for incompatible menu.",
+ );
+ console[n]?.(...v);
+ }
+ return existingContextMenu.apply(this as any, [...arguments] as any);
+ }
+
+ const folders: {[key: string]: IContextMenuValue[]} = {};
+ const specialOps: IContextMenuValue[] = [];
+ const folderless: IContextMenuValue[] = [];
+ for (const value of values) {
+ if (!value) {
+ folderless.push(value);
+ continue;
+ }
+ const newValue = typeof value === "string" ? {content: value} : Object.assign({}, value);
+ (newValue as any).rgthree_originalValue = (value as any).rgthree_originalValue || value;
+ const valueContent = newValue.content || "";
+ const splitBy = valueContent.indexOf("/") > -1 ? "/" : "\\";
+ const valueSplit = valueContent.split(splitBy);
+ if (valueSplit.length > 1) {
+ const key = valueSplit.shift()!;
+ newValue.content = valueSplit.join(splitBy);
+ folders[key] = folders[key] || [];
+ folders[key]!.push(newValue);
+ } else if (SPECIAL_ENTRIES.some((r) => r.test(valueContent))) {
+ specialOps.push(newValue);
+ } else {
+ folderless.push(newValue);
+ }
+ }
+
+ const foldersCount = Object.values(folders).length;
+ if (foldersCount > 0) {
+ // Propogate the original callback down through the options.
+ (options as any).rgthree_originalCallback =
+ (options as any).rgthree_originalCallback ||
+ (options.parentMenu?.options as any)?.rgthree_originalCallback ||
+ options.callback;
+ const oldCallback = (options as any)?.rgthree_originalCallback;
+ options.callback = undefined;
+ const newCallback = (
+ item: IContextMenuValue,
+ options: IContextMenuOptions,
+ event: MouseEvent,
+ parentMenu: ContextMenu | undefined,
+ node: LGraphNode,
+ ) => {
+ oldCallback?.((item as any)?.rgthree_originalValue!, options, event, undefined, node);
+ };
+ const [n, v] = logger.infoParts(`Nested folders found (${foldersCount}).`);
+ console[n]?.(...v);
+ const newValues: IContextMenuValue[] = [];
+ for (const [folderName, folderValues] of Object.entries(folders)) {
+ newValues.push({
+ content: `📁 ${folderName}`,
+ has_submenu: true,
+ callback: () => {
+ /* no-op, use the item callback. */
+ },
+ submenu: {
+ options: folderValues.map((value) => {
+ value!.callback = newCallback;
+ return value;
+ }),
+ },
+ });
+ }
+ values = ([] as IContextMenuValue[]).concat(
+ specialOps.map((f) => {
+ if (typeof f === "string") {
+ f = {content: f};
+ }
+ f!.callback = newCallback;
+ return f;
+ }),
+ newValues,
+ folderless.map((f) => {
+ if (typeof f === "string") {
+ f = {content: f};
+ }
+ f!.callback = newCallback;
+ return f;
+ }),
+ );
+ }
+ if (options.scale == null) {
+ options.scale = Math.max(app.canvas.ds?.scale || 1, 1);
+ }
+
+ const oldCtrResponse = existingContextMenu.call(this as any, values, options as any);
+ // For some reason, LiteGraph calls submenus with "this.constructor" which no longer allows
+ // us to continue building deep nesting, as well as skips many other extensions (even
+ // ComfyUI's core extensions like translations) from working on submenus. It also removes
+ // search, etc. While this is a recent-ish issue, I can't seem to find the culpit as it looks
+ // like old litegraph always did this. Perhaps changing it to a Class? Anyway, this fixes it;
+ // Hopefully without issues.
+ if ((oldCtrResponse as any)?.constructor) {
+ (oldCtrResponse as any).constructor = LiteGraph.ContextMenu;
+ }
+ return this;
+ };
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/menu_copy_image.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/menu_copy_image.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b4c5dac8bb0ebce2deb57cdbb39f95f43f7fd453
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/menu_copy_image.ts
@@ -0,0 +1,75 @@
+import type {IContextMenuValue, LGraphCanvas} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+
+const clipboardSupportedPromise = new Promise(async (resolve) => {
+ try {
+ // MDN says to check this, but it doesn't work in Mozilla... however, in secure contexts
+ // (localhost included), it's given by default if the user has it flagged.. so we should be
+ // able to check in the latter ClipboardItem too.
+ const result = await navigator.permissions.query({name: "clipboard-write"} as any);
+ resolve(result.state === "granted");
+ return;
+ } catch (e) {
+ try {
+ if (!navigator.clipboard.write) {
+ throw new Error();
+ }
+ new ClipboardItem({"image/png": new Blob([], {type: "image/png"})});
+ resolve(true);
+ return;
+ } catch (e) {
+ resolve(false);
+ }
+ }
+});
+
+/**
+ * Adds a "Copy Image" to images in similar fashion to the "native" Open Image and Save Image
+ * options.
+ */
+app.registerExtension({
+ name: "rgthree.CopyImageToClipboard",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name.toLowerCase().includes("image")) {
+ if (await clipboardSupportedPromise) {
+ const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
+ nodeType.prototype.getExtraMenuOptions = function (
+ canvas: LGraphCanvas,
+ options: (IContextMenuValue | null)[],
+ ): (IContextMenuValue | null)[] {
+ options = getExtraMenuOptions?.call(this, canvas, options) ?? options;
+ // If we already have a copy image somehow, then let's skip ours.
+ if (this.imgs?.length) {
+ let img =
+ this.imgs[this.imageIndex || 0] || this.imgs[this.overIndex || 0] || this.imgs[0];
+ const foundIdx = options.findIndex((option) => option?.content?.includes("Copy Image"));
+ if (img && foundIdx === -1) {
+ const menuItem: IContextMenuValue = {
+ content: "Copy Image (rgthree)",
+ callback: () => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d")!;
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
+ canvas.toBlob((blob) => {
+ navigator.clipboard.write([new ClipboardItem({"image/png": blob!})]);
+ });
+ },
+ };
+ let idx = options.findIndex((option) => option?.content?.includes("Open Image")) + 1;
+ if (idx != null) {
+ options.splice(idx, 0, menuItem);
+ } else {
+ options.unshift(menuItem);
+ }
+ }
+ }
+ return [];
+ };
+ }
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/menu_queue_node.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/menu_queue_node.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c8e5507c631cb3a195c37c6f23ee0500cca85a95
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/menu_queue_node.ts
@@ -0,0 +1,93 @@
+import type {
+ IContextMenuValue,
+ LGraphCanvas as TLGraphCanvas,
+ LGraphNode,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {rgthree} from "./rgthree.js";
+import {getGroupNodes, getOutputNodes} from "./utils.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+
+function showQueueNodesMenuIfOutputNodesAreSelected(
+ existingOptions: (IContextMenuValue | null)[],
+) {
+ if (CONFIG_SERVICE.getConfigValue("features.menu_queue_selected_nodes") === false) {
+ return;
+ }
+ const outputNodes = getOutputNodes(Object.values(app.canvas.selected_nodes));
+ const menuItem = {
+ content: `Queue Selected Output Nodes (rgthree) `,
+ className: "rgthree-contextmenu-item",
+ callback: () => {
+ rgthree.queueOutputNodes(outputNodes);
+ },
+ disabled: !outputNodes.length,
+ };
+
+ let idx = existingOptions.findIndex((o) => o?.content === "Outputs") + 1;
+ idx = idx || existingOptions.findIndex((o) => o?.content === "Align") + 1;
+ idx = idx || 3;
+ existingOptions.splice(idx, 0, menuItem);
+}
+
+function showQueueGroupNodesMenuIfGroupIsSelected(
+ existingOptions: (IContextMenuValue | null)[],
+) {
+ if (CONFIG_SERVICE.getConfigValue("features.menu_queue_selected_nodes") === false) {
+ return;
+ }
+ const group =
+ rgthree.lastCanvasMouseEvent &&
+ (app.canvas.getCurrentGraph() || app.graph).getGroupOnPos(
+ rgthree.lastCanvasMouseEvent.canvasX,
+ rgthree.lastCanvasMouseEvent.canvasY,
+ );
+
+ const outputNodes: LGraphNode[] | null = (group && getOutputNodes(getGroupNodes(group))) || null;
+ const menuItem = {
+ content: `Queue Group Output Nodes (rgthree) `,
+ className: "rgthree-contextmenu-item",
+ callback: () => {
+ outputNodes && rgthree.queueOutputNodes(outputNodes);
+ },
+ disabled: !outputNodes?.length,
+ };
+
+ let idx = existingOptions.findIndex((o) => o?.content?.startsWith("Queue Selected ")) + 1;
+ idx = idx || existingOptions.findIndex((o) => o?.content === "Outputs") + 1;
+ idx = idx || existingOptions.findIndex((o) => o?.content === "Align") + 1;
+ idx = idx || 3;
+ existingOptions.splice(idx, 0, menuItem);
+}
+
+/**
+ * Adds a "Queue Node" menu item to all output nodes, working with `rgthree.queueOutputNode` to
+ * execute only a single node's path.
+ */
+app.registerExtension({
+ name: "rgthree.QueueNode",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
+ nodeType.prototype.getExtraMenuOptions = function (
+ canvas: TLGraphCanvas,
+ options: (IContextMenuValue | null)[],
+ ): (IContextMenuValue | null)[] {
+ const extraOptions = getExtraMenuOptions?.call(this, canvas, options) ?? [];
+ showQueueNodesMenuIfOutputNodesAreSelected(options);
+ showQueueGroupNodesMenuIfGroupIsSelected(options);
+ return extraOptions;
+ };
+ },
+
+ async setup() {
+ const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const options = getCanvasMenuOptions.apply(this, [...args] as any);
+ showQueueNodesMenuIfOutputNodesAreSelected(options);
+ showQueueGroupNodesMenuIfGroupIsSelected(options);
+ return options;
+ };
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..89fe00f22d604d28a04c118c74b7768ae076598a
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts
@@ -0,0 +1,51 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {BaseNodeModeChanger} from "./base_node_mode_changer.js";
+import {NodeTypesString} from "./constants.js";
+
+const MODE_MUTE = 2;
+const MODE_ALWAYS = 0;
+
+class MuterNode extends BaseNodeModeChanger {
+ static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
+
+ static override type = NodeTypesString.FAST_MUTER;
+ static override title = NodeTypesString.FAST_MUTER;
+ override comfyClass = NodeTypesString.FAST_MUTER;
+ override readonly modeOn = MODE_ALWAYS;
+ override readonly modeOff = MODE_MUTE;
+
+ constructor(title = MuterNode.title) {
+ super(title);
+ this.onConstructed();
+ }
+
+ override async handleAction(action: string) {
+ if (action === "Mute all") {
+ for (const widget of this.widgets) {
+ this.forceWidgetOff(widget, true);
+ }
+ } else if (action === "Enable all") {
+ for (const widget of this.widgets) {
+ this.forceWidgetOn(widget, true);
+ }
+ } else if (action === "Toggle all") {
+ for (const widget of this.widgets) {
+ this.forceWidgetToggle(widget, true);
+ }
+ }
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.Muter",
+ registerCustomNodes() {
+ MuterNode.setUp();
+ },
+ loadedGraphNode(node: LGraphNode) {
+ if (node.type == MuterNode.title) {
+ (node as any)._tempWidth = node.size[0];
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts
new file mode 100644
index 0000000000000000000000000000000000000000..87bb6ba8151f7d68417a6db0221b9083531d4312
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts
@@ -0,0 +1,168 @@
+import type {
+ LLink,
+ LGraph,
+ LGraphCanvas,
+ LGraphNode as TLGraphNode,
+ IContextMenuOptions,
+ ContextMenu,
+ IContextMenuValue,
+ Size,
+ ISerialisedNode,
+ Point,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {addConnectionLayoutSupport} from "./utils.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {BaseCollectorNode} from "./base_node_collector.js";
+import {NodeTypesString} from "./constants.js";
+
+/**
+ * The Collector Node. Takes any number of inputs as connections for nodes and collects them into
+ * one outputs. The next node will decide what to do with them.
+ *
+ * Currently only works with the Fast Muter, Fast Bypasser, and Fast Actions Button.
+ */
+class CollectorNode extends BaseCollectorNode {
+ static override type = NodeTypesString.NODE_COLLECTOR;
+ static override title = NodeTypesString.NODE_COLLECTOR;
+ override comfyClass = NodeTypesString.NODE_COLLECTOR;
+
+ constructor(title = CollectorNode.title) {
+ super(title);
+ this.onConstructed();
+ }
+
+ override onConstructed(): boolean {
+ this.addOutput("Output", "*");
+ return super.onConstructed();
+ }
+}
+
+/** Legacy "Combiner" */
+class CombinerNode extends CollectorNode {
+ static legacyType = "Node Combiner (rgthree)";
+ static override title = "‼️ Node Combiner [DEPRECATED]";
+
+ constructor(title = CombinerNode.title) {
+ super(title);
+
+ const note = ComfyWidgets["STRING"](
+ this,
+ "last_seed",
+ ["STRING", {multiline: true}],
+ app,
+ ).widget;
+ note.inputEl!.value =
+ 'The Node Combiner has been renamed to Node Collector. You can right-click and select "Update to Node Collector" to attempt to automatically update.';
+ note.inputEl!.readOnly = true;
+ note.inputEl!.style.backgroundColor = "#332222";
+ note.inputEl!.style.fontWeight = "bold";
+ note.inputEl!.style.fontStyle = "italic";
+ note.inputEl!.style.opacity = "0.8";
+
+ this.getExtraMenuOptions = (
+ canvas: LGraphCanvas,
+ options: (IContextMenuValue | null)[],
+ ): (IContextMenuValue | null)[] => {
+ options.splice(options.length - 1, 0, {
+ content: "‼️ Update to Node Collector",
+ callback: (
+ _value: IContextMenuValue,
+ _options: IContextMenuOptions,
+ _event: MouseEvent,
+ _parentMenu: ContextMenu | undefined,
+ _node: TLGraphNode,
+ ) => {
+ updateCombinerToCollector(this);
+ },
+ });
+ return [];
+ };
+ }
+
+ override configure(info: ISerialisedNode) {
+ super.configure(info);
+ if (this.title != CombinerNode.title && !this.title.startsWith("‼️")) {
+ this.title = "‼️ " + this.title;
+ }
+ }
+}
+
+/**
+ * Updates a Node Combiner to a Node Collector.
+ */
+async function updateCombinerToCollector(node: TLGraphNode) {
+ if (node.type === CombinerNode.legacyType) {
+ // Create a new CollectorNode.
+ const newNode = new CollectorNode();
+ if (node.title != CombinerNode.title) {
+ newNode.title = node.title.replace("‼️ ", "");
+ }
+ // Port the position, size, and properties from the old node.
+ newNode.pos = [...node.pos] as Point;
+ newNode.size = [...node.size] as Size;
+ newNode.properties = {...node.properties};
+ // We now collect the links data, inputs and outputs, of the old node since these will be
+ // lost when we remove it.
+ const links: any[] = [];
+ const graph = (node.graph || app.graph);
+ for (const [index, output] of node.outputs.entries()) {
+ for (const linkId of output.links || []) {
+ const link: LLink = graph.links[linkId]!;
+ if (!link) continue;
+ const targetNode = graph.getNodeById(link.target_id);
+ links.push({node: newNode, slot: index, targetNode, targetSlot: link.target_slot});
+ }
+ }
+ for (const [index, input] of node.inputs.entries()) {
+ const linkId = input.link;
+ if (linkId) {
+ const link: LLink = graph.links[linkId]!;
+ const originNode = graph.getNodeById(link.origin_id);
+ links.push({
+ node: originNode,
+ slot: link.origin_slot,
+ targetNode: newNode,
+ targetSlot: index,
+ });
+ }
+ }
+ // Add the new node, remove the old node.
+ graph.add(newNode);
+ await wait();
+ // Now go through and connect the other nodes up as they were.
+ for (const link of links) {
+ link.node.connect(link.slot, link.targetNode, link.targetSlot);
+ }
+ await wait();
+ graph.remove(node);
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.NodeCollector",
+ registerCustomNodes() {
+ addConnectionLayoutSupport(CollectorNode, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+
+ LiteGraph.registerNodeType(CollectorNode.title, CollectorNode);
+ CollectorNode.category = CollectorNode._category;
+ },
+});
+
+app.registerExtension({
+ name: "rgthree.NodeCombiner",
+ registerCustomNodes() {
+ addConnectionLayoutSupport(CombinerNode, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+
+ LiteGraph.registerNodeType(CombinerNode.legacyType, CombinerNode);
+ CombinerNode.category = CombinerNode._category;
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aac47b4c6f62efac5c70a78344258a2ace950de1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts
@@ -0,0 +1,280 @@
+import type {
+ INodeInputSlot,
+ INodeOutputSlot,
+ LGraphCanvas,
+ LGraphEventMode,
+ LGraphNode,
+ LLink,
+ Vector2,
+ ISerialisedNode,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {
+ PassThroughFollowing,
+ addConnectionLayoutSupport,
+ changeModeOfNodes,
+ getConnectedInputNodesAndFilterPassThroughs,
+ getConnectedOutputNodesAndFilterPassThroughs,
+} from "./utils.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {BaseCollectorNode} from "./base_node_collector.js";
+import {NodeTypesString, stripRgthree} from "./constants.js";
+import {fitString} from "./utils_canvas.js";
+import {rgthree} from "./rgthree.js";
+
+const MODE_ALWAYS = 0;
+const MODE_MUTE = 2;
+const MODE_BYPASS = 4;
+const MODE_REPEATS = [MODE_MUTE, MODE_BYPASS];
+const MODE_NOTHING = -99; // MADE THIS UP.
+
+const MODE_TO_OPTION = new Map([
+ [MODE_ALWAYS, "ACTIVE"],
+ [MODE_MUTE, "MUTE"],
+ [MODE_BYPASS, "BYPASS"],
+ [MODE_NOTHING, "NOTHING"],
+]);
+
+const OPTION_TO_MODE = new Map([
+ ["ACTIVE", MODE_ALWAYS],
+ ["MUTE", MODE_MUTE],
+ ["BYPASS", MODE_BYPASS],
+ ["NOTHING", MODE_NOTHING],
+]);
+
+const MODE_TO_PROPERTY = new Map([
+ [MODE_MUTE, "on_muted_inputs"],
+ [MODE_BYPASS, "on_bypassed_inputs"],
+ [MODE_ALWAYS, "on_any_active_inputs"],
+]);
+
+const logger = rgthree.newLogSession("[NodeModeRelay]");
+
+/**
+ * Like a BaseCollectorNode, this relay node connects to a Repeater node and _relays_ mode changes
+ * changes to the repeater (so it can go on to modify its connections).
+ */
+class NodeModeRelay extends BaseCollectorNode {
+ override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
+
+ static override type = NodeTypesString.NODE_MODE_RELAY;
+ static override title = NodeTypesString.NODE_MODE_RELAY;
+ override comfyClass = NodeTypesString.NODE_MODE_RELAY;
+
+ static "@on_muted_inputs" = {
+ type: "combo",
+ values: ["MUTE", "ACTIVE", "BYPASS", "NOTHING"],
+ };
+
+ static "@on_bypassed_inputs" = {
+ type: "combo",
+ values: ["BYPASS", "ACTIVE", "MUTE", "NOTHING"],
+ };
+
+ static "@on_any_active_inputs" = {
+ type: "combo",
+ values: ["BYPASS", "ACTIVE", "MUTE", "NOTHING"],
+ };
+
+ constructor(title?: string) {
+ super(title);
+ this.properties["on_muted_inputs"] = "MUTE";
+ this.properties["on_bypassed_inputs"] = "BYPASS";
+ this.properties["on_any_active_inputs"] = "ACTIVE";
+
+ this.onConstructed();
+ }
+
+ override onConstructed() {
+ this.addOutput("REPEATER", "_NODE_REPEATER_", {
+ color_on: "#Fc0",
+ color_off: "#a80",
+ shape: LiteGraph.ARROW_SHAPE,
+ });
+
+ setTimeout(() => {
+ this.stabilize();
+ }, 500);
+ return super.onConstructed();
+ }
+
+ override onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
+ super.onModeChange(from, to);
+ // If we aren't connected to anything, then we'll use our mode to relay when it changes.
+ if (this.inputs.length <= 1 && !this.isInputConnected(0) && this.isAnyOutputConnected()) {
+ const [n, v] = logger.infoParts(`Mode change without any inputs; relaying our mode.`);
+ console[n]?.(...v);
+ // Pass "to" since there may be other getters in the way to access this.mode directly.
+ this.dispatchModeToRepeater(to);
+ }
+ }
+
+ override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: LGraphCanvas): void {
+ if (this.flags?.collapsed) {
+ return;
+ }
+ if (
+ this.properties["on_muted_inputs"] !== "MUTE" ||
+ this.properties["on_bypassed_inputs"] !== "BYPASS" ||
+ this.properties["on_any_active_inputs"] != "ACTIVE"
+ ) {
+ let margin = 15;
+ ctx.textAlign = "left";
+ let label = `*(MUTE > ${this.properties["on_muted_inputs"]}, `;
+ label += `BYPASS > ${this.properties["on_bypassed_inputs"]}, `;
+ label += `ACTIVE > ${this.properties["on_any_active_inputs"]})`;
+ ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
+ const oldFont = ctx.font;
+ ctx.font = "italic " + (LiteGraph.NODE_SUBTEXT_SIZE - 2) + "px Arial";
+ ctx.fillText(fitString(ctx, label, this.size[0] - 20), 15, this.size[1] - 6);
+ ctx.font = oldFont;
+ }
+ }
+
+ override computeSize(out: Vector2) {
+ let size = super.computeSize(out);
+ if (
+ this.properties["on_muted_inputs"] !== "MUTE" ||
+ this.properties["on_bypassed_inputs"] !== "BYPASS" ||
+ this.properties["on_any_active_inputs"] != "ACTIVE"
+ ) {
+ size[1] += 17;
+ }
+ return size;
+ }
+ override onConnectOutput(
+ outputIndex: number,
+ inputType: string | -1,
+ inputSlot: INodeInputSlot,
+ inputNode: LGraphNode,
+ inputIndex: number,
+ ): boolean {
+ let canConnect = super.onConnectOutput?.(
+ outputIndex,
+ inputType,
+ inputSlot,
+ inputNode,
+ inputIndex,
+ );
+ let nextNode = getConnectedOutputNodesAndFilterPassThroughs(this, inputNode)[0] ?? inputNode;
+ return canConnect && nextNode.type === NodeTypesString.NODE_MODE_REPEATER;
+ }
+
+ override onConnectionsChange(
+ type: number,
+ slotIndex: number,
+ isConnected: boolean,
+ link_info: LLink,
+ ioSlot: INodeOutputSlot | INodeInputSlot,
+ ): void {
+ super.onConnectionsChange(type, slotIndex, isConnected, link_info, ioSlot);
+ setTimeout(() => {
+ this.stabilize();
+ }, 500);
+ }
+
+ stabilize() {
+ // If we aren't connected to a repeater, then theres no sense in checking. And if we are, but
+ // have no inputs, then we're also not ready.
+ if (!this.graph || !this.isAnyOutputConnected() || !this.isInputConnected(0)) {
+ return;
+ }
+ const inputNodes = getConnectedInputNodesAndFilterPassThroughs(
+ this,
+ this,
+ -1,
+ this.inputsPassThroughFollowing,
+ );
+ let mode: LGraphEventMode | -99 | undefined = undefined;
+ for (const inputNode of inputNodes) {
+ // If we haven't set our mode to be, then let's set it. Otherwise, mode will stick if it
+ // remains constant, otherwise, if we hit an ALWAYS, then we'll unmute all repeaters and
+ // if not then we won't do anything.
+ if (mode === undefined) {
+ mode = inputNode.mode;
+ } else if (mode === inputNode.mode && MODE_REPEATS.includes(mode)) {
+ continue;
+ } else if (inputNode.mode === MODE_ALWAYS || mode === MODE_ALWAYS) {
+ mode = MODE_ALWAYS;
+ } else {
+ mode = undefined;
+ }
+ }
+
+ this.dispatchModeToRepeater(mode);
+ setTimeout(() => {
+ this.stabilize();
+ }, 500);
+ }
+
+ /**
+ * Sends the mode to the repeater, checking to see if we're modifying our mode.
+ */
+ private dispatchModeToRepeater(mode?: LGraphEventMode | -99 | null) {
+ if (mode != null) {
+ const propertyVal = this.properties?.[MODE_TO_PROPERTY.get(mode) || ""];
+ const newMode = OPTION_TO_MODE.get(propertyVal as string);
+ mode = (newMode !== null ? newMode : mode) as LGraphEventMode | -99;
+ if (mode !== null && mode !== MODE_NOTHING) {
+ if (this.outputs?.length) {
+ const outputNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
+ for (const outputNode of outputNodes) {
+ changeModeOfNodes(outputNode, mode);
+ wait(16).then(() => {
+ outputNode.setDirtyCanvas(true, true);
+ });
+ }
+ }
+ }
+ }
+ }
+
+ override getHelp() {
+ return `
+
+ This node will relay its input nodes' modes (Mute, Bypass, or Active) to a connected
+ ${stripRgthree(NodeTypesString.NODE_MODE_REPEATER)} (which would then repeat that mode
+ change to all of its inputs).
+
+
+
+ When all connected input nodes are muted, the relay will set a connected repeater to
+ mute (by default).
+
+
+ When all connected input nodes are bypassed, the relay will set a connected repeater to
+ bypass (by default).
+
+
+ When any connected input nodes are active, the relay will set a connected repeater to
+ active (by default).
+
+
+ If no inputs are connected, the relay will set a connected repeater to its mode when
+ its own mode is changed . Note , if any inputs are connected, then the above
+ will occur and the Relay's mode does not matter.
+
+
+
+ Note, you can change which signals get sent on the above in the Properties.
+ For instance, you could configure an inverse relay which will send a MUTE when any of its
+ inputs are active (instead of sending an ACTIVE signal), and send an ACTIVE signal when all
+ of its inputs are muted (instead of sending a MUTE signal), etc.
+
+ `;
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.NodeModeRepeaterHelper",
+ registerCustomNodes() {
+ addConnectionLayoutSupport(NodeModeRelay, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+
+ LiteGraph.registerNodeType(NodeModeRelay.type, NodeModeRelay);
+ NodeModeRelay.category = NodeModeRelay._category;
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts
new file mode 100644
index 0000000000000000000000000000000000000000..452b8d75a52370bc4571211946c5c05c72f1883e
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts
@@ -0,0 +1,216 @@
+import type {
+ INodeInputSlot,
+ INodeOutputSlot,
+ LGraphEventMode,
+ LGraphGroup,
+ LGraphNode,
+ LLink,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {BaseCollectorNode} from "./base_node_collector.js";
+import {NodeTypesString, stripRgthree} from "./constants.js";
+import {
+ PassThroughFollowing,
+ addConnectionLayoutSupport,
+ changeModeOfNodes,
+ getConnectedInputNodesAndFilterPassThroughs,
+ getConnectedOutputNodesAndFilterPassThroughs,
+ getGroupNodes,
+} from "./utils.js";
+
+class NodeModeRepeater extends BaseCollectorNode {
+ override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
+
+ static override type = NodeTypesString.NODE_MODE_REPEATER;
+ static override title = NodeTypesString.NODE_MODE_REPEATER;
+ override comfyClass = NodeTypesString.NODE_MODE_REPEATER;
+
+ private hasRelayInput = false;
+ private hasTogglerOutput = false;
+
+ constructor(title?: string) {
+ super(title);
+ this.onConstructed();
+ }
+
+ override onConstructed(): boolean {
+ this.addOutput("OPT_CONNECTION", "*", {
+ color_on: "#Fc0",
+ color_off: "#a80",
+ });
+
+ return super.onConstructed();
+ }
+
+ override onConnectOutput(
+ outputIndex: number,
+ inputType: string | -1,
+ inputSlot: INodeInputSlot,
+ inputNode: LGraphNode,
+ inputIndex: number,
+ ): boolean {
+ // We can only connect to a a FAST_MUTER or FAST_BYPASSER if we aren't connectged to a relay, since the relay wins.
+ let canConnect = !this.hasRelayInput;
+ canConnect =
+ canConnect && super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
+ // Output can only connect to a FAST MUTER, FAST BYPASSER, NODE_COLLECTOR OR ACTION BUTTON
+ let nextNode = getConnectedOutputNodesAndFilterPassThroughs(this, inputNode)[0] || inputNode;
+ return (
+ canConnect &&
+ [
+ NodeTypesString.FAST_MUTER,
+ NodeTypesString.FAST_BYPASSER,
+ NodeTypesString.NODE_COLLECTOR,
+ NodeTypesString.FAST_ACTIONS_BUTTON,
+ NodeTypesString.REROUTE,
+ NodeTypesString.RANDOM_UNMUTER,
+ ].includes(nextNode.type || "")
+ );
+ }
+
+ override onConnectInput(
+ inputIndex: number,
+ outputType: string | -1,
+ outputSlot: INodeOutputSlot,
+ outputNode: LGraphNode,
+ outputIndex: number,
+ ): boolean {
+ // We can only connect to a a FAST_MUTER or FAST_BYPASSER if we aren't connectged to a relay, since the relay wins.
+ let canConnect = super.onConnectInput?.(
+ inputIndex,
+ outputType,
+ outputSlot,
+ outputNode,
+ outputIndex,
+ );
+ // Output can only connect to a FAST MUTER or FAST BYPASSER
+ let nextNode = getConnectedOutputNodesAndFilterPassThroughs(this, outputNode)[0] || outputNode;
+ const isNextNodeRelay = nextNode.type === NodeTypesString.NODE_MODE_RELAY;
+ return canConnect && (!isNextNodeRelay || !this.hasTogglerOutput);
+ }
+
+ override onConnectionsChange(
+ type: number,
+ slotIndex: number,
+ isConnected: boolean,
+ linkInfo: LLink,
+ ioSlot: INodeOutputSlot | INodeInputSlot,
+ ): void {
+ super.onConnectionsChange(type, slotIndex, isConnected, linkInfo, ioSlot);
+
+ let hasTogglerOutput = false;
+ let hasRelayInput = false;
+
+ const outputNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
+ for (const outputNode of outputNodes) {
+ if (
+ outputNode?.type === NodeTypesString.FAST_MUTER ||
+ outputNode?.type === NodeTypesString.FAST_BYPASSER
+ ) {
+ hasTogglerOutput = true;
+ break;
+ }
+ }
+
+ const inputNodes = getConnectedInputNodesAndFilterPassThroughs(this);
+ for (const [index, inputNode] of inputNodes.entries()) {
+ if (inputNode?.type === NodeTypesString.NODE_MODE_RELAY) {
+ // We can't be connected to a relay if we're connected to a toggler. Something has gone wrong.
+ if (hasTogglerOutput) {
+ console.log(`Can't be connected to a Relay if also output to a toggler.`);
+ this.disconnectInput(index);
+ } else {
+ hasRelayInput = true;
+ if (this.inputs[index]) {
+ this.inputs[index]!.color_on = "#FC0";
+ this.inputs[index]!.color_off = "#a80";
+ }
+ }
+ } else {
+ changeModeOfNodes(inputNode, this.mode);
+ }
+ }
+
+ this.hasTogglerOutput = hasTogglerOutput;
+ this.hasRelayInput = hasRelayInput;
+
+ // If we have a relay input, then we should remove the toggler output, or add it if not.
+ if (this.hasRelayInput) {
+ if (this.outputs[0]) {
+ this.disconnectOutput(0);
+ this.removeOutput(0);
+ }
+ } else if (!this.outputs[0]) {
+ this.addOutput("OPT_CONNECTION", "*", {
+ color_on: "#Fc0",
+ color_off: "#a80",
+ });
+ }
+ }
+
+ /** When a mode change, we want all connected nodes to match except for connected relays. */
+ override onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
+ super.onModeChange(from, to);
+ const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this).filter(
+ (node) => node.type !== NodeTypesString.NODE_MODE_RELAY,
+ );
+ if (linkedNodes.length) {
+ for (const node of linkedNodes) {
+ if (node.type !== NodeTypesString.NODE_MODE_RELAY) {
+ // Use "to" as there may be other getters in the way to access this.mode directly.
+ changeModeOfNodes(node, to);
+ }
+ }
+ } else if (this.graph?._groups?.length) {
+ // No linked nodes.. check if we're in a group.
+ for (const group of this.graph._groups as LGraphGroup[]) {
+ group.recomputeInsideNodes();
+ const groupNodes = getGroupNodes(group);
+ if (groupNodes?.includes(this)) {
+ for (const node of groupNodes) {
+ if (node !== this) {
+ // Use "to" as there may be other getters in the way to access this.mode directly.
+ changeModeOfNodes(node, to);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override getHelp(): string {
+ return `
+
+ When this node's mode (Mute, Bypass, Active) changes, it will "repeat" that mode to all
+ connected input nodes, or, if there are no connected nodes AND it is overlapping a group,
+ "repeat" it's mode to all nodes in that group.
+
+
+
+ Optionally, connect this mode's output to a ${stripRgthree(NodeTypesString.FAST_MUTER)}
+ or ${stripRgthree(NodeTypesString.FAST_BYPASSER)} for a single toggle to quickly
+ mute/bypass all its connected nodes.
+
+
+ Optionally, connect a ${stripRgthree(NodeTypesString.NODE_MODE_RELAY)} to this nodes
+ inputs to have it automatically toggle its mode. If connected, this will always take
+ precedence (and disconnect any connected fast togglers).
+
+
+ `;
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.NodeModeRepeater",
+ registerCustomNodes() {
+ addConnectionLayoutSupport(NodeModeRepeater, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+
+ LiteGraph.registerNodeType(NodeModeRepeater.type, NodeModeRepeater);
+ NodeModeRepeater.category = NodeModeRepeater._category;
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9fb57cfe3682c7939c45b0333d9fcd6188debc46
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts
@@ -0,0 +1,137 @@
+import type {Parser, Node, Tree} from "web-tree-sitter";
+import type {IStringWidget, IWidget} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {Exposed, execute, PyTuple} from "rgthree/common/py_parser.js";
+import {RgthreeBaseVirtualNode} from "./base_node.js";
+import {RgthreeBetterButtonWidget} from "./utils_widgets.js";
+import {NodeTypesString} from "./constants.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+import { changeModeOfNodes, getNodeById } from "./utils.js";
+
+const BUILT_INS = {
+ node: {
+ fn: (query: string | number) => {
+ if (typeof query === "number" || /^\d+(\.\d+)?/.exec(query)) {
+ return new ComfyNodeWrapper(Number(query));
+ }
+ return null;
+ },
+ },
+};
+
+class RgthreePowerConductor extends RgthreeBaseVirtualNode {
+ static override title = NodeTypesString.POWER_CONDUCTOR;
+ static override type = NodeTypesString.POWER_CONDUCTOR;
+ override comfyClass = NodeTypesString.POWER_CONDUCTOR;
+
+ override serialize_widgets = true;
+
+ private codeWidget: IStringWidget;
+ private buttonWidget: RgthreeBetterButtonWidget;
+
+ constructor(title = RgthreePowerConductor.title) {
+ super(title);
+
+ this.codeWidget = ComfyWidgets.STRING(this, "", ["STRING", {multiline: true}], app).widget;
+ this.addCustomWidget(this.codeWidget);
+
+ (this.buttonWidget = new RgthreeBetterButtonWidget("Run", (...args: any[]) => {
+ this.execute();
+ })),
+ this.addCustomWidget(this.buttonWidget);
+
+ this.onConstructed();
+ }
+
+ private execute() {
+ execute(this.codeWidget.value, {}, BUILT_INS);
+ }
+}
+
+const NODE_CLASS = RgthreePowerConductor;
+
+/**
+ * A wrapper around nodes to add helpers and control the exposure of properties and methods.
+ */
+class ComfyNodeWrapper {
+ #id: number;
+
+ constructor(id: number) {
+ this.#id = id;
+ }
+
+ private getNode() {
+ return getNodeById(this.#id)!;
+ }
+
+ @Exposed get id() {
+ return this.getNode().id;
+ }
+
+ @Exposed get title() {
+ return this.getNode().title;
+ }
+ set title(value: string) {
+ this.getNode().title = value;
+ }
+
+ @Exposed get widgets() {
+ return new PyTuple(this.getNode().widgets?.map((w) => new ComfyWidgetWrapper(w as IWidget)));
+ }
+
+ @Exposed get mode() {
+ return this.getNode().mode;
+ }
+
+ @Exposed mute() {
+ changeModeOfNodes(this.getNode(), 2);
+ }
+
+ @Exposed bypass() {
+ changeModeOfNodes(this.getNode(), 4);
+ }
+
+ @Exposed enable() {
+ changeModeOfNodes(this.getNode(), 0);
+ }
+}
+
+/**
+ * A wrapper around widgets to add helpers and control the exposure of properties and methods.
+ */
+class ComfyWidgetWrapper {
+ #widget: IWidget;
+
+ constructor(widget: IWidget) {
+ this.#widget = widget;
+ }
+
+ @Exposed get value() {
+ return this.#widget.value;
+ }
+
+ @Exposed get label() {
+ return this.#widget.label;
+ }
+
+ @Exposed toggle(value?: boolean) {
+ // IF the widget has a "toggle" method, then call it.
+ if (typeof (this.#widget as any)["toggle"] === "function") {
+ (this.#widget as any)["toggle"](value);
+ } else {
+ // Error.
+ }
+ }
+}
+
+/** Register the node. */
+app.registerExtension({
+ name: "rgthree.PowerConductor",
+ registerCustomNodes() {
+ if (CONFIG_SERVICE.getConfigValue("unreleased.power_conductor.enabled")) {
+ NODE_CLASS.setUp();
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts
new file mode 100644
index 0000000000000000000000000000000000000000..557bd5e08d98e061084026ca3dc64677d0bdc302
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts
@@ -0,0 +1,890 @@
+import type {
+ LGraphNode as TLGraphNode,
+ LGraphCanvas,
+ Vector2,
+ IContextMenuValue,
+ IFoundSlot,
+ CanvasMouseEvent,
+ ISerialisedNode,
+ ICustomWidget,
+ CanvasPointerEvent,
+} from "@comfyorg/frontend";
+import type {ComfyApiFormat, ComfyNodeDef} from "typings/comfy.js";
+import type {RgthreeModelInfo} from "typings/rgthree.js";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {rgthree} from "./rgthree.js";
+import {addConnectionLayoutSupport} from "./utils.js";
+import {NodeTypesString} from "./constants.js";
+import {
+ drawInfoIcon,
+ drawNumberWidgetPart,
+ drawRoundedRectangle,
+ drawTogglePart,
+ fitString,
+ isLowQuality,
+} from "./utils_canvas.js";
+import {
+ RgthreeBaseHitAreas,
+ RgthreeBaseWidget,
+ RgthreeBetterButtonWidget,
+ RgthreeDividerWidget,
+} from "./utils_widgets.js";
+import {rgthreeApi} from "rgthree/common/rgthree_api.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+import {showLoraChooser} from "./utils_menu.js";
+import {moveArrayItem, removeArrayItem} from "rgthree/common/shared_utils.js";
+import {RgthreeLoraInfoDialog} from "./dialog_info.js";
+import {LORA_INFO_SERVICE} from "rgthree/common/model_info_service.js";
+// import { RgthreePowerLoraChooserDialog } from "./dialog_power_lora_chooser.js";
+
+const PROP_LABEL_SHOW_STRENGTHS = "Show Strengths";
+const PROP_LABEL_SHOW_STRENGTHS_STATIC = `@${PROP_LABEL_SHOW_STRENGTHS}`;
+const PROP_LABEL_LORA_MATCH = "Match";
+const PROP_LABEL_LORA_MATCH_STATIC = `@${PROP_LABEL_LORA_MATCH}`;
+const PROP_VALUE_SHOW_STRENGTHS_SINGLE = "Single Strength";
+const PROP_VALUE_SHOW_STRENGTHS_SEPARATE = "Separate Model & Clip";
+
+/**
+ * The Power Lora Loader is a super-simply Lora Loader node that can load multiple Loras at once
+ * in an ultra-condensed node allowing fast toggling, and advanced strength setting.
+ */
+class RgthreePowerLoraLoader extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.POWER_LORA_LOADER;
+ static override type = NodeTypesString.POWER_LORA_LOADER;
+ static comfyClass = NodeTypesString.POWER_LORA_LOADER;
+
+ override serialize_widgets = true;
+
+ private logger = rgthree.newLogSession(`[Power Lora Stack]`);
+
+ static [PROP_LABEL_SHOW_STRENGTHS_STATIC] = {
+ type: "combo",
+ values: [PROP_VALUE_SHOW_STRENGTHS_SINGLE, PROP_VALUE_SHOW_STRENGTHS_SEPARATE],
+ };
+
+ static [PROP_LABEL_LORA_MATCH_STATIC] = {
+ type: "string",
+ };
+
+ /** Counts the number of lora widgets. This is used to give unique names. */
+ private loraWidgetsCounter = 0;
+
+ /** Keep track of the spacer, new lora widgets will go before it when it exists. */
+ private widgetButtonSpacer: ICustomWidget | null = null;
+
+ constructor(title = NODE_CLASS.title) {
+ super(title);
+
+ this.properties[PROP_LABEL_SHOW_STRENGTHS] = PROP_VALUE_SHOW_STRENGTHS_SINGLE;
+ this.properties[PROP_LABEL_LORA_MATCH] = "";
+
+ // Prefetch loras list.
+ rgthreeApi.getLoras();
+
+ // [🤮] If ComfyUI is loading from API JSON it doesn't pass us the actual information at all
+ // (like, in a `configure` call) and tries to set the widget data on its own. Unfortunately,
+ // since Power Lora Loader has dynamic widgets, this fails on ComfyUI's side. We can do so after
+ // the fact but, unfortuntely, we need to do it after a timeout since we don't have any
+ // information at this point to be able to tell what data we need (like, even the node id, let
+ // alone the actual data).
+ if (rgthree.loadingApiJson) {
+ const fullApiJson = rgthree.loadingApiJson;
+ setTimeout(() => {
+ this.configureFromApiJson(fullApiJson);
+ }, 16);
+ }
+ }
+
+ private configureFromApiJson(fullApiJson: ComfyApiFormat) {
+ if (this.id == null) {
+ const [n, v] = this.logger.errorParts("Cannot load from API JSON without node id.");
+ console[n]?.(...v);
+ return;
+ }
+ const nodeData =
+ fullApiJson[this.id] || fullApiJson[String(this.id)] || fullApiJson[Number(this.id)];
+ if (nodeData == null) {
+ const [n, v] = this.logger.errorParts(`No node found in API JSON for node id ${this.id}.`);
+ console[n]?.(...v);
+ return;
+ }
+ this.configure({
+ widgets_values: Object.values(nodeData.inputs).filter(
+ (input) => typeof (input as any)?.["lora"] === "string",
+ ),
+ });
+ }
+
+ /**
+ * Handles configuration from a saved workflow by first removing our default widgets that were
+ * added in `onNodeCreated`, letting `super.configure` and do nothing, then create our lora
+ * widgets and, finally, add back in our default widgets.
+ */
+ override configure(
+ info: ISerialisedNode | {widgets_values: ISerialisedNode["widgets_values"]},
+ ): void {
+ while (this.widgets?.length) this.removeWidget(0);
+ this.widgetButtonSpacer = null;
+ // Since we may be calling into configure manually for just widgets_values setting (like, from
+ // API JSON) we want to only call the parent class's configure with a real ISerialisedNode data.
+ if ((info as ISerialisedNode).id != null) {
+ super.configure(info as ISerialisedNode);
+ }
+
+ (this as any)._tempWidth = this.size[0];
+ (this as any)._tempHeight = this.size[1];
+ for (const widgetValue of info.widgets_values || []) {
+ if ((widgetValue as PowerLoraLoaderWidgetValue)?.lora !== undefined) {
+ const widget = this.addNewLoraWidget();
+ widget.value = {...(widgetValue as PowerLoraLoaderWidgetValue)};
+ }
+ }
+ this.addNonLoraWidgets();
+ this.size[0] = (this as any)._tempWidth;
+ this.size[1] = Math.max((this as any)._tempHeight, this.computeSize()[1]);
+ }
+
+ /**
+ * Adds the non-lora widgets. If we'll be configured then we remove them and add them back, so
+ * this is really only for newly created nodes in the current session.
+ */
+ override onNodeCreated() {
+ super.onNodeCreated?.();
+ this.addNonLoraWidgets();
+ const computed = this.computeSize();
+ this.size = this.size || [0, 0];
+ this.size[0] = Math.max(this.size[0], computed[0]);
+ this.size[1] = Math.max(this.size[1], computed[1]);
+ this.setDirtyCanvas(true, true);
+ }
+
+ /** Adds a new lora widget in the proper slot. */
+ private addNewLoraWidget(lora?: string) {
+ this.loraWidgetsCounter++;
+ const widget = this.addCustomWidget(
+ new PowerLoraLoaderWidget("lora_" + this.loraWidgetsCounter),
+ ) as PowerLoraLoaderWidget;
+ if (lora) widget.setLora(lora);
+ if (this.widgetButtonSpacer) {
+ moveArrayItem(this.widgets, widget, this.widgets.indexOf(this.widgetButtonSpacer));
+ }
+
+ return widget;
+ }
+
+ /** Adds the non-lora widgets around any lora ones that may be there from configuration. */
+ private addNonLoraWidgets() {
+ moveArrayItem(
+ this.widgets,
+ this.addCustomWidget(new RgthreeDividerWidget({marginTop: 4, marginBottom: 0, thickness: 0})),
+ 0,
+ );
+ moveArrayItem(this.widgets, this.addCustomWidget(new PowerLoraLoaderHeaderWidget()), 1);
+
+ this.widgetButtonSpacer = this.addCustomWidget(
+ new RgthreeDividerWidget({marginTop: 4, marginBottom: 0, thickness: 0}),
+ ) as RgthreeDividerWidget;
+
+ this.addCustomWidget(
+ new RgthreeBetterButtonWidget(
+ "➕ Add Lora",
+ (event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) => {
+ this.showLoraChooser(event, (value: string) => {
+ if (value.includes("Power Lora Chooser")) {
+ // new RgthreePowerLoraChooserDialog().show();
+ } else if (value !== "NONE") {
+ this.addNewLoraWidget(value);
+ const computed = this.computeSize();
+ const tempHeight = (this as any)._tempHeight ?? 15;
+ this.size[1] = Math.max(tempHeight, computed[1]);
+ this.setDirtyCanvas(true, true);
+ }
+ // }, null, ["⚡️ Power Lora Chooser", ...loras]);
+ });
+ return true;
+ },
+ ),
+ );
+ }
+
+ async showLoraChooser(event: CanvasMouseEvent, onChoose: (value: string) => void) {
+ const lorasDetails = await rgthreeApi.getLoras();
+ let loras = lorasDetails.map((l) => l.file);
+ let prefix = "";
+ if (this.properties[PROP_LABEL_LORA_MATCH]) {
+ const rgx = new RegExp(this.properties[PROP_LABEL_LORA_MATCH] as string);
+ loras = loras.filter((l) => l.match(rgx));
+ if (loras[0]) {
+ prefix = loras[0];
+ for (const lora of loras) {
+ let similar = "";
+ let i = 0;
+ while (prefix[i] && prefix[i] === lora[i]) {
+ similar += prefix[i++];
+ }
+ prefix = similar;
+ if (!prefix) break;
+ }
+ if (prefix) {
+ loras = loras.map((l) => l.replace(prefix!, ""));
+ }
+ }
+ }
+ showLoraChooser(
+ event as MouseEvent,
+ (value: IContextMenuValue | string) => {
+ if (typeof value === "string") {
+ onChoose(prefix + value);
+ }
+ this.setDirtyCanvas(true, true);
+ },
+ null,
+ [...loras],
+ );
+ }
+
+ /**
+ * Hacks the `getSlotInPosition` call made from LiteGraph so we can show a custom context menu
+ * for widgets.
+ *
+ * Normally this method, called from LiteGraph's processContextMenu, will only get Inputs or
+ * Outputs. But that's not good enough because we we also want to provide a custom menu when
+ * clicking a widget for this node... so we are left to HACK once again!
+ *
+ * To achieve this:
+ * - Here, in LiteGraph's processContextMenu it asks the clicked node to tell it which input or
+ * output the user clicked on in `getSlotInPosition`
+ * - We check, and if we didn't, then we see if we clicked a widget and, if so, pass back some
+ * data that looks like we clicked an output to fool LiteGraph like a silly child.
+ * - As LiteGraph continues in its `processContextMenu`, it will then immediately call
+ * the clicked node's `getSlotMenuOptions` when `getSlotInPosition` returns data.
+ * - So, just below, we can then give LiteGraph the ContextMenu options we have.
+ *
+ * The only issue is that LiteGraph also checks `input/output.type` to set the ContextMenu title,
+ * so we need to supply that property (and set it to what we want our title). Otherwise, this
+ * should be pretty clean.
+ */
+ override getSlotInPosition(canvasX: number, canvasY: number): any {
+ const slot = super.getSlotInPosition(canvasX, canvasY);
+ // No slot, let's see if it's a widget.
+ if (!slot) {
+ let lastWidget = null;
+ for (const widget of this.widgets) {
+ // If last_y isn't set, something is wrong. Bail.
+ if (!widget.last_y) return;
+ if (canvasY > this.pos[1] + widget.last_y) {
+ lastWidget = widget;
+ continue;
+ }
+ break;
+ }
+ // Only care about lora widget clicks.
+ if (lastWidget?.name?.startsWith("lora_")) {
+ return {widget: lastWidget, output: {type: "LORA WIDGET"}};
+ }
+ }
+ return slot;
+ }
+
+ /**
+ * Working with the overridden `getSlotInPosition` above, this method checks if the passed in
+ * option is actually a widget from it and then hijacks the context menu all together.
+ */
+ override getSlotMenuOptions(slot: IFoundSlot) {
+ // Oddly, LiteGraph doesn't call back into our node with a custom menu (even though it let's us
+ // define a custom menu to begin with... wtf?). So, we'll return null so the default is not
+ // triggered and then we'll just show one ourselves because.. yea.
+ if (slot?.widget?.name?.startsWith("lora_")) {
+ const widget = slot.widget as PowerLoraLoaderWidget;
+ const index = this.widgets.indexOf(widget);
+ const canMoveUp = !!this.widgets[index - 1]?.name?.startsWith("lora_");
+ const canMoveDown = !!this.widgets[index + 1]?.name?.startsWith("lora_");
+ const menuItems: (IContextMenuValue | null)[] = [
+ {
+ content: `ℹ️ Show Info`,
+ callback: () => {
+ widget.showLoraInfoDialog();
+ },
+ },
+ null, // Divider
+ {
+ content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
+ callback: () => {
+ widget.value.on = !widget.value.on;
+ },
+ },
+ {
+ content: `⬆️ Move Up`,
+ disabled: !canMoveUp,
+ callback: () => {
+ moveArrayItem(this.widgets, widget, index - 1);
+ },
+ },
+ {
+ content: `⬇️ Move Down`,
+ disabled: !canMoveDown,
+ callback: () => {
+ moveArrayItem(this.widgets, widget, index + 1);
+ },
+ },
+ {
+ content: `🗑️ Remove`,
+ callback: () => {
+ removeArrayItem(this.widgets, widget);
+ },
+ },
+ ];
+ new LiteGraph.ContextMenu(menuItems, {
+ title: "LORA WIDGET",
+ event: rgthree.lastCanvasMouseEvent!,
+ });
+
+ // [🤮] ComfyUI doesn't have a possible return type as falsy, even though the impl skips the
+ // menu when the return is falsy. Casting as any.
+ return undefined as any;
+ }
+ return this.defaultGetSlotMenuOptions(slot);
+ }
+
+ /**
+ * When `refreshComboInNode` is called from ComfyUI, then we'll kick off a fresh loras fetch.
+ */
+ override refreshComboInNode(defs: any) {
+ rgthreeApi.getLoras(true);
+ }
+
+ /**
+ * Returns true if there are any Lora Widgets. Useful for widgets to ask as they render.
+ */
+ hasLoraWidgets() {
+ return !!this.widgets?.find((w) => w.name?.startsWith("lora_"));
+ }
+
+ /**
+ * This will return true when all lora widgets are on, false when all are off, or null if it's
+ * mixed.
+ */
+ allLorasState() {
+ let allOn = true;
+ let allOff = true;
+ for (const widget of this.widgets) {
+ if (widget.name?.startsWith("lora_")) {
+ const on = (widget.value as any)?.on;
+ allOn = allOn && on === true;
+ allOff = allOff && on === false;
+ if (!allOn && !allOff) {
+ return null;
+ }
+ }
+ }
+ return allOn && this.widgets?.length ? true : false;
+ }
+
+ /**
+ * Toggles all the loras on or off.
+ */
+ toggleAllLoras() {
+ const allOn = this.allLorasState();
+ const toggledTo = !allOn ? true : false;
+ for (const widget of this.widgets) {
+ if (widget.name?.startsWith("lora_") && (widget.value as any)?.on != null) {
+ (widget.value as any).on = toggledTo;
+ }
+ }
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ addConnectionLayoutSupport(NODE_CLASS, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ setTimeout(() => {
+ NODE_CLASS.category = comfyClass.category;
+ });
+ }
+
+ override getHelp() {
+ return `
+
+ The ${this.type!.replace("(rgthree)", "")} is a powerful node that condenses 100s of pixels
+ of functionality in a single, dynamic node that allows you to add loras, change strengths,
+ and quickly toggle on/off all without taking up half your screen.
+
+
+
+ Add as many Lora's as you would like by clicking the "+ Add Lora" button.
+ There's no real limit!
+
+
+ Right-click on a Lora widget for special options to move the lora up or down
+ (no image affect, only presentational), toggle it on/off, or delete the row all together.
+
+
+
+ Properties. You can change the following properties (by right-clicking
+ on the node, and select "Properties" or "Properties Panel" from the menu):
+
+
+
+ ${PROP_LABEL_SHOW_STRENGTHS} - Change between showing a single, simple
+ strength (which will be used for both model and clip), or a more advanced view with
+ both model and clip strengths being modifiable.
+
+
+
+ `;
+ }
+}
+
+/**
+ * The PowerLoraLoaderHeaderWidget that renders a toggle all switch, as well as some title info
+ * (more necessary for the double model & clip strengths to label them).
+ */
+class PowerLoraLoaderHeaderWidget extends RgthreeBaseWidget<{type: string}> {
+ override value = {type: "PowerLoraLoaderHeaderWidget"};
+ override readonly type = "custom";
+
+ protected override hitAreas: RgthreeBaseHitAreas<"toggle"> = {
+ toggle: {bounds: [0, 0] as Vector2, onDown: this.onToggleDown},
+ };
+
+ private showModelAndClip: boolean | null = null;
+
+ constructor(name: string = "PowerLoraLoaderHeaderWidget") {
+ super(name);
+ }
+
+ draw(
+ ctx: CanvasRenderingContext2D,
+ node: RgthreePowerLoraLoader,
+ w: number,
+ posY: number,
+ height: number,
+ ) {
+ if (!node.hasLoraWidgets()) {
+ return;
+ }
+ // Since draw is the loop that runs, this is where we'll check the property state (rather than
+ // expect the node to tell us it's state etc).
+ this.showModelAndClip =
+ node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE;
+ const margin = 10;
+ const innerMargin = margin * 0.33;
+ const lowQuality = isLowQuality();
+ const allLoraState = node.allLorasState();
+
+ // Move slightly down. We don't have a border and this feels a bit nicer.
+ posY += 2;
+ const midY = posY + height * 0.5;
+ let posX = 10;
+ ctx.save();
+ this.hitAreas.toggle.bounds = drawTogglePart(ctx, {posX, posY, height, value: allLoraState});
+
+ if (!lowQuality) {
+ posX += this.hitAreas.toggle.bounds[1] + innerMargin;
+
+ ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
+ ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+ ctx.fillText("Toggle All", posX, midY);
+
+ let rposX = node.size[0] - margin - innerMargin - innerMargin;
+ ctx.textAlign = "center";
+ ctx.fillText(
+ this.showModelAndClip ? "Clip" : "Strength",
+ rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2,
+ midY,
+ );
+ if (this.showModelAndClip) {
+ rposX = rposX - drawNumberWidgetPart.WIDTH_TOTAL - innerMargin * 2;
+ ctx.fillText("Model", rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, midY);
+ }
+ }
+ ctx.restore();
+ }
+
+ /**
+ * Handles a pointer down on the toggle's defined hit area.
+ */
+ onToggleDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ (node as RgthreePowerLoraLoader).toggleAllLoras();
+ this.cancelMouseDown();
+ return true;
+ }
+}
+
+const DEFAULT_LORA_WIDGET_DATA: PowerLoraLoaderWidgetValue = {
+ on: true,
+ lora: null as string | null,
+ strength: 1,
+ strengthTwo: null as number | null,
+};
+
+type PowerLoraLoaderWidgetValue = {
+ on: boolean;
+ lora: string | null;
+ strength: number;
+ strengthTwo: number | null;
+};
+
+/**
+ * The PowerLoaderWidget that combines several custom drawing and functionality in a single row.
+ */
+class PowerLoraLoaderWidget extends RgthreeBaseWidget {
+ override readonly type = "custom";
+
+ /** Whether the strength has changed with mouse move (to cancel mouse up). */
+ private haveMouseMovedStrength = false;
+ private loraInfoPromise: Promise | null = null;
+ private loraInfo: RgthreeModelInfo | null = null;
+
+ private showModelAndClip: boolean | null = null;
+
+ protected override hitAreas: RgthreeBaseHitAreas<
+ | "toggle"
+ | "lora"
+ | "info"
+ | "strengthDec"
+ | "strengthVal"
+ | "strengthInc"
+ | "strengthAny"
+ | "strengthTwoDec"
+ | "strengthTwoVal"
+ | "strengthTwoInc"
+ | "strengthTwoAny"
+ > = {
+ toggle: {bounds: [0, 0] as Vector2, onDown: this.onToggleDown},
+ lora: {bounds: [0, 0] as Vector2, onClick: this.onLoraClick},
+ info: {bounds: [0, 0] as Vector2, onDown: this.onInfoDown},
+
+ strengthDec: {bounds: [0, 0] as Vector2, onClick: this.onStrengthDecDown},
+ strengthVal: {bounds: [0, 0] as Vector2, onClick: this.onStrengthValUp},
+ strengthInc: {bounds: [0, 0] as Vector2, onClick: this.onStrengthIncDown},
+ strengthAny: {bounds: [0, 0] as Vector2, onMove: this.onStrengthAnyMove},
+
+ strengthTwoDec: {bounds: [0, 0] as Vector2, onClick: this.onStrengthTwoDecDown},
+ strengthTwoVal: {bounds: [0, 0] as Vector2, onClick: this.onStrengthTwoValUp},
+ strengthTwoInc: {bounds: [0, 0] as Vector2, onClick: this.onStrengthTwoIncDown},
+ strengthTwoAny: {bounds: [0, 0] as Vector2, onMove: this.onStrengthTwoAnyMove},
+ };
+
+ constructor(name: string) {
+ super(name);
+ }
+
+ private _value = {
+ on: true,
+ lora: null as string | null,
+ strength: 1,
+ strengthTwo: null as number | null,
+ };
+
+ set value(v) {
+ this._value = v;
+ // In case widgets are messed up, we can correct course here.
+ if (typeof this._value !== "object") {
+ this._value = {...DEFAULT_LORA_WIDGET_DATA};
+ if (this.showModelAndClip) {
+ this._value.strengthTwo = this._value.strength;
+ }
+ }
+ this.getLoraInfo();
+ }
+
+ get value() {
+ return this._value;
+ }
+
+ setLora(lora: string) {
+ this._value.lora = lora;
+ this.getLoraInfo();
+ }
+
+ /** Draws our widget with a toggle, lora selector, and number selector all in a single row. */
+ draw(ctx: CanvasRenderingContext2D, node: TLGraphNode, w: number, posY: number, height: number) {
+ // Since draw is the loop that runs, this is where we'll check the property state (rather than
+ // expect the node to tell us it's state etc).
+ let currentShowModelAndClip =
+ node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE;
+ if (this.showModelAndClip !== currentShowModelAndClip) {
+ let oldShowModelAndClip = this.showModelAndClip;
+ this.showModelAndClip = currentShowModelAndClip;
+ if (this.showModelAndClip) {
+ // If we're setting show both AND we're not null, then re-set to the current strength.
+ if (oldShowModelAndClip != null) {
+ this.value.strengthTwo = this.value.strength ?? 1;
+ }
+ } else {
+ this.value.strengthTwo = null;
+ this.hitAreas.strengthTwoDec.bounds = [0, -1];
+ this.hitAreas.strengthTwoVal.bounds = [0, -1];
+ this.hitAreas.strengthTwoInc.bounds = [0, -1];
+ this.hitAreas.strengthTwoAny.bounds = [0, -1];
+ }
+ }
+
+ ctx.save();
+ const margin = 10;
+ const innerMargin = margin * 0.33;
+ const lowQuality = isLowQuality();
+ const midY = posY + height * 0.5;
+
+ // We'll move posX along as we draw things.
+ let posX = margin;
+
+ // Draw the background.
+ drawRoundedRectangle(ctx, {pos: [posX, posY], size: [node.size[0] - margin * 2, height]});
+
+ // Draw the toggle
+ this.hitAreas.toggle.bounds = drawTogglePart(ctx, {posX, posY, height, value: this.value.on});
+ posX += this.hitAreas.toggle.bounds[1] + innerMargin;
+
+ // If low quality, then we're done rendering.
+ if (lowQuality) {
+ ctx.restore();
+ return;
+ }
+
+ // If we're not toggled on, then make everything after faded.
+ if (!this.value.on) {
+ ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
+ }
+
+ ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
+
+ // Now, we draw the strength number part on the right, so we know the width of it to draw the
+ // lora label as flexible.
+ let rposX = node.size[0] - margin - innerMargin - innerMargin;
+
+ const strengthValue = this.showModelAndClip
+ ? (this.value.strengthTwo ?? 1)
+ : (this.value.strength ?? 1);
+
+ let textColor: string | undefined = undefined;
+ if (this.loraInfo?.strengthMax != null && strengthValue > this.loraInfo?.strengthMax) {
+ textColor = "#c66";
+ } else if (this.loraInfo?.strengthMin != null && strengthValue < this.loraInfo?.strengthMin) {
+ textColor = "#c66";
+ }
+
+ const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, {
+ posX: node.size[0] - margin - innerMargin - innerMargin,
+ posY,
+ height,
+ value: strengthValue,
+ direction: -1,
+ textColor,
+ });
+
+ this.hitAreas.strengthDec.bounds = leftArrow;
+ this.hitAreas.strengthVal.bounds = text;
+ this.hitAreas.strengthInc.bounds = rightArrow;
+ this.hitAreas.strengthAny.bounds = [leftArrow[0], rightArrow[0] + rightArrow[1] - leftArrow[0]];
+
+ rposX = leftArrow[0] - innerMargin;
+
+ if (this.showModelAndClip) {
+ rposX -= innerMargin;
+ // If we're showing both, then the rightmost we just drew is our "strengthTwo", so reset and
+ // then draw our model ("strength" one) to the left.
+ this.hitAreas.strengthTwoDec.bounds = this.hitAreas.strengthDec.bounds;
+ this.hitAreas.strengthTwoVal.bounds = this.hitAreas.strengthVal.bounds;
+ this.hitAreas.strengthTwoInc.bounds = this.hitAreas.strengthInc.bounds;
+ this.hitAreas.strengthTwoAny.bounds = this.hitAreas.strengthAny.bounds;
+
+ let textColor: string | undefined = undefined;
+ if (this.loraInfo?.strengthMax != null && this.value.strength > this.loraInfo?.strengthMax) {
+ textColor = "#c66";
+ } else if (
+ this.loraInfo?.strengthMin != null &&
+ this.value.strength < this.loraInfo?.strengthMin
+ ) {
+ textColor = "#c66";
+ }
+ const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, {
+ posX: rposX,
+ posY,
+ height,
+ value: this.value.strength ?? 1,
+ direction: -1,
+ textColor,
+ });
+ this.hitAreas.strengthDec.bounds = leftArrow;
+ this.hitAreas.strengthVal.bounds = text;
+ this.hitAreas.strengthInc.bounds = rightArrow;
+ this.hitAreas.strengthAny.bounds = [
+ leftArrow[0],
+ rightArrow[0] + rightArrow[1] - leftArrow[0],
+ ];
+ rposX = leftArrow[0] - innerMargin;
+ }
+
+ const infoIconSize = height * 0.66;
+ const infoWidth = infoIconSize + innerMargin + innerMargin;
+ // Draw an info emoji; if checks if it's enabled (to quickly turn it on or off)
+ if (CONFIG_SERVICE.getConfigValue("nodes.power_lora_loader.show_info_badge")) {
+ rposX -= innerMargin;
+ drawInfoIcon(
+ ctx,
+ rposX - infoIconSize,
+ posY + (height - infoIconSize) / 2,
+ infoIconSize,
+ this.loraInfo?.raw?.civitai ? "FILLED" : this.loraInfo?.hasInfoFile ? "OUTLINED" : "GRAYED",
+ );
+ // ctx.fillText('ℹ', posX, midY);
+ (this.hitAreas as any).info.bounds = [rposX - infoIconSize, infoWidth];
+ rposX = rposX - infoIconSize - innerMargin;
+ } else {
+ (this.hitAreas as any).info.bounds = [0, 0];
+ }
+
+ // Draw lora label
+ const loraWidth = rposX - posX;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+ const loraLabel = String(this.value?.lora || "None");
+ ctx.fillText(fitString(ctx, loraLabel, loraWidth), posX, midY);
+
+ this.hitAreas.lora.bounds = [posX, loraWidth];
+ posX += loraWidth + innerMargin;
+
+ ctx.globalAlpha = app.canvas.editor_alpha;
+ ctx.restore();
+ }
+
+ override serializeValue(
+ node: TLGraphNode,
+ index: number,
+ ): PowerLoraLoaderWidgetValue | Promise {
+ const v = {...this.value};
+ // Never send the second value to the backend if we're not showing it, otherwise, let's just
+ // make sure it's not null.
+ if (!this.showModelAndClip) {
+ delete (v as any).strengthTwo;
+ } else {
+ this.value.strengthTwo = this.value.strengthTwo ?? 1;
+ v.strengthTwo = this.value.strengthTwo;
+ }
+ return v;
+ }
+
+ onToggleDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.value.on = !this.value.on;
+ this.cancelMouseDown(); // Clear the down since we handle it.
+ return true;
+ }
+
+ onInfoDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.showLoraInfoDialog();
+ }
+
+ onLoraClick(event: CanvasMouseEvent, pos: Vector2, node: RgthreePowerLoraLoader) {
+ node.showLoraChooser(event, (value: string) => {
+ this.value.lora = value;
+ this.loraInfo = null;
+ this.getLoraInfo();
+ });
+ this.cancelMouseDown();
+ }
+
+ onStrengthDecDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.stepStrength(-1, false);
+ }
+ onStrengthIncDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.stepStrength(1, false);
+ }
+ onStrengthTwoDecDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.stepStrength(-1, true);
+ }
+ onStrengthTwoIncDown(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.stepStrength(1, true);
+ }
+
+ onStrengthAnyMove(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.doOnStrengthAnyMove(event, false);
+ }
+
+ onStrengthTwoAnyMove(event: CanvasMouseEvent, pos: Vector2, node: TLGraphNode) {
+ this.doOnStrengthAnyMove(event, true);
+ }
+
+ private doOnStrengthAnyMove(event: CanvasMouseEvent, isTwo = false) {
+ if (event.deltaX) {
+ let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength";
+ this.haveMouseMovedStrength = true;
+ this.value[prop] = (this.value[prop] ?? 1) + event.deltaX * 0.05;
+ }
+ }
+
+ onStrengthValUp(event: CanvasPointerEvent, pos: Vector2, node: TLGraphNode) {
+ this.doOnStrengthValUp(event, false);
+ }
+
+ onStrengthTwoValUp(event: CanvasPointerEvent, pos: Vector2, node: TLGraphNode) {
+ this.doOnStrengthValUp(event, true);
+ }
+
+ private doOnStrengthValUp(event: CanvasPointerEvent, isTwo = false) {
+ if (this.haveMouseMovedStrength) return;
+ let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength";
+ const canvas = app.canvas as LGraphCanvas;
+ canvas.prompt("Value", this.value[prop], (v: string) => (this.value[prop] = Number(v)), event);
+ }
+
+ override onMouseUp(event: CanvasPointerEvent, pos: Vector2, node: TLGraphNode): boolean | void {
+ super.onMouseUp(event, pos, node);
+ this.haveMouseMovedStrength = false;
+ }
+
+ showLoraInfoDialog() {
+ if (!this.value.lora || this.value.lora === "None") {
+ return;
+ }
+ const infoDialog = new RgthreeLoraInfoDialog(this.value.lora).show();
+ infoDialog.addEventListener("close", ((e: CustomEvent<{dirty: boolean}>) => {
+ if (e.detail.dirty) {
+ this.getLoraInfo(true);
+ }
+ }) as EventListener);
+ }
+
+ private stepStrength(direction: -1 | 1, isTwo = false) {
+ let step = 0.05;
+ let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength";
+ let strength = (this.value[prop] ?? 1) + step * direction;
+ this.value[prop] = Math.round(strength * 100) / 100;
+ }
+
+ private getLoraInfo(force = false) {
+ if (!this.loraInfoPromise || force == true) {
+ let promise;
+ if (this.value.lora && this.value.lora != "None") {
+ promise = LORA_INFO_SERVICE.getInfo(this.value.lora, force, true);
+ } else {
+ promise = Promise.resolve(null);
+ }
+ this.loraInfoPromise = promise.then((v) => (this.loraInfo = v));
+ }
+ return this.loraInfoPromise;
+ }
+}
+
+/** An uniformed name reference to the node class. */
+const NODE_CLASS = RgthreePowerLoraLoader;
+
+/** Register the node. */
+app.registerExtension({
+ name: "rgthree.PowerLoraLoader",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === NODE_CLASS.type) {
+ NODE_CLASS.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a4e4847057ca713ea3704787f9f9c6c5ff5344d3
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts
@@ -0,0 +1,254 @@
+import type {
+ IWidget,
+ INodeInputSlot,
+ LGraphCanvas as TLGraphCanvas,
+ LGraphNodeConstructor,
+ IContextMenuValue,
+ INodeOutputSlot,
+ ISlotType,
+ ISerialisedNode,
+ LLink,
+ IBaseWidget,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {moveArrayItem} from "rgthree/common/shared_utils.js";
+
+const PROPERTY_HIDE_TYPE_SELECTOR = "hideTypeSelector";
+const PRIMITIVES = {
+ STRING: "STRING",
+ // "STRING (multiline)": "STRING",
+ INT: "INT",
+ FLOAT: "FLOAT",
+ BOOLEAN: "BOOLEAN",
+};
+
+class RgthreePowerPrimitive extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.POWER_PRIMITIVE;
+ static override type = NodeTypesString.POWER_PRIMITIVE;
+ static comfyClass = NodeTypesString.POWER_PRIMITIVE;
+
+ private outputTypeWidget!: IWidget;
+ private valueWidget!: IWidget;
+ private typeState: string = '';
+
+ static "@hideTypeSelector" = {type: "boolean"};
+
+ override properties!: RgthreeBaseServerNode["properties"] & {
+ [PROPERTY_HIDE_TYPE_SELECTOR]: boolean;
+ };
+
+ constructor(title = NODE_CLASS.title) {
+ super(title);
+ this.properties[PROPERTY_HIDE_TYPE_SELECTOR] = false;
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
+ }
+
+ /**
+ * Adds the non-lora widgets. If we'll be configured then we remove them and add them back, so
+ * this is really only for newly created nodes in the current session.
+ */
+ override onNodeCreated() {
+ super.onNodeCreated?.();
+ this.addInitialWidgets();
+ }
+
+ /**
+ * Ensures to set the type widget whenever we configure.
+ */
+ override configure(info: ISerialisedNode): void {
+ super.configure(info);
+ // Update BOOL to BOOLEAN due to a bug using BOOL instead of BOOLEAN.
+ if (this.outputTypeWidget.value === 'BOOL') {
+ this.outputTypeWidget.value = 'BOOLEAN';
+ }
+ setTimeout(() => {
+ this.setTypedData();
+ });
+ }
+
+ /**
+ * Adds menu options for the node: quick toggle to show/hide the first widget, and a menu-option
+ * to change the type (for easier changing when hiding the first widget).
+ */
+ override getExtraMenuOptions(
+ canvas: TLGraphCanvas,
+ options: (IContextMenuValue | null)[],
+ ) {
+ const that = this;
+ super.getExtraMenuOptions(canvas, options);
+ const isHidden = !!this.properties[PROPERTY_HIDE_TYPE_SELECTOR];
+
+ const menuItems = [
+ {
+ content: `${isHidden ? "Show" : "Hide"} Type Selector Widget`,
+ callback: (...args: any[]) => {
+ this.setProperty(
+ PROPERTY_HIDE_TYPE_SELECTOR,
+ !this.properties[PROPERTY_HIDE_TYPE_SELECTOR],
+ );
+ },
+ },
+ {
+ content: `Set type`,
+ submenu: {
+ options: Object.keys(PRIMITIVES),
+ callback(value: any, ...args: any[]) {
+ that.outputTypeWidget.value = value;
+ that.setTypedData();
+ },
+ },
+ },
+ ];
+
+ options.splice(0, 0, ...menuItems, null);
+ return [];
+ }
+
+ private addInitialWidgets() {
+ if (!this.outputTypeWidget) {
+ this.outputTypeWidget = this.addWidget(
+ "combo",
+ "type",
+ "STRING",
+ (...args) => {
+ this.setTypedData();
+ },
+ {
+ values: Object.keys(PRIMITIVES),
+ },
+ ) as IWidget;
+ this.outputTypeWidget.hidden = this.properties[PROPERTY_HIDE_TYPE_SELECTOR];
+ }
+ this.setTypedData();
+ }
+
+ /**
+ * Sets the correct inputs, outputs, and widgets for the designated type (with the
+ * `outputTypeWidget`) being the source of truth.
+ */
+ private setTypedData() {
+ const name = "value";
+ const type = this.outputTypeWidget.value as string;
+ const linked = !!this.inputs?.[0]?.link;
+ const newTypeState = `${type}|${linked}`;
+ if (this.typeState == newTypeState) return;
+ this.typeState = newTypeState;
+
+ let value = this.valueWidget?.value ?? null;
+ let newWidget: IWidget | null= null;
+ // If we're linked, then set the UI to an empty string widget input, since the ComfyUI is rather
+ // confusing by showing a value that is not the actual value used (from the input).
+ if (linked) {
+ newWidget = ComfyWidgets["STRING"](this, name, ["STRING"], app).widget;
+ newWidget.value = "";
+ } else if (type == "STRING") {
+ newWidget = ComfyWidgets["STRING"](this, name, ["STRING", {multiline: true}], app).widget;
+ newWidget.value = value ? "" : String(value);
+ } else if (type === "INT" || type === "FLOAT") {
+ const isFloat = type === "FLOAT";
+ newWidget = this.addWidget("number", name, value ?? 1 as any, undefined, {
+ precision: isFloat ? 1 : 0,
+ step2: isFloat ? 0.1 : 0,
+ }) as IWidget;
+ value = Number(value);
+ value = value == null || isNaN(value) ? 0 : value;
+ newWidget.value = value;
+ } else if (type === "BOOLEAN") {
+ newWidget = this.addWidget("toggle", name, !!(value ?? true), undefined, {
+ on: "true",
+ off: "false",
+ }) as IWidget;
+ if (typeof value === "string") {
+ value = !["false", "null", "None", "", "0"].includes(value.toLowerCase());
+ }
+ newWidget.value = !!value;
+ }
+ if (newWidget == null) {
+ throw new Error(`Unsupported type "${type}".`);
+ }
+
+ if (this.valueWidget) {
+ this.replaceWidget(this.valueWidget, newWidget);
+ } else {
+ if (!this.widgets.includes(newWidget)) {
+ this.addCustomWidget(newWidget);
+ }
+ moveArrayItem(this.widgets, newWidget, 1);
+ }
+ this.valueWidget = newWidget;
+
+ // Set the input data.
+ if (!this.inputs?.length) {
+ this.addInput("value", "*", {widget: this.valueWidget as any});
+ } else {
+ this.inputs[0]!.widget = this.valueWidget as any;
+ }
+
+ // Set the output data.
+ const output = this.outputs[0]!;
+ const outputLabel = output.label === "*" || output.label === output.type ? null : output.label;
+ output.type = type;
+ output.label = outputLabel || output.type;
+ }
+
+ /**
+ * Sets the correct typed data when we change any connections (really care about
+ * onnecting/disconnecting the value input.)
+ */
+ override onConnectionsChange(
+ type: ISlotType,
+ index: number,
+ isConnected: boolean,
+ link_info: LLink | null | undefined,
+ inputOrOutput: INodeInputSlot | INodeOutputSlot,
+ ): void {
+ super.onConnectionsChange?.apply(this, [...arguments] as any);
+ if (this.inputs.includes(inputOrOutput as INodeInputSlot)) {
+ this.setTypedData();
+ }
+ }
+
+ /**
+ * Sets the correct output type widget state when our `PROPERTY_HIDE_TYPE_SELECTOR` changes.
+ */
+ override onPropertyChanged(name: string, value: unknown, prev_value?: unknown): boolean {
+ if (name === PROPERTY_HIDE_TYPE_SELECTOR) {
+ if (!this.outputTypeWidget) {
+ return true;
+ }
+ this.outputTypeWidget.hidden = this.properties[PROPERTY_HIDE_TYPE_SELECTOR];
+ if (this.outputTypeWidget.hidden) {
+ this.outputTypeWidget.computeLayoutSize = () => ({
+ minHeight: 0,
+ minWidth: 0,
+ maxHeight: 0,
+ maxWidth: 0,
+ });
+ } else {
+ this.outputTypeWidget.computeLayoutSize = undefined;
+ }
+ }
+ return true;
+ }
+}
+
+/** An uniformed name reference to the node class. */
+const NODE_CLASS = RgthreePowerPrimitive;
+
+/** Register the node. */
+app.registerExtension({
+ name: "rgthree.PowerPrimitive",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === NODE_CLASS.type) {
+ NODE_CLASS.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6eac6d22fea50c21cda72cf03424836ce8dd2496
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts
@@ -0,0 +1,48 @@
+import type {LGraphNode, LGraphNodeConstructor} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {addConnectionLayoutSupport} from "./utils.js";
+import {PowerPrompt} from "./base_power_prompt.js";
+import {NodeTypesString} from "./constants.js";
+
+let nodeData: ComfyNodeDef | null = null;
+app.registerExtension({
+ name: "rgthree.PowerPrompt",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, passedNodeData: ComfyNodeDef) {
+ if (passedNodeData.name.includes("Power Prompt") && passedNodeData.name.includes("rgthree")) {
+ nodeData = passedNodeData;
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
+ (this as any).powerPrompt = new PowerPrompt(this as LGraphNode, passedNodeData);
+ };
+ addConnectionLayoutSupport(nodeType as LGraphNodeConstructor, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ }
+ },
+ async loadedGraphNode(node: LGraphNode) {
+ if (node.type === NodeTypesString.POWER_PROMPT) {
+ setTimeout(() => {
+ // If the first output is STRING, then it's the text output from the initial launch.
+ // Let's port it to the new
+ if (node.outputs[0]!.type === "STRING") {
+ if (node.outputs[0]!.links) {
+ node.outputs[3]!.links = node.outputs[3]!.links || [];
+ for (const link of node.outputs[0]!.links) {
+ node.outputs[3]!.links.push(link);
+ (node.graph || app.graph).links[link]!.origin_slot = 3;
+ }
+ node.outputs[0]!.links = null;
+ }
+ node.outputs[0]!.type = nodeData!.output![0] as string;
+ node.outputs[0]!.name = nodeData!.output_name![0] || (node.outputs[0]!.type as string);
+ node.outputs[0]!.color_on = undefined;
+ node.outputs[0]!.color_off = undefined;
+ }
+ }, 50);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..74fc76869f4d64893fc075a840bc622106ec2814
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts
@@ -0,0 +1,461 @@
+import type {
+ LGraphNode,
+ IWidget,
+ Vector2,
+ CanvasMouseEvent,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
+import {debounce} from "rgthree/common/shared_utils.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {RgthreeBaseHitAreas, RgthreeBaseWidget, RgthreeBaseWidgetBounds} from "./utils_widgets.js";
+import {
+ drawPlusIcon,
+ drawRoundedRectangle,
+ drawWidgetButton,
+ isLowQuality,
+ measureText,
+} from "./utils_canvas.js";
+import {rgthree} from "./rgthree.js";
+
+type Vector4 = [number, number, number, number];
+
+const ALPHABET = "abcdefghijklmnopqrstuv".split("");
+
+const OUTPUT_TYPES = ["STRING", "INT", "FLOAT", "BOOLEAN", "*"];
+
+class RgthreePowerPuter extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.POWER_PUTER;
+ static override type = NodeTypesString.POWER_PUTER;
+ static comfyClass = NodeTypesString.POWER_PUTER;
+
+ private outputTypeWidget!: OutputsWidget;
+ private expressionWidget!: IWidget;
+ private stabilizeBound = this.stabilize.bind(this);
+
+ constructor(title = NODE_CLASS.title) {
+ super(title);
+ // Note, configure will add as many as was in the stored workflow automatically.
+ this.addAnyInput(2);
+ this.addInitialWidgets();
+ }
+
+ // /**
+ // * We need to patch in the configure to fix a bug where Power Puter was using BOOL instead of
+ // * BOOLEAN.
+ // */
+ // override configure(info: ISerialisedNode): void {
+ // super.configure(info);
+ // // Update BOOL to BOOLEAN due to a bug using BOOL instead of BOOLEAN.
+ // this.outputTypeWidget
+ // }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
+ }
+
+ override onConnectionsChange(...args: any[]): void {
+ super.onConnectionsChange?.apply(this, [...arguments] as any);
+ this.scheduleStabilize();
+ }
+
+ scheduleStabilize(ms = 64) {
+ return debounce(this.stabilizeBound, ms);
+ }
+
+ stabilize() {
+ removeUnusedInputsFromEnd(this, 1);
+ this.addAnyInput();
+ this.setOutputs();
+ }
+
+ private addInitialWidgets() {
+ if (!this.outputTypeWidget) {
+ this.outputTypeWidget = this.addCustomWidget(
+ new OutputsWidget("outputs", this),
+ ) as OutputsWidget;
+ this.expressionWidget = ComfyWidgets["STRING"](
+ this,
+ "code",
+ ["STRING", {multiline: true}],
+ app,
+ ).widget;
+ }
+ }
+
+ private addAnyInput(num = 1) {
+ for (let i = 0; i < num; i++) {
+ this.addInput(ALPHABET[this.inputs.length]!, "*" as string);
+ }
+ }
+
+ private setOutputs() {
+ const desiredOutputs = this.outputTypeWidget.value.outputs;
+ for (let i = 0; i < Math.max(this.outputs.length, desiredOutputs.length); i++) {
+ const desired = desiredOutputs[i];
+ let output = this.outputs[i];
+ if (!desired && output) {
+ this.disconnectOutput(i);
+ this.removeOutput(i);
+ continue;
+ }
+ output = output || this.addOutput("", "");
+ const outputLabel =
+ output.label === "*" || output.label === output.type ? null : output.label;
+ output.type = String(desired);
+ output.label = outputLabel || output.type;
+ }
+ }
+
+ override getHelp() {
+ return `
+
+ The ${this.type!.replace("(rgthree)", "")} is a powerful and versatile node that opens the
+ door for a wide range of utility by offering mult-line code parsing for output. This node
+ can be used for simple string concatenation, or math operations; to an image dimension or a
+ node's widgets with advanced list comprehension.
+ If you want to output something in your workflow, this is the node to do it.
+
+
+
+
+ Evaluate almost any kind of input and more, and choose your output from INT, FLOAT,
+ STRING, or BOOLEAN.
+
+
+ Connect some nodes and do simply math operations like a + b or
+ ceil(1 / 2).
+
+
+ Or do more advanced things, like input an image, and get the width like
+ a.shape[2].
+
+
+ Even more powerful, you can target nodes in the prompt that's sent to the backend. For
+ instance; if you have a Power Lora Loader node at id #5, and want to get a comma-delimited
+ list of the enabled loras, you could enter
+ ', '.join([v.lora for v in node(5).inputs.values() if 'lora' in v and v.on]).
+
+
+ See more at the rgthree-comfy
+ wiki .
+
+ `;
+ }
+}
+
+/** An uniformed name reference to the node class. */
+const NODE_CLASS = RgthreePowerPuter;
+
+type OutputsWidgetValue = {
+ outputs: string[];
+};
+
+const OUTPUTS_WIDGET_CHIP_HEIGHT = LiteGraph.NODE_WIDGET_HEIGHT - 4;
+const OUTPUTS_WIDGET_CHIP_SPACE = 4;
+
+const OUTPUTS_WIDGET_CHIP_ARROW_WIDTH = 5.5;
+const OUTPUTS_WIDGET_CHIP_ARROW_HEIGHT = 4;
+
+/**
+ * The OutputsWidget is an advanced widget that has a background similar to others, but then a
+ * series of "chips" that correspond to the outputs of the node. The chips are dynamic and wrap to
+ * additional rows as space is needed. Additionally, there is a "+" chip to add more.
+ */
+class OutputsWidget extends RgthreeBaseWidget {
+ override readonly type = "custom";
+ private _value: OutputsWidgetValue = {outputs: ["STRING"]};
+
+ private rows = 1;
+ private neededHeight = LiteGraph.NODE_WIDGET_HEIGHT + 8;
+ private node!: RgthreePowerPuter;
+
+ protected override hitAreas: RgthreeBaseHitAreas<
+ | "add"
+ | "output0"
+ | "output1"
+ | "output2"
+ | "output3"
+ | "output4"
+ | "output5"
+ | "output6"
+ | "output7"
+ | "output8"
+ | "output9"
+ > = {
+ add: {bounds: [0, 0] as Vector2, onClick: this.onAddChipDown},
+ output0: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 0}},
+ output1: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 1}},
+ output2: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 2}},
+ output3: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 3}},
+ output4: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 4}},
+ output5: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 5}},
+ output6: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 6}},
+ output7: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 7}},
+ output8: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 8}},
+ output9: {bounds: [0, 0] as Vector2, onClick: this.onOutputChipDown, data: {index: 9}},
+ };
+
+ constructor(name: string, node: RgthreePowerPuter) {
+ super(name);
+ this.node = node;
+ }
+
+ set value(v: OutputsWidgetValue) {
+ // Handle a string being passed in, as the original Power Puter output widget was a string.
+ let outputs = typeof v === "string" ? [v] : [...v.outputs];
+ // Handle a case where the initial version used "BOOL" instead of "BOOLEAN" incorrectly.
+ outputs = outputs.map((o) => (o === "BOOL" ? "BOOLEAN" : o));
+ this._value.outputs = outputs;
+ }
+
+ get value(): OutputsWidgetValue {
+ return this._value;
+ }
+
+ /** Displays the menu to choose a new output type. */
+ onAddChipDown(
+ event: CanvasMouseEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds: RgthreeBaseWidgetBounds,
+ ) {
+ new LiteGraph.ContextMenu(OUTPUT_TYPES, {
+ event: event,
+ title: "Add an output",
+ className: "rgthree-dark",
+ callback: (value) => {
+ if (isLowQuality()) return;
+ if (typeof value === "string" && OUTPUT_TYPES.includes(value)) {
+ this._value.outputs.push(value);
+ this.node.scheduleStabilize();
+ }
+ },
+ });
+
+ this.cancelMouseDown();
+ return true;
+ }
+
+ /** Displays a context menu tied to an output chip within our widget. */
+ onOutputChipDown(
+ event: CanvasMouseEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds: RgthreeBaseWidgetBounds,
+ ) {
+ const options: Array = [...OUTPUT_TYPES];
+ if (this.value.outputs.length > 1) {
+ options.push(null, "🗑️ Delete");
+ }
+
+ new LiteGraph.ContextMenu(options, {
+ event: event,
+ title: `Edit output #${bounds.data.index + 1}`,
+ className: "rgthree-dark",
+ callback: (value) => {
+ const index = bounds.data.index;
+ if (typeof value !== "string" || value === this._value.outputs[index] || isLowQuality()) {
+ return;
+ }
+ const output = this.node.outputs[index]!;
+ if (value.toLocaleLowerCase().includes("delete")) {
+ if (output.links?.length) {
+ rgthree.showMessage({
+ id: "puter-remove-linked-output",
+ type: "warn",
+ message: "[Power Puter] Removed and disconnected output from that was connected!",
+ timeout: 3000,
+ });
+ this.node.disconnectOutput(index);
+ }
+ this.node.removeOutput(index);
+ this._value.outputs.splice(index, 1);
+ this.node.scheduleStabilize();
+ return;
+ }
+ if (output.links?.length && value !== "*") {
+ rgthree.showMessage({
+ id: "puter-remove-linked-output",
+ type: "warn",
+ message:
+ "[Power Puter] Changing output type of linked output! You should check for" +
+ " compatibility.",
+ timeout: 3000,
+ });
+ }
+ this._value.outputs[index] = value;
+ this.node.scheduleStabilize();
+ },
+ });
+
+ this.cancelMouseDown();
+ return true;
+ }
+
+ /**
+ * Computes the layout size to ensure the height is what we need to accomodate all the chips;
+ * specifically, SPACE on the top, plus the CHIP_HEIGHT + SPACE underneath multiplied by the
+ * number of rows necessary.
+ */
+ computeLayoutSize(node: LGraphNode) {
+ this.neededHeight =
+ OUTPUTS_WIDGET_CHIP_SPACE +
+ (OUTPUTS_WIDGET_CHIP_HEIGHT + OUTPUTS_WIDGET_CHIP_SPACE) * this.rows;
+ return {
+ minHeight: this.neededHeight,
+ maxHeight: this.neededHeight,
+ minWidth: 0, // Need just zero here to be flexible with the width.
+ };
+ }
+
+ /**
+ * Draws our nifty, advanced widget keeping track of the space and wrapping to multiple lines when
+ * more chips than can fit are shown.
+ */
+ draw(ctx: CanvasRenderingContext2D, node: LGraphNode, w: number, posY: number, height: number) {
+ ctx.save();
+ // Despite what `height` was passed in, which is often not our actual height, we'll use oun
+ // calculated needed height.
+ height = this.neededHeight;
+ const margin = 10;
+ const innerMargin = margin * 0.33;
+ const width = node.size[0] - margin * 2;
+ let borderRadius = LiteGraph.NODE_WIDGET_HEIGHT * 0.5;
+ let midY = posY + height * 0.5;
+ let posX = margin;
+ let rposX = node.size[0] - margin;
+
+ // Draw the background encompassing everything, and move our current posX's to create space from
+ // the border.
+
+ drawRoundedRectangle(ctx, {pos: [posX, posY], size: [width, height], borderRadius});
+ posX += innerMargin * 2;
+ rposX -= innerMargin * 2;
+
+ // If low quality, then we're done.
+ if (isLowQuality()) {
+ ctx.restore();
+ return;
+ }
+
+ // Add put our "outputs" label, and a divider line.
+ ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+ ctx.fillText("outputs", posX, midY);
+ posX += measureText(ctx, "outputs") + innerMargin * 2;
+ ctx.stroke(new Path2D(`M ${posX} ${posY} v ${height}`));
+ posX += 1 + innerMargin * 2;
+
+ // Now, prepare our values for the chips; adjust the posY to be within the space, the height to
+ // be that of the chips, and the new midY for the chips.
+ const inititalPosX = posX;
+ posY += OUTPUTS_WIDGET_CHIP_SPACE;
+ height = OUTPUTS_WIDGET_CHIP_HEIGHT;
+ borderRadius = height * 0.5;
+ midY = posY + height / 2;
+ ctx.textAlign = "center";
+ ctx.lineJoin = ctx.lineCap = "round";
+ ctx.fillStyle = ctx.strokeStyle = LiteGraph.WIDGET_TEXT_COLOR;
+ let rows = 1;
+ const values = this.value?.outputs ?? [];
+ const fontSize = ctx.font.match(/(\d+)px/);
+ if (fontSize?.[1]) {
+ ctx.font = ctx.font.replace(fontSize[1], `${Number(fontSize[1]) - 2}`);
+ }
+
+ // Loop over our values, and add them from left to right, measuring the width before placing to
+ // see if we need to wrap the the next line, and updating the hitAreas of the chips.
+ let i = 0;
+ for (i; i < values.length; i++) {
+ const hitArea = this.hitAreas[`output${i}` as "output1"];
+ const isClicking = !!hitArea.wasMouseClickedAndIsOver;
+ hitArea.data.index = i;
+
+ const text = values[i]!;
+ const textWidth = measureText(ctx, text) + innerMargin * 2;
+ const width = textWidth + OUTPUTS_WIDGET_CHIP_ARROW_WIDTH + innerMargin * 5;
+
+ // If our width is too long, then wrap the values and increment our rows.
+ if (posX + width >= rposX) {
+ posX = inititalPosX;
+ posY = posY + height + 4;
+ midY = posY + height / 2;
+ rows++;
+ }
+
+ drawWidgetButton(
+ ctx,
+ {pos: [posX, posY], size: [width, height], borderRadius},
+ null,
+ isClicking,
+ );
+ const startX = posX;
+ posX += innerMargin * 2;
+ const newMidY = midY + (isClicking ? 1 : 0);
+ ctx.fillText(text, posX + textWidth / 2, newMidY);
+ posX += textWidth + innerMargin;
+ const arrow = new Path2D(
+ `M${posX} ${newMidY - OUTPUTS_WIDGET_CHIP_ARROW_HEIGHT / 2}
+ h${OUTPUTS_WIDGET_CHIP_ARROW_WIDTH}
+ l-${OUTPUTS_WIDGET_CHIP_ARROW_WIDTH / 2} ${OUTPUTS_WIDGET_CHIP_ARROW_HEIGHT} z`,
+ );
+ ctx.fill(arrow);
+ ctx.stroke(arrow);
+ posX += OUTPUTS_WIDGET_CHIP_ARROW_WIDTH + innerMargin * 2;
+ hitArea.bounds = [startX, posY, width, height] as Vector4;
+ posX += OUTPUTS_WIDGET_CHIP_SPACE; // Space Between
+ }
+ // Zero out and following hitAreas.
+ for (i; i < 9; i++) {
+ const hitArea = this.hitAreas[`output${i}` as "output1"];
+ if (hitArea.bounds[0] > 0) {
+ hitArea.bounds = [0, 0, 0, 0] as Vector4;
+ }
+ }
+
+ // Draw the add arrow, if we're not at the max.
+ const addHitArea = this.hitAreas["add"];
+ if (this.value.outputs.length < 10) {
+ const isClicking = !!addHitArea.wasMouseClickedAndIsOver;
+ const plusSize = 10;
+ let plusWidth = innerMargin * 2 + plusSize + innerMargin * 2;
+ if (posX + plusWidth >= rposX) {
+ posX = inititalPosX;
+ posY = posY + height + 4;
+ midY = posY + height / 2;
+ rows++;
+ }
+ drawWidgetButton(
+ ctx,
+ {size: [plusWidth, height], pos: [posX, posY], borderRadius},
+ null,
+ isClicking,
+ );
+ drawPlusIcon(ctx, posX + innerMargin * 2, midY + (isClicking ? 1 : 0), plusSize);
+ addHitArea.bounds = [posX, posY, plusWidth, height] as Vector4;
+ } else {
+ addHitArea.bounds = [0, 0, 0, 0] as Vector4;
+ }
+
+ // Set the rows now that we're drawn.
+ this.rows = rows;
+ ctx.restore();
+ }
+}
+
+/** Register the node. */
+app.registerExtension({
+ name: "rgthree.PowerPuter",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === NODE_CLASS.type) {
+ NODE_CLASS.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1f951a94e8a2614878045fd6a72f8725bc0dbfde
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts
@@ -0,0 +1,117 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
+import {NodeTypesString} from "./constants.js";
+import {rgthree} from "./rgthree.js";
+import {changeModeOfNodes, getConnectedInputNodesAndFilterPassThroughs} from "./utils.js";
+
+const MODE_MUTE = 2;
+const MODE_ALWAYS = 0;
+
+class RandomUnmuterNode extends BaseAnyInputConnectedNode {
+ static override exposedActions = ["Mute all", "Enable all"];
+
+ static override type = NodeTypesString.RANDOM_UNMUTER;
+ override comfyClass = NodeTypesString.RANDOM_UNMUTER;
+ static override title = RandomUnmuterNode.type;
+ readonly modeOn = MODE_ALWAYS;
+ readonly modeOff = MODE_MUTE;
+
+ tempEnabledNode: LGraphNode | null = null;
+ processingQueue: boolean = false;
+
+ onQueueBound = this.onQueue.bind(this);
+ onQueueEndBound = this.onQueueEnd.bind(this);
+ onGraphtoPromptBound = this.onGraphtoPrompt.bind(this);
+ onGraphtoPromptEndBound = this.onGraphtoPromptEnd.bind(this);
+
+ constructor(title = RandomUnmuterNode.title) {
+ super(title);
+
+ rgthree.addEventListener("queue", this.onQueueBound);
+ rgthree.addEventListener("queue-end", this.onQueueEndBound);
+ rgthree.addEventListener("graph-to-prompt", this.onGraphtoPromptBound);
+ rgthree.addEventListener("graph-to-prompt-end", this.onGraphtoPromptEndBound);
+ this.onConstructed();
+ }
+
+ override onRemoved() {
+ rgthree.removeEventListener("queue", this.onQueueBound);
+ rgthree.removeEventListener("queue-end", this.onQueueEndBound);
+ rgthree.removeEventListener("graph-to-prompt", this.onGraphtoPromptBound);
+ rgthree.removeEventListener("graph-to-prompt-end", this.onGraphtoPromptEndBound);
+ }
+
+ onQueue(event: Event) {
+ this.processingQueue = true;
+ }
+ onQueueEnd(event: Event) {
+ this.processingQueue = false;
+ }
+ onGraphtoPrompt(event: Event) {
+ if (!this.processingQueue) {
+ return;
+ }
+ this.tempEnabledNode = null;
+ // Check that all are muted and, if so, choose one to unmute.
+ const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
+ let allMuted = true;
+ if (linkedNodes.length) {
+ for (const node of linkedNodes) {
+ if (node.mode !== this.modeOff) {
+ allMuted = false;
+ break;
+ }
+ }
+ if (allMuted) {
+ this.tempEnabledNode = linkedNodes[Math.floor(Math.random() * linkedNodes.length)] || null;
+ if (this.tempEnabledNode) {
+ changeModeOfNodes(this.tempEnabledNode, this.modeOn);
+ }
+ }
+ }
+ }
+ onGraphtoPromptEnd(event: Event) {
+ if (this.tempEnabledNode) {
+ changeModeOfNodes(this.tempEnabledNode, this.modeOff);
+ this.tempEnabledNode = null;
+ }
+ }
+
+ override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
+ return false; // No-op, no widgets.
+ }
+
+ override getHelp(): string {
+ return `
+
+ Use this node to unmute on of its inputs randomly when the graph is queued (and, immediately
+ mute it back).
+
+
+
+ NOTE: All input nodes MUST be muted to start; if not this node will not randomly unmute
+ another. (This is powerful, as the generated image can be dragged in and the chosen input
+ will already by unmuted and work w/o any further action.)
+
+
+ TIP: Connect a Repeater's output to this nodes input and place that Repeater on a group
+ without any other inputs, and it will mute/unmute the entire group.
+
+
+ `;
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.RandomUnmuter",
+ registerCustomNodes() {
+ RandomUnmuterNode.setUp();
+ },
+ loadedGraphNode(node: LGraphNode) {
+ if (node.type == RandomUnmuterNode.title) {
+ (node as any)._tempWidth = node.size[0];
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts
new file mode 100644
index 0000000000000000000000000000000000000000..de0155ae5c644ccc938ca8ce7b7ade726b7c39b3
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts
@@ -0,0 +1,1285 @@
+import type {
+ Vector2,
+ LLink,
+ LGraphCanvas as TLGraphCanvas,
+ LGraph as TLGraph,
+ INodeInputSlot,
+ INodeOutputSlot,
+ LGraphNode as TLGraphNode,
+ LinkDirection,
+ ISerialisedNode,
+ Point,
+ Size,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+// @ts-ignore
+import {rgthreeConfig} from "rgthree/config.js";
+import {rgthree} from "./rgthree.js";
+import {
+ IoDirection,
+ LAYOUT_CLOCKWISE,
+ LAYOUT_LABEL_OPPOSITES,
+ LAYOUT_LABEL_TO_DATA,
+ addConnectionLayoutSupport,
+ addMenuItem,
+ getSlotLinks,
+ isValidConnection,
+ setConnectionsLayout,
+ waitForCanvas,
+} from "./utils.js";
+import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {RgthreeBaseVirtualNode} from "./base_node.js";
+import {NodeTypesString} from "./constants.js";
+import {rgthreeApi} from "rgthree/common/rgthree_api.js";
+import {getWidgetConfig, mergeIfValid, setWidgetConfig} from "./utils_deprecated_comfyui.js";
+
+const CONFIG_REROUTE = rgthreeConfig?.["nodes"]?.["reroute"] || {};
+
+const CONFIG_FAST_REROUTE = CONFIG_REROUTE["fast_reroute"];
+const CONFIG_FAST_REROUTE_ENABLED = CONFIG_FAST_REROUTE["enabled"] ?? false;
+const CONFIG_KEY_CREATE_WHILE_LINKING = CONFIG_FAST_REROUTE["key_create_while_dragging_link"];
+const CONFIG_KEY_ROTATE = CONFIG_FAST_REROUTE["key_rotate"];
+const CONFIG_KEY_RESIZE = CONFIG_FAST_REROUTE["key_resize"];
+const CONFIG_KEY_MOVE = CONFIG_FAST_REROUTE["key_move"];
+const CONFIG_KEY_CXN_INPUT = CONFIG_FAST_REROUTE["key_connections_input"];
+const CONFIG_KEY_CXN_OUTPUT = CONFIG_FAST_REROUTE["key_connections_output"];
+
+let configWidth = Math.max(
+ Math.round((Number(CONFIG_REROUTE["default_width"]) || 40) / 10) * 10,
+ 10,
+);
+let configHeight = Math.max(
+ Math.round((Number(CONFIG_REROUTE["default_height"]) || 30) / 10) * 10,
+ 10,
+);
+// Don't allow too small sizes. Granted, 400 is too small, but at least you can right click and
+// resize... 10x10 you cannot.
+while (configWidth * configHeight < 400) {
+ configWidth += 10;
+ configHeight += 10;
+}
+const configDefaultSize = [configWidth, configHeight] as Vector2;
+const configResizable = !!CONFIG_REROUTE["default_resizable"];
+let configLayout: [string, string] = CONFIG_REROUTE["default_layout"];
+if (!Array.isArray(configLayout)) {
+ configLayout = ["Left", "Right"];
+}
+if (!LAYOUT_LABEL_TO_DATA[configLayout[0]]) {
+ configLayout[0] = "Left";
+}
+if (!LAYOUT_LABEL_TO_DATA[configLayout[1]] || configLayout[0] == configLayout[1]) {
+ configLayout[1] = LAYOUT_LABEL_OPPOSITES[configLayout[0]]!;
+}
+
+type FastRerouteEntryCtx = {
+ node: TLGraphNode;
+ input?: INodeInputSlot;
+ output?: INodeOutputSlot;
+ slot: number;
+ pos: Vector2;
+ direction?: LinkDirection;
+};
+
+type FastRerouteEntry = {
+ node: RerouteNode;
+ previous: FastRerouteEntryCtx;
+ current?: FastRerouteEntryCtx;
+};
+
+/**
+ * RerouteService handles any coordination between reroute nodes and the system. Mostly, it's for
+ * fast-rerouting that can create a new reroute nodes while dragging a link.
+ */
+class RerouteService {
+ private isFastLinking = false;
+ private handledNewRerouteKeypress = false;
+ private connectingData: FastRerouteEntryCtx | null = null;
+ private fastReroutesHistory: FastRerouteEntry[] = [];
+
+ private handleLinkingKeydownBound = this.handleLinkingKeydown.bind(this);
+ private handleLinkingKeyupBound = this.handleLinkingKeyup.bind(this);
+
+ constructor() {
+ if (CONFIG_FAST_REROUTE_ENABLED && CONFIG_KEY_CREATE_WHILE_LINKING?.trim()) {
+ this.onCanvasSetUpListenerForLinking();
+ }
+ }
+
+ /**
+ * Waits for canvas to be available, then sets up a property accessor for `connecting_node` so
+ * we can start/stop monitoring for shortcut keys.
+ */
+ async onCanvasSetUpListenerForLinking() {
+ const canvas = await waitForCanvas();
+
+ // With the new UI released in August 2024, ComfyUI changed LiteGraph's code, removing
+ // connecting_node, connecting_node, connecting_node, and connecting_node properties and instead
+ // using an array of connecting_links. We can try to accomodate both for a while.
+ const canvasProperty = true ? "connecting_links" : "connecting_node";
+ (canvas as any)[`_${canvasProperty}`];
+ const thisService = this;
+ Object.defineProperty(canvas, canvasProperty, {
+ get: function () {
+ return this[`_${canvasProperty}`];
+ },
+ set: function (value) {
+ const isValNull = !value || !value?.length;
+ const isPropNull = !this[`_${canvasProperty}`] || !this[`_${canvasProperty}`]?.length;
+ const isStartingLinking = !isValNull && isPropNull;
+ const isStoppingLinking = !isPropNull && isValNull;
+ this[`_${canvasProperty}`] = value;
+ if (isStartingLinking) {
+ thisService.startingLinking();
+ }
+ if (isStoppingLinking) {
+ thisService.stoppingLinking();
+ thisService.connectingData = null;
+ }
+ },
+ });
+ }
+
+ /**
+ * When the user is actively dragging a link, listens for keydown events so we can enable
+ * shortcuts.
+ *
+ * Is only accessible if both `CONFIG_FAST_REROUTE_ENABLED` is true, and
+ * CONFIG_KEY_CREATE_WHILE_LINKING is not falsy/empty.
+ */
+ private startingLinking() {
+ this.isFastLinking = true;
+ KEY_EVENT_SERVICE.addEventListener("keydown", this.handleLinkingKeydownBound as EventListener);
+ KEY_EVENT_SERVICE.addEventListener("keyup", this.handleLinkingKeyupBound as EventListener);
+ }
+
+ /**
+ * When the user stops actively dragging a link, cleans up motnioring data and events.
+ *
+ * Is only accessible if both `CONFIG_FAST_REROUTE_ENABLED` is true, and
+ * CONFIG_KEY_CREATE_WHILE_LINKING is not falsy/empty.
+ */
+ private stoppingLinking() {
+ this.isFastLinking = false;
+ this.fastReroutesHistory = [];
+ KEY_EVENT_SERVICE.removeEventListener(
+ "keydown",
+ this.handleLinkingKeydownBound as EventListener,
+ );
+ KEY_EVENT_SERVICE.removeEventListener("keyup", this.handleLinkingKeyupBound as EventListener);
+ }
+
+ /**
+ * Handles the keydown event.
+ *
+ * Is only accessible if both `CONFIG_FAST_REROUTE_ENABLED` is true, and
+ * CONFIG_KEY_CREATE_WHILE_LINKING is not falsy/empty.
+ */
+ private handleLinkingKeydown(event: KeyboardEvent) {
+ if (
+ !this.handledNewRerouteKeypress &&
+ KEY_EVENT_SERVICE.areOnlyKeysDown(CONFIG_KEY_CREATE_WHILE_LINKING)
+ ) {
+ this.handledNewRerouteKeypress = true;
+ this.insertNewRerouteWhileLinking();
+ }
+ }
+
+ /**
+ * Handles the keyup event.
+ *
+ * Is only accessible if both `CONFIG_FAST_REROUTE_ENABLED` is true, and
+ * CONFIG_KEY_CREATE_WHILE_LINKING is not falsy/empty.
+ */
+ private handleLinkingKeyup(event: KeyboardEvent) {
+ if (
+ this.handledNewRerouteKeypress &&
+ !KEY_EVENT_SERVICE.areOnlyKeysDown(CONFIG_KEY_CREATE_WHILE_LINKING)
+ ) {
+ this.handledNewRerouteKeypress = false;
+ }
+ }
+
+ private getConnectingData(): FastRerouteEntryCtx {
+ const oldCanvas = app.canvas as any;
+ if (
+ oldCanvas.connecting_node &&
+ oldCanvas.connecting_slot != null &&
+ oldCanvas.connecting_pos?.length
+ ) {
+ return {
+ node: oldCanvas.connecting_node,
+ input: oldCanvas.connecting_input,
+ output: oldCanvas.connecting_output,
+ slot: oldCanvas.connecting_slot,
+ pos: [...oldCanvas.connecting_pos] as Vector2,
+ };
+ }
+ const canvas = app.canvas;
+ if (canvas.connecting_links?.length) {
+ // Assume just the first.
+ const link = canvas.connecting_links[0]!;
+ return {
+ node: link.node,
+ input: link.input ?? undefined,
+ output: link.output ?? undefined,
+ slot: link.slot,
+ pos: [...link.pos] as Point,
+ };
+ }
+ throw new Error("Error, handling linking keydown, but there's no link.");
+ }
+
+ private setCanvasConnectingData(ctx: FastRerouteEntryCtx) {
+ const oldCanvas = app.canvas as any;
+ if (
+ oldCanvas.connecting_node &&
+ oldCanvas.connecting_slot != null &&
+ oldCanvas.connecting_pos?.length
+ ) {
+ oldCanvas.connecting_node = ctx.node;
+ oldCanvas.connecting_input = ctx.input;
+ oldCanvas.connecting_output = ctx.output;
+ oldCanvas.connecting_slot = ctx.slot;
+ oldCanvas.connecting_pos = ctx.pos;
+ }
+ const canvas = app.canvas;
+ if (canvas.connecting_links?.length) {
+ // Assume just the first.
+ const link = canvas.connecting_links[0]!;
+ link.node = ctx.node;
+ link.input = ctx.input;
+ link.output = ctx.output;
+ link.slot = ctx.slot;
+ link.pos = ctx.pos;
+ }
+ // const newCanvas = app.canvas as unknown as TypedLGraphCanvas;
+ // if (newCanvas.linkConnector.renderLinks?.length) {
+ // newCanvas.linkConnector.reset();
+ // newCanvas.linkConnector.dragNewFromOutput(app.graph as any, ctx.node as any, ctx.output as any);
+ // }
+
+ // const link = newCanvas.linkConnector.renderLinks[0]! as any;
+ // link.node = ctx.node;
+ // link.fromSlot = ctx.output || ctx.input;
+ // link.fromSlotIndex = ctx.slot;
+ // link.fromPos = ctx.pos;
+ // link.fromDirection = ctx.direction || 4;
+ // }
+ }
+
+ /**
+ * Inserts a new reroute (while linking) as called from key down handler.
+ *
+ * Is only accessible if both `CONFIG_FAST_REROUTE_ENABLED` is true, and
+ * CONFIG_KEY_CREATE_WHILE_LINKING is not falsy/empty.
+ */
+ private insertNewRerouteWhileLinking() {
+ const canvas = app.canvas;
+ this.connectingData = this.getConnectingData();
+ if (!this.connectingData) {
+ throw new Error("Error, handling linking keydown, but there's no link.");
+ }
+
+ const data = this.connectingData;
+ const node = LiteGraph.createNode("Reroute (rgthree)") as RerouteNode;
+ const entry: FastRerouteEntry = {
+ node,
+ previous: {...this.connectingData},
+ current: undefined,
+ };
+ this.fastReroutesHistory.push(entry);
+
+ let connectingDir = (data.input || data.output)?.dir;
+ if (!connectingDir) {
+ connectingDir = data.input ? LiteGraph.LEFT : LiteGraph.RIGHT;
+ }
+
+ let newPos = canvas.convertEventToCanvasOffset({
+ clientX: Math.round(canvas.last_mouse_position[0] / 10) * 10,
+ clientY: Math.round(canvas.last_mouse_position[1] / 10) * 10,
+ } as MouseEvent);
+ entry.node.pos = newPos;
+ canvas.graph!.add(entry.node);
+ canvas.selectNode(entry.node);
+
+ // Find out which direction we're generally moving.
+ const distX = entry.node.pos[0] - data.pos[0];
+ const distY = entry.node.pos[1] - data.pos[1];
+
+ const layout: [string, string] = ["Left", "Right"];
+ if (distX > 0 && Math.abs(distX) > Math.abs(distY)) {
+ // To the right, and further right than up or down.
+ layout[0] = data.output ? "Left" : "Right";
+ layout[1] = LAYOUT_LABEL_OPPOSITES[layout[0]]!;
+ node.pos[0] -= node.size[0] + 10;
+ node.pos[1] -= Math.round(node.size[1] / 2 / 10) * 10;
+ } else if (distX < 0 && Math.abs(distX) > Math.abs(distY)) {
+ // To the left, and further right than up or down.
+ layout[0] = data.output ? "Right" : "Left";
+ layout[1] = LAYOUT_LABEL_OPPOSITES[layout[0]]!;
+ node.pos[1] -= Math.round(node.size[1] / 2 / 10) * 10;
+ } else if (distY < 0 && Math.abs(distY) > Math.abs(distX)) {
+ // Above and further above than left or right.
+ layout[0] = data.output ? "Bottom" : "Top";
+ layout[1] = LAYOUT_LABEL_OPPOSITES[layout[0]]!;
+ node.pos[0] -= Math.round(node.size[0] / 2 / 10) * 10;
+ } else if (distY > 0 && Math.abs(distY) > Math.abs(distX)) {
+ // Below and further below than left or right.
+ layout[0] = data.output ? "Top" : "Bottom";
+ layout[1] = LAYOUT_LABEL_OPPOSITES[layout[0]]!;
+ node.pos[0] -= Math.round(node.size[0] / 2 / 10) * 10;
+ node.pos[1] -= node.size[1] + 10;
+ }
+ setConnectionsLayout(entry.node, layout);
+
+ if (data.output) {
+ data.node.connect(data.slot, entry.node, 0);
+ data.node = entry.node;
+ data.output = entry.node.outputs[0]!;
+ data.slot = 0;
+ data.pos = entry.node.getConnectionPos(false, 0);
+ data.direction =
+ layout[0] === "Top" ? 2 : layout[0] === "Bottom" ? 1 : layout[0] === "Left" ? 4 : 3;
+ } else {
+ entry.node.connect(0, data.node, data.slot);
+ data.node = entry.node;
+ data.input = entry.node.inputs[0]!;
+ data.slot = 0;
+ data.pos = entry.node.getConnectionPos(true, 0);
+ data.direction =
+ layout[1] === "Top" ? 2 : layout[1] === "Bottom" ? 1 : layout[1] === "Left" ? 4 : 3;
+ }
+ this.setCanvasConnectingData(data);
+ entry.current = {...this.connectingData};
+
+ app.graph.setDirtyCanvas(true, true);
+ }
+
+ /**
+ * Is called from a reroute node when it is resized or moved so the service can check if we're
+ * actively linking to it, and it can update the linking data so the connection moves too, by
+ * updating `connecting_pos`.
+ */
+ handleMoveOrResizeNodeMaybeWhileDragging(node: RerouteNode) {
+ const data = this.connectingData!;
+ if (this.isFastLinking && node === data?.node) {
+ const entry = this.fastReroutesHistory[this.fastReroutesHistory.length - 1];
+ if (entry) {
+ data.pos = entry.node.getConnectionPos(!!data.input, 0);
+ this.setCanvasConnectingData(data);
+ }
+ }
+ }
+
+ /**
+ * Is called from a reroute node when it is deleted so the service can check if we're actively
+ * linking to it and go "back" in history to the previous node.
+ */
+ handleRemovedNodeMaybeWhileDragging(node: RerouteNode) {
+ const currentEntry = this.fastReroutesHistory[this.fastReroutesHistory.length - 1];
+ if (currentEntry?.node === node) {
+ this.setCanvasConnectingData(currentEntry.previous);
+ this.fastReroutesHistory.splice(this.fastReroutesHistory.length - 1, 1);
+ if (currentEntry.previous.node) {
+ app.canvas.selectNode(currentEntry.previous.node);
+ }
+ }
+ }
+}
+
+const SERVICE = new RerouteService();
+
+/**
+ * The famous ReroutNode, that has true multidirectional, expansive sizes, etc.
+ */
+class RerouteNode extends RgthreeBaseVirtualNode {
+ static override title = NodeTypesString.REROUTE;
+ static override type = NodeTypesString.REROUTE;
+ override comfyClass = NodeTypesString.REROUTE;
+
+ static readonly title_mode = LiteGraph.NO_TITLE;
+
+ static collapsable = false;
+ static layout_slot_offset = 5;
+ static size: Vector2 = configDefaultSize; // Starting size, read from within litegraph.core
+
+ override isVirtualNode = true;
+ readonly hideSlotLabels = true;
+
+ private schedulePromise: Promise | null = null;
+
+ defaultConnectionsLayout = Array.from(configLayout);
+
+ /** Shortcuts defined in the config. */
+ private shortcuts = {
+ rotate: {keys: CONFIG_KEY_ROTATE, state: false},
+ connection_input: {keys: CONFIG_KEY_CXN_INPUT, state: false},
+ connection_output: {keys: CONFIG_KEY_CXN_OUTPUT, state: false},
+ resize: {
+ keys: CONFIG_KEY_RESIZE,
+ state: false,
+ initialMousePos: [-1, -1] as Vector2,
+ initialNodeSize: [-1, -1] as Vector2,
+ initialNodePos: [-1, -1] as Vector2,
+ resizeOnSide: [-1, -1] as Vector2,
+ },
+ move: {
+ keys: CONFIG_KEY_MOVE,
+ state: false,
+ initialMousePos: [-1, -1] as Vector2,
+ initialNodePos: [-1, -1] as Vector2,
+ },
+ };
+
+ constructor(title = RerouteNode.title) {
+ super(title);
+ this.onConstructed();
+ }
+
+ override onConstructed(): boolean {
+ this.setResizable(!!(this.properties["resizable"] ?? configResizable));
+ this.size = RerouteNode.size; // Starting size.
+ this.addInput("", "*");
+ this.addOutput("", "*");
+ setTimeout(() => this.applyNodeSize(), 20);
+ return super.onConstructed();
+ }
+
+ override configure(info: ISerialisedNode): void {
+ if (info.inputs?.length) {
+ info.inputs.length = 1;
+ }
+ super.configure(info);
+ this.configuring = true;
+ this.setResizable(!!(this.properties["resizable"] ?? configResizable));
+ this.applyNodeSize();
+ this.configuring = false;
+ }
+
+ setResizable(resizable: boolean) {
+ this.properties["resizable"] = !!resizable;
+ this.resizable = this.properties["resizable"];
+ }
+
+ override clone() {
+ const cloned = super.clone()!;
+ cloned.inputs[0]!.type = "*";
+ cloned.outputs[0]!.type = "*";
+ return cloned;
+ }
+
+ /**
+ * Copied a good bunch of this from the original reroute included with comfy.
+ */
+ override onConnectionsChange(
+ type: number,
+ _slotIndex: number,
+ connected: boolean,
+ _link_info: LLink,
+ _ioSlot: INodeOutputSlot | INodeInputSlot,
+ ) {
+ // Prevent multiple connections to different types when we have no input
+ if (connected && type === LiteGraph.OUTPUT) {
+ // Ignore wildcard nodes as these will be updated to real types
+ const types = new Set(
+ this.outputs[0]!.links!.map((l) => app.graph.links[l]?.type).filter((t) => t && t !== "*"),
+ );
+ if (types.size > 1) {
+ const linksToDisconnect = [];
+ for (let i = 0; i < this.outputs[0]!.links!.length - 1; i++) {
+ const linkId = this.outputs[0]!.links![i]!;
+ const link = app.graph.links[linkId];
+ linksToDisconnect.push(link);
+ }
+ for (const link of linksToDisconnect) {
+ const node = app.graph.getNodeById(link!.target_id)!;
+ node.disconnectInput(link!.target_slot);
+ }
+ }
+ }
+ this.scheduleStabilize();
+ }
+
+ override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: TLGraphCanvas): void {
+ if (this.properties?.["showLabel"]) {
+ // ComfyUI seemed to break us again, but couldn't repro. No reason to not check, I guess.
+ // https://github.com/rgthree/rgthree-comfy/issues/71
+ const low_quality = canvas?.ds?.scale && canvas.ds.scale < 0.6;
+ if (low_quality || this.size[0] <= 10) {
+ return;
+ }
+ const fontSize = Math.min(14, (this.size[1] * 0.65) | 0);
+ ctx.save();
+ ctx.fillStyle = "#888";
+ ctx.font = `${fontSize}px Arial`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(
+ String(
+ this.title && this.title !== RerouteNode.title
+ ? this.title
+ : this.outputs?.[0]?.type || "",
+ ),
+ this.size[0] / 2,
+ this.size[1] / 2,
+ this.size[0] - 30,
+ );
+ ctx.restore();
+ }
+ }
+
+ /** Finds the input slot; since we only ever have one, this is always 0. */
+ override findInputSlot(name: string, returnObj?: TReturn): number;
+ override findInputSlot(name: string, returnObj?: TReturn): INodeInputSlot;
+ override findInputSlot(name: string, returnObj: boolean = false): number | INodeInputSlot {
+ return returnObj ? this.inputs[0]! : 0;
+ }
+
+ /** Finds the output slot; since we only ever have one, this is always 0. */
+ override findOutputSlot(name: string, returnObj?: TReturn): number;
+ override findOutputSlot(name: string, returnObj?: TReturn): INodeOutputSlot;
+ override findOutputSlot(name: unknown, returnObj?: unknown): number | INodeOutputSlot {
+ return returnObj ? this.outputs[0]! : 0;
+ }
+
+ override disconnectOutput(slot: string | number, targetNode?: TLGraphNode | undefined): boolean {
+ return super.disconnectOutput(slot, targetNode);
+ }
+
+ override disconnectInput(slot: string | number): boolean {
+ // [🤮] ComfyUI's reroute nodes will disconnect our input if it doesn't yet match (ours being
+ // "*" and it's being a type. This mostly happens if we're converting reroutes to rgthree
+ // reroutes, the old reroute does a check and calls disconnectInput. Luckily, we can be smarter
+ // and check if we're being asked to disconnect from an old reroute while we're replacing
+ // reroute nodes (via rgthree.replacingReroute state).
+ if (rgthree.replacingReroute != null && this.inputs[0]?.link) {
+ const graph = app.graph as TLGraph;
+ const link = graph.links[this.inputs[0].link];
+ const node = link?.origin_id != null ? graph.getNodeById(link.origin_id) : null;
+ // We'll also be asked to disconnect when the old one is removed, so we only want to stop a
+ // disconnect when the connected node is NOT the one being removed/replaced.
+ if (rgthree.replacingReroute !== node?.id) {
+ return false;
+ }
+ }
+ return super.disconnectInput(slot);
+ }
+
+ scheduleStabilize(ms = 64) {
+ if (!this.schedulePromise) {
+ this.schedulePromise = new Promise((resolve) => {
+ setTimeout(() => {
+ this.schedulePromise = null;
+ this.stabilize();
+ resolve();
+ }, ms);
+ });
+ }
+ return this.schedulePromise;
+ }
+
+ stabilize() {
+ // If we are currently "configuring" then skip this stabilization. The connected nodes may
+ // not yet be configured.
+ if (this.configuring) {
+ return;
+ }
+ // Find root input
+ let currentNode: TLGraphNode | null = this;
+ let updateNodes = [];
+ let input = null;
+ let inputType = null;
+ let inputNode = null;
+ let inputNodeOutputSlot = null;
+ while (currentNode) {
+ updateNodes.unshift(currentNode);
+ const linkId: number | null = currentNode.inputs[0]!.link;
+ if (linkId !== null) {
+ const link: LLink = (app.graph as TLGraph).links[linkId]!;
+ const node: TLGraphNode = (app.graph as TLGraph).getNodeById(link.origin_id)!;
+ if (!node) {
+ // Bummer, somthing happened.. should we cleanup?
+ app.graph.removeLink(linkId);
+ currentNode = null;
+ break;
+ }
+ const type = (node.constructor as typeof TLGraphNode).type;
+ if (type?.includes("Reroute")) {
+ if (node === this) {
+ // We've found a circle
+ currentNode.disconnectInput(link.target_slot);
+ currentNode = null;
+ } else {
+ // Move the previous node
+ currentNode = node;
+ }
+ } else {
+ // We've found the end
+ inputNode = node;
+ inputNodeOutputSlot = link.origin_slot;
+ input = node.outputs[inputNodeOutputSlot] ?? null;
+ inputType = input?.type ?? null;
+ break;
+ }
+ } else {
+ // This path has no input node
+ currentNode = null;
+ break;
+ }
+ }
+
+ // Find all outputs
+ const nodes: TLGraphNode[] = [this];
+ let outputNode = null;
+ let outputType = null;
+ // For primitive nodes, which look at the widget to dsplay themselves.
+ let outputWidgetConfig = null;
+ let outputWidget = null;
+ while (nodes.length) {
+ currentNode = nodes.pop()!;
+ const outputs = (currentNode.outputs ? currentNode.outputs[0]!.links : []) || [];
+ if (outputs.length) {
+ for (const linkId of outputs) {
+ const link = app.graph.links[linkId];
+
+ // When disconnecting sometimes the link is still registered
+ if (!link) continue;
+
+ const node = app.graph.getNodeById(link.target_id) as TLGraphNode;
+ // Don't know why this ever happens.. but it did around the repeater..
+ if (!node) continue;
+ const type = (node.constructor as any).type;
+ if (type?.includes("Reroute")) {
+ // Follow reroute nodes
+ nodes.push(node);
+ updateNodes.push(node);
+ } else {
+ // We've found an output
+ const output = node.inputs?.[link.target_slot] ?? null;
+ const nodeOutType = output?.type;
+ if (nodeOutType == null) {
+ console.warn(
+ `[rgthree] Reroute - Connected node ${node.id} does not have type information for ` +
+ `slot ${link.target_slot}. Skipping connection enforcement, but something is odd ` +
+ `with that node.`,
+ );
+ } else if (
+ inputType &&
+ inputType !== "*" &&
+ nodeOutType !== "*" &&
+ !isValidConnection(input, output)
+ ) {
+ // The output doesnt match our input so disconnect it
+ console.warn(
+ `[rgthree] Reroute - Disconnecting connected node's input (${node.id}.${
+ link.target_slot
+ }) (${node.type}) because its type (${String(
+ nodeOutType,
+ )}) does not match the reroute type (${String(inputType)})`,
+ );
+ node.disconnectInput(link.target_slot);
+ } else {
+ outputType = nodeOutType;
+ outputNode = node;
+ outputWidgetConfig = null;
+ outputWidget = null;
+ // For primitive nodes, which look at the widget to dsplay themselves.
+ if (output?.widget) {
+ rgthreeApi.print("PRIMITIVE_REROUTE");
+ try {
+ const config = getWidgetConfig(output);
+ if (!outputWidgetConfig && config) {
+ outputWidgetConfig = config[1] ?? {};
+ outputType = config[0];
+ if (!outputWidget) {
+ outputWidget = outputNode.widgets?.find(
+ (w) => w.name === output?.widget?.name,
+ );
+ }
+ const merged = mergeIfValid(output, [config[0], outputWidgetConfig]);
+ if (merged?.customConfig) {
+ outputWidgetConfig = merged.customConfig;
+ }
+ }
+ } catch (e) {
+ // Something happened, probably because comfyUI changes their methods.
+ console.error(
+ "[rgthree] Could not propagate widget infor for reroute; maybe ComfyUI updated?",
+ );
+ outputWidgetConfig = null;
+ outputWidget = null;
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // No more outputs for this path
+ }
+ }
+
+ const displayType = inputType || outputType || "*";
+ const color = LGraphCanvas.link_type_colors[displayType];
+
+ // Update the types of each node
+ for (const node of updateNodes) {
+ // If we dont have an input type we are always wildcard but we'll show the output type
+ // This lets you change the output link to a different type and all nodes will update
+ node.outputs[0]!.type = inputType || "*";
+ (node as any).__outputType = displayType;
+ node.outputs[0]!.name = input?.name || "";
+ node.size = node.computeSize();
+ (node as any).applyNodeSize?.();
+
+ for (const l of node.outputs[0]!.links || []) {
+ const link = app.graph.links[l];
+ if (link && color) {
+ link.color = color;
+ }
+ }
+
+ try {
+ // For primitive nodes, which look at the widget to dsplay themselves; we get by with just
+ // an object with 'name'.
+ if (outputWidgetConfig && outputWidget && outputType) {
+ rgthreeApi.print("PRIMITIVE_REROUTE");
+ node.inputs[0]!.widget = {name: "value"} as any;
+ setWidgetConfig(
+ node.inputs[0],
+ [outputType ?? displayType, outputWidgetConfig],
+ // outputWidget, // This never existed?
+ );
+ } else {
+ setWidgetConfig(node.inputs[0], null);
+ }
+ } catch (e) {
+ // Something happened, probably because comfyUI changes their methods.
+ console.error("[rgthree] Could not set widget config for reroute; maybe ComfyUI updated?");
+ outputWidgetConfig = null;
+ outputWidget = null;
+ if (node.inputs[0]?.widget) {
+ delete node.inputs[0].widget;
+ }
+ }
+ }
+
+ if (inputNode && inputNodeOutputSlot != null) {
+ const links = inputNode.outputs[inputNodeOutputSlot]!.links;
+ for (const l of links || []) {
+ const link = app.graph.links[l];
+ if (link && color) {
+ link.color = color;
+ }
+ }
+ }
+ (inputNode as any)?.onConnectionsChainChange?.();
+ (outputNode as any)?.onConnectionsChainChange?.();
+ app.graph.setDirtyCanvas(true, true);
+ }
+
+ /**
+ * When called, sets the node size, and the properties size, and calls out to `stabilizeLayout`.
+ */
+ override setSize(size: Vector2): void {
+ const oldSize = [...this.size] as Size;
+ const newSize = [...size] as Size;
+ super.setSize(newSize);
+ this.properties["size"] = [...this.size];
+ this.stabilizeLayout(oldSize, newSize);
+ }
+
+ /**
+ * Looks at the current layout and determins if we also need to set a `connections_dir` based on
+ * the size of the node (and what that connections_dir should be).
+ */
+ private stabilizeLayout(oldSize: Vector2, newSize: Vector2) {
+ if (newSize[0] === 10 || newSize[1] === 10) {
+ const props = this.properties;
+ props["connections_layout"] = props["connections_layout"] || ["Left", "Right"];
+ props["connections_dir"] = props["connections_dir"] || [-1, -1];
+
+ const layout = props["connections_layout"] as [string, string];
+ const dir = props["connections_dir"] as [number, number];
+
+ if (oldSize[0] > 10 && newSize[0] === 10) {
+ dir[0] = LiteGraph.DOWN;
+ dir[1] = LiteGraph.UP;
+ if (layout[0] === "Bottom") {
+ layout[1] = "Top";
+ } else if (layout[1] === "Top") {
+ layout[0] = "Bottom";
+ } else {
+ layout[0] = "Top";
+ layout[1] = "Bottom";
+ dir[0] = LiteGraph.UP;
+ dir[1] = LiteGraph.DOWN;
+ }
+ this.setDirtyCanvas(true, true);
+ } else if (oldSize[1] > 10 && newSize[1] === 10) {
+ dir[0] = LiteGraph.RIGHT;
+ dir[1] = LiteGraph.LEFT;
+ if (layout[0] === "Right") {
+ layout[1] = "Left";
+ } else if (layout[1] === "Left") {
+ layout[0] = "Right";
+ } else {
+ layout[0] = "Left";
+ layout[1] = "Right";
+ dir[0] = LiteGraph.LEFT;
+ dir[1] = LiteGraph.RIGHT;
+ }
+ this.setDirtyCanvas(true, true);
+ }
+ }
+ SERVICE.handleMoveOrResizeNodeMaybeWhileDragging(this);
+ }
+
+ applyNodeSize() {
+ this.properties["size"] = this.properties["size"] || RerouteNode.size;
+ this.properties["size"] = [
+ Number((this.properties["size"] as Size)[0]),
+ Number((this.properties["size"] as Size)[1]),
+ ];
+ this.size = this.properties["size"] as Size;
+ app.graph.setDirtyCanvas(true, true);
+ }
+
+ /**
+ * Rotates the node, including changing size and moving input's and output's layouts.
+ */
+ rotate(degrees: 90 | -90 | 180) {
+ const w = this.size[0];
+ const h = this.size[1];
+ this.properties["connections_layout"] =
+ this.properties["connections_layout"] || (this as RerouteNode).defaultConnectionsLayout;
+
+ const connections_layout = this.properties["connections_layout"] as [string, string];
+ const inputDirIndex = LAYOUT_CLOCKWISE.indexOf(connections_layout[0]);
+ const outputDirIndex = LAYOUT_CLOCKWISE.indexOf(connections_layout[1]);
+ if (degrees == 90 || degrees === -90) {
+ if (degrees === -90) {
+ connections_layout[0] = LAYOUT_CLOCKWISE[(((inputDirIndex - 1) % 4) + 4) % 4]!;
+ connections_layout[1] = LAYOUT_CLOCKWISE[(((outputDirIndex - 1) % 4) + 4) % 4]!;
+ } else {
+ connections_layout[0] = LAYOUT_CLOCKWISE[(((inputDirIndex + 1) % 4) + 4) % 4]!;
+ connections_layout[1] = LAYOUT_CLOCKWISE[(((outputDirIndex + 1) % 4) + 4) % 4]!;
+ }
+ } else if (degrees === 180) {
+ connections_layout[0] = LAYOUT_CLOCKWISE[(((inputDirIndex + 2) % 4) + 4) % 4]!;
+ connections_layout[1] = LAYOUT_CLOCKWISE[(((outputDirIndex + 2) % 4) + 4) % 4]!;
+ }
+ this.setSize([h, w]);
+ }
+
+ /**
+ * Manually handles a move called from `onMouseMove` while the resize shortcut is active.
+ */
+ private manuallyHandleMove(event: PointerEvent) {
+ const shortcut = this.shortcuts.move;
+ if (shortcut.state) {
+ const diffX = Math.round((event.clientX - shortcut.initialMousePos[0]) / 10) * 10;
+ const diffY = Math.round((event.clientY - shortcut.initialMousePos[1]) / 10) * 10;
+ this.pos[0] = shortcut.initialNodePos[0] + diffX;
+ this.pos[1] = shortcut.initialNodePos[1] + diffY;
+ this.setDirtyCanvas(true, true);
+ SERVICE.handleMoveOrResizeNodeMaybeWhileDragging(this);
+ }
+ }
+
+ /**
+ * Manually handles a resize called from `onMouseMove` while the resize shortcut is active.
+ */
+ private manuallyHandleResize(event: PointerEvent) {
+ const shortcut = this.shortcuts.resize;
+ if (shortcut.state) {
+ let diffX = Math.round((event.clientX - shortcut.initialMousePos[0]) / 10) * 10;
+ let diffY = Math.round((event.clientY - shortcut.initialMousePos[1]) / 10) * 10;
+ diffX *= shortcut.resizeOnSide[0] === LiteGraph.LEFT ? -1 : 1;
+ diffY *= shortcut.resizeOnSide[1] === LiteGraph.UP ? -1 : 1;
+ const oldSize = [...this.size] as Size;
+ this.setSize([
+ Math.max(10, shortcut.initialNodeSize[0] + diffX),
+ Math.max(10, shortcut.initialNodeSize[1] + diffY),
+ ]);
+ if (shortcut.resizeOnSide[0] === LiteGraph.LEFT && oldSize[0] > 10) {
+ this.pos[0] = shortcut.initialNodePos[0] - diffX;
+ }
+ if (shortcut.resizeOnSide[1] === LiteGraph.UP && oldSize[1] > 10) {
+ this.pos[1] = shortcut.initialNodePos[1] - diffY;
+ }
+ this.setDirtyCanvas(true, true);
+ }
+ }
+
+ /**
+ * Cycles the connection (input or output) to the next available layout. Note, when the width or
+ * height is only 10px, then layout sticks to the ends of the longer size, and we move a
+ * `connections_dir` property which is only paid attention to in `utils` when size of one axis
+ * is equal to 10.
+ * `manuallyHandleResize` handles the reset of `connections_dir` when a node is resized.
+ */
+ private cycleConnection(ioDir: IoDirection) {
+ const props = this.properties;
+ props["connections_layout"] = props["connections_layout"] || ["Left", "Right"];
+ const connections_layout = this.properties["connections_layout"] as [string, string];
+
+ const propIdx = ioDir == IoDirection.INPUT ? 0 : 1;
+ const oppositeIdx = propIdx ? 0 : 1;
+ let currentLayout = connections_layout[propIdx];
+ let oppositeLayout = connections_layout[oppositeIdx];
+
+ if (this.size[0] === 10 || this.size[1] === 10) {
+ props["connections_dir"] = props["connections_dir"] || [-1, -1];
+ const connections_dir = this.properties["connections_dir"] as [number, number];
+ let currentDir = connections_dir[propIdx] as number;
+ // let oppositeDir = connections_dir[oppositeIdx];
+ const options: number[] =
+ this.size[0] === 10
+ ? currentLayout === "Bottom"
+ ? [LiteGraph.DOWN, LiteGraph.RIGHT, LiteGraph.LEFT]
+ : [LiteGraph.UP, LiteGraph.LEFT, LiteGraph.RIGHT]
+ : currentLayout === "Right"
+ ? [LiteGraph.RIGHT, LiteGraph.DOWN, LiteGraph.UP]
+ : [LiteGraph.LEFT, LiteGraph.UP, LiteGraph.DOWN];
+ let idx = options.indexOf(currentDir);
+ let next = options[idx + 1] ?? options[0]!;
+ connections_dir[propIdx] = next;
+ return;
+ }
+
+ let next = currentLayout;
+ do {
+ let idx = LAYOUT_CLOCKWISE.indexOf(next);
+ next = LAYOUT_CLOCKWISE[idx + 1] ?? LAYOUT_CLOCKWISE[0]!;
+ } while (next === oppositeLayout);
+ connections_layout[propIdx] = next;
+ this.setDirtyCanvas(true, true);
+ }
+
+ /**
+ * Handles a mouse move while this node is selected. Note, though, that the actual work here is
+ * processed bycause the move and resize shortcuts set `canvas.node_capturing_input` to this node
+ * when they start (otherwise onMouseMove only fires when the mouse moves within the node's
+ * bounds).
+ */
+ override onMouseMove(event: PointerEvent): void {
+ if (this.shortcuts.move.state) {
+ const shortcut = this.shortcuts.move;
+ if (shortcut.initialMousePos[0] === -1) {
+ shortcut.initialMousePos[0] = event.clientX;
+ shortcut.initialMousePos[1] = event.clientY;
+ shortcut.initialNodePos[0] = this.pos[0];
+ shortcut.initialNodePos[1] = this.pos[1];
+ }
+ this.manuallyHandleMove(event);
+ } else if (this.shortcuts.resize.state) {
+ const shortcut = this.shortcuts.resize;
+ if (shortcut.initialMousePos[0] === -1) {
+ shortcut.initialMousePos[0] = event.clientX;
+ shortcut.initialMousePos[1] = event.clientY;
+ shortcut.initialNodeSize[0] = this.size[0];
+ shortcut.initialNodeSize[1] = this.size[1];
+ shortcut.initialNodePos[0] = this.pos[0];
+ shortcut.initialNodePos[1] = this.pos[1];
+ const canvas = app.canvas as TLGraphCanvas;
+ const offset = canvas.convertEventToCanvasOffset(event);
+ shortcut.resizeOnSide[0] = this.pos[0] > offset[0] ? LiteGraph.LEFT : LiteGraph.RIGHT;
+ shortcut.resizeOnSide[1] = this.pos[1] > offset[1] ? LiteGraph.UP : LiteGraph.DOWN;
+ }
+ this.manuallyHandleResize(event);
+ }
+ }
+
+ /**
+ * Handles a key down while this node is selected, starting a shortcut if the keys are newly
+ * pressed.
+ */
+ override onKeyDown(event: KeyboardEvent) {
+ super.onKeyDown(event);
+ const canvas = app.canvas as TLGraphCanvas;
+
+ // Only handle shortcuts while we're enabled in the config.
+ if (CONFIG_FAST_REROUTE_ENABLED) {
+ for (const [key, shortcut] of Object.entries(this.shortcuts)) {
+ if (!shortcut.state) {
+ const keys = KEY_EVENT_SERVICE.areOnlyKeysDown(shortcut.keys);
+ if (keys) {
+ shortcut.state = true;
+ if (key === "rotate") {
+ this.rotate(90);
+ } else if (key.includes("connection")) {
+ this.cycleConnection(key.includes("input") ? IoDirection.INPUT : IoDirection.OUTPUT);
+ }
+ if ((shortcut as any).initialMousePos) {
+ canvas.node_capturing_input = this;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles a key up while this node is selected, canceling any current shortcut.
+ */
+ override onKeyUp(event: KeyboardEvent) {
+ super.onKeyUp(event);
+ const canvas = app.canvas as TLGraphCanvas;
+
+ // Only handle shortcuts while we're enabled in the config.
+ if (CONFIG_FAST_REROUTE_ENABLED) {
+ for (const [key, shortcut] of Object.entries(this.shortcuts)) {
+ if (shortcut.state) {
+ const keys = KEY_EVENT_SERVICE.areOnlyKeysDown(shortcut.keys);
+ if (!keys) {
+ shortcut.state = false;
+ if ((shortcut as any).initialMousePos) {
+ (shortcut as any).initialMousePos = [-1, -1];
+ if ((canvas.node_capturing_input = this)) {
+ canvas.node_capturing_input = null;
+ }
+ this.setDirtyCanvas(true, true);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles a deselection of the node, canceling any current shortcut.
+ */
+ override onDeselected(): void {
+ super.onDeselected?.();
+ const canvas = app.canvas as TLGraphCanvas;
+ for (const [key, shortcut] of Object.entries(this.shortcuts)) {
+ shortcut.state = false;
+ if ((shortcut as any).initialMousePos) {
+ (shortcut as any).initialMousePos = [-1, -1];
+ if ((canvas.node_capturing_input = this)) {
+ canvas.node_capturing_input = null;
+ }
+ this.setDirtyCanvas(true, true);
+ }
+ }
+ }
+
+ override onRemoved(): void {
+ super.onRemoved?.();
+ // If we're removed, let's call out to the link dragging above. In a settimeout because this is
+ // called as we're removing with further cleanup Litegraph does, and we want the handler to
+ // cleanup further, afterwards
+ setTimeout(() => {
+ SERVICE.handleRemovedNodeMaybeWhileDragging(this);
+ }, 32);
+ }
+
+ override getHelp() {
+ return `
+
+ Finally, a comfortable, powerful reroute node with true multi-direction and powerful
+ shortcuts to bring your workflow to the next level.
+
+
+ ${
+ !CONFIG_FAST_REROUTE_ENABLED
+ ? `Fast Shortcuts are currently disabled.`
+ : `
+
+
+ ${CONFIG_KEY_CREATE_WHILE_LINKING} Create a new reroute node while dragging
+ a link, connecting it to the link in the place and continuing the link.
+
+
+ ${CONFIG_KEY_ROTATE} Rotate the selected reroute node counter clockwise 90
+ degrees.
+
+
+ ${CONFIG_KEY_RESIZE} Resize the selected reroute node from the nearest
+ corner by holding down and moving your mouse.
+
+
+ ${CONFIG_KEY_MOVE} Move the selected reroute node by holding down and
+ moving your mouse.
+
+
+ ${CONFIG_KEY_CXN_INPUT} Change the input layout/direction of the selected
+ reroute node.
+
+
+ ${CONFIG_KEY_CXN_OUTPUT} Change the output layout/direction of the selected
+ reroute node.
+
+
+ `
+ }
+
+ To change, ${!CONFIG_FAST_REROUTE_ENABLED ? "enable" : "disable"} or configure sohrtcuts,
+ make a copy of
+ /custom_nodes/rgthree-comfy/rgthree_config.json.default to
+ /custom_nodes/rgthree-comfy/rgthree_config.json and configure under
+ nodes > reroute > fast_reroute.
+
+ `;
+ }
+}
+
+addMenuItem(RerouteNode, app, {
+ name: (node) => `${node.properties?.["showLabel"] ? "Hide" : "Show"} Label/Title`,
+ property: "showLabel",
+ callback: async (node, value) => {
+ app.graph.setDirtyCanvas(true, true);
+ },
+});
+
+addMenuItem(RerouteNode, app, {
+ name: (node) => `${node.resizable ? "No" : "Allow"} Resizing`,
+ callback: (node) => {
+ (node as RerouteNode).setResizable(!node.resizable);
+ node.size[0] = Math.max(40, node.size[0]);
+ node.size[1] = Math.max(30, node.size[1]);
+ (node as RerouteNode).applyNodeSize();
+ },
+});
+
+addMenuItem(RerouteNode, app, {
+ name: "Static Width",
+ property: "size",
+ subMenuOptions: (() => {
+ const options = [];
+ for (let w = 8; w > 0; w--) {
+ options.push(`${w * 10}`);
+ }
+ return options;
+ })(),
+ prepareValue: (value, node) => [Number(value), node.size[1]],
+ callback: (node) => {
+ (node as RerouteNode).setResizable(false);
+ (node as RerouteNode).applyNodeSize();
+ },
+});
+
+addMenuItem(RerouteNode, app, {
+ name: "Static Height",
+ property: "size",
+ subMenuOptions: (() => {
+ const options = [];
+ for (let w = 8; w > 0; w--) {
+ options.push(`${w * 10}`);
+ }
+ return options;
+ })(),
+ prepareValue: (value, node) => [node.size[0], Number(value)],
+ callback: (node) => {
+ (node as RerouteNode).setResizable(false);
+ (node as RerouteNode).applyNodeSize();
+ },
+});
+
+addConnectionLayoutSupport(
+ RerouteNode,
+ app,
+ [
+ ["Left", "Right"],
+ ["Left", "Top"],
+ ["Left", "Bottom"],
+ ["Right", "Left"],
+ ["Right", "Top"],
+ ["Right", "Bottom"],
+ ["Top", "Left"],
+ ["Top", "Right"],
+ ["Top", "Bottom"],
+ ["Bottom", "Left"],
+ ["Bottom", "Right"],
+ ["Bottom", "Top"],
+ ],
+ (node) => {
+ (node as RerouteNode).applyNodeSize();
+ },
+);
+
+addMenuItem(RerouteNode, app, {
+ name: "Rotate",
+ subMenuOptions: [
+ "Rotate 90° Clockwise",
+ "Rotate 90° Counter-Clockwise",
+ "Rotate 180°",
+ null,
+ "Flip Horizontally",
+ "Flip Vertically",
+ ],
+ callback: (node_: TLGraphNode, value) => {
+ const node = node_ as RerouteNode;
+ if (value?.startsWith("Rotate 90° Clockwise")) {
+ node.rotate(90);
+ } else if (value?.startsWith("Rotate 90° Counter-Clockwise")) {
+ node.rotate(-90);
+ } else if (value?.startsWith("Rotate 180°")) {
+ node.rotate(180);
+ } else {
+ const connections_layout = node.properties["connections_layout"] as [string, string];
+ const inputDirIndex = LAYOUT_CLOCKWISE.indexOf(connections_layout[0]);
+ const outputDirIndex = LAYOUT_CLOCKWISE.indexOf(connections_layout[1]);
+ if (value?.startsWith("Flip Horizontally")) {
+ if (["Left", "Right"].includes(connections_layout[0])) {
+ connections_layout[0] = LAYOUT_CLOCKWISE[(((inputDirIndex + 2) % 4) + 4) % 4]!;
+ }
+ if (["Left", "Right"].includes(connections_layout[1])) {
+ connections_layout[1] = LAYOUT_CLOCKWISE[(((outputDirIndex + 2) % 4) + 4) % 4]!;
+ }
+ } else if (value?.startsWith("Flip Vertically")) {
+ if (["Top", "Bottom"].includes(connections_layout[0])) {
+ connections_layout[0] = LAYOUT_CLOCKWISE[(((inputDirIndex + 2) % 4) + 4) % 4]!;
+ }
+ if (["Top", "Bottom"].includes(connections_layout[1])) {
+ connections_layout[1] = LAYOUT_CLOCKWISE[(((outputDirIndex + 2) % 4) + 4) % 4]!;
+ }
+ }
+ }
+ },
+});
+
+addMenuItem(RerouteNode, app, {
+ name: "Clone New Reroute...",
+ subMenuOptions: ["Before", "After"],
+ callback: async (node, value) => {
+ const clone = node.clone()!;
+ const pos = [...node.pos];
+ if (value === "Before") {
+ clone.pos = [pos[0]! - 20, pos[1]! - 20];
+ app.graph.add(clone);
+ await wait();
+ const inputLinks = getSlotLinks(node.inputs[0]);
+ for (const inputLink of inputLinks) {
+ const link = inputLink.link;
+ const linkedNode = app.graph.getNodeById(link.origin_id) as TLGraphNode;
+ if (linkedNode) {
+ linkedNode.connect(0, clone, 0);
+ }
+ }
+ clone.connect(0, node, 0);
+ } else {
+ clone.pos = [pos[0]! + 20, pos[1]! + 20];
+ app.graph.add(clone);
+ await wait();
+ const outputLinks = getSlotLinks(node.outputs[0]);
+ node.connect(0, clone, 0);
+ for (const outputLink of outputLinks) {
+ const link = outputLink.link;
+ const linkedNode = app.graph.getNodeById(link.target_id) as TLGraphNode;
+ if (linkedNode) {
+ clone.connect(0, linkedNode, link.target_slot);
+ }
+ }
+ }
+ },
+});
+
+app.registerExtension({
+ name: "rgthree.Reroute",
+ registerCustomNodes() {
+ RerouteNode.setUp();
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss b/custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss
new file mode 100644
index 0000000000000000000000000000000000000000..4df9c3a9cf890133029fb8fb8dca2e4b615ff80f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss
@@ -0,0 +1,328 @@
+.rgthree-top-messages-container {
+ position: fixed;
+ z-index: 9999;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: start;
+}
+
+.rgthree-top-messages-container > div {
+ position: relative;
+ height: fit-content;
+ padding: 4px;
+ margin-top: -100px; /* re-set by JS */
+ opacity: 0;
+ transition: all 0.33s ease-in-out;
+ z-index: 3;
+}
+.rgthree-top-messages-container > div:last-child {
+ z-index: 2;
+}
+.rgthree-top-messages-container > div:not(.-show) {
+ z-index: 1;
+}
+
+.rgthree-top-messages-container > div.-show {
+ opacity: 1;
+ margin-top: 0px !important;
+}
+
+.rgthree-top-messages-container > div.-show {
+ opacity: 1;
+ transform: translateY(0%);
+}
+
+.rgthree-top-messages-container > div > div {
+ position: relative;
+ background: #353535;
+ color: #fff;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ height: fit-content;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.88);
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-family: Arial, sans-serif;
+ font-size: 14px;
+}
+.rgthree-top-messages-container > div > div > span {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
+.rgthree-top-messages-container > div > div > span svg {
+ width: 20px;
+ height: auto;
+ margin-right: 8px;
+}
+.rgthree-top-messages-container > div > div > span svg.icon-checkmark {
+ fill: #2e9720;
+}
+
+.rgthree-top-messages-container [type="warn"]::before,
+.rgthree-top-messages-container [type="success"]::before {
+ content: '⚠️';
+ display: inline-block;
+ flex: 0 0 auto;
+ font-size: 18px;
+ margin-right: 4px;
+ line-height: 1;
+}
+.rgthree-top-messages-container [type="success"]::before {
+ content: '🎉';
+}
+
+.rgthree-top-messages-container a {
+ cursor: pointer;
+ text-decoration: underline;
+ color: #fc0;
+ margin-left: 4px;
+ display: inline-block;
+ line-height: 1;
+}
+
+.rgthree-top-messages-container a:hover {
+ color: #fc0;
+ text-decoration: none;
+}
+
+/* Fix node selector being crazy long b/c of array types. */
+.litegraph.litesearchbox input,
+.litegraph.litesearchbox select {
+ max-width: 250px;
+}
+
+/* There's no reason for this z-index to be so high. It layers on top of things it shouldn't,
+ (like pythongssss' image gallery, the properties panel, etc.) */
+.comfy-multiline-input {
+ z-index: 1 !important;
+}
+.comfy-multiline-input:focus {
+ z-index: 2 !important;
+}
+.litegraph .dialog {
+ z-index: 3 !important; /* This is set to 1, but goes under the multi-line inputs, so bump it. */
+}
+
+
+@import '../common/css/buttons.scss';
+@import '../common/css/dialog.scss';
+@import '../common/css/menu.scss';
+
+.rgthree-dialog.-settings {
+ width: 100%;
+}
+.rgthree-dialog.-settings fieldset {
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ padding: 0 12px 8px;
+ margin-bottom: 16px;
+}
+.rgthree-dialog.-settings fieldset > legend {
+ margin-left: 8px;
+ padding: 0 8px;
+ opacity: 0.5;
+}
+.rgthree-dialog.-settings .formrow {
+ display: flex;
+ flex-direction: column;
+}
+.rgthree-dialog.-settings .formrow + .formrow {
+ border-top: 1px solid rgba(255, 255, 255, 0.25);
+}
+.rgthree-dialog.-settings .fieldrow {
+ display: flex;
+ flex-direction: row;
+}
+.rgthree-dialog.-settings .fieldrow > label {
+ flex: 1 1 auto;
+ user-select: none;
+ padding: 8px 12px 12px;
+}
+.rgthree-dialog.-settings .fieldrow > label span {
+ font-weight: bold;
+}
+.rgthree-dialog.-settings .fieldrow > label small {
+ display: block;
+ margin-top: 4px;
+ font-size: calc(11rem / 16);
+ opacity: 0.75;
+ padding-left: 16px;
+}
+.rgthree-dialog.-settings .fieldrow ~ .fieldrow {
+ font-size: 0.9rem;
+ border-top: 1px dotted rgba(255, 255, 255, 0.25);
+}
+.rgthree-dialog.-settings .fieldrow ~ .fieldrow label {
+ padding-left: 28px;
+}
+.rgthree-dialog.-settings .fieldrow:first-child:not(.-checked) ~ .fieldrow {
+ display: none;
+}
+.rgthree-dialog.-settings .fieldrow:hover {
+ background: rgba(255,255,255,0.1);
+}
+.rgthree-dialog.-settings .fieldrow ~ .fieldrow span {
+ font-weight: normal;
+}
+
+.rgthree-dialog.-settings .fieldrow > .fieldrow-value {
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ flex: 0 0 auto;
+ width: 50%;
+ max-width: 230px;
+}
+.rgthree-dialog.-settings .fieldrow.-type-boolean > .fieldrow-value {
+ max-width: 64px;
+}
+.rgthree-dialog.-settings .fieldrow.-type-number input {
+ width: 48px;
+ text-align: right;
+}
+
+.rgthree-dialog.-settings .fieldrow input[type="checkbox"] {
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+}
+
+.rgthree-dialog.-settings .fieldrow fieldset.rgthree-checklist-group {
+ padding: 0;
+ border: 0;
+ margin: 0;
+
+ > span.rgthree-checklist-item {
+ display: inline-block;
+ white-space: nowrap;
+ padding-right: 6px;
+ vertical-align: middle;
+
+ input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ }
+
+ label {
+ padding-left: 4px;
+ text-align: left;
+ cursor: pointer;
+ }
+ }
+}
+
+.rgthree-comfyui-settings-row div {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: end;
+}
+.rgthree-comfyui-settings-row div svg {
+ width: 36px;
+ height: 36px;
+ margin-right: 16px;
+}
+
+
+.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy,
+.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: start;
+}
+
+.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy svg,
+.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-item svg {
+ fill: currentColor;
+ width: auto;
+ height: 16px;
+ margin-right: 6px;
+}
+.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-item svg.github-star {
+ fill: rgb(227, 179, 65);
+}
+
+.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy,
+.litegraph.litecontextmenu .litemenu-entry.rgthree-contextmenu-label {
+ color: #dde;
+ background-color: #212121 !important;
+ margin: 0;
+ padding: 2px;
+ cursor: default;
+ opacity: 1;
+ padding: 4px;
+ font-weight: bold;
+}
+.litegraph.litecontextmenu .litemenu-title .rgthree-contextmenu-title-rgthree-comfy {
+ font-size: 1.1em;
+ color: #fff;
+ background-color: #090909 !important;
+ justify-content: center;
+ padding: 4px 8px;
+}
+
+rgthree-progress-bar {
+ display: block;
+ position: relative;
+ z-index: 999;
+ top: 0;
+ left: 0;
+ height: 14px;
+ font-size: 10px;
+ width: 100%;
+ overflow: hidden;
+ box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.25);
+ box-shadow:
+ inset 0px -1px 0px rgba(0, 0, 0, 0.25),
+ 0px 1px 0px rgba(255, 255, 255, 0.125);
+
+}
+
+* ~ rgthree-progress-bar,
+.comfyui-body-bottom rgthree-progress-bar {
+ box-shadow:
+ 0px -1px 0px rgba(0, 0, 0, 1),
+ inset 0px 1px 0px rgba(255, 255, 255, 0.15), inset 0px -1px 0px rgba(0, 0, 0, 0.25), 0px 1px 0px rgba(255, 255, 255, 0.125);
+}
+
+body:not([style*=grid]):not([class*=grid]) {
+ rgthree-progress-bar {
+ position: fixed;
+ top: 0px;
+ bottom: auto;
+ }
+ rgthree-progress-bar.rgthree-pos-bottom {
+ top: auto;
+ bottom: 0px;
+ }
+}
+
+
+.rgthree-debug-keydowns {
+ display: block;
+ position: fixed;
+ z-index: 1050;
+ top: 3px;
+ right: 8px;
+ font-size: 10px;
+ color: #fff;
+ font-family: sans-serif;
+ pointer-events: none;
+}
+
+
+.rgthree-comfy-about-badge-logo {
+ width: 20px;
+ height: 20px;
+ background: url(/rgthree/logo.svg?bg=transparent&fg=%2393c5fd);
+ background-size: 100% 100%;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.ts
new file mode 100644
index 0000000000000000000000000000000000000000..206cb938be881d121361fc1c35581c47367b7a46
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.ts
@@ -0,0 +1,1062 @@
+import type {
+ LGraphCanvas as TLGraphCanvas,
+ LGraphNode,
+ IContextMenuValue,
+ LGraph as TLGraph,
+ IContextMenuOptions,
+ ISerialisedGraph,
+ CanvasMouseEvent,
+ CanvasPointerExtensions,
+ NodeId,
+ ISerialisedNode,
+ Positionable,
+ ComfyApp,
+} from "@comfyorg/frontend";
+import type {ComfyApiFormat, ComfyApiPrompt} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {api} from "scripts/api.js";
+import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
+import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
+import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
+import {WorkflowLinkFixer} from "rgthree/common/link_fixer.js";
+import {injectCss, wait} from "rgthree/common/shared_utils.js";
+import {replaceNode, waitForCanvas, waitForGraph} from "./utils.js";
+import {NodeTypesString, addRgthree, getNodeTypeStrings} from "./constants.js";
+import {RgthreeProgressBar} from "rgthree/common/progress_bar.js";
+import {RgthreeConfigDialog} from "./config.js";
+import {
+ iconGear,
+ iconNode,
+ iconReplace,
+ iconStarFilled,
+ logoRgthree,
+} from "rgthree/common/media/svgs.js";
+import {createElement, queryAll, query} from "rgthree/common/utils_dom.js";
+
+export enum LogLevel {
+ IMPORTANT = 1,
+ ERROR,
+ WARN,
+ INFO,
+ DEBUG,
+ DEV,
+}
+
+const LogLevelKeyToLogLevel: {[key: string]: LogLevel} = {
+ IMPORTANT: LogLevel.IMPORTANT,
+ ERROR: LogLevel.ERROR,
+ WARN: LogLevel.WARN,
+ INFO: LogLevel.INFO,
+ DEBUG: LogLevel.DEBUG,
+ DEV: LogLevel.DEV,
+};
+
+type ConsoleLogFns = "log" | "error" | "warn" | "debug" | "info";
+const LogLevelToMethod: {[key in LogLevel]: ConsoleLogFns} = {
+ [LogLevel.IMPORTANT]: "log",
+ [LogLevel.ERROR]: "error",
+ [LogLevel.WARN]: "warn",
+ [LogLevel.INFO]: "info",
+ [LogLevel.DEBUG]: "log",
+ [LogLevel.DEV]: "log",
+};
+const LogLevelToCSS: {[key in LogLevel]: string} = {
+ [LogLevel.IMPORTANT]: "font-weight: bold; color: blue;",
+ [LogLevel.ERROR]: "",
+ [LogLevel.WARN]: "",
+ [LogLevel.INFO]: "font-style: italic; color: blue;",
+ [LogLevel.DEBUG]: "font-style: italic; color: #444;",
+ [LogLevel.DEV]: "color: #004b68;",
+};
+
+let GLOBAL_LOG_LEVEL = LogLevel.ERROR;
+
+/**
+ * At some point in Summer of 2024 ComfyUI broke third-party api calls by assuming api paths follow
+ * a certain structure. However, rgthree-comfy wants an `/rgthree/` prefix for that same reason, so
+ * we overwrite the apiUrl method to fix.
+ */
+const apiURL = api.apiURL;
+api.apiURL = function (route: string): string {
+ if (route.includes("rgthree/")) {
+ return (this.api_base + "/" + route).replace(/\/\//g, "/");
+ }
+ return apiURL.apply(this, arguments as any);
+};
+
+/**
+ * A blocklist of extensions to disallow hooking into rgthree's base classes when calling the
+ * `rgthree.invokeExtensionsAsync` method (which runs outside of ComfyNode's
+ * `app.invokeExtensionsAsync` which is private).
+ *
+ * In Apr 2024 the base rgthree node class added support for other extensions using `nodeCreated`
+ * and `beforeRegisterNodeDef` which allows other extensions to modify the class. However, since it
+ * had been months since divorcing the ComfyNode in rgthree-comfy due to instability and
+ * inflexibility, this was a bit risky as other extensions hadn't ever run with this ability. This
+ * list attempts to block extensions from being able to call into rgthree-comfy nodes via the
+ * `nodeCreated` and `beforeRegisterNodeDef` callbacks now that rgthree-comfy is utilizing them
+ * because they do not work. Oddly, it's ComfyUI's own extension that is broken.
+ */
+const INVOKE_EXTENSIONS_BLOCKLIST = [
+ {
+ name: "Comfy.WidgetInputs",
+ reason:
+ "Major conflict with rgthree-comfy nodes' inputs causing instability and " +
+ "repeated link disconnections.",
+ },
+ {
+ name: "efficiency.widgethider",
+ reason:
+ "Overrides value getter before widget getter is prepared. Can be lifted if/when " +
+ "https://github.com/jags111/efficiency-nodes-comfyui/pull/203 is pulled.",
+ },
+];
+
+/** A basic wrapper around logger. */
+class Logger {
+ /** Logs a message to the console if it meets the current log level. */
+ log(level: LogLevel, message: string, ...args: any[]) {
+ const [n, v] = this.logParts(level, message, ...args);
+ console[n]?.(...v);
+ }
+
+ /**
+ * Returns a tuple of the console function and its arguments. Useful for callers to make the
+ * actual console. call to gain benefits of DevTools knowing the source line.
+ *
+ * If the input is invalid or the level doesn't meet the configuration level, then the return
+ * value is an unknown function and empty set of values. Callers can use optionla chaining
+ * successfully:
+ *
+ * const [fn, values] = logger.logPars(LogLevel.INFO, 'my message');
+ * console[fn]?.(...values); // Will work even if INFO won't be logged.
+ *
+ */
+ logParts(level: LogLevel, message: string, ...args: any[]): [ConsoleLogFns, any[]] {
+ if (level <= GLOBAL_LOG_LEVEL) {
+ const css = LogLevelToCSS[level] || "";
+ if (level === LogLevel.DEV) {
+ message = `🔧 ${message}`;
+ }
+ return [LogLevelToMethod[level], [`%c${message}`, css, ...args]];
+ }
+ return ["none" as "info", []];
+ }
+}
+
+/**
+ * A log session, with the name as the prefix. A new session will stack prefixes.
+ */
+class LogSession {
+ readonly logger = new Logger();
+ readonly logsCache: {[key: string]: {lastShownTime: number}} = {};
+
+ constructor(readonly name?: string) {}
+
+ /**
+ * Returns the console log method to use and the arguments to pass so the call site can log from
+ * there. This extra work at the call site allows for easier debugging in the dev console.
+ *
+ * const [logMethod, logArgs] = logger.logParts(LogLevel.DEBUG, message, ...args);
+ * console[logMethod]?.(...logArgs);
+ */
+ logParts(level: LogLevel, message?: string, ...args: any[]): [ConsoleLogFns, any[]] {
+ message = `${this.name || ""}${message ? " " + message : ""}`;
+ return this.logger.logParts(level, message, ...args);
+ }
+
+ logPartsOnceForTime(
+ level: LogLevel,
+ time: number,
+ message?: string,
+ ...args: any[]
+ ): [ConsoleLogFns, any[]] {
+ message = `${this.name || ""}${message ? " " + message : ""}`;
+ const cacheKey = `${level}:${message}`;
+ const cacheEntry = this.logsCache[cacheKey];
+ const now = +new Date();
+ if (cacheEntry && cacheEntry.lastShownTime + time > now) {
+ return ["none" as "info", []];
+ }
+ const parts = this.logger.logParts(level, message, ...args);
+ if (console[parts[0]]) {
+ this.logsCache[cacheKey] = this.logsCache[cacheKey] || ({} as {lastShownTime: number});
+ this.logsCache[cacheKey]!.lastShownTime = now;
+ }
+ return parts;
+ }
+
+ debugParts(message?: string, ...args: any[]) {
+ return this.logParts(LogLevel.DEBUG, message, ...args);
+ }
+
+ infoParts(message?: string, ...args: any[]) {
+ return this.logParts(LogLevel.INFO, message, ...args);
+ }
+
+ warnParts(message?: string, ...args: any[]) {
+ return this.logParts(LogLevel.WARN, message, ...args);
+ }
+
+ errorParts(message?: string, ...args: any[]) {
+ return this.logParts(LogLevel.ERROR, message, ...args);
+ }
+
+ newSession(name?: string) {
+ return new LogSession(`${this.name}${name}`);
+ }
+}
+
+export type RgthreeUiMessage = {
+ id: string;
+ message: string;
+ type?: "warn" | "info" | "success" | null;
+ timeout?: number;
+ // closeable?: boolean; // TODO
+ actions?: Array<{
+ label: string;
+ href?: string;
+ callback?: (event: MouseEvent) => void;
+ }>;
+};
+
+/**
+ * A global class as 'rgthree'; exposed on wiindow. Lots can go in here.
+ */
+class Rgthree extends EventTarget {
+ /** Exposes the ComfyUI api instance on rgthree. */
+ readonly api = api;
+ private settingsDialog: RgthreeConfigDialog | null = null;
+ private progressBarEl: RgthreeProgressBar | null = null;
+ private rgthreeCssPromise: Promise;
+
+ /** Stores a node id that we will use to queu only that output node (with `queueOutputNode`). */
+ private queueNodeIds: NodeId[] | null = null;
+
+ readonly version = CONFIG_SERVICE.getConfigValue("version");
+
+ logger = new LogSession("[rgthree]");
+
+ monitorBadLinksAlerted = false;
+ monitorLinkTimeout: number | null = null;
+
+ processingQueue = false;
+ /**
+ * The API Json currently being loaded, or null. Can be used as a falsy boolean to determine if
+ * `app.loadApiJson` is currently executing.
+ */
+ loadingApiJson: ComfyApiFormat | null = null;
+ replacingReroute: NodeId | null = null;
+ processingMouseDown = false;
+ processingMouseUp = false;
+ processingMouseMove = false;
+ lastCanvasMouseEvent: CanvasMouseEvent | null = null;
+
+ // Comfy/LiteGraph states so nodes and tell what the hell is going on.
+ canvasCurrentlyCopyingToClipboard = false;
+ canvasCurrentlyCopyingToClipboardWithMultipleNodes = false;
+ canvasCurrentlyPastingFromClipboard = false;
+ canvasCurrentlyPastingFromClipboardWithMultipleNodes = false;
+ initialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff: any = null;
+
+ private readonly isMac: boolean = !!(
+ navigator.platform?.toLocaleUpperCase().startsWith("MAC") ||
+ (navigator as any).userAgentData?.platform?.toLocaleUpperCase().startsWith("MAC")
+ );
+
+ constructor() {
+ super();
+
+ const logLevel =
+ LogLevelKeyToLogLevel[CONFIG_SERVICE.getConfigValue("log_level")] ?? GLOBAL_LOG_LEVEL;
+ this.setLogLevel(logLevel);
+
+ this.initializeGraphAndCanvasHooks();
+ this.initializeComfyUIHooks();
+ this.initializeContextMenu();
+
+ this.rgthreeCssPromise = injectCss("extensions/rgthree-comfy/rgthree.css");
+
+ this.initializeProgressBar();
+
+ CONFIG_SERVICE.addEventListener("config-change", ((e: CustomEvent) => {
+ if (e.detail?.key?.includes("features.progress_bar")) {
+ this.initializeProgressBar();
+ }
+ }) as EventListener);
+
+ if (CONFIG_SERVICE.getConfigValue("debug.keys_down.enabled")) {
+ const elDebugKeydowns = createElement("div.rgthree-debug-keydowns", {
+ parent: document.body,
+ });
+ const updateDebugKeyDown = () => {
+ elDebugKeydowns.innerText = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" ");
+ };
+ KEY_EVENT_SERVICE.addEventListener("keydown", updateDebugKeyDown);
+ KEY_EVENT_SERVICE.addEventListener("keyup", updateDebugKeyDown);
+ }
+ }
+
+ /**
+ * Initializes the top progress bar, if it's configured.
+ */
+ async initializeProgressBar() {
+ if (CONFIG_SERVICE.getConfigValue("features.progress_bar.enabled")) {
+ await this.rgthreeCssPromise;
+ if (!this.progressBarEl) {
+ this.progressBarEl = RgthreeProgressBar.create();
+ this.progressBarEl.setAttribute(
+ "title",
+ "Progress Bar by rgthree. right-click for rgthree menu.",
+ );
+
+ this.progressBarEl.addEventListener("contextmenu", async (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ });
+
+ this.progressBarEl.addEventListener("pointerdown", async (e) => {
+ LiteGraph.closeAllContextMenus();
+ if (e.button == 2) {
+ const canvas = await waitForCanvas();
+ new LiteGraph.ContextMenu(this.getRgthreeIContextMenuValues(), {
+ title: ``,
+ left: e.clientX,
+ top: 5,
+ });
+ return;
+ }
+ if (e.button == 0) {
+ const nodeId = this.progressBarEl?.currentNodeId;
+ if (nodeId) {
+ const [canvas, graph] = await Promise.all([waitForCanvas(), waitForGraph()]);
+ const node = graph.getNodeById(Number(nodeId));
+ if (node) {
+ canvas.centerOnNode(node);
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
+ return;
+ }
+ });
+ }
+ // Handle both cases in case someone hasn't updated. Can probably just assume
+ // `isUpdatedComfyBodyClasses` is true in the near future.
+ const isUpdatedComfyBodyClasses = !!query(".comfyui-body-top");
+ const position = CONFIG_SERVICE.getConfigValue("features.progress_bar.position");
+ this.progressBarEl.classList.toggle("rgthree-pos-bottom", position === "bottom");
+ // If ComfyUI is updated with the body segments, then use that.
+ if (isUpdatedComfyBodyClasses) {
+ if (position === "bottom") {
+ query(".comfyui-body-bottom")!.appendChild(this.progressBarEl);
+ } else {
+ query(".comfyui-body-top")!.appendChild(this.progressBarEl);
+ }
+ } else {
+ document.body.appendChild(this.progressBarEl);
+ }
+ const height = CONFIG_SERVICE.getConfigValue("features.progress_bar.height") || 14;
+ this.progressBarEl.style.height = `${height}px`;
+ const fontSize = Math.max(10, Number(height) - 10);
+ this.progressBarEl.style.fontSize = `${fontSize}px`;
+ this.progressBarEl.style.fontWeight = fontSize <= 12 ? "bold" : "normal";
+ } else {
+ this.progressBarEl?.remove();
+ }
+ }
+
+ /**
+ * Initialize a bunch of hooks into LiteGraph itself so we can either keep state or context on
+ * what's happening so nodes can respond appropriately. This is usually to fix broken assumptions
+ * in the unowned code [🤮], but sometimes to add features or enhancements too [⭐].
+ */
+ private async initializeGraphAndCanvasHooks() {
+ const rgthree = this;
+
+ // [🤮] To mitigate changes from https://github.com/rgthree/rgthree-comfy/issues/69
+ // and https://github.com/comfyanonymous/ComfyUI/issues/2193 we can try to store the workflow
+ // node so our nodes can find the seralized node. Works with method
+ // `getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff` to find a node
+ // while serializing. What a way to work around...
+ const graphSerialize = LGraph.prototype.serialize;
+ LGraph.prototype.serialize = function () {
+ const response = graphSerialize.apply(this, [...arguments] as any) as any;
+ rgthree.initialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff = response;
+ return response;
+ };
+
+ // Overrides LiteGraphs' processMouseDown to both keep state as well as dispatch a custom event.
+ const processMouseDown = LGraphCanvas.prototype.processMouseDown;
+ LGraphCanvas.prototype.processMouseDown = function (e: PointerEvent) {
+ rgthree.processingMouseDown = true;
+ const returnVal = processMouseDown.apply(this, [...arguments] as any);
+ rgthree.dispatchCustomEvent("on-process-mouse-down", {originalEvent: e});
+ rgthree.processingMouseDown = false;
+ return returnVal;
+ };
+
+ // Overrides LiteGraph's `adjustMouseEvent` to capture the last even coming in and out. Useful
+ // to capture the last `canvasX` and `canvasY` properties, which are not the same as LiteGraph's
+ // `canvas.last_mouse_position`, unfortunately.
+ const adjustMouseEvent = LGraphCanvas.prototype.adjustMouseEvent;
+ LGraphCanvas.prototype.adjustMouseEvent = function (
+ e: T & Partial,
+ ): asserts e is T & CanvasMouseEvent {
+ adjustMouseEvent.apply(this, [...arguments] as any);
+ rgthree.lastCanvasMouseEvent = e as CanvasMouseEvent;
+ };
+
+ // [🤮] Copying to clipboard clones nodes and then manipulates the linking data manually which
+ // does not allow a node to handle connections. This harms nodes that manually handle inputs,
+ // like our any-input nodes that may start with one input, and manually add new ones when one is
+ // attached.
+ const copyToClipboard = LGraphCanvas.prototype.copyToClipboard;
+ LGraphCanvas.prototype.copyToClipboard = function (items?: Iterable) {
+ rgthree.canvasCurrentlyCopyingToClipboard = true;
+ rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes =
+ Object.values(items || this.selected_nodes || []).length > 1;
+ const value = copyToClipboard.apply(this, [...arguments] as any);
+ rgthree.canvasCurrentlyCopyingToClipboard = false;
+ rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes = false;
+ return value;
+ };
+
+ // [🤮] Pasting from clipboard.
+ const pasteFromClipboard = LGraphCanvas.prototype.pasteFromClipboard;
+ LGraphCanvas.prototype.pasteFromClipboard = function (...args: any[]) {
+ rgthree.canvasCurrentlyPastingFromClipboard = true;
+ pasteFromClipboard.apply(this, [...arguments] as any);
+ rgthree.canvasCurrentlyPastingFromClipboard = false;
+ };
+
+ // [⭐] Make it so when we add a group, we get to name it immediately.
+ const onGroupAdd = LGraphCanvas.onGroupAdd;
+ LGraphCanvas.onGroupAdd = function (...args: any[]) {
+ const graph = app.canvas.getCurrentGraph()!;
+ onGroupAdd.apply(this, [...args] as any);
+ // [🤮] Bad typing here.. especially the last arg; it is LGraphNode but can really be anything
+ // with pos or size... pity. See more in our litegraph.d.ts.
+ LGraphCanvas.onShowPropertyEditor(
+ {} as any,
+ null as any,
+ null as any,
+ null as any,
+ graph._groups[graph._groups.length - 1]! as unknown as LGraphNode,
+ );
+ };
+ }
+
+ /**
+ * [🤮] Handles the same exact thing as ComfyApp's `invokeExtensionsAsync`, but done here since
+ * it is #private in ComfyApp because... of course it us. This is necessary since we purposefully
+ * avoid using the ComfyNode due to historical instability and inflexibility for all the advanced
+ * ui stuff rgthree-comfy nodes do, but we can still have other custom nodes know what's happening
+ * with rgthree-comfy; specifically, for `nodeCreated` as of now.
+ */
+ async invokeExtensionsAsync(method: "nodeCreated", ...args: any[]) {
+ const comfyapp = app as ComfyApp;
+ if (CONFIG_SERVICE.getConfigValue("features.invoke_extensions_async.node_created") === false) {
+ const [m, a] = this.logParts(
+ LogLevel.INFO,
+ `Skipping invokeExtensionsAsync for applicable rgthree-comfy nodes`,
+ );
+ console[m]?.(...a);
+ return Promise.resolve();
+ }
+ return await Promise.all(
+ comfyapp.extensions.map(async (ext) => {
+ if (ext?.[method]) {
+ try {
+ const blocked = INVOKE_EXTENSIONS_BLOCKLIST.find((block) =>
+ ext.name.toLowerCase().startsWith(block.name.toLowerCase()),
+ );
+ if (blocked) {
+ const [n, v] = this.logger.logPartsOnceForTime(
+ LogLevel.WARN,
+ 5000,
+ `Blocked extension '${ext.name}' method '${method}' for rgthree-nodes because: ${blocked.reason}`,
+ );
+ console[n]?.(...v);
+ return Promise.resolve();
+ }
+ return await (ext[method] as Function)(...args, comfyapp);
+ } catch (error) {
+ const [n, v] = this.logParts(
+ LogLevel.ERROR,
+ `Error calling extension '${ext.name}' method '${method}' for rgthree-node.`,
+ {error},
+ {extension: ext},
+ {args},
+ );
+ console[n]?.(...v);
+ }
+ }
+ }),
+ );
+ }
+
+ /**
+ * Wraps `dispatchEvent` for easier CustomEvent dispatching.
+ */
+ private dispatchCustomEvent(event: string, detail?: any) {
+ if (detail != null) {
+ return this.dispatchEvent(new CustomEvent(event, {detail}));
+ }
+ return this.dispatchEvent(new CustomEvent(event));
+ }
+
+ /**
+ * Initializes hooks specific to an rgthree-comfy context menu on the root menu.
+ */
+ private async initializeContextMenu() {
+ const that = this;
+ setTimeout(async () => {
+ const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ let existingOptions = getCanvasMenuOptions.apply(this, [...args] as any);
+
+ const options: (IContextMenuValue | null)[] = [];
+ options.push(null); // Divider
+ options.push(null); // Divider
+ options.push(null); // Divider
+ options.push({
+ content: logoRgthree + `rgthree-comfy`,
+ className: "rgthree-contextmenu-item rgthree-contextmenu-main-item-rgthree-comfy",
+ submenu: {
+ options: that.getRgthreeIContextMenuValues(),
+ },
+ });
+ options.push(null); // Divider
+ options.push(null); // Divider
+
+ let idx = null;
+ idx = idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Queue Group")) + 1;
+ idx =
+ idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Queue Selected")) + 1;
+ idx = idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Convert to Group"));
+ idx = idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Arrange ("));
+ idx = idx || existingOptions.findIndex((o) => !o) + 1;
+ idx = idx || 3;
+ // [🤮] existingOptions is typed as IContextMenuValue even though it need not be
+ // a string due to the crazy typing from the original litegraph. oh well.
+ (existingOptions as (IContextMenuValue | null)[]).splice(idx, 0, ...options);
+ for (let i = existingOptions.length; i > 0; i--) {
+ if (existingOptions[i] === null && existingOptions[i + 1] === null) {
+ existingOptions.splice(i, 1);
+ }
+ }
+
+ return existingOptions;
+ };
+ }, 1016);
+ }
+
+ /**
+ * Returns the standard menu items for an rgthree-comfy context menu.
+ */
+ private getRgthreeIContextMenuValues(): IContextMenuValue[] {
+ const [canvas, graph] = [app.canvas as TLGraphCanvas, app.canvas.getCurrentGraph()!];
+ const selectedNodes = Object.values(canvas.selected_nodes || {});
+ let rerouteNodes: LGraphNode[] = [];
+ if (selectedNodes.length) {
+ rerouteNodes = selectedNodes.filter((n) => n.type === "Reroute");
+ } else {
+ rerouteNodes = graph._nodes.filter((n) => n.type == "Reroute");
+ }
+ const rerouteLabel = selectedNodes.length ? "selected" : "all";
+
+ const showBookmarks = CONFIG_SERVICE.getFeatureValue("menu_bookmarks.enabled");
+ const bookmarkMenuItems = showBookmarks ? getBookmarks() : [];
+
+ return [
+ {
+ content: "Nodes",
+ disabled: true,
+ className: "rgthree-contextmenu-item rgthree-contextmenu-label",
+ },
+ {
+ content: iconNode + "All",
+ className: "rgthree-contextmenu-item",
+ has_submenu: true,
+ submenu: {
+ options: getNodeTypeStrings() as unknown as IContextMenuValue[],
+ callback: (
+ value: string | IContextMenuValue,
+ options: IContextMenuOptions,
+ event: MouseEvent,
+ ) => {
+ const node = LiteGraph.createNode(addRgthree(value as string));
+ if (node) {
+ node.pos = [
+ rgthree.lastCanvasMouseEvent!.canvasX,
+ rgthree.lastCanvasMouseEvent!.canvasY,
+ ];
+ canvas.graph!.add(node);
+ canvas.selectNode(node);
+ graph.setDirtyCanvas(true, true);
+ }
+ },
+ extra: {rgthree_doNotNest: true},
+ },
+ },
+
+ {
+ content: "Actions",
+ disabled: true,
+ className: "rgthree-contextmenu-item rgthree-contextmenu-label",
+ },
+ {
+ content: iconGear + "Settings (rgthree-comfy)",
+ disabled: !!this.settingsDialog,
+ className: "rgthree-contextmenu-item",
+ callback: (...args: any[]) => {
+ this.settingsDialog = new RgthreeConfigDialog().show();
+ this.settingsDialog.addEventListener("close", (e) => {
+ this.settingsDialog = null;
+ });
+ },
+ },
+ {
+ content: iconReplace + ` Convert ${rerouteLabel} Reroutes`,
+ disabled: !rerouteNodes.length,
+ className: "rgthree-contextmenu-item",
+ callback: (...args: any[]) => {
+ const msg =
+ `Convert ${rerouteLabel} ComfyUI Reroutes to Reroute (rgthree) nodes? \n` +
+ `(First save a copy of your workflow & check reroute connections afterwards)`;
+ if (!window.confirm(msg)) {
+ return;
+ }
+ (async () => {
+ for (const node of [...rerouteNodes]) {
+ if (node.type == "Reroute") {
+ this.replacingReroute = node.id;
+ await replaceNode(node, NodeTypesString.REROUTE);
+ this.replacingReroute = null;
+ }
+ }
+ })();
+ },
+ },
+ ...bookmarkMenuItems,
+ {
+ content: "More...",
+ disabled: true,
+ className: "rgthree-contextmenu-item rgthree-contextmenu-label",
+ },
+ {
+ content: iconStarFilled + "Star on Github",
+ className: "rgthree-contextmenu-item rgthree-contextmenu-github",
+ callback: (...args: any[]) => {
+ window.open("https://github.com/rgthree/rgthree-comfy", "_blank");
+ },
+ },
+ ];
+ }
+
+ /**
+ * Wraps an `app.queuePrompt` call setting a specific node id that we will inspect and change the
+ * serialized graph right before being sent (below, in our `api.queuePrompt` override).
+ */
+ async queueOutputNodes(nodes: LGraphNode[]) {
+ // We can use just these next two lines when
+ // https://github.com/ltdrdata/ComfyUI-Inspire-Pack/pull/258 is pulled. Until then, we'll keep
+ // our custom logic as ComfyUI-Inspire-Pack would cause it not to work.
+ // app.canvas.selectItems(nodes);
+ // app.extensionManager.command.execute('Comfy.QueueSelectedOutputNodes');
+ const nodeIds = nodes.map((n) => n.id);
+ try {
+ this.queueNodeIds = nodeIds;
+ await app.queuePrompt(0);
+ } catch (e) {
+ const [n, v] = this.logParts(
+ LogLevel.ERROR,
+ `There was an error queuing nodes ${nodeIds}`,
+ e,
+ );
+ console[n]?.(...v);
+ } finally {
+ this.queueNodeIds = null;
+ }
+ }
+
+ /**
+ * Recusively walks backwards from a node adding its inputs to the `newOutput` from `oldOutput`.
+ */
+ private recursiveAddNodes(nodeId: string, oldOutput: ComfyApiFormat, newOutput: ComfyApiFormat) {
+ let currentId = nodeId;
+ let currentNode = oldOutput[currentId]!;
+ if (newOutput[currentId] == null) {
+ newOutput[currentId] = currentNode;
+ for (const inputValue of Object.values(currentNode.inputs || [])) {
+ if (Array.isArray(inputValue)) {
+ this.recursiveAddNodes(inputValue[0], oldOutput, newOutput);
+ }
+ }
+ }
+ return newOutput;
+ }
+
+ /**
+ * Initialize a bunch of hooks into ComfyUI and/or LiteGraph itself so we can either keep state or
+ * context on what's happening so nodes can respond appropriately. This is usually to fix broken
+ * assumptions in the unowned code [🤮], but sometimes to add features or enhancements too [⭐].
+ */
+ private initializeComfyUIHooks() {
+ const rgthree = this;
+
+ // Keep state for when the app is queuing the prompt. For instance, this is used for seed to
+ // understand if we're serializing because we're queueing (and return the random seed to use) or
+ // for saving the workflow (and keep -1, etc.).
+ const queuePrompt = app.queuePrompt as Function;
+ app.queuePrompt = async function (number: number, batchCount?: number) {
+ rgthree.processingQueue = true;
+ rgthree.dispatchCustomEvent("queue");
+ try {
+ return await queuePrompt.apply(app, [...arguments]);
+ } finally {
+ rgthree.processingQueue = false;
+ rgthree.dispatchCustomEvent("queue-end");
+ }
+ };
+
+ // Keep state for when the app is in the middle of loading from an api JSON file.
+ const loadApiJson = app.loadApiJson;
+ app.loadApiJson = async function (apiData: any, fileName: string) {
+ rgthree.loadingApiJson = apiData as ComfyApiFormat;
+ try {
+ loadApiJson.apply(app, [...arguments] as any);
+ } finally {
+ rgthree.loadingApiJson = null;
+ }
+ };
+
+ // Keep state for when the app is serizalizing the graph to prompt.
+ const graphToPrompt = app.graphToPrompt;
+ app.graphToPrompt = async function () {
+ rgthree.dispatchCustomEvent("graph-to-prompt");
+ let promise = graphToPrompt.apply(app, [...arguments] as any);
+ await promise;
+ rgthree.dispatchCustomEvent("graph-to-prompt-end");
+ return promise;
+ };
+
+ // Override the queuePrompt for api to intercept the prompt output and, if queueNodeIds is set,
+ // then we only want to queue those nodes, by rewriting the api format (prompt 'output' field)
+ // so only those are evaluated.
+ const apiQueuePrompt = api.queuePrompt as Function;
+ api.queuePrompt = async function (index: number, prompt: ComfyApiPrompt, ...args: any[]) {
+ if (rgthree.queueNodeIds?.length && prompt.output) {
+ const oldOutput = prompt.output;
+ let newOutput = {};
+ for (const queueNodeId of rgthree.queueNodeIds) {
+ rgthree.recursiveAddNodes(String(queueNodeId), oldOutput, newOutput);
+ }
+ prompt.output = newOutput;
+ }
+ rgthree.dispatchCustomEvent("comfy-api-queue-prompt-before", {
+ workflow: prompt.workflow,
+ output: prompt.output,
+ });
+ const response = apiQueuePrompt.apply(app, [index, prompt, ...args]);
+ rgthree.dispatchCustomEvent("comfy-api-queue-prompt-end");
+ return response;
+ };
+
+ // Hook into a clean call; allow us to clear and rgthree messages.
+ const clean = app.clean;
+ app.clean = function () {
+ rgthree.clearAllMessages();
+ clean && clean.apply(app, [...arguments] as any);
+ };
+
+ // Hook into a data load, like from an image or JSON drop-in. This is (currently) used to
+ // monitor for bad linking data.
+ const loadGraphData = app.loadGraphData;
+ // NOTE: This was "serializedLGraph" in pre-litegraph types, which maps to `ISerialisedGraph`
+ // now; though, @comfyorg/comfyui-frontend-types have the signature as `ComfyWorkflowJSON` which
+ // is not exported and a zod type. Looks like there's mostly an overlap with ISerialisedGraph.
+ app.loadGraphData = function (graph: ISerialisedGraph) {
+ if (rgthree.monitorLinkTimeout) {
+ clearTimeout(rgthree.monitorLinkTimeout);
+ rgthree.monitorLinkTimeout = null;
+ }
+ rgthree.clearAllMessages();
+ // Try to make a copy to use, because ComfyUI's loadGraphData will modify it.
+ let graphCopy: ISerialisedGraph | null;
+ try {
+ graphCopy = JSON.parse(JSON.stringify(graph));
+ } catch (e) {
+ graphCopy = null;
+ }
+ setTimeout(() => {
+ const wasLoadingAborted = document
+ .querySelector(".comfy-modal-content")
+ ?.textContent?.includes("Loading aborted due");
+ const graphToUse = wasLoadingAborted ? graphCopy || graph : app.graph;
+ const fixer = WorkflowLinkFixer.create(graphToUse as unknown as TLGraph);
+ const fixBadLinksResult = fixer.check();
+ if (fixBadLinksResult.hasBadLinks) {
+ const [n, v] = rgthree.logParts(
+ LogLevel.WARN,
+ `The workflow you've loaded has corrupt linking data. Open ${
+ new URL(location.href).origin
+ }/rgthree/link_fixer to try to fix.`,
+ );
+ console[n]?.(...v);
+ if (CONFIG_SERVICE.getConfigValue("features.show_alerts_for_corrupt_workflows")) {
+ rgthree.showMessage({
+ id: "bad-links",
+ type: "warn",
+ message:
+ "The workflow you've loaded has corrupt linking data that may be able to be fixed.",
+ actions: [
+ {
+ label: "Open fixer",
+ href: "/rgthree/link_fixer",
+ },
+ {
+ label: "Fix in place",
+ href: "/rgthree/link_fixer",
+ callback: (event) => {
+ event.stopPropagation();
+ event.preventDefault();
+ if (
+ confirm(
+ "This will attempt to fix in place. Please make sure to have a saved copy of your workflow.",
+ )
+ ) {
+ try {
+ const fixBadLinksResult = fixer.fix();
+ if (!fixBadLinksResult.hasBadLinks) {
+ rgthree.hideMessage("bad-links");
+ alert(
+ "Success! It's possible some valid links may have been affected. Please check and verify your workflow.",
+ );
+ wasLoadingAborted && app.loadGraphData(fixBadLinksResult.graph);
+ if (
+ CONFIG_SERVICE.getConfigValue("features.monitor_for_corrupt_links") ||
+ CONFIG_SERVICE.getConfigValue("features.monitor_bad_links")
+ ) {
+ rgthree.monitorLinkTimeout = setTimeout(() => {
+ rgthree.monitorBadLinks();
+ }, 5000);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ alert("Unsuccessful at fixing corrupt data. :(");
+ rgthree.hideMessage("bad-links");
+ }
+ }
+ },
+ },
+ ],
+ });
+ }
+ } else if (
+ CONFIG_SERVICE.getConfigValue("features.monitor_for_corrupt_links") ||
+ CONFIG_SERVICE.getConfigValue("features.monitor_bad_links")
+ ) {
+ rgthree.monitorLinkTimeout = setTimeout(() => {
+ rgthree.monitorBadLinks();
+ }, 5000);
+ }
+ }, 100);
+ return loadGraphData && loadGraphData.apply(app, [...arguments] as any);
+ };
+ }
+
+ /**
+ * [🤮] Finds a node in the currently serializing workflow from the hook setup above. This is to
+ * mitigate breakages from https://github.com/comfyanonymous/ComfyUI/issues/2193 we can try to
+ * store the workflow node so our nodes can find the seralized node.
+ */
+ getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff(
+ node: LGraphNode,
+ ): ISerialisedNode | null {
+ return (
+ this.initialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff?.nodes?.find(
+ (n: ISerialisedNode) => n.id === node.id,
+ ) ?? null
+ );
+ }
+
+ /**
+ * Shows a message in the UI.
+ */
+ async showMessage(data: RgthreeUiMessage) {
+ let container = document.querySelector(".rgthree-top-messages-container");
+ if (!container) {
+ container = document.createElement("div");
+ container.classList.add("rgthree-top-messages-container");
+ document.body.appendChild(container);
+ }
+ // If we have a dialog open then we want to append the message to the dialog so they show over
+ // the modal.
+ const dialogs = queryAll("dialog[open]");
+ if (dialogs.length) {
+ let dialog = dialogs[dialogs.length - 1]!;
+ dialog.appendChild(container);
+ dialog.addEventListener("close", (e) => {
+ document.body.appendChild(container!);
+ });
+ }
+ // Hide if we exist.
+ await this.hideMessage(data.id);
+
+ const messageContainer = document.createElement("div");
+ messageContainer.setAttribute("type", data.type || "info");
+
+ const message = document.createElement("span");
+ message.innerHTML = data.message;
+ messageContainer.appendChild(message);
+
+ for (let a = 0; a < (data.actions || []).length; a++) {
+ const action = data.actions![a]!;
+ if (a > 0) {
+ const sep = document.createElement("span");
+ sep.innerHTML = " | ";
+ messageContainer.appendChild(sep);
+ }
+
+ const actionEl = document.createElement("a");
+ actionEl.innerText = action.label;
+ if (action.href) {
+ actionEl.target = "_blank";
+ actionEl.href = action.href;
+ }
+ if (action.callback) {
+ actionEl.onclick = (e) => {
+ return action.callback!(e);
+ };
+ }
+ messageContainer.appendChild(actionEl);
+ }
+
+ const messageAnimContainer = document.createElement("div");
+ messageAnimContainer.setAttribute("msg-id", data.id);
+ messageAnimContainer.appendChild(messageContainer);
+ container.appendChild(messageAnimContainer);
+
+ // Add. Wait. Measure. Wait. Anim.
+ await wait(64);
+ messageAnimContainer.style.marginTop = `-${messageAnimContainer.offsetHeight}px`;
+ await wait(64);
+ messageAnimContainer.classList.add("-show");
+
+ if (data.timeout) {
+ await wait(data.timeout);
+ this.hideMessage(data.id);
+ }
+ }
+
+ /**
+ * Hides a message in the UI.
+ */
+ async hideMessage(id: string) {
+ const msg = document.querySelector(`.rgthree-top-messages-container > [msg-id="${id}"]`);
+ if (msg?.classList.contains("-show")) {
+ msg.classList.remove("-show");
+ await wait(750);
+ }
+ msg && msg.remove();
+ }
+
+ /**
+ * Clears all messages in the UI.
+ */
+ async clearAllMessages() {
+ let container = document.querySelector(".rgthree-top-messages-container");
+ container && (container.innerHTML = "");
+ }
+
+ setLogLevel(level?: LogLevel | string) {
+ if (typeof level === "string") {
+ level = LogLevelKeyToLogLevel[CONFIG_SERVICE.getConfigValue("log_level")];
+ }
+ if (level != null) {
+ GLOBAL_LOG_LEVEL = level;
+ }
+ }
+
+ logParts(level: LogLevel, message?: string, ...args: any[]) {
+ return this.logger.logParts(level, message, ...args);
+ }
+
+ newLogSession(name?: string) {
+ return this.logger.newSession(name);
+ }
+
+ isDebugMode() {
+ if (window.location.href.includes("rgthree-debug=false")) {
+ return false;
+ }
+ return GLOBAL_LOG_LEVEL >= LogLevel.DEBUG || window.location.href.includes("rgthree-debug");
+ }
+
+ isDevMode() {
+ if (window.location.href.includes("rgthree-dev=false")) {
+ return false;
+ }
+ return GLOBAL_LOG_LEVEL >= LogLevel.DEV || window.location.href.includes("rgthree-dev");
+ }
+
+ monitorBadLinks() {
+ const badLinksFound = WorkflowLinkFixer.create(app.graph).check();
+ if (badLinksFound.hasBadLinks && !this.monitorBadLinksAlerted) {
+ this.monitorBadLinksAlerted = true;
+ alert(
+ `Problematic links just found in live data. Can you save your workflow and file a bug with ` +
+ `the last few steps you took to trigger this at ` +
+ `https://github.com/rgthree/rgthree-comfy/issues. Thank you!`,
+ );
+ } else if (!badLinksFound.hasBadLinks) {
+ // Clear the alert once fixed so we can alert again.
+ this.monitorBadLinksAlerted = false;
+ }
+ this.monitorLinkTimeout = setTimeout(() => {
+ this.monitorBadLinks();
+ }, 5000);
+ }
+}
+
+function getBookmarks(): IContextMenuValue[] {
+ const bookmarks = BOOKMARKS_SERVICE.getCurrentBookmarks();
+ const bookmarkItems = bookmarks.map((n) => ({
+ content: `[${n.shortcutKey}] ${n.title}`,
+ className: "rgthree-contextmenu-item",
+ callback: () => {
+ n.canvasToBookmark();
+ },
+ }));
+
+ return !bookmarkItems.length
+ ? []
+ : [
+ {
+ content: "🔖 Bookmarks",
+ disabled: true,
+ className: "rgthree-contextmenu-item rgthree-contextmenu-label",
+ },
+ ...bookmarkItems,
+ ];
+}
+
+export const rgthree = new Rgthree();
+// Expose it on window because, why not.
+(window as any).rgthree = rgthree;
+
+app.registerExtension({
+ name: "Comfy.RgthreeComfy",
+
+ aboutPageBadges: [
+ {
+ label: `rgthree-comfy v${rgthree.version}`,
+ url: "https://github.com/rgthree/rgthree-comfy",
+ icon: "rgthree-comfy-about-badge-logo",
+ },
+ ],
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/seed.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/seed.ts
new file mode 100644
index 0000000000000000000000000000000000000000..638385bb45846cc270de003c923ff3c206afe48f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/seed.ts
@@ -0,0 +1,334 @@
+import type {
+ IContextMenuOptions,
+ ContextMenu,
+ LGraphNode as TLGraphNode,
+ IWidget,
+ LGraphCanvas,
+ IContextMenuValue,
+ LGraphNodeConstructor,
+ ISerialisedNode,
+ IButtonWidget,
+} from "@comfyorg/frontend";
+import type {ComfyNodeDef, ComfyApiPrompt} from "typings/comfy.js";
+
+import {app} from "scripts/app.js";
+import {ComfyWidgets} from "scripts/widgets.js";
+import {RgthreeBaseServerNode} from "./base_node.js";
+import {rgthree} from "./rgthree.js";
+import {
+ addConnectionLayoutSupport,
+ getFullNodeIdFromApiPrompt,
+ getNodeByIdFromApiPrompt,
+} from "./utils.js";
+import {NodeTypesString} from "./constants.js";
+import {RgthreeBetterButtonWidget} from "./utils_widgets.js";
+
+const LAST_SEED_BUTTON_LABEL = "♻️ (Use Last Queued Seed)";
+
+const SPECIAL_SEED_RANDOM = -1;
+const SPECIAL_SEED_INCREMENT = -2;
+const SPECIAL_SEED_DECREMENT = -3;
+const SPECIAL_SEEDS = [SPECIAL_SEED_RANDOM, SPECIAL_SEED_INCREMENT, SPECIAL_SEED_DECREMENT];
+
+interface SeedSerializedCtx {
+ inputSeed?: number;
+ seedUsed?: number;
+}
+
+class RgthreeSeed extends RgthreeBaseServerNode {
+ static override title = NodeTypesString.SEED;
+ static override type = NodeTypesString.SEED;
+ static comfyClass = NodeTypesString.SEED;
+
+ override serialize_widgets = true;
+
+ private logger = rgthree.newLogSession(`[Seed]`);
+
+ static override exposedActions = ["Randomize Each Time", "Use Last Queued Seed"];
+
+ static "@randomMax" = {type: "number"};
+ static "@randomMin" = {type: "number"};
+
+ lastSeed?: number = undefined;
+ serializedCtx: SeedSerializedCtx = {};
+ seedWidget!: IWidget;
+
+ // lastSeedButton!: RgthreeBetterButtonWidget;
+ lastSeedButton!: IWidget;
+ lastSeedValue: IWidget | null = null;
+
+ private handleApiHijackingBound = this.handleApiHijacking.bind(this);
+
+ constructor(title = RgthreeSeed.title) {
+ super(title);
+
+ this.properties["randomMax"] = 1125899906842624;
+ // We can have a full range of seeds, including negative. But, for the randomRange we'll
+ // only generate positives, since that's what folks assume.
+ this.properties["randomMin"] = 0;
+
+ rgthree.addEventListener(
+ "comfy-api-queue-prompt-before",
+ this.handleApiHijackingBound as EventListener,
+ );
+
+ console.log("SEED NODE STARTED!");
+ }
+
+ override onPropertyChanged(prop: string, value: unknown, prevValue?: unknown): boolean {
+ if (prop === "randomMax") {
+ this.properties["randomMax"] = Math.min(1125899906842624, Number(value as number));
+ } else if (prop === "randomMin") {
+ this.properties["randomMin"] = Math.max(-1125899906842624, Number(value as number));
+ }
+ return true;
+ }
+
+ override onRemoved() {
+ console.log("SEED NODE onRemoved!");
+ rgthree.removeEventListener(
+ "comfy-api-queue-prompt-before",
+ this.handleApiHijackingBound as EventListener,
+ );
+ }
+
+ override onExecuted(output: any): void {
+ console.log(`SEED ON EXECUTED. #${this.id}.`, output);
+ }
+
+ override configure(info: ISerialisedNode): void {
+ super.configure(info);
+ if (this.properties?.["showLastSeed"]) {
+ this.addLastSeedValue();
+ }
+ }
+
+ override async handleAction(action: string) {
+ if (action === "Randomize Each Time") {
+ this.seedWidget.value = SPECIAL_SEED_RANDOM;
+ } else if (action === "Use Last Queued Seed") {
+ this.seedWidget.value = this.lastSeed != null ? this.lastSeed : this.seedWidget.value;
+ this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL;
+ this.lastSeedButton.disabled = true;
+ }
+ }
+
+ override onNodeCreated() {
+ super.onNodeCreated?.();
+ // Grab the already available widgets, and remove the built-in control_after_generate
+ for (const [i, w] of this.widgets.entries()) {
+ if (w.name === "seed") {
+ this.seedWidget = w; // as ComfyWidget;
+ this.seedWidget.value = SPECIAL_SEED_RANDOM;
+ } else if (w.name === "control_after_generate") {
+ this.widgets.splice(i, 1);
+ }
+ }
+
+ this.addWidget(
+ "button",
+ "🎲 Randomize Each Time",
+ "",
+ () => {
+ this.seedWidget.value = SPECIAL_SEED_RANDOM;
+ },
+ {serialize: false},
+ );
+
+ this.addWidget(
+ "button",
+ "🎲 New Fixed Random",
+ "",
+ () => {
+ this.seedWidget.value = this.generateRandomSeed();
+ },
+ {serialize: false},
+ );
+
+ this.lastSeedButton = this.addWidget(
+ "button",
+ 'USE_LAST_SEED',
+ "okay",
+ () => {
+ this.seedWidget.value = this.lastSeed != null ? this.lastSeed : this.seedWidget.value;
+ this.lastSeedButton.label = LAST_SEED_BUTTON_LABEL;
+ this.lastSeedButton.disabled = true;
+ },
+ {width: 50, serialize: false} as any,
+ ) as IButtonWidget;
+
+ // this.lastSeedButton = this.addCustomWidget(
+ // new RgthreeBetterButtonWidget(
+ // LAST_SEED_BUTTON_LABEL,
+ // () => {
+ // this.seedWidget.value = this.lastSeed != null ? this.lastSeed : this.seedWidget.value;
+ // this.lastSeedButton.label = LAST_SEED_BUTTON_LABEL;
+ // this.lastSeedButton.disabled = true;
+ // return true;
+ // },
+ // ),
+ // ) as RgthreeBetterButtonWidget;
+ this.lastSeedButton.label = LAST_SEED_BUTTON_LABEL;
+ this.lastSeedButton.disabled = true;
+ }
+
+ generateRandomSeed() {
+ let step = this.seedWidget.options.step || 1;
+ const randomMin = Number(this.properties["randomMin"] || 0);
+ const randomMax = Number(this.properties["randomMax"] || 1125899906842624);
+ const randomRange = (randomMax - randomMin) / (step / 10);
+ let seed = Math.floor(Math.random() * randomRange) * (step / 10) + randomMin;
+ if (SPECIAL_SEEDS.includes(seed)) {
+ seed = 0;
+ }
+ return seed;
+ }
+
+ override getExtraMenuOptions(canvas: LGraphCanvas, options: IContextMenuValue[]) {
+ super.getExtraMenuOptions?.apply(this, [...arguments] as any);
+ options.splice(options.length - 1, 0, {
+ content: "Show/Hide Last Seed Value",
+ callback: (
+ _value: IContextMenuValue,
+ _options: IContextMenuOptions,
+ _event: MouseEvent,
+ _parentMenu: ContextMenu | undefined,
+ _node: TLGraphNode,
+ ) => {
+ this.properties["showLastSeed"] = !this.properties["showLastSeed"];
+ if (this.properties["showLastSeed"]) {
+ this.addLastSeedValue();
+ } else {
+ this.removeLastSeedValue();
+ }
+ },
+ });
+ return [];
+ }
+
+ addLastSeedValue() {
+ if (this.lastSeedValue) return;
+ this.lastSeedValue = ComfyWidgets["STRING"](
+ this,
+ "last_seed",
+ ["STRING", {multiline: true}],
+ app,
+ ).widget as unknown as IWidget;
+ this.lastSeedValue!.inputEl!.readOnly = true;
+ this.lastSeedValue!.inputEl!.style.fontSize = "0.75rem";
+ this.lastSeedValue!.inputEl!.style.textAlign = "center";
+ this.computeSize();
+ }
+
+ removeLastSeedValue() {
+ if (!this.lastSeedValue) return;
+ this.lastSeedValue!.inputEl!.remove();
+ this.widgets.splice(this.widgets.indexOf(this.lastSeedValue), 1);
+ this.lastSeedValue = null;
+ this.computeSize();
+ }
+
+ /**
+ * Intercepts the prompt right before ComfyUI sends it to the server (as fired from rgthree) so we
+ * can inspect the prompt and workflow data and change swap in the seeds.
+ *
+ * Note, the original implementation tried to change the widget value itself when the graph was
+ * queued (and the relied on ComfyUI serializing the data changed data) and then changing it back.
+ * This worked well until other extensions kept calling graphToPrompt during asynchronous
+ * operations within, causing the widget to get confused without a reliable state to reflect upon.
+ */
+ handleApiHijacking(e: CustomEvent) {
+ // Don't do any work if we're muted/bypassed.
+ if (this.mode === LiteGraph.NEVER || this.mode === 4) {
+ return;
+ }
+
+ const output = e.detail.output;
+ const fullId = getFullNodeIdFromApiPrompt(e.detail, this.id) ?? "";
+ let workflowNode = getNodeByIdFromApiPrompt(e.detail, fullId);
+ let outputInputs = output?.[fullId]?.inputs;
+
+ if (
+ !workflowNode ||
+ !outputInputs ||
+ outputInputs[this.seedWidget.name || "seed"] === undefined
+ ) {
+ const [n, v] = this.logger.warnParts(
+ `Node ${fullId} not found in prompt data sent to server. This may be fine if only ` +
+ `queuing part of the workflow. If not, then this could be a bug.`,
+ );
+ console[n]?.(...v);
+ return;
+ }
+
+ const seedToUse = this.getSeedToUse();
+ const seedWidgetndex = this.widgets.indexOf(this.seedWidget);
+
+ workflowNode.widgets_values![seedWidgetndex] = seedToUse;
+ outputInputs[this.seedWidget.name || "seed"] = seedToUse;
+
+ this.lastSeed = seedToUse;
+ if (seedToUse != this.seedWidget.value) {
+ this.lastSeedButton.label = `♻️ ${this.lastSeed}`;
+ this.lastSeedButton.disabled = false;
+ } else {
+ this.lastSeedButton.label = LAST_SEED_BUTTON_LABEL;
+ this.lastSeedButton.disabled = true;
+ }
+ if (this.lastSeedValue) {
+ this.lastSeedValue.value = `Last Seed: ${this.lastSeed}`;
+ }
+ }
+
+ /**
+ * Determines a seed to use depending on the seed widget's current value and the last used seed.
+ * There are no sideffects to calling this method.
+ */
+ private getSeedToUse() {
+ const inputSeed = Number(this.seedWidget.value);
+ let seedToUse: number | null = null;
+
+ // If our input seed was a special seed, then handle it.
+ if (SPECIAL_SEEDS.includes(inputSeed)) {
+ // If the last seed was not a special seed and we have increment/decrement, then do that on
+ // the last seed.
+ if (typeof this.lastSeed === "number" && !SPECIAL_SEEDS.includes(this.lastSeed)) {
+ if (inputSeed === SPECIAL_SEED_INCREMENT) {
+ seedToUse = this.lastSeed + 1;
+ } else if (inputSeed === SPECIAL_SEED_DECREMENT) {
+ seedToUse = this.lastSeed - 1;
+ }
+ }
+ // If we don't have a seed to use, or it's special seed (like we incremented into one), then
+ // we randomize.
+ if (seedToUse == null || SPECIAL_SEEDS.includes(seedToUse)) {
+ seedToUse = this.generateRandomSeed();
+ }
+ }
+
+ return seedToUse ?? inputSeed;
+ }
+
+ static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeSeed);
+ }
+
+ static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
+ addConnectionLayoutSupport(RgthreeSeed, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ setTimeout(() => {
+ RgthreeSeed.category = comfyClass.category;
+ });
+ }
+}
+
+app.registerExtension({
+ name: "rgthree.Seed",
+ async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
+ if (nodeData.name === RgthreeSeed.type) {
+ RgthreeSeed.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/services/bookmarks_services.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/services/bookmarks_services.ts
new file mode 100644
index 0000000000000000000000000000000000000000..25a34c7aecf12483b1827c8ccb502a4d54741103
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/services/bookmarks_services.ts
@@ -0,0 +1,34 @@
+import type {Bookmark} from "../bookmark.js";
+
+import {app} from "scripts/app.js";
+import {NodeTypesString} from "../constants.js";
+import {reduceNodesDepthFirst} from "../utils.js";
+
+const SHORTCUT_DEFAULTS = "1234567890abcdefghijklmnopqrstuvwxyz".split("");
+
+class BookmarksService {
+ /**
+ * Gets a list of the current bookmarks within the current workflow.
+ */
+ getCurrentBookmarks(): Bookmark[] {
+ return reduceNodesDepthFirst(app.graph.nodes, (n, acc) => {
+ if (n.type === NodeTypesString.BOOKMARK) {
+ acc.push(n as Bookmark);
+ }
+ }, []).sort((a, b) => a.title.localeCompare(b.title));
+ }
+
+ getExistingShortcuts() {
+ const bookmarkNodes = this.getCurrentBookmarks();
+ const usedShortcuts = new Set(bookmarkNodes.map((n) => n.shortcutKey));
+ return usedShortcuts;
+ }
+
+ getNextShortcut() {
+ const existingShortcuts = this.getExistingShortcuts();
+ return SHORTCUT_DEFAULTS.find((char) => !existingShortcuts.has(char)) ?? "1";
+ }
+}
+
+/** The BookmarksService singleton. */
+export const SERVICE = new BookmarksService();
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/services/config_service.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/services/config_service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4737ab11c9190055b7bfa5a62b3c1268a305733e
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/services/config_service.ts
@@ -0,0 +1,40 @@
+// @ts-ignore
+import {rgthreeConfig} from "rgthree/config.js";
+import {getObjectValue, setObjectValue} from "rgthree/common/shared_utils.js";
+import {rgthreeApi} from "rgthree/common/rgthree_api.js";
+
+/**
+ * A singleton service exported as `SERVICE` to handle configuration routines.
+ */
+class ConfigService extends EventTarget {
+ getConfigValue(key: string, def?: any) {
+ return getObjectValue(rgthreeConfig, key, def);
+ }
+
+ getFeatureValue(key: string, def?: any) {
+ key = "features." + key.replace(/^features\./, "");
+ return getObjectValue(rgthreeConfig, key, def);
+ }
+
+ /**
+ * Given an object of key:value changes it will send to the server and wait for a successful
+ * response before setting the values on the local rgthreeConfig.
+ */
+ async setConfigValues(changed: {[key: string]: any}) {
+ const body = new FormData();
+ body.append("json", JSON.stringify(changed));
+ const response = await rgthreeApi.fetchJson("/config", {method: "POST", body});
+ if (response.status === "ok") {
+ for (const [key, value] of Object.entries(changed)) {
+ setObjectValue(rgthreeConfig, key, value);
+ this.dispatchEvent(new CustomEvent("config-change", {detail: {key, value}}));
+ }
+ } else {
+ return false;
+ }
+ return true;
+ }
+}
+
+/** The ConfigService singleton. */
+export const SERVICE = new ConfigService();
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/services/context_service.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/services/context_service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3a3cfaff57d724f8bf9965518b149a28c9f89e1c
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/services/context_service.ts
@@ -0,0 +1,74 @@
+import type {DynamicContextNodeBase} from "../dynamic_context_base.js";
+
+import {NodeTypesString} from "../constants.js";
+import {getConnectedOutputNodesAndFilterPassThroughs} from "../utils.js";
+import {INodeInputSlot, INodeOutputSlot, INodeSlot, LGraphNode} from "@comfyorg/frontend";
+
+export let SERVICE: ContextService;
+
+const OWNED_PREFIX = "+";
+const REGEX_PREFIX = /^[\+⚠️]\s*/;
+const REGEX_EMPTY_INPUT = /^\+\s*$/;
+
+export function stripContextInputPrefixes(name: string) {
+ return name.replace(REGEX_PREFIX, "");
+}
+
+export function getContextOutputName(inputName: string) {
+ if (inputName === "base_ctx") return "CONTEXT";
+ return stripContextInputPrefixes(inputName).toUpperCase();
+}
+
+export enum InputMutationOperation {
+ "UNKNOWN",
+ "ADDED",
+ "REMOVED",
+ "RENAMED",
+}
+
+export type InputMutation = {
+ operation: InputMutationOperation;
+ node: DynamicContextNodeBase;
+ slotIndex: number;
+ slot: INodeSlot;
+};
+
+export class ContextService {
+ constructor() {
+ if (SERVICE) {
+ throw new Error("ContextService was already instantiated.");
+ }
+ }
+
+ onInputChanges(node: any, mutation: InputMutation) {
+ const childCtxs = getConnectedOutputNodesAndFilterPassThroughs(
+ node,
+ node,
+ 0,
+ ) as DynamicContextNodeBase[];
+ for (const childCtx of childCtxs) {
+ childCtx.handleUpstreamMutation(mutation);
+ }
+ }
+
+ getDynamicContextInputsData(node: DynamicContextNodeBase) {
+ return node
+ .getContextInputsList()
+ .map((input: INodeInputSlot, index: number) => ({
+ name: stripContextInputPrefixes(input.name),
+ type: String(input.type),
+ index,
+ }))
+ .filter((i) => i.type !== "*");
+ }
+
+ getDynamicContextOutputsData(node: LGraphNode) {
+ return node.outputs.map((output: INodeOutputSlot, index: number) => ({
+ name: stripContextInputPrefixes(output.name),
+ type: String(output.type),
+ index,
+ }));
+ }
+}
+
+SERVICE = new ContextService();
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/services/fast_groups_service.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/services/fast_groups_service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dc765e8f45f1f8b1e5766146833ee6ad874a2581
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/services/fast_groups_service.ts
@@ -0,0 +1,231 @@
+import type {LGraphGroup as TLGraphGroup} from "@comfyorg/frontend";
+import type {BaseFastGroupsModeChanger} from "../fast_groups_muter.js";
+
+import {app} from "scripts/app.js";
+import {getGraphDependantNodeKey, getGroupNodes, reduceNodesDepthFirst} from "../utils.js";
+
+type Vector4 = [number, number, number, number];
+
+
+
+/**
+ * A service that keeps global state that can be shared by multiple FastGroupsMuter or
+ * FastGroupsBypasser nodes rather than calculate it on it's own.
+ */
+class FastGroupsService {
+ private msThreshold = 400;
+ private msLastUnsorted = 0;
+ private msLastAlpha = 0;
+ private msLastPosition = 0;
+
+ private groupsUnsorted: TLGraphGroup[] = [];
+ private groupsSortedAlpha: TLGraphGroup[] = [];
+ private groupsSortedPosition: TLGraphGroup[] = [];
+
+ private readonly fastGroupNodes: BaseFastGroupsModeChanger[] = [];
+
+ private runScheduledForMs: number | null = null;
+ private runScheduleTimeout: number | null = null;
+ private runScheduleAnimation: number | null = null;
+
+ private cachedNodeBoundings: {[key: string]: Vector4} | null = null;
+
+ constructor() {
+ // Don't need to do anything, wait until a signal.
+ }
+
+ addFastGroupNode(node: BaseFastGroupsModeChanger) {
+ this.fastGroupNodes.push(node);
+ // Schedule it because the node may not be ready to refreshWidgets (like, when added it may
+ // not have cloned properties to filter against, etc.).
+ this.scheduleRun(8);
+ }
+
+ removeFastGroupNode(node: BaseFastGroupsModeChanger) {
+ const index = this.fastGroupNodes.indexOf(node);
+ if (index > -1) {
+ this.fastGroupNodes.splice(index, 1);
+ }
+ // If we have no more group nodes, then clear out data; it could be because of a canvas clear.
+ if (!this.fastGroupNodes?.length) {
+ this.clearScheduledRun();
+ this.groupsUnsorted = [];
+ this.groupsSortedAlpha = [];
+ this.groupsSortedPosition = [];
+ }
+ }
+
+ private run() {
+ // We only run if we're scheduled, so if we're not, then bail.
+ if (!this.runScheduledForMs) {
+ return;
+ }
+ for (const node of this.fastGroupNodes) {
+ node.refreshWidgets();
+ }
+ this.clearScheduledRun();
+ this.scheduleRun();
+ }
+
+ private scheduleRun(ms = 500) {
+ // If we got a request for an immediate schedule and already have on scheduled for longer, then
+ // cancel the long one to expediate a fast one.
+ if (this.runScheduledForMs && ms < this.runScheduledForMs) {
+ this.clearScheduledRun();
+ }
+ if (!this.runScheduledForMs && this.fastGroupNodes.length) {
+ this.runScheduledForMs = ms;
+ this.runScheduleTimeout = setTimeout(() => {
+ this.runScheduleAnimation = requestAnimationFrame(() => this.run());
+ }, ms);
+ }
+ }
+
+ private clearScheduledRun() {
+ this.runScheduleTimeout && clearTimeout(this.runScheduleTimeout);
+ this.runScheduleAnimation && cancelAnimationFrame(this.runScheduleAnimation);
+ this.runScheduleTimeout = null;
+ this.runScheduleAnimation = null;
+ this.runScheduledForMs = null;
+ }
+
+ /**
+ * Returns the boundings for all nodes on the graph, then clears it after a short delay. This is
+ * to increase efficiency by caching the nodes' boundings when multiple groups are on the page.
+ */
+ getBoundingsForAllNodes() {
+ if (!this.cachedNodeBoundings) {
+ this.cachedNodeBoundings = reduceNodesDepthFirst(
+ app.graph._nodes,
+ (node, acc) => {
+ let bounds = node.getBounding();
+ // If the bounds are zero'ed out, then we could be a subgraph that hasn't rendered yet and
+ // need to update them.
+ if (bounds[0] === 0 && bounds[1] === 0 && bounds[2] === 0 && bounds[3] === 0) {
+ const ctx = node.graph?.primaryCanvas?.canvas.getContext("2d");
+ if (ctx) {
+ node.updateArea(ctx);
+ bounds = node.getBounding();
+ }
+ }
+ acc[getGraphDependantNodeKey(node)] = bounds as Vector4;
+ },
+ {} as {[key: string]: Vector4},
+ );
+ setTimeout(() => {
+ this.cachedNodeBoundings = null;
+ }, 50);
+ }
+ return this.cachedNodeBoundings;
+ }
+
+ /**
+ * This overrides `TLGraphGroup.prototype.recomputeInsideNodes` to be much more efficient when
+ * calculating for many groups at once (only compute all nodes once in `getBoundingsForAllNodes`).
+ */
+ recomputeInsideNodesForGroup(group: TLGraphGroup) {
+ // If the canvas is currently being dragged (includes if a group is being dragged around) then
+ // don't recompute anything.
+ if (app.canvas.isDragging) return;
+ const cachedBoundings = this.getBoundingsForAllNodes();
+ const nodes = group.graph!.nodes;
+ group._children.clear();
+ group.nodes.length = 0;
+
+ for (const node of nodes) {
+ const nodeBounding = cachedBoundings[getGraphDependantNodeKey(node)];
+ const nodeCenter =
+ nodeBounding &&
+ ([nodeBounding[0] + nodeBounding[2] * 0.5, nodeBounding[1] + nodeBounding[3] * 0.5] as [
+ number,
+ number,
+ ]);
+ if (nodeCenter) {
+ const grouBounds = group._bounding as unknown as [number, number, number, number];
+ if (
+ nodeCenter[0] >= grouBounds[0] &&
+ nodeCenter[0] < grouBounds[0] + grouBounds[2] &&
+ nodeCenter[1] >= grouBounds[1] &&
+ nodeCenter[1] < grouBounds[1] + grouBounds[3]
+ ) {
+ group._children.add(node);
+ group.nodes.push(node);
+ }
+ }
+ }
+ }
+
+ /**
+ * Everything goes through getGroupsUnsorted, so we only get groups once. However, LiteGraph's
+ * `recomputeInsideNodes` is inefficient when calling multiple groups (it iterates over all nodes
+ * each time). So, we'll do our own dang thing, once.
+ */
+ private getGroupsUnsorted(now: number) {
+ const canvas = app.canvas;
+ const graph = canvas.getCurrentGraph() ?? app.graph;
+
+ if (
+ // Don't recalculate nodes if we're moving a group (added by ComfyUI in app.js)
+ // TODO: This doesn't look available anymore... ?
+ !canvas.selected_group_moving &&
+ (!this.groupsUnsorted.length || now - this.msLastUnsorted > this.msThreshold)
+ ) {
+ this.groupsUnsorted = [...graph._groups];
+ const subgraphs = graph.subgraphs?.values();
+ if (subgraphs) {
+ let s;
+ while ((s = subgraphs.next().value)) this.groupsUnsorted.push(...(s.groups ?? []));
+ }
+ for (const group of this.groupsUnsorted) {
+ this.recomputeInsideNodesForGroup(group);
+ group.rgthree_hasAnyActiveNode = getGroupNodes(group).some(
+ (n) => n.mode === LiteGraph.ALWAYS,
+ );
+ }
+ this.msLastUnsorted = now;
+ }
+ return this.groupsUnsorted;
+ }
+
+ private getGroupsAlpha(now: number) {
+ if (!this.groupsSortedAlpha.length || now - this.msLastAlpha > this.msThreshold) {
+ this.groupsSortedAlpha = [...this.getGroupsUnsorted(now)].sort((a, b) => {
+ return a.title.localeCompare(b.title);
+ });
+ this.msLastAlpha = now;
+ }
+ return this.groupsSortedAlpha;
+ }
+
+ private getGroupsPosition(now: number) {
+ if (!this.groupsSortedPosition.length || now - this.msLastPosition > this.msThreshold) {
+ this.groupsSortedPosition = [...this.getGroupsUnsorted(now)].sort((a, b) => {
+ // Sort by y, then x, clamped to 30.
+ const aY = Math.floor(a._pos[1] / 30);
+ const bY = Math.floor(b._pos[1] / 30);
+ if (aY == bY) {
+ const aX = Math.floor(a._pos[0] / 30);
+ const bX = Math.floor(b._pos[0] / 30);
+ return aX - bX;
+ }
+ return aY - bY;
+ });
+ this.msLastPosition = now;
+ }
+ return this.groupsSortedPosition;
+ }
+
+ getGroups(sort?: string) {
+ const now = +new Date();
+ if (sort === "alphanumeric") {
+ return this.getGroupsAlpha(now);
+ }
+ if (sort === "position") {
+ return this.getGroupsPosition(now);
+ }
+ return this.getGroupsUnsorted(now);
+ }
+}
+
+/** The FastGroupsService singleton. */
+export const SERVICE = new FastGroupsService();
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/services/key_events_services.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/services/key_events_services.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6d9a332147e1f10b033b5430f33e8f9b63f9bfcb
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/services/key_events_services.ts
@@ -0,0 +1,184 @@
+/**
+ * A service responsible for capturing keys within LiteGraph's canvas, and outside of it, allowing
+ * nodes and other services to confidently determine what's going on.
+ */
+class KeyEventService extends EventTarget {
+ readonly downKeys: { [key: string]: boolean } = {};
+ readonly shiftDownKeys: { [key: string]: boolean } = {};
+
+ ctrlKey = false;
+ altKey = false;
+ metaKey = false;
+ shiftKey = false;
+
+ private readonly isMac: boolean = !!(
+ navigator.platform?.toLocaleUpperCase().startsWith("MAC") ||
+ (navigator as any).userAgentData?.platform?.toLocaleUpperCase().startsWith("MAC")
+ );
+
+ constructor() {
+ super();
+ this.initialize();
+ }
+
+ initialize() {
+ const that = this;
+ // [🤮] Sometimes ComfyUI and/or LiteGraph stop propagation of key events which makes it hard
+ // to determine if keys are currently pressed. To attempt to get around this, we'll hijack
+ // LiteGraph's processKey to try to get better consistency.
+ const processKey = LGraphCanvas.prototype.processKey;
+ LGraphCanvas.prototype.processKey = function (e: KeyboardEvent) {
+ if (e.type === "keydown" || e.type === "keyup") {
+ that.handleKeyDownOrUp(e);
+ }
+ return processKey.apply(this, [...arguments] as any) as any;
+ };
+
+ // Now that ComfyUI has more non-canvas UI (like the top bar), we listen on window as well, and
+ // de-dupe when we get multiple events from both window and/or LiteGraph.
+ window.addEventListener("keydown", (e) => {
+ that.handleKeyDownOrUp(e);
+ });
+ window.addEventListener("keyup", (e) => {
+ that.handleKeyDownOrUp(e);
+ });
+
+ // If we get a visibilitychange, then clear the keys since we can't listen for keys up/down when
+ // not visible.
+ document.addEventListener("visibilitychange", (e) => {
+ this.clearKeydowns();
+ });
+
+ // If we get a blur, then also clear the keys since we can't listen for keys up/down when
+ // blurred. This can happen w/o a visibilitychange, like a browser alert.
+ window.addEventListener("blur", (e) => {
+ this.clearKeydowns();
+ });
+ }
+
+ /**
+ * Adds a new queue item, unless the last is the same.
+ */
+ handleKeyDownOrUp(e: KeyboardEvent) {
+ const key = e.key.toLocaleUpperCase();
+ // If we're already down, or already up, then ignore and don't fire.
+ if ((e.type === 'keydown' && this.downKeys[key] === true)
+ || (e.type === 'keyup' && this.downKeys[key] === undefined)) {
+ return;
+ }
+
+ this.ctrlKey = !!e.ctrlKey;
+ this.altKey = !!e.altKey;
+ this.metaKey = !!e.metaKey;
+ this.shiftKey = !!e.shiftKey;
+ if (e.type === "keydown") {
+ this.downKeys[key] = true;
+ this.dispatchCustomEvent("keydown", { originalEvent: e });
+
+ // If SHIFT is pressed down as well, then we need to keep track of this separetly to "release"
+ // it once SHIFT is also released.
+ if (this.shiftKey && key !== 'SHIFT') {
+ this.shiftDownKeys[key] = true;
+ }
+ } else if (e.type === "keyup") {
+ // See https://github.com/rgthree/rgthree-comfy/issues/238
+ // A little bit of a hack, but Mac reportedly does something odd with copy/paste. ComfyUI
+ // gobbles the copy event propagation, but it happens for paste too and reportedly 'Enter' which
+ // I can't find a reason for in LiteGraph/comfy. So, for Mac only, whenever we lift a Command
+ // (META) key, we'll also clear any other keys.
+ if (key === "META" && this.isMac) {
+ this.clearKeydowns();
+ } else {
+ delete this.downKeys[key];
+ }
+
+ // If we're releasing the SHIFT key, then we may also be releasing all other keys we pressed
+ // during the SHIFT key as well. We should get an additional keydown for them after.
+ if (key === 'SHIFT') {
+ for (const key in this.shiftDownKeys) {
+ delete this.downKeys[key];
+ delete this.shiftDownKeys[key];
+ }
+ }
+ this.dispatchCustomEvent("keyup", { originalEvent: e });
+ }
+
+ }
+
+ private clearKeydowns() {
+ this.ctrlKey = false;
+ this.altKey = false;
+ this.metaKey = false;
+ this.shiftKey = false;
+ for (const key in this.downKeys) delete this.downKeys[key];
+ }
+
+ /**
+ * Wraps `dispatchEvent` for easier CustomEvent dispatching.
+ */
+ private dispatchCustomEvent(event: string, detail?: any) {
+ if (detail != null) {
+ return this.dispatchEvent(new CustomEvent(event, { detail }));
+ }
+ return this.dispatchEvent(new CustomEvent(event));
+ }
+
+ /**
+ * Parses a shortcut string.
+ *
+ * - 's' => ['S']
+ * - 'shift + c' => ['SHIFT', 'C']
+ * - 'shift + meta + @' => ['SHIFT', 'META', '@']
+ * - 'shift + + + @' => ['SHIFT', '__PLUS__', '=']
+ * - '+ + p' => ['__PLUS__', 'P']
+ */
+ private getKeysFromShortcut(shortcut: string | string[]) {
+ let keys;
+ if (typeof shortcut === "string") {
+ // Rip all spaces out. Note, Comfy swallows space, so we don't have to handle it. Otherwise,
+ // we would require space to be fed as "Space" or "Spacebar" instead of " ".
+ shortcut = shortcut.replace(/\s/g, "");
+ // Change a real "+" to something we can encode.
+ shortcut = shortcut.replace(/^\+/, "__PLUS__").replace(/\+\+/, "+__PLUS__");
+ keys = shortcut.split("+").map((i) => i.replace("__PLUS__", "+"));
+ } else {
+ keys = [...shortcut];
+ }
+ return keys.map((k) => k.toLocaleUpperCase());
+ }
+
+ /**
+ * Checks if all keys passed in are down.
+ */
+ areAllKeysDown(keys: string | string[]) {
+ keys = this.getKeysFromShortcut(keys);
+ return keys.every((k) => {
+ return this.downKeys[k];
+ });
+ }
+
+ /**
+ * Checks if only the keys passed in are down; optionally and additionally allowing "shift" key.
+ */
+ areOnlyKeysDown(keys: string | string[], alsoAllowShift = false) {
+ keys = this.getKeysFromShortcut(keys);
+ const allKeysDown = this.areAllKeysDown(keys);
+ const downKeysLength = Object.values(this.downKeys).length;
+ // All keys are down and they're the only ones.
+ if (allKeysDown && keys.length === downKeysLength) {
+ return true;
+ }
+ // Special case allowing the shift key in addition to the shortcut keys. This helps when a user
+ // may had originally defined "$" as a shortcut, but needs to press "shift + $" since it's an
+ // upper key character, etc.
+ if (alsoAllowShift && !keys.includes("SHIFT") && keys.length === downKeysLength - 1) {
+ // If we're holding down shift, have one extra key held down, and the original keys don't
+ // include shift, then we're good to go.
+ return allKeysDown && this.areAllKeysDown(["SHIFT"]);
+ }
+ return false;
+ }
+}
+
+/** The KeyEventService singleton. */
+export const SERVICE = new KeyEventService();
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/testing/comfyui_env.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/testing/comfyui_env.ts
new file mode 100644
index 0000000000000000000000000000000000000000..23d88331bba9f9d7b393b69476b4d85b17d3876d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/testing/comfyui_env.ts
@@ -0,0 +1,71 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {NodeTypesString} from "../constants.js";
+
+type addNodeOptions = {
+ placement?: string;
+};
+
+/**
+ * A testing environment to make setting up, clearing, and queuing more predictable in an
+ * integration test environment.
+ */
+export class ComfyUITestEnvironment {
+ private lastNode: LGraphNode | null = null;
+ private maxY = 0;
+
+ constructor() {}
+
+ wait = wait;
+
+ async addNode(nodeString: string, options: addNodeOptions = {}) {
+ const [canvas, graph] = [app.canvas, app.graph];
+ const node = LiteGraph.createNode(nodeString)!;
+ let x = 0;
+ let y = 30;
+ if (this.lastNode) {
+ const placement = options.placement || "right";
+ if (placement === "under") {
+ x = this.lastNode.pos[0];
+ y = this.lastNode.pos[1] + this.lastNode.size[1] + 30;
+ } else if (placement === "right") {
+ x = this.lastNode.pos[0] + this.lastNode.size[0] + 100;
+ y = this.lastNode.pos[1];
+ } else if (placement === "start") {
+ x = 0;
+ y = this.maxY + 50;
+ }
+ }
+ canvas.graph!.add(node);
+ node.pos = [x, y];
+ canvas.selectNode(node);
+ app.graph.setDirtyCanvas(true, true);
+ await wait();
+ this.lastNode = node;
+ this.maxY = Math.max(this.maxY, y + this.lastNode.size[1]);
+ return (this.lastNode = node);
+ }
+
+ async clear() {
+ app.clean();
+ app.graph.clear();
+ const nodeConfig = await this.addNode(NodeTypesString.KSAMPLER_CONFIG);
+ const displayAny = await this.addNode(NodeTypesString.DISPLAY_ANY);
+ nodeConfig.widgets = nodeConfig.widgets || [];
+ nodeConfig.widgets[0]!.value = Math.round(Math.random() * 100);
+ nodeConfig.connect(0, displayAny, 0);
+ await this.queuePrompt();
+ app.clean();
+ app.graph.clear();
+ this.lastNode = null;
+ this.maxY = 0;
+ await wait();
+ }
+
+ async queuePrompt() {
+ await app.queuePrompt(0);
+ await wait(150);
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/testing/runner.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/testing/runner.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2cf36d48fc6cf69d37ca6d23b6ed757d67905f1c
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/testing/runner.ts
@@ -0,0 +1,167 @@
+/**
+ * @fileoverview A set of methods that mimic a bit of the Jasmine testing library, but simpler and
+ * more succinct for manipulating a comfy integration test.
+ *
+ * Tests are not bundled by default, to test build with "--with-tests" and then invoke from the
+ * dev console like `rgthree_tests.TestDescribeLabel()`. The output is in the test itself.
+ */
+import { wait } from "rgthree/common/shared_utils.js";
+
+declare global {
+ interface Window {
+ rgthree_tests: {
+ [key: string]: any;
+ };
+ }
+}
+
+
+window.rgthree_tests = window.rgthree_tests || {};
+
+type TestContext = {
+ label?: string;
+ beforeEach?: Function[];
+};
+
+let contexts: TestContext[] = [];
+
+export function describe(label: string, fn: Function) {
+ if (!label.startsWith('Test')) {
+ throw new Error('Test labels should start with "Test"');
+ }
+ window.rgthree_tests[label] = async () => {
+ await describeRun(label, fn);
+ };
+ return window.rgthree_tests[label];
+}
+
+export async function describeRun(label: string, fn: Function) {
+ await wait();
+ contexts.push({ label });
+ console.group(`[Start] ${contexts[contexts.length - 1]!.label}`);
+ await fn();
+ contexts.pop();
+ console.groupEnd();
+}
+
+export async function should(declaration: string, fn: Function) {
+ if (!contexts[contexts.length - 1]) {
+ throw Error("Called should outside of a describe.");
+ }
+ console.group(`...should ${declaration}`);
+ try {
+ for (const context of contexts) {
+ for (const beforeEachFn of context?.beforeEach || []) {
+ await beforeEachFn();
+ }
+ }
+ await fn();
+ } catch (e: any) {
+ fail(e);
+ }
+ console.groupEnd();
+}
+
+export async function beforeEach(fn: Function) {
+ if (!contexts[contexts.length - 1]) {
+ throw Error("Called beforeEach outside of a describe.");
+ }
+ const last = contexts[contexts.length - 1]!;
+ last.beforeEach = last?.beforeEach || [];
+ last.beforeEach.push(fn);
+}
+
+export function fail(e: Error) {
+ log(`X Failure: ${e}`, "color:#600; background:#fdd; padding: 2px 6px;");
+}
+
+function log(msg: string, styles: string) {
+ if (styles) {
+ console.log(`%c ${msg}`, styles);
+ } else {
+ console.log(msg);
+ }
+}
+
+class Expectation {
+ private propertyLabel: string | null = "";
+ private expectedLabel: string | null = "";
+ private verbLabel: string | null = "be";
+ private expectedFn!: (v: any) => boolean;
+ private value: any;
+
+ constructor(value: any) {
+ this.value = value;
+ }
+
+ toBe(labelOrExpected: any, maybeExpected?: any) {
+ const expected = maybeExpected !== undefined ? maybeExpected : labelOrExpected;
+ this.propertyLabel = maybeExpected !== undefined ? labelOrExpected : null;
+ this.expectedLabel = JSON.stringify(expected);
+ this.expectedFn = (v) => v == expected;
+ return this.toBeEval();
+ }
+ toMatchJson(labelOrExpected: any, maybeExpected?: any) {
+ const expected = maybeExpected !== undefined ? maybeExpected : labelOrExpected;
+ this.propertyLabel = maybeExpected !== undefined ? labelOrExpected : null;
+ this.expectedLabel = JSON.stringify(expected);
+ this.expectedFn = (v) => JSON.stringify(JSON.parse(v)) == JSON.stringify(JSON.parse(expected));
+ return this.toBeEval();
+ }
+ toBeUndefined(propertyLabel: string) {
+ this.expectedFn = (v) => v === undefined;
+ this.propertyLabel = propertyLabel || "";
+ this.expectedLabel = "undefined";
+ return this.toBeEval(true);
+ }
+ toBeNullOrUndefined(propertyLabel: string) {
+ this.expectedFn = (v) => v == null;
+ this.propertyLabel = propertyLabel || "";
+ this.expectedLabel = "null or undefined";
+ return this.toBeEval(true);
+ }
+ toBeTruthy(propertyLabel: string) {
+ this.expectedFn = (v) => !v;
+ this.propertyLabel = propertyLabel || "";
+ this.expectedLabel = "truthy";
+ return this.toBeEval(false);
+ }
+ toBeANumber(propertyLabel: string) {
+ this.expectedFn = (v) => typeof v === "number";
+ this.propertyLabel = propertyLabel || "";
+ this.expectedLabel = "a number";
+ return this.toBeEval();
+ }
+ toContain(labelOrExpected: any, maybeExpected?: any) {
+ const expected = maybeExpected !== undefined ? maybeExpected : labelOrExpected;
+ this.propertyLabel = maybeExpected !== undefined ? labelOrExpected : null;
+ this.verbLabel = 'contain';
+ this.expectedLabel = JSON.stringify(expected);
+ this.expectedFn = (v) => v.includes(expected);
+ return this.toBeEval();
+ }
+ toBeEval(strict = false) {
+ let evaluation = this.expectedFn(this.value);
+ let msg = `Expected ${this.propertyLabel ? this.propertyLabel + ` to ${this.verbLabel} ` : ""}${
+ this.expectedLabel
+ }`;
+ msg += evaluation ? "." : `, but was ${JSON.stringify(this.value)}`;
+ this.log(evaluation, msg);
+ return evaluation;
+ }
+ log(value: boolean, msg: string) {
+ if (value) {
+ log(`🗸 ${msg}`, "color:#060; background:#cec; padding: 2px 6px;");
+ } else {
+ log(`X ${msg}`, "color:#600; background:#fdd; padding: 2px 6px;");
+ }
+ }
+}
+
+export function expect(value: any, msg?: string) {
+ const expectation = new Expectation(value);
+ if (msg) {
+ expectation.log(value, msg);
+ }
+ return expectation;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/testing/utils_test.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/testing/utils_test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9c805f2cf79283fcce377d7505a1ef578503c903
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/testing/utils_test.ts
@@ -0,0 +1,41 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+import type {ComfyUITestEnvironment} from "./comfyui_env";
+
+export const PNG_1x1 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4v5ThPwAG7wKklwQ/bwAAAABJRU5ErkJggg==";
+export const PNG_1x2 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEElEQVQIW2NgYGD4D8QM/wEHAwH/OMSHKAAAAABJRU5ErkJggg==";
+export const PNG_2x1 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAD0lEQVQIW2NkYGD4D8QMAAUNAQFqjhCLAAAAAElFTkSuQmCC";
+
+export async function pasteImageToLoadImageNode(
+ env: ComfyUITestEnvironment,
+ dataUrl?: string,
+ node?: LGraphNode,
+) : Promise {
+ const dataArr = (dataUrl ?? PNG_1x1).split(",");
+ const mime = dataArr[0]!.match(/:(.*?);/)![1];
+ const bstr = atob(dataArr[1]!);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ const filename = `test_image_${+new Date()}.png`;
+ const file = new File([u8arr], filename, {type: mime});
+ if (!node) {
+ node = await env.addNode("LoadImage");
+ }
+ await (node as any).pasteFiles([file]);
+ let i = 0;
+ let good = false;
+ while (i++ < 10 || good) {
+ good = node.widgets![0]!.value === filename;
+ if (good) break;
+ await env.wait(100);
+ }
+ if (!good) {
+ throw new Error("Expected file not loaded.");
+ }
+ return node;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/tests/context_dynamic_tests.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/tests/context_dynamic_tests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..45c2b2c82c85fa073208ee84687c6c3057d4a3a7
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/tests/context_dynamic_tests.ts
@@ -0,0 +1,182 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {NodeTypesString} from "../constants.js";
+import {wait} from "rgthree/common/shared_utils.js";
+import {describe, should, beforeEach, expect, describeRun} from "../testing/runner.js";
+import {ComfyUITestEnvironment} from "../testing/comfyui_env.js";
+
+const env = new ComfyUITestEnvironment();
+
+function verifyInputAndOutputName(
+ node: LGraphNode,
+ index: number,
+ inputName: string | null,
+ isLinked?: boolean,
+) {
+ if (inputName != null) {
+ expect(node.inputs[index]!.name).toBe(`input ${index} name`, inputName);
+ }
+ if (isLinked) {
+ expect(node.inputs[index]!.link).toBeANumber(`input ${index} connection`);
+ } else if (isLinked === false) {
+ expect(node.inputs[index]!.link).toBeNullOrUndefined(`input ${index} connection`);
+ }
+ if (inputName != null) {
+ if (inputName === "+") {
+ expect(node.outputs[index]).toBeUndefined(`output ${index}`);
+ } else {
+ let outputName =
+ inputName === "base_ctx" ? "CONTEXT" : inputName.replace(/^\+\s/, "").toUpperCase();
+ expect(node.outputs[index]!.name).toBe(`output ${index} name`, outputName);
+ }
+ }
+}
+
+function vertifyInputsStructure(node: LGraphNode, expectedLength: number) {
+ expect(node.inputs.length).toBe("inputs length", expectedLength);
+ expect(node.outputs.length).toBe("outputs length", expectedLength - 1);
+ verifyInputAndOutputName(node, expectedLength - 1, "+", false);
+}
+
+describe("TestContextDynamic", async () => {
+ let nodeConfig!: LGraphNode;
+ let nodeCtx!: LGraphNode;
+
+ let lastNode: LGraphNode | null = null;
+
+ await beforeEach(async () => {
+ await env.clear();
+ lastNode = nodeConfig = await env.addNode(NodeTypesString.KSAMPLER_CONFIG);
+ lastNode = nodeCtx = await env.addNode(NodeTypesString.DYNAMIC_CONTEXT);
+ nodeConfig.connect(0, nodeCtx, 1); // steps
+ nodeConfig.connect(2, nodeCtx, 2); // cfg
+ nodeConfig.connect(4, nodeCtx, 3); // scheduler
+ nodeConfig.connect(0, nodeCtx, 4); // This is the step.1
+ nodeConfig.connect(0, nodeCtx, 5); // This is the step.2
+ nodeCtx.disconnectInput(2);
+ nodeCtx.disconnectInput(5);
+ nodeConfig.connect(0, nodeCtx, 6); // This is the step.3
+ nodeCtx.disconnectInput(6);
+ await wait();
+ });
+
+ await should("add correct inputs", async () => {
+ vertifyInputsStructure(nodeCtx, 8);
+ let i = 0;
+ verifyInputAndOutputName(nodeCtx, i++, "base_ctx", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ cfg", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ scheduler", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.1", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.2", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.3", false);
+ });
+
+ await should("add evaluate correct outputs", async () => {
+ const displayAny1 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "right"});
+ const displayAny2 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "under"});
+ const displayAny3 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "under"});
+ const displayAny4 = await env.addNode(NodeTypesString.DISPLAY_ANY, {placement: "under"});
+
+ nodeCtx.connect(1, displayAny1, 0); // steps
+ nodeCtx.connect(3, displayAny2, 0); // scheduler
+ nodeCtx.connect(4, displayAny3, 0); // steps.1
+ nodeCtx.connect(6, displayAny4, 0); // steps.3 (unlinked)
+
+ await env.queuePrompt();
+
+ expect(displayAny1.widgets![0]!.value).toBe("output 1", 30);
+ expect(displayAny2.widgets![0]!.value).toBe("output 3", '"normal"');
+ expect(displayAny3.widgets![0]!.value).toBe("output 4", 30);
+ expect(displayAny4.widgets![0]!.value).toBe("output 6", "None");
+ });
+
+ await describeRun("Nested", async () => {
+ let nodeConfig2!: LGraphNode;
+ let nodeCtx2!: LGraphNode;
+
+ await beforeEach(async () => {
+ nodeConfig2 = await env.addNode(NodeTypesString.KSAMPLER_CONFIG, {placement: "start"});
+ nodeConfig2.widgets = nodeConfig2.widgets || [];
+ nodeConfig2.widgets[0]!.value = 111;
+ nodeConfig2.widgets[2]!.value = 11.1;
+ nodeCtx2 = await env.addNode(NodeTypesString.DYNAMIC_CONTEXT, {placement: "right"});
+ nodeConfig2.connect(0, nodeCtx2, 1); // steps
+ nodeConfig2.connect(2, nodeCtx2, 2); // cfg
+ nodeConfig2.connect(3, nodeCtx2, 3); // sampler
+ nodeConfig2.connect(2, nodeCtx2, 4); // This is the cfg.1
+ nodeConfig2.connect(0, nodeCtx2, 5); // This is the steps.1
+ nodeCtx2.disconnectInput(2);
+ nodeCtx2.disconnectInput(5);
+ nodeConfig2.connect(2, nodeCtx2, 6); // This is the cfg.2
+ nodeCtx2.disconnectInput(6);
+
+ await wait();
+ });
+
+ await should("disallow context node to be connected to non-first spot.", async () => {
+ // Connect to first node.
+ let expectedInputs = 8;
+
+ nodeCtx2.connect(0, nodeCtx, expectedInputs - 1);
+ console.log(nodeCtx.inputs);
+
+ vertifyInputsStructure(nodeCtx, expectedInputs);
+ verifyInputAndOutputName(nodeCtx, 0, "base_ctx", false);
+ verifyInputAndOutputName(nodeCtx, nodeCtx.inputs.length - 1, null, false);
+
+ nodeCtx2.connect(0, nodeCtx, 0);
+ expectedInputs = 14;
+ vertifyInputsStructure(nodeCtx, expectedInputs);
+ verifyInputAndOutputName(nodeCtx, 0, "base_ctx", true);
+ verifyInputAndOutputName(nodeCtx, expectedInputs - 1, null, false);
+ });
+
+ await should("add inputs from connected above owned.", async () => {
+ // Connect to first node.
+ nodeCtx2.connect(0, nodeCtx, 0);
+
+ let expectedInputs = 14;
+ vertifyInputsStructure(nodeCtx, expectedInputs);
+ let i = 0;
+ verifyInputAndOutputName(nodeCtx, i++, "base_ctx", true);
+ verifyInputAndOutputName(nodeCtx, i++, "steps", false);
+ verifyInputAndOutputName(nodeCtx, i++, "cfg", false);
+ verifyInputAndOutputName(nodeCtx, i++, "sampler", false);
+ verifyInputAndOutputName(nodeCtx, i++, "cfg.1", false);
+ verifyInputAndOutputName(nodeCtx, i++, "steps.1", false);
+ verifyInputAndOutputName(nodeCtx, i++, "cfg.2", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.2", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ cfg.3", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ scheduler", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.3", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.4", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.5", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+", false);
+ });
+
+ await should("add then remove inputs when disconnected.", async () => {
+ // Connect to first node.
+ nodeCtx2.connect(0, nodeCtx, 0);
+
+ let expectedInputs = 14;
+ expect(nodeCtx.inputs.length).toBe("inputs length", expectedInputs);
+ expect(nodeCtx.outputs.length).toBe("outputs length", expectedInputs - 1);
+
+ nodeCtx.disconnectInput(0);
+
+ expectedInputs = 8;
+ expect(nodeCtx.inputs.length).toBe("inputs length", expectedInputs);
+ expect(nodeCtx.outputs.length).toBe("outputs length", expectedInputs - 1);
+ let i = 0;
+ verifyInputAndOutputName(nodeCtx, i++, "base_ctx", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ cfg", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ scheduler", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.1", true);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.2", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+ steps.3", false);
+ verifyInputAndOutputName(nodeCtx, i++, "+", false);
+ });
+ });
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/tests/image_or_latent_size_tests.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/tests/image_or_latent_size_tests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0cb932bc66afcf23b9b8d8ed17fd4098c5c1f887
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/tests/image_or_latent_size_tests.ts
@@ -0,0 +1,103 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {NodeTypesString} from "../constants";
+import {ComfyUITestEnvironment} from "../testing/comfyui_env";
+import {describe, should, beforeEach, expect, describeRun} from "../testing/runner.js";
+import {pasteImageToLoadImageNode, PNG_1x1, PNG_1x2, PNG_2x1} from "../testing/utils_test.js";
+
+const env = new ComfyUITestEnvironment();
+
+describe("TestImageOrLatentSize", async () => {
+ await beforeEach(async () => {
+ await env.clear();
+ });
+
+ await describeRun("LoadImage", async () => {
+ let imageNode: LGraphNode;
+ let displayAnyW: LGraphNode;
+ let displayAnyH: LGraphNode;
+
+ await beforeEach(async () => {
+ await env.clear();
+ imageNode = await env.addNode("LoadImage");
+ const sizeNode = await env.addNode(NodeTypesString.IMAGE_OR_LATENT_SIZE);
+ displayAnyW = await env.addNode(NodeTypesString.DISPLAY_ANY);
+ displayAnyH = await env.addNode(NodeTypesString.DISPLAY_ANY);
+ imageNode.connect(0, sizeNode, 0);
+ sizeNode.connect(0, displayAnyW, 0);
+ sizeNode.connect(1, displayAnyH, 0);
+ await env.wait();
+ });
+
+ await should("get correct size for a 1x1 image", async () => {
+ await pasteImageToLoadImageNode(env, PNG_1x1, imageNode);
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 1);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 1);
+ });
+
+ await should("get correct size for a 1x2 image", async () => {
+ await pasteImageToLoadImageNode(env, PNG_1x2, imageNode);
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 1);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 2);
+ });
+
+ await should("get correct size for a 2x1 image", async () => {
+ await pasteImageToLoadImageNode(env, PNG_2x1, imageNode);
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 2);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 1);
+ });
+ });
+
+ await describeRun("Latent", async () => {
+ let latentNode: LGraphNode;
+ let displayAnyW: LGraphNode;
+ let displayAnyH: LGraphNode;
+
+ await beforeEach(async () => {
+ await env.clear();
+ latentNode = await env.addNode("EmptyLatentImage");
+ const sizeNode = await env.addNode(NodeTypesString.IMAGE_OR_LATENT_SIZE);
+ displayAnyW = await env.addNode(NodeTypesString.DISPLAY_ANY);
+ displayAnyH = await env.addNode(NodeTypesString.DISPLAY_ANY);
+ latentNode.connect(0, sizeNode, 0);
+ sizeNode.connect(0, displayAnyW, 0);
+ sizeNode.connect(1, displayAnyH, 0);
+ await env.wait();
+ latentNode.widgets![0]!.value = 16; // Width
+ latentNode.widgets![1]!.value = 16; // Height
+ latentNode.widgets![2]!.value = 1; // Batch
+ await env.wait();
+ });
+
+ await should("get correct size for a 16x16 latent", async () => {
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 16);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 16);
+ });
+
+ await should("get correct size for a 16x32 latent", async () => {
+ latentNode.widgets![1]!.value = 32;
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 16);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 32);
+ });
+
+ await should("get correct size for a 32x16 image", async () => {
+ latentNode.widgets![0]!.value = 32;
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 32);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 16);
+ });
+
+ await should("get correct size with a batch", async () => {
+ latentNode.widgets![0]!.value = 32;
+ latentNode.widgets![2]!.value = 2;
+ await env.queuePrompt();
+ expect(displayAnyW.widgets![0]!.value).toBe("width", 32);
+ expect(displayAnyH.widgets![0]!.value).toBe("height", 16);
+ });
+ });
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/tests/power_puter.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/tests/power_puter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4ed575990f65282e44a63a3c10d8342d79781562
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/tests/power_puter.ts
@@ -0,0 +1,162 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+
+import {NodeTypesString} from "../constants";
+import {ComfyUITestEnvironment} from "../testing/comfyui_env";
+import {describe, should, beforeEach, expect, describeRun} from "../testing/runner.js";
+import {pasteImageToLoadImageNode, PNG_1x1, PNG_1x2, PNG_2x1} from "../testing/utils_test.js";
+
+const env = new ComfyUITestEnvironment();
+
+function setPowerPuterValue(node: LGraphNode, outputType: string, value: string) {
+ // Strip as much whitespace on first non-empty line from all lines.
+ if (value.includes("\n")) {
+ value = value.replace(/^\n/gm, "");
+ const strip = value.match(/^(.*?)\S/)?.[1]?.length;
+ if (strip) {
+ value = value.replace(new RegExp(`^.{${strip}}`, "mg"), "");
+ }
+ }
+ node.widgets![1]!.value = value;
+ node.widgets![0]!.value = outputType;
+}
+
+describe("TestPowerPuter", async () => {
+ let powerPuter!: LGraphNode;
+ let displayAny!: LGraphNode;
+
+ await beforeEach(async () => {
+ await env.clear();
+ powerPuter = await env.addNode(NodeTypesString.POWER_PUTER);
+ displayAny = await env.addNode(NodeTypesString.DISPLAY_ANY);
+ powerPuter.connect(0, displayAny, 0);
+ await env.wait();
+ });
+
+ await should("output constants and concatenation", async () => {
+ const checks: Array<[string, string, string]> = [
+ ["1", "1", "STRING"],
+ ['"abc"', "abc", "STRING"],
+ ["1 + 2", "3", "STRING"],
+ ['"abc" + "xyz"', "abcxyz", "STRING"],
+ // INT
+ ["1", "1", "INT"],
+ ["1 + 2", "3", "INT"],
+ // FLOAT
+ ["1", "1.0", "FLOAT"],
+ ["1.3 + 2.8", "4.1", "FLOAT"],
+ // BOOLEAN
+ ["1", "True", "BOOLEAN"],
+ ["1 - 1", "False", "BOOLEAN"],
+ ];
+ for (const data of checks) {
+ setPowerPuterValue(powerPuter, data[2], data[0]);
+ await env.queuePrompt();
+ expect(displayAny.widgets![0]!.value).toBe(data[0], data[1]);
+ }
+ });
+
+ await should("handle inputs", async () => {
+ // TODO
+ });
+
+ await should("handle complex inputs", async () => {
+ // TODO
+ });
+
+ await should("handle a for loop", async () => {
+ setPowerPuterValue(
+ powerPuter,
+ "STRING",
+ `
+ a = 0
+ b = ''
+ for n in range(4):
+ a += n
+ for m in range(2):
+ b += f'{str(n)}-{str(m)}.'
+ f'a:{a} b:{b}'
+ `,
+ );
+ await env.queuePrompt();
+ expect(displayAny.widgets![0]!.value).toBe("a:6 b:0-0.0-1.1-0.1-1.2-0.2-1.3-0.3-1.");
+ });
+
+ await should("handle assigning with a subscript slice", async () => {
+ setPowerPuterValue(
+ powerPuter,
+ "STRING",
+ `
+ a = [1,2,0]
+ a[a[2]] = 3
+ tuple(a)
+ `,
+ );
+ await env.queuePrompt();
+ expect(displayAny.widgets![0]!.value).toBe("(3, 2, 0)");
+ });
+
+ await should("handle aug assigning with a subscript slice", async () => {
+ setPowerPuterValue(
+ powerPuter,
+ "STRING",
+ `
+ a = [1,2,0]
+ a[a[2]] += 3
+ tuple(a)
+ `,
+ );
+ await env.queuePrompt();
+ expect(displayAny.widgets![0]!.value).toBe("(4, 2, 0)");
+ });
+
+ await should("disallow calls to some methods", async () => {
+ const imageNode = await pasteImageToLoadImageNode(env);
+ imageNode.connect(0, powerPuter, 0);
+ setPowerPuterValue(
+ powerPuter,
+ "STRING",
+ `a.numpy().tofile('/tmp/test')
+ `,
+ );
+ await env.queuePrompt();
+
+ // Check to see if there's an error.
+ expect(document.querySelector(".p-dialog-mask .p-card-body")!.textContent).toContain(
+ "error message",
+ "Disallowed access to \"tofile\" for type ",
+ );
+ (document.querySelector(".p-dialog-mask .p-dialog-close-button")! as HTMLButtonElement).click();
+ });
+
+ await should("handle boolean operators correctly", async () => {
+ const checks: Array<[string, string, string, ('toMatchJson'|'toBe')?]> = [
+ // And operator all success
+ ["1 and 42", "42", "STRING"],
+ ["True and [42]", "[42]", "STRING", "toMatchJson"],
+ ["a = 42\nTrue and [a]", "[42]", "STRING", "toMatchJson"],
+ ["1 and 3 and True and [1] and 42", "42", "STRING"],
+ // And operator w/ a failure
+ ["1 and 3 and True and [] and 42", "[]", "STRING", "toMatchJson"],
+ ["1 and 0 and True and [] and 42", "0", "STRING"],
+ ["1 and 2 and False and [] and 42", "False", "STRING"],
+ ["b = None\n1 and 2 and True and b and 42", "None", "STRING"],
+ // Or operator
+ ["1 or 42", "1", "STRING"],
+ ["0 or 42", "42", "STRING"],
+ ["0 or None or False or [] or 42", "42", "STRING"],
+ ["b=42\n0 or None or False or [] or b", "42", "STRING"],
+ ["b=42\n0 or None or False or [b] or b", "[42]", "STRING", "toMatchJson"],
+ ["b=42\n0 or None or True or [b] or b", "True", "STRING"],
+ // Mix
+ ["1 and 2 and 0 or 5", "5", "STRING"],
+ ["None and 1 or True", "True", "STRING"],
+ ["0 or False and True", "False", "STRING"],
+
+ ];
+ for (const data of checks) {
+ setPowerPuterValue(powerPuter, data[2], data[0]);
+ await env.queuePrompt();
+ expect(displayAny.widgets![0]!.value)[data[3] || 'toBe'](data[0], data[1]);
+ }
+ });
+});
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/utils.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..72bd0a15c1e3819d4c7eb4e08f204c81115bda56
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/utils.ts
@@ -0,0 +1,1160 @@
+import type {
+ Vector2,
+ LGraphCanvas as TLGraphCanvas,
+ LLink,
+ LGraph,
+ IContextMenuOptions,
+ ContextMenu,
+ LGraphNode as TLGraphNode,
+ INodeSlot,
+ INodeInputSlot,
+ INodeOutputSlot,
+ IContextMenuValue,
+ ISerialisedNode,
+ ISlotType,
+ LGraphNodeConstructor,
+ LGraphEventMode,
+ NodeProperty,
+ LinkDirection,
+ Point,
+ ComfyApp,
+ LGraphGroup,
+ NodeId,
+ Subgraph,
+ SubgraphNode,
+} from "@comfyorg/frontend";
+import type {ComfyApiPrompt, ComfyNodeDef} from "typings/comfy";
+import type {Constructor} from "typings/rgthree";
+
+import {app} from "scripts/app.js";
+import {api} from "scripts/api.js";
+import {Resolver, getResolver, wait} from "rgthree/common/shared_utils.js";
+import {RgthreeHelpDialog} from "rgthree/common/dialog.js";
+
+/**
+ * Override the api.getNodeDefs call to add a hook for refreshing node defs.
+ * This is necessary for power prompt's custom combos. Since API implements
+ * add/removeEventListener already, this is rather trivial.
+ */
+const oldApiGetNodeDefs = api.getNodeDefs;
+api.getNodeDefs = async function () {
+ const defs = await oldApiGetNodeDefs.call(api);
+ this.dispatchEvent(new CustomEvent("fresh-node-defs", {detail: defs}));
+ return defs;
+};
+
+export enum IoDirection {
+ INPUT,
+ OUTPUT,
+}
+
+const PADDING = 0;
+
+type LiteGraphDir =
+ | typeof LiteGraph.LEFT
+ | typeof LiteGraph.RIGHT
+ | typeof LiteGraph.UP
+ | typeof LiteGraph.DOWN;
+export const LAYOUT_LABEL_TO_DATA: {[label: string]: [LiteGraphDir, Vector2, Vector2]} = {
+ Left: [LiteGraph.LEFT, [0, 0.5], [PADDING, 0]],
+ Right: [LiteGraph.RIGHT, [1, 0.5], [-PADDING, 0]],
+ Top: [LiteGraph.UP, [0.5, 0], [0, PADDING]],
+ Bottom: [LiteGraph.DOWN, [0.5, 1], [0, -PADDING]],
+};
+export const LAYOUT_LABEL_OPPOSITES: {[label: string]: string} = {
+ Left: "Right",
+ Right: "Left",
+ Top: "Bottom",
+ Bottom: "Top",
+};
+export const LAYOUT_CLOCKWISE = ["Top", "Right", "Bottom", "Left"];
+
+interface MenuConfig {
+ name: string | ((node: TLGraphNode) => string);
+ property?: string;
+ prepareValue?: (value: NodeProperty | undefined, node: TLGraphNode) => any;
+ callback?: (node: TLGraphNode, value?: string) => void;
+ subMenuOptions?: (string | null)[] | ((node: TLGraphNode) => (string | null)[]);
+}
+
+export function addMenuItem(
+ node: LGraphNodeConstructor,
+ _app: ComfyApp,
+ config: MenuConfig,
+ after = "Shape",
+) {
+ const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions;
+ node.prototype.getExtraMenuOptions = function (
+ canvas: TLGraphCanvas,
+ menuOptions: IContextMenuValue[],
+ ) {
+ oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]);
+ addMenuItemOnExtraMenuOptions(this, config, menuOptions, after);
+ };
+}
+
+/**
+ * Waits for the canvas to be available on app using a single promise.
+ */
+let canvasResolver: Resolver | null = null;
+export function waitForCanvas() {
+ if (canvasResolver === null) {
+ canvasResolver = getResolver();
+ function _waitForCanvas() {
+ if (!canvasResolver!.completed) {
+ if (app?.canvas) {
+ canvasResolver!.resolve(app.canvas);
+ } else {
+ requestAnimationFrame(_waitForCanvas);
+ }
+ }
+ }
+ _waitForCanvas();
+ }
+ return canvasResolver.promise;
+}
+
+/**
+ * Waits for the graph to be available on app using a single promise.
+ */
+let graphResolver: Resolver | null = null;
+export function waitForGraph() {
+ if (graphResolver === null) {
+ graphResolver = getResolver();
+ function _wait() {
+ if (!graphResolver!.completed) {
+ if (app?.graph) {
+ graphResolver!.resolve(app.graph);
+ } else {
+ requestAnimationFrame(_wait);
+ }
+ }
+ }
+ _wait();
+ }
+ return graphResolver.promise;
+}
+
+export function addMenuItemOnExtraMenuOptions(
+ node: TLGraphNode,
+ config: MenuConfig,
+ menuOptions: (IContextMenuValue | null)[],
+ after = "Shape",
+) {
+ let idx = menuOptions
+ .slice()
+ .reverse()
+ .findIndex((option) => (option as any)?.isRgthree);
+ if (idx == -1) {
+ idx = menuOptions.findIndex((option) => option?.content?.includes(after)) + 1;
+ if (!idx) {
+ idx = menuOptions.length - 1;
+ }
+ // Add a separator, and move to the next one.
+ menuOptions.splice(idx, 0, null);
+ idx++;
+ } else {
+ idx = menuOptions.length - idx;
+ }
+
+ const subMenuOptions =
+ typeof config.subMenuOptions === "function"
+ ? config.subMenuOptions(node)
+ : config.subMenuOptions;
+
+ menuOptions.splice(idx, 0, {
+ content: typeof config.name == "function" ? config.name(node) : config.name,
+ has_submenu: !!subMenuOptions?.length,
+ isRgthree: true, // Mark it, so we can find it.
+ callback: (
+ value: IContextMenuValue,
+ _options: IContextMenuOptions,
+ event: MouseEvent,
+ parentMenu: ContextMenu | undefined,
+ _node: TLGraphNode,
+ ) => {
+ if (!!subMenuOptions?.length) {
+ new LiteGraph.ContextMenu(
+ subMenuOptions.map((option) => (option ? {content: option} : null)),
+ {
+ event,
+ parentMenu,
+ callback: (
+ subValue: IContextMenuValue,
+ _options: IContextMenuOptions,
+ _event: MouseEvent,
+ _parentMenu: ContextMenu | undefined,
+ _node: TLGraphNode,
+ ) => {
+ if (config.property) {
+ node.properties = node.properties || {};
+ node.properties[config.property] = config.prepareValue
+ ? config.prepareValue(subValue!.content || "", node)
+ : subValue!.content || "";
+ }
+ config.callback && config.callback(node, subValue?.content);
+ },
+ },
+ );
+ return;
+ }
+ if (config.property) {
+ node.properties = node.properties || {};
+ node.properties[config.property] = config.prepareValue
+ ? config.prepareValue(node.properties[config.property], node)
+ : !node.properties[config.property];
+ }
+ config.callback && config.callback(node, value?.content);
+ },
+ } as IContextMenuValue);
+}
+
+export function addConnectionLayoutSupport(
+ node: LGraphNodeConstructor,
+ app: ComfyApp,
+ options = [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ],
+ callback?: (node: TLGraphNode) => void,
+) {
+ addMenuItem(node, app, {
+ name: "Connections Layout",
+ property: "connections_layout",
+ subMenuOptions: options.map((option) => option[0] + (option[1] ? " -> " + option[1] : "")),
+ prepareValue: (value, node) => {
+ const values = String(value).split(" -> ");
+ if (!values[1] && !node.outputs?.length) {
+ values[1] = LAYOUT_LABEL_OPPOSITES[values[0]!]!;
+ }
+ if (!LAYOUT_LABEL_TO_DATA[values[0]!] || !LAYOUT_LABEL_TO_DATA[values[1]!]) {
+ throw new Error(`New Layout invalid: [${values[0]}, ${values[1]}]`);
+ }
+ return values;
+ },
+ callback: (node) => {
+ callback && callback(node);
+ node.graph?.setDirtyCanvas(true, true);
+ },
+ });
+
+ // [🤮] This is deprecated in a https://github.com/Comfy-Org/litegraph.js/pull/716 @v0.9.9 and
+ // replaced by getInputPos and getOutputPos conveniently added below.
+ node.prototype.getConnectionPos = function (isInput: boolean, slotNumber: number, out: Vector2) {
+ // Purposefully do not need to call the old one.
+ return getConnectionPosForLayout(this, isInput, slotNumber, out);
+ };
+ node.prototype.getInputPos = function (slotNumber: number): Vector2 {
+ // Purposefully do not need to call the existing one because we're overriding.
+ return getConnectionPosForLayout(this, true, slotNumber, [0, 0]);
+ };
+ node.prototype.getOutputPos = function (slotNumber: number): Vector2 {
+ // Purposefully do not need to call the existing one because we're overriding.
+ return getConnectionPosForLayout(this, false, slotNumber, [0, 0]);
+ };
+}
+
+export function setConnectionsLayout(node: TLGraphNode, newLayout: [string, string]) {
+ newLayout = newLayout || (node as any).defaultConnectionsLayout || ["Left", "Right"];
+ // If we didn't supply an output layout, and there's no outputs, then just choose the opposite of the
+ // input as a safety.
+ if (!newLayout[1] && !node.outputs?.length) {
+ newLayout[1] = LAYOUT_LABEL_OPPOSITES[newLayout[0]!]!;
+ }
+ if (!LAYOUT_LABEL_TO_DATA[newLayout[0]] || !LAYOUT_LABEL_TO_DATA[newLayout[1]]) {
+ throw new Error(`New Layout invalid: [${newLayout[0]}, ${newLayout[1]}]`);
+ }
+ node.properties = node.properties || {};
+ node.properties["connections_layout"] = newLayout;
+}
+
+/** Allows collapsing of connections into one. Pretty unusable, unless you're the muter. */
+export function setConnectionsCollapse(
+ node: TLGraphNode,
+ collapseConnections: boolean | null = null,
+) {
+ node.properties = node.properties || {};
+ collapseConnections =
+ collapseConnections !== null ? collapseConnections : !node.properties["collapse_connections"];
+ node.properties["collapse_connections"] = collapseConnections;
+}
+
+export function getConnectionPosForLayout(
+ node: TLGraphNode,
+ isInput: boolean,
+ slotNumber: number,
+ out: Vector2,
+) {
+ out = out || new Float32Array(2);
+ node.properties = node.properties || {};
+ const layout = node.properties["connections_layout"] ||
+ (node as any).defaultConnectionsLayout || ["Left", "Right"];
+ const collapseConnections = (node.properties["collapse_connections"] as boolean) || false;
+ const offset = (node.constructor as any).layout_slot_offset ?? LiteGraph.NODE_SLOT_HEIGHT * 0.5;
+ let side = isInput ? layout[0] : layout[1];
+ const otherSide = isInput ? layout[1] : layout[0];
+ let data = LAYOUT_LABEL_TO_DATA[side]!; // || LAYOUT_LABEL_TO_DATA[isInput ? 'Left' : 'Right'];
+ const slotList = node[isInput ? "inputs" : "outputs"];
+ const cxn = slotList[slotNumber];
+ if (!cxn) {
+ console.log("No connection found.. weird", isInput, slotNumber);
+ return out;
+ }
+ // Experimental; doesn't work without node.clip_area set (so it won't draw outside),
+ // but litegraph.core inexplicably clips the title off which we want... so, no go.
+ // if (cxn.hidden) {
+ // out[0] = node.pos[0] - 100000
+ // out[1] = node.pos[1] - 100000
+ // return out
+ // }
+ if (cxn.disabled) {
+ // Let's store the original colors if have them and haven't yet overridden
+ if (cxn.color_on !== "#666665") {
+ (cxn as any)._color_on_org = (cxn as any)._color_on_org || cxn.color_on;
+ (cxn as any)._color_off_org = (cxn as any)._color_off_org || cxn.color_off;
+ }
+ cxn.color_on = "#666665";
+ cxn.color_off = "#666665";
+ } else if (cxn.color_on === "#666665") {
+ cxn.color_on = (cxn as any)._color_on_org || undefined;
+ cxn.color_off = (cxn as any)._color_off_org || undefined;
+ }
+ const displaySlot = collapseConnections
+ ? 0
+ : slotNumber -
+ slotList.reduce((count, ioput, index) => {
+ count += index < slotNumber && ioput.hidden ? 1 : 0;
+ return count;
+ }, 0);
+ // Set the direction first. This is how the connection line will be drawn.
+ cxn.dir = data[0];
+
+ // If we are only 10px tall or wide, then look at connections_dir for the direction.
+ const connections_dir = node.properties["connections_dir"] as [LinkDirection, LinkDirection];
+ if ((node.size[0] == 10 || node.size[1] == 10) && connections_dir) {
+ cxn.dir = connections_dir[isInput ? 0 : 1]!;
+ }
+
+ if (side === "Left") {
+ if (node.flags.collapsed) {
+ var w = (node as any)._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH;
+ out[0] = node.pos[0];
+ out[1] = node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5;
+ } else {
+ // If we're an output, then the litegraph.core hates us; we need to blank out the name
+ // because it's not flexible enough to put the text on the inside.
+ toggleConnectionLabel(cxn, !isInput || collapseConnections || !!(node as any).hideSlotLabels);
+ out[0] = node.pos[0] + offset;
+ if ((node.constructor as any)?.type.includes("Reroute")) {
+ out[1] = node.pos[1] + node.size[1] * 0.5;
+ } else {
+ out[1] =
+ node.pos[1] +
+ (displaySlot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
+ ((node.constructor as any).slot_start_y || 0);
+ }
+ }
+ } else if (side === "Right") {
+ if (node.flags.collapsed) {
+ var w = (node as any)._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH;
+ out[0] = node.pos[0] + w;
+ out[1] = node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5;
+ } else {
+ // If we're an input, then the litegraph.core hates us; we need to blank out the name
+ // because it's not flexible enough to put the text on the inside.
+ toggleConnectionLabel(cxn, isInput || collapseConnections || !!(node as any).hideSlotLabels);
+ out[0] = node.pos[0] + node.size[0] + 1 - offset;
+ if ((node.constructor as any)?.type.includes("Reroute")) {
+ out[1] = node.pos[1] + node.size[1] * 0.5;
+ } else {
+ out[1] =
+ node.pos[1] +
+ (displaySlot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
+ ((node.constructor as any).slot_start_y || 0);
+ }
+ }
+
+ // Right now, only reroute uses top/bottom, so this may not work for other nodes
+ // (like, applying to nodes with titles, collapsed, multiple inputs/outputs, etc).
+ } else if (side === "Top") {
+ if (!(cxn as any).has_old_label) {
+ (cxn as any).has_old_label = true;
+ (cxn as any).old_label = cxn.label;
+ cxn.label = " ";
+ }
+ out[0] = node.pos[0] + node.size[0] * 0.5;
+ out[1] = node.pos[1] + offset;
+ } else if (side === "Bottom") {
+ if (!(cxn as any).has_old_label) {
+ (cxn as any).has_old_label = true;
+ (cxn as any).old_label = cxn.label;
+ cxn.label = " ";
+ }
+ out[0] = node.pos[0] + node.size[0] * 0.5;
+ out[1] = node.pos[1] + node.size[1] - offset;
+ }
+ return out;
+}
+
+function toggleConnectionLabel(cxn: any, hide = true) {
+ if (hide) {
+ if (!(cxn as any).has_old_label) {
+ (cxn as any).has_old_label = true;
+ (cxn as any).old_label = cxn.label;
+ }
+ cxn.label = " ";
+ } else if (!hide && (cxn as any).has_old_label) {
+ (cxn as any).has_old_label = false;
+ cxn.label = (cxn as any).old_label;
+ (cxn as any).old_label = undefined;
+ }
+ return cxn;
+}
+
+export function addHelpMenuItem(
+ node: TLGraphNode,
+ content: string,
+ menuOptions: (IContextMenuValue | null)[],
+) {
+ addMenuItemOnExtraMenuOptions(
+ node,
+ {
+ name: "🛟 Node Help",
+ callback: (node) => {
+ if ((node as any).showHelp) {
+ (node as any).showHelp();
+ } else {
+ new RgthreeHelpDialog(node, content).show();
+ }
+ },
+ },
+ menuOptions,
+ "Properties Panel",
+ );
+}
+
+export enum PassThroughFollowing {
+ ALL,
+ NONE,
+ REROUTE_ONLY,
+}
+
+/**
+ * Determines if, when doing a chain lookup for connected nodes, we want to pass through this node,
+ * like reroutes, etc.
+ */
+export function shouldPassThrough(
+ node?: TLGraphNode | null,
+ passThroughFollowing = PassThroughFollowing.ALL,
+) {
+ const type = (node?.constructor as typeof TLGraphNode)?.type;
+ if (!type || passThroughFollowing === PassThroughFollowing.NONE) {
+ return false;
+ }
+ if (passThroughFollowing === PassThroughFollowing.REROUTE_ONLY) {
+ return type.includes("Reroute");
+ }
+ return (
+ type.includes("Reroute") || type.includes("Node Combiner") || type.includes("Node Collector")
+ );
+}
+
+function filterOutPassthroughNodes(
+ infos: ConnectedNodeInfo[],
+ passThroughFollowing = PassThroughFollowing.ALL,
+) {
+ return infos.filter((i) => !shouldPassThrough(i.node, passThroughFollowing));
+}
+
+/**
+ * Looks through the immediate chain of a node to collect all connected nodes, passing through nodes
+ * like reroute, etc. Will also disconnect duplicate nodes from a provided node
+ */
+export function getConnectedInputNodes(
+ startNode: TLGraphNode,
+ currentNode?: TLGraphNode,
+ slot?: number,
+ passThroughFollowing = PassThroughFollowing.ALL,
+): TLGraphNode[] {
+ return getConnectedNodesInfo(
+ startNode,
+ IoDirection.INPUT,
+ currentNode,
+ slot,
+ passThroughFollowing,
+ ).map((n) => n.node);
+}
+export function getConnectedInputInfosAndFilterPassThroughs(
+ startNode: TLGraphNode,
+ currentNode?: TLGraphNode,
+ slot?: number,
+ passThroughFollowing = PassThroughFollowing.ALL,
+) {
+ return filterOutPassthroughNodes(
+ getConnectedNodesInfo(startNode, IoDirection.INPUT, currentNode, slot, passThroughFollowing),
+ passThroughFollowing,
+ );
+}
+export function getConnectedInputNodesAndFilterPassThroughs(
+ startNode: TLGraphNode,
+ currentNode?: TLGraphNode,
+ slot?: number,
+ passThroughFollowing = PassThroughFollowing.ALL,
+): TLGraphNode[] {
+ return getConnectedInputInfosAndFilterPassThroughs(
+ startNode,
+ currentNode,
+ slot,
+ passThroughFollowing,
+ ).map((n) => n.node);
+}
+
+export function getConnectedOutputNodes(
+ startNode: TLGraphNode,
+ currentNode?: TLGraphNode,
+ slot?: number,
+ passThroughFollowing = PassThroughFollowing.ALL,
+): TLGraphNode[] {
+ return getConnectedNodesInfo(
+ startNode,
+ IoDirection.OUTPUT,
+ currentNode,
+ slot,
+ passThroughFollowing,
+ ).map((n) => n.node);
+}
+
+export function getConnectedOutputNodesAndFilterPassThroughs(
+ startNode: TLGraphNode,
+ currentNode?: TLGraphNode,
+ slot?: number,
+ passThroughFollowing = PassThroughFollowing.ALL,
+): TLGraphNode[] {
+ return filterOutPassthroughNodes(
+ getConnectedNodesInfo(startNode, IoDirection.OUTPUT, currentNode, slot, passThroughFollowing),
+ passThroughFollowing,
+ ).map((n) => n.node);
+}
+
+export type ConnectedNodeInfo = {
+ node: TLGraphNode;
+ travelFromSlot: number;
+ travelToSlot: number;
+ originTravelFromSlot: number;
+};
+
+export function getConnectedNodesInfo(
+ startNode: TLGraphNode,
+ dir = IoDirection.INPUT,
+ currentNode?: TLGraphNode,
+ slot?: number,
+ passThroughFollowing = PassThroughFollowing.ALL,
+ originTravelFromSlot?: number,
+): ConnectedNodeInfo[] {
+ currentNode = currentNode || startNode;
+ let rootNodes: ConnectedNodeInfo[] = [];
+ if (startNode === currentNode || shouldPassThrough(currentNode, passThroughFollowing)) {
+ let linkIds: Array;
+
+ slot = slot != null && slot > -1 ? slot : undefined;
+ if (dir == IoDirection.OUTPUT) {
+ if (slot != null) {
+ linkIds = [...(currentNode.outputs?.[slot]?.links || [])];
+ } else {
+ linkIds = currentNode.outputs?.flatMap((i) => i.links) || [];
+ }
+ } else {
+ if (slot != null) {
+ linkIds = [currentNode.inputs?.[slot]?.link];
+ } else {
+ linkIds = currentNode.inputs?.map((i) => i.link) || [];
+ }
+ }
+ const graph = currentNode.graph ?? app.graph;
+ for (const linkId of linkIds) {
+ let link: LLink | null = null;
+ if (typeof linkId == "number") {
+ link = graph.links[linkId] ?? null;
+ }
+ if (!link) {
+ continue;
+ }
+ const travelFromSlot = dir == IoDirection.OUTPUT ? link.origin_slot : link.target_slot;
+ const connectedId = dir == IoDirection.OUTPUT ? link.target_id : link.origin_id;
+ const travelToSlot = dir == IoDirection.OUTPUT ? link.target_slot : link.origin_slot;
+ originTravelFromSlot = originTravelFromSlot != null ? originTravelFromSlot : travelFromSlot;
+ const originNode: TLGraphNode = graph.getNodeById(connectedId)!;
+ if (!link) {
+ console.error("No connected node found... weird");
+ continue;
+ }
+ if (rootNodes.some((n) => n.node == originNode)) {
+ console.log(
+ `${startNode.title} (${startNode.id}) seems to have two links to ${originNode.title} (${
+ originNode.id
+ }). One may be stale: ${linkIds.join(", ")}`,
+ );
+ } else {
+ // Add the node and, if it's a pass through, let's collect all its nodes as well.
+ rootNodes.push({node: originNode, travelFromSlot, travelToSlot, originTravelFromSlot});
+ if (shouldPassThrough(originNode, passThroughFollowing)) {
+ for (const foundNode of getConnectedNodesInfo(
+ startNode,
+ dir,
+ originNode,
+ undefined,
+ undefined,
+ originTravelFromSlot,
+ )) {
+ if (!rootNodes.map((n) => n.node).includes(foundNode.node)) {
+ rootNodes.push(foundNode);
+ }
+ }
+ }
+ }
+ }
+ }
+ return rootNodes;
+}
+
+export type ConnectionType = {
+ type: string | string[];
+ name: string | undefined;
+ label: string | undefined;
+};
+
+/**
+ * Follows a connection until we find a type associated with a slot.
+ * `skipSelf` skips the current slot, useful when we may have a dynamic slot that we want to start
+ * from, but find a type _after_ it (in case it needs to change).
+ */
+export function followConnectionUntilType(
+ node: TLGraphNode,
+ dir: IoDirection,
+ slotNum?: number,
+ skipSelf = false,
+): ConnectionType | null {
+ const slots = dir === IoDirection.OUTPUT ? node.outputs : node.inputs;
+ if (!slots || !slots.length) {
+ return null;
+ }
+ let type: ConnectionType | null = null;
+ if (slotNum) {
+ if (!slots[slotNum]) {
+ return null;
+ }
+ type = getTypeFromSlot(slots[slotNum], dir, skipSelf);
+ } else {
+ for (const slot of slots) {
+ type = getTypeFromSlot(slot, dir, skipSelf);
+ if (type) {
+ break;
+ }
+ }
+ }
+ return type;
+}
+
+/**
+ * Gets the type from a slot. If the type is '*' then it will follow the node to find the next slot.
+ */
+function getTypeFromSlot(
+ slot: INodeInputSlot | INodeOutputSlot | undefined,
+ dir: IoDirection,
+ skipSelf = false,
+): ConnectionType | null {
+ let graph = app.canvas.getCurrentGraph()!;
+ let type = slot?.type;
+ if (!skipSelf && type != null && type != "*") {
+ return {type: type as string, label: slot?.label, name: slot?.name};
+ }
+ const links = getSlotLinks(slot);
+ for (const link of links) {
+ const connectedId = dir == IoDirection.OUTPUT ? link.link.target_id : link.link.origin_id;
+ const connectedSlotNum =
+ dir == IoDirection.OUTPUT ? link.link.target_slot : link.link.origin_slot;
+ const connectedNode: TLGraphNode = graph.getNodeById(connectedId)!;
+ // Reversed since if we're traveling down the output we want the connected node's input, etc.
+ const connectedSlots =
+ dir === IoDirection.OUTPUT ? connectedNode.inputs : connectedNode.outputs;
+ let connectedSlot = connectedSlots[connectedSlotNum];
+ if (connectedSlot?.type != null && connectedSlot?.type != "*") {
+ return {
+ type: connectedSlot.type as string,
+ label: connectedSlot?.label,
+ name: connectedSlot?.name,
+ };
+ } else if (connectedSlot?.type == "*") {
+ return followConnectionUntilType(connectedNode, dir);
+ }
+ }
+ return null;
+}
+
+export async function replaceNode(
+ existingNode: TLGraphNode,
+ typeOrNewNode: string | TLGraphNode,
+ inputNameMap?: Map,
+) {
+ const existingCtor = existingNode.constructor as typeof TLGraphNode;
+
+ const newNode =
+ typeof typeOrNewNode === "string" ? LiteGraph.createNode(typeOrNewNode)! : typeOrNewNode;
+ // Port title (maybe) the position, size, and properties from the old node.
+ if (existingNode.title != existingCtor.title) {
+ newNode.title = existingNode.title;
+ }
+ newNode.pos = [...existingNode.pos] as Point;
+ newNode.properties = {...existingNode.properties};
+ const oldComputeSize = [...existingNode.computeSize()];
+ // oldSize to use. If we match the smallest size (computeSize) then don't record and we'll use
+ // the smalles side after conversion.
+ const oldSize = [
+ existingNode.size[0] === oldComputeSize[0] ? null : existingNode.size[0],
+ existingNode.size[1] === oldComputeSize[1] ? null : existingNode.size[1],
+ ];
+
+ let setSizeIters = 0;
+ const setSizeFn = () => {
+ // Size gets messed up when ComfyUI adds the text widget, so reset after a delay.
+ // Since we could be adding many more slots, let's take the larger of the two.
+ const newComputesize = newNode.computeSize();
+ newNode.size[0] = Math.max(oldSize[0] || 0, newComputesize[0]);
+ newNode.size[1] = Math.max(oldSize[1] || 0, newComputesize[1]);
+ setSizeIters++;
+ if (setSizeIters > 10) {
+ requestAnimationFrame(setSizeFn);
+ }
+ };
+ setSizeFn();
+
+ // We now collect the links data, inputs and outputs, of the old node since these will be
+ // lost when we remove it.
+ const links: {
+ node: TLGraphNode;
+ slot: number | string;
+ targetNode: TLGraphNode;
+ targetSlot: number | string;
+ }[] = [];
+ const graph = existingNode.graph || app.graph;
+ for (const [index, output] of existingNode.outputs.entries()) {
+ for (const linkId of output.links || []) {
+ const link: LLink = graph.links[linkId]!;
+ if (!link) continue;
+ const targetNode = graph.getNodeById(link.target_id)!;
+ links.push({node: newNode, slot: output.name, targetNode, targetSlot: link.target_slot});
+ }
+ }
+ for (const [index, input] of existingNode.inputs.entries()) {
+ const linkId = input.link;
+ if (linkId) {
+ const link: LLink = graph.links[linkId]!;
+ const originNode = graph.getNodeById(link.origin_id)!;
+ links.push({
+ node: originNode,
+ slot: link.origin_slot,
+ targetNode: newNode,
+ targetSlot: inputNameMap?.has(input.name)
+ ? inputNameMap.get(input.name)!
+ : input.name || index,
+ });
+ }
+ }
+ // Add the new node, remove the old node.
+ graph.add(newNode);
+ await wait();
+ // Now go through and connect the other nodes up as they were.
+ for (const link of links) {
+ link.node.connect(link.slot, link.targetNode, link.targetSlot);
+ }
+ await wait();
+ graph.remove(existingNode);
+ newNode.size = newNode.computeSize();
+ newNode.setDirtyCanvas(true, true);
+ return newNode;
+}
+
+export function getOriginNodeByLink(linkId?: number | null) {
+ let node: TLGraphNode | null = null;
+ if (linkId != null) {
+ const link = getLinkById(linkId);
+ node = (link != null && getNodeById(link.origin_id)) || null;
+ }
+ return node;
+}
+
+/**
+ * Gets a link by id across all graphs and subgraphs.
+ */
+export function getLinkById(linkId?: number | null) {
+ if (linkId == null) return null;
+ let link: LLink | null = app.graph.links[linkId] ?? null;
+ link = link ?? app.canvas.getCurrentGraph()?.links[linkId] ?? null;
+ return link || findSomethingInAllSubgraphs((subgraph) => subgraph?.links[linkId] ?? null);
+}
+
+/**
+ * Gets a node by id across all graphs and subgraphs.
+ */
+export function getNodeById(id: NodeId) {
+ if (id == null) return null;
+ let node = app.graph.getNodeById(id);
+ node = node ?? app.canvas.getCurrentGraph()?.getNodeById(id) ?? null;
+ return node || findSomethingInAllSubgraphs((subgraph) => subgraph?.getNodeById(id) ?? null);
+}
+
+/**
+ * Given a serialized workflow, get the mutable node data.
+ *
+ * Should be kept up to date with `py/utils_graph#get_worflow_node`
+ */
+export function getNodeByIdFromApiPrompt(apiPrompt: ComfyApiPrompt, id: string | number) {
+ const fullId = getFullNodeIdFromApiPrompt(apiPrompt, id);
+ const workflow = apiPrompt.workflow ?? {};
+ const nodeIds = String(fullId).split(":");
+ const workflowNodes = workflow?.["nodes"] ?? [];
+ const workflowSubgraphs = workflow?.["definitions"]?.["subgraphs"] ?? [];
+
+ let nodesList = workflowNodes;
+ let found: ISerialisedNode | null = null;
+
+ for (const nodeId of nodeIds) {
+ found = nodesList.find((n: ISerialisedNode) => String(n.id) === String(nodeId)) || null;
+ if (found?.["type"]) {
+ const subgraph =
+ workflowSubgraphs.find((n: ISerialisedNode) => n.id === found!["type"]) || null;
+ if (subgraph?.["nodes"]) {
+ nodesList = subgraph["nodes"];
+ }
+ }
+ }
+ return found;
+}
+
+/**
+ * Finds the correct "full id" of a node (that is, with subgraph prefixes like "5:12:5"). This is
+ * really only important when attempting to modify the prompt sent to the backend, like the seed
+ * node does to generate a new seed on the client.
+ *
+ * Because the prompt `output` is an object keyed by full id, we can look to see if it has either
+ * the id passed in (either a single id, or an already-full id), or ends with the id passed in.
+ *
+ * NOTE: This does NOT find subgraph nodes since they do not exist in `apiPrompt.output`.
+ * TODO: We could search for subgraph nodes in the `apiPrompt.workflow.definitions.subgraphs`.
+ */
+export function getFullNodeIdFromApiPrompt(apiPrompt: ComfyApiPrompt, id: string | number) {
+ const output = apiPrompt.output ?? {};
+ return output[id] ? id : Object.keys(output).find((i) => i.endsWith(`:${id}`));
+}
+
+/**
+ * [🤮] At some point ComfyUI added a second param for `openSubgraph` which is the node representing
+ * the subgraph in the UI that a user would double click (at least, from what I can tell).
+ * Unfortunately, we sometimes want to open a subgraph without knowing what that node is (like, from
+ * a bookmark).
+ *
+ * While that doesn't break right now, it does throw an error. As a best-attempt to fix their
+ * no longer allowing a subgraph to just open, we can search all nodes for subgraphs for the one
+ * that represents the subgraph itself.
+ */
+export function findFromNodeForSubgraph(subgraphId: string): SubgraphNode | null {
+ const node =
+ findSomethingInAllSubgraphs((subgraph) =>
+ subgraph.nodes
+ .filter((node) => node.isSubgraphNode())
+ .find((node) => node.subgraph.id === subgraphId),
+ ) ?? null;
+ return node;
+}
+
+/**
+ * Finds something across all the graphs and subgraphs.
+ */
+function findSomethingInAllSubgraphs(fn: (subgraph: Subgraph) => T | null): T | null {
+ const rootGraph = app.rootGraph ?? app.graph.rootGraph;
+ const subgraphs = [rootGraph, ...rootGraph.subgraphs?.values()] as Subgraph[];
+ for (const subgraph of subgraphs) {
+ const thing = fn(subgraph);
+ if (thing) return thing;
+ }
+ return null;
+}
+
+export function applyMixins(original: Constructor, constructors: any[]) {
+ constructors.forEach((baseCtor) => {
+ Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
+ Object.defineProperty(
+ original.prototype,
+ name,
+ Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null),
+ );
+ });
+ });
+}
+
+/**
+ * Retruns a list of `{id: number, link: LLlink}` for a given input or output.
+ *
+ * Obviously, for an input, this will be a max of one.
+ */
+export function getSlotLinks(inputOrOutput?: INodeInputSlot | INodeOutputSlot | null) {
+ const links: {id: number; link: LLink}[] = [];
+ if (!inputOrOutput) {
+ return links;
+ }
+ if ((inputOrOutput as INodeOutputSlot).links?.length) {
+ const output = inputOrOutput as INodeOutputSlot;
+ for (const linkId of output.links || []) {
+ const link: LLink = (app.graph as LGraph).links[linkId]!;
+ if (link) {
+ links.push({id: linkId, link: link});
+ }
+ }
+ }
+ if ((inputOrOutput as INodeInputSlot).link) {
+ const input = inputOrOutput as INodeInputSlot;
+ const link: LLink = (app.graph as LGraph).links[input.link!]!;
+ if (link) {
+ links.push({id: input.link!, link: link});
+ }
+ }
+ return links;
+}
+
+/**
+ * Given a node, whether we're dealing with INPUTS or OUTPUTS, and the server data, re-arrange then
+ * slots to match the order.
+ */
+export async function matchLocalSlotsToServer(
+ node: TLGraphNode,
+ direction: IoDirection,
+ serverNodeData: ComfyNodeDef,
+) {
+ const serverSlotNames =
+ direction == IoDirection.INPUT
+ ? Object.keys(serverNodeData.input?.optional || {})
+ : serverNodeData.output_name;
+ const serverSlotTypes =
+ direction == IoDirection.INPUT
+ ? (Object.values(serverNodeData.input?.optional || {}).map((i) => i[0]) as string[])
+ : serverNodeData.output;
+ const slots = direction == IoDirection.INPUT ? node.inputs : node.outputs;
+
+ // Let's go through the node data names and make sure our current ones match, and update if not.
+ let firstIndex = slots.findIndex((o, i) => i !== serverSlotNames.indexOf(o.name));
+ if (firstIndex > -1) {
+ // Have mismatches. First, let's go through and save all our links by name.
+ const links: {[key: string]: {id: number; link: LLink}[]} = {};
+ slots.map((slot) => {
+ // There's a chance we have duplicate names on an upgrade, so we'll collect all links to one
+ // name so we don't ovewrite our list per name.
+ links[slot.name] = links[slot.name] || [];
+ links[slot.name]?.push(...getSlotLinks(slot));
+ });
+
+ // Now, go through and rearrange outputs by splicing
+ for (const [index, serverSlotName] of serverSlotNames.entries()) {
+ const currentNodeSlot = slots.map((s) => s.name).indexOf(serverSlotName);
+ if (currentNodeSlot > -1) {
+ if (currentNodeSlot != index) {
+ const splicedItem = slots.splice(currentNodeSlot, 1)[0]!;
+ slots.splice(index, 0, splicedItem as any);
+ }
+ } else if (currentNodeSlot === -1) {
+ const splicedItem = {
+ name: serverSlotName,
+ type: serverSlotTypes![index],
+ links: [],
+ };
+ slots.splice(index, 0, splicedItem as any);
+ }
+ }
+
+ if (slots.length > serverSlotNames.length) {
+ for (let i = slots.length - 1; i > serverSlotNames.length - 1; i--) {
+ if (direction == IoDirection.INPUT) {
+ node.disconnectInput(i);
+ node.removeInput(i);
+ } else {
+ node.disconnectOutput(i);
+ node.removeOutput(i);
+ }
+ }
+ }
+
+ // Now, go through the link data again and make sure the origin_slot is the correct slot.
+ for (const [name, slotLinks] of Object.entries(links)) {
+ let currentNodeSlot = slots.map((s) => s.name).indexOf(name);
+ if (currentNodeSlot > -1) {
+ for (const linkData of slotLinks) {
+ if (direction == IoDirection.INPUT) {
+ linkData.link.target_slot = currentNodeSlot;
+ } else {
+ linkData.link.origin_slot = currentNodeSlot;
+ // If our next node is a Reroute, then let's get it to update the type.
+ const nextNode = app.graph.getNodeById(linkData.link.target_id);
+ // (Check nextNode, as sometimes graphs seem to have very stale data and that node id
+ // doesn't exist).
+ if (nextNode && (nextNode.constructor as any)?.type!.includes("Reroute")) {
+ (nextNode as any).stabilize && (nextNode as any).stabilize();
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+export function isValidConnection(ioA?: INodeSlot | null, ioB?: INodeSlot | null) {
+ if (!ioA || !ioB) {
+ return false;
+ }
+ const typeA = String(ioA.type);
+ const typeB = String(ioB.type);
+ // What does litegraph think, which includes looking at array values.
+ let isValid = LiteGraph.isValidConnection(typeA, typeB);
+
+ // This is here to fix the churn happening in list types in comfyui itself..
+ // https://github.com/comfyanonymous/ComfyUI/issues/1674
+ if (!isValid) {
+ let areCombos =
+ (typeA.includes(",") && typeB === "COMBO") || (typeA === "COMBO" && typeB.includes(","));
+ // We don't want to let any old combo connect to any old combo, so we'll look at the names too.
+ if (areCombos) {
+ // Some nodes use "_name" and some use "model" and "ckpt", so normalize
+ const nameA = ioA.name.toUpperCase().replace("_NAME", "").replace("CKPT", "MODEL");
+ const nameB = ioB.name.toUpperCase().replace("_NAME", "").replace("CKPT", "MODEL");
+ isValid = nameA.includes(nameB) || nameB.includes(nameA);
+ }
+ }
+ return isValid;
+}
+
+/**
+ * Patches the LiteGraph.isValidConnection so old nodes can connect to this new COMBO type for all
+ * lists (without users needing to go through and re-create all their nodes one by one).
+ */
+const oldIsValidConnection = LiteGraph.isValidConnection;
+LiteGraph.isValidConnection = function (typeA: ISlotType, typeB: ISlotType): boolean {
+ let isValid = oldIsValidConnection.call(LiteGraph, typeA, typeB);
+ if (!isValid) {
+ typeA = String(typeA);
+ typeB = String(typeB);
+ // This is waaaay too liberal and now any combos can connect to any combos. But we only have the
+ // types (not names like my util above), and connecting too liberally is better than old nodes
+ // with lists not being able to connect to this new COMBO type. And, anyway, it matches the
+ // current behavior today with new nodes anyway, where all lists are COMBO types.
+ // Refs: https://github.com/comfyanonymous/ComfyUI/issues/1674
+ // https://github.com/comfyanonymous/ComfyUI/pull/1675
+ let areCombos =
+ (typeA.includes(",") && typeB === "COMBO") || (typeA === "COMBO" && typeB.includes(","));
+ isValid = areCombos;
+ }
+ return isValid;
+};
+
+/**
+ * Returns a list of output nodes given a list of nodes.
+ */
+export function getOutputNodes(nodes: TLGraphNode[]) {
+ return (
+ nodes?.filter((n) => {
+ return (
+ n.mode != LiteGraph.NEVER && ((n.constructor as any).nodeData as ComfyNodeDef)?.output_node
+ );
+ }) || []
+ );
+}
+
+/**
+ * Changes the mode of a node. We must go through this to change a node's mode after the
+ * introduction of subgraphs, as ComfyUI doesn't update the mode of a node in a subgraph on its own.
+ */
+export function changeModeOfNodes(nodeOrNodes: TLGraphNode | TLGraphNode[], mode: LGraphEventMode) {
+ reduceNodesDepthFirst(nodeOrNodes, (n) => {
+ n.mode = mode;
+ });
+}
+
+/**
+ * Performs depth-first traversal of nodes and their subgraphs.
+ * Adapted from ComfyUI Frontend's method.
+ */
+export function reduceNodesDepthFirst(
+ nodeOrNodes: TLGraphNode | TLGraphNode[],
+ reduceFn: (node: TLGraphNode) => void,
+): void;
+export function reduceNodesDepthFirst(
+ nodeOrNodes: TLGraphNode | TLGraphNode[],
+ reduceFn: (node: TLGraphNode, reduceTo: T) => T | void,
+ reduceTo: T,
+): T;
+export function reduceNodesDepthFirst(
+ nodeOrNodes: TLGraphNode | TLGraphNode[],
+ reduceFn: (node: TLGraphNode, reduceTo: T) => T | void,
+ reduceTo?: T,
+): T {
+ const nodes = Array.isArray(nodeOrNodes) ? nodeOrNodes : [nodeOrNodes];
+ const stack: Array<{node: TLGraphNode}> = nodes.map((node) => ({node}));
+
+ // Process stack iteratively (DFS)
+ while (stack.length > 0) {
+ const {node} = stack.pop()!;
+ const result = reduceFn(node, reduceTo as T);
+ if (result !== undefined && result !== reduceTo) {
+ reduceTo = result;
+ }
+
+ // If it's a subgraph and we should expand, add children to stack
+ if (node.isSubgraphNode?.() && node.subgraph) {
+ // Process children in reverse order to maintain left-to-right DFS processing
+ // when popping from stack (LIFO). Iterate backwards to avoid array reversal.
+ const children = node.subgraph.nodes;
+ for (let i = children.length - 1; i >= 0; i--) {
+ stack.push({node: children[i]!});
+ }
+ }
+ }
+ return reduceTo as T;
+}
+
+/**
+ * Found an issue where group._nodes had nodes that weren't in the actual group. group._nodes is
+ * marked deprecated, so we'll go ahead and use _children and filter.
+ */
+export function getGroupNodes(group: LGraphGroup): TLGraphNode[] {
+ return Array.from(group._children).filter((c) => c instanceof LGraphNode);
+}
+
+/**
+ * Gets a node identifier alongside the graph identifier.
+ *
+ * Perhaps a bug, but it appears the same node id _could_ be in different subgraphs (or, at least,
+ * was definitively reported to be in the main graph, and a subgraph). So, if we're trying
+ * identifying a node alongside other nodes (like, a cache map), we need to keep the graph id along
+ * side it as well.
+ */
+export function getGraphDependantNodeKey(node: TLGraphNode): string {
+ const graph = node.graph ?? app.graph;
+ return `${graph.id}:${node.id}`;
+}
+
+/**
+ * Gets a full color string, including parsing from the LGraphCanvas data.
+ */
+export function getFullColor(
+ color?: string,
+ liteGraphKey: "groupcolor" | "color" | "bgcolor" = "color",
+) {
+ if (!color) {
+ return "";
+ }
+ if (LGraphCanvas.node_colors[color]) {
+ color = LGraphCanvas.node_colors[color]![liteGraphKey];
+ }
+ color = color.replace("#", "").toLocaleLowerCase();
+ if (color.length === 3) {
+ color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
+ }
+ return `#${color}`;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/utils_canvas.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_canvas.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4d1c227d76fc7c81b3edacb34f29fe43cdccbcf6
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_canvas.ts
@@ -0,0 +1,399 @@
+import type {LGraphCanvas as TLGraphCanvas, Vector2} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+
+function binarySearch(max: number, getValue: (n: number) => number, match: number) {
+ let min = 0;
+
+ while (min <= max) {
+ let guess = Math.floor((min + max) / 2);
+ const compareVal = getValue(guess);
+
+ if (compareVal === match) return guess;
+ if (compareVal < match) min = guess + 1;
+ else max = guess - 1;
+ }
+
+ return max;
+}
+
+/**
+ * Fits a string against a max width for a ctx. Font should be defined on ctx beforehand.
+ */
+export function fitString(ctx: CanvasRenderingContext2D, str: string, maxWidth: number) {
+ let width = ctx.measureText(str).width;
+ const ellipsis = "…";
+ const ellipsisWidth = measureText(ctx, ellipsis);
+ if (width <= maxWidth || width <= ellipsisWidth) {
+ return str;
+ }
+
+ const index = binarySearch(
+ str.length,
+ (guess) => measureText(ctx, str.substring(0, guess)),
+ maxWidth - ellipsisWidth,
+ );
+
+ return str.substring(0, index) + ellipsis;
+}
+
+/** Measures the width of text for a canvas context. */
+export function measureText(ctx: CanvasRenderingContext2D, str: string) {
+ return ctx.measureText(str).width;
+}
+
+export type WidgetRenderingOptionsPart = {
+ type?: "toggle" | "custom";
+ margin?: number;
+ fillStyle?: string;
+ strokeStyle?: string;
+ lowQuality?: boolean;
+ draw?(ctx: CanvasRenderingContext2D, x: number, lowQuality: boolean): number;
+};
+
+type WidgetRenderingOptions = {
+ size: [number, number];
+ pos: [number, number];
+ borderRadius?: number;
+ colorStroke?: string;
+ colorBackground?: string;
+};
+
+export function isLowQuality() {
+ const canvas = app.canvas as TLGraphCanvas;
+ return (canvas.ds?.scale || 1) <= 0.5;
+}
+
+export function drawNodeWidget(ctx: CanvasRenderingContext2D, options: WidgetRenderingOptions) {
+ const lowQuality = isLowQuality();
+
+ const data = {
+ width: options.size[0],
+ height: options.size[1],
+ posY: options.pos[1],
+ lowQuality,
+ margin: 15,
+ colorOutline: LiteGraph.WIDGET_OUTLINE_COLOR,
+ colorBackground: LiteGraph.WIDGET_BGCOLOR,
+ colorText: LiteGraph.WIDGET_TEXT_COLOR,
+ colorTextSecondary: LiteGraph.WIDGET_SECONDARY_TEXT_COLOR,
+ };
+
+ // Draw background.
+ ctx.strokeStyle = options.colorStroke || data.colorOutline;
+ ctx.fillStyle = options.colorBackground || data.colorBackground;
+ ctx.beginPath();
+ ctx.roundRect(
+ data.margin,
+ data.posY,
+ data.width - data.margin * 2,
+ data.height,
+ lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5],
+ );
+ ctx.fill();
+ if (!lowQuality) {
+ ctx.stroke();
+ }
+
+ return data;
+}
+
+/** Draws a rounded rectangle. */
+export function drawRoundedRectangle(
+ ctx: CanvasRenderingContext2D,
+ options: WidgetRenderingOptions,
+) {
+ const lowQuality = isLowQuality();
+ options = {...options};
+ ctx.save();
+ ctx.strokeStyle = options.colorStroke || LiteGraph.WIDGET_OUTLINE_COLOR;
+ ctx.fillStyle = options.colorBackground || LiteGraph.WIDGET_BGCOLOR;
+ ctx.beginPath();
+ ctx.roundRect(
+ ...options.pos,
+ ...options.size,
+ lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5],
+ );
+ ctx.fill();
+ !lowQuality && ctx.stroke();
+ ctx.restore();
+}
+
+type DrawNumberWidgetPartOptions = {
+ posX: number;
+ posY: number;
+ height: number;
+ value: number;
+ direction?: 1 | -1;
+ textColor?: string;
+};
+
+/**
+ * Draws a number picker with arrows off to each side.
+ *
+ * This is for internal widgets that may have many hit areas (full-width, default number widgets put
+ * the arrows on either side of the full-width row).
+ */
+export function drawNumberWidgetPart(
+ ctx: CanvasRenderingContext2D,
+ options: DrawNumberWidgetPartOptions,
+): [Vector2, Vector2, Vector2] {
+ const arrowWidth = 9;
+ const arrowHeight = 10;
+ const innerMargin = 3;
+ const numberWidth = 32;
+
+ const xBoundsArrowLess: Vector2 = [0, 0];
+ const xBoundsNumber: Vector2 = [0, 0];
+ const xBoundsArrowMore: Vector2 = [0, 0];
+
+ ctx.save();
+
+ let posX = options.posX;
+ const {posY, height, value, textColor} = options;
+ const midY = posY + height / 2;
+
+ // If we're drawing parts from right to left (usually when something in the middle will be
+ // flexible), then we can simply move left the expected width of our widget and draw forwards.
+ if (options.direction === -1) {
+ posX = posX - arrowWidth - innerMargin - numberWidth - innerMargin - arrowWidth;
+ }
+
+ // Draw the strength left arrow.
+ ctx.fill(
+ new Path2D(
+ `M ${posX} ${midY} l ${arrowWidth} ${
+ arrowHeight / 2
+ } l 0 -${arrowHeight} L ${posX} ${midY} z`,
+ ),
+ );
+
+ xBoundsArrowLess[0] = posX;
+ xBoundsArrowLess[1] = arrowWidth;
+ posX += arrowWidth + innerMargin;
+
+ // Draw the strength text.
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ const oldTextcolor = ctx.fillStyle;
+ if (textColor) {
+ ctx.fillStyle = textColor;
+ }
+ ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY);
+ ctx.fillStyle = oldTextcolor;
+
+ xBoundsNumber[0] = posX;
+ xBoundsNumber[1] = numberWidth;
+ posX += numberWidth + innerMargin;
+
+ // Draw the strength right arrow.
+ ctx.fill(
+ new Path2D(
+ `M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${
+ arrowHeight / 2
+ } v -${arrowHeight} z`,
+ ),
+ );
+
+ xBoundsArrowMore[0] = posX;
+ xBoundsArrowMore[1] = arrowWidth;
+
+ ctx.restore();
+
+ return [xBoundsArrowLess, xBoundsNumber, xBoundsArrowMore];
+}
+drawNumberWidgetPart.WIDTH_TOTAL = 9 + 3 + 32 + 3 + 9;
+
+type DrawTogglePartOptions = {
+ posX: number;
+ posY: number;
+ height: number;
+ value: boolean | null;
+};
+
+/**
+ * Draws a toggle for a widget. The toggle is a three-way switch with left being false, right being
+ * true, and a middle state being null.
+ */
+export function drawTogglePart(
+ ctx: CanvasRenderingContext2D,
+ options: DrawTogglePartOptions,
+): Vector2 {
+ const lowQuality = isLowQuality();
+ ctx.save();
+
+ const {posX, posY, height, value} = options;
+
+ const toggleRadius = height * 0.36; // This is the standard toggle height calc.
+ const toggleBgWidth = height * 1.5; // We don't draw a separate bg, but this would be it.
+
+ // Toggle Track
+ if (!lowQuality) {
+ ctx.beginPath();
+ ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
+ ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
+ ctx.fillStyle = "rgba(255,255,255,0.45)";
+ ctx.fill();
+ ctx.globalAlpha = app.canvas.editor_alpha;
+ }
+
+ // Toggle itself
+ ctx.fillStyle = value === true ? "#89B" : "#888";
+ const toggleX =
+ lowQuality || value === false
+ ? posX + height * 0.5
+ : value === true
+ ? posX + height
+ : posX + height * 0.75;
+ ctx.beginPath();
+ ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.restore();
+
+ return [posX, toggleBgWidth];
+}
+
+export function drawInfoIcon(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ size: number = 12,
+ treatment: 'FILLED' | 'OUTLINED' | 'GRAYED' = 'GRAYED'
+) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.roundRect(x, y, size, size, [size * 0.1]);
+ if (treatment === 'GRAYED') {
+ ctx.fillStyle = "#aaa";
+ ctx.strokeStyle = "#aaa";
+ } else {
+ ctx.fillStyle = "#2f82ec";
+ ctx.strokeStyle = "#2f82ec";
+ // ctx.strokeStyle = "#0f2a5e";
+ }
+ if (treatment === 'FILLED') {
+ ctx.fill();
+ } else {
+ ctx.stroke();
+ }
+ ctx.strokeStyle = "#FFF";
+ ctx.lineWidth = 2;
+ // ctx.lineCap = 'round';
+ const midX = x + size / 2;
+ const serifSize = size * 0.175;
+ ctx.stroke(
+ new Path2D(`
+ M ${midX} ${y + size * 0.15}
+ v 2
+ M ${midX - serifSize} ${y + size * 0.45}
+ h ${serifSize}
+ v ${size * 0.325}
+ h ${serifSize}
+ h -${serifSize * 2}
+ `),
+ );
+ ctx.restore();
+}
+
+export function drawPlusIcon(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ midY: number,
+ size: number = 12,
+) {
+ ctx.save();
+ const s = size / 3;
+ const plus = new Path2D(`
+ M ${x} ${midY + s / 2}
+ v-${s} h${s} v-${s} h${s}
+ v${s} h${s} v${s} h-${s}
+ v${s} h-${s} v-${s} h-${s}
+ z
+ `);
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ ctx.fillStyle = "#3a3";
+ ctx.strokeStyle = "#383";
+ ctx.fill(plus);
+ ctx.stroke(plus);
+
+ ctx.restore();
+}
+
+/**
+ * Draws a better button.
+ */
+export function drawWidgetButton(
+ ctx: CanvasRenderingContext2D,
+ options: WidgetRenderingOptions,
+ text: string | null = null,
+ isMouseDownedAndOver: boolean = false,
+) {
+ const borderRadius = isLowQuality() ? 0 : (options.borderRadius ?? 4);
+ ctx.save();
+
+ if (!isLowQuality() && !isMouseDownedAndOver) {
+ drawRoundedRectangle(ctx, {
+ size: [options.size[0] - 2, options.size[1]],
+ pos: [options.pos[0] + 1, options.pos[1] + 1],
+ borderRadius,
+ colorBackground: "#000000aa",
+ colorStroke: "#000000aa",
+ });
+ }
+
+ // BG
+ drawRoundedRectangle(ctx, {
+ size: options.size,
+ pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
+ borderRadius,
+ colorBackground: isMouseDownedAndOver ? "#444" : LiteGraph.WIDGET_BGCOLOR,
+ colorStroke: "transparent",
+ });
+
+ if (isLowQuality()) {
+ ctx.restore();
+ return;
+ }
+
+ if (!isMouseDownedAndOver) {
+ // Shadow
+ drawRoundedRectangle(ctx, {
+ size: [options.size[0] - 0.75, options.size[1] - 0.75],
+ pos: options.pos,
+ borderRadius: borderRadius - 0.5,
+ colorBackground: "transparent",
+ colorStroke: "#00000044",
+ });
+
+ // Highlight
+ drawRoundedRectangle(ctx, {
+ size: [options.size[0] - 0.75, options.size[1] - 0.75],
+ pos: [options.pos[0] + 0.75, options.pos[1] + 0.75],
+ borderRadius: borderRadius - 0.5,
+ colorBackground: "transparent",
+ colorStroke: "#ffffff11",
+ });
+ }
+
+ // Stroke
+ drawRoundedRectangle(ctx, {
+ size: options.size,
+ pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
+ borderRadius,
+ colorBackground: "transparent",
+ });
+
+ if (!isLowQuality() && text) {
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "center";
+ ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
+ ctx.fillText(
+ text,
+ options.size[0] / 2,
+ options.pos[1] + options.size[1] / 2 + (isMouseDownedAndOver ? 1 : 0),
+ );
+ }
+ ctx.restore();
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/utils_deprecated_comfyui.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_deprecated_comfyui.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2077e6e92f8e459ca4c85688e8daf186efdad3ab
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_deprecated_comfyui.ts
@@ -0,0 +1,294 @@
+/**
+ * [🤮] ComfyUI started deprecating the use of their legacy JavaScript files. These are ports/shims
+ * since we relied on them at one point.
+ *
+ * TODO: Should probably remove these all together at some point.
+ */
+
+import {app} from "scripts/app.js";
+
+import type {INodeInputSlot, INodeOutputSlot, InputSpec, LGraphNode} from "@comfyorg/frontend";
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts#L462 */
+interface PrimitiveNode extends LGraphNode {
+ recreateWidget(): void;
+ onLastDisconnect(): void;
+}
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/renderer/utils/nodeTypeGuards.ts */
+function isPrimitiveNode(node: LGraphNode): node is PrimitiveNode {
+ return node.type === "PrimitiveNode";
+}
+
+/**
+ * CONFIG and GET_CONFIG in https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/services/litegraphService.ts
+ * are not accessible publicly, so we need to look at a slot.widget's symbols and check to see if it
+ * matches rather than access it directly. Cool...
+ */
+function getWidgetGetConfigSymbols(slot: INodeOutputSlot | INodeInputSlot): {
+ CONFIG?: symbol;
+ GET_CONFIG?: symbol;
+} {
+ const widget = slot?.widget;
+ if (!widget) return {};
+ const syms = Object.getOwnPropertySymbols(widget || {});
+ for (const sym of syms) {
+ const symVal = widget![sym];
+ const isGetConfig = typeof symVal === "function";
+ let maybeCfg = isGetConfig ? symVal() : symVal;
+ if (
+ Array.isArray(maybeCfg) &&
+ maybeCfg.length >= 2 &&
+ typeof maybeCfg[0] === "string" &&
+ (maybeCfg[0] === "*" || typeof maybeCfg[1]?.type === "string")
+ ) {
+ return isGetConfig ? {GET_CONFIG: sym} : {CONFIG: sym};
+ }
+ }
+ return {};
+}
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts
+ */
+export function getWidgetConfig(slot: INodeOutputSlot | INodeInputSlot): InputSpec {
+ const configSyms = getWidgetGetConfigSymbols(slot);
+ const widget = slot.widget || ({} as any);
+ return (
+ (configSyms.CONFIG && widget[configSyms.CONFIG]) ??
+ (configSyms.GET_CONFIG && widget[configSyms.GET_CONFIG]?.()) ?? ["*", {}]
+ );
+}
+
+/**
+ * This is lossy, since we don't have access to GET_CONFIG Symbol, we cannot accurately set it. As a
+ * best-chance we can look for a function that seems to return a
+ *
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts
+ */
+export function setWidgetConfig(
+ slot: INodeOutputSlot | INodeInputSlot | undefined,
+ config: InputSpec,
+) {
+ if (!slot?.widget) return;
+ if (config) {
+ const configSyms = getWidgetGetConfigSymbols(slot);
+ const widget = slot.widget || ({} as any);
+ if (configSyms.GET_CONFIG) {
+ widget[configSyms.GET_CONFIG] = () => config;
+ } else if (configSyms.CONFIG) {
+ widget[configSyms.CONFIG] = config;
+ } else {
+ console.error(
+ "Cannot set widget Config. This is due to ComfyUI removing the ability to call legacy " +
+ "JavaScript APIs that are now deprecated without new, supported APIs. It's possible " +
+ "some things in rgthree-comfy do not work correctly. If you see this, please file a bug.",
+ );
+ }
+ } else {
+ delete slot.widget;
+ }
+
+ if ("link" in slot) {
+ const link = app.graph.links[(slot as INodeInputSlot)?.link ?? -1];
+ if (link) {
+ const originNode = app.graph.getNodeById(link.origin_id);
+ if (originNode && isPrimitiveNode(originNode)) {
+ if (config) {
+ originNode.recreateWidget();
+ } else if (!app.configuringGraph) {
+ originNode.disconnectOutput(0);
+ originNode.onLastDisconnect();
+ }
+ }
+ }
+ }
+}
+
+/**
+ * A slimmed-down version of `mergeIfValid` for only what was needed in rgthree-comfy.
+ *
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/extensions/core/widgetInputs.ts
+ */
+export function mergeIfValid(
+ output: INodeOutputSlot | INodeInputSlot,
+ config2: InputSpec,
+): InputSpec[1] | null {
+ const config1 = getWidgetConfig(output);
+ const customSpec = mergeInputSpec(config1, config2);
+ if (customSpec) {
+ setWidgetConfig(output, customSpec);
+ }
+ return customSpec?.[1] ?? null;
+}
+
+/**
+ * Merges two input specs.
+ *
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts
+ */
+const mergeInputSpec = (spec1: InputSpec, spec2: InputSpec): InputSpec | null => {
+ const type1 = getInputSpecType(spec1);
+ const type2 = getInputSpecType(spec2);
+
+ if (type1 !== type2) {
+ return null;
+ }
+
+ if (isIntInputSpec(spec1) || isFloatInputSpec(spec1)) {
+ return mergeNumericInputSpec(spec1, spec2 as typeof spec1);
+ }
+
+ if (isComboInputSpec(spec1)) {
+ return mergeComboInputSpec(spec1, spec2 as typeof spec1);
+ }
+
+ return mergeCommonInputSpec(spec1, spec2);
+};
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function getInputSpecType(inputSpec: InputSpec): string {
+ return isComboInputSpec(inputSpec) ? "COMBO" : inputSpec[0];
+}
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function isComboInputSpecV1(inputSpec: InputSpec) {
+ return Array.isArray(inputSpec[0]);
+}
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function isIntInputSpec(inputSpec: InputSpec) {
+ return inputSpec[0] === "INT";
+}
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function isFloatInputSpec(inputSpec: InputSpec) {
+ return inputSpec[0] === "FLOAT";
+}
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function isComboInputSpecV2(inputSpec: InputSpec) {
+ return inputSpec[0] === "COMBO";
+}
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function isComboInputSpec(inputSpec: InputSpec) {
+ return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec);
+}
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
+const getRange = (options: any) => {
+ const min = options.min ?? -Infinity;
+ const max = options.max ?? Infinity;
+ return {min, max};
+};
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
+const mergeNumericInputSpec = (spec1: any, spec2: any): T | null => {
+ const type = spec1[0];
+ const options1 = spec1[1] ?? {};
+ const options2 = spec2[1] ?? {};
+
+ const range1 = getRange(options1);
+ const range2 = getRange(options2);
+
+ // If the ranges do not overlap, return null
+ if (range1.min > range2.max || range1.max < range2.min) {
+ return null;
+ }
+
+ const step1 = options1.step ?? 1;
+ const step2 = options2.step ?? 1;
+
+ const mergedOptions = {
+ // Take intersection of ranges
+ min: Math.max(range1.min, range2.min),
+ max: Math.min(range1.max, range2.max),
+ step: lcm(step1, step2),
+ };
+
+ return mergeCommonInputSpec(
+ [type, {...options1, ...mergedOptions}] as T,
+ [type, {...options2, ...mergedOptions}] as T,
+ );
+};
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
+const mergeComboInputSpec = (spec1: any, spec2: any): T | null => {
+ const options1 = spec1[1] ?? {};
+ const options2 = spec2[1] ?? {};
+
+ const comboOptions1 = getComboSpecComboOptions(spec1);
+ const comboOptions2 = getComboSpecComboOptions(spec2);
+
+ const intersection = comboOptions1.filter((value) => comboOptions2.includes(value));
+
+ // If the intersection is empty, return null
+ if (intersection.length === 0) {
+ return null;
+ }
+
+ return mergeCommonInputSpec(
+ ["COMBO", {...options1, options: intersection}] as T,
+ ["COMBO", {...options2, options: intersection}] as T,
+ );
+};
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
+const mergeCommonInputSpec = (spec1: T, spec2: T): T | null => {
+ const type = getInputSpecType(spec1);
+ const options1 = spec1[1] ?? {};
+ const options2 = spec2[1] ?? {};
+
+ const compareKeys = [...new Set([...Object.keys(options1), ...Object.keys(options2)])].filter(
+ (key: string) => !IGNORE_KEYS.has(key),
+ );
+
+ const mergeIsValid = compareKeys.every((key: string) => {
+ const value1 = options1[key];
+ const value2 = options2[key];
+ return value1 === value2 || (value1 == null && value2 == null);
+ });
+
+ return mergeIsValid ? ([type, {...options1, ...options2}] as T) : null;
+};
+
+/** Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/nodeDefUtil.ts */
+const IGNORE_KEYS = new Set([
+ "default",
+ "forceInput",
+ "defaultInput",
+ "control_after_generate",
+ "multiline",
+ "tooltip",
+ "dynamicPrompts",
+]);
+
+/**
+ * Derived from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/schemas/nodeDefSchema.ts
+ */
+function getComboSpecComboOptions(inputSpec: any): (number | string)[] {
+ return (isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? [];
+}
+
+/** Taken from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/mathUtil.ts */
+const lcm = (a: number, b: number): number => {
+ return Math.abs(a * b) / gcd(a, b);
+};
+
+/** Taken from https://github.com/Comfy-Org/ComfyUI_frontend/blob/1f3fb90b1b79c4190b3faa7928b05a8ba3671307/src/utils/mathUtil.ts */
+const gcd = (a: number, b: number): number => {
+ return b === 0 ? a : gcd(b, a % b);
+};
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/utils_inputs_outputs.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_inputs_outputs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..69a215d6631fdfff01ec6c3bc31114b7868a73f1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_inputs_outputs.ts
@@ -0,0 +1,18 @@
+import type {LGraphNode} from "@comfyorg/frontend";
+import { RgthreeBaseNode } from "./base_node";
+
+/** Removes all inputs from the end. */
+export function removeUnusedInputsFromEnd(node: LGraphNode, minNumber = 1, nameMatch?: RegExp) {
+ // No need to remove inputs from nodes that have been removed. This can happen because we may
+ // have debounced cleanup tasks.
+ if ((node as RgthreeBaseNode).removed) return;
+ for (let i = node.inputs.length - 1; i >= minNumber; i--) {
+ if (!node.inputs[i]?.link) {
+ if (!nameMatch || nameMatch.test(node.inputs[i]!.name)) {
+ node.removeInput(i);
+ }
+ continue;
+ }
+ break;
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/utils_menu.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_menu.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fdf921a0f066716f3f55ed081bbcb4bd8ba2690d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_menu.ts
@@ -0,0 +1,98 @@
+import type {
+ LGraphCanvas as TLGraphCanvas,
+ LGraphNode,
+ ContextMenu,
+ IContextMenuValue,
+ IBaseWidget,
+ IContextMenuOptions,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {rgthreeApi} from "rgthree/common/rgthree_api.js";
+
+const PASS_THROUGH = function (item: T) {
+ return item as T;
+};
+
+/**
+ * Shows a lora chooser context menu.
+ */
+export async function showLoraChooser(
+ event: PointerEvent | MouseEvent,
+ callback: IContextMenuOptions["callback"],
+ parentMenu?: ContextMenu | null,
+ loras?: string[],
+) {
+ const canvas = app.canvas as TLGraphCanvas;
+ if (!loras) {
+ loras = ["None", ...(await rgthreeApi.getLoras().then((loras) => loras.map((l) => l.file)))];
+ }
+ new LiteGraph.ContextMenu(loras, {
+ event: event,
+ parentMenu: parentMenu != null ? parentMenu : undefined,
+ title: "Choose a lora",
+ scale: Math.max(1, canvas.ds?.scale ?? 1),
+ className: "dark",
+ callback,
+ });
+}
+
+/**
+ * Shows a context menu chooser of nodes.
+ *
+ * @param mapFn The function used to map each node to the context menu item. If null is returned
+ * it will be filtered out (rather than use a separate filter method).
+ */
+export function showNodesChooser(
+ event: PointerEvent | MouseEvent,
+ mapFn: (n: LGraphNode) => T | null,
+ callback: IContextMenuOptions["callback"],
+ parentMenu?: ContextMenu,
+) {
+ const canvas = app.canvas as TLGraphCanvas;
+ const nodesOptions: T[] = (app.graph._nodes as LGraphNode[])
+ .map(mapFn)
+ .filter((e): e is NonNullable => e != null);
+
+ nodesOptions.sort((a: any, b: any) => {
+ return a.value - b.value;
+ });
+
+ new LiteGraph.ContextMenu(nodesOptions, {
+ event: event,
+ parentMenu,
+ title: "Choose a node id",
+ scale: Math.max(1, canvas.ds?.scale ?? 1),
+ className: "dark",
+ callback,
+ });
+}
+
+/**
+ * Shows a conmtext menu chooser for a specific node.
+ *
+ * @param mapFn The function used to map each node to the context menu item. If null is returned
+ * it will be filtered out (rather than use a separate filter method).
+ */
+export function showWidgetsChooser(
+ event: PointerEvent | MouseEvent,
+ node: LGraphNode,
+ mapFn: (n: IBaseWidget) => T | null,
+ callback: IContextMenuOptions["callback"],
+ parentMenu?: ContextMenu,
+) {
+ const options: T[] = (node.widgets || [])
+ .map(mapFn)
+ .filter((e): e is NonNullable => e != null);
+ if (options.length) {
+ const canvas = app.canvas as TLGraphCanvas;
+ new LiteGraph.ContextMenu(options, {
+ event,
+ parentMenu,
+ title: "Choose an input/widget",
+ scale: Math.max(1, canvas.ds?.scale ?? 1),
+ className: "dark",
+ callback,
+ });
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/comfyui/utils_widgets.ts b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_widgets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b6c765b97cb0d1cb79380a5f4bf3faa1b18f67ac
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/comfyui/utils_widgets.ts
@@ -0,0 +1,506 @@
+import type {
+ LGraphNode,
+ LGraphCanvas as TLGraphCanvas,
+ Vector2,
+ ICustomWidget,
+ IWidgetOptions,
+ CanvasPointerEvent,
+} from "@comfyorg/frontend";
+
+import {app} from "scripts/app.js";
+import {drawNodeWidget, drawWidgetButton, fitString, isLowQuality} from "./utils_canvas.js";
+
+type Vector4 = [number, number, number, number];
+
+/**
+ * Draws a label on teft, and a value on the right, ellipsizing when out of space.
+ */
+export function drawLabelAndValue(
+ ctx: CanvasRenderingContext2D,
+ label: string,
+ value: string,
+ width: number,
+ posY: number,
+ height: number,
+ options?: {offsetLeft: number},
+) {
+ const outerMargin = 15;
+ const innerMargin = 10;
+ const midY = posY + height / 2;
+ ctx.save();
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+ ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
+ const labelX = outerMargin + innerMargin + (options?.offsetLeft ?? 0);
+ ctx.fillText(label, labelX, midY);
+
+ const valueXLeft = labelX + ctx.measureText(label).width + 7;
+ const valueXRight = width - (outerMargin + innerMargin);
+
+ ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
+ ctx.textAlign = "right";
+ ctx.fillText(fitString(ctx, value, valueXRight - valueXLeft), valueXRight, midY);
+ ctx.restore();
+}
+
+export type RgthreeBaseWidgetBounds = {
+ /** The bounds, either [x, width] assuming the full height, or [x, y, width, height] if height. */
+ bounds: Vector2 | Vector4;
+ onDown?(
+ event: CanvasPointerEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds: RgthreeBaseWidgetBounds,
+ ): boolean | void;
+ onUp?(
+ event: CanvasPointerEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds: RgthreeBaseWidgetBounds,
+ ): boolean | void;
+ onMove?(
+ event: CanvasPointerEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds: RgthreeBaseWidgetBounds,
+ ): boolean | void;
+ onClick?(
+ event: CanvasPointerEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ bounds: RgthreeBaseWidgetBounds,
+ ): boolean | void;
+ data?: any;
+ wasMouseClickedAndIsOver?: boolean;
+};
+
+export type RgthreeBaseHitAreas = {
+ [K in Keys]: RgthreeBaseWidgetBounds;
+};
+
+type NotArray = T extends Array ? never : T;
+
+/**
+ * A base widget that handles mouse events more properly.
+ */
+export abstract class RgthreeBaseWidget implements ICustomWidget {
+ // Needed here b/c it was added to ComfyUI's types for IBaseWidget. No idea what they use it for,
+ // or why it's only boolean.
+ [symbol: symbol]: boolean;
+
+ // We don't want our value to be an array as a widget will be serialized as an "input" for the API
+ // which uses an array value to represent a link. To keep things simpler, we'll avoid using an
+ // array at all.
+ abstract value: NotArray;
+
+ type: ICustomWidget["type"] = "custom";
+ name: string;
+ options: IWidgetOptions = {};
+ y: number = 0;
+ last_y: number = 0;
+ disabled: boolean = false;
+
+ protected mouseDowned: Vector2 | null = null;
+ protected isMouseDownedAndOver: boolean = false;
+
+ // protected hitAreas: {[key: string]: RgthreeBaseWidgetBounds} = {};
+ protected readonly hitAreas: RgthreeBaseHitAreas = {};
+ private downedHitAreasForMove: RgthreeBaseWidgetBounds[] = [];
+ private downedHitAreasForClick: RgthreeBaseWidgetBounds[] = [];
+
+ constructor(name: string) {
+ this.name = name;
+ }
+
+ serializeValue(node: LGraphNode, index: number): Promise | V {
+ return this.value;
+ }
+
+ private clickWasWithinBounds(pos: Vector2, bounds: Vector2 | Vector4) {
+ let xStart = bounds[0];
+ let xEnd = xStart + (bounds.length > 2 ? bounds[2]! : bounds[1]!);
+ const clickedX = pos[0] >= xStart && pos[0] <= xEnd;
+ if (bounds.length === 2) {
+ return clickedX;
+ }
+ return clickedX && pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]!;
+ }
+
+ mouse(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode) {
+ const canvas = app.canvas as TLGraphCanvas;
+
+ if (event.type == "pointerdown") {
+ this.mouseDowned = [...pos] as Vector2;
+ this.isMouseDownedAndOver = true;
+ this.downedHitAreasForMove.length = 0;
+ this.downedHitAreasForClick.length = 0;
+ // Loop over out bounds data and call any specifics.
+ let anyHandled = false;
+ for (const part of Object.values(this.hitAreas)) {
+ if (this.clickWasWithinBounds(pos, part.bounds)) {
+ if (part.onMove) {
+ this.downedHitAreasForMove.push(part);
+ }
+ if (part.onClick) {
+ this.downedHitAreasForClick.push(part);
+ }
+ if (part.onDown) {
+ const thisHandled = part.onDown.apply(this, [event, pos, node, part]);
+ anyHandled = anyHandled || thisHandled == true;
+ }
+ part.wasMouseClickedAndIsOver = true;
+ }
+ }
+ return this.onMouseDown(event, pos, node) ?? anyHandled;
+ }
+
+ // This only fires when LiteGraph has a node_widget (meaning it's pressed), but we may not be
+ // the original widget pressed, so we still need `mouseDowned`.
+ if (event.type == "pointerup") {
+ if (!this.mouseDowned) return true;
+ this.downedHitAreasForMove.length = 0;
+ const wasMouseDownedAndOver = this.isMouseDownedAndOver;
+ this.cancelMouseDown();
+ let anyHandled = false;
+ for (const part of Object.values(this.hitAreas)) {
+ if (part.onUp && this.clickWasWithinBounds(pos, part.bounds)) {
+ const thisHandled = part.onUp.apply(this, [event, pos, node, part]);
+ anyHandled = anyHandled || thisHandled == true;
+ }
+ part.wasMouseClickedAndIsOver = false;
+ }
+ for (const part of this.downedHitAreasForClick) {
+ if (this.clickWasWithinBounds(pos, part.bounds)) {
+ const thisHandled = part.onClick!.apply(this, [event, pos, node, part]);
+ anyHandled = anyHandled || thisHandled == true;
+ }
+ }
+ this.downedHitAreasForClick.length = 0;
+ if (wasMouseDownedAndOver) {
+ const thisHandled = this.onMouseClick(event, pos, node);
+ anyHandled = anyHandled || thisHandled == true;
+ }
+ return this.onMouseUp(event, pos, node) ?? anyHandled;
+ }
+
+ // This only fires when LiteGraph has a node_widget (meaning it's pressed).
+ if (event.type == "pointermove") {
+ this.isMouseDownedAndOver = !!this.mouseDowned;
+ // If we've moved off the button while pressing, then consider us no longer pressing.
+ if (
+ this.mouseDowned &&
+ (pos[0] < 15 ||
+ pos[0] > node.size[0] - 15 ||
+ pos[1] < this.last_y ||
+ pos[1] > this.last_y + LiteGraph.NODE_WIDGET_HEIGHT)
+ ) {
+ this.isMouseDownedAndOver = false;
+ }
+ for (const part of Object.values(this.hitAreas)) {
+ if (this.downedHitAreasForMove.includes(part)) {
+ part.onMove!.apply(this, [event, pos, node, part]);
+ }
+ if (this.downedHitAreasForClick.includes(part)) {
+ part.wasMouseClickedAndIsOver = this.clickWasWithinBounds(pos, part.bounds);
+ }
+ }
+ return this.onMouseMove(event, pos, node) ?? true;
+ }
+ return false;
+ }
+
+ /** Sometimes we want to cancel a mouse down, so that an up/move aren't fired. */
+ cancelMouseDown() {
+ this.mouseDowned = null;
+ this.isMouseDownedAndOver = false;
+ this.downedHitAreasForMove.length = 0;
+ }
+
+ /** An event that fires when the pointer is pressed down (once). */
+ onMouseDown(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
+ return;
+ }
+
+ /**
+ * An event that fires when the pointer is let go. Only fires if this was the widget that was
+ * originally pressed down.
+ */
+ onMouseUp(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
+ return;
+ }
+
+ /**
+ * An event that fires when the pointer is let go _over the widget_ and when the widget that was
+ * originally pressed down.
+ */
+ onMouseClick(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
+ return;
+ }
+
+ /**
+ * An event that fires when the pointer is moving after pressing down. Will fire both on and off
+ * of the widget. Check `isMouseDownedAndOver` to determine if the mouse is currently over the
+ * widget or not.
+ */
+ onMouseMove(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean | void {
+ return;
+ }
+}
+
+/**
+ * A better implementation of the LiteGraph button widget.
+ */
+export class RgthreeBetterButtonWidget extends RgthreeBaseWidget {
+ override readonly type = "custom";
+
+ value: string = "";
+ label: string = "";
+ mouseClickCallback: (event: CanvasPointerEvent, pos: Vector2, node: LGraphNode) => boolean | void;
+
+ constructor(
+ name: string,
+ mouseClickCallback: (
+ event: CanvasPointerEvent,
+ pos: Vector2,
+ node: LGraphNode,
+ ) => boolean | void,
+ label?: string,
+ ) {
+ super(name);
+ this.mouseClickCallback = mouseClickCallback;
+ this.label = label || name;
+ }
+
+ draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, y: number, height: number) {
+ drawWidgetButton(
+ ctx,
+ {size: [width - 30, height], pos: [15, y]},
+ this.label,
+ this.isMouseDownedAndOver,
+ );
+ }
+
+ override onMouseClick(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode) {
+ return this.mouseClickCallback(event, pos, node);
+ }
+}
+
+/**
+ * A better implementation of the LiteGraph text widget, including auto ellipsis.
+ */
+export class RgthreeBetterTextWidget extends RgthreeBaseWidget {
+ value: string;
+
+ constructor(name: string, value: string) {
+ super(name);
+ this.name = name;
+ this.value = value;
+ }
+
+ draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, y: number, height: number) {
+ const widgetData = drawNodeWidget(ctx, {size: [width, height], pos: [15, y]});
+
+ if (!widgetData.lowQuality) {
+ drawLabelAndValue(ctx, this.name, this.value, width, y, height);
+ }
+ }
+
+ override mouse(event: CanvasPointerEvent, pos: Vector2, node: LGraphNode): boolean {
+ const canvas = app.canvas as TLGraphCanvas;
+ if (event.type == "pointerdown") {
+ canvas.prompt("Label", this.value, (v: string) => (this.value = v), event);
+ return true;
+ }
+ return false;
+ }
+}
+
+/**
+ * Options for the Divider Widget.
+ */
+type RgthreeDividerWidgetOptions = {
+ marginTop: number;
+ marginBottom: number;
+ marginLeft: number;
+ marginRight: number;
+ color: string;
+ thickness: number;
+};
+
+/**
+ * A divider widget; can also be used as a spacer if fed a 0 thickness.
+ */
+export class RgthreeDividerWidget extends RgthreeBaseWidget<{}> {
+ override value = {};
+ override options = {serialize: false};
+ override readonly type = "custom";
+
+ private readonly widgetOptions: RgthreeDividerWidgetOptions = {
+ marginTop: 7,
+ marginBottom: 7,
+ marginLeft: 15,
+ marginRight: 15,
+ color: LiteGraph.WIDGET_OUTLINE_COLOR,
+ thickness: 1,
+ };
+
+ constructor(widgetOptions?: Partial) {
+ super("divider");
+ Object.assign(this.widgetOptions, widgetOptions || {});
+ }
+
+ draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, posY: number, h: number) {
+ if (this.widgetOptions.thickness) {
+ ctx.strokeStyle = this.widgetOptions.color;
+ const x = this.widgetOptions.marginLeft;
+ const y = posY + this.widgetOptions.marginTop;
+ const w = width - this.widgetOptions.marginLeft - this.widgetOptions.marginRight;
+ ctx.stroke(new Path2D(`M ${x} ${y} h ${w}`));
+ }
+ }
+
+ computeSize(width: number): [number, number] {
+ return [
+ width,
+ this.widgetOptions.marginTop + this.widgetOptions.marginBottom + this.widgetOptions.thickness,
+ ];
+ }
+}
+
+/**
+ * Options for the Label Widget.
+ */
+export type RgthreeLabelWidgetOptions = {
+ align?: "left" | "center" | "right";
+ color?: string;
+ italic?: boolean;
+ size?: number;
+ text?: string | (() => string); // Text, or fall back to the name.
+
+ /** A label to put on the right side. */
+ actionLabel?: "__PLUS_ICON__" | string;
+ actionCallback?: (event: PointerEvent | CanvasPointerEvent) => void;
+};
+
+/**
+ * A simple label widget, drawn with no background.
+ */
+export class RgthreeLabelWidget extends RgthreeBaseWidget {
+ override readonly type = "custom";
+ override options = {serialize: false};
+ value = "";
+
+ private readonly widgetOptions: RgthreeLabelWidgetOptions = {};
+ private posY: number = 0;
+
+ constructor(name: string, widgetOptions?: RgthreeLabelWidgetOptions) {
+ super(name);
+ Object.assign(this.widgetOptions, widgetOptions);
+ }
+
+ update(widgetOptions: RgthreeLabelWidgetOptions) {
+ Object.assign(this.widgetOptions, widgetOptions);
+ }
+
+ draw(
+ ctx: CanvasRenderingContext2D,
+ node: LGraphNode,
+ width: number,
+ posY: number,
+ height: number,
+ ) {
+ this.posY = posY;
+ ctx.save();
+
+ let text = this.widgetOptions.text ?? this.name;
+ if (typeof text === "function") {
+ text = text();
+ }
+
+ ctx.textAlign = this.widgetOptions.align || "left";
+ ctx.fillStyle = this.widgetOptions.color || LiteGraph.WIDGET_TEXT_COLOR;
+ const oldFont = ctx.font;
+ if (this.widgetOptions.italic) {
+ ctx.font = "italic " + ctx.font;
+ }
+ if (this.widgetOptions.size) {
+ ctx.font = ctx.font.replace(/\d+px/, `${this.widgetOptions.size}px`);
+ }
+
+ const midY = posY + height / 2;
+ ctx.textBaseline = "middle";
+
+ if (this.widgetOptions.align === "center") {
+ ctx.fillText(text, node.size[0] / 2, midY);
+ } else {
+ ctx.fillText(text, 15, midY);
+ } // TODO(right);
+
+ ctx.font = oldFont;
+
+ if (this.widgetOptions.actionLabel === "__PLUS_ICON__") {
+ const plus = new Path2D(
+ `M${node.size[0] - 15 - 2} ${posY + 7} v4 h-4 v4 h-4 v-4 h-4 v-4 h4 v-4 h4 v4 h4 z`,
+ );
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ ctx.fillStyle = "#3a3";
+ ctx.strokeStyle = "#383";
+ ctx.fill(plus);
+ ctx.stroke(plus);
+ }
+ ctx.restore();
+ }
+
+ override mouse(event: CanvasPointerEvent, nodePos: Vector2, node: LGraphNode): boolean {
+ if (
+ event.type !== "pointerdown" ||
+ isLowQuality() ||
+ !this.widgetOptions.actionLabel ||
+ !this.widgetOptions.actionCallback
+ ) {
+ return false;
+ }
+
+ const pos: Vector2 = [nodePos[0], nodePos[1] - this.posY];
+ const rightX = node.size[0] - 15;
+ if (pos[0] > rightX || pos[0] < rightX - 16) {
+ return false;
+ }
+ this.widgetOptions.actionCallback(event);
+ return true;
+ }
+}
+
+/** An invisible widget. */
+export class RgthreeInvisibleWidget extends RgthreeBaseWidget {
+ override readonly type = "custom";
+
+ value: NotArray;
+ private serializeValueFn?: (node: LGraphNode, index: number) => Promise | T;
+
+ constructor(
+ name: string,
+ type: string,
+ value: NotArray,
+ serializeValueFn?: (node: LGraphNode, index: number) => Promise | T,
+ ) {
+ super(name);
+ // this.type = type;
+ this.value = value;
+ this.serializeValueFn = serializeValueFn;
+ }
+
+ draw() {
+ return;
+ }
+ computeSize(width: number): Vector2 {
+ return [0, 0];
+ }
+
+ override serializeValue(node: LGraphNode, index: number): T | Promise {
+ return this.serializeValueFn != null
+ ? this.serializeValueFn(node, index)
+ : super.serializeValue(node, index);
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/comfyui_shim.ts b/custom_nodes/rgthree-comfy/src_web/common/comfyui_shim.ts
new file mode 100644
index 0000000000000000000000000000000000000000..45befcf386a0ac5c16237293ca7d8fc5bb633841
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/comfyui_shim.ts
@@ -0,0 +1,49 @@
+/**
+ * [🤮] At some point the new ComfyUI frontend stopped loading it's source as modules and started
+ * bundling them together. This removed the ability for to import the individual modules (like the
+ * api.js or pnginfo.js) in stand alone pages, like link_fixer.
+ *
+ * So, what do we have to do? Well, we have to fork, hardcode, or port what we would want to load
+ * from ComfyUI as our own, independant files again, which is unforunate for several reasons;
+ * duplicate code, risk of falling behind, etc...
+ *
+ * Anyway, this file is a shim that will either detect we're in the ComfyUI app and pass through the
+ * bundled module from the ComfyUI global or load from our own code when that's not available
+ * (because we're not in the actual ComfyUI UI).
+ */
+
+import type {getPngMetadata, getWebpMetadata} from "typings/comfy.js";
+
+const shimCache = new Map();
+
+async function shimComfyUiModule(moduleName: string, prop?: string) {
+ let module = shimCache.get(moduleName);
+ if (!module) {
+ if (window.comfyAPI?.[moduleName]) {
+ module = window.comfyAPI?.[moduleName];
+ } else {
+ module = await import(`./comfyui_shim_${moduleName}.js`);
+ }
+ if (!module) {
+ throw new Error(`Module ${moduleName} could not be loaded.`);
+ }
+ shimCache.set(moduleName, module);
+ }
+ if (prop) {
+ if (!module[prop]) {
+ throw new Error(`Property ${prop} on module ${moduleName} could not be loaded.`);
+ }
+ return module[prop];
+ }
+ return module;
+}
+
+export async function getPngMetadata(file: File | Blob) {
+ const fn = (await shimComfyUiModule("pnginfo", "getPngMetadata")) as getPngMetadata;
+ return fn(file);
+}
+
+export async function getWebpMetadata(file: File | Blob) {
+ const fn = (await shimComfyUiModule("pnginfo", "getWebpMetadata")) as getWebpMetadata;
+ return fn(file);
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/comfyui_shim_pnginfo.ts b/custom_nodes/rgthree-comfy/src_web/common/comfyui_shim_pnginfo.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e3d4639796c63f009be27d24f34e807579b36b70
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/comfyui_shim_pnginfo.ts
@@ -0,0 +1,541 @@
+
+/**
+ * [🤮] See `./comfyui_shim.ts`.
+ *
+ * This code has been forked from https://github.com/Comfy-Org/ComfyUI_frontend/blob/0937c1f2cd5026f390a6efa64f630e01ea414d1d/src/scripts/pnginfo.ts
+ * with some modifications made, such as removing unneeded exported functions, cleaning up trivial
+ * typing, etc.
+ */
+
+import { rgthreeApi } from "./rgthree_api.js";
+
+/**
+ * [🤮] A type to add to untypd portions of the code below where they were not yet typed in Comfy's
+ * code.
+ */
+type lazyComfyAny = any;
+
+
+// [🤮] A shim for ComfyAPI getEmbeddings.
+const api = {
+ async getEmbeddings(): Promise {
+ const resp = await rgthreeApi.fetchComfyApi('/embeddings', { cache: 'no-store' })
+ return await resp.json();
+ }
+}
+
+
+function getFromPngBuffer(buffer: ArrayBuffer) {
+ // Get the PNG data as a Uint8Array
+ const pngData = new Uint8Array(buffer)
+ const dataView = new DataView(pngData.buffer)
+
+ // Check that the PNG signature is present
+ if (dataView.getUint32(0) !== 0x89504e47) {
+ console.error('Not a valid PNG file')
+ return
+ }
+
+ // Start searching for chunks after the PNG signature
+ let offset = 8
+ let txt_chunks: Record = {}
+ // Loop through the chunks in the PNG file
+ while (offset < pngData.length) {
+ // Get the length of the chunk
+ const length = dataView.getUint32(offset)
+ // Get the chunk type
+ const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8))
+ if (type === 'tEXt' || type == 'comf' || type === 'iTXt') {
+ // Get the keyword
+ let keyword_end = offset + 8
+ while (pngData[keyword_end] !== 0) {
+ keyword_end++
+ }
+ const keyword = String.fromCharCode(
+ ...pngData.slice(offset + 8, keyword_end)
+ )
+ // Get the text
+ const contentArraySegment = pngData.slice(
+ keyword_end + 1,
+ offset + 8 + length
+ )
+ const contentJson = new TextDecoder('utf-8').decode(contentArraySegment)
+ txt_chunks[keyword] = contentJson
+ }
+
+ offset += 12 + length
+ }
+ return txt_chunks
+}
+
+function getFromPngFile(file: File) {
+ return new Promise>((r) => {
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ r(getFromPngBuffer((event.target as lazyComfyAny).result as ArrayBuffer) as lazyComfyAny)
+ }
+
+ reader.readAsArrayBuffer(file)
+ })
+}
+
+function parseExifData(exifData: lazyComfyAny) {
+ // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
+ const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II'
+
+ // Function to read 16-bit and 32-bit integers from binary data
+ function readInt(offset: lazyComfyAny, isLittleEndian: lazyComfyAny, length: lazyComfyAny) {
+ let arr = exifData.slice(offset, offset + length)
+ if (length === 2) {
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
+ 0,
+ isLittleEndian
+ )
+ } else if (length === 4) {
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
+ 0,
+ isLittleEndian
+ )
+ }
+ // lazyComfyAny
+ throw new Error('Shouldn\'t get here.');
+ }
+
+ // Read the offset to the first IFD (Image File Directory)
+ const ifdOffset = readInt(4, isLittleEndian, 4)
+
+ function parseIFD(offset: lazyComfyAny) {
+ const numEntries = readInt(offset, isLittleEndian, 2) as lazyComfyAny;
+ const result = {} as lazyComfyAny
+
+ for (let i = 0; i < numEntries; i++) {
+ const entryOffset = offset + 2 + i * 12
+ const tag = readInt(entryOffset, isLittleEndian, 2) as lazyComfyAny
+ const type = readInt(entryOffset + 2, isLittleEndian, 2)
+ const numValues = readInt(entryOffset + 4, isLittleEndian, 4)
+ const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4) as lazyComfyAny;
+
+ // Read the value(s) based on the data type
+ let value
+ if (type === 2) {
+ // ASCII string
+ value = new TextDecoder('utf-8').decode(
+ exifData.subarray(valueOffset, valueOffset + numValues - 1)
+ )
+ }
+
+ result[tag] = value
+ }
+
+ return result
+ }
+
+ // Parse the first IFD
+ const ifdData = parseIFD(ifdOffset)
+ return ifdData
+}
+
+function splitValues(input: lazyComfyAny) {
+ var output = {} as lazyComfyAny
+ for (var key in input) {
+ var value = input[key]
+ var splitValues = value.split(':', 2)
+ output[splitValues[0]] = splitValues[1]
+ }
+ return output
+}
+
+export function getPngMetadata(file: File): Promise> {
+ return getFromPngFile(file)
+}
+
+export function getWebpMetadata(file: lazyComfyAny) {
+ return new Promise>((r) => {
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ const webp = new Uint8Array((event.target as lazyComfyAny).result as ArrayBuffer)
+ const dataView = new DataView(webp.buffer)
+
+ // Check that the WEBP signature is present
+ if (
+ dataView.getUint32(0) !== 0x52494646 ||
+ dataView.getUint32(8) !== 0x57454250
+ ) {
+ console.error('Not a valid WEBP file')
+ r({})
+ return
+ }
+
+ // Start searching for chunks after the WEBP signature
+ let offset = 12
+ let txt_chunks = {} as lazyComfyAny
+ // Loop through the chunks in the WEBP file
+ while (offset < webp.length) {
+ const chunk_length = dataView.getUint32(offset + 4, true)
+ const chunk_type = String.fromCharCode(
+ ...webp.slice(offset, offset + 4)
+ )
+ if (chunk_type === 'EXIF') {
+ if (
+ String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
+ 'Exif\0\0'
+ ) {
+ offset += 6
+ }
+ let data = parseExifData(
+ webp.slice(offset + 8, offset + 8 + chunk_length)
+ )
+ for (var key in data) {
+ const value = data[key] as string
+ if (typeof value === 'string') {
+ const index = value.indexOf(':')
+ txt_chunks[value.slice(0, index)] = value.slice(index + 1)
+ }
+ }
+ break
+ }
+
+ offset += 8 + chunk_length
+ }
+
+ r(txt_chunks)
+ }
+
+ reader.readAsArrayBuffer(file)
+ })
+}
+
+export function getLatentMetadata(file: lazyComfyAny) {
+ return new Promise((r) => {
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ const safetensorsData = new Uint8Array((event.target as lazyComfyAny).result as ArrayBuffer)
+ const dataView = new DataView(safetensorsData.buffer)
+ let header_size = dataView.getUint32(0, true)
+ let offset = 8
+ let header = JSON.parse(
+ new TextDecoder().decode(
+ safetensorsData.slice(offset, offset + header_size)
+ )
+ )
+ r(header.__metadata__)
+ }
+
+ var slice = file.slice(0, 1024 * 1024 * 4)
+ reader.readAsArrayBuffer(slice)
+ })
+}
+
+
+export async function importA1111(graph: lazyComfyAny, parameters: lazyComfyAny) {
+ const p = parameters.lastIndexOf('\nSteps:')
+ if (p > -1) {
+ const embeddings = await api.getEmbeddings()
+ const opts = parameters
+ .substr(p)
+ .split('\n')[1]
+ .match(
+ new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', 'g')
+ )
+ .reduce((p: lazyComfyAny, n: lazyComfyAny) => {
+ const s = n.split(':')
+ if (s[1].endsWith(',')) {
+ s[1] = s[1].substr(0, s[1].length - 1)
+ }
+ p[s[0].trim().toLowerCase()] = s[1].trim()
+ return p
+ }, {})
+ const p2 = parameters.lastIndexOf('\nNegative prompt:', p)
+ if (p2 > -1) {
+ let positive = parameters.substr(0, p2).trim()
+ let negative = parameters.substring(p2 + 18, p).trim()
+
+ const ckptNode = LiteGraph.createNode('CheckpointLoaderSimple')!
+ const clipSkipNode = LiteGraph.createNode('CLIPSetLastLayer')!
+ const positiveNode = LiteGraph.createNode('CLIPTextEncode')!
+ const negativeNode = LiteGraph.createNode('CLIPTextEncode')!
+ const samplerNode = LiteGraph.createNode('KSampler')!
+ const imageNode = LiteGraph.createNode('EmptyLatentImage')!
+ const vaeNode = LiteGraph.createNode('VAEDecode')!
+ const vaeLoaderNode = LiteGraph.createNode('VAELoader')!
+ const saveNode = LiteGraph.createNode('SaveImage')!
+ let hrSamplerNode = null as lazyComfyAny
+ let hrSteps = null
+
+ const ceil64 = (v: lazyComfyAny) => Math.ceil(v / 64) * 64
+
+ const getWidget = (node: lazyComfyAny, name: lazyComfyAny) => {
+ return node.widgets.find((w: lazyComfyAny) => w.name === name)
+ }
+
+ const setWidgetValue = (node: lazyComfyAny, name: lazyComfyAny, value: lazyComfyAny, isOptionPrefix?: lazyComfyAny) => {
+ const w = getWidget(node, name)
+ if (isOptionPrefix) {
+ const o = w.options.values.find((w: lazyComfyAny) => w.startsWith(value))
+ if (o) {
+ w.value = o
+ } else {
+ console.warn(`Unknown value '${value}' for widget '${name}'`, node)
+ w.value = value
+ }
+ } else {
+ w.value = value
+ }
+ }
+
+ const createLoraNodes = (clipNode:lazyComfyAny, text: lazyComfyAny, prevClip: lazyComfyAny, prevModel: lazyComfyAny) => {
+ const loras = [] as lazyComfyAny
+ text = text.replace(/]+)>/g, function (m: lazyComfyAny, c: lazyComfyAny) {
+ const s = c.split(':')
+ const weight = parseFloat(s[1])
+ if (isNaN(weight)) {
+ console.warn('Invalid LORA', m)
+ } else {
+ loras.push({ name: s[0], weight })
+ }
+ return ''
+ })
+
+ for (const l of loras) {
+ const loraNode = LiteGraph.createNode('LoraLoader')
+ graph.add(loraNode)
+ setWidgetValue(loraNode, 'lora_name', l.name, true)
+ setWidgetValue(loraNode, 'strength_model', l.weight)
+ setWidgetValue(loraNode, 'strength_clip', l.weight)
+ prevModel.node.connect(prevModel.index, loraNode, 0)
+ prevClip.node.connect(prevClip.index, loraNode, 1)
+ prevModel = { node: loraNode, index: 0 }
+ prevClip = { node: loraNode, index: 1 }
+ }
+
+ prevClip.node.connect(1, clipNode, 0)
+ prevModel.node.connect(0, samplerNode, 0)
+ if (hrSamplerNode) {
+ prevModel.node.connect(0, hrSamplerNode, 0)
+ }
+
+ return { text, prevModel, prevClip }
+ }
+
+ const replaceEmbeddings = (text: lazyComfyAny) => {
+ if (!embeddings.length) return text
+ return text.replaceAll(
+ new RegExp(
+ '\\b(' +
+ embeddings
+ .map((e: lazyComfyAny) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('\\b|\\b') +
+ ')\\b',
+ 'ig'
+ ),
+ 'embedding:$1'
+ )
+ }
+
+ const popOpt = (name: lazyComfyAny) => {
+ const v = opts[name]
+ delete opts[name]
+ return v
+ }
+
+ graph.clear()
+ graph.add(ckptNode)
+ graph.add(clipSkipNode)
+ graph.add(positiveNode)
+ graph.add(negativeNode)
+ graph.add(samplerNode)
+ graph.add(imageNode)
+ graph.add(vaeNode)
+ graph.add(vaeLoaderNode)
+ graph.add(saveNode)
+
+ ckptNode.connect(1, clipSkipNode, 0)
+ clipSkipNode.connect(0, positiveNode, 0)
+ clipSkipNode.connect(0, negativeNode, 0)
+ ckptNode.connect(0, samplerNode, 0)
+ positiveNode.connect(0, samplerNode, 1)
+ negativeNode.connect(0, samplerNode, 2)
+ imageNode.connect(0, samplerNode, 3)
+ vaeNode.connect(0, saveNode, 0)
+ samplerNode.connect(0, vaeNode, 0)
+ vaeLoaderNode.connect(0, vaeNode, 1)
+
+ const handlers = {
+ model(v: lazyComfyAny) {
+ setWidgetValue(ckptNode, 'ckpt_name', v, true)
+ },
+ vae(v: lazyComfyAny) {
+ setWidgetValue(vaeLoaderNode, 'vae_name', v, true)
+ },
+ 'cfg scale'(v: lazyComfyAny) {
+ setWidgetValue(samplerNode, 'cfg', +v)
+ },
+ 'clip skip'(v: lazyComfyAny) {
+ setWidgetValue(clipSkipNode, 'stop_at_clip_layer', -v)
+ },
+ sampler(v: lazyComfyAny) {
+ let name = v.toLowerCase().replace('++', 'pp').replaceAll(' ', '_')
+ if (name.includes('karras')) {
+ name = name.replace('karras', '').replace(/_+$/, '')
+ setWidgetValue(samplerNode, 'scheduler', 'karras')
+ } else {
+ setWidgetValue(samplerNode, 'scheduler', 'normal')
+ }
+ const w = getWidget(samplerNode, 'sampler_name')
+ const o = w.options.values.find(
+ (w: lazyComfyAny) => w === name || w === 'sample_' + name
+ )
+ if (o) {
+ setWidgetValue(samplerNode, 'sampler_name', o)
+ }
+ },
+ size(v: lazyComfyAny) {
+ const wxh = v.split('x')
+ const w = ceil64(+wxh[0])
+ const h = ceil64(+wxh[1])
+ const hrUp = popOpt('hires upscale')
+ const hrSz = popOpt('hires resize')
+ hrSteps = popOpt('hires steps')
+ let hrMethod = popOpt('hires upscaler')
+
+ setWidgetValue(imageNode, 'width', w)
+ setWidgetValue(imageNode, 'height', h)
+
+ if (hrUp || hrSz) {
+ let uw, uh
+ if (hrUp) {
+ uw = w * hrUp
+ uh = h * hrUp
+ } else {
+ const s = hrSz.split('x')
+ uw = +s[0]
+ uh = +s[1]
+ }
+
+ let upscaleNode
+ let latentNode
+
+ if (hrMethod.startsWith('Latent')) {
+ latentNode = upscaleNode = LiteGraph.createNode('LatentUpscale')!
+ graph.add(upscaleNode)
+ samplerNode.connect(0, upscaleNode, 0)
+
+ switch (hrMethod) {
+ case 'Latent (nearest-exact)':
+ hrMethod = 'nearest-exact'
+ break
+ }
+ setWidgetValue(upscaleNode, 'upscale_method', hrMethod, true)
+ } else {
+ const decode = LiteGraph.createNode('VAEDecodeTiled')!
+ graph.add(decode)
+ samplerNode.connect(0, decode, 0)
+ vaeLoaderNode.connect(0, decode, 1)
+
+ const upscaleLoaderNode = LiteGraph.createNode('UpscaleModelLoader')!
+ graph.add(upscaleLoaderNode)
+ setWidgetValue(upscaleLoaderNode, 'model_name', hrMethod, true)
+
+ const modelUpscaleNode = LiteGraph.createNode(
+ 'ImageUpscaleWithModel'
+ )!
+ graph.add(modelUpscaleNode)
+ decode.connect(0, modelUpscaleNode, 1)
+ upscaleLoaderNode.connect(0, modelUpscaleNode, 0)
+
+ upscaleNode = LiteGraph.createNode('ImageScale')!
+ graph.add(upscaleNode)
+ modelUpscaleNode.connect(0, upscaleNode, 0)
+
+ const vaeEncodeNode = (latentNode =
+ LiteGraph.createNode('VAEEncodeTiled')!)!
+ graph.add(vaeEncodeNode)
+ upscaleNode.connect(0, vaeEncodeNode, 0)
+ vaeLoaderNode.connect(0, vaeEncodeNode, 1)
+ }
+
+ setWidgetValue(upscaleNode, 'width', ceil64(uw))
+ setWidgetValue(upscaleNode, 'height', ceil64(uh))
+
+ hrSamplerNode = LiteGraph.createNode('KSampler')!
+ graph.add(hrSamplerNode)
+ ckptNode.connect(0, hrSamplerNode, 0)
+ positiveNode.connect(0, hrSamplerNode, 1)
+ negativeNode.connect(0, hrSamplerNode, 2)
+ latentNode.connect(0, hrSamplerNode, 3)
+ hrSamplerNode.connect(0, vaeNode, 0)
+ }
+ },
+ steps(v: lazyComfyAny) {
+ setWidgetValue(samplerNode, 'steps', +v)
+ },
+ seed(v: lazyComfyAny) {
+ setWidgetValue(samplerNode, 'seed', +v)
+ }
+ }
+
+ for (const opt in opts) {
+ if (opt in handlers) {
+ ((handlers as lazyComfyAny)[opt] as lazyComfyAny)(popOpt(opt))
+ }
+ }
+
+ if (hrSamplerNode) {
+ setWidgetValue(
+ hrSamplerNode,
+ 'steps',
+ hrSteps ? +hrSteps : getWidget(samplerNode, 'steps').value
+ )
+ setWidgetValue(
+ hrSamplerNode,
+ 'cfg',
+ getWidget(samplerNode, 'cfg').value
+ )
+ setWidgetValue(
+ hrSamplerNode,
+ 'scheduler',
+ getWidget(samplerNode, 'scheduler').value
+ )
+ setWidgetValue(
+ hrSamplerNode,
+ 'sampler_name',
+ getWidget(samplerNode, 'sampler_name').value
+ )
+ setWidgetValue(
+ hrSamplerNode,
+ 'denoise',
+ +(popOpt('denoising strength') || '1')
+ )
+ }
+
+ let n = createLoraNodes(
+ positiveNode,
+ positive,
+ { node: clipSkipNode, index: 0 },
+ { node: ckptNode, index: 0 }
+ )
+ positive = n.text
+ n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel)
+ negative = n.text
+
+ setWidgetValue(positiveNode, 'text', replaceEmbeddings(positive))
+ setWidgetValue(negativeNode, 'text', replaceEmbeddings(negative))
+
+ graph.arrange()
+
+ for (const opt of [
+ 'model hash',
+ 'ensd',
+ 'version',
+ 'vae hash',
+ 'ti hashes',
+ 'lora hashes',
+ 'hashes'
+ ]) {
+ delete opts[opt]
+ }
+
+ console.warn('Unhandled parameters:', opts)
+ }
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/components/base_custom_element.ts b/custom_nodes/rgthree-comfy/src_web/common/components/base_custom_element.ts
new file mode 100644
index 0000000000000000000000000000000000000000..659bee5918fb72320ef69047f57789987180b6b2
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/components/base_custom_element.ts
@@ -0,0 +1,252 @@
+import {$el, getActionEls} from "rgthree/common/utils_dom.js";
+import {bind, register} from "../utils_templates";
+
+const CSS_STYLE_SHEETS = new Map();
+const CSS_STYLE_SHEETS_ADDED = new Map();
+const HTML_TEMPLATE_FILES = new Map();
+
+function getCommonPath(name: string, extension: string) {
+ return `rgthree/common/components/${name.replace("rgthree-", "").replace(/\-/g, "_")}.${extension}`;
+}
+
+/**
+ * Fetches the stylesheet for the component, matched by the element name (minus the "rgthree-"
+ * prefix).
+ */
+async function getStyleSheet(name: string, markupOrPath: string) {
+ if (markupOrPath.includes("{")) {
+ return markupOrPath;
+ }
+ if (!CSS_STYLE_SHEETS.has(name)) {
+ try {
+ const path = markupOrPath || getCommonPath(name, "css");
+ const text = await (await fetch(path)).text();
+ CSS_STYLE_SHEETS.set(name, text);
+ } catch (e) {
+ // alert("Error loading rgthree custom component css.");
+ }
+ }
+ return CSS_STYLE_SHEETS.get(name)!;
+}
+
+/**
+ * Adds the stylesheet to the page, once.
+ */
+async function addStyleSheet(name: string, markupOrPath: string) {
+ if (markupOrPath.includes("{")) {
+ throw new Error("Page-level stylesheets should be passed a path.");
+ }
+ if (!CSS_STYLE_SHEETS_ADDED.has(name)) {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = markupOrPath;
+ document.head.appendChild(link);
+ CSS_STYLE_SHEETS_ADDED.set(name, link);
+ }
+ return CSS_STYLE_SHEETS_ADDED.get(name)!;
+}
+
+/**
+ * Fetches the stylesheet for the component, matched by the element name (minus the "rgthree-"
+ * prefix).
+ */
+async function getTemplateMarkup(name: string, markupOrPath: string) {
+ if (markupOrPath.includes("(): T {
+ if (this.NAME === "rgthree-override") {
+ throw new Error("Must override component NAME");
+ }
+ if (!window.customElements.get(this.NAME)) {
+ window.customElements.define(this.NAME, this as unknown as CustomElementConstructor);
+ }
+ return document.createElement(this.NAME) as T;
+ }
+
+ protected ctor = this.constructor as typeof RgthreeCustomElement;
+ protected hasBeenConnected: boolean = false;
+ protected connected: boolean = false;
+ protected root!: ShadowRoot | HTMLElement;
+ protected readonly templates = new Map();
+ protected firstConnectedPromiseResolver!: Function;
+ protected firstConnectedPromise = new Promise(
+ (resolve) => (this.firstConnectedPromiseResolver = resolve),
+ );
+
+ onFirstConnected(): void {
+ // Optionally overridden.
+ }
+ onReconnected(): void {
+ // Optionally overridden.
+ }
+ onConnected(): void {
+ // Optionally overridden.
+ }
+ onDisconnected(): void {
+ // Optionally overridden.
+ }
+ onAction(action: string, e?: Event): void {
+ console.log("onAction", action, e);
+ // Optionally overridden.
+ }
+
+ getElement(query: string) {
+ const el = this.querySelector(query);
+ if (!el) {
+ throw new Error("No element found for query: " + query);
+ }
+ return el as E;
+ }
+
+ private onActionInternal(action: string, e?: Event): void {
+ if (typeof (this as any)[action] === "function") {
+ (this as any)[action](e);
+ } else {
+ this.onAction(action, e);
+ }
+ }
+
+ private onConnectedInternal(): void {
+ this.connectActionElements();
+ this.onConnected();
+ }
+
+ private onDisconnectedInternal(): void {
+ this.disconnectActionElements();
+ this.onDisconnected();
+ }
+
+ async connectedCallback() {
+ const elementName = this.ctor.NAME;
+ const wasConnected = this.connected;
+ if (!wasConnected) {
+ this.connected = true;
+ }
+ if (!this.hasBeenConnected) {
+ const [stylesheet, markup] = await Promise.all([
+ this.ctor.USE_SHADOW
+ ? getStyleSheet(elementName, this.ctor.CSS)
+ : addStyleSheet(elementName, this.ctor.CSS),
+ getTemplateMarkup(elementName, this.ctor.TEMPLATES),
+ ]);
+
+ if (markup) {
+ const temp = $el("div");
+ const templatesMarkup = markup.match(//gm) || [];
+ for (const markup of templatesMarkup) {
+ temp.innerHTML = markup;
+ const template = temp.children[0];
+ if (!(template instanceof HTMLTemplateElement)) {
+ throw new Error("Not a template element.");
+ }
+ let id = template.getAttribute("id");
+ if (!id) {
+ id = this.ctor.NAME;
+ // throw new Error("Not template id.");
+ }
+ this.templates.set(id, template);
+ }
+ }
+
+ // If we're using a shadow, then it's our root as a ShadowRoot. If we're not, then the root is
+ // the custom element itself. This allows easy binding on "this.root" but it also means if we
+ // want to set an atrtibute or otherwise access the actual custom element, we should use
+ // "this" to be compatible with both.
+ if (this.ctor.USE_SHADOW) {
+ this.root = this.attachShadow({mode: "open"});
+ if (typeof stylesheet === "string") {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(stylesheet);
+ this.root.adoptedStyleSheets = [sheet];
+ }
+ } else {
+ this.root = this;
+ }
+
+ let template: HTMLTemplateElement | undefined;
+ if (this.templates.has(elementName)) {
+ template = this.templates.get(elementName);
+ } else if (this.templates.has(elementName.replace("rgthree-", ""))) {
+ template = this.templates.get(elementName.replace("rgthree-", ""));
+ }
+ if (template) {
+ this.root.appendChild(template.content.cloneNode(true));
+ for (const name of template.getAttributeNames()) {
+ if (name != "id" && template.getAttribute(name)) {
+ this.setAttribute(name, template.getAttribute(name)!);
+ }
+ }
+ }
+
+ this.onFirstConnected();
+ this.hasBeenConnected = true;
+ this.firstConnectedPromiseResolver();
+ } else {
+ this.onReconnected();
+ }
+ this.onConnectedInternal();
+ }
+
+ disconnectedCallback() {
+ this.connected = false;
+ this.onDisconnected();
+ }
+
+ private readonly eventElements = new Map();
+
+ private connectActionElements() {
+ const data = getActionEls(this);
+ for (const dataItem of Object.values(data)) {
+ const mapItem = this.eventElements.get(dataItem.el) || {};
+ for (const [event, action] of Object.entries(dataItem.actions)) {
+ if (mapItem[event]) {
+ console.warn(`Element already has an event for ${event}`);
+ continue;
+ }
+ mapItem[event] = (e: Event) => {
+ this.onActionInternal(action, e);
+ };
+ dataItem.el.addEventListener(event as keyof ElementEventMap, mapItem[event]);
+ }
+ }
+ }
+
+ private disconnectActionElements() {
+ for (const [el, eventData] of this.eventElements.entries()) {
+ for (const [event, fn] of Object.entries(eventData)) {
+ el.removeEventListener(event, fn);
+ }
+ }
+ }
+
+ async bindWhenConnected(data: any, el?: HTMLElement | ShadowRoot) {
+ await this.firstConnectedPromise;
+ this.bind(data, el);
+ }
+
+ bind(data: any, el?: HTMLElement | ShadowRoot) {
+ bind(el || this.root, data);
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/css/buttons.scss b/custom_nodes/rgthree-comfy/src_web/common/css/buttons.scss
new file mode 100644
index 0000000000000000000000000000000000000000..569d380aeda6b6846ed3051cc348f029eeaa87b5
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/css/buttons.scss
@@ -0,0 +1,156 @@
+:not(#fakeid) .rgthree-button-reset {
+ position: relative;
+ appearance: none;
+ cursor: pointer;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ padding: 0;
+ margin: 0;
+}
+
+$borderRadius: 0.33rem;
+
+:not(#fakeid) .rgthree-button {
+ --padding-top: 7px;
+ --padding-bottom: 9px;
+ --padding-x: 16px;
+ position: relative;
+ cursor: pointer;
+ border: 0;
+ border-radius: $borderRadius;
+ background: rgba(0, 0, 0, 0.5);
+ color: white;
+ font-family: system-ui, sans-serif;
+ font-size: calc(16rem / 16);
+ line-height: 1;
+ white-space: nowrap;
+ text-decoration: none;
+ margin: 0.25rem;
+ box-shadow: 0px 0px 2px rgb(0, 0, 0);
+ background: #212121;
+ transition: all 0.1s ease-in-out;
+ padding: var(--padding-top) var(--padding-x) var(--padding-bottom);
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ &::before,
+ &::after {
+ content: "";
+ display: block;
+ position: absolute;
+ border-radius: $borderRadius;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow:
+ inset 1px 1px 0px rgba(255, 255, 255, 0.12),
+ inset -1px -1px 0px rgba(0, 0, 0, 0.75);
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15));
+ mix-blend-mode: screen;
+ }
+
+ &::after {
+ mix-blend-mode: multiply;
+ }
+
+ &:hover {
+ background: #303030;
+ }
+ &:active {
+ box-shadow: 0px 0px 0px rgba(0, 0, 0, 0);
+ background: #121212;
+ padding: calc(var(--padding-top) + 1px) calc(var(--padding-x) - 1px)
+ calc(var(--padding-bottom) - 1px) calc(var(--padding-x) + 1px);
+ }
+
+ &:active::before,
+ &:active::after {
+ box-shadow:
+ 1px 1px 0px rgba(255, 255, 255, 0.15),
+ inset 1px 1px 0px rgba(0, 0, 0, 0.5),
+ inset 1px 3px 5px rgba(0, 0, 0, 0.33);
+ }
+
+ &.-blue {
+ background: #346599 !important;
+ }
+ &.-blue:hover {
+ background: #3b77b8 !important;
+ }
+ &.-blue:active {
+ background: #1d5086 !important;
+ }
+
+ &.-green {
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15)), #14580b;
+ }
+ &.-green:hover {
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15)), #1a6d0f;
+ }
+ &.-green:active {
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.06)), #0f3f09;
+ }
+
+ &[disabled] {
+ box-shadow: none;
+ background: #666 !important;
+ color: #aaa;
+ pointer-events: none;
+ }
+ &[disabled]::before,
+ &[disabled]::after {
+ display: none;
+ }
+}
+
+:not(#fakeid) .rgthree-comfybar-top-button-group {
+ font-size: 0;
+ flex: 1 1 auto;
+ display: flex;
+ align-items: stretch;
+
+ .rgthree-comfybar-top-button {
+ margin: 0;
+ flex: 1 1;
+ height: 36px;
+ padding: 0 12px;
+ border-radius: 0;
+ background: var(--p-button-secondary-background);
+ color: var(--p-button-secondary-color);
+
+ &.-primary {
+ background: var(--p-button-primary-background);
+ color: var(--p-button-primary-color);
+ }
+
+ &::before,
+ &::after {
+ border-radius: 0;
+ }
+
+
+ svg {
+ fill: currentColor;
+ width: 28px;
+ height: 28px;
+ }
+ }
+
+ .rgthree-comfybar-top-button:first-of-type,
+ .rgthree-comfybar-top-button:first-of-type::before,
+ .rgthree-comfybar-top-button:first-of-type::after {
+ border-top-left-radius: 0.33rem;
+ border-bottom-left-radius: 0.33rem;
+
+ }
+ .rgthree-comfybar-top-button:last-of-type,
+ .rgthree-comfybar-top-button:last-of-type::before,
+ .rgthree-comfybar-top-button:last-of-type::after {
+ border-top-right-radius: 0.33rem;
+ border-bottom-right-radius: 0.33rem;
+ }
+}
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/common/css/dialog.scss b/custom_nodes/rgthree-comfy/src_web/common/css/dialog.scss
new file mode 100644
index 0000000000000000000000000000000000000000..c47e2f9d651102c5546d0d0a974f9639f91d3828
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/css/dialog.scss
@@ -0,0 +1,129 @@
+
+.rgthree-dialog {
+ outline: 0;
+ border: 0;
+ border-radius: 6px;
+ background: #414141;
+ color: #fff;
+ box-shadow:
+ inset 1px 1px 0px rgba(255, 255, 255, 0.05),
+ inset -1px -1px 0px rgba(0, 0, 0, 0.5),
+ 2px 2px 20px rgb(0, 0, 0);
+ max-width: 800px;
+ box-sizing: border-box;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+ font-size: 1rem;
+ padding: 0;
+ max-height: calc(100% - 32px);
+
+ *, *::before, *::after {
+ box-sizing: inherit;
+ }
+}
+
+.rgthree-dialog-container {
+ // padding: 16px;
+ > * {
+ padding: 8px 16px;
+
+ &:first-child {
+ padding-top: 16px;
+ }
+ &:last-child {
+ padding-bottom: 16px;
+ }
+ }
+}
+
+.rgthree-dialog.-iconed::after {
+ content: "";
+ font-size: 276px;
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ opacity: 0.15;
+ display: block;
+ width: 237px;
+ overflow: hidden;
+ height: 186px;
+ line-height: 1;
+ pointer-events: none;
+ z-index: -1;
+}
+.rgthree-dialog.-iconed.-help::after {
+ content: "🛟";
+}
+.rgthree-dialog.-iconed.-settings::after {
+ content: "⚙️";
+}
+
+@media (max-width: 832px) {
+ .rgthree-dialog {
+ max-width: calc(100% - 32px);
+ }
+}
+
+.rgthree-dialog-container-title {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: start;
+}
+.rgthree-dialog-container-title > svg:first-child {
+ width: 36px;
+ height: 36px;
+ margin-right: 16px;
+}
+.rgthree-dialog-container-title h2 {
+ font-size: calc(22rem / 16);
+ margin: 0;
+ font-weight: bold;
+}
+
+.rgthree-dialog-container-title h2 small {
+ font-size: calc(13rem / 16);
+ font-weight: normal;
+ opacity: 0.75;
+}
+
+.rgthree-dialog-container-content {
+ overflow: auto;
+ max-height: calc(100vh - 200px); /* Arbitrary height to copensate for margin, title, and footer.*/
+}
+.rgthree-dialog-container-content p {
+ font-size: calc(13rem / 16);
+ margin-top: 0;
+}
+
+.rgthree-dialog-container-content ul li p {
+ margin-bottom: 4px;
+}
+
+.rgthree-dialog-container-content ul li p + p {
+ margin-top: 0.5em;
+}
+
+.rgthree-dialog-container-content ul li ul {
+ margin-top: 0.5em;
+ margin-bottom: 1em;
+}
+
+.rgthree-dialog-container-content p code {
+ display: inline-block;
+ padding: 2px 4px;
+ margin: 0px 2px;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.rgthree-dialog-container-footer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+body.rgthree-dialog-open > *:not(.rgthree-dialog):not(.rgthree-top-messages-container) {
+ filter: blur(5px);
+}
+
diff --git a/custom_nodes/rgthree-comfy/src_web/common/css/dialog_lora_chooser.scss b/custom_nodes/rgthree-comfy/src_web/common/css/dialog_lora_chooser.scss
new file mode 100644
index 0000000000000000000000000000000000000000..373152d1d51baf93c90303978181eec378acc3f9
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/css/dialog_lora_chooser.scss
@@ -0,0 +1,161 @@
+
+.rgthree-lora-chooser-dialog {
+ max-width: 100%;
+
+
+ .rgthree-dialog-container-title {
+ display: flex;
+ flex-direction: column;
+ }
+ .rgthree-dialog-container-title h2 {
+ display: flex;
+ width: 100%;
+ }
+ .rgthree-lora-chooser-search {
+ margin-left: auto;
+ border-radius: 50px;
+ width: 50%;
+ max-width: 170px;
+ padding: 2px 8px;
+ }
+
+ .rgthree-lora-chooser-header {
+ display: flex;
+ flex-direction: row;
+ }
+
+ .rgthree-lora-filters-container {
+ svg {width: 16px; height: 16px;}
+ }
+
+ .rgthree-dialog-container-content {
+ width: 80vw;
+ height: 80vh;
+ }
+
+ .rgthree-button-reset {
+ width: 32px;
+ height: 32px;
+ > svg {width: 100%; height: 100%;}
+
+ }
+
+ ul.rgthree-lora-chooser-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: start;
+ justify-content: space-around;
+
+ > li {
+ position: relative;
+ flex: 0 0 auto;
+ width: 170px;
+ max-width: 100%;
+ margin: 8px 8px 16px;
+
+ label {
+ position: absolute;
+ display: block;
+ inset: 0;
+ z-index: 3;
+ cursor: pointer;
+ }
+ input[type="checkbox"] {
+ position: absolute;
+ right: 8px;
+ top: 8px;
+ margin: 0;
+ z-index: 2;
+ appearance: none;
+ background-color: #fff;
+ width: 48px;
+ height: 48px;
+ border-radius: 4px;
+ border: 1px solid rgba(120,120,120,1);
+ opacity: 0;
+ transition: opacity 0.15s ease-in-out;
+
+ &:checked {
+ opacity: 1;
+ background: #0060df;
+ &::before {
+ content: "";
+ display: block;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 100px 100px #fff;
+ clip-path: polygon(40.13% 68.39%, 23.05% 51.31%, 17.83% 48.26%, 12.61% 49.57%, 9.57% 53.04%, 8% 60%, 34.13% 85.87%, 39.82% 89.57%, 45.88% 86.73%, 90.66% 32.39%, 88.92% 26.1%, 83.03% 22.17%, 76.94% 22.62%)
+ }
+ }
+ }
+
+
+ figure {
+ position: relative;
+ display: block;
+ margin: 0 0 8px;
+ padding: 0;
+ border: 1px solid rgba(120, 120, 120, .8);
+ background: rgba(120, 120, 120, .5);
+ width: 100%;
+ padding-top: 120%;
+ transition: box-shadow 0.15s ease-in-out;
+ opacity: 0.75;
+ &::after {
+ content: '';
+ display: block;
+ position: absolute;
+ inset: 0;
+ }
+
+ &:empty {
+ &::before {
+ content: 'No image.';
+ color: rgba(200, 200, 200, .8);
+ position: absolute;
+ display: block;
+ inset: 0;
+ font-size: 1.2em;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ > img, > video {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ object-fit: cover;
+ }
+ }
+ div {
+ word-wrap: break-word;
+ font-size: 0.8rem;
+ opacity: 0.75;
+ }
+
+ &:hover figure::after{
+ box-shadow: 0px 2px 6px rgba(0,0,0,0.75);
+ }
+ :checked ~ figure::after {
+ box-shadow: 0 0 5px #fff, 0px 0px 15px rgba(49, 131, 255, 0.88), inset 0 0 3px #fff, inset 0px 0px 5px rgba(49, 131, 255, 0.88)
+ }
+
+ &:hover *,
+ &:hover input[type="checkbox"],
+ :checked ~ * {
+ opacity: 1
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/common/css/dialog_model_info.scss b/custom_nodes/rgthree-comfy/src_web/common/css/dialog_model_info.scss
new file mode 100644
index 0000000000000000000000000000000000000000..247cdc08ad504bf0f7bffa5c10c7e16d42b277e6
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/css/dialog_model_info.scss
@@ -0,0 +1,400 @@
+
+.rgthree-info-dialog {
+
+ width: 90vw;
+ max-width: 960px;
+
+ .rgthree-info-area {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+
+ > li {
+ display: inline-flex;
+ margin: 0;
+ vertical-align: top;
+
+ + li {
+ margin-left: 6px;
+ }
+ &:not(.-link) + li.-link {
+ margin-left: auto;
+ }
+
+ &.rgthree-info-tag > * {
+ min-height: 24px;
+ border-radius: 4px;
+ line-height: 1;
+ color: rgba(255,255,255,0.85);
+ background: rgb(69, 92, 85);;
+ font-size: 14px;
+ font-weight: bold;
+ text-decoration: none;
+ display: flex;
+ height: 1.6em;
+ padding-left: .5em;
+ padding-right: .5em;
+ padding-bottom: .1em;
+ align-content: center;
+ justify-content: center;
+ align-items: center;
+ box-shadow: inset 0px 0px 0 1px rgba(0, 0, 0, 0.5);
+
+ > svg {
+ width: 16px;
+ height: 16px;
+
+ &:last-child {
+ margin-left: .5em;
+ }
+ }
+
+
+ &[href] {
+ box-shadow: inset 0px 1px 0px rgba(255,255,255,0.25), inset 0px -1px 0px rgba(0,0,0,0.66);
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ // &.-civitai > * {
+ // color: #ddd;
+ // background: #1b65aa;
+ // transition: all 0.15s ease-in-out;
+ // &:hover {
+ // color: #fff;
+ // border-color: #1971c2;
+ // background: #1971c2;
+ // }
+ // }
+ &.-type > * {
+ background: rgb(73, 54, 94);
+ color: rgb(228, 209, 248);
+ }
+
+ &.rgthree-info-menu {
+ margin-left: auto;
+
+ :not(#fakeid) & .rgthree-button {
+ margin: 0;
+ min-height: 24px;
+ padding: 0 12px;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
+ }
+ }
+
+ .rgthree-info-table {
+ border-collapse: collapse;
+ margin: 16px 0px;
+ width: 100%;
+ font-size: 12px;
+
+ tr.editable button {
+ display: flex;
+ width: 28px;
+ height: 28px;
+ align-items: center;
+ justify-content: center;
+
+ svg + svg {display: none;}
+ }
+ tr.editable.-rgthree-editing button {
+ svg {display: none;}
+ svg + svg {display: inline-block;}
+ }
+
+ td {
+ position: relative;
+ border: 1px solid rgba(255,255,255,0.25);
+ padding: 0;
+ vertical-align: top;
+
+ &:first-child {
+ background: rgba(255,255,255,0.075);
+ width: 10px; // Small, so it doesn't adjust.
+ > *:first-child {
+ white-space: nowrap;
+ padding-right: 32px;
+ }
+
+ small {
+ display: block;
+ margin-top: 2px;
+ opacity: 0.75;
+
+ > [data-action] {
+ text-decoration: underline;
+ cursor: pointer;
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+
+ a, a:hover, a:visited {
+ color: inherit;
+ }
+
+ svg {
+ width: 1.3333em;
+ height: 1.3333em;
+ vertical-align: -0.285em;
+
+ &.logo-civitai {
+ margin-right: 0.3333em;
+ }
+ }
+
+ > *:first-child {
+ display: block;
+ padding: 6px 10px;
+ }
+
+ > input, > textarea{
+ padding: 5px 10px;
+ border: 0;
+ box-shadow: inset 1px 1px 5px 0px rgba(0,0,0,0.5);
+ font: inherit;
+ appearance: none;
+ background: #fff;
+ color: #121212;
+ resize: vertical;
+
+ &:only-child {
+ width: 100%;
+ }
+ }
+
+ :not(#fakeid) & .rgthree-button[data-action="fetch-civitai"] {
+ font-size: inherit;
+ padding: 6px 16px;
+ margin: 2px;
+ }
+ }
+
+ tr[data-field-name="userNote"] td > span:first-child {
+ white-space: pre;
+ }
+
+ tr.rgthree-info-table-break-row td {
+ border: 0;
+ background: transparent;
+ padding: 12px 4px 4px;
+ font-size: 1.2em;
+
+ > small {
+ font-style: italic;
+ opacity: 0.66;
+ }
+
+ &:empty {
+ padding: 4px;
+ }
+ }
+
+ td .-help {
+ border: 1px solid currentColor;
+ position: absolute;
+ right: 5px;
+ top: 6px;
+ line-height: 1;
+ font-size: 11px;
+ width: 12px;
+ height: 12px;
+ border-radius: 8px;
+ display: flex;
+ align-content: center;
+ justify-content: center;
+ cursor: help;
+ &::before {
+ content: '?';
+ }
+
+ }
+
+ td > ul.rgthree-info-trained-words-list {
+ list-style: none;
+ padding: 2px 8px;
+ margin: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ max-height: 15vh;
+ overflow: auto;
+
+ > li {
+ display: inline-flex;
+ margin: 2px;
+ vertical-align: top;
+ border-radius: 4px;
+ line-height: 1;
+ color: rgba(255,255,255,0.85);
+ background: rgb(73, 91, 106);
+ font-size: 1.2em;
+ font-weight: 600;
+ text-decoration: none;
+ display: flex;
+ height: 1.6em;
+ align-content: center;
+ justify-content: center;
+ align-items: center;
+ box-shadow: inset 0px 0px 0 1px rgba(0, 0, 0, 0.5);
+ cursor: pointer;
+ white-space: nowrap;
+ max-width: 183px;
+
+ &:hover {
+ background: rgb(68, 109, 142);
+ }
+
+ > svg {
+ width: auto;
+ height: 1.2em;
+ }
+
+ > span {
+ padding-left: .5em;
+ padding-right: .5em;
+ padding-bottom: .1em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > small {
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0.5em;
+ background: rgba(0,0,0,0.2);
+ }
+
+ &.-rgthree-is-selected {
+ background: rgb(42, 126, 193);
+ }
+ }
+ }
+ }
+
+ .rgthree-info-images {
+ list-style:none;
+ padding:0;
+ margin:0;
+ scroll-snap-type: x mandatory;
+ display:flex;
+ flex-direction:row;
+ overflow: auto;
+
+ > li {
+ scroll-snap-align: start;
+ max-width: 90%;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 0;
+ margin: 6px;
+ font-size: 0;
+ position: relative;
+
+ figure {
+ margin: 0;
+ position: static;
+
+ video, img {
+ max-height: 45vh;
+ }
+
+ figcaption {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ bottom: 0;
+ padding: 12px;
+ font-size: 12px;
+ background: rgba(0,0,0,0.85);
+ opacity: 0;
+ transform: translateY(50px);
+ transition: all 0.25s ease-in-out;
+
+ > span {
+ display: inline-block;
+ padding: 2px 4px;
+ margin: 2px;
+ border-radius: 2px;
+ border: 1px solid rgba(255,255,255,0.2);
+ word-break: break-word;
+
+ label {
+ display: inline;
+ padding: 0;
+ margin: 0;
+ opacity: 0.5;
+ pointer-events: none;
+ user-select: none;
+ }
+ a {
+ color: inherit;
+ text-decoration: underline;
+ &:hover {
+ text-decoration: none;
+ }
+
+ svg {
+ height: 10px;
+ margin-left: 4px;
+ fill: currentColor;
+ }
+ }
+ }
+ &:empty {
+ text-align: center;
+
+ &::before {
+ content: 'No data.';
+ }
+ }
+ }
+ }
+
+ &:hover figure figcaption {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+
+ .rgthree-info-table {
+ width: calc(100% - 16px);
+ }
+ }
+ }
+
+ .rgthree-info-civitai-link {
+ margin: 8px;
+ color: #eee;
+
+ a, a:hover, a:visited {
+ color: inherit;
+ text-decoration: none;
+ }
+
+ > svg {
+ width: 16px;
+ height: 16px;
+ margin-right: 8px;
+ }
+ }
+}
+
+
diff --git a/custom_nodes/rgthree-comfy/src_web/common/css/menu.scss b/custom_nodes/rgthree-comfy/src_web/common/css/menu.scss
new file mode 100644
index 0000000000000000000000000000000000000000..31b661a1164964cecff95148f26cef67733a7137
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/css/menu.scss
@@ -0,0 +1,122 @@
+
+
+.rgthree-menu {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ position: fixed;
+ z-index: 999999;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.08s ease-in-out;
+
+ color: #dde;
+ background-color: #111;
+ font-size: 12px;
+ box-shadow: 0 0 10px black !important;
+
+ > li {
+ position: relative;
+ padding: 4px 6px;
+ z-index: 9999;
+ white-space: nowrap;
+
+ &[role="button"] {
+ background-color: var(--comfy-menu-bg) !important;
+ color: var(--input-text);
+ cursor: pointer;
+ &:hover {
+ filter: brightness(155%);
+ }
+ }
+ }
+
+ &[state^="measuring"] {
+ display: block;
+ opacity: 0;
+ }
+ &[state="open"] {
+ display: block;
+ opacity: 1;
+ pointer-events: all;
+ }
+}
+
+
+.rgthree-top-menu {
+ box-sizing: border-box;
+ white-space: nowrap;
+ background: var(--content-bg);
+ color: var(--content-fg);
+ display: flex;
+ flex-direction: column;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ * {
+ box-sizing: inherit;
+ }
+
+
+ > li:not(#fakeid) {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ position: relative;
+ z-index: 2;
+
+ > button {
+ cursor: pointer;
+ padding: 8px 12px 8px 8px;
+ width: 100%;
+ text-align: start;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: start;
+
+ &:hover {
+ background-color: var(--comfy-input-bg);
+ }
+
+ svg {
+ height: 16px;
+ width: auto;
+ margin-inline-end: 0.6em;
+
+ &.github-star {
+ fill: rgb(227, 179, 65);
+ }
+ }
+ }
+
+ &.rgthree-message {
+ // ComfyUI's code has strange behavior that that always puts the popupat to if its less than
+ // 30px... we'll force our message to be at least 32px tall so it won't do that unless it's
+ // actually on the bottom.
+ min-height: 32px;
+ > span {
+ padding: 8px 12px;
+ display: block;
+ width: 100%;
+ text-align: center;
+ font-style: italic;
+ font-size: 12px;
+ }
+ }
+ }
+
+ &.-modal::after {
+ content: '';
+ display: block;
+ position: fixed;
+ z-index: 1;
+ inset: 0;
+ background: #0001;
+ }
+}
+
+body.rgthree-modal-menu-open > *:not(.rgthree-menu):not(.rgthree-top-messages-container) {
+ filter: blur(2px);
+}
+
diff --git a/custom_nodes/rgthree-comfy/src_web/common/css/pages_base.scss b/custom_nodes/rgthree-comfy/src_web/common/css/pages_base.scss
new file mode 100644
index 0000000000000000000000000000000000000000..09f292edce41bd58342c8d18cc3f9efc8b8e88d8
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/css/pages_base.scss
@@ -0,0 +1,69 @@
+
+html, body {
+
+}
+html {
+ font-size: 100%;
+ overflow-y: scroll;
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+ box-sizing: border-box;
+}
+*, *:before, *:after {
+ box-sizing: inherit
+}
+
+:root {
+ --header-height: 56px;
+ --progress-height: 12px;
+}
+
+button {
+ all: unset;
+}
+
+.-bevel {
+ position: relative;
+}
+.-bevel::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ border: 1px solid red;
+ border-color: rgba(255,255,255,0.15) rgba(255,255,255,0.15) rgba(0,0,0,0.5) rgba(0,0,0,0.5);
+ z-index: 5;
+ pointer-events: none;
+}
+
+
+body {
+ background: #202020;
+ font-family: Arial, sans-serif;
+ font-size: calc(16 * 0.0625rem);
+ font-weight: 400;
+ margin: 0;
+ padding-top: calc(var(--header-height) + var(--progress-height));
+ color: #ffffff;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: start;
+}
+
+.app-header {
+ height: var( --header-height);
+ padding: 0;
+ position: fixed;
+ z-index: 99;
+ top: 0;
+ left: 0;
+ width: 100%;
+ background: #353535;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: start;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/dialog.ts b/custom_nodes/rgthree-comfy/src_web/common/dialog.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2f107106a6956c57f1ff9f4fff37d4d8520fc29a
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/dialog.ts
@@ -0,0 +1,165 @@
+import type { LGraphNode, LGraphNodeConstructor } from "@comfyorg/frontend";
+import { createElement as $el, getClosestOrSelf, setAttributes } from "./utils_dom.js";
+
+type RgthreeDialogButton = {
+ label: string;
+ className?: string;
+ closes?: boolean;
+ disabled?: boolean;
+ callback?: (e: PointerEvent | MouseEvent) => void;
+};
+
+export type RgthreeDialogOptions = {
+ content: string | HTMLElement | HTMLElement[];
+ class?: string | string[];
+ title?: string | HTMLElement | HTMLElement[];
+ closeX?: boolean;
+ closeOnEsc?: boolean;
+ closeOnModalClick?: boolean;
+ closeButtonLabel?: string | boolean;
+ buttons?: RgthreeDialogButton[];
+ onBeforeClose?: () => Promise | boolean;
+};
+
+/**
+ * A Dialog that shows content, and closes.
+ */
+export class RgthreeDialog extends EventTarget {
+ element: HTMLDialogElement;
+ contentElement: HTMLDivElement;
+ titleElement: HTMLDivElement;
+ options: RgthreeDialogOptions;
+
+ constructor(options: RgthreeDialogOptions) {
+ super();
+ this.options = options;
+ let container = $el("div.rgthree-dialog-container");
+ this.element = $el("dialog", {
+ classes: ["rgthree-dialog", options.class || ""],
+ child: container,
+ parent: document.body,
+ events: {
+ click: (event: MouseEvent) => {
+ // Close the dialog if we've clicked outside of our container. The dialog modal will
+ // report itself as the dialog itself, so we use the inner container div (and CSS to
+ // remove default padding from the dialog element).
+ if (
+ !this.element.open ||
+ event.target === container ||
+ getClosestOrSelf(event.target, `.rgthree-dialog-container`) === container
+ ) {
+ return;
+ }
+ return this.close();
+ },
+ },
+ });
+ this.element.addEventListener("close", (event) => {
+ this.onDialogElementClose();
+ });
+
+ this.titleElement = $el("div.rgthree-dialog-container-title", {
+ parent: container,
+ children: !options.title
+ ? null
+ : options.title instanceof Element || Array.isArray(options.title)
+ ? options.title
+ : typeof options.title === "string"
+ ? !options.title.includes(" {
+ button.callback?.(e);
+ },
+ },
+ });
+ }
+
+ if (options.closeButtonLabel !== false) {
+ $el("button", {
+ text: options.closeButtonLabel || "Close",
+ className: "rgthree-button",
+ parent: footerEl,
+ events: {
+ click: (e: MouseEvent) => {
+ this.close(e);
+ },
+ },
+ });
+ }
+ }
+
+ setTitle(content: string | HTMLElement | HTMLElement[]) {
+ const title =
+ typeof content !== "string" || content.includes(" = {},
+ ) {
+ const title = (node.type || node.title || "").replace(
+ /\s*\(rgthree\).*/,
+ " by rgthree ",
+ );
+ const options = Object.assign({}, opts, {
+ class: "-iconed -help",
+ title,
+ content,
+ });
+ super(options);
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/link_fixer.ts b/custom_nodes/rgthree-comfy/src_web/common/link_fixer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1ec3cf714049a5ee1cd522187f046957bc0c6abc
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/link_fixer.ts
@@ -0,0 +1,529 @@
+import type {
+ LGraph,
+ LGraphNode,
+ LLink,
+ ISlotType,
+ INodeOutputSlot,
+ INodeInputSlot,
+ SerialisedLLinkArray,
+ LinkId,
+ ISerialisedNode,
+ ISerialisedGraph,
+ NodeId,
+} from "@comfyorg/frontend";
+
+/**
+ * The bad links data returned from either a fixer `check()`, or the results of a `fix()` call.
+ */
+export interface BadLinksData {
+ hasBadLinks: boolean;
+ graph: T;
+ patches: number;
+ deletes: number;
+}
+
+enum IoDirection {
+ INPUT,
+ OUTPUT,
+}
+
+/**
+ * Data interface that mimics a nodes `inputs` and `outputs` holding the _to be_ mutated node data
+ * during a check.
+ */
+interface PatchedNodeSlots {
+ [nodeId: string]: {
+ inputs?: {[slot: number]: number | null};
+ outputs?: {
+ [slots: number]: {
+ links: number[];
+ changes: {[linkId: number]: "ADD" | "REMOVE"};
+ };
+ };
+ };
+}
+
+/**
+ * Link data derived from either a ISerialisedGraph or LGraph `links` property.
+ */
+interface LinkData {
+ id: LinkId;
+ origin_id: NodeId;
+ origin_slot: number;
+ target_id: NodeId;
+ target_slot: number;
+ type: ISlotType;
+}
+
+/**
+ * Returns a list of links data for the given links type; either from an LGraph or SerializedGraph.
+ */
+function getLinksData(
+ links: ISerialisedGraph["links"] | LGraph["links"] | {[key: string]: LLink},
+): LinkData[] {
+ if (links instanceof Map) {
+ const data: LinkData[] = [];
+ for (const [key, llink] of links.entries()) {
+ if (!llink) continue;
+ data.push(llink);
+ }
+ return data;
+ }
+ // This is apparently marked deprecated in ComfyUI but who knows if we would get stale data in
+ // here that's not a map (handled above). Go ahead and handle it anyway.
+ if (!Array.isArray(links)) {
+ const data: LinkData[] = [];
+ for (const key in links) {
+ const llink = (links.hasOwnProperty(key) && links[key]) || null;
+ if (!llink) continue;
+ data.push(llink);
+ }
+ return data;
+ }
+ return links.map((link: SerialisedLLinkArray) => ({
+ id: link[0],
+ origin_id: link[1],
+ origin_slot: link[2],
+ target_id: link[3],
+ target_slot: link[4],
+ type: link[5],
+ }));
+}
+
+/** The instruction data for fixing a node's inputs or outputs. */
+interface WorkflowLinkFixerNodeInstruction {
+ node: ISerialisedNode | LGraphNode;
+ op: "REMOVE" | "ADD";
+ dir: IoDirection;
+ slot: number;
+ linkId: number;
+ linkIdToUse: number | null;
+}
+
+/** The instruction data for fixing a link from a workflow links. */
+interface WorkflowLinkFixerLinksInstruction {
+ op: "DELETE";
+ linkId: number;
+ reason: string;
+}
+
+type WorkflowLinkFixerInstruction =
+ | WorkflowLinkFixerNodeInstruction
+ | WorkflowLinkFixerLinksInstruction;
+
+/**
+ * The WorkflowLinkFixer for either ISerialisedGraph or a live LGraph.
+ *
+ * Use `WorkflowLinkFixer.create(graph: ISerialisedGraph | LGraph)` to create a new instance.
+ */
+export abstract class WorkflowLinkFixer<
+ G extends ISerialisedGraph | LGraph,
+ N extends ISerialisedNode | LGraphNode,
+> {
+ silent: boolean = false;
+ checkedData: BadLinksData | null = null;
+
+ protected logger: {log: (...args: any[]) => void} = console;
+ protected graph: G;
+ protected patchedNodeSlots: PatchedNodeSlots = {};
+ protected instructions: WorkflowLinkFixerInstruction[] = [];
+
+ /**
+ * Creates the WorkflowLinkFixer for the given graph type.
+ */
+ static create(graph: ISerialisedGraph): WorkflowLinkFixerSerialized;
+ static create(graph: LGraph): WorkflowLinkFixerGraph;
+ static create(
+ graph: ISerialisedGraph | LGraph,
+ ): WorkflowLinkFixerSerialized | WorkflowLinkFixerGraph {
+ if (typeof (graph as LGraph).getNodeById === "function") {
+ return new WorkflowLinkFixerGraph(graph as LGraph);
+ }
+ return new WorkflowLinkFixerSerialized(graph as ISerialisedGraph);
+ }
+
+ protected constructor(graph: G) {
+ this.graph = graph;
+ }
+
+ abstract getNodeById(id: NodeId): N | null;
+ abstract deleteGraphLink(id: LinkId): true | string;
+
+ /**
+ * Checks the current graph data for any bad links.
+ */
+ check(force: boolean = false): BadLinksData {
+ if (this.checkedData && !force) {
+ return {...this.checkedData};
+ }
+ this.instructions = [];
+ this.patchedNodeSlots = {};
+
+ const instructions: (WorkflowLinkFixerInstruction | null)[] = [];
+
+ const links: LinkData[] = getLinksData(this.graph.links);
+ links.reverse();
+ for (const link of links) {
+ if (!link) continue;
+
+ const originNode = this.getNodeById(link.origin_id);
+ const originHasLink = () =>
+ this.nodeHasLinkId(originNode!, IoDirection.OUTPUT, link.origin_slot, link.id);
+ const patchOrigin = (op: "ADD" | "REMOVE", id = link.id) =>
+ this.getNodePatchInstruction(originNode!, IoDirection.OUTPUT, link.origin_slot, id, op);
+
+ const targetNode = this.getNodeById(link.target_id);
+ const targetHasLink = () =>
+ this.nodeHasLinkId(targetNode!, IoDirection.INPUT, link.target_slot, link.id);
+ const targetHasAnyLink = () =>
+ this.nodeHasAnyLink(targetNode!, IoDirection.INPUT, link.target_slot);
+ const patchTarget = (op: "ADD" | "REMOVE", id = link.id) =>
+ this.getNodePatchInstruction(targetNode!, IoDirection.INPUT, link.target_slot, id, op);
+
+ const originLog = `origin(${link.origin_id}).outputs[${link.origin_slot}].links`;
+ const targetLog = `target(${link.target_id}).inputs[${link.target_slot}].link`;
+
+ if (!originNode || !targetNode) {
+ if (!originNode && !targetNode) {
+ // This can fall through and continue; we remove it after this loop.
+ } else if (!originNode && targetNode) {
+ this.log(
+ `Link ${link.id} is funky... ` +
+ `origin ${link.origin_id} does not exist, but target ${link.target_id} does.`,
+ );
+ if (targetHasLink()) {
+ this.log(` > [PATCH] ${targetLog} does have link, will remove the inputs' link first.`);
+ instructions.push(patchTarget("REMOVE", -1));
+ }
+ } else if (!targetNode && originNode) {
+ this.log(
+ `Link ${link.id} is funky... ` +
+ `target ${link.target_id} does not exist, but origin ${link.origin_id} does.`,
+ );
+ if (originHasLink()) {
+ this.log(` > [PATCH] Origin's links' has ${link.id}; will remove the link first.`);
+ instructions.push(patchOrigin("REMOVE"));
+ }
+ }
+ continue;
+ }
+
+ if (targetHasLink() || originHasLink()) {
+ if (!originHasLink()) {
+ this.log(
+ `${link.id} is funky... ${originLog} does NOT contain it, but ${targetLog} does.`,
+ );
+ this.log(` > [PATCH] Attempt a fix by adding this ${link.id} to ${originLog}.`);
+ instructions.push(patchOrigin("ADD"));
+ } else if (!targetHasLink()) {
+ this.log(
+ `${link.id} is funky... ${targetLog} is NOT correct (is ${
+ targetNode.inputs?.[link.target_slot]?.link
+ }), but ${originLog} contains it`,
+ );
+ if (!targetHasAnyLink()) {
+ this.log(` > [PATCH] ${targetLog} is not defined, will set to ${link.id}.`);
+ let instruction = patchTarget("ADD");
+ if (!instruction) {
+ this.log(
+ ` > [PATCH] Nvm, ${targetLog} already patched. Removing ${link.id} from ${originLog}.`,
+ );
+ instruction = patchOrigin("REMOVE");
+ }
+ instructions.push(instruction);
+ } else {
+ this.log(` > [PATCH] ${targetLog} is defined, removing ${link.id} from ${originLog}.`);
+ instructions.push(patchOrigin("REMOVE"));
+ }
+ }
+ }
+ }
+
+ // Now that we've cleaned up the inputs, outputs, run through it looking for dangling links.,
+ for (let link of links) {
+ if (!link) continue;
+ const originNode = this.getNodeById(link.origin_id);
+ const targetNode = this.getNodeById(link.target_id);
+ if (!originNode && !targetNode) {
+ instructions.push({
+ op: "DELETE",
+ linkId: link.id,
+ reason: `Both nodes #${link.origin_id} & #${link.target_id} are removed`,
+ });
+ }
+ // Now that we've manipulated the linking, check again if they both exist.
+ if (
+ (!originNode ||
+ !this.nodeHasLinkId(originNode, IoDirection.OUTPUT, link.origin_slot, link.id)) &&
+ (!targetNode ||
+ !this.nodeHasLinkId(targetNode, IoDirection.INPUT, link.target_slot, link.id))
+ ) {
+ instructions.push({
+ op: "DELETE",
+ linkId: link.id,
+ reason:
+ `both origin node #${link.origin_id} ` +
+ `${!originNode ? "is removed" : `is missing link id output slot ${link.origin_slot}`}` +
+ `and target node #${link.target_id} ` +
+ `${!targetNode ? "is removed" : `is missing link id input slot ${link.target_slot}`}.`,
+ });
+ continue;
+ }
+ }
+
+ this.instructions = instructions.filter((i) => !!i);
+ this.checkedData = {
+ hasBadLinks: !!this.instructions.length,
+ graph: this.graph,
+ patches: this.instructions.filter((i) => !!(i as WorkflowLinkFixerNodeInstruction).node)
+ .length,
+ deletes: this.instructions.filter((i) => i.op === "DELETE").length,
+ };
+ return {...this.checkedData};
+ }
+
+ /**
+ * Fixes a checked graph by running through the instructions generated during the check run. Also
+ * double-checks for inconsistencies after the fix, recursively calling itself up to five times
+ * before giving up.
+ */
+ fix(force: boolean = false, times?: number): BadLinksData {
+ if (!this.checkedData || force) {
+ this.check(force);
+ }
+ let patches = 0;
+ let deletes = 0;
+ for (const instruction of this.instructions) {
+ if ((instruction as WorkflowLinkFixerNodeInstruction).node) {
+ let {node, slot, linkIdToUse, dir, op} = instruction as WorkflowLinkFixerNodeInstruction;
+ if (dir == IoDirection.INPUT) {
+ node.inputs = node.inputs || [];
+ const old = node.inputs[slot]?.link;
+ node.inputs[slot] = node.inputs[slot] || ({} as INodeInputSlot);
+ node.inputs[slot].link = linkIdToUse;
+ this.log(`Node #${node.id}: Set link ${linkIdToUse} to input slot ${slot} (was ${old})`);
+ } else if (op === "ADD" && linkIdToUse != null) {
+ node.outputs = node.outputs || [];
+ node.outputs[slot] = node.outputs[slot] || ({} as INodeOutputSlot);
+ node.outputs[slot].links = node.outputs[slot].links || [];
+ node.outputs[slot].links.push(linkIdToUse);
+ this.log(`Node #${node.id}: Add link ${linkIdToUse} to output slot #${slot}`);
+ } else if (op === "REMOVE" && linkIdToUse != null) {
+ // We should never not have this data since the check call would have found it to be
+ // removed, but we can be safe and appease TS compiler at the same time.
+ if (node.outputs?.[slot]?.links?.length === undefined) {
+ this.log(
+ `Node #${node.id}: Couldn't remove link ${linkIdToUse} from output slot #${slot}` +
+ ` because it didn't exist.`,
+ );
+ } else {
+ let linkIdIndex = node.outputs![slot].links.indexOf(linkIdToUse);
+ node.outputs[slot].links.splice(linkIdIndex, 1);
+ this.log(`Node #${node.id}: Remove link ${linkIdToUse} from output slot #${slot}`);
+ }
+ } else {
+ throw new Error("Unhandled Node Instruction");
+ }
+ patches++;
+ } else if (instruction.op === "DELETE") {
+ const wasDeleted = this.deleteGraphLink(instruction.linkId);
+ if (wasDeleted === true) {
+ this.log(`Link #${instruction.linkId}: Removed workflow link b/c ${instruction.reason}`);
+ } else {
+ this.log(`Error Link #${instruction.linkId} was not removed!`);
+ }
+ deletes += wasDeleted ? 1 : 0;
+ } else {
+ throw new Error("Unhandled Instruction");
+ }
+ }
+
+ const newCheck = this.check(force);
+ times = times == null ? 5 : times;
+ let newFix = null;
+ // If we still have bad links, then recurse (up to five times).
+ if (newCheck.hasBadLinks && times > 0) {
+ newFix = this.fix(true, times - 1);
+ }
+
+ return {
+ hasBadLinks: newFix?.hasBadLinks ?? newCheck.hasBadLinks,
+ graph: this.graph,
+ patches: patches + (newFix?.patches ?? 0),
+ deletes: deletes + (newFix?.deletes ?? 0),
+ };
+ }
+
+ /** Logs if not silent. */
+ protected log(...args: any[]) {
+ if (this.silent) return;
+ this.logger.log(...args);
+ }
+
+ /**
+ * Patches a node for a check run, returning the instruction that would be made.
+ */
+ private getNodePatchInstruction(
+ node: N,
+ ioDir: IoDirection,
+ slot: number,
+ linkId: number,
+ op: "ADD" | "REMOVE",
+ ): WorkflowLinkFixerNodeInstruction | null {
+ const nodeId = node.id;
+ this.patchedNodeSlots[nodeId] = this.patchedNodeSlots[nodeId] || {};
+ const patchedNode = this.patchedNodeSlots[nodeId];
+ if (ioDir == IoDirection.INPUT) {
+ patchedNode["inputs"] = patchedNode["inputs"] || {};
+ // We can set to null (delete), so undefined means we haven't set it at all.
+ if (patchedNode["inputs"][slot] !== undefined) {
+ this.log(
+ ` > Already set ${nodeId}.inputs[${slot}] to ${patchedNode["inputs"][slot]} Skipping.`,
+ );
+ return null;
+ }
+ let linkIdToUse = op === "REMOVE" ? null : linkId;
+ patchedNode["inputs"][slot] = linkIdToUse;
+ return {node, dir: ioDir, op, slot, linkId, linkIdToUse};
+ }
+
+ patchedNode["outputs"] = patchedNode["outputs"] || {};
+ patchedNode["outputs"][slot] = patchedNode["outputs"][slot] || {
+ links: [...(node.outputs?.[slot]?.links || [])],
+ changes: {},
+ };
+ if (patchedNode["outputs"][slot]["changes"][linkId] !== undefined) {
+ this.log(
+ ` > Already set ${nodeId}.outputs[${slot}] to ${patchedNode["outputs"][slot]}! Skipping.`,
+ );
+ return null;
+ }
+ patchedNode["outputs"][slot]["changes"][linkId] = op;
+ if (op === "ADD") {
+ let linkIdIndex = patchedNode["outputs"][slot]["links"].indexOf(linkId);
+ if (linkIdIndex !== -1) {
+ this.log(` > Hmmm.. asked to add ${linkId} but it is already in list...`);
+ return null;
+ }
+ patchedNode["outputs"][slot]["links"].push(linkId);
+ return {node, dir: ioDir, op, slot, linkId, linkIdToUse: linkId};
+ }
+
+ let linkIdIndex = patchedNode["outputs"][slot]["links"].indexOf(linkId);
+ if (linkIdIndex === -1) {
+ this.log(` > Hmmm.. asked to remove ${linkId} but it doesn't exist...`);
+ return null;
+ }
+ patchedNode["outputs"][slot]["links"].splice(linkIdIndex, 1);
+ return {node, dir: ioDir, op, slot, linkId, linkIdToUse: linkId};
+ }
+
+ /** Checks if a node (or patched data) has a linkId. */
+ private nodeHasLinkId(node: N, ioDir: IoDirection, slot: number, linkId: number) {
+ const nodeId = node.id;
+ let has = false;
+ if (ioDir === IoDirection.INPUT) {
+ let nodeHasIt = node.inputs?.[slot]?.link === linkId;
+ if (this.patchedNodeSlots[nodeId]?.["inputs"]) {
+ let patchedHasIt = this.patchedNodeSlots[nodeId]["inputs"][slot] === linkId;
+ has = patchedHasIt;
+ } else {
+ has = nodeHasIt;
+ }
+ } else {
+ let nodeHasIt = node.outputs?.[slot]?.links?.includes(linkId);
+ if (this.patchedNodeSlots[nodeId]?.["outputs"]?.[slot]?.["changes"][linkId]) {
+ let patchedHasIt = this.patchedNodeSlots[nodeId]["outputs"][slot].links.includes(linkId);
+ has = !!patchedHasIt;
+ } else {
+ has = !!nodeHasIt;
+ }
+ }
+ return has;
+ }
+
+ /** Checks if a node (or patched data) has a linkId. */
+ private nodeHasAnyLink(node: N, ioDir: IoDirection, slot: number) {
+ // Patched data should be canonical. We can double check if fixing too.
+ const nodeId = node.id;
+ let hasAny = false;
+ if (ioDir === IoDirection.INPUT) {
+ let nodeHasAny = node.inputs?.[slot]?.link != null;
+ if (this.patchedNodeSlots[nodeId]?.["inputs"]) {
+ let patchedHasAny = this.patchedNodeSlots[nodeId]["inputs"][slot] != null;
+ hasAny = patchedHasAny;
+ } else {
+ hasAny = !!nodeHasAny;
+ }
+ } else {
+ let nodeHasAny = node.outputs?.[slot]?.links?.length;
+ if (this.patchedNodeSlots[nodeId]?.["outputs"]?.[slot]?.["changes"]) {
+ let patchedHasAny = this.patchedNodeSlots[nodeId]["outputs"][slot].links?.length;
+ hasAny = !!patchedHasAny;
+ } else {
+ hasAny = !!nodeHasAny;
+ }
+ }
+ return hasAny;
+ }
+}
+
+/**
+ * A WorkflowLinkFixer for serialized data.
+ */
+class WorkflowLinkFixerSerialized extends WorkflowLinkFixer {
+ constructor(graph: ISerialisedGraph) {
+ super(graph);
+ }
+
+ getNodeById(id: NodeId) {
+ return this.graph.nodes.find((node) => Number(node.id) === id) ?? null;
+ }
+
+ override fix(force: boolean = false, times?: number) {
+ const ret = super.fix(force, times);
+ // If we're a serialized graph, we can filter out the links because it's just an array.
+ this.graph.links = this.graph.links.filter((l) => !!l);
+ return ret;
+ }
+
+ deleteGraphLink(id: LinkId) {
+ // Sometimes we got objects instead of serializzed array for links if passed after ComfyUI's
+ // loadGraphData modifies the data. Let's find the id handling the bastardized objects just in
+ // case.
+ const idx = this.graph.links.findIndex((l) => l && (l[0] === id || (l as any).id === id));
+ if (idx === -1) {
+ return `Link #${id} not found in workflow links.`;
+ }
+ this.graph.links.splice(idx, 1);
+ return true;
+ }
+}
+
+/**
+ * A WorkflowLinkFixer for live LGraph data.
+ */
+class WorkflowLinkFixerGraph extends WorkflowLinkFixer {
+ constructor(graph: LGraph) {
+ super(graph);
+ }
+
+ getNodeById(id: NodeId) {
+ return this.graph.getNodeById(id) ?? null;
+ }
+
+ deleteGraphLink(id: LinkId) {
+ if (this.graph.links instanceof Map) {
+ if (!this.graph.links.has(id)) {
+ return `Link #${id} not found in workflow links.`;
+ }
+ this.graph.links.delete(id);
+ return true;
+ }
+ if (this.graph.links[id] == null) {
+ return `Link #${id} not found in workflow links.`;
+ }
+ delete this.graph.links[id];
+ return true;
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/media/rgthree.svg b/custom_nodes/rgthree-comfy/src_web/common/media/rgthree.svg
new file mode 100644
index 0000000000000000000000000000000000000000..766a76152eefcdf8d05bf320c329b82afc704eaa
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/media/rgthree.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/common/media/svgs.ts b/custom_nodes/rgthree-comfy/src_web/common/media/svgs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b468cd0e9070b9cac52412ea0a7fe9e0eab5518c
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/media/svgs.ts
@@ -0,0 +1,210 @@
+import {createElement as $el} from "../utils_dom.js";
+
+// Some svg repo : https://www.svgrepo.com/svg/326731/open-outline
+
+export let logoRgthree: string = "";
+
+export async function logoRgthreeAsync(): Promise {
+ if (logoRgthree) return logoRgthree;
+ let baseUrl = null;
+ if (window.location.pathname.includes("/rgthree/")) {
+ // Try to find how many relatives paths we need to go back to hit ./rgthree/api
+ const parts = window.location.pathname.split("/rgthree/")[1]?.split("/");
+ if (parts && parts.length) {
+ baseUrl = parts.map(() => "../").join("") + "rgthree";
+ }
+ }
+ baseUrl = baseUrl || "./rgthree";
+ return fetch(`${baseUrl}/logo_markup.svg?fg=currentColor&cssClass=rgthree-logo&w=auto&h=auto`)
+ .then((r) => r.text())
+ .then((t) => {
+ if (t.length < 100) {
+ t = `
+
+
+
+
+ `;
+ }
+ logoRgthree = t;
+ return t;
+ });
+}
+// Kick it off to cache upfront.
+logoRgthreeAsync();
+
+export const github = `
+
+ `;
+
+export const iconStarFilled = `
+
+ `;
+
+export const iconReplace = `
+
+
+
+
+ `;
+
+export const iconNode = `
+
+
+ `;
+
+export const iconGear = `
+
+ `;
+
+export const checkmark = `
+
+
+
+ `;
+
+export const logoCivitai = `
+
+
+
+
+
+
+
+
+
+ `;
+
+export const iconOutLink = `
+
+ `;
+
+export const link = `
+
+ `;
+
+export const pencil = `
+
+ `;
+
+export const dotdotdot = `
+
+
+
+ `;
+
+export const models = `
+
+
+
+
+ `;
+
+/** https://www.svgrepo.com/svg/402308/pencil */
+export const pencilColored = `
+
+
+
+
+
+
+
+
+ `;
+
+/** https://www.svgrepo.com/svg/395640/save */
+export const diskColored = `
+
+
+
+
+
+
+
+ `;
+
+/** https://www.svgrepo.com/svg/229838/folder */
+export const folderColored = `
+
+
+ `;
+
+export const modelsColored = `
+
+
+
+
+ `;
+
+export const legoBlocksColored = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+export const legoBlockColored = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+export const gearColored = `
+
+
+
+
+ `;
+
+export function $svg(markup: string, attrs: {[key: string]: string}) {
+ if (!markup.match(/^\s* Promise> = new Map();
+
+ private handleWindowPointerDownBound = this.handleWindowPointerDown.bind(this);
+
+ constructor(options: MenuOption[]) {
+ this.setOptions(options);
+ this.element.addEventListener('pointerup', async (e) => {
+ const target = getClosestOrSelf(e.target as HTMLElement, "[data-callback],menu");
+ if (e.which !== 1) {
+ return;
+ }
+ const callback = target?.dataset?.['callback'];
+ if (callback) {
+ const halt = await this.callbacks.get(callback)?.(e);
+ if (halt !== false) {
+ this.close();
+ }
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ });
+ }
+
+ setOptions(options: MenuOption[]) {
+ for (const option of options) {
+ if (option.type === 'title') {
+ this.element.appendChild($el(`li`, {
+ html: option.label
+ }));
+ } else {
+ const id = generateId(8);
+ this.callbacks.set(id, async (e: PointerEvent) => { return option?.callback?.(e); });
+ this.element.appendChild($el(`li[role="button"][data-callback="${id}"]`, {
+ html: option.label
+ }));
+ }
+ }
+ }
+
+ toElement() {
+ return this.element;
+ }
+
+ async open(e: PointerEvent) {
+ const parent = (e.target as HTMLElement).closest('div,dialog,body') as HTMLElement
+ parent.appendChild(this.element);
+ setAttributes(this.element, {
+ style: {
+ left: `${e.clientX + 16}px`,
+ top: `${e.clientY - 16}px`,
+ }
+ });
+ this.element.setAttribute('state', 'measuring-open');
+ await wait(16);
+ const rect = this.element.getBoundingClientRect();
+ if (rect.right > window.innerWidth) {
+ this.element.style.left = `${e.clientX - rect.width - 16}px`;
+ await wait(16);
+ }
+ this.element.setAttribute('state', 'open');
+ setTimeout(() => {
+ window.addEventListener('pointerdown', this.handleWindowPointerDownBound);
+ });
+ }
+
+ handleWindowPointerDown(e:PointerEvent) {
+ if (!this.element.contains(e.target as HTMLElement)) {
+ this.close();
+ }
+ }
+
+ async close() {
+ window.removeEventListener('pointerdown', this.handleWindowPointerDownBound);
+ this.element.setAttribute('state', 'measuring-closed');
+ await wait(16);
+ this.element.setAttribute('state', 'closed');
+ this.element.remove();
+ }
+
+ isOpen() {
+ return (this.element.getAttribute('state') || '').includes('open');
+ }
+
+}
+
+type MenuOption = {
+ label: string;
+ type?: 'title'|'item'|'separator';
+ callback?: (e: PointerEvent) => void;
+}
+
+type MenuButtonOptions = {
+ icon: string;
+ options: MenuOption[];
+}
+
+export class MenuButton {
+
+ private options: MenuButtonOptions;
+ private menu: Menu;
+
+ private element: HTMLButtonElement = $el('button.rgthree-button[data-action="open-menu"]')
+
+ constructor(options: MenuButtonOptions) {
+ this.options = options;
+ this.element.innerHTML = options.icon;
+ this.menu = new Menu(options.options);
+
+ this.element.addEventListener('pointerdown', (e) => {
+ if (!this.menu.isOpen()) {
+ this.menu.open(e);
+ }
+ });
+ }
+
+ toElement() {
+ return this.element;
+ }
+
+}
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/common/model_info_service.ts b/custom_nodes/rgthree-comfy/src_web/common/model_info_service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..efa36866c5c4203e0ecf65e6c999a518dcfbe57a
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/model_info_service.ts
@@ -0,0 +1,99 @@
+import type {RgthreeModelInfo} from "typings/rgthree.js";
+import {ModelInfoType, rgthreeApi} from "./rgthree_api.js";
+import {api} from "scripts/api.js";
+
+/**
+ * Abstract class defining information syncing for different types.
+ */
+abstract class BaseModelInfoService extends EventTarget {
+ private readonly fileToInfo = new Map();
+ protected abstract readonly modelInfoType: ModelInfoType;
+
+ protected abstract readonly apiRefreshEventString: string;
+
+ constructor() {
+ super();
+ this.init();
+ }
+
+ private init() {
+ api.addEventListener(
+ this.apiRefreshEventString,
+ this.handleAsyncUpdate.bind(this) as EventListener,
+ );
+ }
+
+ async getInfo(file: string, refresh: boolean, light: boolean) {
+ if (this.fileToInfo.has(file) && !refresh) {
+ return this.fileToInfo.get(file)!;
+ }
+ return this.fetchInfo(file, refresh, light);
+ }
+
+ async refreshInfo(file: string) {
+ return this.fetchInfo(file, true);
+ }
+
+ async clearFetchedInfo(file: string) {
+ await rgthreeApi.clearModelsInfo({type: this.modelInfoType, files: [file]});
+ this.fileToInfo.delete(file);
+ return null;
+ }
+
+ async savePartialInfo(file: string, data: Partial) {
+ let info = await rgthreeApi.saveModelInfo(this.modelInfoType, file, data);
+ this.fileToInfo.set(file, info);
+ return info;
+ }
+
+ handleAsyncUpdate(event: CustomEvent<{data: RgthreeModelInfo}>) {
+ const info = event.detail?.data as RgthreeModelInfo;
+ if (info?.file) {
+ this.setFreshInfo(info.file, info);
+ }
+ }
+
+ private async fetchInfo(file: string, refresh = false, light = false) {
+ let info = null;
+ if (!refresh) {
+ info = await rgthreeApi.getModelsInfo({type: this.modelInfoType, files: [file], light});
+ } else {
+ info = await rgthreeApi.refreshModelsInfo({type: this.modelInfoType, files: [file]});
+ }
+ info = info?.[0] ?? null;
+ if (!light) {
+ this.fileToInfo.set(file, info);
+ }
+ return info;
+ }
+
+ /**
+ * Single point to set data into the info cache, and fire an event. Note, this doesn't determine
+ * if the data is actually different.
+ */
+ private setFreshInfo(file: string, info: RgthreeModelInfo) {
+ this.fileToInfo.set(file, info);
+ // this.dispatchEvent(
+ // new CustomEvent("rgthree-model-service-lora-details", { detail: { lora: info } }),
+ // );
+ }
+}
+
+/**
+ * Lora type implementation of ModelInfoTypeService.
+ */
+class LoraInfoService extends BaseModelInfoService {
+ protected override readonly apiRefreshEventString = "rgthree-refreshed-loras-info";
+ protected override readonly modelInfoType = 'loras';
+}
+
+/**
+ * Checkpoint type implementation of ModelInfoTypeService.
+ */
+class CheckpointInfoService extends BaseModelInfoService {
+ protected override readonly apiRefreshEventString = "rgthree-refreshed-checkpoints-info";
+ protected override readonly modelInfoType = 'checkpoints';
+}
+
+export const LORA_INFO_SERVICE = new LoraInfoService();
+export const CHECKPOINT_INFO_SERVICE = new CheckpointInfoService();
diff --git a/custom_nodes/rgthree-comfy/src_web/common/progress_bar.ts b/custom_nodes/rgthree-comfy/src_web/common/progress_bar.ts
new file mode 100644
index 0000000000000000000000000000000000000000..af55edab93c02595c2eb61e19445220c0febf5a1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/progress_bar.ts
@@ -0,0 +1,218 @@
+/**
+ * Progress bar web component.
+ */
+
+import { SERVICE as PROMPT_SERVICE, type PromptExecution } from "rgthree/common/prompt_service.js";
+import { createElement } from "./utils_dom.js";
+
+/**
+ * The progress bar web component.
+ */
+export class RgthreeProgressBar extends HTMLElement {
+ static NAME = "rgthree-progress-bar";
+
+ static create(): RgthreeProgressBar {
+ return document.createElement(RgthreeProgressBar.NAME) as RgthreeProgressBar;
+ }
+
+ private shadow: ShadowRoot | null = null;
+ private progressNodesEl!: HTMLDivElement;
+ private progressStepsEl!: HTMLDivElement;
+ private progressTextEl!: HTMLSpanElement;
+
+ private currentPromptExecution: PromptExecution | null = null;
+
+ private readonly onProgressUpdateBound = this.onProgressUpdate.bind(this);
+
+ private connected: boolean = false;
+
+ /** The currentNodeId so outside callers can see what we're currently executing against. */
+ get currentNodeId() {
+ const prompt = this.currentPromptExecution;
+ const nodeId = prompt?.errorDetails?.node_id || prompt?.currentlyExecuting?.nodeId;
+ return nodeId || null;
+ }
+
+ constructor() {
+ super();
+ }
+
+ private onProgressUpdate(e: CustomEvent<{ queue: number; prompt: PromptExecution }>) {
+ if (!this.connected) return;
+
+ const prompt = e.detail.prompt;
+ this.currentPromptExecution = prompt;
+
+ if (prompt?.errorDetails) {
+ let progressText = `${prompt.errorDetails?.exception_type} ${
+ prompt.errorDetails?.node_id || ""
+ } ${prompt.errorDetails?.node_type || ""}`;
+ this.progressTextEl.innerText = progressText;
+ this.progressNodesEl.classList.add("-error");
+ this.progressStepsEl.classList.add("-error");
+ return;
+ }
+ if (prompt?.currentlyExecuting) {
+ this.progressNodesEl.classList.remove("-error");
+ this.progressStepsEl.classList.remove("-error");
+
+ const current = prompt?.currentlyExecuting;
+
+ let progressText = `(${e.detail.queue}) `;
+
+ // Sometimes we may get status updates for a workflow that was already running. In that case
+ // we don't know totalNodes.
+ if (!prompt.totalNodes) {
+ progressText += `??%`;
+ this.progressNodesEl.style.width = `0%`;
+ } else {
+ const percent = (prompt.executedNodeIds.length / prompt.totalNodes) * 100;
+ this.progressNodesEl.style.width = `${Math.max(2, percent)}%`;
+ // progressText += `Node ${prompt.executedNodeIds.length + 1} of ${prompt.totalNodes || "?"}`;
+ progressText += `${Math.round(percent)}%`;
+ }
+
+ let nodeLabel = current.nodeLabel?.trim();
+ let stepsLabel = "";
+ if (current.step != null && current.maxSteps) {
+ const percent = (current.step / current.maxSteps) * 100;
+ this.progressStepsEl.style.width = `${percent}%`;
+ // stepsLabel += `Step ${current.step} of ${current.maxSteps}`;
+ if (current.pass > 1 || current.maxPasses != null) {
+ stepsLabel += `#${current.pass}`;
+ if (current.maxPasses && current.maxPasses > 0) {
+ stepsLabel += `/${current.maxPasses}`;
+ }
+ stepsLabel += ` - `;
+ }
+ stepsLabel += `${Math.round(percent)}%`;
+ }
+
+ if (nodeLabel || stepsLabel) {
+ progressText += ` - ${nodeLabel || "???"}${stepsLabel ? ` (${stepsLabel})` : ""}`;
+ }
+ if (!stepsLabel) {
+ this.progressStepsEl.style.width = `0%`;
+ }
+ this.progressTextEl.innerText = progressText;
+ } else {
+ if (e?.detail.queue) {
+ this.progressTextEl.innerText = `(${e.detail.queue}) Running... in another tab`;
+ } else {
+ this.progressTextEl.innerText = "Idle";
+ }
+ this.progressNodesEl.style.width = `0%`;
+ this.progressStepsEl.style.width = `0%`;
+ }
+ }
+
+ connectedCallback() {
+ if (!this.connected) {
+ PROMPT_SERVICE.addEventListener(
+ "progress-update",
+ this.onProgressUpdateBound as EventListener,
+ );
+ this.connected = true;
+ }
+ // We were already connected, so we just need to reset.
+ if (this.shadow) {
+ this.progressTextEl.innerText = "Idle";
+ this.progressNodesEl.style.width = `0%`;
+ this.progressStepsEl.style.width = `0%`;
+ return;
+ }
+
+ this.shadow = this.attachShadow({ mode: "open" });
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(`
+
+ :host {
+ position: relative;
+ overflow: hidden;
+ box-sizing: border-box;
+ background: var(--rgthree-progress-bg-color);
+ --rgthree-progress-bg-color: rgba(23, 23, 23, 0.9);
+ --rgthree-progress-nodes-bg-color: rgb(0, 128, 0);
+ --rgthree-progress-steps-bg-color: rgb(0, 128, 0);
+ --rgthree-progress-error-bg-color: rgb(128, 0, 0);
+ --rgthree-progress-text-color: #fff;
+ }
+ :host * {
+ box-sizing: inherit;
+ }
+
+ :host > div.bar {
+ background: var(--rgthree-progress-nodes-bg-color);
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 0%;
+ height: 50%;
+ z-index: 1;
+ transition: width 50ms ease-in-out;
+ }
+ :host > div.bar + div.bar {
+ background: var(--rgthree-progress-steps-bg-color);
+ top: 50%;
+ height: 50%;
+ z-index: 2;
+ }
+ :host > div.bar.-error {
+ background: var(--rgthree-progress-error-bg-color);
+ }
+
+ :host > .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 5;
+ background: linear-gradient(to bottom, rgba(255,255,255,0.25), rgba(0,0,0,0.25));
+ mix-blend-mode: overlay;
+ }
+
+ :host > span {
+ position: relative;
+ z-index: 4;
+ text-align: left;
+ font-size: inherit;
+ height: 100%;
+ font-family: sans-serif;
+ text-shadow: 1px 1px 0px #000;
+ display: flex;
+ flex-direction: row;
+ padding: 0 6px;
+ align-items: center;
+ justify-content: start;
+ color: var(--rgthree-progress-text-color);
+ text-shadow: black 0px 0px 2px;
+ }
+
+ :host > div.bar[style*="width: 0%"]:first-child,
+ :host > div.bar[style*="width:0%"]:first-child {
+ height: 0%;
+ }
+ :host > div.bar[style*="width: 0%"]:first-child + div,
+ :host > div.bar[style*="width:0%"]:first-child + div {
+ bottom: 0%;
+ }
+ `);
+ this.shadow.adoptedStyleSheets = [sheet];
+
+ const overlayEl = createElement(`div.overlay[part="overlay"]`, { parent: this.shadow });
+ this.progressNodesEl = createElement(`div.bar[part="progress-nodes"]`, { parent: this.shadow });
+ this.progressStepsEl = createElement(`div.bar[part="progress-steps"]`, { parent: this.shadow });
+ this.progressTextEl = createElement(`span[part="text"]`, { text: "Idle", parent: this.shadow });
+ }
+
+ disconnectedCallback() {
+ this.connected = false;
+ PROMPT_SERVICE.removeEventListener(
+ "progress-update",
+ this.onProgressUpdateBound as EventListener,
+ );
+ }
+}
+
+customElements.define(RgthreeProgressBar.NAME, RgthreeProgressBar);
diff --git a/custom_nodes/rgthree-comfy/src_web/common/prompt_service.ts b/custom_nodes/rgthree-comfy/src_web/common/prompt_service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4ed8b40f5290b77e64e64c7b3a0e622e005f23cf
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/prompt_service.ts
@@ -0,0 +1,274 @@
+import type {
+ ComfyApiEventDetailCached,
+ ComfyApiEventDetailError,
+ ComfyApiEventDetailExecuted,
+ ComfyApiEventDetailExecuting,
+ ComfyApiEventDetailExecutionStart,
+ ComfyApiEventDetailProgress,
+ ComfyApiEventDetailStatus,
+ ComfyApiFormat,
+ ComfyApiPrompt,
+} from "typings/comfy.js";
+import { api } from "scripts/api.js";
+import type { LGraph as TLGraph, LGraphCanvas as TLGraphCanvas } from "@comfyorg/frontend";
+import { Resolver, getResolver } from "./shared_utils.js";
+
+/**
+ * Wraps general data of a prompt's execution.
+ */
+export class PromptExecution {
+ id: string;
+ promptApi: ComfyApiFormat | null = null;
+ executedNodeIds: string[] = [];
+ totalNodes: number = 0;
+ currentlyExecuting: {
+ nodeId: string;
+ nodeLabel?: string;
+ step?: number;
+ maxSteps?: number;
+ /** The current pass, for nodes with multiple progress passes. */
+ pass: number;
+ /**
+ * The max num of passes. Can be calculated for some nodes, or set to -1 when known there will
+ * be multiple passes, but the number cannot be calculated.
+ */
+ maxPasses?: number;
+ } | null = null;
+ errorDetails: any | null = null;
+
+ apiPrompt: Resolver = getResolver();
+
+ constructor(id: string) {
+ this.id = id;
+ }
+
+ /**
+ * Sets the prompt and prompt-related data. This can technically come in lazily, like if the web
+ * socket fires the 'execution-start' event before we actually get a response back from the
+ * initial prompt call.
+ */
+ setPrompt(prompt: ComfyApiPrompt) {
+ this.promptApi = prompt.output;
+ this.totalNodes = Object.keys(this.promptApi).length;
+ this.apiPrompt.resolve(null);
+ }
+
+ getApiNode(nodeId: string | number) {
+ return this.promptApi?.[String(nodeId)] || null;
+ }
+
+ private getNodeLabel(nodeId: string | number) {
+ const apiNode = this.getApiNode(nodeId);
+ let label = apiNode?._meta?.title || apiNode?.class_type || undefined;
+ if (!label) {
+ const graphNode = this.maybeGetComfyGraph()?.getNodeById(Number(nodeId));
+ label = graphNode?.title || graphNode?.type || undefined;
+ }
+ return label;
+ }
+
+ /**
+ * Updates the execution data depending on the passed data, fed from api events.
+ */
+ executing(nodeId: string | null, step?: number, maxSteps?: number) {
+ if (nodeId == null) {
+ // We're done, any left over nodes must be skipped...
+ this.currentlyExecuting = null;
+ return;
+ }
+ if (this.currentlyExecuting?.nodeId !== nodeId) {
+ if (this.currentlyExecuting != null) {
+ this.executedNodeIds.push(nodeId);
+ }
+ this.currentlyExecuting = { nodeId, nodeLabel: this.getNodeLabel(nodeId), pass: 0 };
+ // We'll see if we're known node for multiple passes, that will come in as generic 'progress'
+ // updates from the api. If we're known to have multiple passes, then we'll pre-set data to
+ // allow the progress bar to handle intial rendering. If we're not, that's OK, the data will
+ // be shown with the second pass.
+ this.apiPrompt.promise.then(() => {
+ // If we execute with a null node id and clear the currently executing, then we can just
+ // move on. This seems to only happen with a super-fast execution (like, just seed node
+ // and display any for testing).
+ if (this.currentlyExecuting == null) {
+ return;
+ }
+ const apiNode = this.getApiNode(nodeId);
+ if (!this.currentlyExecuting.nodeLabel) {
+ this.currentlyExecuting.nodeLabel = this.getNodeLabel(nodeId);
+ }
+ if (apiNode?.class_type === "UltimateSDUpscale") {
+ // From what I can tell, UltimateSDUpscale, does an initial pass that isn't actually a
+ // tile. It seems to always be 4 steps... We'll start our pass at -1, so this prepass is
+ // "0" and "1" will start with the first tile. This way, a user knows they have 4 tiles,
+ // know this pass counter will go to 4 (and not 5). Also, we cannot calculate maxPasses
+ // for 'UltimateSDUpscale' :(
+ this.currentlyExecuting.pass--;
+ this.currentlyExecuting.maxPasses = -1;
+ } else if (apiNode?.class_type === "IterativeImageUpscale") {
+ this.currentlyExecuting.maxPasses = (apiNode?.inputs["steps"] as number) ?? -1;
+ }
+ });
+ }
+ if (step != null) {
+ // If we haven't had any stpes before, or the passes step is lower than the previous, then
+ // increase the passes.
+ if (!this.currentlyExecuting!.step || step < this.currentlyExecuting!.step) {
+ this.currentlyExecuting!.pass!++;
+ }
+ this.currentlyExecuting!.step = step;
+ this.currentlyExecuting!.maxSteps = maxSteps;
+ }
+ }
+
+ /**
+ * If there's an error, we add the details.
+ */
+ error(details: any) {
+ this.errorDetails = details;
+ }
+
+ private maybeGetComfyGraph(): TLGraph | null {
+ return ((window as any)?.app?.graph as TLGraph) || null;
+ }
+}
+
+/**
+ * A singleton service that wraps the Comfy API and simplifies the event data being fired.
+ */
+class PromptService extends EventTarget {
+ promptsMap: Map = new Map();
+ currentExecution: PromptExecution | null = null;
+ lastQueueRemaining = 0;
+
+ constructor(api: any) {
+ super();
+ const that = this;
+
+ // Patch the queuePrompt method so we can capture new data going through.
+ const queuePrompt = api.queuePrompt;
+ api.queuePrompt = async function (num: number, prompt: ComfyApiPrompt, ...args: any[]) {
+ let response;
+ try {
+ response = await queuePrompt.apply(api, [...arguments]);
+ } catch (e) {
+ const promptExecution = that.getOrMakePrompt("error");
+ promptExecution.error({ exception_type: "Unknown." });
+ // console.log("ERROR QUEUE PROMPT", response, arguments);
+ throw e;
+ }
+ // console.log("QUEUE PROMPT", response, arguments);
+ const promptExecution = that.getOrMakePrompt(response.prompt_id);
+ promptExecution.setPrompt(prompt);
+ if (!that.currentExecution) {
+ that.currentExecution = promptExecution;
+ }
+ that.promptsMap.set(response.prompt_id, promptExecution);
+ that.dispatchEvent(
+ new CustomEvent("queue-prompt", {
+ detail: {
+ prompt: promptExecution,
+ },
+ }),
+ );
+ return response;
+ };
+
+ api.addEventListener("status", (e: CustomEvent) => {
+ // console.log("status", JSON.stringify(e.detail));
+ // Sometimes a status message is fired when the app loades w/o any details.
+ if (!e.detail?.exec_info) return;
+ this.lastQueueRemaining = e.detail.exec_info.queue_remaining;
+ this.dispatchProgressUpdate();
+ });
+
+ api.addEventListener("execution_start", (e: CustomEvent) => {
+ // console.log("execution_start", JSON.stringify(e.detail));
+ if (!this.promptsMap.has(e.detail.prompt_id)) {
+ console.warn("'execution_start' fired before prompt was made.");
+ }
+ const prompt = this.getOrMakePrompt(e.detail.prompt_id);
+ this.currentExecution = prompt;
+ this.dispatchProgressUpdate();
+ });
+
+ api.addEventListener("executing", (e: CustomEvent) => {
+ // console.log("executing", JSON.stringify(e.detail));
+ if (!this.currentExecution) {
+ this.currentExecution = this.getOrMakePrompt("unknown");
+ console.warn("'executing' fired before prompt was made.");
+ }
+ this.currentExecution.executing(e.detail);
+ this.dispatchProgressUpdate();
+ if (e.detail == null) {
+ this.currentExecution = null;
+ }
+ });
+
+ api.addEventListener("progress", (e: CustomEvent) => {
+ // console.log("progress", JSON.stringify(e.detail));
+ if (!this.currentExecution) {
+ this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
+ console.warn("'progress' fired before prompt was made.");
+ }
+ this.currentExecution.executing(e.detail.node, e.detail.value, e.detail.max);
+ this.dispatchProgressUpdate();
+ });
+
+ api.addEventListener("execution_cached", (e: CustomEvent) => {
+ // console.log("execution_cached", JSON.stringify(e.detail));
+ if (!this.currentExecution) {
+ this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
+ console.warn("'execution_cached' fired before prompt was made.");
+ }
+ for (const cached of e.detail.nodes) {
+ this.currentExecution.executing(cached);
+ }
+ this.dispatchProgressUpdate();
+ });
+
+ api.addEventListener("executed", (e: CustomEvent) => {
+ // console.log("executed", JSON.stringify(e.detail));
+ if (!this.currentExecution) {
+ this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
+ console.warn("'executed' fired before prompt was made.");
+ }
+ });
+
+ api.addEventListener("execution_error", (e: CustomEvent) => {
+ // console.log("execution_error", e.detail);
+ if (!this.currentExecution) {
+ this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
+ console.warn("'execution_error' fired before prompt was made.");
+ }
+ this.currentExecution?.error(e.detail);
+ this.dispatchProgressUpdate();
+ });
+ }
+
+ /** A helper method, since we extend/override api.queuePrompt above anyway. */
+ async queuePrompt(prompt: ComfyApiPrompt) {
+ return await api.queuePrompt(-1, prompt);
+ }
+
+ dispatchProgressUpdate() {
+ this.dispatchEvent(
+ new CustomEvent("progress-update", {
+ detail: {
+ queue: this.lastQueueRemaining,
+ prompt: this.currentExecution,
+ },
+ }),
+ );
+ }
+
+ getOrMakePrompt(id: string) {
+ let prompt = this.promptsMap.get(id);
+ if (!prompt) {
+ prompt = new PromptExecution(id);
+ this.promptsMap.set(id, prompt);
+ }
+ return prompt;
+ }
+}
+
+export const SERVICE = new PromptService(api);
diff --git a/custom_nodes/rgthree-comfy/src_web/common/py_parser.ts b/custom_nodes/rgthree-comfy/src_web/common/py_parser.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22d69d281c460082e043fce28626fadbeb110baf
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/py_parser.ts
@@ -0,0 +1,983 @@
+/**
+ * @fileoverview An AST executor using the TreeSitter parser to parse python-like code and execute
+ * in JS. This parser is self-contained and isolated from other parts of the app (like Comfy-UI
+ * specific types, etc). Instead, additional handlers, builtins, and types can be passed into the
+ * pure functions below.
+ */
+import type {Parser, Node as TreeSitterNode, Tree} from "web-tree-sitter";
+
+import {check, deepFreeze} from "./shared_utils.js";
+
+// Hacky memoization because I don't feel like writing a decorator.
+const MEMOIZED = {parser: null as unknown as Parser};
+
+interface Dict extends Object {
+ [k: string]: unknown;
+}
+interface ExecutionContextData extends Object {
+ [k: string]: unknown;
+}
+class ExecuteContext implements ExecutionContextData {
+ [k: string]: unknown;
+
+ constructor(existing: Object = {}) {
+ Object.assign(this, !!window.structuredClone ? structuredClone(existing) : {...existing});
+ }
+}
+class InitialExecuteContext extends ExecuteContext {}
+
+type NodeHandlerArgs = [ExecutionContextData, BuiltInFns];
+type NodeHandler = (node: Node, ...args: NodeHandlerArgs) => Promise;
+
+const TYPE_TO_HANDLER = new Map([
+ ["module", handleChildren],
+ ["expression_statement", handleChildren],
+ ["interpolation", handleInterpolation],
+ ["block", handleChildren], // Block of code, like in a for loop
+
+ ["comment", handleSwallow],
+ ["return_statement", handleReturn],
+
+ ["assignment", handleAssignment],
+ ["named_expression", handleNamedExpression],
+
+ ["identifier", handleIdentifier],
+ ["attribute", handleAttribute],
+ ["subscript", handleSubscript],
+
+ ["call", handleCall],
+ ["argument_list", handleArgumentsList],
+
+ ["for_statement", handleForStatement],
+ ["list_comprehension", handleListComprehension],
+
+ ["comparison_operator", handleComparisonOperator],
+ ["boolean_operator", handleBooleanOperator],
+ ["binary_operator", handleBinaryOperator],
+ ["not_operator", handleNotOperator],
+ ["unary_operator", handleUnaryOperator],
+
+ // Types
+ ["integer", handleNumber],
+ ["float", handleNumber],
+ ["string", handleString],
+ ["tuple", handleList],
+ ["list", handleList],
+ ["dictionary", handleDictionary],
+ ["pair", handleDictionaryPair],
+ ["true", async (...args: any[]) => true],
+ ["false", async (...args: any[]) => false],
+]);
+
+type BuiltInFn = {fn: Function};
+type BuiltInFns = {[key: string]: BuiltInFn};
+
+const DEFAULT_BUILT_INS: BuiltInFns = {
+ round: {fn: (n: any) => Math.round(Number(n))},
+ ceil: {fn: (n: any) => Math.ceil(Number(n))},
+ floor: {fn: (n: any) => Math.floor(Number(n))},
+ // Function(name="sqrt", call=math.sqrt, args=(1, 1)),
+ // Function(name="min", call=min, args=(2, None)),
+ // Function(name="max", call=max, args=(2, None)),
+ // Function(name=".random_int", call=random.randint, args=(2, 2)),
+ // Function(name=".random_choice", call=random.choice, args=(1, 1)),
+ // Function(name=".random_seed", call=random.seed, args=(1, 1)),
+ // Function(name="re", call=re.compile, args=(1, 1)),
+ len: {fn: (n: any) => n?.__len__?.() ?? n?.length},
+ // Function(name="enumerate", call=enumerate, args=(1, 1)),
+ // Function(name="range", call=range, args=(1, 3)),
+
+ // Types
+ int: {fn: (n: any) => Math.floor(Number(n))},
+ float: {fn: (n: any) => Number(n)},
+ str: {fn: (n: any) => String(n)},
+ bool: {fn: (n: any) => !!n},
+ list: {fn: (tupl: any[] = []) => new PyList(tupl)},
+ tuple: {fn: (list: any[] = []) => new PyTuple(list)},
+ dict: {fn: (dict: Dict = {}) => new PyDict(dict)},
+
+ // Special
+ dir: {fn: (...args: any[]) => console.dir(...__unwrap__(...args))},
+ print: {fn: (...args: any[]) => console.log(...__unwrap__(...args))},
+ log: {fn: (...args: any[]) => console.log(...__unwrap__(...args))},
+};
+
+/**
+ * The main entry point to parse code.
+ */
+export async function execute(
+ code: string,
+ ctx: ExecutionContextData,
+ additionalBuiltins?: BuiltInFns,
+) {
+ const builtIns = deepFreeze({...DEFAULT_BUILT_INS, ...(additionalBuiltins ?? {})});
+ // When we start the execution, we create an InitialExecuteContext as an instance so we can check
+ // if we're the initial, global context during execution (as we may pass in a new context in the
+ // like if evaluating a list comprehension, or setting on an object).
+ ctx = new InitialExecuteContext(ctx);
+
+ const root = (await parse(code)).rootNode;
+ const value = await handleNode(new Node(root), ctx, builtIns);
+
+ console.log("=====");
+ console.log(`value`, value?.__unwrap__?.() ?? value);
+ console.log("context", ctx);
+
+ return value;
+}
+
+/**
+ * Parses a code string to a `Tree`.
+ */
+async function parse(code: string): Promise {
+ if (!MEMOIZED.parser) {
+ // @ts-ignore - Path is rewritten.
+ const TreeSitter = (await import("rgthree/lib/tree-sitter.js")) as TreeSitter;
+ await TreeSitter.Parser.init();
+ const lang = await TreeSitter.Language.load("rgthree/lib/tree-sitter-python.wasm");
+ MEMOIZED.parser = new TreeSitter.Parser() as Parser;
+ MEMOIZED.parser.setLanguage(lang);
+ }
+ return MEMOIZED.parser.parse(code)!;
+}
+
+/**
+ * The generic node handler, calls out to specific handlers based on the node type. This is
+ * recursively called from other handlers.
+ */
+async function handleNode(
+ node: Node,
+ ctx: ExecutionContextData,
+ builtIns: BuiltInFns,
+): Promise {
+ const type = node.type as string;
+
+ // If we have a returned value, then just return it, which should recursively settle.
+ if (ctx.hasOwnProperty("__returned__")) return ctx["__returned__"];
+
+ // console.log(`-----`);
+ // console.log(`eval_node`);
+ // console.log(`type: ${type}`);
+ // console.log(`text: ${node.text}`);
+ // console.log(`children: ${node.children?.length ?? 0}`);
+ // console.log(ctx);
+ // console.log(node);
+
+ const handler = TYPE_TO_HANDLER.get(type);
+ check(handler, "Unhandled type: " + type, node);
+ return handler(node, ctx, builtIns);
+}
+
+/**
+ * Generic handler to loop over children of a node, and evaluate each.
+ */
+async function handleChildren(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ let lastValue = null;
+ for (const child of node.children) {
+ if (!child) continue;
+ lastValue = await handleNode(child, ctx, builtIns);
+ }
+ return lastValue;
+}
+
+/**
+ * Swallows the execution. Likely just to allow development.
+ */
+async function handleSwallow(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ // No op
+}
+
+/**
+ * Handles a return statement.
+ */
+async function handleReturn(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const value = node.children.length > 1 ? handleNode(node.child(1), ctx, builtIns) : undefined;
+ // Mark that we have a return value, as we may be deeper in evaluation, like going through an
+ // if condition's body.
+ ctx["__returned__"] = value;
+ return value;
+}
+
+/**
+ * Handles the retrieval of a variable identifier, already be set in the context.
+ */
+async function handleIdentifier(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ let value = ctx[node.text];
+ if (value === undefined) {
+ value = builtIns[node.text]?.fn ?? undefined;
+ }
+ return maybeWrapValue(value);
+}
+
+async function handleAttribute(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const children = node.children;
+ check(children.length === 3, "Expected 3 children for attribute.");
+ check(children[1]!.type === ".", "Expected middle child to be '.' for attribute.");
+ const inst = await handleNode(children[0]!, ctx, builtIns);
+ // const attr = await handleNode(node.child(2), inst);
+ // console.log('handleAttribute', inst, attr);
+ const attr = children[2]!.text;
+ checkAttributeAccessibility(inst, attr);
+ let attribute = maybeWrapValue(inst[attr]);
+ // check(attribute !== undefined, `"${attr}" not found on instance of type ${typeof inst}.`);
+ // If the attribute is a function, then bind it to the instance.
+ return typeof attribute === "function" ? attribute.bind(inst) : attribute;
+}
+
+async function handleSubscript(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const children = node.children;
+ check(children.length === 4, "Expected 4 children for subscript.");
+ check(children[1]!.type === "[", "Expected 2nd child to be '[' for subscript.");
+ check(children[3]!.type === "]", "Expected 4thd child to be ']' for subscript.");
+ const inst = await handleNode(children[0]!, ctx, builtIns);
+ const attr = await handleNode(children[2]!, ctx, builtIns);
+ if (inst instanceof PyTuple && isInt(attr)) {
+ return maybeWrapValue(inst.__at__(attr));
+ }
+ if (inst instanceof PyDict && typeof attr === "string") {
+ return maybeWrapValue(inst.get(attr));
+ }
+ checkAttributeAccessibility(inst, attr);
+ let attribute = maybeWrapValue(inst[attr]);
+ return typeof attribute === "function" ? attribute.bind(inst) : attribute;
+}
+
+/**
+ * Handles the assignment.
+ */
+async function handleAssignment(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ check(
+ node.children.length === 3,
+ "Expected 3 children for assignment: identifier/attr, =, and value.",
+ );
+ check(node.children[1]!.type === "=", "Expected middle child to be an '='.");
+
+ let right = await handleNode(node.children[2]!, ctx, builtIns);
+ const leftNode = node.children[0]!;
+ let leftObj: any = ctx;
+ let leftProp: string | number = "";
+ if (leftNode.type === "identifier") {
+ leftProp = leftNode.text;
+ } else if (leftNode.type === "attribute") {
+ leftObj = await handleNode(leftNode.children[0]!, ctx, builtIns);
+ check(
+ leftNode.children[2]!.type === "identifier",
+ "Expected left hand assignment attribute to be an identifier.",
+ leftNode,
+ );
+ leftProp = leftNode.children[2]!.text;
+ } else if (leftNode.type === "subscript") {
+ leftObj = await handleNode(leftNode.children[0]!, ctx, builtIns);
+ check(leftNode.children[1]!.type === "[");
+ check(leftNode.children[3]!.type === "]");
+ leftProp = await handleNode(leftNode.children[2]!, ctx, builtIns);
+ } else {
+ throw new Error(`Unhandled left-hand assignement type: ${leftNode.type}`);
+ }
+
+ if (leftProp == null) {
+ throw new Error(`No property to assign value`);
+ }
+ // If we're a PyTuple or extended from, then try add like a list (PyTuple will fail, PyList will
+ // allow).
+ if (leftObj instanceof PyTuple) {
+ check(isInt(leftProp), "Expected an int for list assignment");
+ leftObj.__put__(leftProp, right);
+ } else if (leftObj instanceof PyDict) {
+ check(typeof leftProp === "string", "Expected a string for dict assignment");
+ leftObj.__put__(leftProp, right);
+ } else {
+ check(typeof leftProp === "string", "Expected a string for object assignment");
+ // InitialExecutionContext can have anything added, otherwise we're a specific context and
+ // should check for attribute accessibility.
+ if (!(leftObj instanceof InitialExecuteContext)) {
+ checkAttributeAccessibility(leftObj, leftProp);
+ }
+ leftObj[leftProp] = right;
+ }
+ return right;
+}
+
+/**
+ * Handles a named expression, like assigning a var in a list comprehension with:
+ * `[name for node in node_list if (name := node.name)]`
+ */
+async function handleNamedExpression(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ check(node.children.length === 3, "Expected three children for named expression.");
+ check(node.child(0).type === "identifier", "Expected identifier first in named expression.");
+ const varName = node.child(0).text;
+ ctx[varName] = await handleNode(node.child(2), ctx, builtIns);
+ return maybeWrapValue(ctx[varName]);
+}
+
+/**
+ * Handles a function call.
+ */
+async function handleCall(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ check(node.children.length === 2, "Expected 2 children for call, identifier and arguments.");
+ const fn = await handleNode(node.children[0]!, ctx, builtIns);
+ const args = await handleNode(node.children[1]!, ctx, builtIns);
+ console.log("handleCall", fn, args);
+ return fn(...args);
+}
+
+async function handleArgumentsList(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const args = (await handleList(node, ctx, builtIns)).__unwrap__(false);
+ return [...args];
+}
+
+/**
+ * Handles a simple for...in loop.
+ */
+async function handleForStatement(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const childs = node.children;
+ check(childs.length === 6);
+ check(childs[4]!.type === ":");
+ check(childs[5]!.type === "block");
+ await helperGetLoopForIn(node, ctx, builtIns, async (forCtx) => {
+ await handleNode(childs[5]!, forCtx, builtIns);
+ });
+}
+
+async function handleListComprehension(
+ node: Node,
+ ctx: ExecutionContextData,
+ builtIns: BuiltInFns,
+) {
+ // Create a new context that we don't want to pollute our outer one.
+ const finalList = new PyList();
+ const newCtx = {...ctx};
+
+ let finalEntryNode;
+ const loopNodes: {forIn: Node; if?: Node}[] = [];
+
+ for (const child of node.children) {
+ if (!child || ["[", "]"].includes(child.type)) continue;
+ if (child.type === "identifier" || child.type === "attribute") {
+ if (finalEntryNode) {
+ throw Error("Already have a list comprehension finalEntryNode.");
+ }
+ finalEntryNode = child;
+ } else if (child.type === "for_in_clause") {
+ loopNodes.push({forIn: child});
+ } else if (child.type === "if_clause") {
+ loopNodes[loopNodes.length - 1]!["if"] = child;
+ }
+ }
+ if (!finalEntryNode) {
+ throw Error("No list comprehension finalEntryNode.");
+ }
+
+ console.log(`handleListComprehension.loopNodes`, loopNodes);
+
+ const handleLoop = async (loopNodes: {forIn: Node; if?: Node}[]) => {
+ const loopNode = loopNodes.shift()!;
+ await helperGetLoopForIn(
+ loopNode.forIn,
+ newCtx,
+ builtIns,
+ async (forCtx) => {
+ if (loopNode.if) {
+ const ifNode = loopNode.if;
+ check(ifNode.children.length === 2, "Expected 2 children for if_clause.");
+ check(ifNode.child(0).text === "if", "Expected first child to be 'if'.");
+ const good = await handleNode(ifNode.child(1), forCtx, builtIns);
+ if (!good) return;
+ }
+ Object.assign(newCtx, forCtx);
+ if (loopNodes.length) {
+ await handleLoop(loopNodes);
+ } else {
+ finalList.append(await handleNode(finalEntryNode, newCtx, builtIns));
+ }
+ },
+ () => ({...newCtx}),
+ );
+ loopNodes.unshift(loopNode);
+ };
+
+ await handleLoop(loopNodes);
+ return finalList;
+}
+
+/**
+ * Handles the identifiers, iterable, and initial looping with context setting. Handles both simple
+ * identifiers (like `for item in items`) or a pattern list (like `for key, val in mydict.items()`).
+ *
+ * @param eachFn The function to call for each iteration. Will be passed the current context with
+ * the identifiers assigned.
+ * @param provideForCtx An optional function that can provide an `ctx`. If not supplied the passed
+ * `ctx` param will be used. This is useful for providing a new ctx to use for cases like an
+ * if condition in a list comprhension where we don't want to add to the current context unless
+ * the condition is met.
+ */
+async function helperGetLoopForIn(
+ node: Node,
+ ctx: ExecutionContextData,
+ builtIns: BuiltInFns,
+ eachFn: (forCtx: ExecutionContextData) => Promise,
+ provideForCtx?: () => ExecutionContextData,
+) {
+ const childs = node.children;
+ check(childs.length >= 3);
+ check(childs[0]!.type === "for");
+ check(
+ ["identifier", "pattern_list"].includes(childs[1]!.type),
+ "Expected identifier for for loop.",
+ );
+ check(childs[2]!.type === "in");
+
+ let identifiers: string[];
+ if (childs[1]!.type === "identifier") {
+ // identifier: for k in my_list
+ identifiers = [childs[1]!.text];
+ } else {
+ // pattern_list: for k,v in my_dict.items()
+ identifiers = childs[1]!.children
+ .map((n) => {
+ if (n.type === ",") return null;
+ check(n.type === "identifier");
+ return node.text;
+ })
+ .filter((n) => n != null);
+ }
+ const iterable = await handleNode(childs[3]!, ctx, builtIns);
+ check(iterable instanceof PyTuple, "Expected for loop instance to be a list/tuple.");
+
+ for (const item of iterable.__unwrap__(false)) {
+ const forCtx = provideForCtx?.() ?? ctx;
+ if (identifiers.length === 1) {
+ forCtx[identifiers[0]!] = item;
+ } else {
+ check(
+ Array.isArray(item) && identifiers.length === item.length,
+ "Expected iterable to be a list, like using dict.items()",
+ );
+ for (let i = 0; i < identifiers.length; i++) {
+ forCtx[identifiers[i]!] = item[i];
+ }
+ }
+ await eachFn(forCtx);
+ }
+}
+
+async function handleNumber(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ return Number(node.text);
+}
+
+async function handleString(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ // check(node.children.length === 3, "Expected 3 children for str (quotes and value).");
+ let str = "";
+ for (const child of node.children) {
+ if (!child || ["string_start", "string_end"].includes(child.type)) continue;
+ if (child.type === "string_content") {
+ str += child.text;
+ } else if (child.type === "interpolation") {
+ check(child.children.length === 3, "Expected interpolation");
+ str += await handleNode(child, ctx, builtIns);
+ }
+ }
+ return str;
+}
+
+async function handleInterpolation(node: Node, ...args: NodeHandlerArgs) {
+ check(node.children.length === 3, "Expected interpolation to be three nodes length.");
+ check(
+ node.children[0]!.type === "{" && node.children[2]!.type === "}",
+ 'Expected interpolation to be wrapped in "{" and "}".',
+ );
+ return await handleNode(node.children[1]!, ...args);
+}
+
+async function handleList(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const list = [];
+ for (const child of node.children) {
+ if (!child || ["(", "[", ",", "]", ")"].includes(child.type)) continue;
+ list.push(await handleNode(child, ctx, builtIns));
+ }
+ if (node.type === "tuple") {
+ return new PyTuple(list);
+ }
+ return new PyList(list);
+}
+
+async function handleComparisonOperator(
+ node: Node,
+ ctx: ExecutionContextData,
+ builtIns: BuiltInFns,
+) {
+ const op = node.child(1).text;
+ const left = await handleNode(node.child(0), ctx, builtIns);
+ const right = await handleNode(node.child(2), ctx, builtIns);
+ if (op === "==") return left === right; // Python '==' is equiv to '===' in JS.
+ if (op === "!=") return left !== right;
+ if (op === ">") return left > right;
+ if (op === ">=") return left >= right;
+ if (op === "<") return left < right;
+ if (op === "<=") return left <= right;
+ if (op === "in") return (right.__unwrap__ ? right.__unwrap__(false) : right).includes(left);
+ throw new Error(`Comparison not handled: "${op}"`);
+}
+async function handleBooleanOperator(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const op = node.child(1).text;
+ const left = await handleNode(node.child(0), ctx, builtIns);
+ // If we're an AND and already false, then don't even evaluate the right.
+ if (!left && op === "and") return left;
+ const right = await handleNode(node.child(2), ctx, builtIns);
+ if (op === "and") return left && right;
+ if (op === "or") return left || right;
+}
+
+async function handleBinaryOperator(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const op = node.child(1).text;
+ const left = await handleNode(node.child(0), ctx, builtIns);
+ const right = await handleNode(node.child(2), ctx, builtIns);
+ if (left.constructor !== right.constructor) {
+ throw new Error(`Can only run ${op} operator on same type.`);
+ }
+ if (op === "+") return left.__add__ ? left.__add__(right) : left + right;
+ if (op === "-") return left - right;
+ if (op === "/") return left / right;
+ if (op === "//") return Math.floor(left / right);
+ if (op === "*") return left * right;
+ if (op === "%") return left % right;
+ if (op === "&") return left & right;
+ if (op === "|") return left | right;
+ if (op === "^") return left ^ right;
+ if (op === "<<") return left << right;
+ if (op === ">>") return left >> right;
+ throw new Error(`Comparison not handled: "${op}"`);
+}
+
+async function handleNotOperator(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ check(node.children.length === 2, "Expected 2 children for not operator.");
+ check(node.child(0).text === "not", "Expected first child to be 'not'.");
+ const value = await handleNode(node.child(1), ctx, builtIns);
+ return !value;
+}
+
+async function handleUnaryOperator(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ check(node.children.length === 2, "Expected 2 children for not operator.");
+ const value = await handleNode(node.child(1), ctx, builtIns);
+ const op = node.child(0).text;
+ if (op === "-") return value * -1;
+ console.warn(`Unhandled unary operator: ${op}`);
+ return value;
+}
+
+async function handleDictionary(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ const dict = new PyDict();
+ for (const child of node.children) {
+ if (!child || ["{", ",", "}"].includes(child.type)) continue;
+ check(child.type === "pair", "Expected a pair type for dict.");
+ const pair = await handleNode(child, ctx, builtIns);
+ dict.__put__(pair[0], pair[1]);
+ }
+ return dict;
+}
+
+async function handleDictionaryPair(node: Node, ctx: ExecutionContextData, builtIns: BuiltInFns) {
+ check(node.children.length === 3, "Expected 3 children for dict pair.");
+ let varName = await handleNode(node.child(0)!, ctx, builtIns);
+ let varValue = await handleNode(node.child(2)!, ctx, builtIns);
+ check(typeof varName === "string", "Expected varname to be string.");
+ return [varName, varValue];
+}
+
+/**
+ * Wraps some common functionality of a TreeSitterNode.
+ */
+class Node {
+ type: string;
+ text: string;
+ children: Node[];
+ private node: TreeSitterNode;
+
+ constructor(node: TreeSitterNode) {
+ this.type = node.type;
+ this.text = node.text;
+ if (this.type === "ERROR") {
+ throw new Error(`Error found in parsing near "${this.text}"`);
+ }
+ this.children = [];
+ for (const child of node.children) {
+ this.children.push(new Node(child!));
+ }
+ this.node = node;
+ }
+
+ child(index: number): Node {
+ const child = this.children[index];
+ if (!child) throw Error(`No child at index ${index}.`);
+ return child;
+ }
+
+ log(tab = "", showNode = false) {
+ console.log(`${tab}--- Node`);
+ console.log(`${tab} type: ${this.type}`);
+ console.log(`${tab} text: ${this.text}`);
+ console.log(`${tab} children:`, this.children);
+ if (showNode) {
+ console.log(`${tab} node:`, this.node);
+ }
+ }
+}
+
+/**
+ * A type that mimics a Python Tuple.
+ */
+export class PyTuple {
+ protected list: any[];
+ constructor(...args: any[]) {
+ if (args.length === 1 && args[0] instanceof PyTuple) {
+ args = args[0].__unwrap__(false);
+ }
+ if (args.length === 1 && Array.isArray(args[0])) {
+ args = [...args[0]];
+ }
+ this.list = [...args];
+ }
+
+ @Exposed count(v: any) {
+ // TODO
+ }
+
+ @Exposed index() {
+ // TODO
+ }
+
+ __at__(index: number) {
+ index = this.__get_relative_index__(index);
+ return this.list[index];
+ }
+
+ __len__() {
+ return this.list.length;
+ }
+
+ __add__(v: any) {
+ if (!(v instanceof PyTuple)) {
+ throw new Error("Can only concatenate tuple to tuple.");
+ }
+ return new PyTuple(this.__unwrap__(false).concat(v.__unwrap__(false)));
+ }
+
+ /** Puts the value to the current, existing index. Not available for Tuple. */
+ __put__(index: number, v: any) {
+ throw new Error("Tuple does not support item assignment");
+ }
+
+ /** Gets the index for the current list, with negative index support. Throws if out of range. */
+ protected __get_relative_index__(index: number) {
+ if (index >= 0) {
+ check(this.list.length > index, `Index ${index} out of range.`);
+ return index;
+ }
+ const relIndex = this.list.length + index;
+ check(relIndex >= 0, `Index ${index} out of range.`);
+ return relIndex;
+ }
+
+ /**
+ * Recursively unwraps the PyTuple returning an Array.
+ */
+ __unwrap__(deep = true) {
+ const l = [...this.list];
+ if (deep) {
+ for (let i = 0; i < l.length; i++) {
+ l[i] = l[i]?.__unwrap__ ? l[i].__unwrap__(deep) : l[i];
+ }
+ }
+ return l;
+ }
+
+ // a = [
+ // "__add__",
+ // "__class__",
+ // "__class_getitem__",
+ // "__contains__",
+ // "__delattr__",
+ // "__dir__",
+ // "__doc__",
+ // "__eq__",
+ // "__format__",
+ // "__ge__",
+ // "__getattribute__",
+ // "__getitem__",
+ // "__getnewargs__",
+ // "__gt__",
+ // "__hash__",
+ // "__init__",
+ // "__init_subclass__",
+ // "__iter__",
+ // "__le__",
+ // "__len__",
+ // "__lt__",
+ // "__mul__",
+ // "__ne__",
+ // "__new__",
+ // "__reduce__",
+ // "__reduce_ex__",
+ // "__repr__",
+ // "__rmul__",
+ // "__setattr__",
+ // "__sizeof__",
+ // "__str__",
+ // "__subclasshook__",
+ // "count",
+ // "index",
+ // ];
+}
+
+/**
+ * A type that mimics a Python List.
+ */
+export class PyList extends PyTuple {
+ @Exposed append(...args: any[]) {
+ this.list.push(...args);
+ }
+
+ @Exposed clear() {
+ this.list.length = 0;
+ }
+
+ @Exposed copy() {
+ // TODO
+ }
+
+ @Exposed override count() {
+ // TODO
+ }
+ @Exposed extend() {
+ // TODO
+ }
+ @Exposed override index() {
+ // TODO
+ }
+ @Exposed insert() {
+ // TODO
+ }
+ @Exposed pop() {
+ // TODO
+ }
+ @Exposed remove() {
+ // TODO
+ }
+ @Exposed reverse() {
+ // TODO
+ }
+ @Exposed sort() {
+ // TODO
+ }
+
+ override __add__(v: any) {
+ if (!(v instanceof PyList)) {
+ throw new Error("Can only concatenate list to list.");
+ }
+ return new PyList(this.__unwrap__(false).concat(v.__unwrap__(false)));
+ }
+
+ /** Assigns an element to the current, existing index. Overriden for support on lists. */
+ override __put__(index: number, v: any) {
+ index = this.__get_relative_index__(index);
+ this.list[index] = v;
+ }
+
+ // aa = [
+ // "__add__",
+ // "__class__",
+ // "__class_getitem__",
+ // "__contains__",
+ // "__delattr__",
+ // "__delitem__",
+ // "__dir__",
+ // "__doc__",
+ // "__eq__",
+ // "__format__",
+ // "__ge__",
+ // "__getattribute__",
+ // "__getitem__",
+ // "__gt__",
+ // "__hash__",
+ // "__iadd__",
+ // "__imul__",
+ // "__init__",
+ // "__init_subclass__",
+ // "__iter__",
+ // "__le__",
+ // "__len__",
+ // "__lt__",
+ // "__mul__",
+ // "__ne__",
+ // "__new__",
+ // "__reduce__",
+ // "__reduce_ex__",
+ // "__repr__",
+ // "__reversed__",
+ // "__rmul__",
+ // "__setattr__",
+ // "__setitem__",
+ // "__sizeof__",
+ // "__str__",
+ // "__subclasshook__",
+ // ];
+}
+
+class PyInt {}
+
+class PyDict {
+ #dict: {[key: string]: any};
+ constructor(dict?: {[key: string]: any}) {
+ this.#dict = {...(dict ?? {})};
+ }
+
+ @Exposed clear() {} // Removes all the elements from the dictionary
+ @Exposed copy() {} // Returns a copy of the dictionary
+ @Exposed fromkeys() {} // Returns a dictionary with the specified keys and value
+ /** Returns the value of the specified key. */
+ @Exposed get(key: string) {
+ return this.#dict[key];
+ }
+ /** Returns a list containing a tuple for each key value pair. */
+ @Exposed items() {
+ return new PyTuple(Object.entries(this.#dict).map((e) => new PyTuple(e)));
+ }
+ @Exposed keys() {} // Returns a list containing the dictionary's keys
+ @Exposed pop() {} // Removes the element with the specified key
+ @Exposed popitem() {} // Removes the last inserted key-value pair
+ @Exposed setdefault() {} // Returns the value of the specified key. If the key does not exist: insert the key, with the specified value
+ @Exposed update() {} // Updates the dictionary with the specified key-value pairs
+ @Exposed values() {} // Returns a list of all the values in the dictionary
+
+ __put__(key: string, v: any) {
+ this.#dict[key] = v;
+ }
+
+ __len__() {
+ return Object.keys(this.#dict).length;
+ }
+
+ // a = [
+ // "__class__",
+ // "__class_getitem__",
+ // "__contains__",
+ // "__delattr__",
+ // "__delitem__",
+ // "__dir__",
+ // "__doc__",
+ // "__eq__",
+ // "__format__",
+ // "__ge__",
+ // "__getattribute__",
+ // "__getitem__",
+ // "__gt__",
+ // "__hash__",
+ // "__init__",
+ // "__init_subclass__",
+ // "__ior__",
+ // "__iter__",
+ // "__le__",
+ // "__lt__",
+ // "__ne__",
+ // "__new__",
+ // "__or__",
+ // "__reduce__",
+ // "__reduce_ex__",
+ // "__repr__",
+ // "__reversed__",
+ // "__ror__",
+ // "__setattr__",
+ // "__setitem__",
+ // "__sizeof__",
+ // "__str__",
+ // "__subclasshook__",
+ // ];
+
+ /**
+ * Recursively unwraps the PyDict returning an Object.
+ */
+ __unwrap__(deep = true) {
+ const d = {...this.#dict};
+ if (deep) {
+ for (let k of Object.keys(d)) {
+ d[k] = d[k]?.__unwrap__ ? d[k].__unwrap__(deep) : d[k];
+ }
+ }
+ return d;
+ }
+}
+
+/**
+ * Deeply unwraps a list of values.
+ */
+function __unwrap__(...args: any[]) {
+ for (let i = 0; i < args.length; i++) {
+ args[i] = args[i]?.__unwrap__ ? args[i].__unwrap__(true) : args[i];
+ }
+ return args;
+}
+
+/**
+ * Checks if access to the attribute/method is allowed.
+ */
+function checkAttributeAccessibility(inst: any, attr: string) {
+ const instType = typeof inst;
+ check(
+ instType === "object" || instType === "function",
+ `Instance of type ${instType} does not have attributes.`,
+ );
+
+ // If the attr starts and ends with a "__" then consider it unaccessible.
+ check(!attr.startsWith("__") && !attr.endsWith("__"), `"${attr}" is not accessible.`);
+
+ const attrType = typeof inst[attr];
+ if (attrType === "function") {
+ const allowedMethods = inst.constructor?.__ALLOWED_METHODS__ ?? inst.__ALLOWED_METHODS__ ?? [];
+ check(allowedMethods.includes(attr), `Method ${attr} is not accessible.`);
+ } else {
+ const allowedProps =
+ inst.constructor?.__ALLOWED_PROPERTIES__ ?? inst.__ALLOWED_PROPERTIES__ ?? [];
+ check(allowedProps.includes(attr), `Property ${attr} is not accessible.`);
+ }
+}
+
+function maybeWrapValue(value: any) {
+ if (Array.isArray(value)) {
+ return new PyList(value);
+ }
+ return value;
+}
+
+function isInt(value: any): value is number {
+ return typeof value === "number" && Math.round(value) === value;
+}
+
+function isIntLike(value: any): boolean {
+ let is = isInt(value);
+ if (!is) {
+ is = typeof value === "string" && !!/^\d+$/.exec(value);
+ }
+ return is;
+}
+
+/**
+ * An experimental decorator to add allowed properties and methods to an instance. Decorated
+ * properties and methods on a class, and they'll be added to a static __ALLOWED_PROPERTIES__ and
+ * __ALLOWED_METHODS__ lists, which can then be checked while parsing to ensure entered code
+ * cannot end up calling something more.
+ *
+ * Note: The decorator does no work on static members; only on instance properties, methods, and
+ * getters (or setters). If you wish to allow access to only a getter and not setter, then you'll
+ * need not define the setter (or vice-versa), as adding `@Exposed` to a getter/setter decorates
+ * the property entirely, not just that individual getter/setter.
+ */
+export function Exposed(target: any, key: string) {
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
+ if (typeof descriptor?.value === "function") {
+ target.constructor.__ALLOWED_METHODS__ = target.constructor.__ALLOWED_METHODS__ || [];
+ target.constructor.__ALLOWED_METHODS__.push(key);
+ } else {
+ target.constructor.__ALLOWED_PROPERTIES__ = target.constructor.__ALLOWED_PROPERTIES__ || [];
+ target.constructor.__ALLOWED_PROPERTIES__.push(key);
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/rgthree_api.ts b/custom_nodes/rgthree-comfy/src_web/common/rgthree_api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c9e36779930e09cac8bd5624adc91faa8679ec4b
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/rgthree_api.ts
@@ -0,0 +1,209 @@
+import type {RgthreeModelInfo} from "typings/rgthree.js";
+
+export type ModelInfoType = "loras" | "checkpoints";
+
+type ModelsOptions = {
+ type: ModelInfoType;
+ files?: string[];
+};
+
+type GetModelsOptions = ModelsOptions & {
+ type: ModelInfoType;
+ files?: string[];
+ format?: null | "details";
+};
+
+type GetModelsInfoOptions = GetModelsOptions & {
+ light?: boolean;
+};
+
+type GetModelsResponseDetails = {
+ file: string;
+ modified: number;
+ has_info: boolean;
+ image?: string;
+};
+
+class RgthreeApi {
+ private baseUrl!: string;
+ private comfyBaseUrl!: string;
+ getCheckpointsPromise: Promise | null = null;
+ getSamplersPromise: Promise | null = null;
+ getSchedulersPromise: Promise | null = null;
+ getLorasPromise: Promise | null = null;
+ getWorkflowsPromise: Promise | null = null;
+
+ constructor(baseUrl?: string) {
+ this.setBaseUrl(baseUrl);
+ }
+
+ setBaseUrl(baseUrlArg?: string) {
+ let baseUrl = null;
+ if (baseUrlArg) {
+ baseUrl = baseUrlArg;
+ } else if (window.location.pathname.includes("/rgthree/")) {
+ // Try to find how many relatives paths we need to go back to hit ./rgthree/api
+ const parts = window.location.pathname.split("/rgthree/")[1]?.split("/");
+ if (parts && parts.length) {
+ baseUrl = parts.map(() => "../").join("") + "rgthree/api";
+ }
+ }
+ this.baseUrl = baseUrl || "./rgthree/api";
+
+ // Calculate the comfyUI api base path by checkin gif we're on an rgthree independant page (as
+ // we'll always use '/rgthree/' prefix) and, if so, assume the path before `/rgthree/` is the
+ // base path. If we're not, then just use the same pathname logic as the ComfyUI api.js uses.
+ const comfyBasePathname = location.pathname.includes("/rgthree/")
+ ? location.pathname.split("rgthree/")[0]!
+ : location.pathname;
+ this.comfyBaseUrl = comfyBasePathname.split("/").slice(0, -1).join("/");
+ }
+
+ apiURL(route: string) {
+ return `${this.baseUrl}${route}`;
+ }
+
+ fetchApi(route: string, options?: RequestInit) {
+ return fetch(this.apiURL(route), options);
+ }
+
+ async fetchJson(route: string, options?: RequestInit) {
+ const r = await this.fetchApi(route, options);
+ return await r.json();
+ }
+
+ async postJson(route: string, json: any) {
+ const body = new FormData();
+ body.append("json", JSON.stringify(json));
+ return await rgthreeApi.fetchJson(route, {method: "POST", body});
+ }
+
+ getLoras(force = false) {
+ if (!this.getLorasPromise || force) {
+ this.getLorasPromise = this.fetchJson("/loras?format=details", {cache: "no-store"});
+ }
+ return this.getLorasPromise;
+ }
+
+ async fetchApiJsonOrNull(route: string, options?: RequestInit) {
+ const response = await this.fetchJson(route, options);
+ if (response.status === 200 && response.data) {
+ return (response.data as T) || null;
+ }
+ return null;
+ }
+
+ /**
+ * Fetches the lora information.
+ *
+ * @param light Whether or not to generate a json file if there isn't one. This isn't necessary if
+ * we're just checking for values, but is more necessary when opening an info dialog.
+ */
+
+ async getModelsInfo(options: GetModelsInfoOptions): Promise {
+ const params = new URLSearchParams();
+ if (options.files?.length) {
+ params.set("files", options.files.join(","));
+ }
+ if (options.light) {
+ params.set("light", "1");
+ }
+ if (options.format) {
+ params.set("format", options.format);
+ }
+ const path = `/${options.type}/info?` + params.toString();
+ return (await this.fetchApiJsonOrNull(path)) || [];
+ }
+ async getLorasInfo(options: Omit = {}) {
+ return this.getModelsInfo({type: "loras", ...options});
+ }
+ async getCheckpointsInfo(options: Omit = {}) {
+ return this.getModelsInfo({type: "checkpoints", ...options});
+ }
+
+ async refreshModelsInfo(options: ModelsOptions) {
+ const params = new URLSearchParams();
+ if (options.files?.length) {
+ params.set("files", options.files.join(","));
+ }
+ const path = `/${options.type}/info/refresh?` + params.toString();
+ const infos = await this.fetchApiJsonOrNull(path);
+ return infos;
+ }
+ async refreshLorasInfo(options: Omit = {}) {
+ return this.refreshModelsInfo({type: "loras", ...options});
+ }
+ async refreshCheckpointsInfo(options: Omit = {}) {
+ return this.refreshModelsInfo({type: "checkpoints", ...options});
+ }
+
+ async clearModelsInfo(options: ModelsOptions) {
+ const params = new URLSearchParams();
+ if (options.files?.length) {
+ // encodeURIComponent ?
+ params.set("files", options.files.join(","));
+ }
+ const path = `/${options.type}/info/clear?` + params.toString();
+ await this.fetchApiJsonOrNull(path);
+ return;
+ }
+ async clearLorasInfo(options: Omit = {}) {
+ return this.clearModelsInfo({type: "loras", ...options});
+ }
+ async clearCheckpointsInfo(options: Omit = {}) {
+ return this.clearModelsInfo({type: "checkpoints", ...options});
+ }
+
+ /**
+ * Saves partial data sending it to the backend..
+ */
+ async saveModelInfo(
+ type: ModelInfoType,
+ file: string,
+ data: Partial,
+ ): Promise {
+ const body = new FormData();
+ body.append("json", JSON.stringify(data));
+ return await this.fetchApiJsonOrNull(
+ `/${type}/info?file=${encodeURIComponent(file)}`,
+ {cache: "no-store", method: "POST", body},
+ );
+ }
+
+ async saveLoraInfo(
+ file: string,
+ data: Partial,
+ ): Promise {
+ return this.saveModelInfo("loras", file, data);
+ }
+
+ async saveCheckpointsInfo(
+ file: string,
+ data: Partial,
+ ): Promise {
+ return this.saveModelInfo("checkpoints", file, data);
+ }
+
+ /**
+ * [🤮] Fetches from the ComfyUI given a similar functionality to the real ComfyUI API
+ * implementation, but can be available on independant pages outside of the ComfyUI UI. This is
+ * because ComfyUI frontend stopped serving its modules independantly and opted for a giant bundle
+ * instead which no longer allows us to load its `api.js` file separately.
+ */
+ fetchComfyApi(route: string, options?: any): Promise {
+ const url = this.comfyBaseUrl + "/api" + route;
+ options = options || {};
+ options.headers = options.headers || {};
+ options.cache = options.cache || "no-cache";
+ return fetch(url, options);
+ }
+
+ /**
+ * A way to log to the terminal from JS.
+ */
+ print(messageType: string) {
+ this.fetchApi(`/print?type=${messageType}`, {})
+ }
+}
+
+export const rgthreeApi = new RgthreeApi();
diff --git a/custom_nodes/rgthree-comfy/src_web/common/shared_utils.ts b/custom_nodes/rgthree-comfy/src_web/common/shared_utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4adea7ed1f191f1fc5dfe267b9b98e49323b39f9
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/shared_utils.ts
@@ -0,0 +1,581 @@
+/**
+ * @fileoverview
+ * A bunch of shared utils that can be used in ComfyUI, as well as in any single-HTML pages.
+ */
+
+export type Resolver = {
+ id: string;
+ completed: boolean;
+ resolved: boolean;
+ rejected: boolean;
+ promise: Promise;
+ resolve: (data: T) => void;
+ reject: (e?: Error) => void;
+ timeout: number | null;
+ deferment?: {data?: any; timeout?: number | null; signal?: string};
+};
+
+/**
+ * Returns a new `Resolver` type that allows creating a "disconnected" `Promise` that can be
+ * returned and resolved separately.
+ */
+export function getResolver(timeout: number = 5000): Resolver {
+ const resolver: Partial> = {};
+ resolver.id = generateId(8);
+ resolver.completed = false;
+ resolver.resolved = false;
+ resolver.rejected = false;
+ resolver.promise = new Promise((resolve, reject) => {
+ resolver.reject = (e?: Error) => {
+ resolver.completed = true;
+ resolver.rejected = true;
+ reject(e);
+ };
+ resolver.resolve = (data: T) => {
+ resolver.completed = true;
+ resolver.resolved = true;
+ resolve(data);
+ };
+ });
+ resolver.timeout = setTimeout(() => {
+ if (!resolver.completed) {
+ resolver.reject!();
+ }
+ }, timeout);
+ return resolver as Resolver;
+}
+
+/** The WeakMap for debounced functions. */
+const DEBOUNCE_FN_TO_PROMISE: WeakMap> = new WeakMap();
+
+/**
+ * Debounces a function call so it is only called once in the initially provided ms even if asked
+ * to be called multiple times within that period.
+ */
+export function debounce(fn: Function, ms = 64) {
+ if (!DEBOUNCE_FN_TO_PROMISE.get(fn)) {
+ DEBOUNCE_FN_TO_PROMISE.set(
+ fn,
+ wait(ms).then(() => {
+ DEBOUNCE_FN_TO_PROMISE.delete(fn);
+ fn();
+ }),
+ );
+ }
+ return DEBOUNCE_FN_TO_PROMISE.get(fn);
+}
+
+/** Checks that a value is not falsy. */
+export function check(value: any, msg = "", ...args: any[]): asserts value {
+ if (!value) {
+ console.error(msg, ...(args || []));
+ throw new Error(msg || "Error");
+ }
+}
+
+/** Waits a certain number of ms, as a `Promise.` */
+export function wait(ms = 16): Promise {
+ // Special logic, if we're waiting 16ms, then trigger on next frame.
+ if (ms === 16) {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+ }
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, ms);
+ });
+}
+
+/** Deeply freezes the passed in object. */
+export function deepFreeze(obj: T): T {
+ // Retrieve the property names defined on object
+ const propNames = Reflect.ownKeys(obj);
+
+ // Freeze properties before freezing self
+ for (const name of propNames) {
+ const value = (obj as any)[name];
+ if ((value && typeof value === "object") || typeof value === "function") {
+ deepFreeze(value);
+ }
+ }
+ return Object.freeze(obj);
+}
+
+function dec2hex(dec: number) {
+ return dec.toString(16).padStart(2, "0");
+}
+
+/** Generates an unique id of a specific length. */
+export function generateId(length: number) {
+ const arr = new Uint8Array(length / 2);
+ crypto.getRandomValues(arr);
+ return Array.from(arr, dec2hex).join("");
+}
+
+/**
+ * Returns the deep value of an object given a dot-delimited key.
+ */
+export function getObjectValue(obj: {[key: string]: any}, objKey: string, def?: any) {
+ if (!obj || !objKey) return def;
+
+ const keys = objKey.split(".");
+ const key = keys.shift()!;
+ const found = obj[key];
+ if (keys.length) {
+ return getObjectValue(found, keys.join("."), def);
+ }
+ return found;
+}
+
+/**
+ * Sets the deep value of an object given a dot-delimited key.
+ *
+ * By default, missing objects will be created while settng the path. If `createMissingObjects` is
+ * set to false, then the setting will be abandoned if the key path is missing an intermediate
+ * value. For example:
+ *
+ * setObjectValue({a: {z: false}}, 'a.b.c', true); // {a: {z: false, b: {c: true } } }
+ * setObjectValue({a: {z: false}}, 'a.b.c', true, false); // {a: {z: false}}
+ *
+ */
+export function setObjectValue(obj: any, objKey: string, value: any, createMissingObjects = true) {
+ if (!obj || !objKey) return obj;
+
+ const keys = objKey.split(".");
+ const key = keys.shift()!;
+ if (obj[key] === undefined) {
+ if (!createMissingObjects) {
+ return;
+ }
+ obj[key] = {};
+ }
+ if (!keys.length) {
+ obj[key] = value;
+ } else {
+ if (typeof obj[key] != "object") {
+ obj[key] = {};
+ }
+ setObjectValue(obj[key], keys.join("."), value, createMissingObjects);
+ }
+ return obj;
+}
+
+/**
+ * Moves an item in an array (by item or its index) to another index.
+ */
+export function moveArrayItem(arr: any[], itemOrFrom: any, to: number) {
+ const from = typeof itemOrFrom === "number" ? itemOrFrom : arr.indexOf(itemOrFrom);
+ arr.splice(to, 0, arr.splice(from, 1)[0]!);
+}
+
+/**
+ * Moves an item in an array (by item or its index) to another index.
+ */
+export function removeArrayItem(arr: T[], itemOrIndex: T | number) {
+ const index = typeof itemOrIndex === "number" ? itemOrIndex : arr.indexOf(itemOrIndex);
+ arr.splice(index, 1);
+}
+
+/**
+ * Injects CSS into the page with a promise when complete.
+ */
+export function injectCss(href: string): Promise {
+ if (document.querySelector(`link[href^="${href}"]`)) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ const link = document.createElement("link");
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("type", "text/css");
+ const timeout = setTimeout(resolve, 1000);
+ link.addEventListener("load", (e) => {
+ clearInterval(timeout);
+ resolve();
+ });
+ link.href = href;
+ document.head.appendChild(link);
+ });
+}
+
+/**
+ * Calls `Object.defineProperty` with special care around getters and setters to call out to a
+ * parent getter or setter (like a super.set call) to ensure any side effects up the chain
+ * are still invoked.
+ */
+export function defineProperty(instance: any, property: string, desc: PropertyDescriptor) {
+ const existingDesc = Object.getOwnPropertyDescriptor(instance, property);
+ if (existingDesc?.configurable === false) {
+ throw new Error(`Error: rgthree-comfy cannot define un-configurable property "${property}"`);
+ }
+
+ if (existingDesc?.get && desc.get) {
+ const descGet = desc.get;
+ desc.get = () => {
+ existingDesc.get!.apply(instance, []);
+ return descGet!.apply(instance, []);
+ };
+ }
+ if (existingDesc?.set && desc.set) {
+ const descSet = desc.set;
+ desc.set = (v: any) => {
+ existingDesc.set!.apply(instance, [v]);
+ return descSet!.apply(instance, [v]);
+ };
+ }
+
+ desc.enumerable = desc.enumerable ?? existingDesc?.enumerable ?? true;
+ desc.configurable = desc.configurable ?? existingDesc?.configurable ?? true;
+ if (!desc.get && !desc.set) {
+ desc.writable = desc.writable ?? existingDesc?.writable ?? true;
+ }
+ return Object.defineProperty(instance, property, desc);
+}
+
+/**
+ * Determines if two DataViews are equal.
+ */
+export function areDataViewsEqual(a: DataView, b: DataView) {
+ if (a.byteLength !== b.byteLength) {
+ return false;
+ }
+ for (let i = 0; i < a.byteLength; i++) {
+ if (a.getUint8(i) !== b.getUint8(i)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * A cheap check if the source looks like base64.
+ */
+function looksLikeBase64(source: string) {
+ return source.length > 500 || source.startsWith("data:") || source.includes(";base64,");
+}
+
+/**
+ * Determines if two ArrayBuffers are equal.
+ */
+export function areArrayBuffersEqual(a?: ArrayBuffer | null, b?: ArrayBuffer | null) {
+ if (a == b || !a || !b) {
+ return a == b;
+ }
+ return areDataViewsEqual(new DataView(a), new DataView(b));
+}
+
+export function newCanvas(
+ widthOrPtOrImage: number | {width: number; height: number} | HTMLImageElement,
+ height?: number,
+) {
+ let width: number;
+ if (typeof widthOrPtOrImage !== "number") {
+ width = widthOrPtOrImage.width;
+ height = widthOrPtOrImage.height;
+ } else {
+ width = widthOrPtOrImage;
+ height = height;
+ }
+ if (height == null) {
+ throw new Error("Invalid height supplied when creating new canvas object.");
+ }
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ if (widthOrPtOrImage instanceof HTMLImageElement) {
+ const ctx = canvas.getContext("2d")!;
+ ctx.drawImage(widthOrPtOrImage, 0, 0, width, height);
+ }
+ return canvas;
+}
+
+/**
+ * Returns canvas image data for an HTML Image.
+ */
+export function getCanvasImageData(
+ image: HTMLImageElement,
+): [HTMLCanvasElement, CanvasRenderingContext2D, ImageData] {
+ const canvas = newCanvas(image);
+ const ctx = canvas.getContext("2d")!;
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ return [canvas, ctx, imageData];
+}
+
+/** Union of types for image conversion. */
+type ImageConverstionTypes = string | Blob | ArrayBuffer | HTMLImageElement | HTMLCanvasElement;
+
+/**
+ * Converts an ImageConverstionTypes to a base64 string.
+ */
+export async function convertToBase64(
+ source: ImageConverstionTypes | Promise,
+): Promise {
+ if (source instanceof Promise) {
+ source = await source;
+ }
+ if (typeof source === "string" && looksLikeBase64(source)) {
+ return source;
+ }
+ if (typeof source === "string" || source instanceof Blob || source instanceof ArrayBuffer) {
+ return convertToBase64(await loadImage(source));
+ }
+ if (source instanceof HTMLImageElement) {
+ if (looksLikeBase64(source.src)) {
+ return source.src;
+ }
+ const [canvas, ctx, imageData] = getCanvasImageData(source);
+ return convertToBase64(canvas);
+ }
+ if (source instanceof HTMLCanvasElement) {
+ return source.toDataURL("image/png");
+ }
+ throw Error("Unknown source to convert to base64.");
+}
+
+/**
+ * Converts an ImageConverstionTypes to an image array buffer.
+ */
+export async function convertToArrayBuffer(
+ source: ImageConverstionTypes | Promise,
+): Promise {
+ if (source instanceof Promise) {
+ source = await source;
+ }
+ if (source instanceof ArrayBuffer) {
+ return source;
+ }
+ if (typeof source === "string") {
+ if (looksLikeBase64(source)) {
+ var binaryString = atob(source.replace(/^.*?;base64,/, ""));
+ var bytes = new Uint8Array(binaryString.length);
+ for (var i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+ return convertToArrayBuffer(await loadImage(source));
+ }
+ if (source instanceof HTMLImageElement) {
+ const [canvas, ctx, imageData] = getCanvasImageData(source);
+ return convertToArrayBuffer(canvas);
+ }
+ if (source instanceof HTMLCanvasElement) {
+ return convertToArrayBuffer(source.toDataURL());
+ }
+ if (source instanceof Blob) {
+ return source.arrayBuffer();
+ }
+ throw Error("Unknown source to convert to arraybuffer.");
+}
+
+/**
+ * Loads an image into an HTMLImageElement.
+ */
+export async function loadImage(
+ source: ImageConverstionTypes | Promise,
+): Promise {
+ if (source instanceof Promise) {
+ source = await source;
+ }
+ if (source instanceof HTMLImageElement) {
+ return loadImage(source.src);
+ }
+ if (source instanceof Blob) {
+ return loadImage(source.arrayBuffer());
+ }
+ if (source instanceof HTMLCanvasElement) {
+ return loadImage(source.toDataURL());
+ }
+ if (source instanceof ArrayBuffer) {
+ var binary = "";
+ var bytes = new Uint8Array(source);
+ var len = bytes.byteLength;
+ for (var i = 0; i < len; i++) {
+ binary += String.fromCharCode(bytes[i]!);
+ }
+ return loadImage(`data:${getMimeTypeFromArrayBuffer(bytes)};base64,${btoa(binary)}`);
+ }
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.addEventListener("load", () => {
+ resolve(img);
+ });
+ img.addEventListener("error", () => {
+ reject(img);
+ });
+ img.src = source;
+ });
+}
+
+/**
+ * Determines the mime type from an array buffer.
+ */
+function getMimeTypeFromArrayBuffer(buffer: Uint8Array) {
+ const len = 4;
+ if (buffer.length >= len) {
+ let signatureArr = new Array(len);
+ for (let i = 0; i < len; i++) signatureArr[i] = buffer[i]!.toString(16);
+ const signature = signatureArr.join("").toUpperCase();
+ switch (signature) {
+ case "89504E47":
+ return "image/png";
+ case "47494638":
+ return "image/gif";
+ case "25504446":
+ return "application/pdf";
+ case "FFD8FFDB":
+ case "FFD8FFE0":
+ return "image/jpeg";
+ case "504B0304":
+ return "application/zip";
+ default:
+ return null;
+ }
+ }
+ return null;
+}
+
+type BroadcasterMessage = {
+ id: string;
+ replyId?: string;
+ action: string;
+ window: Window;
+ port: MessagePort;
+ payload?: T;
+};
+
+type BroadcasterMessageOptions = {
+ timeout?: number;
+ listenForReply?: boolean;
+};
+
+/**
+ * A Broadcaster is a wrapper around a BroadcastChannel for communication with other windows.
+ */
+export class Broadcaster extends EventTarget {
+ private channel: BroadcastChannel;
+ private queue: {[key: string]: Resolver} = {};
+
+ constructor(channelName: string) {
+ super();
+ this.queue = {};
+ this.channel = new BroadcastChannel(channelName);
+ this.channel.addEventListener("message", (e) => {
+ this.onMessage(e);
+ });
+ }
+
+ /**
+ * Returns a unique id within the queue.
+ */
+ private getId() {
+ let id: string;
+ do {
+ id = generateId(6);
+ } while (this.queue[id]);
+ return id;
+ }
+
+ /**
+ * Broadcasts an action, and waits for a response, with a timeout before cancelling.
+ */
+ async broadcastAndWait(
+ action: string,
+ payload?: OutPayload,
+ options?: BroadcasterMessageOptions,
+ ): Promise {
+ const id = this.getId();
+ this.queue[id] = getResolver(options?.timeout);
+ this.channel.postMessage({
+ id,
+ action,
+ payload,
+ });
+ let response: InPayload[];
+ try {
+ response = await this.queue[id]!.promise;
+ } catch (e) {
+ console.log("CAUGHT", e);
+ response = [];
+ }
+ return response;
+ }
+
+ broadcast(action: string, payload?: OutPayload) {
+ this.channel.postMessage({
+ id: this.getId(),
+ action,
+ payload,
+ });
+ }
+
+ reply(replyId: string, action: string, payload?: OutPayload) {
+ this.channel.postMessage({
+ id: this.getId(),
+ replyId,
+ action,
+ payload,
+ });
+ }
+
+ openWindowAndWaitForMessage(rgthreePath: string, windowName?: string) {
+ const id = this.getId();
+ this.queue[id] = getResolver();
+ const win = window.open(`/rgthree/${rgthreePath}#broadcastLoadMsgId=${id}`, windowName);
+ return {window: win, promise: this.queue[id]!.promise};
+ }
+
+ onMessage(e: MessageEvent>) {
+ const msgId = e.data?.replyId || "";
+ const queueItem = this.queue[msgId];
+ if (queueItem) {
+ if (queueItem.completed) {
+ console.error(`${msgId} already completed..`);
+ }
+ queueItem.deferment = queueItem.deferment || {data: []};
+ queueItem.deferment.data.push(e.data.payload);
+ queueItem.deferment.timeout && clearTimeout(queueItem.deferment.timeout);
+ queueItem.deferment.timeout = setTimeout(() => {
+ queueItem.resolve(queueItem.deferment!.data);
+ }, 250);
+ } else {
+ this.dispatchEvent(
+ new CustomEvent("rgthree-broadcast-message", {
+ detail: Object.assign({replyTo: e.data?.id}, e.data),
+ }),
+ );
+ }
+ }
+
+ addMessageListener(callback: EventListener, options?: any) {
+ return super.addEventListener("rgthree-broadcast-message", callback, options);
+ }
+}
+
+const broadcastChannelMap: Map}> = new Map();
+
+export function broadcastOnChannel(
+ channel: BroadcastChannel,
+ action: string,
+ payload?: T,
+) {
+ let queue = broadcastChannelMap.get(channel);
+ if (!queue) {
+ broadcastChannelMap.set(channel, {});
+ queue = broadcastChannelMap.get(channel)!;
+ }
+ let id: string;
+ do {
+ id = generateId(6);
+ } while (queue[id]);
+ queue[id] = getResolver();
+ channel.postMessage({
+ id,
+ action,
+ payload,
+ });
+ return queue[id]!.promise;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/utils_dom.ts b/custom_nodes/rgthree-comfy/src_web/common/utils_dom.ts
new file mode 100644
index 0000000000000000000000000000000000000000..143b68c54d8ac7e655185de921c002c3762735e1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/utils_dom.ts
@@ -0,0 +1,429 @@
+/**
+ * Various dom manipulation utils that have followed me around.
+ */
+const DIRECT_ATTRIBUTE_MAP: {[name: string]: string} = {
+ cellpadding: "cellPadding",
+ cellspacing: "cellSpacing",
+ colspan: "colSpan",
+ frameborder: "frameBorder",
+ height: "height",
+ maxlength: "maxLength",
+ nonce: "nonce",
+ role: "role",
+ rowspan: "rowSpan",
+ type: "type",
+ usemap: "useMap",
+ valign: "vAlign",
+ width: "width",
+};
+
+const RGX_NUMERIC_STYLE_UNIT = "px";
+const RGX_NUMERIC_STYLE =
+ /^((max|min)?(width|height)|margin|padding|(margin|padding)?(left|top|bottom|right)|fontsize|borderwidth)$/i;
+const RGX_DEFAULT_VALUE_PROP = /input|textarea|select/i;
+
+function localAssertNotFalsy(input?: T | null, errorMsg = `Input is not of type.`): T {
+ if (input == null) {
+ throw new Error(errorMsg);
+ }
+ return input;
+}
+
+const RGX_STRING_VALID = "[a-z0-9_-]";
+const RGX_TAG = new RegExp(`^([a-z]${RGX_STRING_VALID}*)(\\.|\\[|\\#|$)`, "i");
+const RGX_ATTR_ID = new RegExp(`#(${RGX_STRING_VALID}+)`, "gi");
+const RGX_ATTR_CLASS = new RegExp(`(^|\\S)\\.([a-z0-9_\\-\\.]+)`, "gi");
+const RGX_STRING_CONTENT_TO_SQUARES = "(.*?)(\\[|\\])";
+const RGX_ATTRS_MAYBE_OPEN = new RegExp(`\\[${RGX_STRING_CONTENT_TO_SQUARES}`, "gi");
+const RGX_ATTRS_FOLLOW_OPEN = new RegExp(`^${RGX_STRING_CONTENT_TO_SQUARES}`, "gi");
+
+type QueryParent = HTMLElement | Document | DocumentFragment;
+
+export function queryAll(
+ selectors: K,
+ parent?: QueryParent,
+): Array;
+export function queryAll(
+ selectors: K,
+ parent?: QueryParent,
+): Array;
+export function queryAll(
+ selectors: K,
+ parent?: QueryParent,
+): Array;
+export function queryAll(selectors: string, parent?: QueryParent): Array;
+export function queryAll(selectors: string, parent: QueryParent = document) {
+ return Array.from(parent.querySelectorAll(selectors)).filter((n) => !!n);
+}
+
+export function query(
+ selectors: K,
+ parent?: QueryParent,
+): HTMLElementTagNameMap[K] | null;
+export function query(
+ selectors: K,
+ parent?: QueryParent,
+): SVGElementTagNameMap[K] | null;
+export function query(
+ selectors: K,
+ parent?: QueryParent,
+): MathMLElementTagNameMap[K] | null;
+export function query(selectors: string, parent?: QueryParent): T | null;
+export function query(selectors: string, parent: QueryParent = document) {
+ return parent.querySelector(selectors) ?? null;
+}
+
+export function createText(text: string) {
+ return document.createTextNode(text);
+}
+
+export function getClosestOrSelf(
+ element: EventTarget | HTMLElement | null,
+ query: string,
+): HTMLElement | null {
+ const el = element as HTMLElement;
+ return (el?.closest && (((el.matches(query) && el) || el.closest(query)) as HTMLElement)) || null;
+}
+
+export function containsOrSelf(
+ parent: EventTarget | HTMLElement | null,
+ contained: EventTarget | HTMLElement | null,
+): boolean {
+ return (
+ parent === contained || (parent as HTMLElement)?.contains?.(contained as HTMLElement) || false
+ );
+}
+
+type Attrs = {
+ [name: string]: any;
+};
+
+export function createElement(selectorOrMarkup: string, attrs?: Attrs) {
+ const frag = getHtmlFragment(selectorOrMarkup);
+ let element = frag?.firstElementChild as HTMLElement;
+ let selector = "";
+ if (!element) {
+ selector = selectorOrMarkup.replace(/[\r\n]\s*/g, "");
+ const tag = getSelectorTag(selector) || "div";
+ element = document.createElement(tag);
+ selector = selector.replace(RGX_TAG, "$2");
+ const brackets = selector.match(/(\[[^\]]+\])/g) || [];
+ for (const bracket of brackets) {
+ selector = selector.replace(bracket, "");
+ }
+ // Turn id and classname into [attr]s that can be nested
+ selector = selector.replace(RGX_ATTR_ID, '[id="$1"]');
+ selector = selector.replace(
+ RGX_ATTR_CLASS,
+ (match, p1, p2) => `${p1}[class="${p2.replace(/\./g, " ")}"]`,
+ );
+ selector += brackets.join("");
+ }
+
+ const selectorAttrs = getSelectorAttributes(selector);
+ if (selectorAttrs) {
+ for (const attr of selectorAttrs) {
+ let matches = attr.substring(1, attr.length - 1).split("=");
+ let key = localAssertNotFalsy(matches.shift());
+ let value: string = matches.join("=");
+ if (value === undefined) {
+ setAttribute(element, key, true);
+ } else {
+ value = value.replace(/^['"](.*)['"]$/, "$1");
+ setAttribute(element, key, value);
+ }
+ }
+ }
+ if (attrs) {
+ setAttributes(element, attrs);
+ }
+ return element as T;
+}
+export const $el = createElement;
+
+function getSelectorTag(str: string) {
+ return tryMatch(str, RGX_TAG);
+}
+
+function getSelectorAttributes(selector: string) {
+ RGX_ATTRS_MAYBE_OPEN.lastIndex = 0;
+ let attrs: string[] = [];
+ let result;
+ while ((result = RGX_ATTRS_MAYBE_OPEN.exec(selector))) {
+ let attr = result[0];
+ if (attr.endsWith("]")) {
+ attrs.push(attr);
+ } else {
+ attr =
+ result[0] + getOpenAttributesRecursive(selector.substr(RGX_ATTRS_MAYBE_OPEN.lastIndex), 2);
+ RGX_ATTRS_MAYBE_OPEN.lastIndex += attr.length - result[0].length;
+ attrs.push(attr);
+ }
+ }
+ return attrs;
+}
+
+function getOpenAttributesRecursive(selectorSubstring: string, openCount: number) {
+ let matches = selectorSubstring.match(RGX_ATTRS_FOLLOW_OPEN);
+ let result = "";
+ if (matches && matches.length) {
+ result = matches[0];
+ openCount += result.endsWith("]") ? -1 : 1;
+ if (openCount > 0) {
+ result += getOpenAttributesRecursive(selectorSubstring.substr(result.length), openCount);
+ }
+ }
+ return result;
+}
+
+function tryMatch(str: string, rgx: RegExp, index = 1) {
+ let found = "";
+ try {
+ found = str.match(rgx)?.[index] || "";
+ } catch (e) {
+ found = "";
+ }
+ return found;
+}
+
+export function setAttributes(element: HTMLElement, data: {[name: string]: any}) {
+ let attr;
+ for (attr in data) {
+ if (data.hasOwnProperty(attr)) {
+ setAttribute(element, attr, data[attr]);
+ }
+ }
+}
+
+function getHtmlFragment(value: string) {
+ if (value.match(/^\s*<.*?>[\s\S]*<\/[a-z0-9]+>\s*$/)) {
+ return document.createRange().createContextualFragment(value.trim());
+ }
+ return null;
+}
+
+function getChild(value: any): HTMLElement | DocumentFragment | Text | null {
+ if (value instanceof Node) {
+ return value as HTMLElement;
+ }
+ if (typeof value === "string") {
+ let child = getHtmlFragment(value);
+ if (child) {
+ return child;
+ }
+ if (getSelectorTag(value)) {
+ return createElement(value);
+ }
+ return createText(value);
+ }
+ if (value && typeof value.toElement === "function") {
+ return value.toElement() as HTMLElement;
+ }
+ return null;
+}
+
+export function setAttribute(element: HTMLElement, attribute: string, value: any) {
+ let isRemoving = value == null;
+
+ if (attribute === "default") {
+ attribute = RGX_DEFAULT_VALUE_PROP.test(element.nodeName) ? "value" : "text";
+ }
+
+ if (attribute === "text") {
+ empty(element).appendChild(createText(value != null ? String(value) : ""));
+ } else if (attribute === "html") {
+ empty(element).innerHTML += value != null ? String(value) : "";
+ } else if (attribute == "style") {
+ if (typeof value === "string") {
+ element.style.cssText = isRemoving ? "" : value != null ? String(value) : "";
+ } else {
+ for (const [styleKey, styleValue] of Object.entries(value as {[key: string]: any})) {
+ element.style[styleKey as "display"] = styleValue;
+ }
+ }
+ } else if (attribute == "events") {
+ for (const [key, fn] of Object.entries(value as {[key: string]: (e: Event) => void})) {
+ addEvent(element, key, fn);
+ }
+ } else if (attribute === "parent") {
+ value.appendChild(element);
+ } else if (attribute === "child" || attribute === "children") {
+ // Try to handle an array, like [li,li,li]. Not nested brackets, though
+ if (typeof value === "string" && /^\[[^\[\]]+\]$/.test(value)) {
+ const parseable = value.replace(/^\[([^\[\]]+)\]$/, '["$1"]').replace(/,/g, '","');
+ try {
+ const parsed = JSON.parse(parseable);
+ value = parsed;
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ // "children" is a replace of the children, while "child" appends a new child if others exist.
+ if (attribute === "children") {
+ empty(element);
+ }
+
+ let children = value instanceof Array ? value : [value];
+ for (let child of children) {
+ child = getChild(child);
+ if (child instanceof Node) {
+ if (element instanceof HTMLTemplateElement) {
+ element.content.appendChild(child);
+ } else {
+ element.appendChild(child);
+ }
+ }
+ }
+ } else if (attribute == "for") {
+ (element as HTMLLabelElement).htmlFor = value != null ? String(value) : "";
+ if (isRemoving) {
+ // delete (element as HTMLLabelElement).htmlFor;
+ element.removeAttribute("for");
+ }
+ } else if (attribute === "class" || attribute === "className" || attribute === "classes") {
+ element.className = isRemoving ? "" : Array.isArray(value) ? value.join(" ") : String(value);
+ } else if (attribute === "dataset") {
+ if (typeof value !== "object") {
+ console.error("Expecting an object for dataset");
+ return;
+ }
+ for (const [key, val] of Object.entries(value)) {
+ element.dataset[key] = String(val);
+ }
+ } else if (attribute.startsWith("on") && typeof value === "function") {
+ element.addEventListener(attribute.substring(2), value);
+ } else if (["checked", "disabled", "readonly", "required", "selected"].includes(attribute)) {
+ // Could be input, button, etc. We are not discriminate.
+ (element as HTMLInputElement)[attribute as "checked"] = !!value;
+ if (!value) {
+ (element as HTMLInputElement).removeAttribute(attribute);
+ } else {
+ (element as HTMLInputElement).setAttribute(attribute, attribute);
+ }
+ } else if (DIRECT_ATTRIBUTE_MAP.hasOwnProperty(attribute)) {
+ if (isRemoving) {
+ element.removeAttribute(DIRECT_ATTRIBUTE_MAP[attribute]!);
+ } else {
+ element.setAttribute(DIRECT_ATTRIBUTE_MAP[attribute]!, String(value));
+ }
+ } else if (isRemoving) {
+ element.removeAttribute(attribute);
+ } else {
+ let oldVal = element.getAttribute(attribute);
+ if (oldVal !== value) {
+ element.setAttribute(attribute, String(value));
+ }
+ }
+}
+
+function addEvent(element: HTMLElement, key: string, fn: (e: Event) => void) {
+ element.addEventListener(key, fn);
+}
+
+function setStyles(element: HTMLElement, styles: {[name: string]: string | number} | null = null) {
+ if (styles) {
+ for (let name in styles) {
+ setStyle(element, name, styles[name]!);
+ }
+ }
+ return element;
+}
+
+export function setStyle(element: HTMLElement, name: string, value: string | number | null) {
+ // Note: Old IE uses 'styleFloat'
+ name = name.indexOf("float") > -1 ? "cssFloat" : name;
+ // Camelcase
+ if (name.indexOf("-") != -1) {
+ name = name.replace(/-\D/g, (match) => {
+ return match.charAt(1).toUpperCase();
+ });
+ }
+ if (value == String(Number(value)) && RGX_NUMERIC_STYLE.test(name)) {
+ value = value + RGX_NUMERIC_STYLE_UNIT;
+ }
+ if (name === "display" && typeof value !== "string") {
+ value = !!value ? null : "none";
+ }
+ (element.style as any)[name] = value === null ? null : String(value);
+ return element;
+}
+
+export function empty(element: HTMLElement) {
+ while (element.firstChild) {
+ element.removeChild(element.firstChild);
+ }
+ return element;
+}
+
+export function remove(element: HTMLElement) {
+ while (element.parentElement) {
+ element.parentElement.removeChild(element);
+ }
+ return element;
+}
+
+export function replaceChild(oldChildNode: Node, newNode: Node) {
+ oldChildNode.parentNode!.replaceChild(newNode, oldChildNode);
+ return newNode;
+}
+
+type ChildType = HTMLElement | DocumentFragment | Text | string | null;
+export function appendChildren(el: HTMLElement, children: ChildType | ChildType[]) {
+ children = !Array.isArray(children) ? [children] : children;
+ for (let child of children) {
+ child = getChild(child);
+ if (child instanceof Node) {
+ if (el instanceof HTMLTemplateElement) {
+ el.content.appendChild(child);
+ } else {
+ el.appendChild(child);
+ }
+ }
+ }
+}
+
+/**
+ * Returns elements and their actions.
+ *
+ * data-action="click:action-signal"
+ */
+export function getActionEls(parent: Element | Document = document) {
+ const els = Array.from(parent.querySelectorAll("[data-action],[on-action],[on]"));
+ if (parent instanceof Element) {
+ els.unshift(parent);
+ }
+ return els
+ .map((actionEl) => {
+ const actions: {[action: string]: string} = {};
+ const actionSegments = (
+ actionEl.getAttribute("data-action") ||
+ actionEl.getAttribute("on-action") ||
+ actionEl.getAttribute("on") ||
+ ""
+ ).split(";");
+ for (let segment of actionSegments) {
+ let actionsData = segment
+ .trim()
+ .split(/\s*:\s*/g)
+ .filter((i) => !!i.trim()) as [string, string?];
+ if (!actionsData.length) continue;
+ if (actionsData.length === 1) {
+ if (actionEl instanceof HTMLInputElement) {
+ actionsData.unshift("input");
+ } else {
+ actionsData.unshift("click");
+ }
+ }
+ if (actionsData[0] && actionsData[1]) {
+ actions[actionsData[0]] = actionsData[1];
+ // actionEl.addEventListener(actionsData[0], (e) => {this.handleAction(actionsData[1]!, actionEl, e);});
+ }
+ }
+ return {
+ el: actionEl,
+ actions,
+ };
+ })
+ .filter((el) => !!el);
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/utils_templates.ts b/custom_nodes/rgthree-comfy/src_web/common/utils_templates.ts
new file mode 100644
index 0000000000000000000000000000000000000000..897f4289947df703421d0a6fe90d7ad3b8aa3ed5
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/utils_templates.ts
@@ -0,0 +1,879 @@
+import * as dom from "./utils_dom";
+import {getObjectValue} from "./shared_utils";
+
+const CONFIG_DEFAULT = {
+ attrBind: "data-bind",
+ attrIf: "data-if",
+ attrIfIs: "data-if-is",
+};
+
+const CONFIG = Object.assign({}, CONFIG_DEFAULT, {
+ attrBind: "bind",
+ attrIf: "if",
+ attrIfIs: "if-is",
+});
+
+export interface BindOptions {
+ /**
+ * If true then only those data-bind keys in the data map will be bound,
+ * and no `data-bind` fields will be unbound.
+ */
+ onlyDefined?: boolean;
+
+ /** If true, then binding/init will not be called on nested templates. */
+ singleScoped?: boolean;
+
+ /** Context elemnt. */
+ contextElement?: HTMLElement;
+}
+
+export interface InflateOptions {
+ skipInit?: boolean;
+ bindOptions?: BindOptions;
+}
+
+export interface TemplateData {
+ fragment: DocumentFragment;
+ preProcessScript: (data: any) => void;
+}
+
+export interface BindingContext {
+ data: any;
+ contextElement: HTMLElement;
+ currentElement: HTMLElement;
+}
+
+// const RGX_COMPARISON = /^\(?([a-z0-9\.\-\[\]'"]+)((?:<|>|==)=?)([a-z0-9\.\-\[\]'"]+)\)?$/i;
+const RGX_COMPARISON = (() => {
+ // /^\(?([a-z0-9\.\-\[\]'"]+)((?:<|>|==)=?)([a-z0-9\.\-\[\]'"]+)\)?$/i;
+ let value = "((?:\\!*)[_a-z0-9\\.\\-\\[\\]'\"]+)";
+ let comparison = "((?:<|>|==|\\!=)=?)";
+ return new RegExp(`^(?:\\!*)\\(?${value}\\s*${comparison}\\s*${value}\\)?$`, "i");
+})();
+
+const RGXPART_BIND_FN_TEMPLATE_STRING = "template|tpl";
+const RGXPART_BIND_FN_ELEMENT_STRING = "element|el";
+const RGX_BIND_FN_TEMPLATE = new RegExp(
+ `^(?:${RGXPART_BIND_FN_TEMPLATE_STRING})\\(([^\\)]+)\\)`,
+ "i",
+);
+const RGX_BIND_FN_ELEMENT = new RegExp(
+ `^(?:${RGXPART_BIND_FN_ELEMENT_STRING})\\(([^\\)]+)\\)`,
+ "i",
+);
+const RGX_BIND_FN_TEMPLATE_OR_ELEMENT = new RegExp(
+ `^(?:${RGXPART_BIND_FN_TEMPLATE_STRING}|${RGXPART_BIND_FN_ELEMENT_STRING})\\(([^\\)]+)\\)`,
+ "i",
+);
+const RGX_BIND_FN_LENGTH = /^(?:length|len|size)\(([^\)]+)\)/i;
+const RGX_BIND_FN_FORMAT = /^(?:format|fmt)\(([^\,]+),([^\)]+)\)/i;
+const RGX_BIND_FN_CALL = /^([^\(]+)\(([^\)]*)\)/i;
+
+const EMPTY_PREPROCESS_FN = (data: any) => data;
+
+// This is used within exec, so we don't need to check the first part since it's
+// always the lastIndex start position
+// const RGX_BIND_DECLARATIONS = /\s*((?:[\$_a-z0-9-\.]|\?\?|\|\|)+(?:\([^\)]+\))?)(?::(.*?))?(\s|$)/ig;
+const RGX_BIND_DECLARATIONS =
+ /\s*(\!*(?:[\$_a-z0-9-\.\'\"]|\?\?|\|\||\&\&|(?:(?:<|>|==|\!=)=?))+(?:\`[^\`]+\`)?(?:\([^\)]*\))?)(?::(.*?))?(\s|$)/gi;
+
+/**
+ * Asserts that something is not null of undefined.
+ */
+function localAssertNotFalsy(input?: T | null, errorMsg = `Input is not of type.`): T {
+ if (input == null) {
+ throw new Error(errorMsg);
+ }
+ return input;
+}
+
+/**
+ * Cleans a key.
+ */
+function cleanKey(key: string) {
+ return key.toLowerCase().trim().replace(/\s/g, "");
+}
+
+/**
+ * Ensures the value is an array, converting array-like items to an array.
+ */
+function toArray(value: any | any[]): any[] {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ if (value instanceof Set) {
+ return Array.from(value);
+ }
+ // Array-like.
+ if (typeof value === "object" && typeof value.length === "number") {
+ return [].slice.call(value);
+ }
+ return [value];
+}
+
+/**
+ * Flattens an array.
+ */
+function flattenArray(arr: any | any[]): any[] {
+ return toArray(arr).reduce((acc, val) => {
+ return acc.concat(Array.isArray(val) ? flattenArray(val) : val);
+ }, []);
+}
+
+/**
+ * Gets an object value by a string lookup.
+ */
+function getObjValue(lookup: string, obj: any): any {
+ // If we want to cast as a boolean via a /!+/ prefix.
+ let booleanMatch: string[] = lookup.match(/^(\!+)(.+?)$/i) || [];
+ let booleanNots: string[] = [];
+ if (booleanMatch[1] && booleanMatch[2]) {
+ booleanNots = booleanMatch[1].split("");
+ lookup = booleanMatch[2];
+ }
+
+ let value = getObjectValue(obj, lookup);
+ while (booleanNots.length) {
+ value = !value;
+ booleanNots.shift();
+ }
+ return value;
+}
+
+/**
+ * Gets a primotove or object value.
+ */
+function getPrimitiveOrObjValue(stringValue: string | null | undefined, data: any) {
+ let value;
+ if (stringValue == null) {
+ return stringValue;
+ }
+ let negate = getNegates(stringValue);
+ if (negate != null) {
+ stringValue = stringValue.replace(/^\!+/, "");
+ }
+ try {
+ const cleanedStringValue = stringValue.replace(/^'(.*)'$/, '"$1"');
+ value = JSON.parse(cleanedStringValue);
+ } catch (e) {
+ value = getObjValue(stringValue, data);
+ }
+ value = negate !== null ? (negate === 1 ? !value : !!value) : value;
+ return value;
+}
+
+/**
+ * Get the negates for a string. A `null` value means there are no negates,
+ * otherwise a `1` means it should be negated, and a `0` means double-negate
+ * (e.g. cast as boolean).
+ *
+ * 'boolVar' => null
+ * '!boolVar' => 1
+ * '!!boolVar' => 0
+ * '!!!boolVar' => 1
+ * '!!!!boolVar' => 0
+ */
+function getNegates(stringValue: string): number | null {
+ let negate = null;
+ let negateMatches = stringValue.match(/^(\!+)(.*)/);
+ if (negateMatches && negateMatches.length >= 3) {
+ negate = negateMatches[1]!.length % 2;
+ }
+ return negate;
+}
+
+/**
+ * Returns the boolean for a comparison of object values. A `null` value would
+ * be if the experssion does not match a comparison, and undefined if there was
+ * but it's not a known comparison (===, ==, <=, >=, !==, !=, <, >).
+ */
+function getStringComparisonExpression(
+ bindingPropName: string,
+ data: any,
+): boolean | null | undefined {
+ let comparisonMatches = bindingPropName.match(RGX_COMPARISON);
+ if (!comparisonMatches?.length) {
+ return null;
+ }
+ let a = getPrimitiveOrObjValue(comparisonMatches[1]!, data);
+ let b = getPrimitiveOrObjValue(comparisonMatches[3]!, data);
+ let c = comparisonMatches[2];
+ let value = (() => {
+ switch (c) {
+ case "===":
+ return a === b;
+ case "==":
+ return a == b;
+ case "<=":
+ return a <= b;
+ case ">=":
+ return a >= b;
+ case "!==":
+ return a !== b;
+ case "!=":
+ return a != b;
+ case "<":
+ return a < b;
+ case ">":
+ return a > b;
+ default:
+ return undefined;
+ }
+ })();
+ return value;
+}
+
+/**
+ * Replaces a element with children with special attribute copies for
+ * single children.
+ */
+function replaceTplElementWithChildren(
+ tplEl: HTMLElement,
+ fragOrElOrEls: DocumentFragment | HTMLElement | Array,
+) {
+ const els = Array.isArray(fragOrElOrEls) ? fragOrElOrEls : [fragOrElOrEls];
+ tplEl.replaceWith(...els);
+ // dom.replaceChild(tplEl, fragOrElOrEls);
+ const numOfChildren = Array.isArray(fragOrElOrEls)
+ ? fragOrElOrEls.length
+ : fragOrElOrEls.childElementCount;
+ if (numOfChildren === 1) {
+ const firstChild = Array.isArray(fragOrElOrEls)
+ ? fragOrElOrEls[0]
+ : fragOrElOrEls.firstElementChild;
+ if (firstChild instanceof Element) {
+ if (tplEl.className.length) {
+ firstChild.className += ` ${tplEl.className}`;
+ }
+ let attr = tplEl.getAttribute("data");
+ if (attr) {
+ firstChild.setAttribute("data", attr);
+ }
+ attr = tplEl.getAttribute(CONFIG.attrBind);
+ if (attr) {
+ firstChild.setAttribute(CONFIG.attrBind, attr);
+ }
+ }
+ }
+}
+
+/**
+ * Entry point to get data from a binding name. Checks for null coelescing and
+ * logical-or operators.
+ *
+ * Handles templates as well:
+ *
+ * my.value`some string ${value} is cool.`
+ */
+function getValueForBinding(bindingPropName: string, context: BindingContext) {
+ console.log("getValueForBinding", bindingPropName, context);
+ const data = context.data;
+ let stringTemplate = null;
+ let stringTemplates = /^(.*?)\`([^\`]*)\`$/.exec(bindingPropName.trim());
+ if (stringTemplates?.length === 3) {
+ bindingPropName = stringTemplates[1]!;
+ stringTemplate = stringTemplates[2];
+ }
+ let value = null;
+
+ let hadALogicalOp = false;
+ const opsToValidation = new Map([
+ [/\s*\?\?\s*/, (v: any) => v != null],
+ [/\s*\|\|\s*/, (v: any) => !!v],
+ [/\s*\&\&\s*/, (v: any) => !v],
+ ]);
+ for (const [op, fn] of opsToValidation.entries()) {
+ if (bindingPropName.match(op)) {
+ hadALogicalOp = true;
+ const bindingPropNames = bindingPropName.split(op);
+ for (const propName of bindingPropNames) {
+ value = getValueForBindingPropName(propName, context);
+ if (fn(value)) {
+ break;
+ }
+ }
+ break;
+ }
+ }
+
+ if (!hadALogicalOp) {
+ value = getValueForBindingPropName(bindingPropName, context);
+ }
+
+ return stringTemplate && value != null
+ ? stringTemplate.replace(/\$\{value\}/g, String(value))
+ : value;
+}
+
+/**
+ * Gets the value for a binding prop name.
+ */
+function getValueForBindingPropName(bindingPropName: string, context: BindingContext) {
+ const data = context.data;
+ let negate = getNegates(bindingPropName);
+ if (negate != null) {
+ bindingPropName = bindingPropName.replace(/^\!+/, "");
+ }
+ let value;
+ RGX_COMPARISON.lastIndex = 0;
+ if (RGX_COMPARISON.test(bindingPropName)) {
+ value = getStringComparisonExpression(bindingPropName, data);
+ } else if (RGX_BIND_FN_LENGTH.test(bindingPropName)) {
+ bindingPropName = RGX_BIND_FN_LENGTH.exec(bindingPropName)![1]!;
+ value = getPrimitiveOrObjValue(bindingPropName, data);
+ value = (value && value.length) || 0;
+ } else if (RGX_BIND_FN_FORMAT.test(bindingPropName)) {
+ let matches = RGX_BIND_FN_FORMAT.exec(bindingPropName);
+ bindingPropName = matches![1]!;
+ value = getPrimitiveOrObjValue(bindingPropName, data);
+ value = matches![2]!.replace(/^['"]/, "").replace(/['"]$/, "").replace(/\$1/g, value);
+ } else if (RGX_BIND_FN_CALL.test(bindingPropName)) {
+ console.log("-----");
+ console.log(bindingPropName);
+ let matches = RGX_BIND_FN_CALL.exec(bindingPropName);
+ const functionName = matches![1]!;
+ const maybeDataName = matches![2] ?? null;
+ value = getPrimitiveOrObjValue(maybeDataName, data);
+ console.log(functionName, maybeDataName, value);
+ // First, see if the instance has this call
+ if (typeof value?.[functionName] === "function") {
+ value = value[functionName](value, data, context.currentElement, context.contextElement);
+ } else if (typeof data?.[functionName] === "function") {
+ value = data[functionName](value, data, context.currentElement, context.contextElement);
+ } else if (typeof (context.currentElement as any)?.[functionName] === "function") {
+ value = (context.currentElement as any)[functionName](
+ value,
+ data,
+ context.currentElement,
+ context.contextElement,
+ );
+ } else if (typeof (context.contextElement as any)?.[functionName] === "function") {
+ value = (context.contextElement as any)[functionName](
+ value,
+ data,
+ context.currentElement,
+ context.contextElement,
+ );
+ } else {
+ console.error(
+ `No method named ${functionName} on data or element instance. Just calling regular value.`,
+ );
+ value = getPrimitiveOrObjValue(bindingPropName, data);
+ }
+ } else {
+ value = getPrimitiveOrObjValue(bindingPropName, data);
+ }
+
+ if (value !== undefined) {
+ value = negate !== null ? (negate === 1 ? !value : !!value) : value;
+ }
+ return value;
+}
+
+/**
+ * Removes data-bind attributes, ostensibly "freezing" the current element.
+ *
+ * @param deep Will remove all data-bind attributes when true. default behavior
+ * is only up to the next data-tpl.
+ */
+function removeBindingAttributes(
+ elOrEls: DocumentFragment | HTMLElement | HTMLElement[],
+ deep = false,
+) {
+ flattenArray(elOrEls || []).forEach((el) => {
+ el.removeAttribute(CONFIG.attrBind);
+ const innerBinds = dom.queryAll(`:scope [${CONFIG.attrBind}]`, el);
+ // If we're deep, then pretend there are no data-tpl.
+ const innerTplBinds = deep ? [] : dom.queryAll(`:scope [data-tpl] [${CONFIG.attrBind}]`);
+
+ innerBinds.forEach((el) => {
+ if (deep || !innerTplBinds.includes(el)) {
+ el.removeAttribute(CONFIG.attrBind);
+ }
+ });
+ });
+}
+
+const templateCache: {[key: string]: TemplateData} = {};
+
+/**
+ * Checks if a template exists.
+ */
+export function checkKey(key: string) {
+ return !!templateCache[cleanKey(key)];
+}
+
+/**
+ * Register a template to it's key and a DocumentFragment to store the markup.
+ * Uses `` shadow DOM if possible. Overloaded to accept a register
+ * a script as second param.
+ */
+export function register(
+ key: string,
+ htmlOrElement: string | Element | null = null,
+ preProcessScript?: (data: any) => void,
+) {
+ key = cleanKey(key);
+ if (templateCache[key]) {
+ return templateCache[key];
+ }
+
+ let fragment: DocumentFragment | null = null;
+ if (typeof htmlOrElement === "string") {
+ const frag = document.createDocumentFragment();
+ if (htmlOrElement.includes("<")) {
+ const html = htmlOrElement.trim();
+ const htmlParentTag =
+ (html.startsWith(" {
+ // const els = dom.queryAll(['[data-template]', '[data-templateid]','[template]'], node);
+ const els = dom.queryAll("[data-template], [data-templateid], [template]", node);
+ for (const child of els) {
+ // If there's a class name specified on the template call, then we want to add it
+ let className = child.className || null;
+ let childTemplateId = localAssertNotFalsy(
+ child.getAttribute("data-template") ||
+ child.getAttribute("data-templateid") ||
+ child.getAttribute("template"),
+ "No child template id provided.",
+ );
+
+ const dataAttribute = child.getAttribute("data") || "";
+
+ const childData = (dataAttribute && getObjValue(dataAttribute, templateData)) || templateData;
+ const tplsInflateOptions = Object.assign({}, inflateOptions);
+ // If we passed in skipInit we'll use it, otherwise set to true assuming
+ // this pass is initializing final markup
+ if (tplsInflateOptions.skipInit != null) {
+ tplsInflateOptions.skipInit = true;
+ }
+ let tpls = localAssertNotFalsy(
+ inflate(childTemplateId, childData, tplsInflateOptions),
+ `No template inflated from ${childTemplateId}.`,
+ );
+ tpls = !Array.isArray(tpls) ? [tpls] : tpls;
+ if (className) {
+ for (const tpl of tpls) {
+ tpl.classList.add(className);
+ }
+ }
+ if (child.nodeName.toUpperCase() === "TPL") {
+ replaceTplElementWithChildren(child, tpls);
+ } else {
+ child.append(...tpls);
+ }
+ // Old.
+ // tpls.reverse().forEach((tplChild) => {
+ // dom.insertAfter(tplChild, child);
+ // if (className) {
+ // tplChild.classList.add(className);
+ // }
+ // });
+ child.remove();
+ }
+
+ let children: HTMLElement[] = [];
+ for (const child of node.children) {
+ let tplAttributes = (child.getAttribute("data-tpl") || "").split(" ");
+ if (!tplAttributes.includes((node as any).__templateid__)) {
+ tplAttributes.push((node as any).__templateid__);
+ }
+ child.setAttribute("data-tpl", tplAttributes.join(" ").trim());
+ children.push(child as HTMLElement);
+ }
+ let childOrChildren = children.length === 1 ? children[0]! : children;
+ if (!inflateOptions.skipInit) {
+ init(childOrChildren, templateData, inflateOptions.bindOptions);
+ }
+ return childOrChildren;
+ }
+ return null;
+}
+
+export function inflateSingle(
+ nodeOrKey: string | HTMLElement,
+ scriptData: any = null,
+ bindOptions: InflateOptions = {},
+): HTMLElement {
+ const inflated = localAssertNotFalsy(inflate(nodeOrKey, scriptData, bindOptions));
+ return Array.isArray(inflated) ? inflated[0]! : inflated;
+}
+
+/**
+ * Same as inflate, but removes bindings after inflating.
+ * Useful when an element only needs to be inflated once without a desire to rebind
+ * (or accidentally unbind elements)
+ */
+export function inflateOnce(
+ nodeOrKey: string | HTMLElement | DocumentFragment,
+ templateData: any = null,
+ inflateOptions: InflateOptions = {},
+) {
+ let children = inflate(nodeOrKey, templateData, inflateOptions);
+ children && removeBindingAttributes(children, false);
+ return children;
+}
+
+export function inflateSingleOnce(
+ nodeOrKey: string | HTMLElement,
+ scriptData: any = null,
+ bindOptions: InflateOptions = {},
+): HTMLElement {
+ const inflated = inflate(nodeOrKey, scriptData, bindOptions) || [];
+ removeBindingAttributes(inflated, false);
+ return Array.isArray(inflated) ? inflated[0]! : inflated;
+}
+
+/**
+ * Initialize a template and bind to it's data.
+ * Different than bind in that it will check for a registered script
+ * and call that (bind simply binds the data to data-bind fields)
+ */
+export function init(els: HTMLElement | HTMLElement[], data: any, bindOptions: BindOptions = {}) {
+ (!els ? [] : els instanceof Element ? [els] : els).forEach((el) => {
+ const dataTplAttr = el.getAttribute("data-tpl");
+ if (dataTplAttr) {
+ const tpls = dataTplAttr.split(" ");
+ tpls.forEach((tpl) => {
+ // if (templateCache[tpl].script)
+ // templateCache[tpl].script(el, (data && (data[tpl] || data[tpl.replace('tpl:','')])) || data, options);
+ // else
+ const dataAttribute = el.getAttribute("data") || "";
+ const childData = (dataAttribute && getObjValue(dataAttribute, data)) || data;
+ bind(el, childData, bindOptions);
+ });
+ } else {
+ bind(el, data, bindOptions);
+ }
+ });
+}
+
+// ### templates::bind
+//
+// Binds all elements under a template to the passed `data` JSON object. This *does not* call a registered script.
+// It will stop binding elements once it reaches an element in the DOM with a `data-autobind` attribute set to false
+// (which **MooVeeStar.View**s do automatically -- so each view is in control of it's binding)
+//
+// - If a single empty element is passed, and data is a string value, then it will be used
+// as the value for that element
+// - If `options.onlyDefined === true` then no `data-bind` fields will be unbound, only those
+// `data-bind` keys in the data map will be bound
+//
+export function bind(
+ elOrEls: HTMLElement | ShadowRoot | HTMLElement[],
+ data: any = {},
+ bindOptions: BindOptions = {},
+) {
+ if (elOrEls instanceof HTMLElement) {
+ data = getPreProcessScript(elOrEls)({...data});
+ }
+
+ // If `els` is a single empty element w/ no `[data-bind]` set _and_
+ // `data` is a string, set it to be the value of the el
+ if (typeof data !== "object") {
+ data = {value: data};
+ if (
+ elOrEls instanceof HTMLElement &&
+ elOrEls.children.length === 0 &&
+ !elOrEls.getAttribute(CONFIG.attrBind)
+ ) {
+ dom.setAttributes(elOrEls, {[CONFIG.attrBind]: "value"});
+ }
+ }
+
+ // Get all children to be bind that are not inner binds
+ let passedEls = !Array.isArray(elOrEls) ? [elOrEls] : elOrEls;
+ for (const el of passedEls) {
+ // First, get any condition els, evaluate them, then we'll skip them and children from binding
+ // if they are false.
+ const conditionEls = toArray(dom.queryAll(`[${CONFIG.attrIf}]`, el));
+ const contextElement =
+ bindOptions.contextElement ?? (el instanceof ShadowRoot ? (el.host as HTMLElement) : el);
+
+ for (const conditionEl of conditionEls) {
+ getValueForBindingPropName;
+ // const isTrue = getStringComparisonExpression(conditionEl.getAttribute(CONFIG.attrIf), data);
+ let isTrue = getValueForBinding(conditionEl.getAttribute(CONFIG.attrIf), {
+ data,
+ contextElement: contextElement,
+ currentElement: conditionEl,
+ });
+ conditionEl.setAttribute(CONFIG.attrIfIs, String(!!isTrue));
+ }
+
+ let toBindEls = toArray(
+ dom.queryAll(
+ `:not([${CONFIG.attrIfIs}="false"]) [${CONFIG.attrBind}]:not([data-tpl]):not([${CONFIG.attrIfIs}="false"])`,
+ el,
+ ),
+ );
+ if (el instanceof HTMLElement && el.getAttribute(CONFIG.attrBind)) {
+ toBindEls.unshift(el);
+ }
+
+ if (toBindEls.length) {
+ // Exclude any els that are in their own data-tpl (which will follow)
+ // let innerBindsElements = dom.queryAll([':scope [data-tpl] [data-bind]', ':scope [data-autobind="false"] [data-bind]'], el);
+ let innerBindsElements = dom.queryAll(
+ `:scope [data-tpl] [${CONFIG.attrBind}], :scope [data-autobind="false"] [${CONFIG.attrBind}]`,
+ el,
+ );
+ toBindEls = toBindEls.filter((maybeBind) => !innerBindsElements.includes(maybeBind));
+ toBindEls.forEach((child) => {
+ // Get the bindings this elements wants
+ // let bindings = child.getAttribute('data-bind').replace(/\s+/,' ').trim().split(' ') || [];
+ RGX_BIND_DECLARATIONS.lastIndex = 0;
+ let bindings = [];
+ let bindingMatch;
+ while (
+ (bindingMatch = RGX_BIND_DECLARATIONS.exec(
+ child.getAttribute(CONFIG.attrBind).replace(/\s+/, " ").trim(),
+ )) !== null
+ ) {
+ bindings.push([bindingMatch[1], bindingMatch[2]]);
+ }
+
+ // let bindingStrings: string[] = child.getAttribute(CONFIG.attrBind).split(' ') || [];
+ // bindingStrings.forEach((bindingString) => {
+ bindings.forEach((bindings) => {
+ // let bindingStringsSplit = bindings.split(':');
+ let bindingDataProperty = localAssertNotFalsy(bindings.shift());
+ let bindingFields = ((bindings.length && bindings[0]) || "default")
+ .trim()
+ .replace(/^\[(.*?)\]$/i, "$1")
+ .split(",");
+
+ let value = getValueForBinding(bindingDataProperty, {
+ data,
+ contextElement: contextElement,
+ currentElement: child,
+ });
+ if (value === undefined) {
+ if (bindOptions.onlyDefined === true) {
+ return;
+ } else {
+ value = null;
+ }
+ }
+ bindingFields.forEach((field) => {
+ if (field.startsWith("style.")) {
+ let stringVal = String(value);
+ if (
+ value &&
+ !stringVal.includes("url(") &&
+ stringVal !== "none" &&
+ (field.includes("background-image") || stringVal.startsWith("http"))
+ ) {
+ value = `url(${value})`;
+ }
+ dom.setStyle(child, field.replace("style.", ""), value);
+
+ // special element methods.
+ } else if (field.startsWith("el.")) {
+ if (field === "el.remove") {
+ if (value === true) {
+ child.remove();
+ }
+ } else if (field === "el.toggle") {
+ dom.setStyle(child, "display", value === true ? "" : "none");
+ } else if (field.startsWith("el.classList.toggle")) {
+ const cssClass = field.replace(/el.classList.toggle\(['"]?(.*?)['"]?\)/, "$1");
+ child.classList.toggle(cssClass, !!value);
+ }
+
+ // [array]:tpl() will inflate the specified template for each item
+ } else if (RGX_BIND_FN_TEMPLATE_OR_ELEMENT.test(field)) {
+ dom.empty(child);
+ let elementOrTemplateName = RGX_BIND_FN_TEMPLATE_OR_ELEMENT.exec(field)![1]!;
+ if (Array.isArray(value) || value instanceof Set) {
+ const arrayVals = toArray(value);
+ let isElement = RGX_BIND_FN_ELEMENT.test(field);
+ let frag = document.createDocumentFragment();
+ arrayVals.forEach((item, index) => {
+ let itemData: {};
+ if (typeof item === "object") {
+ itemData = Object.assign({$index: index}, item);
+ } else {
+ itemData = {$index: index, value: item};
+ }
+
+ const els = bindToElOrTemplate(elementOrTemplateName, itemData);
+ frag.append(...els);
+ });
+ // If we're a
+ if (child.nodeName.toUpperCase() === "TPL") {
+ replaceTplElementWithChildren(child, frag);
+ } else {
+ dom.empty(child).appendChild(frag);
+ }
+ } else if (value) {
+ const els = bindToElOrTemplate(elementOrTemplateName, value);
+ // If we're a
+ if (child.nodeName.toUpperCase() === "TPL") {
+ replaceTplElementWithChildren(child, els);
+ } else {
+ child.append(...els);
+ }
+ }
+ } else {
+ dom.setAttributes(child, {[field]: value});
+ }
+ });
+ });
+ });
+ }
+
+ // Now loop over children w/ "data-tpl" and init them, unless they have an "data-autobind" set to "false"
+ // (as in, they have a separate View Controller rendering their data)
+ if (bindOptions.singleScoped !== true) {
+ let toInitEls = toArray(el.querySelectorAll(":scope *[data-tpl]"));
+ if (toInitEls.length) {
+ // let innerInits = dom.queryAll([':scope *[data-tpl] *[data-tpl]', ':scope [data-autobind="false"] [data-tpl]'], el);
+ let innerInits = dom.queryAll(
+ ':scope *[data-tpl] *[data-tpl], :scope [data-autobind="false"] [data-tpl]',
+ el,
+ );
+ toInitEls = toInitEls.filter((maybeInitEl) => {
+ // If the el is inside another [data-tpl] don't init now (it will recursively next time)
+ if (innerInits.includes(maybeInitEl)) {
+ return false;
+ }
+
+ // If we passed in a specific map in data for this, then init
+ let tplKey = maybeInitEl.getAttribute("data-tpl");
+ if (data && (data[tplKey] || data[tplKey.replace("tpl:", "")])) {
+ return true;
+ }
+
+ // Only init cascadingly if autobind is not "false"
+ // (as in, a separate controller handles it's own rendering)
+ return maybeInitEl.getAttribute("data-autobind") !== "false";
+ });
+ toInitEls.forEach((toInitEl) => {
+ var tplKey = toInitEl.getAttribute("data-tpl");
+ init(toInitEl, (data && (data[tplKey] || data[tplKey.replace("tpl:", "")])) || data);
+ });
+ }
+ }
+ }
+}
+
+function bindToElOrTemplate(elementOrTemplateName: string, data: any) {
+ let el: DocumentFragment | HTMLElement | HTMLElement[] | null =
+ getTemplateFragment(elementOrTemplateName);
+ if (!el) {
+ el = dom.createElement(elementOrTemplateName, data);
+ } else {
+ // Inflate each template passing in item and have them init (force false skipInit)
+ el = inflateOnce(el, data, {skipInit: false});
+ }
+ // Then, remove data-tpl b/c we just inflated it (and, presumably, it's data is
+ // already set so we don't want to set it again below).
+ // const els = (Array.isArray(el) ? el : [el]).filter(el => !!el) as HTMLElement[];
+ // els.forEach(el => {
+ // el.removeAttribute('data-tpl');
+ // dom.queryAll('[data-tpl]', el).forEach(c => c.removeAttribute('data-tpl'));
+ // });
+ const els = (Array.isArray(el) ? el : [el]).filter((el) => !!el) as HTMLElement[];
+ els.forEach((el) => {
+ el.removeAttribute("data-tpl");
+ let toBindEls = dom.queryAll("[data-tpl]", el);
+ // let innerBindsElements = dom.queryAll([':scope [data-tpl] [data-bind]', ':scope [data-autobind="false"] [data-bind]'], el);
+ let innerBindsElements = dom.queryAll(
+ `:scope [data-tpl] [${CONFIG.attrBind}], :scope [data-autobind="false"] [${CONFIG.attrBind}]`,
+ el,
+ );
+ toBindEls = toBindEls.filter((maybeBind) => !innerBindsElements.includes(maybeBind));
+ toBindEls.forEach((c) => c.removeAttribute("data-tpl"));
+ });
+ return els;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/common/utils_workflow.ts b/custom_nodes/rgthree-comfy/src_web/common/utils_workflow.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4c3668ea371e74c5cf39e67feb1e98823784db7a
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/common/utils_workflow.ts
@@ -0,0 +1,72 @@
+import type {ISerialisedGraph} from "@comfyorg/frontend";
+import type {ComfyApiFormat} from "typings/comfy.js";
+
+import {getResolver} from "./shared_utils.js";
+import {getPngMetadata, getWebpMetadata} from "./comfyui_shim.js";
+
+/**
+ * Parses the workflow JSON and do any necessary cleanup.
+ */
+function parseWorkflowJson(stringJson?: string) {
+ stringJson = stringJson || "null";
+ // Starting around August 2024 the serialized JSON started to get messy and contained `NaN` (for
+ // an is_changed property, specifically). NaN is not parseable, so we'll get those on out of there
+ // and cleanup anything else we need.
+ stringJson = stringJson.replace(/:\s*NaN/g, ": null");
+ return JSON.parse(stringJson);
+}
+
+export async function tryToGetWorkflowDataFromEvent(
+ e: DragEvent,
+): Promise<{workflow: ISerialisedGraph | null; prompt: ComfyApiFormat | null}> {
+ let work;
+ for (const file of e.dataTransfer?.files || []) {
+ const data = await tryToGetWorkflowDataFromFile(file);
+ if (data.workflow || data.prompt) {
+ return data;
+ }
+ }
+ const validTypes = ["text/uri-list", "text/x-moz-url"];
+ const match = (e.dataTransfer?.types || []).find((t) => validTypes.find((v) => t === v));
+ if (match) {
+ const uri = e.dataTransfer!.getData(match)?.split("\n")?.[0];
+ if (uri) {
+ return tryToGetWorkflowDataFromFile(await (await fetch(uri)).blob());
+ }
+ }
+ return {workflow: null, prompt: null};
+}
+
+export async function tryToGetWorkflowDataFromFile(
+ file: File | Blob,
+): Promise<{workflow: ISerialisedGraph | null; prompt: ComfyApiFormat | null}> {
+ if (file.type === "image/png") {
+ const pngInfo = await getPngMetadata(file);
+ return {
+ workflow: parseWorkflowJson(pngInfo?.workflow),
+ prompt: parseWorkflowJson(pngInfo?.prompt),
+ };
+ }
+
+ if (file.type === "image/webp") {
+ const pngInfo = await getWebpMetadata(file);
+ // Support loading workflows from that webp custom node.
+ const workflow = parseWorkflowJson(pngInfo?.workflow || pngInfo?.Workflow || "null");
+ const prompt = parseWorkflowJson(pngInfo?.prompt || pngInfo?.Prompt || "null");
+ return {workflow, prompt};
+ }
+
+ if (file.type === "application/json" || (file as File).name?.endsWith(".json")) {
+ const resolver = getResolver<{workflow: any; prompt: any}>();
+ const reader = new FileReader();
+ reader.onload = async () => {
+ const json = parseWorkflowJson(reader.result as string);
+ const isApiJson = Object.values(json).every((v: any) => v.class_type);
+ const prompt = isApiJson ? json : null;
+ const workflow = !isApiJson && !json?.templates ? json : null;
+ return {workflow, prompt};
+ };
+ return resolver.promise;
+ }
+ return {workflow: null, prompt: null};
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter-python.wasm b/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter-python.wasm
new file mode 100644
index 0000000000000000000000000000000000000000..be8aa834399b9e57158e4a813be2c0dbf50d8dad
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter-python.wasm
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8c93692fb368e288a5824cee55773c9b3602804f513bda48c97661e52e9c2da2
+size 457796
diff --git a/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.js b/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.js
new file mode 100644
index 0000000000000000000000000000000000000000..1ef49495cdd5971427d2b1b78f36cf2a1c41ab41
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.js
@@ -0,0 +1,3978 @@
+var __defProp = Object.defineProperty;
+var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
+
+// src/constants.ts
+var SIZE_OF_SHORT = 2;
+var SIZE_OF_INT = 4;
+var SIZE_OF_CURSOR = 4 * SIZE_OF_INT;
+var SIZE_OF_NODE = 5 * SIZE_OF_INT;
+var SIZE_OF_POINT = 2 * SIZE_OF_INT;
+var SIZE_OF_RANGE = 2 * SIZE_OF_INT + 2 * SIZE_OF_POINT;
+var ZERO_POINT = { row: 0, column: 0 };
+var INTERNAL = Symbol("INTERNAL");
+function assertInternal(x) {
+ if (x !== INTERNAL) throw new Error("Illegal constructor");
+}
+__name(assertInternal, "assertInternal");
+function isPoint(point) {
+ return !!point && typeof point.row === "number" && typeof point.column === "number";
+}
+__name(isPoint, "isPoint");
+function setModule(module2) {
+ C = module2;
+}
+__name(setModule, "setModule");
+var C;
+
+// src/lookahead_iterator.ts
+var LookaheadIterator = class {
+ static {
+ __name(this, "LookaheadIterator");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ language;
+ /** @internal */
+ constructor(internal, address, language) {
+ assertInternal(internal);
+ this[0] = address;
+ this.language = language;
+ }
+ /** Get the current symbol of the lookahead iterator. */
+ get currentTypeId() {
+ return C._ts_lookahead_iterator_current_symbol(this[0]);
+ }
+ /** Get the current symbol name of the lookahead iterator. */
+ get currentType() {
+ return this.language.types[this.currentTypeId] || "ERROR";
+ }
+ /** Delete the lookahead iterator, freeing its resources. */
+ delete() {
+ C._ts_lookahead_iterator_delete(this[0]);
+ this[0] = 0;
+ }
+ /**
+ * Reset the lookahead iterator.
+ *
+ * This returns `true` if the language was set successfully and `false`
+ * otherwise.
+ */
+ reset(language, stateId) {
+ if (C._ts_lookahead_iterator_reset(this[0], language[0], stateId)) {
+ this.language = language;
+ return true;
+ }
+ return false;
+ }
+ /**
+ * Reset the lookahead iterator to another state.
+ *
+ * This returns `true` if the iterator was reset to the given state and
+ * `false` otherwise.
+ */
+ resetState(stateId) {
+ return Boolean(C._ts_lookahead_iterator_reset_state(this[0], stateId));
+ }
+ /**
+ * Returns an iterator that iterates over the symbols of the lookahead iterator.
+ *
+ * The iterator will yield the current symbol name as a string for each step
+ * until there are no more symbols to iterate over.
+ */
+ [Symbol.iterator]() {
+ return {
+ next: /* @__PURE__ */ __name(() => {
+ if (C._ts_lookahead_iterator_next(this[0])) {
+ return { done: false, value: this.currentType };
+ }
+ return { done: true, value: "" };
+ }, "next")
+ };
+ }
+};
+
+// src/tree.ts
+function getText(tree, startIndex, endIndex, startPosition) {
+ const length = endIndex - startIndex;
+ let result = tree.textCallback(startIndex, startPosition);
+ if (result) {
+ startIndex += result.length;
+ while (startIndex < endIndex) {
+ const string = tree.textCallback(startIndex, startPosition);
+ if (string && string.length > 0) {
+ startIndex += string.length;
+ result += string;
+ } else {
+ break;
+ }
+ }
+ if (startIndex > endIndex) {
+ result = result.slice(0, length);
+ }
+ }
+ return result ?? "";
+}
+__name(getText, "getText");
+var Tree = class _Tree {
+ static {
+ __name(this, "Tree");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ textCallback;
+ /** The language that was used to parse the syntax tree. */
+ language;
+ /** @internal */
+ constructor(internal, address, language, textCallback) {
+ assertInternal(internal);
+ this[0] = address;
+ this.language = language;
+ this.textCallback = textCallback;
+ }
+ /** Create a shallow copy of the syntax tree. This is very fast. */
+ copy() {
+ const address = C._ts_tree_copy(this[0]);
+ return new _Tree(INTERNAL, address, this.language, this.textCallback);
+ }
+ /** Delete the syntax tree, freeing its resources. */
+ delete() {
+ C._ts_tree_delete(this[0]);
+ this[0] = 0;
+ }
+ /** Get the root node of the syntax tree. */
+ get rootNode() {
+ C._ts_tree_root_node_wasm(this[0]);
+ return unmarshalNode(this);
+ }
+ /**
+ * Get the root node of the syntax tree, but with its position shifted
+ * forward by the given offset.
+ */
+ rootNodeWithOffset(offsetBytes, offsetExtent) {
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ C.setValue(address, offsetBytes, "i32");
+ marshalPoint(address + SIZE_OF_INT, offsetExtent);
+ C._ts_tree_root_node_with_offset_wasm(this[0]);
+ return unmarshalNode(this);
+ }
+ /**
+ * Edit the syntax tree to keep it in sync with source code that has been
+ * edited.
+ *
+ * You must describe the edit both in terms of byte offsets and in terms of
+ * row/column coordinates.
+ */
+ edit(edit) {
+ marshalEdit(edit);
+ C._ts_tree_edit_wasm(this[0]);
+ }
+ /** Create a new {@link TreeCursor} starting from the root of the tree. */
+ walk() {
+ return this.rootNode.walk();
+ }
+ /**
+ * Compare this old edited syntax tree to a new syntax tree representing
+ * the same document, returning a sequence of ranges whose syntactic
+ * structure has changed.
+ *
+ * For this to work correctly, this syntax tree must have been edited such
+ * that its ranges match up to the new tree. Generally, you'll want to
+ * call this method right after calling one of the [`Parser::parse`]
+ * functions. Call it on the old tree that was passed to parse, and
+ * pass the new tree that was returned from `parse`.
+ */
+ getChangedRanges(other) {
+ if (!(other instanceof _Tree)) {
+ throw new TypeError("Argument must be a Tree");
+ }
+ C._ts_tree_get_changed_ranges_wasm(this[0], other[0]);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ result[i2] = unmarshalRange(address);
+ address += SIZE_OF_RANGE;
+ }
+ C._free(buffer);
+ }
+ return result;
+ }
+ /** Get the included ranges that were used to parse the syntax tree. */
+ getIncludedRanges() {
+ C._ts_tree_included_ranges_wasm(this[0]);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ result[i2] = unmarshalRange(address);
+ address += SIZE_OF_RANGE;
+ }
+ C._free(buffer);
+ }
+ return result;
+ }
+};
+
+// src/tree_cursor.ts
+var TreeCursor = class _TreeCursor {
+ static {
+ __name(this, "TreeCursor");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ [1] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ [2] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ [3] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ tree;
+ /** @internal */
+ constructor(internal, tree) {
+ assertInternal(internal);
+ this.tree = tree;
+ unmarshalTreeCursor(this);
+ }
+ /** Creates a deep copy of the tree cursor. This allocates new memory. */
+ copy() {
+ const copy = new _TreeCursor(INTERNAL, this.tree);
+ C._ts_tree_cursor_copy_wasm(this.tree[0]);
+ unmarshalTreeCursor(copy);
+ return copy;
+ }
+ /** Delete the tree cursor, freeing its resources. */
+ delete() {
+ marshalTreeCursor(this);
+ C._ts_tree_cursor_delete_wasm(this.tree[0]);
+ this[0] = this[1] = this[2] = 0;
+ }
+ /** Get the tree cursor's current {@link Node}. */
+ get currentNode() {
+ marshalTreeCursor(this);
+ C._ts_tree_cursor_current_node_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get the numerical field id of this tree cursor's current node.
+ *
+ * See also {@link TreeCursor#currentFieldName}.
+ */
+ get currentFieldId() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_field_id_wasm(this.tree[0]);
+ }
+ /** Get the field name of this tree cursor's current node. */
+ get currentFieldName() {
+ return this.tree.language.fields[this.currentFieldId];
+ }
+ /**
+ * Get the depth of the cursor's current node relative to the original
+ * node that the cursor was constructed with.
+ */
+ get currentDepth() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_depth_wasm(this.tree[0]);
+ }
+ /**
+ * Get the index of the cursor's current node out of all of the
+ * descendants of the original node that the cursor was constructed with.
+ */
+ get currentDescendantIndex() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_descendant_index_wasm(this.tree[0]);
+ }
+ /** Get the type of the cursor's current node. */
+ get nodeType() {
+ return this.tree.language.types[this.nodeTypeId] || "ERROR";
+ }
+ /** Get the type id of the cursor's current node. */
+ get nodeTypeId() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_node_type_id_wasm(this.tree[0]);
+ }
+ /** Get the state id of the cursor's current node. */
+ get nodeStateId() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_node_state_id_wasm(this.tree[0]);
+ }
+ /** Get the id of the cursor's current node. */
+ get nodeId() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_node_id_wasm(this.tree[0]);
+ }
+ /**
+ * Check if the cursor's current node is *named*.
+ *
+ * Named nodes correspond to named rules in the grammar, whereas
+ * *anonymous* nodes correspond to string literals in the grammar.
+ */
+ get nodeIsNamed() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_node_is_named_wasm(this.tree[0]) === 1;
+ }
+ /**
+ * Check if the cursor's current node is *missing*.
+ *
+ * Missing nodes are inserted by the parser in order to recover from
+ * certain kinds of syntax errors.
+ */
+ get nodeIsMissing() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_current_node_is_missing_wasm(this.tree[0]) === 1;
+ }
+ /** Get the string content of the cursor's current node. */
+ get nodeText() {
+ marshalTreeCursor(this);
+ const startIndex = C._ts_tree_cursor_start_index_wasm(this.tree[0]);
+ const endIndex = C._ts_tree_cursor_end_index_wasm(this.tree[0]);
+ C._ts_tree_cursor_start_position_wasm(this.tree[0]);
+ const startPosition = unmarshalPoint(TRANSFER_BUFFER);
+ return getText(this.tree, startIndex, endIndex, startPosition);
+ }
+ /** Get the start position of the cursor's current node. */
+ get startPosition() {
+ marshalTreeCursor(this);
+ C._ts_tree_cursor_start_position_wasm(this.tree[0]);
+ return unmarshalPoint(TRANSFER_BUFFER);
+ }
+ /** Get the end position of the cursor's current node. */
+ get endPosition() {
+ marshalTreeCursor(this);
+ C._ts_tree_cursor_end_position_wasm(this.tree[0]);
+ return unmarshalPoint(TRANSFER_BUFFER);
+ }
+ /** Get the start index of the cursor's current node. */
+ get startIndex() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_start_index_wasm(this.tree[0]);
+ }
+ /** Get the end index of the cursor's current node. */
+ get endIndex() {
+ marshalTreeCursor(this);
+ return C._ts_tree_cursor_end_index_wasm(this.tree[0]);
+ }
+ /**
+ * Move this cursor to the first child of its current node.
+ *
+ * This returns `true` if the cursor successfully moved, and returns
+ * `false` if there were no children.
+ */
+ gotoFirstChild() {
+ marshalTreeCursor(this);
+ const result = C._ts_tree_cursor_goto_first_child_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Move this cursor to the last child of its current node.
+ *
+ * This returns `true` if the cursor successfully moved, and returns
+ * `false` if there were no children.
+ *
+ * Note that this function may be slower than
+ * {@link TreeCursor#gotoFirstChild} because it needs to
+ * iterate through all the children to compute the child's position.
+ */
+ gotoLastChild() {
+ marshalTreeCursor(this);
+ const result = C._ts_tree_cursor_goto_last_child_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Move this cursor to the parent of its current node.
+ *
+ * This returns `true` if the cursor successfully moved, and returns
+ * `false` if there was no parent node (the cursor was already on the
+ * root node).
+ *
+ * Note that the node the cursor was constructed with is considered the root
+ * of the cursor, and the cursor cannot walk outside this node.
+ */
+ gotoParent() {
+ marshalTreeCursor(this);
+ const result = C._ts_tree_cursor_goto_parent_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Move this cursor to the next sibling of its current node.
+ *
+ * This returns `true` if the cursor successfully moved, and returns
+ * `false` if there was no next sibling node.
+ *
+ * Note that the node the cursor was constructed with is considered the root
+ * of the cursor, and the cursor cannot walk outside this node.
+ */
+ gotoNextSibling() {
+ marshalTreeCursor(this);
+ const result = C._ts_tree_cursor_goto_next_sibling_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Move this cursor to the previous sibling of its current node.
+ *
+ * This returns `true` if the cursor successfully moved, and returns
+ * `false` if there was no previous sibling node.
+ *
+ * Note that this function may be slower than
+ * {@link TreeCursor#gotoNextSibling} due to how node
+ * positions are stored. In the worst case, this will need to iterate
+ * through all the children up to the previous sibling node to recalculate
+ * its position. Also note that the node the cursor was constructed with is
+ * considered the root of the cursor, and the cursor cannot walk outside this node.
+ */
+ gotoPreviousSibling() {
+ marshalTreeCursor(this);
+ const result = C._ts_tree_cursor_goto_previous_sibling_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Move the cursor to the node that is the nth descendant of
+ * the original node that the cursor was constructed with, where
+ * zero represents the original node itself.
+ */
+ gotoDescendant(goalDescendantIndex) {
+ marshalTreeCursor(this);
+ C._ts_tree_cursor_goto_descendant_wasm(this.tree[0], goalDescendantIndex);
+ unmarshalTreeCursor(this);
+ }
+ /**
+ * Move this cursor to the first child of its current node that contains or
+ * starts after the given byte offset.
+ *
+ * This returns `true` if the cursor successfully moved to a child node, and returns
+ * `false` if no such child was found.
+ */
+ gotoFirstChildForIndex(goalIndex) {
+ marshalTreeCursor(this);
+ C.setValue(TRANSFER_BUFFER + SIZE_OF_CURSOR, goalIndex, "i32");
+ const result = C._ts_tree_cursor_goto_first_child_for_index_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Move this cursor to the first child of its current node that contains or
+ * starts after the given byte offset.
+ *
+ * This returns the index of the child node if one was found, and returns
+ * `null` if no such child was found.
+ */
+ gotoFirstChildForPosition(goalPosition) {
+ marshalTreeCursor(this);
+ marshalPoint(TRANSFER_BUFFER + SIZE_OF_CURSOR, goalPosition);
+ const result = C._ts_tree_cursor_goto_first_child_for_position_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ return result === 1;
+ }
+ /**
+ * Re-initialize this tree cursor to start at the original node that the
+ * cursor was constructed with.
+ */
+ reset(node) {
+ marshalNode(node);
+ marshalTreeCursor(this, TRANSFER_BUFFER + SIZE_OF_NODE);
+ C._ts_tree_cursor_reset_wasm(this.tree[0]);
+ unmarshalTreeCursor(this);
+ }
+ /**
+ * Re-initialize a tree cursor to the same position as another cursor.
+ *
+ * Unlike {@link TreeCursor#reset}, this will not lose parent
+ * information and allows reusing already created cursors.
+ */
+ resetTo(cursor) {
+ marshalTreeCursor(this, TRANSFER_BUFFER);
+ marshalTreeCursor(cursor, TRANSFER_BUFFER + SIZE_OF_CURSOR);
+ C._ts_tree_cursor_reset_to_wasm(this.tree[0], cursor.tree[0]);
+ unmarshalTreeCursor(this);
+ }
+};
+
+// src/node.ts
+var Node = class {
+ static {
+ __name(this, "Node");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ _children;
+ /** @internal */
+ _namedChildren;
+ /** @internal */
+ constructor(internal, {
+ id,
+ tree,
+ startIndex,
+ startPosition,
+ other
+ }) {
+ assertInternal(internal);
+ this[0] = other;
+ this.id = id;
+ this.tree = tree;
+ this.startIndex = startIndex;
+ this.startPosition = startPosition;
+ }
+ /**
+ * The numeric id for this node that is unique.
+ *
+ * Within a given syntax tree, no two nodes have the same id. However:
+ *
+ * * If a new tree is created based on an older tree, and a node from the old tree is reused in
+ * the process, then that node will have the same id in both trees.
+ *
+ * * A node not marked as having changes does not guarantee it was reused.
+ *
+ * * If a node is marked as having changed in the old tree, it will not be reused.
+ */
+ id;
+ /** The byte index where this node starts. */
+ startIndex;
+ /** The position where this node starts. */
+ startPosition;
+ /** The tree that this node belongs to. */
+ tree;
+ /** Get this node's type as a numerical id. */
+ get typeId() {
+ marshalNode(this);
+ return C._ts_node_symbol_wasm(this.tree[0]);
+ }
+ /**
+ * Get the node's type as a numerical id as it appears in the grammar,
+ * ignoring aliases.
+ */
+ get grammarId() {
+ marshalNode(this);
+ return C._ts_node_grammar_symbol_wasm(this.tree[0]);
+ }
+ /** Get this node's type as a string. */
+ get type() {
+ return this.tree.language.types[this.typeId] || "ERROR";
+ }
+ /**
+ * Get this node's symbol name as it appears in the grammar, ignoring
+ * aliases as a string.
+ */
+ get grammarType() {
+ return this.tree.language.types[this.grammarId] || "ERROR";
+ }
+ /**
+ * Check if this node is *named*.
+ *
+ * Named nodes correspond to named rules in the grammar, whereas
+ * *anonymous* nodes correspond to string literals in the grammar.
+ */
+ get isNamed() {
+ marshalNode(this);
+ return C._ts_node_is_named_wasm(this.tree[0]) === 1;
+ }
+ /**
+ * Check if this node is *extra*.
+ *
+ * Extra nodes represent things like comments, which are not required
+ * by the grammar, but can appear anywhere.
+ */
+ get isExtra() {
+ marshalNode(this);
+ return C._ts_node_is_extra_wasm(this.tree[0]) === 1;
+ }
+ /**
+ * Check if this node represents a syntax error.
+ *
+ * Syntax errors represent parts of the code that could not be incorporated
+ * into a valid syntax tree.
+ */
+ get isError() {
+ marshalNode(this);
+ return C._ts_node_is_error_wasm(this.tree[0]) === 1;
+ }
+ /**
+ * Check if this node is *missing*.
+ *
+ * Missing nodes are inserted by the parser in order to recover from
+ * certain kinds of syntax errors.
+ */
+ get isMissing() {
+ marshalNode(this);
+ return C._ts_node_is_missing_wasm(this.tree[0]) === 1;
+ }
+ /** Check if this node has been edited. */
+ get hasChanges() {
+ marshalNode(this);
+ return C._ts_node_has_changes_wasm(this.tree[0]) === 1;
+ }
+ /**
+ * Check if this node represents a syntax error or contains any syntax
+ * errors anywhere within it.
+ */
+ get hasError() {
+ marshalNode(this);
+ return C._ts_node_has_error_wasm(this.tree[0]) === 1;
+ }
+ /** Get the byte index where this node ends. */
+ get endIndex() {
+ marshalNode(this);
+ return C._ts_node_end_index_wasm(this.tree[0]);
+ }
+ /** Get the position where this node ends. */
+ get endPosition() {
+ marshalNode(this);
+ C._ts_node_end_point_wasm(this.tree[0]);
+ return unmarshalPoint(TRANSFER_BUFFER);
+ }
+ /** Get the string content of this node. */
+ get text() {
+ return getText(this.tree, this.startIndex, this.endIndex, this.startPosition);
+ }
+ /** Get this node's parse state. */
+ get parseState() {
+ marshalNode(this);
+ return C._ts_node_parse_state_wasm(this.tree[0]);
+ }
+ /** Get the parse state after this node. */
+ get nextParseState() {
+ marshalNode(this);
+ return C._ts_node_next_parse_state_wasm(this.tree[0]);
+ }
+ /** Check if this node is equal to another node. */
+ equals(other) {
+ return this.tree === other.tree && this.id === other.id;
+ }
+ /**
+ * Get the node's child at the given index, where zero represents the first child.
+ *
+ * This method is fairly fast, but its cost is technically log(n), so if
+ * you might be iterating over a long list of children, you should use
+ * {@link Node#children} instead.
+ */
+ child(index) {
+ marshalNode(this);
+ C._ts_node_child_wasm(this.tree[0], index);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get this node's *named* child at the given index.
+ *
+ * See also {@link Node#isNamed}.
+ * This method is fairly fast, but its cost is technically log(n), so if
+ * you might be iterating over a long list of children, you should use
+ * {@link Node#namedChildren} instead.
+ */
+ namedChild(index) {
+ marshalNode(this);
+ C._ts_node_named_child_wasm(this.tree[0], index);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get this node's child with the given numerical field id.
+ *
+ * See also {@link Node#childForFieldName}. You can
+ * convert a field name to an id using {@link Language#fieldIdForName}.
+ */
+ childForFieldId(fieldId) {
+ marshalNode(this);
+ C._ts_node_child_by_field_id_wasm(this.tree[0], fieldId);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get the first child with the given field name.
+ *
+ * If multiple children may have the same field name, access them using
+ * {@link Node#childrenForFieldName}.
+ */
+ childForFieldName(fieldName) {
+ const fieldId = this.tree.language.fields.indexOf(fieldName);
+ if (fieldId !== -1) return this.childForFieldId(fieldId);
+ return null;
+ }
+ /** Get the field name of this node's child at the given index. */
+ fieldNameForChild(index) {
+ marshalNode(this);
+ const address = C._ts_node_field_name_for_child_wasm(this.tree[0], index);
+ if (!address) return null;
+ return C.AsciiToString(address);
+ }
+ /** Get the field name of this node's named child at the given index. */
+ fieldNameForNamedChild(index) {
+ marshalNode(this);
+ const address = C._ts_node_field_name_for_named_child_wasm(this.tree[0], index);
+ if (!address) return null;
+ return C.AsciiToString(address);
+ }
+ /**
+ * Get an array of this node's children with a given field name.
+ *
+ * See also {@link Node#children}.
+ */
+ childrenForFieldName(fieldName) {
+ const fieldId = this.tree.language.fields.indexOf(fieldName);
+ if (fieldId !== -1 && fieldId !== 0) return this.childrenForFieldId(fieldId);
+ return [];
+ }
+ /**
+ * Get an array of this node's children with a given field id.
+ *
+ * See also {@link Node#childrenForFieldName}.
+ */
+ childrenForFieldId(fieldId) {
+ marshalNode(this);
+ C._ts_node_children_by_field_id_wasm(this.tree[0], fieldId);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ result[i2] = unmarshalNode(this.tree, address);
+ address += SIZE_OF_NODE;
+ }
+ C._free(buffer);
+ }
+ return result;
+ }
+ /** Get the node's first child that contains or starts after the given byte offset. */
+ firstChildForIndex(index) {
+ marshalNode(this);
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ C.setValue(address, index, "i32");
+ C._ts_node_first_child_for_byte_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get the node's first named child that contains or starts after the given byte offset. */
+ firstNamedChildForIndex(index) {
+ marshalNode(this);
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ C.setValue(address, index, "i32");
+ C._ts_node_first_named_child_for_byte_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get this node's number of children. */
+ get childCount() {
+ marshalNode(this);
+ return C._ts_node_child_count_wasm(this.tree[0]);
+ }
+ /**
+ * Get this node's number of *named* children.
+ *
+ * See also {@link Node#isNamed}.
+ */
+ get namedChildCount() {
+ marshalNode(this);
+ return C._ts_node_named_child_count_wasm(this.tree[0]);
+ }
+ /** Get this node's first child. */
+ get firstChild() {
+ return this.child(0);
+ }
+ /**
+ * Get this node's first named child.
+ *
+ * See also {@link Node#isNamed}.
+ */
+ get firstNamedChild() {
+ return this.namedChild(0);
+ }
+ /** Get this node's last child. */
+ get lastChild() {
+ return this.child(this.childCount - 1);
+ }
+ /**
+ * Get this node's last named child.
+ *
+ * See also {@link Node#isNamed}.
+ */
+ get lastNamedChild() {
+ return this.namedChild(this.namedChildCount - 1);
+ }
+ /**
+ * Iterate over this node's children.
+ *
+ * If you're walking the tree recursively, you may want to use the
+ * {@link TreeCursor} APIs directly instead.
+ */
+ get children() {
+ if (!this._children) {
+ marshalNode(this);
+ C._ts_node_children_wasm(this.tree[0]);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ this._children = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ this._children[i2] = unmarshalNode(this.tree, address);
+ address += SIZE_OF_NODE;
+ }
+ C._free(buffer);
+ }
+ }
+ return this._children;
+ }
+ /**
+ * Iterate over this node's named children.
+ *
+ * See also {@link Node#children}.
+ */
+ get namedChildren() {
+ if (!this._namedChildren) {
+ marshalNode(this);
+ C._ts_node_named_children_wasm(this.tree[0]);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ this._namedChildren = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ this._namedChildren[i2] = unmarshalNode(this.tree, address);
+ address += SIZE_OF_NODE;
+ }
+ C._free(buffer);
+ }
+ }
+ return this._namedChildren;
+ }
+ /**
+ * Get the descendants of this node that are the given type, or in the given types array.
+ *
+ * The types array should contain node type strings, which can be retrieved from {@link Language#types}.
+ *
+ * Additionally, a `startPosition` and `endPosition` can be passed in to restrict the search to a byte range.
+ */
+ descendantsOfType(types, startPosition = ZERO_POINT, endPosition = ZERO_POINT) {
+ if (!Array.isArray(types)) types = [types];
+ const symbols = [];
+ const typesBySymbol = this.tree.language.types;
+ for (const node_type of types) {
+ if (node_type == "ERROR") {
+ symbols.push(65535);
+ }
+ }
+ for (let i2 = 0, n = typesBySymbol.length; i2 < n; i2++) {
+ if (types.includes(typesBySymbol[i2])) {
+ symbols.push(i2);
+ }
+ }
+ const symbolsAddress = C._malloc(SIZE_OF_INT * symbols.length);
+ for (let i2 = 0, n = symbols.length; i2 < n; i2++) {
+ C.setValue(symbolsAddress + i2 * SIZE_OF_INT, symbols[i2], "i32");
+ }
+ marshalNode(this);
+ C._ts_node_descendants_of_type_wasm(
+ this.tree[0],
+ symbolsAddress,
+ symbols.length,
+ startPosition.row,
+ startPosition.column,
+ endPosition.row,
+ endPosition.column
+ );
+ const descendantCount = C.getValue(TRANSFER_BUFFER, "i32");
+ const descendantAddress = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(descendantCount);
+ if (descendantCount > 0) {
+ let address = descendantAddress;
+ for (let i2 = 0; i2 < descendantCount; i2++) {
+ result[i2] = unmarshalNode(this.tree, address);
+ address += SIZE_OF_NODE;
+ }
+ }
+ C._free(descendantAddress);
+ C._free(symbolsAddress);
+ return result;
+ }
+ /** Get this node's next sibling. */
+ get nextSibling() {
+ marshalNode(this);
+ C._ts_node_next_sibling_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get this node's previous sibling. */
+ get previousSibling() {
+ marshalNode(this);
+ C._ts_node_prev_sibling_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get this node's next *named* sibling.
+ *
+ * See also {@link Node#isNamed}.
+ */
+ get nextNamedSibling() {
+ marshalNode(this);
+ C._ts_node_next_named_sibling_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get this node's previous *named* sibling.
+ *
+ * See also {@link Node#isNamed}.
+ */
+ get previousNamedSibling() {
+ marshalNode(this);
+ C._ts_node_prev_named_sibling_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get the node's number of descendants, including one for the node itself. */
+ get descendantCount() {
+ marshalNode(this);
+ return C._ts_node_descendant_count_wasm(this.tree[0]);
+ }
+ /**
+ * Get this node's immediate parent.
+ * Prefer {@link Node#childWithDescendant} for iterating over this node's ancestors.
+ */
+ get parent() {
+ marshalNode(this);
+ C._ts_node_parent_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Get the node that contains `descendant`.
+ *
+ * Note that this can return `descendant` itself.
+ */
+ childWithDescendant(descendant) {
+ marshalNode(this);
+ marshalNode(descendant, 1);
+ C._ts_node_child_with_descendant_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get the smallest node within this node that spans the given byte range. */
+ descendantForIndex(start2, end = start2) {
+ if (typeof start2 !== "number" || typeof end !== "number") {
+ throw new Error("Arguments must be numbers");
+ }
+ marshalNode(this);
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ C.setValue(address, start2, "i32");
+ C.setValue(address + SIZE_OF_INT, end, "i32");
+ C._ts_node_descendant_for_index_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get the smallest named node within this node that spans the given byte range. */
+ namedDescendantForIndex(start2, end = start2) {
+ if (typeof start2 !== "number" || typeof end !== "number") {
+ throw new Error("Arguments must be numbers");
+ }
+ marshalNode(this);
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ C.setValue(address, start2, "i32");
+ C.setValue(address + SIZE_OF_INT, end, "i32");
+ C._ts_node_named_descendant_for_index_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get the smallest node within this node that spans the given point range. */
+ descendantForPosition(start2, end = start2) {
+ if (!isPoint(start2) || !isPoint(end)) {
+ throw new Error("Arguments must be {row, column} objects");
+ }
+ marshalNode(this);
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ marshalPoint(address, start2);
+ marshalPoint(address + SIZE_OF_POINT, end);
+ C._ts_node_descendant_for_position_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /** Get the smallest named node within this node that spans the given point range. */
+ namedDescendantForPosition(start2, end = start2) {
+ if (!isPoint(start2) || !isPoint(end)) {
+ throw new Error("Arguments must be {row, column} objects");
+ }
+ marshalNode(this);
+ const address = TRANSFER_BUFFER + SIZE_OF_NODE;
+ marshalPoint(address, start2);
+ marshalPoint(address + SIZE_OF_POINT, end);
+ C._ts_node_named_descendant_for_position_wasm(this.tree[0]);
+ return unmarshalNode(this.tree);
+ }
+ /**
+ * Create a new {@link TreeCursor} starting from this node.
+ *
+ * Note that the given node is considered the root of the cursor,
+ * and the cursor cannot walk outside this node.
+ */
+ walk() {
+ marshalNode(this);
+ C._ts_tree_cursor_new_wasm(this.tree[0]);
+ return new TreeCursor(INTERNAL, this.tree);
+ }
+ /**
+ * Edit this node to keep it in-sync with source code that has been edited.
+ *
+ * This function is only rarely needed. When you edit a syntax tree with
+ * the {@link Tree#edit} method, all of the nodes that you retrieve from
+ * the tree afterward will already reflect the edit. You only need to
+ * use {@link Node#edit} when you have a specific {@link Node} instance that
+ * you want to keep and continue to use after an edit.
+ */
+ edit(edit) {
+ if (this.startIndex >= edit.oldEndIndex) {
+ this.startIndex = edit.newEndIndex + (this.startIndex - edit.oldEndIndex);
+ let subbedPointRow;
+ let subbedPointColumn;
+ if (this.startPosition.row > edit.oldEndPosition.row) {
+ subbedPointRow = this.startPosition.row - edit.oldEndPosition.row;
+ subbedPointColumn = this.startPosition.column;
+ } else {
+ subbedPointRow = 0;
+ subbedPointColumn = this.startPosition.column;
+ if (this.startPosition.column >= edit.oldEndPosition.column) {
+ subbedPointColumn = this.startPosition.column - edit.oldEndPosition.column;
+ }
+ }
+ if (subbedPointRow > 0) {
+ this.startPosition.row += subbedPointRow;
+ this.startPosition.column = subbedPointColumn;
+ } else {
+ this.startPosition.column += subbedPointColumn;
+ }
+ } else if (this.startIndex > edit.startIndex) {
+ this.startIndex = edit.newEndIndex;
+ this.startPosition.row = edit.newEndPosition.row;
+ this.startPosition.column = edit.newEndPosition.column;
+ }
+ }
+ /** Get the S-expression representation of this node. */
+ toString() {
+ marshalNode(this);
+ const address = C._ts_node_to_string_wasm(this.tree[0]);
+ const result = C.AsciiToString(address);
+ C._free(address);
+ return result;
+ }
+};
+
+// src/marshal.ts
+function unmarshalCaptures(query, tree, address, patternIndex, result) {
+ for (let i2 = 0, n = result.length; i2 < n; i2++) {
+ const captureIndex = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const node = unmarshalNode(tree, address);
+ address += SIZE_OF_NODE;
+ result[i2] = { patternIndex, name: query.captureNames[captureIndex], node };
+ }
+ return address;
+}
+__name(unmarshalCaptures, "unmarshalCaptures");
+function marshalNode(node, index = 0) {
+ let address = TRANSFER_BUFFER + index * SIZE_OF_NODE;
+ C.setValue(address, node.id, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, node.startIndex, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, node.startPosition.row, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, node.startPosition.column, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, node[0], "i32");
+}
+__name(marshalNode, "marshalNode");
+function unmarshalNode(tree, address = TRANSFER_BUFFER) {
+ const id = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ if (id === 0) return null;
+ const index = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const row = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const column = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const other = C.getValue(address, "i32");
+ const result = new Node(INTERNAL, {
+ id,
+ tree,
+ startIndex: index,
+ startPosition: { row, column },
+ other
+ });
+ return result;
+}
+__name(unmarshalNode, "unmarshalNode");
+function marshalTreeCursor(cursor, address = TRANSFER_BUFFER) {
+ C.setValue(address + 0 * SIZE_OF_INT, cursor[0], "i32");
+ C.setValue(address + 1 * SIZE_OF_INT, cursor[1], "i32");
+ C.setValue(address + 2 * SIZE_OF_INT, cursor[2], "i32");
+ C.setValue(address + 3 * SIZE_OF_INT, cursor[3], "i32");
+}
+__name(marshalTreeCursor, "marshalTreeCursor");
+function unmarshalTreeCursor(cursor) {
+ cursor[0] = C.getValue(TRANSFER_BUFFER + 0 * SIZE_OF_INT, "i32");
+ cursor[1] = C.getValue(TRANSFER_BUFFER + 1 * SIZE_OF_INT, "i32");
+ cursor[2] = C.getValue(TRANSFER_BUFFER + 2 * SIZE_OF_INT, "i32");
+ cursor[3] = C.getValue(TRANSFER_BUFFER + 3 * SIZE_OF_INT, "i32");
+}
+__name(unmarshalTreeCursor, "unmarshalTreeCursor");
+function marshalPoint(address, point) {
+ C.setValue(address, point.row, "i32");
+ C.setValue(address + SIZE_OF_INT, point.column, "i32");
+}
+__name(marshalPoint, "marshalPoint");
+function unmarshalPoint(address) {
+ const result = {
+ row: C.getValue(address, "i32") >>> 0,
+ column: C.getValue(address + SIZE_OF_INT, "i32") >>> 0
+ };
+ return result;
+}
+__name(unmarshalPoint, "unmarshalPoint");
+function marshalRange(address, range) {
+ marshalPoint(address, range.startPosition);
+ address += SIZE_OF_POINT;
+ marshalPoint(address, range.endPosition);
+ address += SIZE_OF_POINT;
+ C.setValue(address, range.startIndex, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, range.endIndex, "i32");
+ address += SIZE_OF_INT;
+}
+__name(marshalRange, "marshalRange");
+function unmarshalRange(address) {
+ const result = {};
+ result.startPosition = unmarshalPoint(address);
+ address += SIZE_OF_POINT;
+ result.endPosition = unmarshalPoint(address);
+ address += SIZE_OF_POINT;
+ result.startIndex = C.getValue(address, "i32") >>> 0;
+ address += SIZE_OF_INT;
+ result.endIndex = C.getValue(address, "i32") >>> 0;
+ return result;
+}
+__name(unmarshalRange, "unmarshalRange");
+function marshalEdit(edit, address = TRANSFER_BUFFER) {
+ marshalPoint(address, edit.startPosition);
+ address += SIZE_OF_POINT;
+ marshalPoint(address, edit.oldEndPosition);
+ address += SIZE_OF_POINT;
+ marshalPoint(address, edit.newEndPosition);
+ address += SIZE_OF_POINT;
+ C.setValue(address, edit.startIndex, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, edit.oldEndIndex, "i32");
+ address += SIZE_OF_INT;
+ C.setValue(address, edit.newEndIndex, "i32");
+ address += SIZE_OF_INT;
+}
+__name(marshalEdit, "marshalEdit");
+function unmarshalLanguageMetadata(address) {
+ const result = {};
+ result.major_version = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ result.minor_version = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ result.field_count = C.getValue(address, "i32");
+ return result;
+}
+__name(unmarshalLanguageMetadata, "unmarshalLanguageMetadata");
+
+// src/query.ts
+var PREDICATE_STEP_TYPE_CAPTURE = 1;
+var PREDICATE_STEP_TYPE_STRING = 2;
+var QUERY_WORD_REGEX = /[\w-]+/g;
+var CaptureQuantifier = {
+ Zero: 0,
+ ZeroOrOne: 1,
+ ZeroOrMore: 2,
+ One: 3,
+ OneOrMore: 4
+};
+var isCaptureStep = /* @__PURE__ */ __name((step) => step.type === "capture", "isCaptureStep");
+var isStringStep = /* @__PURE__ */ __name((step) => step.type === "string", "isStringStep");
+var QueryErrorKind = {
+ Syntax: 1,
+ NodeName: 2,
+ FieldName: 3,
+ CaptureName: 4,
+ PatternStructure: 5
+};
+var QueryError = class _QueryError extends Error {
+ constructor(kind, info2, index, length) {
+ super(_QueryError.formatMessage(kind, info2));
+ this.kind = kind;
+ this.info = info2;
+ this.index = index;
+ this.length = length;
+ this.name = "QueryError";
+ }
+ static {
+ __name(this, "QueryError");
+ }
+ /** Formats an error message based on the error kind and info */
+ static formatMessage(kind, info2) {
+ switch (kind) {
+ case QueryErrorKind.NodeName:
+ return `Bad node name '${info2.word}'`;
+ case QueryErrorKind.FieldName:
+ return `Bad field name '${info2.word}'`;
+ case QueryErrorKind.CaptureName:
+ return `Bad capture name @${info2.word}`;
+ case QueryErrorKind.PatternStructure:
+ return `Bad pattern structure at offset ${info2.suffix}`;
+ case QueryErrorKind.Syntax:
+ return `Bad syntax at offset ${info2.suffix}`;
+ }
+ }
+};
+function parseAnyPredicate(steps, index, operator, textPredicates) {
+ if (steps.length !== 3) {
+ throw new Error(
+ `Wrong number of arguments to \`#${operator}\` predicate. Expected 2, got ${steps.length - 1}`
+ );
+ }
+ if (!isCaptureStep(steps[1])) {
+ throw new Error(
+ `First argument of \`#${operator}\` predicate must be a capture. Got "${steps[1].value}"`
+ );
+ }
+ const isPositive = operator === "eq?" || operator === "any-eq?";
+ const matchAll = !operator.startsWith("any-");
+ if (isCaptureStep(steps[2])) {
+ const captureName1 = steps[1].name;
+ const captureName2 = steps[2].name;
+ textPredicates[index].push((captures) => {
+ const nodes1 = [];
+ const nodes2 = [];
+ for (const c of captures) {
+ if (c.name === captureName1) nodes1.push(c.node);
+ if (c.name === captureName2) nodes2.push(c.node);
+ }
+ const compare = /* @__PURE__ */ __name((n1, n2, positive) => {
+ return positive ? n1.text === n2.text : n1.text !== n2.text;
+ }, "compare");
+ return matchAll ? nodes1.every((n1) => nodes2.some((n2) => compare(n1, n2, isPositive))) : nodes1.some((n1) => nodes2.some((n2) => compare(n1, n2, isPositive)));
+ });
+ } else {
+ const captureName = steps[1].name;
+ const stringValue = steps[2].value;
+ const matches = /* @__PURE__ */ __name((n) => n.text === stringValue, "matches");
+ const doesNotMatch = /* @__PURE__ */ __name((n) => n.text !== stringValue, "doesNotMatch");
+ textPredicates[index].push((captures) => {
+ const nodes = [];
+ for (const c of captures) {
+ if (c.name === captureName) nodes.push(c.node);
+ }
+ const test = isPositive ? matches : doesNotMatch;
+ return matchAll ? nodes.every(test) : nodes.some(test);
+ });
+ }
+}
+__name(parseAnyPredicate, "parseAnyPredicate");
+function parseMatchPredicate(steps, index, operator, textPredicates) {
+ if (steps.length !== 3) {
+ throw new Error(
+ `Wrong number of arguments to \`#${operator}\` predicate. Expected 2, got ${steps.length - 1}.`
+ );
+ }
+ if (steps[1].type !== "capture") {
+ throw new Error(
+ `First argument of \`#${operator}\` predicate must be a capture. Got "${steps[1].value}".`
+ );
+ }
+ if (steps[2].type !== "string") {
+ throw new Error(
+ `Second argument of \`#${operator}\` predicate must be a string. Got @${steps[2].name}.`
+ );
+ }
+ const isPositive = operator === "match?" || operator === "any-match?";
+ const matchAll = !operator.startsWith("any-");
+ const captureName = steps[1].name;
+ const regex = new RegExp(steps[2].value);
+ textPredicates[index].push((captures) => {
+ const nodes = [];
+ for (const c of captures) {
+ if (c.name === captureName) nodes.push(c.node.text);
+ }
+ const test = /* @__PURE__ */ __name((text, positive) => {
+ return positive ? regex.test(text) : !regex.test(text);
+ }, "test");
+ if (nodes.length === 0) return !isPositive;
+ return matchAll ? nodes.every((text) => test(text, isPositive)) : nodes.some((text) => test(text, isPositive));
+ });
+}
+__name(parseMatchPredicate, "parseMatchPredicate");
+function parseAnyOfPredicate(steps, index, operator, textPredicates) {
+ if (steps.length < 2) {
+ throw new Error(
+ `Wrong number of arguments to \`#${operator}\` predicate. Expected at least 1. Got ${steps.length - 1}.`
+ );
+ }
+ if (steps[1].type !== "capture") {
+ throw new Error(
+ `First argument of \`#${operator}\` predicate must be a capture. Got "${steps[1].value}".`
+ );
+ }
+ const isPositive = operator === "any-of?";
+ const captureName = steps[1].name;
+ const stringSteps = steps.slice(2);
+ if (!stringSteps.every(isStringStep)) {
+ throw new Error(
+ `Arguments to \`#${operator}\` predicate must be strings.".`
+ );
+ }
+ const values = stringSteps.map((s) => s.value);
+ textPredicates[index].push((captures) => {
+ const nodes = [];
+ for (const c of captures) {
+ if (c.name === captureName) nodes.push(c.node.text);
+ }
+ if (nodes.length === 0) return !isPositive;
+ return nodes.every((text) => values.includes(text)) === isPositive;
+ });
+}
+__name(parseAnyOfPredicate, "parseAnyOfPredicate");
+function parseIsPredicate(steps, index, operator, assertedProperties, refutedProperties) {
+ if (steps.length < 2 || steps.length > 3) {
+ throw new Error(
+ `Wrong number of arguments to \`#${operator}\` predicate. Expected 1 or 2. Got ${steps.length - 1}.`
+ );
+ }
+ if (!steps.every(isStringStep)) {
+ throw new Error(
+ `Arguments to \`#${operator}\` predicate must be strings.".`
+ );
+ }
+ const properties = operator === "is?" ? assertedProperties : refutedProperties;
+ if (!properties[index]) properties[index] = {};
+ properties[index][steps[1].value] = steps[2]?.value ?? null;
+}
+__name(parseIsPredicate, "parseIsPredicate");
+function parseSetDirective(steps, index, setProperties) {
+ if (steps.length < 2 || steps.length > 3) {
+ throw new Error(`Wrong number of arguments to \`#set!\` predicate. Expected 1 or 2. Got ${steps.length - 1}.`);
+ }
+ if (!steps.every(isStringStep)) {
+ throw new Error(`Arguments to \`#set!\` predicate must be strings.".`);
+ }
+ if (!setProperties[index]) setProperties[index] = {};
+ setProperties[index][steps[1].value] = steps[2]?.value ?? null;
+}
+__name(parseSetDirective, "parseSetDirective");
+function parsePattern(index, stepType, stepValueId, captureNames, stringValues, steps, textPredicates, predicates, setProperties, assertedProperties, refutedProperties) {
+ if (stepType === PREDICATE_STEP_TYPE_CAPTURE) {
+ const name2 = captureNames[stepValueId];
+ steps.push({ type: "capture", name: name2 });
+ } else if (stepType === PREDICATE_STEP_TYPE_STRING) {
+ steps.push({ type: "string", value: stringValues[stepValueId] });
+ } else if (steps.length > 0) {
+ if (steps[0].type !== "string") {
+ throw new Error("Predicates must begin with a literal value");
+ }
+ const operator = steps[0].value;
+ switch (operator) {
+ case "any-not-eq?":
+ case "not-eq?":
+ case "any-eq?":
+ case "eq?":
+ parseAnyPredicate(steps, index, operator, textPredicates);
+ break;
+ case "any-not-match?":
+ case "not-match?":
+ case "any-match?":
+ case "match?":
+ parseMatchPredicate(steps, index, operator, textPredicates);
+ break;
+ case "not-any-of?":
+ case "any-of?":
+ parseAnyOfPredicate(steps, index, operator, textPredicates);
+ break;
+ case "is?":
+ case "is-not?":
+ parseIsPredicate(steps, index, operator, assertedProperties, refutedProperties);
+ break;
+ case "set!":
+ parseSetDirective(steps, index, setProperties);
+ break;
+ default:
+ predicates[index].push({ operator, operands: steps.slice(1) });
+ }
+ steps.length = 0;
+ }
+}
+__name(parsePattern, "parsePattern");
+var Query = class {
+ static {
+ __name(this, "Query");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ exceededMatchLimit;
+ /** @internal */
+ textPredicates;
+ /** The names of the captures used in the query. */
+ captureNames;
+ /** The quantifiers of the captures used in the query. */
+ captureQuantifiers;
+ /**
+ * The other user-defined predicates associated with the given index.
+ *
+ * This includes predicates with operators other than:
+ * - `match?`
+ * - `eq?` and `not-eq?`
+ * - `any-of?` and `not-any-of?`
+ * - `is?` and `is-not?`
+ * - `set!`
+ */
+ predicates;
+ /** The properties for predicates with the operator `set!`. */
+ setProperties;
+ /** The properties for predicates with the operator `is?`. */
+ assertedProperties;
+ /** The properties for predicates with the operator `is-not?`. */
+ refutedProperties;
+ /** The maximum number of in-progress matches for this cursor. */
+ matchLimit;
+ /**
+ * Create a new query from a string containing one or more S-expression
+ * patterns.
+ *
+ * The query is associated with a particular language, and can only be run
+ * on syntax nodes parsed with that language. References to Queries can be
+ * shared between multiple threads.
+ *
+ * @link {@see https://tree-sitter.github.io/tree-sitter/using-parsers/queries}
+ */
+ constructor(language, source) {
+ const sourceLength = C.lengthBytesUTF8(source);
+ const sourceAddress = C._malloc(sourceLength + 1);
+ C.stringToUTF8(source, sourceAddress, sourceLength + 1);
+ const address = C._ts_query_new(
+ language[0],
+ sourceAddress,
+ sourceLength,
+ TRANSFER_BUFFER,
+ TRANSFER_BUFFER + SIZE_OF_INT
+ );
+ if (!address) {
+ const errorId = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const errorByte = C.getValue(TRANSFER_BUFFER, "i32");
+ const errorIndex = C.UTF8ToString(sourceAddress, errorByte).length;
+ const suffix = source.slice(errorIndex, errorIndex + 100).split("\n")[0];
+ const word = suffix.match(QUERY_WORD_REGEX)?.[0] ?? "";
+ C._free(sourceAddress);
+ switch (errorId) {
+ case QueryErrorKind.Syntax:
+ throw new QueryError(QueryErrorKind.Syntax, { suffix: `${errorIndex}: '${suffix}'...` }, errorIndex, 0);
+ case QueryErrorKind.NodeName:
+ throw new QueryError(errorId, { word }, errorIndex, word.length);
+ case QueryErrorKind.FieldName:
+ throw new QueryError(errorId, { word }, errorIndex, word.length);
+ case QueryErrorKind.CaptureName:
+ throw new QueryError(errorId, { word }, errorIndex, word.length);
+ case QueryErrorKind.PatternStructure:
+ throw new QueryError(errorId, { suffix: `${errorIndex}: '${suffix}'...` }, errorIndex, 0);
+ }
+ }
+ const stringCount = C._ts_query_string_count(address);
+ const captureCount = C._ts_query_capture_count(address);
+ const patternCount = C._ts_query_pattern_count(address);
+ const captureNames = new Array(captureCount);
+ const captureQuantifiers = new Array(patternCount);
+ const stringValues = new Array(stringCount);
+ for (let i2 = 0; i2 < captureCount; i2++) {
+ const nameAddress = C._ts_query_capture_name_for_id(
+ address,
+ i2,
+ TRANSFER_BUFFER
+ );
+ const nameLength = C.getValue(TRANSFER_BUFFER, "i32");
+ captureNames[i2] = C.UTF8ToString(nameAddress, nameLength);
+ }
+ for (let i2 = 0; i2 < patternCount; i2++) {
+ const captureQuantifiersArray = new Array(captureCount);
+ for (let j = 0; j < captureCount; j++) {
+ const quantifier = C._ts_query_capture_quantifier_for_id(address, i2, j);
+ captureQuantifiersArray[j] = quantifier;
+ }
+ captureQuantifiers[i2] = captureQuantifiersArray;
+ }
+ for (let i2 = 0; i2 < stringCount; i2++) {
+ const valueAddress = C._ts_query_string_value_for_id(
+ address,
+ i2,
+ TRANSFER_BUFFER
+ );
+ const nameLength = C.getValue(TRANSFER_BUFFER, "i32");
+ stringValues[i2] = C.UTF8ToString(valueAddress, nameLength);
+ }
+ const setProperties = new Array(patternCount);
+ const assertedProperties = new Array(patternCount);
+ const refutedProperties = new Array(patternCount);
+ const predicates = new Array(patternCount);
+ const textPredicates = new Array(patternCount);
+ for (let i2 = 0; i2 < patternCount; i2++) {
+ const predicatesAddress = C._ts_query_predicates_for_pattern(address, i2, TRANSFER_BUFFER);
+ const stepCount = C.getValue(TRANSFER_BUFFER, "i32");
+ predicates[i2] = [];
+ textPredicates[i2] = [];
+ const steps = new Array();
+ let stepAddress = predicatesAddress;
+ for (let j = 0; j < stepCount; j++) {
+ const stepType = C.getValue(stepAddress, "i32");
+ stepAddress += SIZE_OF_INT;
+ const stepValueId = C.getValue(stepAddress, "i32");
+ stepAddress += SIZE_OF_INT;
+ parsePattern(
+ i2,
+ stepType,
+ stepValueId,
+ captureNames,
+ stringValues,
+ steps,
+ textPredicates,
+ predicates,
+ setProperties,
+ assertedProperties,
+ refutedProperties
+ );
+ }
+ Object.freeze(textPredicates[i2]);
+ Object.freeze(predicates[i2]);
+ Object.freeze(setProperties[i2]);
+ Object.freeze(assertedProperties[i2]);
+ Object.freeze(refutedProperties[i2]);
+ }
+ C._free(sourceAddress);
+ this[0] = address;
+ this.captureNames = captureNames;
+ this.captureQuantifiers = captureQuantifiers;
+ this.textPredicates = textPredicates;
+ this.predicates = predicates;
+ this.setProperties = setProperties;
+ this.assertedProperties = assertedProperties;
+ this.refutedProperties = refutedProperties;
+ this.exceededMatchLimit = false;
+ }
+ /** Delete the query, freeing its resources. */
+ delete() {
+ C._ts_query_delete(this[0]);
+ this[0] = 0;
+ }
+ /**
+ * Iterate over all of the matches in the order that they were found.
+ *
+ * Each match contains the index of the pattern that matched, and a list of
+ * captures. Because multiple patterns can match the same set of nodes,
+ * one match may contain captures that appear *before* some of the
+ * captures from a previous match.
+ *
+ * @param {Node} node - The node to execute the query on.
+ *
+ * @param {QueryOptions} options - Options for query execution.
+ */
+ matches(node, options = {}) {
+ const startPosition = options.startPosition ?? ZERO_POINT;
+ const endPosition = options.endPosition ?? ZERO_POINT;
+ const startIndex = options.startIndex ?? 0;
+ const endIndex = options.endIndex ?? 0;
+ const matchLimit = options.matchLimit ?? 4294967295;
+ const maxStartDepth = options.maxStartDepth ?? 4294967295;
+ const timeoutMicros = options.timeoutMicros ?? 0;
+ const progressCallback = options.progressCallback;
+ if (typeof matchLimit !== "number") {
+ throw new Error("Arguments must be numbers");
+ }
+ this.matchLimit = matchLimit;
+ if (endIndex !== 0 && startIndex > endIndex) {
+ throw new Error("`startIndex` cannot be greater than `endIndex`");
+ }
+ if (endPosition !== ZERO_POINT && (startPosition.row > endPosition.row || startPosition.row === endPosition.row && startPosition.column > endPosition.column)) {
+ throw new Error("`startPosition` cannot be greater than `endPosition`");
+ }
+ if (progressCallback) {
+ C.currentQueryProgressCallback = progressCallback;
+ }
+ marshalNode(node);
+ C._ts_query_matches_wasm(
+ this[0],
+ node.tree[0],
+ startPosition.row,
+ startPosition.column,
+ endPosition.row,
+ endPosition.column,
+ startIndex,
+ endIndex,
+ matchLimit,
+ maxStartDepth,
+ timeoutMicros
+ );
+ const rawCount = C.getValue(TRANSFER_BUFFER, "i32");
+ const startAddress = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const didExceedMatchLimit = C.getValue(TRANSFER_BUFFER + 2 * SIZE_OF_INT, "i32");
+ const result = new Array(rawCount);
+ this.exceededMatchLimit = Boolean(didExceedMatchLimit);
+ let filteredCount = 0;
+ let address = startAddress;
+ for (let i2 = 0; i2 < rawCount; i2++) {
+ const patternIndex = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const captureCount = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const captures = new Array(captureCount);
+ address = unmarshalCaptures(this, node.tree, address, patternIndex, captures);
+ if (this.textPredicates[patternIndex].every((p) => p(captures))) {
+ result[filteredCount] = { pattern: patternIndex, patternIndex, captures };
+ const setProperties = this.setProperties[patternIndex];
+ result[filteredCount].setProperties = setProperties;
+ const assertedProperties = this.assertedProperties[patternIndex];
+ result[filteredCount].assertedProperties = assertedProperties;
+ const refutedProperties = this.refutedProperties[patternIndex];
+ result[filteredCount].refutedProperties = refutedProperties;
+ filteredCount++;
+ }
+ }
+ result.length = filteredCount;
+ C._free(startAddress);
+ C.currentQueryProgressCallback = null;
+ return result;
+ }
+ /**
+ * Iterate over all of the individual captures in the order that they
+ * appear.
+ *
+ * This is useful if you don't care about which pattern matched, and just
+ * want a single, ordered sequence of captures.
+ *
+ * @param {Node} node - The node to execute the query on.
+ *
+ * @param {QueryOptions} options - Options for query execution.
+ */
+ captures(node, options = {}) {
+ const startPosition = options.startPosition ?? ZERO_POINT;
+ const endPosition = options.endPosition ?? ZERO_POINT;
+ const startIndex = options.startIndex ?? 0;
+ const endIndex = options.endIndex ?? 0;
+ const matchLimit = options.matchLimit ?? 4294967295;
+ const maxStartDepth = options.maxStartDepth ?? 4294967295;
+ const timeoutMicros = options.timeoutMicros ?? 0;
+ const progressCallback = options.progressCallback;
+ if (typeof matchLimit !== "number") {
+ throw new Error("Arguments must be numbers");
+ }
+ this.matchLimit = matchLimit;
+ if (endIndex !== 0 && startIndex > endIndex) {
+ throw new Error("`startIndex` cannot be greater than `endIndex`");
+ }
+ if (endPosition !== ZERO_POINT && (startPosition.row > endPosition.row || startPosition.row === endPosition.row && startPosition.column > endPosition.column)) {
+ throw new Error("`startPosition` cannot be greater than `endPosition`");
+ }
+ if (progressCallback) {
+ C.currentQueryProgressCallback = progressCallback;
+ }
+ marshalNode(node);
+ C._ts_query_captures_wasm(
+ this[0],
+ node.tree[0],
+ startPosition.row,
+ startPosition.column,
+ endPosition.row,
+ endPosition.column,
+ startIndex,
+ endIndex,
+ matchLimit,
+ maxStartDepth,
+ timeoutMicros
+ );
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const startAddress = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const didExceedMatchLimit = C.getValue(TRANSFER_BUFFER + 2 * SIZE_OF_INT, "i32");
+ const result = new Array();
+ this.exceededMatchLimit = Boolean(didExceedMatchLimit);
+ const captures = new Array();
+ let address = startAddress;
+ for (let i2 = 0; i2 < count; i2++) {
+ const patternIndex = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const captureCount = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ const captureIndex = C.getValue(address, "i32");
+ address += SIZE_OF_INT;
+ captures.length = captureCount;
+ address = unmarshalCaptures(this, node.tree, address, patternIndex, captures);
+ if (this.textPredicates[patternIndex].every((p) => p(captures))) {
+ const capture = captures[captureIndex];
+ const setProperties = this.setProperties[patternIndex];
+ capture.setProperties = setProperties;
+ const assertedProperties = this.assertedProperties[patternIndex];
+ capture.assertedProperties = assertedProperties;
+ const refutedProperties = this.refutedProperties[patternIndex];
+ capture.refutedProperties = refutedProperties;
+ result.push(capture);
+ }
+ }
+ C._free(startAddress);
+ C.currentQueryProgressCallback = null;
+ return result;
+ }
+ /** Get the predicates for a given pattern. */
+ predicatesForPattern(patternIndex) {
+ return this.predicates[patternIndex];
+ }
+ /**
+ * Disable a certain capture within a query.
+ *
+ * This prevents the capture from being returned in matches, and also
+ * avoids any resource usage associated with recording the capture.
+ */
+ disableCapture(captureName) {
+ const captureNameLength = C.lengthBytesUTF8(captureName);
+ const captureNameAddress = C._malloc(captureNameLength + 1);
+ C.stringToUTF8(captureName, captureNameAddress, captureNameLength + 1);
+ C._ts_query_disable_capture(this[0], captureNameAddress, captureNameLength);
+ C._free(captureNameAddress);
+ }
+ /**
+ * Disable a certain pattern within a query.
+ *
+ * This prevents the pattern from matching, and also avoids any resource
+ * usage associated with the pattern. This throws an error if the pattern
+ * index is out of bounds.
+ */
+ disablePattern(patternIndex) {
+ if (patternIndex >= this.predicates.length) {
+ throw new Error(
+ `Pattern index is ${patternIndex} but the pattern count is ${this.predicates.length}`
+ );
+ }
+ C._ts_query_disable_pattern(this[0], patternIndex);
+ }
+ /**
+ * Check if, on its last execution, this cursor exceeded its maximum number
+ * of in-progress matches.
+ */
+ didExceedMatchLimit() {
+ return this.exceededMatchLimit;
+ }
+ /** Get the byte offset where the given pattern starts in the query's source. */
+ startIndexForPattern(patternIndex) {
+ if (patternIndex >= this.predicates.length) {
+ throw new Error(
+ `Pattern index is ${patternIndex} but the pattern count is ${this.predicates.length}`
+ );
+ }
+ return C._ts_query_start_byte_for_pattern(this[0], patternIndex);
+ }
+ /** Get the byte offset where the given pattern ends in the query's source. */
+ endIndexForPattern(patternIndex) {
+ if (patternIndex >= this.predicates.length) {
+ throw new Error(
+ `Pattern index is ${patternIndex} but the pattern count is ${this.predicates.length}`
+ );
+ }
+ return C._ts_query_end_byte_for_pattern(this[0], patternIndex);
+ }
+ /** Get the number of patterns in the query. */
+ patternCount() {
+ return C._ts_query_pattern_count(this[0]);
+ }
+ /** Get the index for a given capture name. */
+ captureIndexForName(captureName) {
+ return this.captureNames.indexOf(captureName);
+ }
+ /** Check if a given pattern within a query has a single root node. */
+ isPatternRooted(patternIndex) {
+ return C._ts_query_is_pattern_rooted(this[0], patternIndex) === 1;
+ }
+ /** Check if a given pattern within a query has a single root node. */
+ isPatternNonLocal(patternIndex) {
+ return C._ts_query_is_pattern_non_local(this[0], patternIndex) === 1;
+ }
+ /**
+ * Check if a given step in a query is 'definite'.
+ *
+ * A query step is 'definite' if its parent pattern will be guaranteed to
+ * match successfully once it reaches the step.
+ */
+ isPatternGuaranteedAtStep(byteIndex) {
+ return C._ts_query_is_pattern_guaranteed_at_step(this[0], byteIndex) === 1;
+ }
+};
+
+// src/language.ts
+var LANGUAGE_FUNCTION_REGEX = /^tree_sitter_\w+$/;
+var Language = class _Language {
+ static {
+ __name(this, "Language");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /**
+ * A list of all node types in the language. The index of each type in this
+ * array is its node type id.
+ */
+ types;
+ /**
+ * A list of all field names in the language. The index of each field name in
+ * this array is its field id.
+ */
+ fields;
+ /** @internal */
+ constructor(internal, address) {
+ assertInternal(internal);
+ this[0] = address;
+ this.types = new Array(C._ts_language_symbol_count(this[0]));
+ for (let i2 = 0, n = this.types.length; i2 < n; i2++) {
+ if (C._ts_language_symbol_type(this[0], i2) < 2) {
+ this.types[i2] = C.UTF8ToString(C._ts_language_symbol_name(this[0], i2));
+ }
+ }
+ this.fields = new Array(C._ts_language_field_count(this[0]) + 1);
+ for (let i2 = 0, n = this.fields.length; i2 < n; i2++) {
+ const fieldName = C._ts_language_field_name_for_id(this[0], i2);
+ if (fieldName !== 0) {
+ this.fields[i2] = C.UTF8ToString(fieldName);
+ } else {
+ this.fields[i2] = null;
+ }
+ }
+ }
+ /**
+ * Gets the name of the language.
+ */
+ get name() {
+ const ptr = C._ts_language_name(this[0]);
+ if (ptr === 0) return null;
+ return C.UTF8ToString(ptr);
+ }
+ /**
+ * @deprecated since version 0.25.0, use {@link Language#abiVersion} instead
+ * Gets the version of the language.
+ */
+ get version() {
+ return C._ts_language_version(this[0]);
+ }
+ /**
+ * Gets the ABI version of the language.
+ */
+ get abiVersion() {
+ return C._ts_language_abi_version(this[0]);
+ }
+ /**
+ * Get the metadata for this language. This information is generated by the
+ * CLI, and relies on the language author providing the correct metadata in
+ * the language's `tree-sitter.json` file.
+ */
+ get metadata() {
+ C._ts_language_metadata(this[0]);
+ const length = C.getValue(TRANSFER_BUFFER, "i32");
+ const address = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ if (length === 0) return null;
+ return unmarshalLanguageMetadata(address);
+ }
+ /**
+ * Gets the number of fields in the language.
+ */
+ get fieldCount() {
+ return this.fields.length - 1;
+ }
+ /**
+ * Gets the number of states in the language.
+ */
+ get stateCount() {
+ return C._ts_language_state_count(this[0]);
+ }
+ /**
+ * Get the field id for a field name.
+ */
+ fieldIdForName(fieldName) {
+ const result = this.fields.indexOf(fieldName);
+ return result !== -1 ? result : null;
+ }
+ /**
+ * Get the field name for a field id.
+ */
+ fieldNameForId(fieldId) {
+ return this.fields[fieldId] ?? null;
+ }
+ /**
+ * Get the node type id for a node type name.
+ */
+ idForNodeType(type, named) {
+ const typeLength = C.lengthBytesUTF8(type);
+ const typeAddress = C._malloc(typeLength + 1);
+ C.stringToUTF8(type, typeAddress, typeLength + 1);
+ const result = C._ts_language_symbol_for_name(this[0], typeAddress, typeLength, named ? 1 : 0);
+ C._free(typeAddress);
+ return result || null;
+ }
+ /**
+ * Gets the number of node types in the language.
+ */
+ get nodeTypeCount() {
+ return C._ts_language_symbol_count(this[0]);
+ }
+ /**
+ * Get the node type name for a node type id.
+ */
+ nodeTypeForId(typeId) {
+ const name2 = C._ts_language_symbol_name(this[0], typeId);
+ return name2 ? C.UTF8ToString(name2) : null;
+ }
+ /**
+ * Check if a node type is named.
+ *
+ * @see {@link https://tree-sitter.github.io/tree-sitter/using-parsers/2-basic-parsing.html#named-vs-anonymous-nodes}
+ */
+ nodeTypeIsNamed(typeId) {
+ return C._ts_language_type_is_named_wasm(this[0], typeId) ? true : false;
+ }
+ /**
+ * Check if a node type is visible.
+ */
+ nodeTypeIsVisible(typeId) {
+ return C._ts_language_type_is_visible_wasm(this[0], typeId) ? true : false;
+ }
+ /**
+ * Get the supertypes ids of this language.
+ *
+ * @see {@link https://tree-sitter.github.io/tree-sitter/using-parsers/6-static-node-types.html?highlight=supertype#supertype-nodes}
+ */
+ get supertypes() {
+ C._ts_language_supertypes_wasm(this[0]);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ result[i2] = C.getValue(address, "i16");
+ address += SIZE_OF_SHORT;
+ }
+ }
+ return result;
+ }
+ /**
+ * Get the subtype ids for a given supertype node id.
+ */
+ subtypes(supertype) {
+ C._ts_language_subtypes_wasm(this[0], supertype);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ result[i2] = C.getValue(address, "i16");
+ address += SIZE_OF_SHORT;
+ }
+ }
+ return result;
+ }
+ /**
+ * Get the next state id for a given state id and node type id.
+ */
+ nextState(stateId, typeId) {
+ return C._ts_language_next_state(this[0], stateId, typeId);
+ }
+ /**
+ * Create a new lookahead iterator for this language and parse state.
+ *
+ * This returns `null` if state is invalid for this language.
+ *
+ * Iterating {@link LookaheadIterator} will yield valid symbols in the given
+ * parse state. Newly created lookahead iterators will return the `ERROR`
+ * symbol from {@link LookaheadIterator#currentType}.
+ *
+ * Lookahead iterators can be useful for generating suggestions and improving
+ * syntax error diagnostics. To get symbols valid in an `ERROR` node, use the
+ * lookahead iterator on its first leaf node state. For `MISSING` nodes, a
+ * lookahead iterator created on the previous non-extra leaf node may be
+ * appropriate.
+ */
+ lookaheadIterator(stateId) {
+ const address = C._ts_lookahead_iterator_new(this[0], stateId);
+ if (address) return new LookaheadIterator(INTERNAL, address, this);
+ return null;
+ }
+ /**
+ * @deprecated since version 0.25.0, call `new` on a {@link Query} instead
+ *
+ * Create a new query from a string containing one or more S-expression
+ * patterns.
+ *
+ * The query is associated with a particular language, and can only be run
+ * on syntax nodes parsed with that language. References to Queries can be
+ * shared between multiple threads.
+ *
+ * @link {@see https://tree-sitter.github.io/tree-sitter/using-parsers/queries}
+ */
+ query(source) {
+ console.warn("Language.query is deprecated. Use new Query(language, source) instead.");
+ return new Query(this, source);
+ }
+ /**
+ * Load a language from a WebAssembly module.
+ * The module can be provided as a path to a file or as a buffer.
+ */
+ static async load(input) {
+ let bytes;
+ if (input instanceof Uint8Array) {
+ bytes = Promise.resolve(input);
+ } else {
+ if (globalThis.process?.versions.node) {
+ const fs2 = await import("fs/promises");
+ bytes = fs2.readFile(input);
+ } else {
+ bytes = fetch(input).then((response) => response.arrayBuffer().then((buffer) => {
+ if (response.ok) {
+ return new Uint8Array(buffer);
+ } else {
+ const body2 = new TextDecoder("utf-8").decode(buffer);
+ throw new Error(`Language.load failed with status ${response.status}.
+
+${body2}`);
+ }
+ }));
+ }
+ }
+ const mod = await C.loadWebAssemblyModule(await bytes, { loadAsync: true });
+ const symbolNames = Object.keys(mod);
+ const functionName = symbolNames.find((key) => LANGUAGE_FUNCTION_REGEX.test(key) && !key.includes("external_scanner_"));
+ if (!functionName) {
+ console.log(`Couldn't find language function in WASM file. Symbols:
+${JSON.stringify(symbolNames, null, 2)}`);
+ throw new Error("Language.load failed: no language function found in WASM file");
+ }
+ const languageAddress = mod[functionName]();
+ return new _Language(INTERNAL, languageAddress);
+ }
+};
+
+// lib/tree-sitter.mjs
+var Module2 = (() => {
+ var _scriptName = import.meta.url;
+ return async function(moduleArg = {}) {
+ var moduleRtn;
+ var Module = moduleArg;
+ var readyPromiseResolve, readyPromiseReject;
+ var readyPromise = new Promise((resolve, reject) => {
+ readyPromiseResolve = resolve;
+ readyPromiseReject = reject;
+ });
+ var ENVIRONMENT_IS_WEB = typeof window == "object";
+ var ENVIRONMENT_IS_WORKER = typeof WorkerGlobalScope != "undefined";
+ var ENVIRONMENT_IS_NODE = typeof process == "object" && typeof process.versions == "object" && typeof process.versions.node == "string" && process.type != "renderer";
+ var ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER;
+ if (ENVIRONMENT_IS_NODE) {
+ const { createRequire } = await import("module");
+ var require = createRequire(import.meta.url);
+ }
+ Module.currentQueryProgressCallback = null;
+ Module.currentProgressCallback = null;
+ Module.currentLogCallback = null;
+ Module.currentParseCallback = null;
+ var moduleOverrides = Object.assign({}, Module);
+ var arguments_ = [];
+ var thisProgram = "./this.program";
+ var quit_ = /* @__PURE__ */ __name((status, toThrow) => {
+ throw toThrow;
+ }, "quit_");
+ var scriptDirectory = "";
+ function locateFile(path) {
+ if (Module["locateFile"]) {
+ return Module["locateFile"](path, scriptDirectory);
+ }
+ return scriptDirectory + path;
+ }
+ __name(locateFile, "locateFile");
+ var readAsync, readBinary;
+ if (ENVIRONMENT_IS_NODE) {
+ var fs = require("fs");
+ var nodePath = require("path");
+ if (!import.meta.url.startsWith("data:")) {
+ scriptDirectory = nodePath.dirname(require("url").fileURLToPath(import.meta.url)) + "/";
+ }
+ readBinary = /* @__PURE__ */ __name((filename) => {
+ filename = isFileURI(filename) ? new URL(filename) : filename;
+ var ret = fs.readFileSync(filename);
+ return ret;
+ }, "readBinary");
+ readAsync = /* @__PURE__ */ __name(async (filename, binary2 = true) => {
+ filename = isFileURI(filename) ? new URL(filename) : filename;
+ var ret = fs.readFileSync(filename, binary2 ? void 0 : "utf8");
+ return ret;
+ }, "readAsync");
+ if (!Module["thisProgram"] && process.argv.length > 1) {
+ thisProgram = process.argv[1].replace(/\\/g, "/");
+ }
+ arguments_ = process.argv.slice(2);
+ quit_ = /* @__PURE__ */ __name((status, toThrow) => {
+ process.exitCode = status;
+ throw toThrow;
+ }, "quit_");
+ } else if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) {
+ if (ENVIRONMENT_IS_WORKER) {
+ scriptDirectory = self.location.href;
+ } else if (typeof document != "undefined" && document.currentScript) {
+ scriptDirectory = document.currentScript.src;
+ }
+ if (_scriptName) {
+ scriptDirectory = _scriptName;
+ }
+ if (scriptDirectory.startsWith("blob:")) {
+ scriptDirectory = "";
+ } else {
+ scriptDirectory = scriptDirectory.slice(0, scriptDirectory.replace(/[?#].*/, "").lastIndexOf("/") + 1);
+ }
+ {
+ if (ENVIRONMENT_IS_WORKER) {
+ readBinary = /* @__PURE__ */ __name((url) => {
+ var xhr = new XMLHttpRequest();
+ xhr.open("GET", url, false);
+ xhr.responseType = "arraybuffer";
+ xhr.send(null);
+ return new Uint8Array(
+ /** @type{!ArrayBuffer} */
+ xhr.response
+ );
+ }, "readBinary");
+ }
+ readAsync = /* @__PURE__ */ __name(async (url) => {
+ if (isFileURI(url)) {
+ return new Promise((resolve, reject) => {
+ var xhr = new XMLHttpRequest();
+ xhr.open("GET", url, true);
+ xhr.responseType = "arraybuffer";
+ xhr.onload = () => {
+ if (xhr.status == 200 || xhr.status == 0 && xhr.response) {
+ resolve(xhr.response);
+ return;
+ }
+ reject(xhr.status);
+ };
+ xhr.onerror = reject;
+ xhr.send(null);
+ });
+ }
+ var response = await fetch(url, {
+ credentials: "same-origin"
+ });
+ if (response.ok) {
+ return response.arrayBuffer();
+ }
+ throw new Error(response.status + " : " + response.url);
+ }, "readAsync");
+ }
+ } else {
+ }
+ var out = Module["print"] || console.log.bind(console);
+ var err = Module["printErr"] || console.error.bind(console);
+ Object.assign(Module, moduleOverrides);
+ moduleOverrides = null;
+ if (Module["arguments"]) arguments_ = Module["arguments"];
+ if (Module["thisProgram"]) thisProgram = Module["thisProgram"];
+ var dynamicLibraries = Module["dynamicLibraries"] || [];
+ var wasmBinary = Module["wasmBinary"];
+ var wasmMemory;
+ var ABORT = false;
+ var EXITSTATUS;
+ function assert(condition, text) {
+ if (!condition) {
+ abort(text);
+ }
+ }
+ __name(assert, "assert");
+ var HEAP, HEAP8, HEAPU8, HEAP16, HEAPU16, HEAP32, HEAPU32, HEAPF32, HEAP64, HEAPU64, HEAPF64;
+ var HEAP_DATA_VIEW;
+ var runtimeInitialized = false;
+ var isFileURI = /* @__PURE__ */ __name((filename) => filename.startsWith("file://"), "isFileURI");
+ function updateMemoryViews() {
+ var b = wasmMemory.buffer;
+ Module["HEAP_DATA_VIEW"] = HEAP_DATA_VIEW = new DataView(b);
+ Module["HEAP8"] = HEAP8 = new Int8Array(b);
+ Module["HEAP16"] = HEAP16 = new Int16Array(b);
+ Module["HEAPU8"] = HEAPU8 = new Uint8Array(b);
+ Module["HEAPU16"] = HEAPU16 = new Uint16Array(b);
+ Module["HEAP32"] = HEAP32 = new Int32Array(b);
+ Module["HEAPU32"] = HEAPU32 = new Uint32Array(b);
+ Module["HEAPF32"] = HEAPF32 = new Float32Array(b);
+ Module["HEAPF64"] = HEAPF64 = new Float64Array(b);
+ Module["HEAP64"] = HEAP64 = new BigInt64Array(b);
+ Module["HEAPU64"] = HEAPU64 = new BigUint64Array(b);
+ }
+ __name(updateMemoryViews, "updateMemoryViews");
+ if (Module["wasmMemory"]) {
+ wasmMemory = Module["wasmMemory"];
+ } else {
+ var INITIAL_MEMORY = Module["INITIAL_MEMORY"] || 33554432;
+ wasmMemory = new WebAssembly.Memory({
+ "initial": INITIAL_MEMORY / 65536,
+ // In theory we should not need to emit the maximum if we want "unlimited"
+ // or 4GB of memory, but VMs error on that atm, see
+ // https://github.com/emscripten-core/emscripten/issues/14130
+ // And in the pthreads case we definitely need to emit a maximum. So
+ // always emit one.
+ "maximum": 32768
+ });
+ }
+ updateMemoryViews();
+ var __RELOC_FUNCS__ = [];
+ function preRun() {
+ if (Module["preRun"]) {
+ if (typeof Module["preRun"] == "function") Module["preRun"] = [Module["preRun"]];
+ while (Module["preRun"].length) {
+ addOnPreRun(Module["preRun"].shift());
+ }
+ }
+ callRuntimeCallbacks(onPreRuns);
+ }
+ __name(preRun, "preRun");
+ function initRuntime() {
+ runtimeInitialized = true;
+ callRuntimeCallbacks(__RELOC_FUNCS__);
+ wasmExports["__wasm_call_ctors"]();
+ callRuntimeCallbacks(onPostCtors);
+ }
+ __name(initRuntime, "initRuntime");
+ function preMain() {
+ }
+ __name(preMain, "preMain");
+ function postRun() {
+ if (Module["postRun"]) {
+ if (typeof Module["postRun"] == "function") Module["postRun"] = [Module["postRun"]];
+ while (Module["postRun"].length) {
+ addOnPostRun(Module["postRun"].shift());
+ }
+ }
+ callRuntimeCallbacks(onPostRuns);
+ }
+ __name(postRun, "postRun");
+ var runDependencies = 0;
+ var dependenciesFulfilled = null;
+ function getUniqueRunDependency(id) {
+ return id;
+ }
+ __name(getUniqueRunDependency, "getUniqueRunDependency");
+ function addRunDependency(id) {
+ runDependencies++;
+ Module["monitorRunDependencies"]?.(runDependencies);
+ }
+ __name(addRunDependency, "addRunDependency");
+ function removeRunDependency(id) {
+ runDependencies--;
+ Module["monitorRunDependencies"]?.(runDependencies);
+ if (runDependencies == 0) {
+ if (dependenciesFulfilled) {
+ var callback = dependenciesFulfilled;
+ dependenciesFulfilled = null;
+ callback();
+ }
+ }
+ }
+ __name(removeRunDependency, "removeRunDependency");
+ function abort(what) {
+ Module["onAbort"]?.(what);
+ what = "Aborted(" + what + ")";
+ err(what);
+ ABORT = true;
+ what += ". Build with -sASSERTIONS for more info.";
+ var e = new WebAssembly.RuntimeError(what);
+ readyPromiseReject(e);
+ throw e;
+ }
+ __name(abort, "abort");
+ var wasmBinaryFile;
+ function findWasmBinary() {
+ if (Module["locateFile"]) {
+ return locateFile("tree-sitter.wasm");
+ }
+ return new URL("tree-sitter.wasm", import.meta.url).href;
+ }
+ __name(findWasmBinary, "findWasmBinary");
+ function getBinarySync(file) {
+ if (file == wasmBinaryFile && wasmBinary) {
+ return new Uint8Array(wasmBinary);
+ }
+ if (readBinary) {
+ return readBinary(file);
+ }
+ throw "both async and sync fetching of the wasm failed";
+ }
+ __name(getBinarySync, "getBinarySync");
+ async function getWasmBinary(binaryFile) {
+ if (!wasmBinary) {
+ try {
+ var response = await readAsync(binaryFile);
+ return new Uint8Array(response);
+ } catch {
+ }
+ }
+ return getBinarySync(binaryFile);
+ }
+ __name(getWasmBinary, "getWasmBinary");
+ async function instantiateArrayBuffer(binaryFile, imports) {
+ try {
+ var binary2 = await getWasmBinary(binaryFile);
+ var instance2 = await WebAssembly.instantiate(binary2, imports);
+ return instance2;
+ } catch (reason) {
+ err(`failed to asynchronously prepare wasm: ${reason}`);
+ abort(reason);
+ }
+ }
+ __name(instantiateArrayBuffer, "instantiateArrayBuffer");
+ async function instantiateAsync(binary2, binaryFile, imports) {
+ if (!binary2 && typeof WebAssembly.instantiateStreaming == "function" && !isFileURI(binaryFile) && !ENVIRONMENT_IS_NODE) {
+ try {
+ var response = fetch(binaryFile, {
+ credentials: "same-origin"
+ });
+ var instantiationResult = await WebAssembly.instantiateStreaming(response, imports);
+ return instantiationResult;
+ } catch (reason) {
+ err(`wasm streaming compile failed: ${reason}`);
+ err("falling back to ArrayBuffer instantiation");
+ }
+ }
+ return instantiateArrayBuffer(binaryFile, imports);
+ }
+ __name(instantiateAsync, "instantiateAsync");
+ function getWasmImports() {
+ return {
+ "env": wasmImports,
+ "wasi_snapshot_preview1": wasmImports,
+ "GOT.mem": new Proxy(wasmImports, GOTHandler),
+ "GOT.func": new Proxy(wasmImports, GOTHandler)
+ };
+ }
+ __name(getWasmImports, "getWasmImports");
+ async function createWasm() {
+ function receiveInstance(instance2, module2) {
+ wasmExports = instance2.exports;
+ wasmExports = relocateExports(wasmExports, 1024);
+ var metadata2 = getDylinkMetadata(module2);
+ if (metadata2.neededDynlibs) {
+ dynamicLibraries = metadata2.neededDynlibs.concat(dynamicLibraries);
+ }
+ mergeLibSymbols(wasmExports, "main");
+ LDSO.init();
+ loadDylibs();
+ __RELOC_FUNCS__.push(wasmExports["__wasm_apply_data_relocs"]);
+ removeRunDependency("wasm-instantiate");
+ return wasmExports;
+ }
+ __name(receiveInstance, "receiveInstance");
+ addRunDependency("wasm-instantiate");
+ function receiveInstantiationResult(result2) {
+ return receiveInstance(result2["instance"], result2["module"]);
+ }
+ __name(receiveInstantiationResult, "receiveInstantiationResult");
+ var info2 = getWasmImports();
+ if (Module["instantiateWasm"]) {
+ return new Promise((resolve, reject) => {
+ Module["instantiateWasm"](info2, (mod, inst) => {
+ receiveInstance(mod, inst);
+ resolve(mod.exports);
+ });
+ });
+ }
+ wasmBinaryFile ??= findWasmBinary();
+ try {
+ var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info2);
+ var exports = receiveInstantiationResult(result);
+ return exports;
+ } catch (e) {
+ readyPromiseReject(e);
+ return Promise.reject(e);
+ }
+ }
+ __name(createWasm, "createWasm");
+ var ASM_CONSTS = {};
+ class ExitStatus {
+ static {
+ __name(this, "ExitStatus");
+ }
+ name = "ExitStatus";
+ constructor(status) {
+ this.message = `Program terminated with exit(${status})`;
+ this.status = status;
+ }
+ }
+ var GOT = {};
+ var currentModuleWeakSymbols = /* @__PURE__ */ new Set([]);
+ var GOTHandler = {
+ get(obj, symName) {
+ var rtn = GOT[symName];
+ if (!rtn) {
+ rtn = GOT[symName] = new WebAssembly.Global({
+ "value": "i32",
+ "mutable": true
+ });
+ }
+ if (!currentModuleWeakSymbols.has(symName)) {
+ rtn.required = true;
+ }
+ return rtn;
+ }
+ };
+ var LE_HEAP_LOAD_F32 = /* @__PURE__ */ __name((byteOffset) => HEAP_DATA_VIEW.getFloat32(byteOffset, true), "LE_HEAP_LOAD_F32");
+ var LE_HEAP_LOAD_F64 = /* @__PURE__ */ __name((byteOffset) => HEAP_DATA_VIEW.getFloat64(byteOffset, true), "LE_HEAP_LOAD_F64");
+ var LE_HEAP_LOAD_I16 = /* @__PURE__ */ __name((byteOffset) => HEAP_DATA_VIEW.getInt16(byteOffset, true), "LE_HEAP_LOAD_I16");
+ var LE_HEAP_LOAD_I32 = /* @__PURE__ */ __name((byteOffset) => HEAP_DATA_VIEW.getInt32(byteOffset, true), "LE_HEAP_LOAD_I32");
+ var LE_HEAP_LOAD_U16 = /* @__PURE__ */ __name((byteOffset) => HEAP_DATA_VIEW.getUint16(byteOffset, true), "LE_HEAP_LOAD_U16");
+ var LE_HEAP_LOAD_U32 = /* @__PURE__ */ __name((byteOffset) => HEAP_DATA_VIEW.getUint32(byteOffset, true), "LE_HEAP_LOAD_U32");
+ var LE_HEAP_STORE_F32 = /* @__PURE__ */ __name((byteOffset, value) => HEAP_DATA_VIEW.setFloat32(byteOffset, value, true), "LE_HEAP_STORE_F32");
+ var LE_HEAP_STORE_F64 = /* @__PURE__ */ __name((byteOffset, value) => HEAP_DATA_VIEW.setFloat64(byteOffset, value, true), "LE_HEAP_STORE_F64");
+ var LE_HEAP_STORE_I16 = /* @__PURE__ */ __name((byteOffset, value) => HEAP_DATA_VIEW.setInt16(byteOffset, value, true), "LE_HEAP_STORE_I16");
+ var LE_HEAP_STORE_I32 = /* @__PURE__ */ __name((byteOffset, value) => HEAP_DATA_VIEW.setInt32(byteOffset, value, true), "LE_HEAP_STORE_I32");
+ var LE_HEAP_STORE_U16 = /* @__PURE__ */ __name((byteOffset, value) => HEAP_DATA_VIEW.setUint16(byteOffset, value, true), "LE_HEAP_STORE_U16");
+ var LE_HEAP_STORE_U32 = /* @__PURE__ */ __name((byteOffset, value) => HEAP_DATA_VIEW.setUint32(byteOffset, value, true), "LE_HEAP_STORE_U32");
+ var callRuntimeCallbacks = /* @__PURE__ */ __name((callbacks) => {
+ while (callbacks.length > 0) {
+ callbacks.shift()(Module);
+ }
+ }, "callRuntimeCallbacks");
+ var onPostRuns = [];
+ var addOnPostRun = /* @__PURE__ */ __name((cb) => onPostRuns.unshift(cb), "addOnPostRun");
+ var onPreRuns = [];
+ var addOnPreRun = /* @__PURE__ */ __name((cb) => onPreRuns.unshift(cb), "addOnPreRun");
+ var UTF8Decoder = typeof TextDecoder != "undefined" ? new TextDecoder() : void 0;
+ var UTF8ArrayToString = /* @__PURE__ */ __name((heapOrArray, idx = 0, maxBytesToRead = NaN) => {
+ var endIdx = idx + maxBytesToRead;
+ var endPtr = idx;
+ while (heapOrArray[endPtr] && !(endPtr >= endIdx)) ++endPtr;
+ if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) {
+ return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr));
+ }
+ var str = "";
+ while (idx < endPtr) {
+ var u0 = heapOrArray[idx++];
+ if (!(u0 & 128)) {
+ str += String.fromCharCode(u0);
+ continue;
+ }
+ var u1 = heapOrArray[idx++] & 63;
+ if ((u0 & 224) == 192) {
+ str += String.fromCharCode((u0 & 31) << 6 | u1);
+ continue;
+ }
+ var u2 = heapOrArray[idx++] & 63;
+ if ((u0 & 240) == 224) {
+ u0 = (u0 & 15) << 12 | u1 << 6 | u2;
+ } else {
+ u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | heapOrArray[idx++] & 63;
+ }
+ if (u0 < 65536) {
+ str += String.fromCharCode(u0);
+ } else {
+ var ch = u0 - 65536;
+ str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023);
+ }
+ }
+ return str;
+ }, "UTF8ArrayToString");
+ var getDylinkMetadata = /* @__PURE__ */ __name((binary2) => {
+ var offset = 0;
+ var end = 0;
+ function getU8() {
+ return binary2[offset++];
+ }
+ __name(getU8, "getU8");
+ function getLEB() {
+ var ret = 0;
+ var mul = 1;
+ while (1) {
+ var byte = binary2[offset++];
+ ret += (byte & 127) * mul;
+ mul *= 128;
+ if (!(byte & 128)) break;
+ }
+ return ret;
+ }
+ __name(getLEB, "getLEB");
+ function getString() {
+ var len = getLEB();
+ offset += len;
+ return UTF8ArrayToString(binary2, offset - len, len);
+ }
+ __name(getString, "getString");
+ function failIf(condition, message) {
+ if (condition) throw new Error(message);
+ }
+ __name(failIf, "failIf");
+ var name2 = "dylink.0";
+ if (binary2 instanceof WebAssembly.Module) {
+ var dylinkSection = WebAssembly.Module.customSections(binary2, name2);
+ if (dylinkSection.length === 0) {
+ name2 = "dylink";
+ dylinkSection = WebAssembly.Module.customSections(binary2, name2);
+ }
+ failIf(dylinkSection.length === 0, "need dylink section");
+ binary2 = new Uint8Array(dylinkSection[0]);
+ end = binary2.length;
+ } else {
+ var int32View = new Uint32Array(new Uint8Array(binary2.subarray(0, 24)).buffer);
+ var magicNumberFound = int32View[0] == 1836278016 || int32View[0] == 6386541;
+ failIf(!magicNumberFound, "need to see wasm magic number");
+ failIf(binary2[8] !== 0, "need the dylink section to be first");
+ offset = 9;
+ var section_size = getLEB();
+ end = offset + section_size;
+ name2 = getString();
+ }
+ var customSection = {
+ neededDynlibs: [],
+ tlsExports: /* @__PURE__ */ new Set(),
+ weakImports: /* @__PURE__ */ new Set()
+ };
+ if (name2 == "dylink") {
+ customSection.memorySize = getLEB();
+ customSection.memoryAlign = getLEB();
+ customSection.tableSize = getLEB();
+ customSection.tableAlign = getLEB();
+ var neededDynlibsCount = getLEB();
+ for (var i2 = 0; i2 < neededDynlibsCount; ++i2) {
+ var libname = getString();
+ customSection.neededDynlibs.push(libname);
+ }
+ } else {
+ failIf(name2 !== "dylink.0");
+ var WASM_DYLINK_MEM_INFO = 1;
+ var WASM_DYLINK_NEEDED = 2;
+ var WASM_DYLINK_EXPORT_INFO = 3;
+ var WASM_DYLINK_IMPORT_INFO = 4;
+ var WASM_SYMBOL_TLS = 256;
+ var WASM_SYMBOL_BINDING_MASK = 3;
+ var WASM_SYMBOL_BINDING_WEAK = 1;
+ while (offset < end) {
+ var subsectionType = getU8();
+ var subsectionSize = getLEB();
+ if (subsectionType === WASM_DYLINK_MEM_INFO) {
+ customSection.memorySize = getLEB();
+ customSection.memoryAlign = getLEB();
+ customSection.tableSize = getLEB();
+ customSection.tableAlign = getLEB();
+ } else if (subsectionType === WASM_DYLINK_NEEDED) {
+ var neededDynlibsCount = getLEB();
+ for (var i2 = 0; i2 < neededDynlibsCount; ++i2) {
+ libname = getString();
+ customSection.neededDynlibs.push(libname);
+ }
+ } else if (subsectionType === WASM_DYLINK_EXPORT_INFO) {
+ var count = getLEB();
+ while (count--) {
+ var symname = getString();
+ var flags2 = getLEB();
+ if (flags2 & WASM_SYMBOL_TLS) {
+ customSection.tlsExports.add(symname);
+ }
+ }
+ } else if (subsectionType === WASM_DYLINK_IMPORT_INFO) {
+ var count = getLEB();
+ while (count--) {
+ var modname = getString();
+ var symname = getString();
+ var flags2 = getLEB();
+ if ((flags2 & WASM_SYMBOL_BINDING_MASK) == WASM_SYMBOL_BINDING_WEAK) {
+ customSection.weakImports.add(symname);
+ }
+ }
+ } else {
+ offset += subsectionSize;
+ }
+ }
+ }
+ return customSection;
+ }, "getDylinkMetadata");
+ function getValue(ptr, type = "i8") {
+ if (type.endsWith("*")) type = "*";
+ switch (type) {
+ case "i1":
+ return HEAP8[ptr];
+ case "i8":
+ return HEAP8[ptr];
+ case "i16":
+ return LE_HEAP_LOAD_I16((ptr >> 1) * 2);
+ case "i32":
+ return LE_HEAP_LOAD_I32((ptr >> 2) * 4);
+ case "i64":
+ return HEAP64[ptr >> 3];
+ case "float":
+ return LE_HEAP_LOAD_F32((ptr >> 2) * 4);
+ case "double":
+ return LE_HEAP_LOAD_F64((ptr >> 3) * 8);
+ case "*":
+ return LE_HEAP_LOAD_U32((ptr >> 2) * 4);
+ default:
+ abort(`invalid type for getValue: ${type}`);
+ }
+ }
+ __name(getValue, "getValue");
+ var newDSO = /* @__PURE__ */ __name((name2, handle2, syms) => {
+ var dso = {
+ refcount: Infinity,
+ name: name2,
+ exports: syms,
+ global: true
+ };
+ LDSO.loadedLibsByName[name2] = dso;
+ if (handle2 != void 0) {
+ LDSO.loadedLibsByHandle[handle2] = dso;
+ }
+ return dso;
+ }, "newDSO");
+ var LDSO = {
+ loadedLibsByName: {},
+ loadedLibsByHandle: {},
+ init() {
+ newDSO("__main__", 0, wasmImports);
+ }
+ };
+ var ___heap_base = 78224;
+ var alignMemory = /* @__PURE__ */ __name((size, alignment) => Math.ceil(size / alignment) * alignment, "alignMemory");
+ var getMemory = /* @__PURE__ */ __name((size) => {
+ if (runtimeInitialized) {
+ return _calloc(size, 1);
+ }
+ var ret = ___heap_base;
+ var end = ret + alignMemory(size, 16);
+ ___heap_base = end;
+ GOT["__heap_base"].value = end;
+ return ret;
+ }, "getMemory");
+ var isInternalSym = /* @__PURE__ */ __name((symName) => ["__cpp_exception", "__c_longjmp", "__wasm_apply_data_relocs", "__dso_handle", "__tls_size", "__tls_align", "__set_stack_limits", "_emscripten_tls_init", "__wasm_init_tls", "__wasm_call_ctors", "__start_em_asm", "__stop_em_asm", "__start_em_js", "__stop_em_js"].includes(symName) || symName.startsWith("__em_js__"), "isInternalSym");
+ var uleb128Encode = /* @__PURE__ */ __name((n, target) => {
+ if (n < 128) {
+ target.push(n);
+ } else {
+ target.push(n % 128 | 128, n >> 7);
+ }
+ }, "uleb128Encode");
+ var sigToWasmTypes = /* @__PURE__ */ __name((sig) => {
+ var typeNames = {
+ "i": "i32",
+ "j": "i64",
+ "f": "f32",
+ "d": "f64",
+ "e": "externref",
+ "p": "i32"
+ };
+ var type = {
+ parameters: [],
+ results: sig[0] == "v" ? [] : [typeNames[sig[0]]]
+ };
+ for (var i2 = 1; i2 < sig.length; ++i2) {
+ type.parameters.push(typeNames[sig[i2]]);
+ }
+ return type;
+ }, "sigToWasmTypes");
+ var generateFuncType = /* @__PURE__ */ __name((sig, target) => {
+ var sigRet = sig.slice(0, 1);
+ var sigParam = sig.slice(1);
+ var typeCodes = {
+ "i": 127,
+ // i32
+ "p": 127,
+ // i32
+ "j": 126,
+ // i64
+ "f": 125,
+ // f32
+ "d": 124,
+ // f64
+ "e": 111
+ };
+ target.push(96);
+ uleb128Encode(sigParam.length, target);
+ for (var i2 = 0; i2 < sigParam.length; ++i2) {
+ target.push(typeCodes[sigParam[i2]]);
+ }
+ if (sigRet == "v") {
+ target.push(0);
+ } else {
+ target.push(1, typeCodes[sigRet]);
+ }
+ }, "generateFuncType");
+ var convertJsFunctionToWasm = /* @__PURE__ */ __name((func2, sig) => {
+ if (typeof WebAssembly.Function == "function") {
+ return new WebAssembly.Function(sigToWasmTypes(sig), func2);
+ }
+ var typeSectionBody = [1];
+ generateFuncType(sig, typeSectionBody);
+ var bytes = [
+ 0,
+ 97,
+ 115,
+ 109,
+ // magic ("\0asm")
+ 1,
+ 0,
+ 0,
+ 0,
+ // version: 1
+ 1
+ ];
+ uleb128Encode(typeSectionBody.length, bytes);
+ bytes.push(...typeSectionBody);
+ bytes.push(
+ 2,
+ 7,
+ // import section
+ // (import "e" "f" (func 0 (type 0)))
+ 1,
+ 1,
+ 101,
+ 1,
+ 102,
+ 0,
+ 0,
+ 7,
+ 5,
+ // export section
+ // (export "f" (func 0 (type 0)))
+ 1,
+ 1,
+ 102,
+ 0,
+ 0
+ );
+ var module2 = new WebAssembly.Module(new Uint8Array(bytes));
+ var instance2 = new WebAssembly.Instance(module2, {
+ "e": {
+ "f": func2
+ }
+ });
+ var wrappedFunc = instance2.exports["f"];
+ return wrappedFunc;
+ }, "convertJsFunctionToWasm");
+ var wasmTableMirror = [];
+ var wasmTable = new WebAssembly.Table({
+ "initial": 31,
+ "element": "anyfunc"
+ });
+ var getWasmTableEntry = /* @__PURE__ */ __name((funcPtr) => {
+ var func2 = wasmTableMirror[funcPtr];
+ if (!func2) {
+ if (funcPtr >= wasmTableMirror.length) wasmTableMirror.length = funcPtr + 1;
+ wasmTableMirror[funcPtr] = func2 = wasmTable.get(funcPtr);
+ }
+ return func2;
+ }, "getWasmTableEntry");
+ var updateTableMap = /* @__PURE__ */ __name((offset, count) => {
+ if (functionsInTableMap) {
+ for (var i2 = offset; i2 < offset + count; i2++) {
+ var item = getWasmTableEntry(i2);
+ if (item) {
+ functionsInTableMap.set(item, i2);
+ }
+ }
+ }
+ }, "updateTableMap");
+ var functionsInTableMap;
+ var getFunctionAddress = /* @__PURE__ */ __name((func2) => {
+ if (!functionsInTableMap) {
+ functionsInTableMap = /* @__PURE__ */ new WeakMap();
+ updateTableMap(0, wasmTable.length);
+ }
+ return functionsInTableMap.get(func2) || 0;
+ }, "getFunctionAddress");
+ var freeTableIndexes = [];
+ var getEmptyTableSlot = /* @__PURE__ */ __name(() => {
+ if (freeTableIndexes.length) {
+ return freeTableIndexes.pop();
+ }
+ try {
+ wasmTable.grow(1);
+ } catch (err2) {
+ if (!(err2 instanceof RangeError)) {
+ throw err2;
+ }
+ throw "Unable to grow wasm table. Set ALLOW_TABLE_GROWTH.";
+ }
+ return wasmTable.length - 1;
+ }, "getEmptyTableSlot");
+ var setWasmTableEntry = /* @__PURE__ */ __name((idx, func2) => {
+ wasmTable.set(idx, func2);
+ wasmTableMirror[idx] = wasmTable.get(idx);
+ }, "setWasmTableEntry");
+ var addFunction = /* @__PURE__ */ __name((func2, sig) => {
+ var rtn = getFunctionAddress(func2);
+ if (rtn) {
+ return rtn;
+ }
+ var ret = getEmptyTableSlot();
+ try {
+ setWasmTableEntry(ret, func2);
+ } catch (err2) {
+ if (!(err2 instanceof TypeError)) {
+ throw err2;
+ }
+ var wrapped = convertJsFunctionToWasm(func2, sig);
+ setWasmTableEntry(ret, wrapped);
+ }
+ functionsInTableMap.set(func2, ret);
+ return ret;
+ }, "addFunction");
+ var updateGOT = /* @__PURE__ */ __name((exports, replace) => {
+ for (var symName in exports) {
+ if (isInternalSym(symName)) {
+ continue;
+ }
+ var value = exports[symName];
+ GOT[symName] ||= new WebAssembly.Global({
+ "value": "i32",
+ "mutable": true
+ });
+ if (replace || GOT[symName].value == 0) {
+ if (typeof value == "function") {
+ GOT[symName].value = addFunction(value);
+ } else if (typeof value == "number") {
+ GOT[symName].value = value;
+ } else {
+ err(`unhandled export type for '${symName}': ${typeof value}`);
+ }
+ }
+ }
+ }, "updateGOT");
+ var relocateExports = /* @__PURE__ */ __name((exports, memoryBase2, replace) => {
+ var relocated = {};
+ for (var e in exports) {
+ var value = exports[e];
+ if (typeof value == "object") {
+ value = value.value;
+ }
+ if (typeof value == "number") {
+ value += memoryBase2;
+ }
+ relocated[e] = value;
+ }
+ updateGOT(relocated, replace);
+ return relocated;
+ }, "relocateExports");
+ var isSymbolDefined = /* @__PURE__ */ __name((symName) => {
+ var existing = wasmImports[symName];
+ if (!existing || existing.stub) {
+ return false;
+ }
+ return true;
+ }, "isSymbolDefined");
+ var dynCall = /* @__PURE__ */ __name((sig, ptr, args2 = []) => {
+ var rtn = getWasmTableEntry(ptr)(...args2);
+ return rtn;
+ }, "dynCall");
+ var stackSave = /* @__PURE__ */ __name(() => _emscripten_stack_get_current(), "stackSave");
+ var stackRestore = /* @__PURE__ */ __name((val) => __emscripten_stack_restore(val), "stackRestore");
+ var createInvokeFunction = /* @__PURE__ */ __name((sig) => (ptr, ...args2) => {
+ var sp = stackSave();
+ try {
+ return dynCall(sig, ptr, args2);
+ } catch (e) {
+ stackRestore(sp);
+ if (e !== e + 0) throw e;
+ _setThrew(1, 0);
+ if (sig[0] == "j") return 0n;
+ }
+ }, "createInvokeFunction");
+ var resolveGlobalSymbol = /* @__PURE__ */ __name((symName, direct = false) => {
+ var sym;
+ if (isSymbolDefined(symName)) {
+ sym = wasmImports[symName];
+ } else if (symName.startsWith("invoke_")) {
+ sym = wasmImports[symName] = createInvokeFunction(symName.split("_")[1]);
+ }
+ return {
+ sym,
+ name: symName
+ };
+ }, "resolveGlobalSymbol");
+ var onPostCtors = [];
+ var addOnPostCtor = /* @__PURE__ */ __name((cb) => onPostCtors.unshift(cb), "addOnPostCtor");
+ var UTF8ToString = /* @__PURE__ */ __name((ptr, maxBytesToRead) => ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : "", "UTF8ToString");
+ var loadWebAssemblyModule = /* @__PURE__ */ __name((binary, flags, libName, localScope, handle) => {
+ var metadata = getDylinkMetadata(binary);
+ currentModuleWeakSymbols = metadata.weakImports;
+ function loadModule() {
+ var memAlign = Math.pow(2, metadata.memoryAlign);
+ var memoryBase = metadata.memorySize ? alignMemory(getMemory(metadata.memorySize + memAlign), memAlign) : 0;
+ var tableBase = metadata.tableSize ? wasmTable.length : 0;
+ if (handle) {
+ HEAP8[handle + 8] = 1;
+ LE_HEAP_STORE_U32((handle + 12 >> 2) * 4, memoryBase);
+ LE_HEAP_STORE_I32((handle + 16 >> 2) * 4, metadata.memorySize);
+ LE_HEAP_STORE_U32((handle + 20 >> 2) * 4, tableBase);
+ LE_HEAP_STORE_I32((handle + 24 >> 2) * 4, metadata.tableSize);
+ }
+ if (metadata.tableSize) {
+ wasmTable.grow(metadata.tableSize);
+ }
+ var moduleExports;
+ function resolveSymbol(sym) {
+ var resolved = resolveGlobalSymbol(sym).sym;
+ if (!resolved && localScope) {
+ resolved = localScope[sym];
+ }
+ if (!resolved) {
+ resolved = moduleExports[sym];
+ }
+ return resolved;
+ }
+ __name(resolveSymbol, "resolveSymbol");
+ var proxyHandler = {
+ get(stubs, prop) {
+ switch (prop) {
+ case "__memory_base":
+ return memoryBase;
+ case "__table_base":
+ return tableBase;
+ }
+ if (prop in wasmImports && !wasmImports[prop].stub) {
+ var res = wasmImports[prop];
+ return res;
+ }
+ if (!(prop in stubs)) {
+ var resolved;
+ stubs[prop] = (...args2) => {
+ resolved ||= resolveSymbol(prop);
+ return resolved(...args2);
+ };
+ }
+ return stubs[prop];
+ }
+ };
+ var proxy = new Proxy({}, proxyHandler);
+ var info = {
+ "GOT.mem": new Proxy({}, GOTHandler),
+ "GOT.func": new Proxy({}, GOTHandler),
+ "env": proxy,
+ "wasi_snapshot_preview1": proxy
+ };
+ function postInstantiation(module, instance) {
+ updateTableMap(tableBase, metadata.tableSize);
+ moduleExports = relocateExports(instance.exports, memoryBase);
+ if (!flags.allowUndefined) {
+ reportUndefinedSymbols();
+ }
+ function addEmAsm(addr, body) {
+ var args = [];
+ var arity = 0;
+ for (; arity < 16; arity++) {
+ if (body.indexOf("$" + arity) != -1) {
+ args.push("$" + arity);
+ } else {
+ break;
+ }
+ }
+ args = args.join(",");
+ var func = `(${args}) => { ${body} };`;
+ ASM_CONSTS[start] = eval(func);
+ }
+ __name(addEmAsm, "addEmAsm");
+ if ("__start_em_asm" in moduleExports) {
+ var start = moduleExports["__start_em_asm"];
+ var stop = moduleExports["__stop_em_asm"];
+ while (start < stop) {
+ var jsString = UTF8ToString(start);
+ addEmAsm(start, jsString);
+ start = HEAPU8.indexOf(0, start) + 1;
+ }
+ }
+ function addEmJs(name, cSig, body) {
+ var jsArgs = [];
+ cSig = cSig.slice(1, -1);
+ if (cSig != "void") {
+ cSig = cSig.split(",");
+ for (var i in cSig) {
+ var jsArg = cSig[i].split(" ").pop();
+ jsArgs.push(jsArg.replace("*", ""));
+ }
+ }
+ var func = `(${jsArgs}) => ${body};`;
+ moduleExports[name] = eval(func);
+ }
+ __name(addEmJs, "addEmJs");
+ for (var name in moduleExports) {
+ if (name.startsWith("__em_js__")) {
+ var start = moduleExports[name];
+ var jsString = UTF8ToString(start);
+ var parts = jsString.split("<::>");
+ addEmJs(name.replace("__em_js__", ""), parts[0], parts[1]);
+ delete moduleExports[name];
+ }
+ }
+ var applyRelocs = moduleExports["__wasm_apply_data_relocs"];
+ if (applyRelocs) {
+ if (runtimeInitialized) {
+ applyRelocs();
+ } else {
+ __RELOC_FUNCS__.push(applyRelocs);
+ }
+ }
+ var init = moduleExports["__wasm_call_ctors"];
+ if (init) {
+ if (runtimeInitialized) {
+ init();
+ } else {
+ addOnPostCtor(init);
+ }
+ }
+ return moduleExports;
+ }
+ __name(postInstantiation, "postInstantiation");
+ if (flags.loadAsync) {
+ if (binary instanceof WebAssembly.Module) {
+ var instance = new WebAssembly.Instance(binary, info);
+ return Promise.resolve(postInstantiation(binary, instance));
+ }
+ return WebAssembly.instantiate(binary, info).then((result) => postInstantiation(result.module, result.instance));
+ }
+ var module = binary instanceof WebAssembly.Module ? binary : new WebAssembly.Module(binary);
+ var instance = new WebAssembly.Instance(module, info);
+ return postInstantiation(module, instance);
+ }
+ __name(loadModule, "loadModule");
+ if (flags.loadAsync) {
+ return metadata.neededDynlibs.reduce((chain, dynNeeded) => chain.then(() => loadDynamicLibrary(dynNeeded, flags, localScope)), Promise.resolve()).then(loadModule);
+ }
+ metadata.neededDynlibs.forEach((needed) => loadDynamicLibrary(needed, flags, localScope));
+ return loadModule();
+ }, "loadWebAssemblyModule");
+ var mergeLibSymbols = /* @__PURE__ */ __name((exports, libName2) => {
+ for (var [sym, exp] of Object.entries(exports)) {
+ const setImport = /* @__PURE__ */ __name((target) => {
+ if (!isSymbolDefined(target)) {
+ wasmImports[target] = exp;
+ }
+ }, "setImport");
+ setImport(sym);
+ const main_alias = "__main_argc_argv";
+ if (sym == "main") {
+ setImport(main_alias);
+ }
+ if (sym == main_alias) {
+ setImport("main");
+ }
+ }
+ }, "mergeLibSymbols");
+ var asyncLoad = /* @__PURE__ */ __name(async (url) => {
+ var arrayBuffer = await readAsync(url);
+ return new Uint8Array(arrayBuffer);
+ }, "asyncLoad");
+ function loadDynamicLibrary(libName2, flags2 = {
+ global: true,
+ nodelete: true
+ }, localScope2, handle2) {
+ var dso = LDSO.loadedLibsByName[libName2];
+ if (dso) {
+ if (!flags2.global) {
+ if (localScope2) {
+ Object.assign(localScope2, dso.exports);
+ }
+ } else if (!dso.global) {
+ dso.global = true;
+ mergeLibSymbols(dso.exports, libName2);
+ }
+ if (flags2.nodelete && dso.refcount !== Infinity) {
+ dso.refcount = Infinity;
+ }
+ dso.refcount++;
+ if (handle2) {
+ LDSO.loadedLibsByHandle[handle2] = dso;
+ }
+ return flags2.loadAsync ? Promise.resolve(true) : true;
+ }
+ dso = newDSO(libName2, handle2, "loading");
+ dso.refcount = flags2.nodelete ? Infinity : 1;
+ dso.global = flags2.global;
+ function loadLibData() {
+ if (handle2) {
+ var data = LE_HEAP_LOAD_U32((handle2 + 28 >> 2) * 4);
+ var dataSize = LE_HEAP_LOAD_U32((handle2 + 32 >> 2) * 4);
+ if (data && dataSize) {
+ var libData = HEAP8.slice(data, data + dataSize);
+ return flags2.loadAsync ? Promise.resolve(libData) : libData;
+ }
+ }
+ var libFile = locateFile(libName2);
+ if (flags2.loadAsync) {
+ return asyncLoad(libFile);
+ }
+ if (!readBinary) {
+ throw new Error(`${libFile}: file not found, and synchronous loading of external files is not available`);
+ }
+ return readBinary(libFile);
+ }
+ __name(loadLibData, "loadLibData");
+ function getExports() {
+ if (flags2.loadAsync) {
+ return loadLibData().then((libData) => loadWebAssemblyModule(libData, flags2, libName2, localScope2, handle2));
+ }
+ return loadWebAssemblyModule(loadLibData(), flags2, libName2, localScope2, handle2);
+ }
+ __name(getExports, "getExports");
+ function moduleLoaded(exports) {
+ if (dso.global) {
+ mergeLibSymbols(exports, libName2);
+ } else if (localScope2) {
+ Object.assign(localScope2, exports);
+ }
+ dso.exports = exports;
+ }
+ __name(moduleLoaded, "moduleLoaded");
+ if (flags2.loadAsync) {
+ return getExports().then((exports) => {
+ moduleLoaded(exports);
+ return true;
+ });
+ }
+ moduleLoaded(getExports());
+ return true;
+ }
+ __name(loadDynamicLibrary, "loadDynamicLibrary");
+ var reportUndefinedSymbols = /* @__PURE__ */ __name(() => {
+ for (var [symName, entry] of Object.entries(GOT)) {
+ if (entry.value == 0) {
+ var value = resolveGlobalSymbol(symName, true).sym;
+ if (!value && !entry.required) {
+ continue;
+ }
+ if (typeof value == "function") {
+ entry.value = addFunction(value, value.sig);
+ } else if (typeof value == "number") {
+ entry.value = value;
+ } else {
+ throw new Error(`bad export type for '${symName}': ${typeof value}`);
+ }
+ }
+ }
+ }, "reportUndefinedSymbols");
+ var loadDylibs = /* @__PURE__ */ __name(() => {
+ if (!dynamicLibraries.length) {
+ reportUndefinedSymbols();
+ return;
+ }
+ addRunDependency("loadDylibs");
+ dynamicLibraries.reduce((chain, lib) => chain.then(() => loadDynamicLibrary(lib, {
+ loadAsync: true,
+ global: true,
+ nodelete: true,
+ allowUndefined: true
+ })), Promise.resolve()).then(() => {
+ reportUndefinedSymbols();
+ removeRunDependency("loadDylibs");
+ });
+ }, "loadDylibs");
+ var noExitRuntime = Module["noExitRuntime"] || true;
+ function setValue(ptr, value, type = "i8") {
+ if (type.endsWith("*")) type = "*";
+ switch (type) {
+ case "i1":
+ HEAP8[ptr] = value;
+ break;
+ case "i8":
+ HEAP8[ptr] = value;
+ break;
+ case "i16":
+ LE_HEAP_STORE_I16((ptr >> 1) * 2, value);
+ break;
+ case "i32":
+ LE_HEAP_STORE_I32((ptr >> 2) * 4, value);
+ break;
+ case "i64":
+ HEAP64[ptr >> 3] = BigInt(value);
+ break;
+ case "float":
+ LE_HEAP_STORE_F32((ptr >> 2) * 4, value);
+ break;
+ case "double":
+ LE_HEAP_STORE_F64((ptr >> 3) * 8, value);
+ break;
+ case "*":
+ LE_HEAP_STORE_U32((ptr >> 2) * 4, value);
+ break;
+ default:
+ abort(`invalid type for setValue: ${type}`);
+ }
+ }
+ __name(setValue, "setValue");
+ var ___memory_base = new WebAssembly.Global({
+ "value": "i32",
+ "mutable": false
+ }, 1024);
+ var ___stack_pointer = new WebAssembly.Global({
+ "value": "i32",
+ "mutable": true
+ }, 78224);
+ var ___table_base = new WebAssembly.Global({
+ "value": "i32",
+ "mutable": false
+ }, 1);
+ var __abort_js = /* @__PURE__ */ __name(() => abort(""), "__abort_js");
+ __abort_js.sig = "v";
+ var _emscripten_get_now = /* @__PURE__ */ __name(() => performance.now(), "_emscripten_get_now");
+ _emscripten_get_now.sig = "d";
+ var _emscripten_date_now = /* @__PURE__ */ __name(() => Date.now(), "_emscripten_date_now");
+ _emscripten_date_now.sig = "d";
+ var nowIsMonotonic = 1;
+ var checkWasiClock = /* @__PURE__ */ __name((clock_id) => clock_id >= 0 && clock_id <= 3, "checkWasiClock");
+ var INT53_MAX = 9007199254740992;
+ var INT53_MIN = -9007199254740992;
+ var bigintToI53Checked = /* @__PURE__ */ __name((num) => num < INT53_MIN || num > INT53_MAX ? NaN : Number(num), "bigintToI53Checked");
+ function _clock_time_get(clk_id, ignored_precision, ptime) {
+ ignored_precision = bigintToI53Checked(ignored_precision);
+ if (!checkWasiClock(clk_id)) {
+ return 28;
+ }
+ var now;
+ if (clk_id === 0) {
+ now = _emscripten_date_now();
+ } else if (nowIsMonotonic) {
+ now = _emscripten_get_now();
+ } else {
+ return 52;
+ }
+ var nsec = Math.round(now * 1e3 * 1e3);
+ HEAP64[ptime >> 3] = BigInt(nsec);
+ return 0;
+ }
+ __name(_clock_time_get, "_clock_time_get");
+ _clock_time_get.sig = "iijp";
+ var getHeapMax = /* @__PURE__ */ __name(() => (
+ // Stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate
+ // full 4GB Wasm memories, the size will wrap back to 0 bytes in Wasm side
+ // for any code that deals with heap sizes, which would require special
+ // casing all heap size related code to treat 0 specially.
+ 2147483648
+ ), "getHeapMax");
+ var growMemory = /* @__PURE__ */ __name((size) => {
+ var b = wasmMemory.buffer;
+ var pages = (size - b.byteLength + 65535) / 65536 | 0;
+ try {
+ wasmMemory.grow(pages);
+ updateMemoryViews();
+ return 1;
+ } catch (e) {
+ }
+ }, "growMemory");
+ var _emscripten_resize_heap = /* @__PURE__ */ __name((requestedSize) => {
+ var oldSize = HEAPU8.length;
+ requestedSize >>>= 0;
+ var maxHeapSize = getHeapMax();
+ if (requestedSize > maxHeapSize) {
+ return false;
+ }
+ for (var cutDown = 1; cutDown <= 4; cutDown *= 2) {
+ var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown);
+ overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296);
+ var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536));
+ var replacement = growMemory(newSize);
+ if (replacement) {
+ return true;
+ }
+ }
+ return false;
+ }, "_emscripten_resize_heap");
+ _emscripten_resize_heap.sig = "ip";
+ var _fd_close = /* @__PURE__ */ __name((fd) => 52, "_fd_close");
+ _fd_close.sig = "ii";
+ function _fd_seek(fd, offset, whence, newOffset) {
+ offset = bigintToI53Checked(offset);
+ return 70;
+ }
+ __name(_fd_seek, "_fd_seek");
+ _fd_seek.sig = "iijip";
+ var printCharBuffers = [null, [], []];
+ var printChar = /* @__PURE__ */ __name((stream, curr) => {
+ var buffer = printCharBuffers[stream];
+ if (curr === 0 || curr === 10) {
+ (stream === 1 ? out : err)(UTF8ArrayToString(buffer));
+ buffer.length = 0;
+ } else {
+ buffer.push(curr);
+ }
+ }, "printChar");
+ var flush_NO_FILESYSTEM = /* @__PURE__ */ __name(() => {
+ if (printCharBuffers[1].length) printChar(1, 10);
+ if (printCharBuffers[2].length) printChar(2, 10);
+ }, "flush_NO_FILESYSTEM");
+ var SYSCALLS = {
+ varargs: void 0,
+ getStr(ptr) {
+ var ret = UTF8ToString(ptr);
+ return ret;
+ }
+ };
+ var _fd_write = /* @__PURE__ */ __name((fd, iov, iovcnt, pnum) => {
+ var num = 0;
+ for (var i2 = 0; i2 < iovcnt; i2++) {
+ var ptr = LE_HEAP_LOAD_U32((iov >> 2) * 4);
+ var len = LE_HEAP_LOAD_U32((iov + 4 >> 2) * 4);
+ iov += 8;
+ for (var j = 0; j < len; j++) {
+ printChar(fd, HEAPU8[ptr + j]);
+ }
+ num += len;
+ }
+ LE_HEAP_STORE_U32((pnum >> 2) * 4, num);
+ return 0;
+ }, "_fd_write");
+ _fd_write.sig = "iippp";
+ function _tree_sitter_log_callback(isLexMessage, messageAddress) {
+ if (Module.currentLogCallback) {
+ const message = UTF8ToString(messageAddress);
+ Module.currentLogCallback(message, isLexMessage !== 0);
+ }
+ }
+ __name(_tree_sitter_log_callback, "_tree_sitter_log_callback");
+ function _tree_sitter_parse_callback(inputBufferAddress, index, row, column, lengthAddress) {
+ const INPUT_BUFFER_SIZE = 10 * 1024;
+ const string = Module.currentParseCallback(index, {
+ row,
+ column
+ });
+ if (typeof string === "string") {
+ setValue(lengthAddress, string.length, "i32");
+ stringToUTF16(string, inputBufferAddress, INPUT_BUFFER_SIZE);
+ } else {
+ setValue(lengthAddress, 0, "i32");
+ }
+ }
+ __name(_tree_sitter_parse_callback, "_tree_sitter_parse_callback");
+ function _tree_sitter_progress_callback(currentOffset, hasError) {
+ if (Module.currentProgressCallback) {
+ return Module.currentProgressCallback({
+ currentOffset,
+ hasError
+ });
+ }
+ return false;
+ }
+ __name(_tree_sitter_progress_callback, "_tree_sitter_progress_callback");
+ function _tree_sitter_query_progress_callback(currentOffset) {
+ if (Module.currentQueryProgressCallback) {
+ return Module.currentQueryProgressCallback({
+ currentOffset
+ });
+ }
+ return false;
+ }
+ __name(_tree_sitter_query_progress_callback, "_tree_sitter_query_progress_callback");
+ var runtimeKeepaliveCounter = 0;
+ var keepRuntimeAlive = /* @__PURE__ */ __name(() => noExitRuntime || runtimeKeepaliveCounter > 0, "keepRuntimeAlive");
+ var _proc_exit = /* @__PURE__ */ __name((code) => {
+ EXITSTATUS = code;
+ if (!keepRuntimeAlive()) {
+ Module["onExit"]?.(code);
+ ABORT = true;
+ }
+ quit_(code, new ExitStatus(code));
+ }, "_proc_exit");
+ _proc_exit.sig = "vi";
+ var exitJS = /* @__PURE__ */ __name((status, implicit) => {
+ EXITSTATUS = status;
+ _proc_exit(status);
+ }, "exitJS");
+ var handleException = /* @__PURE__ */ __name((e) => {
+ if (e instanceof ExitStatus || e == "unwind") {
+ return EXITSTATUS;
+ }
+ quit_(1, e);
+ }, "handleException");
+ var lengthBytesUTF8 = /* @__PURE__ */ __name((str) => {
+ var len = 0;
+ for (var i2 = 0; i2 < str.length; ++i2) {
+ var c = str.charCodeAt(i2);
+ if (c <= 127) {
+ len++;
+ } else if (c <= 2047) {
+ len += 2;
+ } else if (c >= 55296 && c <= 57343) {
+ len += 4;
+ ++i2;
+ } else {
+ len += 3;
+ }
+ }
+ return len;
+ }, "lengthBytesUTF8");
+ var stringToUTF8Array = /* @__PURE__ */ __name((str, heap, outIdx, maxBytesToWrite) => {
+ if (!(maxBytesToWrite > 0)) return 0;
+ var startIdx = outIdx;
+ var endIdx = outIdx + maxBytesToWrite - 1;
+ for (var i2 = 0; i2 < str.length; ++i2) {
+ var u = str.charCodeAt(i2);
+ if (u >= 55296 && u <= 57343) {
+ var u1 = str.charCodeAt(++i2);
+ u = 65536 + ((u & 1023) << 10) | u1 & 1023;
+ }
+ if (u <= 127) {
+ if (outIdx >= endIdx) break;
+ heap[outIdx++] = u;
+ } else if (u <= 2047) {
+ if (outIdx + 1 >= endIdx) break;
+ heap[outIdx++] = 192 | u >> 6;
+ heap[outIdx++] = 128 | u & 63;
+ } else if (u <= 65535) {
+ if (outIdx + 2 >= endIdx) break;
+ heap[outIdx++] = 224 | u >> 12;
+ heap[outIdx++] = 128 | u >> 6 & 63;
+ heap[outIdx++] = 128 | u & 63;
+ } else {
+ if (outIdx + 3 >= endIdx) break;
+ heap[outIdx++] = 240 | u >> 18;
+ heap[outIdx++] = 128 | u >> 12 & 63;
+ heap[outIdx++] = 128 | u >> 6 & 63;
+ heap[outIdx++] = 128 | u & 63;
+ }
+ }
+ heap[outIdx] = 0;
+ return outIdx - startIdx;
+ }, "stringToUTF8Array");
+ var stringToUTF8 = /* @__PURE__ */ __name((str, outPtr, maxBytesToWrite) => stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite), "stringToUTF8");
+ var stackAlloc = /* @__PURE__ */ __name((sz) => __emscripten_stack_alloc(sz), "stackAlloc");
+ var stringToUTF8OnStack = /* @__PURE__ */ __name((str) => {
+ var size = lengthBytesUTF8(str) + 1;
+ var ret = stackAlloc(size);
+ stringToUTF8(str, ret, size);
+ return ret;
+ }, "stringToUTF8OnStack");
+ var AsciiToString = /* @__PURE__ */ __name((ptr) => {
+ var str = "";
+ while (1) {
+ var ch = HEAPU8[ptr++];
+ if (!ch) return str;
+ str += String.fromCharCode(ch);
+ }
+ }, "AsciiToString");
+ var stringToUTF16 = /* @__PURE__ */ __name((str, outPtr, maxBytesToWrite) => {
+ maxBytesToWrite ??= 2147483647;
+ if (maxBytesToWrite < 2) return 0;
+ maxBytesToWrite -= 2;
+ var startPtr = outPtr;
+ var numCharsToWrite = maxBytesToWrite < str.length * 2 ? maxBytesToWrite / 2 : str.length;
+ for (var i2 = 0; i2 < numCharsToWrite; ++i2) {
+ var codeUnit = str.charCodeAt(i2);
+ LE_HEAP_STORE_I16((outPtr >> 1) * 2, codeUnit);
+ outPtr += 2;
+ }
+ LE_HEAP_STORE_I16((outPtr >> 1) * 2, 0);
+ return outPtr - startPtr;
+ }, "stringToUTF16");
+ var wasmImports = {
+ /** @export */
+ __heap_base: ___heap_base,
+ /** @export */
+ __indirect_function_table: wasmTable,
+ /** @export */
+ __memory_base: ___memory_base,
+ /** @export */
+ __stack_pointer: ___stack_pointer,
+ /** @export */
+ __table_base: ___table_base,
+ /** @export */
+ _abort_js: __abort_js,
+ /** @export */
+ clock_time_get: _clock_time_get,
+ /** @export */
+ emscripten_resize_heap: _emscripten_resize_heap,
+ /** @export */
+ fd_close: _fd_close,
+ /** @export */
+ fd_seek: _fd_seek,
+ /** @export */
+ fd_write: _fd_write,
+ /** @export */
+ memory: wasmMemory,
+ /** @export */
+ tree_sitter_log_callback: _tree_sitter_log_callback,
+ /** @export */
+ tree_sitter_parse_callback: _tree_sitter_parse_callback,
+ /** @export */
+ tree_sitter_progress_callback: _tree_sitter_progress_callback,
+ /** @export */
+ tree_sitter_query_progress_callback: _tree_sitter_query_progress_callback
+ };
+ var wasmExports = await createWasm();
+ var ___wasm_call_ctors = wasmExports["__wasm_call_ctors"];
+ var _malloc = Module["_malloc"] = wasmExports["malloc"];
+ var _calloc = Module["_calloc"] = wasmExports["calloc"];
+ var _realloc = Module["_realloc"] = wasmExports["realloc"];
+ var _free = Module["_free"] = wasmExports["free"];
+ var _memcmp = Module["_memcmp"] = wasmExports["memcmp"];
+ var _ts_language_symbol_count = Module["_ts_language_symbol_count"] = wasmExports["ts_language_symbol_count"];
+ var _ts_language_state_count = Module["_ts_language_state_count"] = wasmExports["ts_language_state_count"];
+ var _ts_language_version = Module["_ts_language_version"] = wasmExports["ts_language_version"];
+ var _ts_language_abi_version = Module["_ts_language_abi_version"] = wasmExports["ts_language_abi_version"];
+ var _ts_language_metadata = Module["_ts_language_metadata"] = wasmExports["ts_language_metadata"];
+ var _ts_language_name = Module["_ts_language_name"] = wasmExports["ts_language_name"];
+ var _ts_language_field_count = Module["_ts_language_field_count"] = wasmExports["ts_language_field_count"];
+ var _ts_language_next_state = Module["_ts_language_next_state"] = wasmExports["ts_language_next_state"];
+ var _ts_language_symbol_name = Module["_ts_language_symbol_name"] = wasmExports["ts_language_symbol_name"];
+ var _ts_language_symbol_for_name = Module["_ts_language_symbol_for_name"] = wasmExports["ts_language_symbol_for_name"];
+ var _strncmp = Module["_strncmp"] = wasmExports["strncmp"];
+ var _ts_language_symbol_type = Module["_ts_language_symbol_type"] = wasmExports["ts_language_symbol_type"];
+ var _ts_language_field_name_for_id = Module["_ts_language_field_name_for_id"] = wasmExports["ts_language_field_name_for_id"];
+ var _ts_lookahead_iterator_new = Module["_ts_lookahead_iterator_new"] = wasmExports["ts_lookahead_iterator_new"];
+ var _ts_lookahead_iterator_delete = Module["_ts_lookahead_iterator_delete"] = wasmExports["ts_lookahead_iterator_delete"];
+ var _ts_lookahead_iterator_reset_state = Module["_ts_lookahead_iterator_reset_state"] = wasmExports["ts_lookahead_iterator_reset_state"];
+ var _ts_lookahead_iterator_reset = Module["_ts_lookahead_iterator_reset"] = wasmExports["ts_lookahead_iterator_reset"];
+ var _ts_lookahead_iterator_next = Module["_ts_lookahead_iterator_next"] = wasmExports["ts_lookahead_iterator_next"];
+ var _ts_lookahead_iterator_current_symbol = Module["_ts_lookahead_iterator_current_symbol"] = wasmExports["ts_lookahead_iterator_current_symbol"];
+ var _ts_parser_delete = Module["_ts_parser_delete"] = wasmExports["ts_parser_delete"];
+ var _ts_parser_reset = Module["_ts_parser_reset"] = wasmExports["ts_parser_reset"];
+ var _ts_parser_set_language = Module["_ts_parser_set_language"] = wasmExports["ts_parser_set_language"];
+ var _ts_parser_timeout_micros = Module["_ts_parser_timeout_micros"] = wasmExports["ts_parser_timeout_micros"];
+ var _ts_parser_set_timeout_micros = Module["_ts_parser_set_timeout_micros"] = wasmExports["ts_parser_set_timeout_micros"];
+ var _ts_parser_set_included_ranges = Module["_ts_parser_set_included_ranges"] = wasmExports["ts_parser_set_included_ranges"];
+ var _ts_query_new = Module["_ts_query_new"] = wasmExports["ts_query_new"];
+ var _ts_query_delete = Module["_ts_query_delete"] = wasmExports["ts_query_delete"];
+ var _iswspace = Module["_iswspace"] = wasmExports["iswspace"];
+ var _iswalnum = Module["_iswalnum"] = wasmExports["iswalnum"];
+ var _ts_query_pattern_count = Module["_ts_query_pattern_count"] = wasmExports["ts_query_pattern_count"];
+ var _ts_query_capture_count = Module["_ts_query_capture_count"] = wasmExports["ts_query_capture_count"];
+ var _ts_query_string_count = Module["_ts_query_string_count"] = wasmExports["ts_query_string_count"];
+ var _ts_query_capture_name_for_id = Module["_ts_query_capture_name_for_id"] = wasmExports["ts_query_capture_name_for_id"];
+ var _ts_query_capture_quantifier_for_id = Module["_ts_query_capture_quantifier_for_id"] = wasmExports["ts_query_capture_quantifier_for_id"];
+ var _ts_query_string_value_for_id = Module["_ts_query_string_value_for_id"] = wasmExports["ts_query_string_value_for_id"];
+ var _ts_query_predicates_for_pattern = Module["_ts_query_predicates_for_pattern"] = wasmExports["ts_query_predicates_for_pattern"];
+ var _ts_query_start_byte_for_pattern = Module["_ts_query_start_byte_for_pattern"] = wasmExports["ts_query_start_byte_for_pattern"];
+ var _ts_query_end_byte_for_pattern = Module["_ts_query_end_byte_for_pattern"] = wasmExports["ts_query_end_byte_for_pattern"];
+ var _ts_query_is_pattern_rooted = Module["_ts_query_is_pattern_rooted"] = wasmExports["ts_query_is_pattern_rooted"];
+ var _ts_query_is_pattern_non_local = Module["_ts_query_is_pattern_non_local"] = wasmExports["ts_query_is_pattern_non_local"];
+ var _ts_query_is_pattern_guaranteed_at_step = Module["_ts_query_is_pattern_guaranteed_at_step"] = wasmExports["ts_query_is_pattern_guaranteed_at_step"];
+ var _ts_query_disable_capture = Module["_ts_query_disable_capture"] = wasmExports["ts_query_disable_capture"];
+ var _ts_query_disable_pattern = Module["_ts_query_disable_pattern"] = wasmExports["ts_query_disable_pattern"];
+ var _ts_tree_copy = Module["_ts_tree_copy"] = wasmExports["ts_tree_copy"];
+ var _ts_tree_delete = Module["_ts_tree_delete"] = wasmExports["ts_tree_delete"];
+ var _ts_init = Module["_ts_init"] = wasmExports["ts_init"];
+ var _ts_parser_new_wasm = Module["_ts_parser_new_wasm"] = wasmExports["ts_parser_new_wasm"];
+ var _ts_parser_enable_logger_wasm = Module["_ts_parser_enable_logger_wasm"] = wasmExports["ts_parser_enable_logger_wasm"];
+ var _ts_parser_parse_wasm = Module["_ts_parser_parse_wasm"] = wasmExports["ts_parser_parse_wasm"];
+ var _ts_parser_included_ranges_wasm = Module["_ts_parser_included_ranges_wasm"] = wasmExports["ts_parser_included_ranges_wasm"];
+ var _ts_language_type_is_named_wasm = Module["_ts_language_type_is_named_wasm"] = wasmExports["ts_language_type_is_named_wasm"];
+ var _ts_language_type_is_visible_wasm = Module["_ts_language_type_is_visible_wasm"] = wasmExports["ts_language_type_is_visible_wasm"];
+ var _ts_language_supertypes_wasm = Module["_ts_language_supertypes_wasm"] = wasmExports["ts_language_supertypes_wasm"];
+ var _ts_language_subtypes_wasm = Module["_ts_language_subtypes_wasm"] = wasmExports["ts_language_subtypes_wasm"];
+ var _ts_tree_root_node_wasm = Module["_ts_tree_root_node_wasm"] = wasmExports["ts_tree_root_node_wasm"];
+ var _ts_tree_root_node_with_offset_wasm = Module["_ts_tree_root_node_with_offset_wasm"] = wasmExports["ts_tree_root_node_with_offset_wasm"];
+ var _ts_tree_edit_wasm = Module["_ts_tree_edit_wasm"] = wasmExports["ts_tree_edit_wasm"];
+ var _ts_tree_included_ranges_wasm = Module["_ts_tree_included_ranges_wasm"] = wasmExports["ts_tree_included_ranges_wasm"];
+ var _ts_tree_get_changed_ranges_wasm = Module["_ts_tree_get_changed_ranges_wasm"] = wasmExports["ts_tree_get_changed_ranges_wasm"];
+ var _ts_tree_cursor_new_wasm = Module["_ts_tree_cursor_new_wasm"] = wasmExports["ts_tree_cursor_new_wasm"];
+ var _ts_tree_cursor_copy_wasm = Module["_ts_tree_cursor_copy_wasm"] = wasmExports["ts_tree_cursor_copy_wasm"];
+ var _ts_tree_cursor_delete_wasm = Module["_ts_tree_cursor_delete_wasm"] = wasmExports["ts_tree_cursor_delete_wasm"];
+ var _ts_tree_cursor_reset_wasm = Module["_ts_tree_cursor_reset_wasm"] = wasmExports["ts_tree_cursor_reset_wasm"];
+ var _ts_tree_cursor_reset_to_wasm = Module["_ts_tree_cursor_reset_to_wasm"] = wasmExports["ts_tree_cursor_reset_to_wasm"];
+ var _ts_tree_cursor_goto_first_child_wasm = Module["_ts_tree_cursor_goto_first_child_wasm"] = wasmExports["ts_tree_cursor_goto_first_child_wasm"];
+ var _ts_tree_cursor_goto_last_child_wasm = Module["_ts_tree_cursor_goto_last_child_wasm"] = wasmExports["ts_tree_cursor_goto_last_child_wasm"];
+ var _ts_tree_cursor_goto_first_child_for_index_wasm = Module["_ts_tree_cursor_goto_first_child_for_index_wasm"] = wasmExports["ts_tree_cursor_goto_first_child_for_index_wasm"];
+ var _ts_tree_cursor_goto_first_child_for_position_wasm = Module["_ts_tree_cursor_goto_first_child_for_position_wasm"] = wasmExports["ts_tree_cursor_goto_first_child_for_position_wasm"];
+ var _ts_tree_cursor_goto_next_sibling_wasm = Module["_ts_tree_cursor_goto_next_sibling_wasm"] = wasmExports["ts_tree_cursor_goto_next_sibling_wasm"];
+ var _ts_tree_cursor_goto_previous_sibling_wasm = Module["_ts_tree_cursor_goto_previous_sibling_wasm"] = wasmExports["ts_tree_cursor_goto_previous_sibling_wasm"];
+ var _ts_tree_cursor_goto_descendant_wasm = Module["_ts_tree_cursor_goto_descendant_wasm"] = wasmExports["ts_tree_cursor_goto_descendant_wasm"];
+ var _ts_tree_cursor_goto_parent_wasm = Module["_ts_tree_cursor_goto_parent_wasm"] = wasmExports["ts_tree_cursor_goto_parent_wasm"];
+ var _ts_tree_cursor_current_node_type_id_wasm = Module["_ts_tree_cursor_current_node_type_id_wasm"] = wasmExports["ts_tree_cursor_current_node_type_id_wasm"];
+ var _ts_tree_cursor_current_node_state_id_wasm = Module["_ts_tree_cursor_current_node_state_id_wasm"] = wasmExports["ts_tree_cursor_current_node_state_id_wasm"];
+ var _ts_tree_cursor_current_node_is_named_wasm = Module["_ts_tree_cursor_current_node_is_named_wasm"] = wasmExports["ts_tree_cursor_current_node_is_named_wasm"];
+ var _ts_tree_cursor_current_node_is_missing_wasm = Module["_ts_tree_cursor_current_node_is_missing_wasm"] = wasmExports["ts_tree_cursor_current_node_is_missing_wasm"];
+ var _ts_tree_cursor_current_node_id_wasm = Module["_ts_tree_cursor_current_node_id_wasm"] = wasmExports["ts_tree_cursor_current_node_id_wasm"];
+ var _ts_tree_cursor_start_position_wasm = Module["_ts_tree_cursor_start_position_wasm"] = wasmExports["ts_tree_cursor_start_position_wasm"];
+ var _ts_tree_cursor_end_position_wasm = Module["_ts_tree_cursor_end_position_wasm"] = wasmExports["ts_tree_cursor_end_position_wasm"];
+ var _ts_tree_cursor_start_index_wasm = Module["_ts_tree_cursor_start_index_wasm"] = wasmExports["ts_tree_cursor_start_index_wasm"];
+ var _ts_tree_cursor_end_index_wasm = Module["_ts_tree_cursor_end_index_wasm"] = wasmExports["ts_tree_cursor_end_index_wasm"];
+ var _ts_tree_cursor_current_field_id_wasm = Module["_ts_tree_cursor_current_field_id_wasm"] = wasmExports["ts_tree_cursor_current_field_id_wasm"];
+ var _ts_tree_cursor_current_depth_wasm = Module["_ts_tree_cursor_current_depth_wasm"] = wasmExports["ts_tree_cursor_current_depth_wasm"];
+ var _ts_tree_cursor_current_descendant_index_wasm = Module["_ts_tree_cursor_current_descendant_index_wasm"] = wasmExports["ts_tree_cursor_current_descendant_index_wasm"];
+ var _ts_tree_cursor_current_node_wasm = Module["_ts_tree_cursor_current_node_wasm"] = wasmExports["ts_tree_cursor_current_node_wasm"];
+ var _ts_node_symbol_wasm = Module["_ts_node_symbol_wasm"] = wasmExports["ts_node_symbol_wasm"];
+ var _ts_node_field_name_for_child_wasm = Module["_ts_node_field_name_for_child_wasm"] = wasmExports["ts_node_field_name_for_child_wasm"];
+ var _ts_node_field_name_for_named_child_wasm = Module["_ts_node_field_name_for_named_child_wasm"] = wasmExports["ts_node_field_name_for_named_child_wasm"];
+ var _ts_node_children_by_field_id_wasm = Module["_ts_node_children_by_field_id_wasm"] = wasmExports["ts_node_children_by_field_id_wasm"];
+ var _ts_node_first_child_for_byte_wasm = Module["_ts_node_first_child_for_byte_wasm"] = wasmExports["ts_node_first_child_for_byte_wasm"];
+ var _ts_node_first_named_child_for_byte_wasm = Module["_ts_node_first_named_child_for_byte_wasm"] = wasmExports["ts_node_first_named_child_for_byte_wasm"];
+ var _ts_node_grammar_symbol_wasm = Module["_ts_node_grammar_symbol_wasm"] = wasmExports["ts_node_grammar_symbol_wasm"];
+ var _ts_node_child_count_wasm = Module["_ts_node_child_count_wasm"] = wasmExports["ts_node_child_count_wasm"];
+ var _ts_node_named_child_count_wasm = Module["_ts_node_named_child_count_wasm"] = wasmExports["ts_node_named_child_count_wasm"];
+ var _ts_node_child_wasm = Module["_ts_node_child_wasm"] = wasmExports["ts_node_child_wasm"];
+ var _ts_node_named_child_wasm = Module["_ts_node_named_child_wasm"] = wasmExports["ts_node_named_child_wasm"];
+ var _ts_node_child_by_field_id_wasm = Module["_ts_node_child_by_field_id_wasm"] = wasmExports["ts_node_child_by_field_id_wasm"];
+ var _ts_node_next_sibling_wasm = Module["_ts_node_next_sibling_wasm"] = wasmExports["ts_node_next_sibling_wasm"];
+ var _ts_node_prev_sibling_wasm = Module["_ts_node_prev_sibling_wasm"] = wasmExports["ts_node_prev_sibling_wasm"];
+ var _ts_node_next_named_sibling_wasm = Module["_ts_node_next_named_sibling_wasm"] = wasmExports["ts_node_next_named_sibling_wasm"];
+ var _ts_node_prev_named_sibling_wasm = Module["_ts_node_prev_named_sibling_wasm"] = wasmExports["ts_node_prev_named_sibling_wasm"];
+ var _ts_node_descendant_count_wasm = Module["_ts_node_descendant_count_wasm"] = wasmExports["ts_node_descendant_count_wasm"];
+ var _ts_node_parent_wasm = Module["_ts_node_parent_wasm"] = wasmExports["ts_node_parent_wasm"];
+ var _ts_node_child_with_descendant_wasm = Module["_ts_node_child_with_descendant_wasm"] = wasmExports["ts_node_child_with_descendant_wasm"];
+ var _ts_node_descendant_for_index_wasm = Module["_ts_node_descendant_for_index_wasm"] = wasmExports["ts_node_descendant_for_index_wasm"];
+ var _ts_node_named_descendant_for_index_wasm = Module["_ts_node_named_descendant_for_index_wasm"] = wasmExports["ts_node_named_descendant_for_index_wasm"];
+ var _ts_node_descendant_for_position_wasm = Module["_ts_node_descendant_for_position_wasm"] = wasmExports["ts_node_descendant_for_position_wasm"];
+ var _ts_node_named_descendant_for_position_wasm = Module["_ts_node_named_descendant_for_position_wasm"] = wasmExports["ts_node_named_descendant_for_position_wasm"];
+ var _ts_node_start_point_wasm = Module["_ts_node_start_point_wasm"] = wasmExports["ts_node_start_point_wasm"];
+ var _ts_node_end_point_wasm = Module["_ts_node_end_point_wasm"] = wasmExports["ts_node_end_point_wasm"];
+ var _ts_node_start_index_wasm = Module["_ts_node_start_index_wasm"] = wasmExports["ts_node_start_index_wasm"];
+ var _ts_node_end_index_wasm = Module["_ts_node_end_index_wasm"] = wasmExports["ts_node_end_index_wasm"];
+ var _ts_node_to_string_wasm = Module["_ts_node_to_string_wasm"] = wasmExports["ts_node_to_string_wasm"];
+ var _ts_node_children_wasm = Module["_ts_node_children_wasm"] = wasmExports["ts_node_children_wasm"];
+ var _ts_node_named_children_wasm = Module["_ts_node_named_children_wasm"] = wasmExports["ts_node_named_children_wasm"];
+ var _ts_node_descendants_of_type_wasm = Module["_ts_node_descendants_of_type_wasm"] = wasmExports["ts_node_descendants_of_type_wasm"];
+ var _ts_node_is_named_wasm = Module["_ts_node_is_named_wasm"] = wasmExports["ts_node_is_named_wasm"];
+ var _ts_node_has_changes_wasm = Module["_ts_node_has_changes_wasm"] = wasmExports["ts_node_has_changes_wasm"];
+ var _ts_node_has_error_wasm = Module["_ts_node_has_error_wasm"] = wasmExports["ts_node_has_error_wasm"];
+ var _ts_node_is_error_wasm = Module["_ts_node_is_error_wasm"] = wasmExports["ts_node_is_error_wasm"];
+ var _ts_node_is_missing_wasm = Module["_ts_node_is_missing_wasm"] = wasmExports["ts_node_is_missing_wasm"];
+ var _ts_node_is_extra_wasm = Module["_ts_node_is_extra_wasm"] = wasmExports["ts_node_is_extra_wasm"];
+ var _ts_node_parse_state_wasm = Module["_ts_node_parse_state_wasm"] = wasmExports["ts_node_parse_state_wasm"];
+ var _ts_node_next_parse_state_wasm = Module["_ts_node_next_parse_state_wasm"] = wasmExports["ts_node_next_parse_state_wasm"];
+ var _ts_query_matches_wasm = Module["_ts_query_matches_wasm"] = wasmExports["ts_query_matches_wasm"];
+ var _ts_query_captures_wasm = Module["_ts_query_captures_wasm"] = wasmExports["ts_query_captures_wasm"];
+ var _memset = Module["_memset"] = wasmExports["memset"];
+ var _memcpy = Module["_memcpy"] = wasmExports["memcpy"];
+ var _memmove = Module["_memmove"] = wasmExports["memmove"];
+ var _iswalpha = Module["_iswalpha"] = wasmExports["iswalpha"];
+ var _iswblank = Module["_iswblank"] = wasmExports["iswblank"];
+ var _iswdigit = Module["_iswdigit"] = wasmExports["iswdigit"];
+ var _iswlower = Module["_iswlower"] = wasmExports["iswlower"];
+ var _iswupper = Module["_iswupper"] = wasmExports["iswupper"];
+ var _iswxdigit = Module["_iswxdigit"] = wasmExports["iswxdigit"];
+ var _memchr = Module["_memchr"] = wasmExports["memchr"];
+ var _strlen = Module["_strlen"] = wasmExports["strlen"];
+ var _strcmp = Module["_strcmp"] = wasmExports["strcmp"];
+ var _strncat = Module["_strncat"] = wasmExports["strncat"];
+ var _strncpy = Module["_strncpy"] = wasmExports["strncpy"];
+ var _towlower = Module["_towlower"] = wasmExports["towlower"];
+ var _towupper = Module["_towupper"] = wasmExports["towupper"];
+ var _setThrew = wasmExports["setThrew"];
+ var __emscripten_stack_restore = wasmExports["_emscripten_stack_restore"];
+ var __emscripten_stack_alloc = wasmExports["_emscripten_stack_alloc"];
+ var _emscripten_stack_get_current = wasmExports["emscripten_stack_get_current"];
+ var ___wasm_apply_data_relocs = wasmExports["__wasm_apply_data_relocs"];
+ Module["setValue"] = setValue;
+ Module["getValue"] = getValue;
+ Module["UTF8ToString"] = UTF8ToString;
+ Module["stringToUTF8"] = stringToUTF8;
+ Module["lengthBytesUTF8"] = lengthBytesUTF8;
+ Module["AsciiToString"] = AsciiToString;
+ Module["stringToUTF16"] = stringToUTF16;
+ Module["loadWebAssemblyModule"] = loadWebAssemblyModule;
+ function callMain(args2 = []) {
+ var entryFunction = resolveGlobalSymbol("main").sym;
+ if (!entryFunction) return;
+ args2.unshift(thisProgram);
+ var argc = args2.length;
+ var argv = stackAlloc((argc + 1) * 4);
+ var argv_ptr = argv;
+ args2.forEach((arg) => {
+ LE_HEAP_STORE_U32((argv_ptr >> 2) * 4, stringToUTF8OnStack(arg));
+ argv_ptr += 4;
+ });
+ LE_HEAP_STORE_U32((argv_ptr >> 2) * 4, 0);
+ try {
+ var ret = entryFunction(argc, argv);
+ exitJS(
+ ret,
+ /* implicit = */
+ true
+ );
+ return ret;
+ } catch (e) {
+ return handleException(e);
+ }
+ }
+ __name(callMain, "callMain");
+ function run(args2 = arguments_) {
+ if (runDependencies > 0) {
+ dependenciesFulfilled = run;
+ return;
+ }
+ preRun();
+ if (runDependencies > 0) {
+ dependenciesFulfilled = run;
+ return;
+ }
+ function doRun() {
+ Module["calledRun"] = true;
+ if (ABORT) return;
+ initRuntime();
+ preMain();
+ readyPromiseResolve(Module);
+ Module["onRuntimeInitialized"]?.();
+ var noInitialRun = Module["noInitialRun"];
+ if (!noInitialRun) callMain(args2);
+ postRun();
+ }
+ __name(doRun, "doRun");
+ if (Module["setStatus"]) {
+ Module["setStatus"]("Running...");
+ setTimeout(() => {
+ setTimeout(() => Module["setStatus"](""), 1);
+ doRun();
+ }, 1);
+ } else {
+ doRun();
+ }
+ }
+ __name(run, "run");
+ if (Module["preInit"]) {
+ if (typeof Module["preInit"] == "function") Module["preInit"] = [Module["preInit"]];
+ while (Module["preInit"].length > 0) {
+ Module["preInit"].pop()();
+ }
+ }
+ run();
+ moduleRtn = readyPromise;
+ return moduleRtn;
+ };
+})();
+var tree_sitter_default = Module2;
+
+// src/bindings.ts
+var Module3 = null;
+async function initializeBinding(moduleOptions) {
+ if (!Module3) {
+ Module3 = await tree_sitter_default(moduleOptions);
+ }
+ return Module3;
+}
+__name(initializeBinding, "initializeBinding");
+function checkModule() {
+ return !!Module3;
+}
+__name(checkModule, "checkModule");
+
+// src/parser.ts
+var TRANSFER_BUFFER;
+var LANGUAGE_VERSION;
+var MIN_COMPATIBLE_VERSION;
+var Parser = class {
+ static {
+ __name(this, "Parser");
+ }
+ /** @internal */
+ [0] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ [1] = 0;
+ // Internal handle for WASM
+ /** @internal */
+ logCallback = null;
+ /** The parser's current language. */
+ language = null;
+ /**
+ * This must always be called before creating a Parser.
+ *
+ * You can optionally pass in options to configure the WASM module, the most common
+ * one being `locateFile` to help the module find the `.wasm` file.
+ */
+ static async init(moduleOptions) {
+ setModule(await initializeBinding(moduleOptions));
+ TRANSFER_BUFFER = C._ts_init();
+ LANGUAGE_VERSION = C.getValue(TRANSFER_BUFFER, "i32");
+ MIN_COMPATIBLE_VERSION = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ }
+ /**
+ * Create a new parser.
+ */
+ constructor() {
+ this.initialize();
+ }
+ /** @internal */
+ initialize() {
+ if (!checkModule()) {
+ throw new Error("cannot construct a Parser before calling `init()`");
+ }
+ C._ts_parser_new_wasm();
+ this[0] = C.getValue(TRANSFER_BUFFER, "i32");
+ this[1] = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ }
+ /** Delete the parser, freeing its resources. */
+ delete() {
+ C._ts_parser_delete(this[0]);
+ C._free(this[1]);
+ this[0] = 0;
+ this[1] = 0;
+ }
+ /**
+ * Set the language that the parser should use for parsing.
+ *
+ * If the language was not successfully assigned, an error will be thrown.
+ * This happens if the language was generated with an incompatible
+ * version of the Tree-sitter CLI. Check the language's version using
+ * {@link Language#version} and compare it to this library's
+ * {@link LANGUAGE_VERSION} and {@link MIN_COMPATIBLE_VERSION} constants.
+ */
+ setLanguage(language) {
+ let address;
+ if (!language) {
+ address = 0;
+ this.language = null;
+ } else if (language.constructor === Language) {
+ address = language[0];
+ const version = C._ts_language_version(address);
+ if (version < MIN_COMPATIBLE_VERSION || LANGUAGE_VERSION < version) {
+ throw new Error(
+ `Incompatible language version ${version}. Compatibility range ${MIN_COMPATIBLE_VERSION} through ${LANGUAGE_VERSION}.`
+ );
+ }
+ this.language = language;
+ } else {
+ throw new Error("Argument must be a Language");
+ }
+ C._ts_parser_set_language(this[0], address);
+ return this;
+ }
+ /**
+ * Parse a slice of UTF8 text.
+ *
+ * @param {string | ParseCallback} callback - The UTF8-encoded text to parse or a callback function.
+ *
+ * @param {Tree | null} [oldTree] - A previous syntax tree parsed from the same document. If the text of the
+ * document has changed since `oldTree` was created, then you must edit `oldTree` to match
+ * the new text using {@link Tree#edit}.
+ *
+ * @param {ParseOptions} [options] - Options for parsing the text.
+ * This can be used to set the included ranges, or a progress callback.
+ *
+ * @returns {Tree | null} A {@link Tree} if parsing succeeded, or `null` if:
+ * - The parser has not yet had a language assigned with {@link Parser#setLanguage}.
+ * - The progress callback returned true.
+ */
+ parse(callback, oldTree, options) {
+ if (typeof callback === "string") {
+ C.currentParseCallback = (index) => callback.slice(index);
+ } else if (typeof callback === "function") {
+ C.currentParseCallback = callback;
+ } else {
+ throw new Error("Argument must be a string or a function");
+ }
+ if (options?.progressCallback) {
+ C.currentProgressCallback = options.progressCallback;
+ } else {
+ C.currentProgressCallback = null;
+ }
+ if (this.logCallback) {
+ C.currentLogCallback = this.logCallback;
+ C._ts_parser_enable_logger_wasm(this[0], 1);
+ } else {
+ C.currentLogCallback = null;
+ C._ts_parser_enable_logger_wasm(this[0], 0);
+ }
+ let rangeCount = 0;
+ let rangeAddress = 0;
+ if (options?.includedRanges) {
+ rangeCount = options.includedRanges.length;
+ rangeAddress = C._calloc(rangeCount, SIZE_OF_RANGE);
+ let address = rangeAddress;
+ for (let i2 = 0; i2 < rangeCount; i2++) {
+ marshalRange(address, options.includedRanges[i2]);
+ address += SIZE_OF_RANGE;
+ }
+ }
+ const treeAddress = C._ts_parser_parse_wasm(
+ this[0],
+ this[1],
+ oldTree ? oldTree[0] : 0,
+ rangeAddress,
+ rangeCount
+ );
+ if (!treeAddress) {
+ C.currentParseCallback = null;
+ C.currentLogCallback = null;
+ C.currentProgressCallback = null;
+ return null;
+ }
+ if (!this.language) {
+ throw new Error("Parser must have a language to parse");
+ }
+ const result = new Tree(INTERNAL, treeAddress, this.language, C.currentParseCallback);
+ C.currentParseCallback = null;
+ C.currentLogCallback = null;
+ C.currentProgressCallback = null;
+ return result;
+ }
+ /**
+ * Instruct the parser to start the next parse from the beginning.
+ *
+ * If the parser previously failed because of a timeout, cancellation,
+ * or callback, then by default, it will resume where it left off on the
+ * next call to {@link Parser#parse} or other parsing functions.
+ * If you don't want to resume, and instead intend to use this parser to
+ * parse some other document, you must call `reset` first.
+ */
+ reset() {
+ C._ts_parser_reset(this[0]);
+ }
+ /** Get the ranges of text that the parser will include when parsing. */
+ getIncludedRanges() {
+ C._ts_parser_included_ranges_wasm(this[0]);
+ const count = C.getValue(TRANSFER_BUFFER, "i32");
+ const buffer = C.getValue(TRANSFER_BUFFER + SIZE_OF_INT, "i32");
+ const result = new Array(count);
+ if (count > 0) {
+ let address = buffer;
+ for (let i2 = 0; i2 < count; i2++) {
+ result[i2] = unmarshalRange(address);
+ address += SIZE_OF_RANGE;
+ }
+ C._free(buffer);
+ }
+ return result;
+ }
+ /**
+ * @deprecated since version 0.25.0, prefer passing a progress callback to {@link Parser#parse}
+ *
+ * Get the duration in microseconds that parsing is allowed to take.
+ *
+ * This is set via {@link Parser#setTimeoutMicros}.
+ */
+ getTimeoutMicros() {
+ return C._ts_parser_timeout_micros(this[0]);
+ }
+ /**
+ * @deprecated since version 0.25.0, prefer passing a progress callback to {@link Parser#parse}
+ *
+ * Set the maximum duration in microseconds that parsing should be allowed
+ * to take before halting.
+ *
+ * If parsing takes longer than this, it will halt early, returning `null`.
+ * See {@link Parser#parse} for more information.
+ */
+ setTimeoutMicros(timeout) {
+ C._ts_parser_set_timeout_micros(this[0], 0, timeout);
+ }
+ /** Set the logging callback that a parser should use during parsing. */
+ setLogger(callback) {
+ if (!callback) {
+ this.logCallback = null;
+ } else if (typeof callback !== "function") {
+ throw new Error("Logger callback must be a function");
+ } else {
+ this.logCallback = callback;
+ }
+ return this;
+ }
+ /** Get the parser's current logger. */
+ getLogger() {
+ return this.logCallback;
+ }
+};
+export {
+ CaptureQuantifier,
+ LANGUAGE_VERSION,
+ Language,
+ LookaheadIterator,
+ MIN_COMPATIBLE_VERSION,
+ Node,
+ Parser,
+ Query,
+ Tree,
+ TreeCursor
+};
+//# sourceMappingURL=tree-sitter.js.map
diff --git a/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.wasm b/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.wasm
new file mode 100644
index 0000000000000000000000000000000000000000..f1056bd4ac93e33a18d5756dffbbebc793848300
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/lib/tree-sitter.wasm
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1340a1d8a45bd63c5adbfa83cd376f2611985e82537e8c733fd5489c9c357ea8
+size 205491
diff --git a/custom_nodes/rgthree-comfy/src_web/link_fixer/icon_file_json.png b/custom_nodes/rgthree-comfy/src_web/link_fixer/icon_file_json.png
new file mode 100644
index 0000000000000000000000000000000000000000..ad3a1cb2b89a2051010d53d69b12cca8735af353
Binary files /dev/null and b/custom_nodes/rgthree-comfy/src_web/link_fixer/icon_file_json.png differ
diff --git a/custom_nodes/rgthree-comfy/src_web/link_fixer/index.html b/custom_nodes/rgthree-comfy/src_web/link_fixer/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..2e1b66ecd2f5e0c4cb1489b02eba9b226e86d819
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/link_fixer/index.html
@@ -0,0 +1,140 @@
+
+
+
+ rgthree's comfy: Workflow Link Fixer
+
+
+
+
+
+
rgthree's Workflow Link Fixer
+
+ Drag and drop a comfy-generated image or workflow json into this window to check its
+ serialized links and attempt to fix.
+
+
+ Sometimes as you have complex workflows the internal data can become corrupt, and the
+ ComfyUI doesn't always understand or display correctly. Maybe links disappear, or reconnect
+ to another node when changing something. Load it here to detect and, if possible, attempt
+ to fix it (sometimes, however, fixing it just isn't feasible, so fingers crossed).
+
+
+
+
+
+
+
+
+
+
+
+
Fix & Save new workflow
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/link_fixer/link_page.ts b/custom_nodes/rgthree-comfy/src_web/link_fixer/link_page.ts
new file mode 100644
index 0000000000000000000000000000000000000000..024ff6bc9c1c8ae6c2b1e007b310fedafe03781f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/link_fixer/link_page.ts
@@ -0,0 +1,225 @@
+import type {ISerialisedGraph} from "@comfyorg/frontend";
+import type {BadLinksData} from "../common/link_fixer.js";
+
+import {WorkflowLinkFixer} from "../common/link_fixer.js";
+import {getPngMetadata} from "../common/comfyui_shim.js";
+
+function wait(ms = 16, value?: any) {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(value);
+ }, ms);
+ });
+}
+
+const logger = {
+ logTo: console as Console | HTMLElement,
+ log: (...args: any[]) => {
+ logger.logTo === console
+ ? console.log(...args)
+ : ((logger.logTo as HTMLElement).innerText += args.join(",") + "\n");
+ },
+};
+
+export class LinkPage {
+ private containerEl: HTMLDivElement;
+ private figcaptionEl: HTMLElement;
+ private btnFix: HTMLButtonElement;
+ private outputeMessageEl: HTMLDivElement;
+ private outputImageEl: HTMLImageElement;
+
+ private file?: File | Blob;
+ private graph?: ISerialisedGraph;
+ private graphResults?: BadLinksData;
+ private graphFinalResults?: BadLinksData;
+
+ private fixer: WorkflowLinkFixer | null = null;
+
+ constructor() {
+ // const consoleEl = document.getElementById("console")!;
+ this.containerEl = document.querySelector(".box")!;
+ this.figcaptionEl = document.querySelector("figcaption")!;
+ this.outputeMessageEl = document.querySelector(".output")!;
+ this.outputImageEl = document.querySelector(".output-image")!;
+ this.btnFix = document.querySelector(".btn-fix")!;
+
+ // Need to prevent on dragover to allow drop...
+ document.addEventListener(
+ "dragover",
+ (e) => {
+ e.preventDefault();
+ },
+ false,
+ );
+ document.addEventListener("drop", (e) => {
+ this.onDrop(e);
+ });
+ this.btnFix.addEventListener("click", (e) => {
+ this.onFixClick(e);
+ });
+ }
+
+ private async onFixClick(e: MouseEvent) {
+ if (!this.fixer?.checkedData || !this.graph) {
+ this.updateUi("⛔ Fix button click without results.");
+ return;
+ }
+ this.graphFinalResults = this.fixer.fix();
+
+ if (this.graphFinalResults.hasBadLinks) {
+ this.updateUi(
+ "⛔ Hmm... Still detecting bad links. Can you file an issue at https://github.com/rgthree/rgthree-comfy/issues with your image/workflow.",
+ );
+ } else {
+ this.updateUi(
+ "✅ Workflow fixed.Please load new saved workflow json and double check linking and execution. ",
+ );
+ }
+ await wait(16);
+ await this.saveFixedWorkflow();
+ }
+
+ private async onDrop(event: DragEvent) {
+ if (!event.dataTransfer) {
+ return;
+ }
+ this.reset();
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ // Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
+ if (event.dataTransfer.files.length && event.dataTransfer.files?.[0]?.type !== "image/bmp") {
+ await this.handleFile(event.dataTransfer.files[0]!);
+ return;
+ }
+
+ // Try loading the first URI in the transfer list
+ const validTypes = ["text/uri-list", "text/x-moz-url"];
+ const match = [...event.dataTransfer.types].find((t) => validTypes.find((v) => t === v));
+ if (match) {
+ const uri = event.dataTransfer.getData(match)?.split("\n")?.[0];
+ if (uri) {
+ await this.handleFile(await (await fetch(uri)).blob());
+ }
+ }
+ }
+
+ reset() {
+ this.file = undefined;
+ this.graph = undefined;
+ this.graphResults = undefined;
+ this.graphFinalResults = undefined;
+ this.updateUi();
+ }
+
+ private updateUi(msg?: string) {
+ this.outputeMessageEl.innerHTML = "";
+ if (this.file && !this.containerEl.classList.contains("-has-file")) {
+ this.containerEl.classList.add("-has-file");
+ this.figcaptionEl.innerHTML = (this.file as File).name || this.file.type;
+ if (this.file.type === "application/json") {
+ this.outputImageEl.src = "icon_file_json.png";
+ } else {
+ const reader = new FileReader();
+ reader.onload = () => (this.outputImageEl.src = reader.result as string);
+ reader.readAsDataURL(this.file);
+ }
+ } else if (!this.file && this.containerEl.classList.contains("-has-file")) {
+ this.containerEl.classList.remove("-has-file");
+ this.outputImageEl.src = "";
+ this.outputImageEl.removeAttribute("src");
+ }
+
+ if (this.graphResults) {
+ this.containerEl.classList.add("-has-results");
+ if (!this.graphResults.patches && !this.graphResults.deletes) {
+ this.outputeMessageEl.innerHTML = "✅ No bad links detected in the workflow.";
+ } else {
+ this.containerEl.classList.add("-has-fixable-results");
+ this.outputeMessageEl.innerHTML = `⚠️ Found ${this.graphResults.patches} links to fix, and ${this.graphResults.deletes} to be removed.`;
+ }
+ } else {
+ this.containerEl.classList.remove("-has-results");
+ this.containerEl.classList.remove("-has-fixable-results");
+ }
+
+ if (msg) {
+ this.outputeMessageEl.innerHTML = msg;
+ }
+ }
+
+ private async handleFile(file: File | Blob) {
+ this.file = file;
+ this.updateUi();
+
+ let workflow: string | undefined | null = null;
+ if (file.type.startsWith("image/")) {
+ const pngInfo = await getPngMetadata(file);
+ workflow = pngInfo?.workflow;
+ } else if (
+ file.type === "application/json" ||
+ (file instanceof File && file.name.endsWith(".json"))
+ ) {
+ workflow = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ resolve(reader.result as string);
+ };
+ reader.readAsText(file);
+ });
+ }
+ if (!workflow) {
+ this.updateUi("⛔ No workflow found in dropped item.");
+ } else {
+ try {
+ this.graph = JSON.parse(workflow);
+ } catch (e) {
+ this.graph = undefined;
+ }
+ if (!this.graph) {
+ this.updateUi("⛔ Invalid workflow found in dropped item.");
+ } else {
+ this.loadGraphData(this.graph);
+ }
+ }
+ }
+
+ private async loadGraphData(graphData: ISerialisedGraph) {
+ this.fixer = WorkflowLinkFixer.create(graphData);
+ this.graphResults = this.fixer.check();
+ this.updateUi();
+ }
+
+ private async saveFixedWorkflow() {
+ if (!this.graphFinalResults) {
+ this.updateUi("⛔ Save w/o final graph patched.");
+ return false;
+ }
+
+ let filename: string | null = (this.file as File).name || "workflow.json";
+ let filenames = filename.split(".");
+ filenames.pop();
+ filename = filenames.join(".");
+ filename += "_fixed.json";
+ filename = prompt("Save workflow as:", filename);
+ if (!filename) return false;
+ if (!filename.toLowerCase().endsWith(".json")) {
+ filename += ".json";
+ }
+ const json = JSON.stringify(this.graphFinalResults.graph, null, 2);
+ const blob = new Blob([json], {type: "application/json"});
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.download = filename;
+ anchor.href = url;
+ anchor.style.display = "none";
+ document.body.appendChild(anchor);
+ await wait();
+ anchor.click();
+ await wait();
+ anchor.remove();
+ window.URL.revokeObjectURL(url);
+ return true;
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.html b/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.html
new file mode 100644
index 0000000000000000000000000000000000000000..3be75c9d3adab73695a711b1943d3edf92212486
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.scss b/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.scss
new file mode 100644
index 0000000000000000000000000000000000000000..82e085a5a468e320ab6cd4092a3c772718b98265
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.scss
@@ -0,0 +1,42 @@
+$space: 8px;
+
+.rgthree-model-info-card {
+ display: block;
+ padding: $space;
+}
+
+.-is-hidden {
+ display: none;
+}
+
+
+.rgthree-model-info-card {
+ display: flex;
+ flex-direction: row;
+
+ >.rgthree-model-info-card-media-container {
+ width: 100px;
+ height: auto;
+ display: block;
+ margin: 0 $space 0 0;
+ padding: 0;
+ flex: 0 0 auto;
+
+ >img,
+ >video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+ }
+
+ >.rgthree-model-info-card-data-container {
+
+ [bind*="name:"] {
+ font-size: 1.3em;
+ margin-bottom: calc($space / 2);
+ font-weight: bold;
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.ts b/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3d5cdea97df5b1e5197ee3ea829a32439b004793
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/models/components/model-info-card.ts
@@ -0,0 +1,53 @@
+import {RgthreeCustomElement} from "rgthree/common/components/base_custom_element";
+import {RgthreeModelInfo} from "typings/rgthree";
+
+export class RgthreeModelInfoCard extends RgthreeCustomElement {
+ static override readonly NAME = "rgthree-model-info-card";
+ static override readonly TEMPLATES = "components/model-info-card.html";
+ static override readonly CSS = "components/model-info-card.css";
+ static override readonly USE_SHADOW = false;
+
+ private data: RgthreeModelInfo = {};
+
+ getModified(
+ value: number,
+ data: any,
+ currentElement: HTMLElement,
+ contextElement: RgthreeModelInfoCard,
+ ) {
+ const date = new Date(value);
+ return String(`${date.toLocaleDateString()} ${date.toLocaleTimeString()}`);
+ }
+
+ getCivitaiLink(links: string[] | undefined) {
+ return links?.find((i) => i.includes("civitai.com/models")) || null;
+ }
+
+ setModelData(data: RgthreeModelInfo) {
+ this.data = data;
+ }
+
+ hasBaseModel(baseModel: string) {
+ return this.data.baseModel === baseModel;
+ }
+
+ hasData(field: string) {
+ // return !!this.data.hasInfoFile;
+ if (field === "civitai") {
+ return !!this.getCivitaiLink(this.data.links)?.length;
+ }
+ return !!(this.data as any)[field];
+ }
+
+ matchesQueryText(query: string) {
+ return (this.data.name || this.data.file)?.includes(query);
+ }
+
+ hide() {
+ this.classList.add("-is-hidden");
+ }
+
+ show() {
+ this.classList.remove("-is-hidden");
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/models/index.html b/custom_nodes/rgthree-comfy/src_web/models/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..137a1f72a9f72a76d585b6a006c4d6eebceb3fe6
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/models/index.html
@@ -0,0 +1,18 @@
+
+
+
+ rgthree-comfy: Models Manager
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/models/models.scss b/custom_nodes/rgthree-comfy/src_web/models/models.scss
new file mode 100644
index 0000000000000000000000000000000000000000..160ff1743023a8151abecc317606f60fa0094357
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/models/models.scss
@@ -0,0 +1,40 @@
+$space: 8px;
+
+:root {
+ --rgthree-bg-color: rgba(23, 23, 23, 0.9);
+ --rgthree-on-bg-color: rgba(48, 48, 48, 0.9);
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: Arial, sans-serif;
+ box-sizing: border-body;
+ background: var(--rgthree-bg-color);
+}
+
+*, *::before, *::after {
+ box-sizing: inherit;
+}
+
+[if-is="false"] {
+ display: none;
+}
+
+.models-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.model-item {
+ display: block;
+ margin: 8px;
+}
+
+rgthree-model-info-card {
+ background: var(--rgthree-on-bg-color);
+ margin: $space * 2;
+ display: block;
+ border-radius: 4px;
+}
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/models/models_info_page.ts b/custom_nodes/rgthree-comfy/src_web/models/models_info_page.ts
new file mode 100644
index 0000000000000000000000000000000000000000..df6e38e415f0701a2f65107c0d527d5d59efa19d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/models/models_info_page.ts
@@ -0,0 +1,136 @@
+import {createElement, getActionEls, query, queryAll} from "../common/utils_dom.js";
+
+import {rgthreeApi} from "rgthree/common/rgthree_api.js";
+import {RgthreeModelInfoCard} from "./components/model-info-card.js";
+
+function parseQuery(query: string) {
+ // Split on spaces
+ const matches = query.match(/"[^\"]+"/g) || [];
+ for (const match of matches) {
+ let cleaned = match.substring(1, match.length - 1);
+ cleaned = cleaned.replace(/\s+/g, " ").trim().replace(/\s/g, "__SPACE__");
+ query = query.replace(match, ` ${cleaned} `);
+ }
+ const queryParts = query
+ .replace(/\s+/g, " ")
+ .trim()
+ .split(" ")
+ .map((p) => p.replace(/__SPACE__/g, " "));
+ return queryParts;
+}
+
+export class ModelsInfoPage {
+ private readonly selectBaseModel: HTMLSelectElement = createElement(
+ 'select[name="baseModel"][on="change:filter"]',
+ );
+ private readonly searchbox: HTMLInputElement = query("#searchbox")!;
+ private readonly modelsList: HTMLUListElement = query("#models-list")!;
+ private queryLast = "";
+ private doSearchDebounce: number = 0;
+
+ constructor() {
+ console.log("hello model page");
+ // rgthreeApi.setBaseUrl('../api');
+ this.init();
+ }
+
+ async init() {
+ this.searchbox.addEventListener("input", (e) => {
+ if (this.doSearchDebounce) {
+ return;
+ }
+ this.doSearchDebounce = setTimeout(() => {
+ this.doSearch();
+ this.doSearchDebounce = 0;
+ }, 250);
+ });
+
+ const loras = await rgthreeApi.getLorasInfo({light: true});
+ console.log(loras);
+
+ const baseModels = new Set();
+
+ for (const lora of loras) {
+ const el = RgthreeModelInfoCard.create();
+ el.setModelData(lora);
+ el.bindWhenConnected(lora);
+ console.log(el);
+ lora.baseModel && baseModels.add(lora.baseModel);
+ this.modelsList.appendChild(createElement("li.model-item", {child: el}));
+ }
+
+ if (baseModels.size > 1) {
+ createElement(`option[value="ALL"][text="Choose base model."]`, {
+ parent: this.selectBaseModel,
+ });
+ for (const baseModel of baseModels.values()) {
+ createElement(`option[value="${baseModel}"][text="${baseModel}"]`, {
+ parent: this.selectBaseModel,
+ });
+ }
+ this.searchbox.insertAdjacentElement("afterend", this.selectBaseModel);
+ }
+
+ const data = getActionEls(document.body);
+ for (const dataItem of Object.values(data)) {
+ for (const [event, action] of Object.entries(dataItem.actions)) {
+ dataItem.el.addEventListener(event as keyof ElementEventMap, (e) => {
+ if (typeof (this as any)[action] != "function") {
+ throw new Error(`"${action}" does not exist on instance.`);
+ }
+ (this as any)[action](e);
+ });
+ }
+ }
+ }
+
+ filter() {
+ const parts = parseQuery(this.queryLast);
+ const baseModel = this.selectBaseModel.value;
+ const els = queryAll(RgthreeModelInfoCard.NAME);
+ const shouldHide = (el: RgthreeModelInfoCard) => {
+ let hide = baseModel !== "ALL" && !el.hasBaseModel(baseModel);
+ if (!hide) {
+ for (let part of parts) {
+ let negate = false;
+ if (part.startsWith("-")) {
+ negate = true;
+ part = part.substring(1);
+ }
+ if (!part) continue;
+
+ if (part.startsWith("has:")) {
+ if (part === "has:civitai") {
+ hide = !el.hasData(part.replace("has:", ""));
+ }
+ } else {
+ hide = !el.matchesQueryText(part);
+ }
+ hide = negate ? !hide : hide;
+ if (hide) {
+ break;
+ }
+ }
+ }
+ return hide;
+ };
+ for (const el of els) {
+ const hide = shouldHide(el);
+ if (hide) {
+ el.hide();
+ } else {
+ el.show();
+ }
+ }
+
+ console.log("filter");
+ }
+
+ doSearch() {
+ const query = this.searchbox.value.trim();
+ if (this.queryLast != query) {
+ this.queryLast = query;
+ this.filter();
+ }
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/scripts_comfy/README.md b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9b5b422571a25740dd1732db363bd442c89c21dc
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/README.md
@@ -0,0 +1,6 @@
+Here lies dummy ts files that decalre/export ComfyUI's own scripts files as typed types w/o needing
+to symlink to the actual implementation.
+
+Actual code in the comfyui/ directory can import these like `import {app} from "/scripts/app.js"`
+and have access to `app` as the fully typed `ComfyApp`. The `__build__.py` script will rewrite these
+to the relative browser path.
\ No newline at end of file
diff --git a/custom_nodes/rgthree-comfy/src_web/scripts_comfy/api.ts b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dc4400852938db227c995d9be9c845c71476bdad
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/api.ts
@@ -0,0 +1,10 @@
+interface ComfyApi extends EventTarget {
+ api_base: string;
+ getNodeDefs(): any;
+ apiURL(url: string): string;
+ queuePrompt(num: number, data: { output: {}; workflow: {} }): Promise<{}>;
+ fetchApi(route: string, options?: RequestInit) : Promise;
+ interrupt(): void;
+}
+
+export declare const api: ComfyApi;
diff --git a/custom_nodes/rgthree-comfy/src_web/scripts_comfy/app.ts b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9bec3ee977a1781792aaf9e9dbcd12bf618d7c96
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/app.ts
@@ -0,0 +1,7 @@
+import {ComfyApp} from "@comfyorg/frontend";
+
+/**
+ * A dummy ComfyApp that we can import from our code, which we'll rewrite later to the comfyui
+ * hosted app.js
+ */
+export declare const app: ComfyApp;
diff --git a/custom_nodes/rgthree-comfy/src_web/scripts_comfy/widgets.ts b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/widgets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a6508d11c51f7ab3cfe136433a866e912fef0ef
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/scripts_comfy/widgets.ts
@@ -0,0 +1,18 @@
+import type {ComfyApp, IStringWidget, IComboWidget, IWidget, LGraphNode} from "@comfyorg/frontend";
+
+type ComfyWidgetFn = (
+ node: LGraphNode,
+ inputName: string,
+ inputData: any,
+ app: ComfyApp,
+) => {widget: WidgetType};
+
+/**
+ * A dummy ComfyWidgets that we can import from our code, which we'll rewrite later to the comfyui
+ * hosted widgets.js
+ */
+export declare const ComfyWidgets: {
+ COMBO: ComfyWidgetFn;
+ STRING: ComfyWidgetFn;
+ [key: string]: ComfyWidgetFn;
+};
diff --git a/custom_nodes/rgthree-comfy/src_web/typings/comfy.d.ts b/custom_nodes/rgthree-comfy/src_web/typings/comfy.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f6f2e1b5fcd2b21a7288cd287bb2041603428080
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/typings/comfy.d.ts
@@ -0,0 +1,153 @@
+/**
+ * These contain types from ComfyUI (or LiteGraph) that are either copied or manually determined
+ * added here mostly because @comfyorg/comfyui-frontend-types hasn't exported them oir they weren't
+ * available.
+ */
+import type {SerializedGraph} from './index.js';
+
+export type getPngMetadata = (file: File | Blob) => { workflow?: string; prompt?: string };
+export type getWebpMetadata = (file: File | Blob) => {
+ Workflow?: string;
+ workflow?: string;
+ Prompt?: string;
+ prompt?: string;
+};
+
+// Below are types derived from the Serialized version of a workflow.
+
+// export type SerializedLink = [
+// number, // this.id,
+// number, // this.origin_id,
+// number, // this.origin_slot,
+// number, // this.target_id,
+// number, // this.target_slot,
+// string, // this.type
+// ];
+
+// interface SerializedNodeInput {
+// name: string;
+// type: string;
+// link: number;
+// }
+// interface SerializedNodeOutput extends SerializedNodeInput {
+// slot_index: number;
+// links: number[];
+// }
+
+// export interface SerializedNode {
+// // id: number;
+// // inputs: SerializedNodeInput[];
+// // outputs: SerializedNodeOutput[];
+// mode: number;
+// order: number;
+// pos: [number, number];
+// properties: any;
+// size: [number, number];
+// type: string;
+// widgets_values: Array;
+// }
+
+// export interface SerializedGraph {
+ // config: any;
+ // extra: any;
+ // groups: any;
+ // last_link_id: number;
+ // last_node_id: number;
+ // links: SerializedLink[];
+ // nodes: SerializedNode[];
+// }
+
+
+/**
+ * ComfyUI-Frontend defines a ComfyNodeDef from Zod, but doesn't expose it. This is a shim.
+ */
+export type ComfyNodeDef = {
+ name: string;
+ display_name?: string;
+ description?: string;
+ category: string;
+ input?: {
+ required?: Record;
+ optional?: Record;
+ hidden?: Record;
+ };
+ output?: string[];
+ output_name: string[];
+ // @rgthree
+ output_node?: boolean;
+};
+
+
+
+// Below are types derived from the formats for the ComfyAPI.
+
+// @rgthree
+type ComfyApiInputLink = [
+ /** The id string of the connected node. */
+ string,
+ /** The output index. */
+ number,
+]
+
+
+type ComfyApiFormatNode = {
+ "inputs": {
+ [input_name: string]: string|number|boolean|ComfyApiInputLink,
+ },
+ "class_type": string,
+ "_meta": {
+ "title": string,
+ }
+}
+
+export type ComfyApiFormat = {
+ [node_id: string]: ComfyApiFormatNode
+}
+
+export type ComfyApiPrompt = {
+ workflow: SerializedGraph,
+ output: ComfyApiFormat,
+}
+
+export type ComfyApiEventDetailStatus = {
+ exec_info: {
+ queue_remaining: number;
+ };
+};
+
+export type ComfyApiEventDetailExecutionStart = {
+ prompt_id: string;
+};
+
+export type ComfyApiEventDetailExecuting = null | string;
+
+export type ComfyApiEventDetailProgress = {
+ node: string;
+ prompt_id: string;
+ max: number;
+ value: number;
+};
+
+export type ComfyApiEventDetailExecuted = {
+ node: string;
+ prompt_id: string;
+ output: any;
+};
+
+export type ComfyApiEventDetailCached = {
+ nodes: string[];
+ prompt_id: string;
+};
+
+export type ComfyApiEventDetailError = {
+ prompt_id: string;
+ exception_type: string;
+ exception_message: string;
+ node_id: string;
+ node_type: string;
+ node_id: string;
+ traceback: string;
+ executed: any[];
+ current_inputs: {[key: string]: (number[]|string[])};
+ current_outputs: {[key: string]: (number[]|string[])};
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/typings/index.d.ts b/custom_nodes/rgthree-comfy/src_web/typings/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ac6f9f01a55a244a321a52b71da51098f246621f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/typings/index.d.ts
@@ -0,0 +1,30 @@
+/**
+ * Declare any global properties and import our other typings here.
+ */
+import "@comfyorg/frontend";
+import "./litegraph";
+import "./rgthree";
+import "./comfy";
+
+import type {
+ LGraphGroup as TLGraphGroup,
+ LGraphNode as TLGraphNode,
+ LGraph as TLGraph,
+ LGraphCanvas as TLGraphCanvas,
+ LiteGraph as TLiteGraph,
+} from "@comfyorg/frontend";
+
+declare global {
+ const LiteGraph: typeof TLiteGraph;
+ const LGraph: typeof TLGraph;
+ const LGraphNode: typeof TLGraphNode;
+ const LGraphCanvas: typeof TLGraphCanvas;
+ const LGraphGroup: typeof TLGraphGroup;
+ interface Window {
+ // Used in the common/comfyui_shim to determine if we're in the app or not.
+ comfyAPI: {
+ // So much more stuffed in here, add as needed.
+ [key: string]: any;
+ };
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/typings/litegraph.d.ts b/custom_nodes/rgthree-comfy/src_web/typings/litegraph.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..24b171a77879172e89a4c45de793f8065084d961
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/typings/litegraph.d.ts
@@ -0,0 +1,193 @@
+/**
+ * This used to augment the LiteGraph types, either to fix them for how they actually behave
+ * (e.g. marking args that are typed as required as optional because they actually are, etc.) or
+ * adding properties/methods that rgthree-comfy adds/uses. Mostly the latter are prefixed 'rgthree_'
+ * but not always.
+ */
+import "@comfyorg/frontend";
+
+declare module "@comfyorg/frontend" {
+ interface INodeSlot {
+ // @rgthree: Hides a slot for rgthree-comfy draw methods.
+ hidden?: boolean;
+
+ // @rgthree: Used to "disable" an input/output. Used in PowerPrompt to disallow connecting
+ // an output if there's no optional corresponding input (since, that would just break).
+ disabled?: boolean;
+
+ // @rgthree: A status we put on some nodes so we can draw things around it.
+ rgthree_status?: "WARN" | "ERROR";
+ }
+
+ interface LGraph {
+ // @rgthree (Fix): `result` arg is optional in impl.
+ findNodesByType(type: string, result?: LGraphNode[]): LGraphNode[];
+ }
+
+ interface LGraphNode {
+ // @rgthree: rgthree-comfy added this before comfyui did and it was a bit more flexible.
+ removeWidget(widget: IBaseWidget | IWidget | number | undefined): void;
+
+ // @rgthree (Fix): Implementation allows a falsy value to be returned and it will suppress the
+ // menu all together.
+ // NOTE: [🤮] We can't actually augment this because it's a return.. but keeping here because
+ // this is how it's actually implemented.
+ // getSlotMenuOptions?(this: LGraphNode, slot: IFoundSlot): IContextMenuValue[] | void;
+
+ // @rgthree (Fix): Implementation allows a falsy value to be returned and it will not add items.
+ // NOTE: [🤮] We can't actually augment this because it's a return.. but keeping here because
+ // this is how it's actually implemented.
+ // getExtraMenuOptions?(
+ // canvas: LGraphCanvas,
+ // options: (IContextMenuValue | null)[],
+ // ): (IContextMenuValue | null)[] | void;
+
+ /**
+ * Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts
+ *
+ * Callback invoked when the node is dragged over from an external source, i.e.
+ * a file or another HTML element.
+ * @param e The drag event
+ * @returns {boolean} True if the drag event should be handled by this node, false otherwise
+ */
+ onDragOver?(e: DragEvent): boolean;
+
+ /**
+ * Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts
+ *
+ * Callback invoked when the node is dropped from an external source, i.e.
+ * a file or another HTML element.
+ * @param e The drag event
+ * @returns {boolean} True if the drag event should be handled by this node, false otherwise
+ */
+ onDragDrop?(e: DragEvent): Promise | boolean;
+
+ /** Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts */
+ onExecuted?(output: any): void;
+
+ /**
+ * Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts
+ *
+ * Index of the currently selected image on a multi-image node such as Preview Image
+ */
+ imageIndex?: number | null;
+
+ /** Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts */
+ overIndex?: number | null;
+
+ /** Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts */
+ imgs?: HTMLImageElement[];
+
+ /** Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts */
+ refreshComboInNode?(defs: Record);
+
+ /**
+ * Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts
+ *
+ * widgets_values is set to LGraphNode by `LGraphNode.configure`, but it is not
+ * used by litegraph internally. We should remove the dependency on it later.
+ */
+ widgets_values?: unknown[];
+ }
+
+ /**
+ * Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts
+ *
+ * Only used by the Primitive node. Primitive node is using the widget property
+ * to store/access the widget config.
+ * We should remove this hacky solution once we have a proper solution.
+ *
+ * @rgthree - Changed this to add `widget?: IWidgetLocator` to INodeOutputSlot (which matches
+ * INodeInputSlot) and then `[key: symbol]: unknown` to IWidgetLocator as that's how it's
+ * used for CONFIG and GET_CONFIG symbols.
+ */
+ interface INodeOutputSlot {
+ widget?: IWidgetLocator; //{name: string; [key: symbol]: unknown};
+ }
+ // @rgthree - See above.
+ interface IWidgetLocator {
+ [key: symbol]: unknown;
+ }
+
+ interface LGraphGroup {
+ // @rgthree: Track whether a group has any active node from the fast group mode changers.
+ rgthree_hasAnyActiveNode?: boolean;
+ }
+
+ interface LGraphCanvas {
+ // @rgthree (Fix): At one point this was in ComfyUI's app.js. I don't see it now... perhaps it's
+ // been removed? We were using it in rgthree-comfy.
+ selected_group_moving?: boolean;
+
+ // @rgthree (Fix): Allows LGraphGroup to be passed (it could be `{size: Point, pos: Point}`).
+ centerOnNode(node: LGraphNode | LGraphGroup);
+
+ // @rgthree (Fix): Makes item's fields optiona, and other params nullable, as well as adds
+ // LGraphGroup to the node, since the implementation accomodates all of these as typed below.
+ // NOTE: [🤮] We can't actually augment this because it's static.. but keeping here because
+ // this is how it's actually implemented.
+ // static onShowPropertyEditor(
+ // item: {
+ // property?: keyof LGraphNode | undefined;
+ // type?: string;
+ // },
+ // options: IContextMenuOptions | null,
+ // e: MouseEvent | null,
+ // menu: ContextMenu | null,
+ // node: LGraphNode | LGraphGroup,
+ // ): void;
+ }
+
+ interface LGraphNodeConstructor {
+ // @rgthree (Fix): Fixes ComfyUI-Frontend which marks this as required, even though elsewhere it
+ // defines it as optional (like for the actual for LGraphNode). Our virtual nodes do not have
+ // a comfyClass since there's nothing to tie it back to.
+ comfyClass?: string;
+
+ // @rgthree: reference the original nodeType data as sometimes extensions clobber it.
+ nodeType?: LGraphNodeConstructor | null;
+ }
+}
+
+declare module "@/lib/litegraph/src/types/widgets" {
+ interface IBaseWidget {
+ // @rgthree (Fix): Where is this in Comfy types?
+ inputEl?: HTMLInputElement;
+
+ // @rgthree: A status we put on some nodes so we can draw things around it.
+ rgthree_lastValue?: any;
+
+ /** Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts */
+ onRemove?(): void;
+ /** Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts */
+ serializeValue?(node: LGraphNode, index: number): Promise | unknown;
+ }
+
+ interface IWidgetOptions {
+ /**
+ * Copied from https://github.com/Comfy-Org/ComfyUI_frontend/blob/ba355b543d0b753365c4e2b40ec376e186727c8c/src/types/litegraph-augmentation.d.ts
+ *
+ * Controls whether the widget's value is included in the API workflow/prompt.
+ * - If false, the value will be excluded from the API workflow but still serialized as part of the graph state
+ * - If true or undefined, the value will be included in both the API workflow and graph state *
+ * @default true
+ * @use {@link IBaseWidget.serialize} if you don't want the widget value to be included in both
+ * the API workflow and graph state.
+ */
+ serialize?: boolean;
+ }
+}
+
+declare module "@/lib/litegraph/src/interfaces" {
+ // @rgthree (Fix): widget is (or was?) available when inputs were moved from a widget.
+ interface IFoundSlot {
+ widget?: IBaseWidget;
+ }
+}
+
+declare module "@comfyorg/litegraph/dist/LiteGraphGlobal" {
+ interface LiteGraphGlobal {
+ // @rgthree (Fix): Window is actually optional in the code.
+ closeAllContextMenus(ref_window?: Window): void;
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/typings/rgthree.d.ts b/custom_nodes/rgthree-comfy/src_web/typings/rgthree.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e1573a0ca0061a6329cc33b9ed2083946ed117d1
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/typings/rgthree.d.ts
@@ -0,0 +1,75 @@
+/**
+ * Typings specific to rgthree-comfy.
+ */
+import type { LGraphNode, Vector2 } from "@comfyorg/frontend";
+import type { CanvasMouseEvent } from "@comfyorg/litegraph/dist/types/events.js";
+import type { RgthreeBaseNode, RgthreeBaseVirtualNode } from '../comfyui/base_node.js'
+
+export type AdjustedMouseCustomEvent = CustomEvent<{ originalEvent: CanvasMouseEvent }>;
+
+export type Constructor = new(...args: any[]) => T;
+
+export interface RgthreeBaseNodeConstructor extends Constructor {
+ static type: string;
+ static category: string;
+ static comfyClass: string;
+ static exposedActions: string[];
+}
+
+export interface RgthreeBaseVirtualNodeConstructor extends Constructor {
+ static type: string;
+ static category: string;
+ static _category: string;
+}
+
+export interface RgthreeBaseServerNodeConstructor extends Constructor {
+ static nodeType: ComfyNodeConstructor;
+ static nodeData: ComfyObjectInfo;
+ static __registeredForOverride__: boolean;
+ onRegisteredForOverride(comfyClass: any, rgthreeClass: any) : void;
+}
+
+export type RgthreeModelInfoDetails = {
+ file?: string;
+ path?: string;
+ hasInfoFile?: boolean;
+ image?: string;
+}
+
+export type RgthreeModelInfo = RgthreeModelInfoDetails & {
+ name?: string;
+ type?: string;
+ baseModel?: string;
+ baseModelFile?: string;
+ links?: string[];
+ strengthMin?: number;
+ strengthMax?: number;
+ triggerWords?: string[];
+ trainedWords?: {
+ word: string;
+ count?: number;
+ civitai?: boolean
+ user?: boolean
+ }[];
+ description?: string;
+ sha256?: string;
+ path?: string;
+ images?: {
+ url: string;
+ civitaiUrl?: string;
+ steps?: string|number;
+ cfg?: string|number;
+ type?: 'image'|'video';
+ sampler?: string;
+ model?: string;
+ seed?: string;
+ negative?: string;
+ positive?: string;
+ resources?: {name?: string, type?: string, weight?: string|number}[];
+ }[]
+ userTags?: string[];
+ userNote?: string;
+ raw?: any;
+ // This one is just on the client.
+ filterDir?: string;
+}
diff --git a/custom_nodes/rgthree-comfy/src_web/typings/web-tree-sitter.d.ts b/custom_nodes/rgthree-comfy/src_web/typings/web-tree-sitter.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..87b3100f108993f4f60e17fd5b4e3a65b5fa48bd
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/src_web/typings/web-tree-sitter.d.ts
@@ -0,0 +1 @@
+import 'web-tree-sitter';
diff --git a/custom_nodes/rgthree-comfy/tsconfig.json b/custom_nodes/rgthree-comfy/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..fd7d330dc31e49b5bd5cec6040cd6acbd7944db4
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/tsconfig.json
@@ -0,0 +1,57 @@
+{
+ "compilerOptions": {
+ "target": "es2019",
+ "module": "ESNext",
+ // "typeRoots": [
+ // "./ts/typings",
+ // ],
+ "baseUrl": "./",
+ "paths": {
+ // Main comfyui typedefs.
+ "@comfyorg/frontend": ["node_modules/@comfyorg/comfyui-frontend-types/index.d.ts"],
+ // ComfyUI augments old litegraph types with these module names, so we need to map them back
+ // to itself to "see" these augmentations in our code.
+ "@/lib/litegraph/src/types/widgets": ["node_modules/@comfyorg/comfyui-frontend-types/index.d.ts"],
+ "@/lib/litegraph/src/interfaces": ["node_modules/@comfyorg/comfyui-frontend-types/index.d.ts"],
+ "@/lib/litegraph/src/litegraph": ["node_modules/@comfyorg/comfyui-frontend-types/index.d.ts"],
+ // Tree Sitter, for parsing python in js.
+ "web-tree-sitter": ["node_modules/web-tree-sitter/web-tree-sitter.d.ts"],
+ "typings/*": ["src_web/typings/*"],
+ "rgthree/common/*": ["src_web/common/*"],
+ "rgthree/lib/*": ["src_web/lib/*"],
+ "node_modules": ["node_modules/*"],
+ "scripts/*": ["src_web/scripts_comfy/*"],
+ },
+ "outDir": "web/",
+ "removeComments": true,
+ "strict": true,
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": true,
+ "strictBindCallApply": true,
+ "strictPropertyInitialization": true,
+ "noImplicitThis": true,
+ "useUnknownInCatchVariables": true,
+ "alwaysStrict": true,
+ // "noUnusedLocals": true,
+ // "noUnusedParameters": true,
+ "exactOptionalPropertyTypes": false,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "allowUnusedLabels": true,
+ "skipLibCheck": true,
+ "experimentalDecorators": true,
+ "types": []
+ },
+ "include": [
+ "src_web/*.ts", "src_web/**/*.ts", "src_web/typings/index.d.ts",
+ ],
+ "exclude": [
+ "**/*.spec.ts",
+ "**/*.d.ts",
+ "node_modules/**/*.ts"
+ ]
+}
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/any_switch.js b/custom_nodes/rgthree-comfy/web/comfyui/any_switch.js
new file mode 100644
index 0000000000000000000000000000000000000000..50e0d796eb939bab92ef8084c1939d7cfdc66d02
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/any_switch.js
@@ -0,0 +1,69 @@
+import { app } from "../../scripts/app.js";
+import { IoDirection, addConnectionLayoutSupport, followConnectionUntilType } from "./utils.js";
+import { RgthreeBaseServerNode } from "./base_node.js";
+import { NodeTypesString } from "./constants.js";
+import { removeUnusedInputsFromEnd } from "./utils_inputs_outputs.js";
+import { debounce } from "../../rgthree/common/shared_utils.js";
+class RgthreeAnySwitch extends RgthreeBaseServerNode {
+ constructor(title = RgthreeAnySwitch.title) {
+ super(title);
+ this.stabilizeBound = this.stabilize.bind(this);
+ this.nodeType = null;
+ this.addAnyInput(5);
+ }
+ onConnectionsChange(type, slotIndex, isConnected, linkInfo, ioSlot) {
+ var _a;
+ (_a = super.onConnectionsChange) === null || _a === void 0 ? void 0 : _a.call(this, type, slotIndex, isConnected, linkInfo, ioSlot);
+ this.scheduleStabilize();
+ }
+ onConnectionsChainChange() {
+ this.scheduleStabilize();
+ }
+ scheduleStabilize(ms = 64) {
+ return debounce(this.stabilizeBound, ms);
+ }
+ addAnyInput(num = 1) {
+ for (let i = 0; i < num; i++) {
+ this.addInput(`any_${String(this.inputs.length + 1).padStart(2, "0")}`, (this.nodeType || "*"));
+ }
+ }
+ stabilize() {
+ removeUnusedInputsFromEnd(this, 4);
+ this.addAnyInput();
+ let connectedType = followConnectionUntilType(this, IoDirection.INPUT, undefined, true);
+ if (!connectedType) {
+ connectedType = followConnectionUntilType(this, IoDirection.OUTPUT, undefined, true);
+ }
+ this.nodeType = (connectedType === null || connectedType === void 0 ? void 0 : connectedType.type) || "*";
+ for (const input of this.inputs) {
+ input.type = this.nodeType;
+ }
+ for (const output of this.outputs) {
+ output.type = this.nodeType;
+ output.label =
+ output.type === "RGTHREE_CONTEXT"
+ ? "CONTEXT"
+ : Array.isArray(this.nodeType) || this.nodeType.includes(",")
+ ? (connectedType === null || connectedType === void 0 ? void 0 : connectedType.label) || String(this.nodeType)
+ : String(this.nodeType);
+ }
+ }
+ static setUp(comfyClass, nodeData) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeAnySwitch);
+ addConnectionLayoutSupport(RgthreeAnySwitch, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ }
+}
+RgthreeAnySwitch.title = NodeTypesString.ANY_SWITCH;
+RgthreeAnySwitch.type = NodeTypesString.ANY_SWITCH;
+RgthreeAnySwitch.comfyClass = NodeTypesString.ANY_SWITCH;
+app.registerExtension({
+ name: "rgthree.AnySwitch",
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (nodeData.name === "Any Switch (rgthree)") {
+ RgthreeAnySwitch.setUp(nodeType, nodeData);
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/base_any_input_connected_node.js b/custom_nodes/rgthree-comfy/web/comfyui/base_any_input_connected_node.js
new file mode 100644
index 0000000000000000000000000000000000000000..09e2950c76a3c503df1fa35102829af0aa0d00ec
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/base_any_input_connected_node.js
@@ -0,0 +1,208 @@
+import { app } from "../../scripts/app.js";
+import { RgthreeBaseVirtualNode } from "./base_node.js";
+import { rgthree } from "./rgthree.js";
+import { PassThroughFollowing, addConnectionLayoutSupport, addMenuItem, getConnectedInputNodes, getConnectedInputNodesAndFilterPassThroughs, getConnectedOutputNodes, getConnectedOutputNodesAndFilterPassThroughs, } from "./utils.js";
+export class BaseAnyInputConnectedNode extends RgthreeBaseVirtualNode {
+ constructor(title = BaseAnyInputConnectedNode.title) {
+ super(title);
+ this.isVirtualNode = true;
+ this.inputsPassThroughFollowing = PassThroughFollowing.NONE;
+ this.debouncerTempWidth = 0;
+ this.schedulePromise = null;
+ }
+ onConstructed() {
+ this.addInput("", "*");
+ return super.onConstructed();
+ }
+ clone() {
+ const cloned = super.clone();
+ if (!rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes) {
+ while (cloned.inputs.length > 1) {
+ cloned.removeInput(cloned.inputs.length - 1);
+ }
+ if (cloned.inputs[0]) {
+ cloned.inputs[0].label = "";
+ }
+ }
+ return cloned;
+ }
+ scheduleStabilizeWidgets(ms = 100) {
+ if (!this.schedulePromise) {
+ this.schedulePromise = new Promise((resolve) => {
+ setTimeout(() => {
+ this.schedulePromise = null;
+ this.doStablization();
+ resolve();
+ }, ms);
+ });
+ }
+ return this.schedulePromise;
+ }
+ stabilizeInputsOutputs() {
+ var _a;
+ let changed = false;
+ const hasEmptyInput = !((_a = this.inputs[this.inputs.length - 1]) === null || _a === void 0 ? void 0 : _a.link);
+ if (!hasEmptyInput) {
+ this.addInput("", "*");
+ changed = true;
+ }
+ for (let index = this.inputs.length - 2; index >= 0; index--) {
+ const input = this.inputs[index];
+ if (!input.link) {
+ this.removeInput(index);
+ changed = true;
+ }
+ else {
+ const node = getConnectedInputNodesAndFilterPassThroughs(this, this, index, this.inputsPassThroughFollowing)[0];
+ const newName = (node === null || node === void 0 ? void 0 : node.title) || "";
+ if (input.name !== newName) {
+ input.name = (node === null || node === void 0 ? void 0 : node.title) || "";
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+ doStablization() {
+ if (!this.graph) {
+ return;
+ }
+ let dirty = false;
+ this._tempWidth = this.size[0];
+ dirty = this.stabilizeInputsOutputs();
+ const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
+ dirty = this.handleLinkedNodesStabilization(linkedNodes) || dirty;
+ if (dirty) {
+ this.graph.setDirtyCanvas(true, true);
+ }
+ this.scheduleStabilizeWidgets(500);
+ }
+ handleLinkedNodesStabilization(linkedNodes) {
+ linkedNodes;
+ throw new Error("handleLinkedNodesStabilization should be overridden.");
+ }
+ onConnectionsChainChange() {
+ this.scheduleStabilizeWidgets();
+ }
+ onConnectionsChange(type, index, connected, linkInfo, ioSlot) {
+ super.onConnectionsChange &&
+ super.onConnectionsChange(type, index, connected, linkInfo, ioSlot);
+ if (!linkInfo)
+ return;
+ const connectedNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
+ for (const node of connectedNodes) {
+ if (node.onConnectionsChainChange) {
+ node.onConnectionsChainChange();
+ }
+ }
+ this.scheduleStabilizeWidgets();
+ }
+ removeInput(slot) {
+ this._tempWidth = this.size[0];
+ return super.removeInput(slot);
+ }
+ addInput(name, type, extra_info) {
+ this._tempWidth = this.size[0];
+ return super.addInput(name, type, extra_info);
+ }
+ addWidget(type, name, value, callback, options) {
+ this._tempWidth = this.size[0];
+ return super.addWidget(type, name, value, callback, options);
+ }
+ removeWidget(widget) {
+ this._tempWidth = this.size[0];
+ super.removeWidget(widget);
+ }
+ computeSize(out) {
+ var _a, _b;
+ let size = super.computeSize(out);
+ if (this._tempWidth) {
+ size[0] = this._tempWidth;
+ this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
+ this.debouncerTempWidth = setTimeout(() => {
+ this._tempWidth = null;
+ }, 32);
+ }
+ if (this.properties["collapse_connections"]) {
+ const rows = Math.max(((_a = this.inputs) === null || _a === void 0 ? void 0 : _a.length) || 0, ((_b = this.outputs) === null || _b === void 0 ? void 0 : _b.length) || 0, 1) - 1;
+ size[1] = size[1] - rows * LiteGraph.NODE_SLOT_HEIGHT;
+ }
+ setTimeout(() => {
+ var _a;
+ (_a = this.graph) === null || _a === void 0 ? void 0 : _a.setDirtyCanvas(true, true);
+ }, 16);
+ return size;
+ }
+ onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex) {
+ let canConnect = true;
+ if (super.onConnectOutput) {
+ canConnect = super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
+ }
+ if (canConnect) {
+ const nodes = getConnectedInputNodes(this);
+ if (nodes.includes(inputNode)) {
+ alert(`Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
+ `a situation that could create a time paradox, the results of which could cause a ` +
+ `chain reaction that would unravel the very fabric of the space time continuum, ` +
+ `and destroy the entire universe!`);
+ canConnect = false;
+ }
+ }
+ return canConnect;
+ }
+ onConnectInput(inputIndex, outputType, outputSlot, outputNode, outputIndex) {
+ let canConnect = true;
+ if (super.onConnectInput) {
+ canConnect = super.onConnectInput(inputIndex, outputType, outputSlot, outputNode, outputIndex);
+ }
+ if (canConnect) {
+ const nodes = getConnectedOutputNodes(this);
+ if (nodes.includes(outputNode)) {
+ alert(`Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
+ `a situation that could create a time paradox, the results of which could cause a ` +
+ `chain reaction that would unravel the very fabric of the space time continuum, ` +
+ `and destroy the entire universe!`);
+ canConnect = false;
+ }
+ }
+ return canConnect;
+ }
+ connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn) {
+ const lastInput = this.inputs[this.inputs.length - 1];
+ if (!(lastInput === null || lastInput === void 0 ? void 0 : lastInput.link) && (lastInput === null || lastInput === void 0 ? void 0 : lastInput.type) === "*") {
+ var sourceSlot = sourceNode.findOutputSlotByType(sourceSlotType, false, true);
+ return sourceNode.connect(sourceSlot, this, slot);
+ }
+ return super.connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn);
+ }
+ static setUp() {
+ super.setUp();
+ addConnectionLayoutSupport(this, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ addMenuItem(this, app, {
+ name: (node) => { var _a; return `${((_a = node.properties) === null || _a === void 0 ? void 0 : _a["collapse_connections"]) ? "Show" : "Collapse"} Connections`; },
+ property: "collapse_connections",
+ prepareValue: (_value, node) => { var _a; return !((_a = node.properties) === null || _a === void 0 ? void 0 : _a["collapse_connections"]); },
+ callback: (_node) => {
+ var _a;
+ (_a = app.canvas.getCurrentGraph()) === null || _a === void 0 ? void 0 : _a.setDirtyCanvas(true, true);
+ },
+ });
+ }
+}
+const oldLGraphNodeConnectByType = LGraphNode.prototype.connectByType;
+LGraphNode.prototype.connectByType = function connectByType(slot, targetNode, targetSlotType, optsIn) {
+ if (targetNode.inputs) {
+ for (const [index, input] of targetNode.inputs.entries()) {
+ if (!input.link && input.type === "*") {
+ this.connect(slot, targetNode, index);
+ return null;
+ }
+ }
+ }
+ return ((oldLGraphNodeConnectByType &&
+ oldLGraphNodeConnectByType.call(this, slot, targetNode, targetSlotType, optsIn)) ||
+ null);
+};
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/base_node.js b/custom_nodes/rgthree-comfy/web/comfyui/base_node.js
new file mode 100644
index 0000000000000000000000000000000000000000..a004ca9fa5138965b1ec544a18f0a78eab892a00
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/base_node.js
@@ -0,0 +1,316 @@
+import { app } from "../../scripts/app.js";
+import { ComfyWidgets } from "../../scripts/widgets.js";
+import { SERVICE as KEY_EVENT_SERVICE } from "./services/key_events_services.js";
+import { LogLevel, rgthree } from "./rgthree.js";
+import { addHelpMenuItem } from "./utils.js";
+import { RgthreeHelpDialog } from "../../rgthree/common/dialog.js";
+import { importIndividualNodesInnerOnDragDrop, importIndividualNodesInnerOnDragOver, } from "./feature_import_individual_nodes.js";
+import { defineProperty, moveArrayItem } from "../../rgthree/common/shared_utils.js";
+export class RgthreeBaseNode extends LGraphNode {
+ constructor(title = RgthreeBaseNode.title, skipOnConstructedCall = true) {
+ super(title);
+ this.comfyClass = "__NEED_COMFY_CLASS__";
+ this.nickname = "rgthree";
+ this.isVirtualNode = false;
+ this.isDropEnabled = false;
+ this.removed = false;
+ this.configuring = false;
+ this._tempWidth = 0;
+ this.__constructed__ = false;
+ this.helpDialog = null;
+ if (title == "__NEED_CLASS_TITLE__") {
+ throw new Error("RgthreeBaseNode needs overrides.");
+ }
+ this.widgets = this.widgets || [];
+ this.properties = this.properties || {};
+ setTimeout(() => {
+ if (this.comfyClass == "__NEED_COMFY_CLASS__") {
+ throw new Error("RgthreeBaseNode needs a comfy class override.");
+ }
+ if (this.constructor.type == "__NEED_CLASS_TYPE__") {
+ throw new Error("RgthreeBaseNode needs overrides.");
+ }
+ this.checkAndRunOnConstructed();
+ });
+ defineProperty(this, "mode", {
+ get: () => {
+ return this.rgthree_mode;
+ },
+ set: (mode) => {
+ if (this.rgthree_mode != mode) {
+ const oldMode = this.rgthree_mode;
+ this.rgthree_mode = mode;
+ this.onModeChange(oldMode, mode);
+ }
+ },
+ });
+ }
+ checkAndRunOnConstructed() {
+ var _a;
+ if (!this.__constructed__) {
+ this.onConstructed();
+ const [n, v] = rgthree.logger.logParts(LogLevel.DEV, `[RgthreeBaseNode] Child class did not call onConstructed for "${this.type}.`);
+ (_a = console[n]) === null || _a === void 0 ? void 0 : _a.call(console, ...v);
+ }
+ return this.__constructed__;
+ }
+ onDragOver(e) {
+ if (!this.isDropEnabled)
+ return false;
+ return importIndividualNodesInnerOnDragOver(this, e);
+ }
+ async onDragDrop(e) {
+ if (!this.isDropEnabled)
+ return false;
+ return importIndividualNodesInnerOnDragDrop(this, e);
+ }
+ onConstructed() {
+ var _a;
+ if (this.__constructed__)
+ return false;
+ this.type = (_a = this.type) !== null && _a !== void 0 ? _a : undefined;
+ this.__constructed__ = true;
+ rgthree.invokeExtensionsAsync("nodeCreated", this);
+ return this.__constructed__;
+ }
+ configure(info) {
+ this.configuring = true;
+ super.configure(info);
+ for (const w of this.widgets || []) {
+ w.last_y = w.last_y || 0;
+ }
+ this.configuring = false;
+ }
+ clone() {
+ const cloned = super.clone();
+ if ((cloned === null || cloned === void 0 ? void 0 : cloned.properties) && !!window.structuredClone) {
+ cloned.properties = structuredClone(cloned.properties);
+ }
+ cloned.graph = this.graph;
+ return cloned;
+ }
+ onModeChange(from, to) {
+ }
+ async handleAction(action) {
+ action;
+ }
+ removeWidget(widget) {
+ var _a;
+ if (typeof widget === "number") {
+ widget = this.widgets[widget];
+ }
+ if (!widget)
+ return;
+ const canUseComfyUIRemoveWidget = false;
+ if (canUseComfyUIRemoveWidget && typeof super.removeWidget === 'function') {
+ super.removeWidget(widget);
+ }
+ else {
+ const index = this.widgets.indexOf(widget);
+ if (index > -1) {
+ this.widgets.splice(index, 1);
+ }
+ (_a = widget.onRemove) === null || _a === void 0 ? void 0 : _a.call(widget);
+ }
+ }
+ replaceWidget(widgetOrSlot, newWidget) {
+ let index = null;
+ if (widgetOrSlot) {
+ index = typeof widgetOrSlot === "number" ? widgetOrSlot : this.widgets.indexOf(widgetOrSlot);
+ this.removeWidget(this.widgets[index]);
+ }
+ index = index != null ? index : this.widgets.length - 1;
+ if (this.widgets.includes(newWidget)) {
+ moveArrayItem(this.widgets, newWidget, index);
+ }
+ else {
+ this.widgets.splice(index, 0, newWidget);
+ }
+ }
+ defaultGetSlotMenuOptions(slot) {
+ var _a, _b;
+ const menu_info = [];
+ if ((_b = (_a = slot === null || slot === void 0 ? void 0 : slot.output) === null || _a === void 0 ? void 0 : _a.links) === null || _b === void 0 ? void 0 : _b.length) {
+ menu_info.push({ content: "Disconnect Links", slot });
+ }
+ let inputOrOutput = slot.input || slot.output;
+ if (inputOrOutput) {
+ if (inputOrOutput.removable) {
+ menu_info.push(inputOrOutput.locked ? { content: "Cannot remove" } : { content: "Remove Slot", slot });
+ }
+ if (!inputOrOutput.nameLocked) {
+ menu_info.push({ content: "Rename Slot", slot });
+ }
+ }
+ return menu_info;
+ }
+ onRemoved() {
+ var _a;
+ (_a = super.onRemoved) === null || _a === void 0 ? void 0 : _a.call(this);
+ this.removed = true;
+ }
+ static setUp(...args) {
+ }
+ getHelp() {
+ return "";
+ }
+ showHelp() {
+ const help = this.getHelp() || this.constructor.help;
+ if (help) {
+ this.helpDialog = new RgthreeHelpDialog(this, help).show();
+ this.helpDialog.addEventListener("close", (e) => {
+ this.helpDialog = null;
+ });
+ }
+ }
+ onKeyDown(event) {
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
+ if (event.key == "?" && !this.helpDialog) {
+ this.showHelp();
+ }
+ }
+ onKeyUp(event) {
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
+ }
+ getExtraMenuOptions(canvas, options) {
+ var _a, _b, _c, _d, _e, _f;
+ if (super.getExtraMenuOptions) {
+ (_a = super.getExtraMenuOptions) === null || _a === void 0 ? void 0 : _a.apply(this, [canvas, options]);
+ }
+ else if ((_c = (_b = this.constructor.nodeType) === null || _b === void 0 ? void 0 : _b.prototype) === null || _c === void 0 ? void 0 : _c.getExtraMenuOptions) {
+ (_f = (_e = (_d = this.constructor.nodeType) === null || _d === void 0 ? void 0 : _d.prototype) === null || _e === void 0 ? void 0 : _e.getExtraMenuOptions) === null || _f === void 0 ? void 0 : _f.apply(this, [canvas, options]);
+ }
+ const help = this.getHelp() || this.constructor.help;
+ if (help) {
+ addHelpMenuItem(this, help, options);
+ }
+ return [];
+ }
+}
+RgthreeBaseNode.exposedActions = [];
+RgthreeBaseNode.title = "__NEED_CLASS_TITLE__";
+RgthreeBaseNode.type = "__NEED_CLASS_TYPE__";
+RgthreeBaseNode.category = "rgthree";
+RgthreeBaseNode._category = "rgthree";
+export class RgthreeBaseVirtualNode extends RgthreeBaseNode {
+ constructor(title = RgthreeBaseNode.title) {
+ super(title, false);
+ this.isVirtualNode = true;
+ }
+ static setUp() {
+ if (!this.type) {
+ throw new Error(`Missing type for RgthreeBaseVirtualNode: ${this.title}`);
+ }
+ LiteGraph.registerNodeType(this.type, this);
+ if (this._category) {
+ this.category = this._category;
+ }
+ }
+}
+export class RgthreeBaseServerNode extends RgthreeBaseNode {
+ constructor(title) {
+ super(title, true);
+ this.isDropEnabled = true;
+ this.serialize_widgets = true;
+ this.setupFromServerNodeData();
+ this.onConstructed();
+ }
+ getWidgets() {
+ return ComfyWidgets;
+ }
+ async setupFromServerNodeData() {
+ var _a, _b, _c, _d, _e;
+ const nodeData = this.constructor.nodeData;
+ if (!nodeData) {
+ throw Error("No node data");
+ }
+ this.comfyClass = nodeData.name;
+ let inputs = nodeData["input"]["required"];
+ if (nodeData["input"]["optional"] != undefined) {
+ inputs = Object.assign({}, inputs, nodeData["input"]["optional"]);
+ }
+ const WIDGETS = this.getWidgets();
+ const config = {
+ minWidth: 1,
+ minHeight: 1,
+ widget: null,
+ };
+ for (const inputName in inputs) {
+ const inputData = inputs[inputName];
+ const type = inputData[0];
+ if ((_a = inputData[1]) === null || _a === void 0 ? void 0 : _a.forceInput) {
+ this.addInput(inputName, type);
+ }
+ else {
+ let widgetCreated = true;
+ if (Array.isArray(type)) {
+ Object.assign(config, WIDGETS.COMBO(this, inputName, inputData, app) || {});
+ }
+ else if (`${type}:${inputName}` in WIDGETS) {
+ Object.assign(config, WIDGETS[`${type}:${inputName}`](this, inputName, inputData, app) || {});
+ }
+ else if (type in WIDGETS) {
+ Object.assign(config, WIDGETS[type](this, inputName, inputData, app) || {});
+ }
+ else {
+ this.addInput(inputName, type);
+ widgetCreated = false;
+ }
+ if (widgetCreated && ((_b = inputData[1]) === null || _b === void 0 ? void 0 : _b.forceInput) && (config === null || config === void 0 ? void 0 : config.widget)) {
+ if (!config.widget.options)
+ config.widget.options = {};
+ config.widget.options.forceInput = inputData[1].forceInput;
+ }
+ if (widgetCreated && ((_c = inputData[1]) === null || _c === void 0 ? void 0 : _c.defaultInput) && (config === null || config === void 0 ? void 0 : config.widget)) {
+ if (!config.widget.options)
+ config.widget.options = {};
+ config.widget.options.defaultInput = inputData[1].defaultInput;
+ }
+ }
+ }
+ for (const o in nodeData["output"]) {
+ let output = nodeData["output"][o];
+ if (output instanceof Array)
+ output = "COMBO";
+ const outputName = nodeData["output_name"][o] || output;
+ const outputShape = nodeData["output_is_list"][o]
+ ? LiteGraph.GRID_SHAPE
+ : LiteGraph.CIRCLE_SHAPE;
+ this.addOutput(outputName, output, { shape: outputShape });
+ }
+ const s = this.computeSize();
+ s[0] = Math.max((_d = config.minWidth) !== null && _d !== void 0 ? _d : 1, s[0] * 1.5);
+ s[1] = Math.max((_e = config.minHeight) !== null && _e !== void 0 ? _e : 1, s[1]);
+ this.size = s;
+ this.serialize_widgets = true;
+ }
+ static registerForOverride(comfyClass, nodeData, rgthreeClass) {
+ if (OVERRIDDEN_SERVER_NODES.has(comfyClass)) {
+ throw Error(`Already have a class to override ${comfyClass.type || comfyClass.name || comfyClass.title}`);
+ }
+ OVERRIDDEN_SERVER_NODES.set(comfyClass, rgthreeClass);
+ if (!rgthreeClass.__registeredForOverride__) {
+ rgthreeClass.__registeredForOverride__ = true;
+ rgthreeClass.nodeType = comfyClass;
+ rgthreeClass.nodeData = nodeData;
+ rgthreeClass.onRegisteredForOverride(comfyClass, rgthreeClass);
+ }
+ }
+ static onRegisteredForOverride(comfyClass, rgthreeClass) {
+ }
+}
+RgthreeBaseServerNode.nodeType = null;
+RgthreeBaseServerNode.nodeData = null;
+RgthreeBaseServerNode.__registeredForOverride__ = false;
+const OVERRIDDEN_SERVER_NODES = new Map();
+const oldregisterNodeType = LiteGraph.registerNodeType;
+LiteGraph.registerNodeType = async function (nodeId, baseClass) {
+ var _a;
+ const clazz = OVERRIDDEN_SERVER_NODES.get(baseClass) || baseClass;
+ if (clazz !== baseClass) {
+ const classLabel = clazz.type || clazz.name || clazz.title;
+ const [n, v] = rgthree.logger.logParts(LogLevel.DEBUG, `${nodeId}: replacing default ComfyNode implementation with custom ${classLabel} class.`);
+ (_a = console[n]) === null || _a === void 0 ? void 0 : _a.call(console, ...v);
+ }
+ return oldregisterNodeType.call(LiteGraph, nodeId, clazz);
+};
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/base_node_collector.js b/custom_nodes/rgthree-comfy/web/comfyui/base_node_collector.js
new file mode 100644
index 0000000000000000000000000000000000000000..3dd924f744bda3fba05c090e272ea74873e8fc16
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/base_node_collector.js
@@ -0,0 +1,51 @@
+import { rgthree } from "./rgthree.js";
+import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js";
+import { PassThroughFollowing, getConnectedInputNodes, getConnectedInputNodesAndFilterPassThroughs, shouldPassThrough, } from "./utils.js";
+export class BaseCollectorNode extends BaseAnyInputConnectedNode {
+ constructor(title) {
+ super(title);
+ this.inputsPassThroughFollowing = PassThroughFollowing.REROUTE_ONLY;
+ this.logger = rgthree.newLogSession("[BaseCollectorNode]");
+ }
+ clone() {
+ const cloned = super.clone();
+ return cloned;
+ }
+ handleLinkedNodesStabilization(linkedNodes) {
+ return false;
+ }
+ onConnectInput(inputIndex, outputType, outputSlot, outputNode, outputIndex) {
+ var _a, _b, _c, _d;
+ let canConnect = super.onConnectInput(inputIndex, outputType, outputSlot, outputNode, outputIndex);
+ if (canConnect) {
+ const allConnectedNodes = getConnectedInputNodes(this);
+ const nodesAlreadyInSlot = getConnectedInputNodes(this, undefined, inputIndex);
+ if (allConnectedNodes.includes(outputNode)) {
+ const [n, v] = this.logger.debugParts(`${outputNode.title} is already connected to ${this.title}.`);
+ (_a = console[n]) === null || _a === void 0 ? void 0 : _a.call(console, ...v);
+ if (nodesAlreadyInSlot.includes(outputNode)) {
+ const [n, v] = this.logger.debugParts(`... but letting it slide since it's for the same slot.`);
+ (_b = console[n]) === null || _b === void 0 ? void 0 : _b.call(console, ...v);
+ }
+ else {
+ canConnect = false;
+ }
+ }
+ if (canConnect && shouldPassThrough(outputNode, PassThroughFollowing.REROUTE_ONLY)) {
+ const connectedNode = getConnectedInputNodesAndFilterPassThroughs(outputNode, undefined, undefined, PassThroughFollowing.REROUTE_ONLY)[0];
+ if (connectedNode && allConnectedNodes.includes(connectedNode)) {
+ const [n, v] = this.logger.debugParts(`${connectedNode.title} is already connected to ${this.title}.`);
+ (_c = console[n]) === null || _c === void 0 ? void 0 : _c.call(console, ...v);
+ if (nodesAlreadyInSlot.includes(connectedNode)) {
+ const [n, v] = this.logger.debugParts(`... but letting it slide since it's for the same slot.`);
+ (_d = console[n]) === null || _d === void 0 ? void 0 : _d.call(console, ...v);
+ }
+ else {
+ canConnect = false;
+ }
+ }
+ }
+ }
+ return canConnect;
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/base_node_mode_changer.js b/custom_nodes/rgthree-comfy/web/comfyui/base_node_mode_changer.js
new file mode 100644
index 0000000000000000000000000000000000000000..8d2b20783025517227b9acab8076a113b838d36d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/base_node_mode_changer.js
@@ -0,0 +1,93 @@
+import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js";
+import { changeModeOfNodes, PassThroughFollowing } from "./utils.js";
+import { wait } from "../../rgthree/common/shared_utils.js";
+export class BaseNodeModeChanger extends BaseAnyInputConnectedNode {
+ constructor(title) {
+ super(title);
+ this.inputsPassThroughFollowing = PassThroughFollowing.ALL;
+ this.isVirtualNode = true;
+ this.modeOn = -1;
+ this.modeOff = -1;
+ this.properties["toggleRestriction"] = "default";
+ }
+ onConstructed() {
+ wait(10).then(() => {
+ if (this.modeOn < 0 || this.modeOff < 0) {
+ throw new Error("modeOn and modeOff must be overridden.");
+ }
+ });
+ this.addOutput("OPT_CONNECTION", "*");
+ return super.onConstructed();
+ }
+ handleLinkedNodesStabilization(linkedNodes) {
+ let changed = false;
+ for (const [index, node] of linkedNodes.entries()) {
+ let widget = this.widgets && this.widgets[index];
+ if (!widget) {
+ this._tempWidth = this.size[0];
+ widget = this.addWidget("toggle", "", false, "", { on: "yes", off: "no" });
+ changed = true;
+ }
+ if (node) {
+ changed = this.setWidget(widget, node) || changed;
+ }
+ }
+ if (this.widgets && this.widgets.length > linkedNodes.length) {
+ this.widgets.length = linkedNodes.length;
+ changed = true;
+ }
+ return changed;
+ }
+ setWidget(widget, linkedNode, forceValue) {
+ let changed = false;
+ const value = forceValue == null ? linkedNode.mode === this.modeOn : forceValue;
+ let name = `Enable ${linkedNode.title}`;
+ if (widget.name !== name) {
+ widget.name = `Enable ${linkedNode.title}`;
+ widget.options = { on: "yes", off: "no" };
+ widget.value = value;
+ widget.doModeChange = (forceValue, skipOtherNodeCheck) => {
+ var _a, _b, _c;
+ let newValue = forceValue == null ? linkedNode.mode === this.modeOff : forceValue;
+ if (skipOtherNodeCheck !== true) {
+ if (newValue && ((_b = (_a = this.properties) === null || _a === void 0 ? void 0 : _a["toggleRestriction"]) === null || _b === void 0 ? void 0 : _b.includes(" one"))) {
+ for (const widget of this.widgets) {
+ widget.doModeChange(false, true);
+ }
+ }
+ else if (!newValue && ((_c = this.properties) === null || _c === void 0 ? void 0 : _c["toggleRestriction"]) === "always one") {
+ newValue = this.widgets.every((w) => !w.value || w === widget);
+ }
+ }
+ changeModeOfNodes(linkedNode, (newValue ? this.modeOn : this.modeOff));
+ widget.value = newValue;
+ };
+ widget.callback = () => {
+ widget.doModeChange();
+ };
+ changed = true;
+ }
+ if (forceValue != null) {
+ const newMode = (forceValue ? this.modeOn : this.modeOff);
+ if (linkedNode.mode !== newMode) {
+ changeModeOfNodes(linkedNode, newMode);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+ forceWidgetOff(widget, skipOtherNodeCheck) {
+ widget.doModeChange(false, skipOtherNodeCheck);
+ }
+ forceWidgetOn(widget, skipOtherNodeCheck) {
+ widget.doModeChange(true, skipOtherNodeCheck);
+ }
+ forceWidgetToggle(widget, skipOtherNodeCheck) {
+ widget.doModeChange(!widget.value, skipOtherNodeCheck);
+ }
+}
+BaseNodeModeChanger.collapsible = false;
+BaseNodeModeChanger["@toggleRestriction"] = {
+ type: "combo",
+ values: ["default", "max one", "always one"],
+};
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/base_power_prompt.js b/custom_nodes/rgthree-comfy/web/comfyui/base_power_prompt.js
new file mode 100644
index 0000000000000000000000000000000000000000..c682f237fafb1d78113e1563c73309cdb42bc84d
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/base_power_prompt.js
@@ -0,0 +1,252 @@
+import { api } from "../../scripts/api.js";
+import { wait } from "../../rgthree/common/shared_utils.js";
+import { rgthree } from "./rgthree.js";
+export class PowerPrompt {
+ constructor(node, nodeData) {
+ this.combos = {};
+ this.combosValues = {};
+ this.configuring = false;
+ this.node = node;
+ this.node.properties = this.node.properties || {};
+ this.node.properties["combos_filter"] = "";
+ this.nodeData = nodeData;
+ this.isSimple = this.nodeData.name.includes("Simple");
+ this.promptEl = node.widgets[0].inputEl;
+ this.addAndHandleKeyboardLoraEditWeight();
+ this.patchNodeRefresh();
+ const oldConfigure = this.node.configure;
+ this.node.configure = (info) => {
+ this.configuring = true;
+ oldConfigure === null || oldConfigure === void 0 ? void 0 : oldConfigure.apply(this.node, [info]);
+ this.configuring = false;
+ };
+ const oldOnConnectionsChange = this.node.onConnectionsChange;
+ this.node.onConnectionsChange = (type, slotIndex, isConnected, link_info, _ioSlot) => {
+ oldOnConnectionsChange === null || oldOnConnectionsChange === void 0 ? void 0 : oldOnConnectionsChange.apply(this.node, [type, slotIndex, isConnected, link_info, _ioSlot]);
+ this.onNodeConnectionsChange(type, slotIndex, isConnected, link_info, _ioSlot);
+ };
+ const oldOnConnectInput = this.node.onConnectInput;
+ this.node.onConnectInput = (inputIndex, outputType, outputSlot, outputNode, outputIndex) => {
+ let canConnect = true;
+ if (oldOnConnectInput) {
+ canConnect = oldOnConnectInput.apply(this.node, [
+ inputIndex,
+ outputType,
+ outputSlot,
+ outputNode,
+ outputIndex,
+ ]);
+ }
+ return (this.configuring ||
+ !!rgthree.loadingApiJson ||
+ (canConnect && !this.node.inputs[inputIndex].disabled));
+ };
+ const oldOnConnectOutput = this.node.onConnectOutput;
+ this.node.onConnectOutput = (outputIndex, inputType, inputSlot, inputNode, inputIndex) => {
+ let canConnect = true;
+ if (oldOnConnectOutput) {
+ canConnect = oldOnConnectOutput === null || oldOnConnectOutput === void 0 ? void 0 : oldOnConnectOutput.apply(this.node, [
+ outputIndex,
+ inputType,
+ inputSlot,
+ inputNode,
+ inputIndex,
+ ]);
+ }
+ return (this.configuring ||
+ !!rgthree.loadingApiJson ||
+ (canConnect && !this.node.outputs[outputIndex].disabled));
+ };
+ const onPropertyChanged = this.node.onPropertyChanged;
+ this.node.onPropertyChanged = (property, value, prevValue) => {
+ const v = onPropertyChanged && onPropertyChanged.call(this.node, property, value, prevValue);
+ if (property === "combos_filter") {
+ this.refreshCombos(this.nodeData);
+ }
+ return v !== null && v !== void 0 ? v : true;
+ };
+ for (let i = this.node.widgets.length - 1; i >= 0; i--) {
+ if (this.shouldRemoveServerWidget(this.node.widgets[i])) {
+ this.node.widgets.splice(i, 1);
+ }
+ }
+ this.refreshCombos(nodeData);
+ setTimeout(() => {
+ this.stabilizeInputsOutputs();
+ }, 32);
+ }
+ onNodeConnectionsChange(_type, _slotIndex, _isConnected, _linkInfo, _ioSlot) {
+ this.stabilizeInputsOutputs();
+ }
+ stabilizeInputsOutputs() {
+ if (this.configuring || rgthree.loadingApiJson) {
+ return;
+ }
+ const clipLinked = this.node.inputs.some((i) => i.name.includes("clip") && !!i.link);
+ const modelLinked = this.node.inputs.some((i) => i.name.includes("model") && !!i.link);
+ for (const output of this.node.outputs) {
+ const type = output.type.toLowerCase();
+ if (type.includes("model")) {
+ output.disabled = !modelLinked;
+ }
+ else if (type.includes("conditioning")) {
+ output.disabled = !clipLinked;
+ }
+ else if (type.includes("clip")) {
+ output.disabled = !clipLinked;
+ }
+ else if (type.includes("string")) {
+ output.color_off = "#7F7";
+ output.color_on = "#7F7";
+ }
+ if (output.disabled) {
+ }
+ }
+ }
+ onFreshNodeDefs(event) {
+ this.refreshCombos(event.detail[this.nodeData.name]);
+ }
+ shouldRemoveServerWidget(widget) {
+ var _a, _b, _c, _d;
+ return (((_a = widget.name) === null || _a === void 0 ? void 0 : _a.startsWith("insert_")) ||
+ ((_b = widget.name) === null || _b === void 0 ? void 0 : _b.startsWith("target_")) ||
+ ((_c = widget.name) === null || _c === void 0 ? void 0 : _c.startsWith("crop_")) ||
+ ((_d = widget.name) === null || _d === void 0 ? void 0 : _d.startsWith("values_")));
+ }
+ refreshCombos(nodeData) {
+ var _a, _b, _c;
+ this.nodeData = nodeData;
+ let filter = null;
+ if ((_a = this.node.properties["combos_filter"]) === null || _a === void 0 ? void 0 : _a.trim()) {
+ try {
+ filter = new RegExp(this.node.properties["combos_filter"].trim(), "i");
+ }
+ catch (e) {
+ console.error(`Could not parse "${filter}" for Regular Expression`, e);
+ filter = null;
+ }
+ }
+ let data = Object.assign({}, ((_b = this.nodeData.input) === null || _b === void 0 ? void 0 : _b.optional) || {}, ((_c = this.nodeData.input) === null || _c === void 0 ? void 0 : _c.hidden) || {});
+ for (const [key, value] of Object.entries(data)) {
+ if (Array.isArray(value[0])) {
+ let values = value[0];
+ if (key.startsWith("insert")) {
+ values = filter
+ ? values.filter((v, i) => i < 1 || (i == 1 && v.match(/^disable\s[a-z]/i)) || (filter === null || filter === void 0 ? void 0 : filter.test(v)))
+ : values;
+ const shouldShow = values.length > 2 || (values.length > 1 && !values[1].match(/^disable\s[a-z]/i));
+ if (shouldShow) {
+ if (!this.combos[key]) {
+ this.combos[key] = this.node.addWidget("combo", key, values[0], (selected) => {
+ if (selected !== values[0] && !selected.match(/^disable\s[a-z]/i)) {
+ wait().then(() => {
+ if (key.includes("embedding")) {
+ this.insertSelectionText(`embedding:${selected}`);
+ }
+ else if (key.includes("saved")) {
+ this.insertSelectionText(this.combosValues[`values_${key}`][values.indexOf(selected)]);
+ }
+ else if (key.includes("lora")) {
+ this.insertSelectionText(``);
+ }
+ this.combos[key].value = values[0];
+ });
+ }
+ }, {
+ values,
+ serialize: true,
+ });
+ this.combos[key].oldComputeSize = this.combos[key].computeSize;
+ let node = this.node;
+ this.combos[key].computeSize = function (width) {
+ var _a, _b;
+ const size = ((_b = (_a = this).oldComputeSize) === null || _b === void 0 ? void 0 : _b.call(_a, width)) || [
+ width,
+ LiteGraph.NODE_WIDGET_HEIGHT,
+ ];
+ if (this === node.widgets[node.widgets.length - 1]) {
+ size[1] += 10;
+ }
+ return size;
+ };
+ }
+ this.combos[key].options.values = values;
+ this.combos[key].value = values[0];
+ }
+ else if (!shouldShow && this.combos[key]) {
+ this.node.widgets.splice(this.node.widgets.indexOf(this.combos[key]), 1);
+ delete this.combos[key];
+ }
+ }
+ else if (key.startsWith("values")) {
+ this.combosValues[key] = values;
+ }
+ }
+ }
+ }
+ insertSelectionText(text) {
+ if (!this.promptEl) {
+ console.error("Asked to insert text, but no textbox found.");
+ return;
+ }
+ let prompt = this.promptEl.value;
+ let first = prompt.substring(0, this.promptEl.selectionEnd).replace(/ +$/, "");
+ first = first + (["\n"].includes(first[first.length - 1]) ? "" : first.length ? " " : "");
+ let second = prompt.substring(this.promptEl.selectionEnd).replace(/^ +/, "");
+ second = (["\n"].includes(second[0]) ? "" : second.length ? " " : "") + second;
+ this.promptEl.value = first + text + second;
+ this.promptEl.focus();
+ this.promptEl.selectionStart = first.length;
+ this.promptEl.selectionEnd = first.length + text.length;
+ }
+ addAndHandleKeyboardLoraEditWeight() {
+ this.promptEl.addEventListener("keydown", (event) => {
+ var _a, _b;
+ if (!(event.key === "ArrowUp" || event.key === "ArrowDown"))
+ return;
+ if (!event.ctrlKey && !event.metaKey)
+ return;
+ const delta = event.shiftKey ? 0.01 : 0.1;
+ let start = this.promptEl.selectionStart;
+ let end = this.promptEl.selectionEnd;
+ let fullText = this.promptEl.value;
+ let selectedText = fullText.substring(start, end);
+ if (!selectedText) {
+ const stopOn = "<>()\r\n\t";
+ if (fullText[start] == ">") {
+ start -= 2;
+ end -= 2;
+ }
+ if (fullText[end - 1] == "<") {
+ start += 2;
+ end += 2;
+ }
+ while (!stopOn.includes(fullText[start]) && start > 0) {
+ start--;
+ }
+ while (!stopOn.includes(fullText[end - 1]) && end < fullText.length) {
+ end++;
+ }
+ selectedText = fullText.substring(start, end);
+ }
+ if (!selectedText.startsWith("")) {
+ return;
+ }
+ let weight = (_b = Number((_a = selectedText.match(/:(-?\d*(\.\d*)?)>$/)) === null || _a === void 0 ? void 0 : _a[1])) !== null && _b !== void 0 ? _b : 1;
+ weight += event.key === "ArrowUp" ? delta : -delta;
+ const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`);
+ this.promptEl.setRangeText(updatedText, start, end, "select");
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ }
+ patchNodeRefresh() {
+ this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this);
+ api.addEventListener("fresh-node-defs", this.boundOnFreshNodeDefs);
+ const oldNodeRemoved = this.node.onRemoved;
+ this.node.onRemoved = () => {
+ oldNodeRemoved === null || oldNodeRemoved === void 0 ? void 0 : oldNodeRemoved.call(this.node);
+ api.removeEventListener("fresh-node-defs", this.boundOnFreshNodeDefs);
+ };
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/bookmark.js b/custom_nodes/rgthree-comfy/web/comfyui/bookmark.js
new file mode 100644
index 0000000000000000000000000000000000000000..c48877185179124eff4200ea6344cdd32603d995
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/bookmark.js
@@ -0,0 +1,105 @@
+import { app } from "../../scripts/app.js";
+import { RgthreeBaseVirtualNode } from "./base_node.js";
+import { SERVICE as KEY_EVENT_SERVICE } from "./services/key_events_services.js";
+import { SERVICE as BOOKMARKS_SERVICE } from "./services/bookmarks_services.js";
+import { NodeTypesString } from "./constants.js";
+import { getClosestOrSelf, query } from "../../rgthree/common/utils_dom.js";
+import { wait } from "../../rgthree/common/shared_utils.js";
+import { findFromNodeForSubgraph } from "./utils.js";
+export class Bookmark extends RgthreeBaseVirtualNode {
+ get _collapsed_width() {
+ return this.___collapsed_width;
+ }
+ set _collapsed_width(width) {
+ const canvas = app.canvas;
+ const ctx = canvas.canvas.getContext("2d");
+ const oldFont = ctx.font;
+ ctx.font = canvas.title_text_font;
+ this.___collapsed_width = 40 + ctx.measureText(this.title).width;
+ ctx.font = oldFont;
+ }
+ constructor(title = Bookmark.title) {
+ super(title);
+ this.comfyClass = NodeTypesString.BOOKMARK;
+ this.___collapsed_width = 0;
+ this.isVirtualNode = true;
+ this.serialize_widgets = true;
+ const nextShortcutChar = BOOKMARKS_SERVICE.getNextShortcut();
+ this.addWidget("text", "shortcut_key", nextShortcutChar, (value, ...args) => {
+ value = value.trim()[0] || "1";
+ }, {
+ y: 8,
+ });
+ this.addWidget("number", "zoom", 1, (value) => { }, {
+ y: 8 + LiteGraph.NODE_WIDGET_HEIGHT + 4,
+ max: 2,
+ min: 0.5,
+ precision: 2,
+ });
+ this.keypressBound = this.onKeypress.bind(this);
+ this.title = "🔖";
+ this.onConstructed();
+ }
+ get shortcutKey() {
+ var _a, _b, _c;
+ return (_c = (_b = (_a = this.widgets[0]) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.toLocaleLowerCase()) !== null && _c !== void 0 ? _c : "";
+ }
+ onAdded(graph) {
+ KEY_EVENT_SERVICE.addEventListener("keydown", this.keypressBound);
+ }
+ onRemoved() {
+ KEY_EVENT_SERVICE.removeEventListener("keydown", this.keypressBound);
+ }
+ onKeypress(event) {
+ const originalEvent = event.detail.originalEvent;
+ const target = originalEvent.target;
+ if (getClosestOrSelf(target, 'input,textarea,[contenteditable="true"]')) {
+ return;
+ }
+ if (KEY_EVENT_SERVICE.areOnlyKeysDown(this.widgets[0].value, true)) {
+ this.canvasToBookmark();
+ originalEvent.preventDefault();
+ originalEvent.stopPropagation();
+ }
+ }
+ onMouseDown(event, pos, graphCanvas) {
+ var _a;
+ const input = query(".graphdialog > input.value");
+ if (input && input.value === ((_a = this.widgets[0]) === null || _a === void 0 ? void 0 : _a.value)) {
+ input.addEventListener("keydown", (e) => {
+ KEY_EVENT_SERVICE.handleKeyDownOrUp(e);
+ e.preventDefault();
+ e.stopPropagation();
+ input.value = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" + ");
+ });
+ }
+ return false;
+ }
+ async canvasToBookmark() {
+ var _a, _b;
+ const canvas = app.canvas;
+ if (this.graph !== app.canvas.getCurrentGraph()) {
+ const subgraph = this.graph;
+ const fromNode = findFromNodeForSubgraph(subgraph.id);
+ canvas.openSubgraph(subgraph, fromNode);
+ await wait(16);
+ }
+ if ((_a = canvas === null || canvas === void 0 ? void 0 : canvas.ds) === null || _a === void 0 ? void 0 : _a.offset) {
+ canvas.ds.offset[0] = -this.pos[0] + 16;
+ canvas.ds.offset[1] = -this.pos[1] + 40;
+ }
+ if (((_b = canvas === null || canvas === void 0 ? void 0 : canvas.ds) === null || _b === void 0 ? void 0 : _b.scale) != null) {
+ canvas.ds.scale = Number(this.widgets[1].value || 1);
+ }
+ canvas.setDirty(true, true);
+ }
+}
+Bookmark.type = NodeTypesString.BOOKMARK;
+Bookmark.title = NodeTypesString.BOOKMARK;
+Bookmark.slot_start_y = -20;
+app.registerExtension({
+ name: "rgthree.Bookmark",
+ registerCustomNodes() {
+ Bookmark.setUp();
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/bypasser.js b/custom_nodes/rgthree-comfy/web/comfyui/bypasser.js
new file mode 100644
index 0000000000000000000000000000000000000000..96e47e21d6b9f5e20d52c81d2105dc3c770a6ec6
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/bypasser.js
@@ -0,0 +1,45 @@
+import { app } from "../../scripts/app.js";
+import { BaseNodeModeChanger } from "./base_node_mode_changer.js";
+import { NodeTypesString } from "./constants.js";
+const MODE_BYPASS = 4;
+const MODE_ALWAYS = 0;
+class BypasserNode extends BaseNodeModeChanger {
+ constructor(title = BypasserNode.title) {
+ super(title);
+ this.comfyClass = NodeTypesString.FAST_BYPASSER;
+ this.modeOn = MODE_ALWAYS;
+ this.modeOff = MODE_BYPASS;
+ this.onConstructed();
+ }
+ async handleAction(action) {
+ if (action === "Bypass all") {
+ for (const widget of this.widgets || []) {
+ this.forceWidgetOff(widget, true);
+ }
+ }
+ else if (action === "Enable all") {
+ for (const widget of this.widgets || []) {
+ this.forceWidgetOn(widget, true);
+ }
+ }
+ else if (action === "Toggle all") {
+ for (const widget of this.widgets || []) {
+ this.forceWidgetToggle(widget, true);
+ }
+ }
+ }
+}
+BypasserNode.exposedActions = ["Bypass all", "Enable all", "Toggle all"];
+BypasserNode.type = NodeTypesString.FAST_BYPASSER;
+BypasserNode.title = NodeTypesString.FAST_BYPASSER;
+app.registerExtension({
+ name: "rgthree.Bypasser",
+ registerCustomNodes() {
+ BypasserNode.setUp();
+ },
+ loadedGraphNode(node) {
+ if (node.type == BypasserNode.title) {
+ node._tempWidth = node.size[0];
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/comfy_ui_bar.js b/custom_nodes/rgthree-comfy/web/comfyui/comfy_ui_bar.js
new file mode 100644
index 0000000000000000000000000000000000000000..a95fb0ff22b968a0df603a75d6645f8bc6a01684
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/comfy_ui_bar.js
@@ -0,0 +1,197 @@
+import { app } from "../../scripts/app.js";
+import { iconGear, iconStarFilled, logoRgthreeAsync } from "../../rgthree/common/media/svgs.js";
+import { $el, empty } from "../../rgthree/common/utils_dom.js";
+import { SERVICE as BOOKMARKS_SERVICE } from "./services/bookmarks_services.js";
+import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
+import { RgthreeConfigDialog } from "./config.js";
+import { wait } from "../../rgthree/common/shared_utils.js";
+let rgthreeButtonGroup = null;
+function addRgthreeTopBarButtons() {
+ var _a, _b, _c;
+ if (!CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.enabled")) {
+ if ((_a = rgthreeButtonGroup === null || rgthreeButtonGroup === void 0 ? void 0 : rgthreeButtonGroup.element) === null || _a === void 0 ? void 0 : _a.parentElement) {
+ rgthreeButtonGroup.element.parentElement.removeChild(rgthreeButtonGroup.element);
+ }
+ return;
+ }
+ else if (rgthreeButtonGroup) {
+ (_b = app.menu) === null || _b === void 0 ? void 0 : _b.settingsGroup.element.before(rgthreeButtonGroup.element);
+ return;
+ }
+ const buttons = [];
+ const rgthreeButton = new RgthreeComfyButton({
+ icon: " ",
+ tooltip: "rgthree-comfy",
+ primary: true,
+ enabled: true,
+ classList: "comfyui-button comfyui-menu-mobile-collapse primary",
+ });
+ buttons.push(rgthreeButton);
+ logoRgthreeAsync().then((t) => {
+ rgthreeButton.setIcon(t);
+ });
+ rgthreeButton.withPopup(new RgthreeComfyPopup({ target: rgthreeButton.element }, $el("menu.rgthree-menu.rgthree-top-menu", {
+ children: [
+ $el("li", {
+ child: $el("button.rgthree-button-reset", {
+ html: iconGear + "Settings (rgthree-comfy)",
+ onclick: () => new RgthreeConfigDialog().show(),
+ }),
+ }),
+ $el("li", {
+ child: $el("button.rgthree-button-reset", {
+ html: iconStarFilled + "Star on Github",
+ onclick: () => window.open("https://github.com/rgthree/rgthree-comfy", "_blank"),
+ }),
+ }),
+ ],
+ })), "click");
+ if (CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.button_bookmarks.enabled")) {
+ const bookmarksListEl = $el("menu.rgthree-menu.rgthree-top-menu");
+ bookmarksListEl.appendChild($el("li.rgthree-message", {
+ child: $el("span", { text: "No bookmarks in current workflow." }),
+ }));
+ const bookmarksButton = new RgthreeComfyButton({
+ icon: "bookmark",
+ tooltip: "Workflow Bookmarks (rgthree-comfy)",
+ });
+ const bookmarksPopup = new RgthreeComfyPopup({ target: bookmarksButton.element, modal: false }, bookmarksListEl);
+ bookmarksPopup.onOpen(() => {
+ const bookmarks = BOOKMARKS_SERVICE.getCurrentBookmarks();
+ empty(bookmarksListEl);
+ if (bookmarks.length) {
+ for (const b of bookmarks) {
+ bookmarksListEl.appendChild($el("li", {
+ child: $el("button.rgthree-button-reset", {
+ text: `[${b.shortcutKey}] ${b.title}`,
+ onclick: () => {
+ b.canvasToBookmark();
+ },
+ }),
+ }));
+ }
+ }
+ else {
+ bookmarksListEl.appendChild($el("li.rgthree-message", {
+ child: $el("span", { text: "No bookmarks in current workflow." }),
+ }));
+ }
+ });
+ bookmarksButton.withPopup(bookmarksPopup, "hover");
+ buttons.push(bookmarksButton);
+ }
+ rgthreeButtonGroup = new RgthreeComfyButtonGroup(...buttons);
+ (_c = app.menu) === null || _c === void 0 ? void 0 : _c.settingsGroup.element.before(rgthreeButtonGroup.element);
+}
+app.registerExtension({
+ name: "rgthree.TopMenu",
+ async setup() {
+ addRgthreeTopBarButtons();
+ CONFIG_SERVICE.addEventListener("config-change", ((e) => {
+ var _a, _b;
+ if ((_b = (_a = e.detail) === null || _a === void 0 ? void 0 : _a.key) === null || _b === void 0 ? void 0 : _b.includes("features.comfy_top_bar_menu")) {
+ addRgthreeTopBarButtons();
+ }
+ }));
+ },
+});
+class RgthreeComfyButtonGroup {
+ constructor(...buttons) {
+ this.element = $el("div.rgthree-comfybar-top-button-group");
+ this.buttons = buttons;
+ this.update();
+ }
+ insert(button, index) {
+ this.buttons.splice(index, 0, button);
+ this.update();
+ }
+ append(button) {
+ this.buttons.push(button);
+ this.update();
+ }
+ remove(indexOrButton) {
+ if (typeof indexOrButton !== "number") {
+ indexOrButton = this.buttons.indexOf(indexOrButton);
+ }
+ if (indexOrButton > -1) {
+ const btn = this.buttons.splice(indexOrButton, 1);
+ this.update();
+ return btn;
+ }
+ return null;
+ }
+ update() {
+ this.element.replaceChildren(...this.buttons.map((b) => { var _a; return (_a = b["element"]) !== null && _a !== void 0 ? _a : b; }));
+ }
+}
+class RgthreeComfyButton {
+ constructor(opts) {
+ this.element = $el("button.rgthree-comfybar-top-button.rgthree-button-reset.rgthree-button");
+ this.iconElement = $el("span.rgthree-button-icon");
+ opts.icon && this.setIcon(opts.icon);
+ opts.tooltip && this.element.setAttribute("title", opts.tooltip);
+ opts.primary && this.element.classList.add("-primary");
+ }
+ setIcon(iconOrMarkup) {
+ const markup = iconOrMarkup.startsWith("<")
+ ? iconOrMarkup
+ : ` `;
+ this.iconElement.innerHTML = markup;
+ if (!this.iconElement.parentElement) {
+ this.element.appendChild(this.iconElement);
+ }
+ }
+ withPopup(popup, trigger) {
+ if (trigger === "click") {
+ this.element.addEventListener("click", () => {
+ popup.open();
+ });
+ }
+ if (trigger === "hover") {
+ this.element.addEventListener("pointerenter", () => {
+ popup.open();
+ });
+ }
+ }
+}
+class RgthreeComfyPopup {
+ constructor(opts, element) {
+ this.onOpenFn = null;
+ this.onWindowClickBound = this.onWindowClick.bind(this);
+ this.element = element;
+ this.opts = opts;
+ opts.target && (this.target = opts.target);
+ opts.modal && this.element.classList.add("-modal");
+ }
+ async open() {
+ if (!this.target) {
+ throw new Error("No target for RgthreeComfyPopup");
+ }
+ if (this.onOpenFn) {
+ await this.onOpenFn();
+ }
+ await wait(16);
+ const rect = this.target.getBoundingClientRect();
+ this.element.setAttribute("state", "measuring");
+ document.body.appendChild(this.element);
+ this.element.style.position = "fixed";
+ this.element.style.left = `${rect.left}px`;
+ this.element.style.top = `${rect.top + rect.height}px`;
+ this.element.setAttribute("state", "open");
+ if (this.opts.modal) {
+ document.body.classList.add("rgthree-modal-menu-open");
+ }
+ window.addEventListener("click", this.onWindowClickBound);
+ }
+ close() {
+ this.element.remove();
+ document.body.classList.remove("rgthree-modal-menu-open");
+ window.removeEventListener("click", this.onWindowClickBound);
+ }
+ onOpen(fn) {
+ this.onOpenFn = fn;
+ }
+ onWindowClick() {
+ this.close();
+ }
+}
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/config.js b/custom_nodes/rgthree-comfy/web/comfyui/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..698d42551e72f79bf77c7c944c579091e44d02f8
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/config.js
@@ -0,0 +1,378 @@
+import { app } from "../../scripts/app.js";
+import { RgthreeDialog } from "../../rgthree/common/dialog.js";
+import { createElement as $el, queryAll as $$ } from "../../rgthree/common/utils_dom.js";
+import { checkmark, logoRgthree } from "../../rgthree/common/media/svgs.js";
+import { rgthree } from "./rgthree.js";
+import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
+var ConfigType;
+(function (ConfigType) {
+ ConfigType[ConfigType["UNKNOWN"] = 0] = "UNKNOWN";
+ ConfigType[ConfigType["BOOLEAN"] = 1] = "BOOLEAN";
+ ConfigType[ConfigType["STRING"] = 2] = "STRING";
+ ConfigType[ConfigType["NUMBER"] = 3] = "NUMBER";
+ ConfigType[ConfigType["ARRAY"] = 4] = "ARRAY";
+})(ConfigType || (ConfigType = {}));
+var ConfigInputType;
+(function (ConfigInputType) {
+ ConfigInputType[ConfigInputType["UNKNOWN"] = 0] = "UNKNOWN";
+ ConfigInputType[ConfigInputType["CHECKLIST"] = 1] = "CHECKLIST";
+})(ConfigInputType || (ConfigInputType = {}));
+const TYPE_TO_STRING = {
+ [ConfigType.UNKNOWN]: "unknown",
+ [ConfigType.BOOLEAN]: "boolean",
+ [ConfigType.STRING]: "string",
+ [ConfigType.NUMBER]: "number",
+ [ConfigType.ARRAY]: "array",
+};
+const CONFIGURABLE = {
+ features: [
+ {
+ key: "features.progress_bar.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Prompt Progress Bar",
+ description: `Shows a minimal progress bar for nodes and steps at the top of the app.`,
+ subconfig: [
+ {
+ key: "features.progress_bar.height",
+ type: ConfigType.NUMBER,
+ label: "Height of the bar",
+ },
+ {
+ key: "features.progress_bar.position",
+ type: ConfigType.STRING,
+ label: "Position at top or bottom of window",
+ options: ["top", "bottom"],
+ },
+ ],
+ },
+ {
+ key: "features.import_individual_nodes.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Import Individual Nodes Widgets",
+ description: "Dragging & Dropping a similar image/JSON workflow onto (most) current workflow nodes" +
+ "will allow you to import that workflow's node's widgets when it has the same " +
+ "id and type. This is useful when you have several images and you'd like to import just " +
+ "one part of a previous iteration, like a seed, or prompt.",
+ },
+ ],
+ menus: [
+ {
+ key: "features.comfy_top_bar_menu.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Enable Top Bar Menu",
+ description: "Have quick access from ComfyUI's new top bar to rgthree-comfy bookmarks, settings " +
+ "(and more to come).",
+ },
+ {
+ key: "features.menu_queue_selected_nodes",
+ type: ConfigType.BOOLEAN,
+ label: "Show 'Queue Selected Output Nodes'",
+ description: "Will show a menu item in the right-click context menus to queue (only) the selected " +
+ "output nodes.",
+ },
+ {
+ key: "features.menu_auto_nest.subdirs",
+ type: ConfigType.BOOLEAN,
+ label: "Auto Nest Subdirectories in Menus",
+ description: "When a large, flat list of values contain sub-directories, auto nest them. (Like, for " +
+ "a large list of checkpoints).",
+ subconfig: [
+ {
+ key: "features.menu_auto_nest.threshold",
+ type: ConfigType.NUMBER,
+ label: "Number of items needed to trigger nesting.",
+ },
+ ],
+ },
+ {
+ key: "features.menu_bookmarks.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Show Bookmarks in context menu",
+ description: "Will list bookmarks in the rgthree-comfy right-click context menu.",
+ },
+ ],
+ groups: [
+ {
+ key: "features.group_header_fast_toggle.enabled",
+ type: ConfigType.BOOLEAN,
+ label: "Show fast toggles in Group Headers",
+ description: "Show quick toggles in Groups' Headers to quickly mute, bypass or queue.",
+ subconfig: [
+ {
+ key: "features.group_header_fast_toggle.toggles",
+ type: ConfigType.ARRAY,
+ label: "Which toggles to show.",
+ inputType: ConfigInputType.CHECKLIST,
+ options: [
+ { value: "queue", label: "queue" },
+ { value: "bypass", label: "bypass" },
+ { value: "mute", label: "mute" },
+ ],
+ },
+ {
+ key: "features.group_header_fast_toggle.show",
+ type: ConfigType.STRING,
+ label: "When to show them.",
+ options: [
+ { value: "hover", label: "on hover" },
+ { value: "always", label: "always" },
+ ],
+ },
+ ],
+ },
+ ],
+ power_lora_loader: [
+ {
+ key: "nodes.power_lora_loader.show_info_badge",
+ type: ConfigType.BOOLEAN,
+ label: "Show info badge/button",
+ description: "Show an info badge/button on each lora row to signal and open lora details.",
+ },
+ ],
+ advanced: [
+ {
+ key: "features.show_alerts_for_corrupt_workflows",
+ type: ConfigType.BOOLEAN,
+ label: "Detect Corrupt Workflows",
+ description: "Will show a message at the top of the screen when loading a workflow that has " +
+ "corrupt linking data.",
+ },
+ {
+ key: "log_level",
+ type: ConfigType.STRING,
+ label: "Log level for browser dev console.",
+ description: "Further down the list, the more verbose logs to the console will be. For instance, " +
+ "selecting 'IMPORTANT' means only important message will be logged to the browser " +
+ "console, while selecting 'WARN' will log all messages at or higher than WARN, including " +
+ "'ERROR' and 'IMPORTANT' etc.",
+ options: ["IMPORTANT", "ERROR", "WARN", "INFO", "DEBUG", "DEV"],
+ isDevOnly: true,
+ onSave: function (value) {
+ rgthree.setLogLevel(value);
+ },
+ },
+ {
+ key: "features.invoke_extensions_async.node_created",
+ type: ConfigType.BOOLEAN,
+ label: "Allow other extensions to call nodeCreated on rgthree-nodes.",
+ isDevOnly: true,
+ description: "Do not disable unless you are having trouble (and then file an issue at rgthree-comfy)." +
+ "Prior to Apr 2024 it was not possible for other extensions to invoke their nodeCreated " +
+ "event on some rgthree-comfy nodes. Now it's possible and this option is only here in " +
+ "for easy if something is wrong.",
+ },
+ ],
+};
+function fieldrow(item) {
+ var _a;
+ const initialValue = CONFIG_SERVICE.getConfigValue(item.key);
+ const container = $el(`div.fieldrow.-type-${TYPE_TO_STRING[item.type]}`, {
+ dataset: {
+ name: item.key,
+ initial: initialValue,
+ type: item.type,
+ },
+ });
+ $el(`label[for="${item.key}"]`, {
+ children: [
+ $el(`span[text="${item.label}"]`),
+ item.description ? $el("small", { html: item.description }) : null,
+ ],
+ parent: container,
+ });
+ let input;
+ if ((_a = item.options) === null || _a === void 0 ? void 0 : _a.length) {
+ if (item.inputType === ConfigInputType.CHECKLIST) {
+ const initialValueList = initialValue || [];
+ input = $el(`fieldset.rgthree-checklist-group[id="${item.key}"]`, {
+ parent: container,
+ children: item.options.map((o) => {
+ const label = o.label || String(o);
+ const value = o.value || o;
+ const id = `${item.key}_${value}`;
+ return $el(`span.rgthree-checklist-item`, {
+ children: [
+ $el(`input[type="checkbox"][value="${value}"]`, {
+ id,
+ checked: initialValueList.includes(value),
+ }),
+ $el(`label`, {
+ for: id,
+ text: label,
+ })
+ ]
+ });
+ }),
+ });
+ }
+ else {
+ input = $el(`select[id="${item.key}"]`, {
+ parent: container,
+ children: item.options.map((o) => {
+ const label = o.label || String(o);
+ const value = o.value || o;
+ const valueSerialized = JSON.stringify({ value: value });
+ return $el(`option[value="${valueSerialized}"]`, {
+ text: label,
+ selected: valueSerialized === JSON.stringify({ value: initialValue }),
+ });
+ }),
+ });
+ }
+ }
+ else if (item.type === ConfigType.BOOLEAN) {
+ container.classList.toggle("-checked", !!initialValue);
+ input = $el(`input[type="checkbox"][id="${item.key}"]`, {
+ parent: container,
+ checked: initialValue,
+ });
+ }
+ else {
+ input = $el(`input[id="${item.key}"]`, {
+ parent: container,
+ value: initialValue,
+ });
+ }
+ $el("div.fieldrow-value", { children: [input], parent: container });
+ return container;
+}
+export class RgthreeConfigDialog extends RgthreeDialog {
+ constructor() {
+ const content = $el("div");
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["features"], "Features"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["menus"], "Menus"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["groups"], "Groups"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["power_lora_loader"], "Power Lora Loader"));
+ content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["advanced"], "Advanced"));
+ content.addEventListener("input", (e) => {
+ const changed = this.getChangedFormData();
+ $$(".save-button", this.element)[0].disabled =
+ !Object.keys(changed).length;
+ });
+ content.addEventListener("change", (e) => {
+ const changed = this.getChangedFormData();
+ $$(".save-button", this.element)[0].disabled =
+ !Object.keys(changed).length;
+ });
+ const dialogOptions = {
+ class: "-iconed -settings",
+ title: logoRgthree + `Settings - rgthree-comfy `,
+ content,
+ onBeforeClose: () => {
+ const changed = this.getChangedFormData();
+ if (Object.keys(changed).length) {
+ return confirm("Looks like there are unsaved changes. Are you sure you want close?");
+ }
+ return true;
+ },
+ buttons: [
+ {
+ label: "Save",
+ disabled: true,
+ className: "rgthree-button save-button -blue",
+ callback: async (e) => {
+ var _a, _b;
+ const changed = this.getChangedFormData();
+ if (!Object.keys(changed).length) {
+ this.close();
+ return;
+ }
+ const success = await CONFIG_SERVICE.setConfigValues(changed);
+ if (success) {
+ for (const key of Object.keys(changed)) {
+ (_b = (_a = Object.values(CONFIGURABLE)
+ .flat()
+ .find((f) => f.key === key)) === null || _a === void 0 ? void 0 : _a.onSave) === null || _b === void 0 ? void 0 : _b.call(_a, changed[key]);
+ }
+ this.close();
+ rgthree.showMessage({
+ id: "config-success",
+ message: `${checkmark} Successfully saved rgthree-comfy settings!`,
+ timeout: 4000,
+ });
+ $$(".save-button", this.element)[0].disabled = true;
+ }
+ else {
+ alert("There was an error saving rgthree-comfy configuration.");
+ }
+ },
+ },
+ ],
+ };
+ super(dialogOptions);
+ }
+ static buildFieldset(datas, label) {
+ const fieldset = $el(`fieldset`, { children: [$el(`legend[text="${label}"]`)] });
+ for (const data of datas) {
+ if (data.isDevOnly && !rgthree.isDevMode()) {
+ continue;
+ }
+ const container = $el("div.formrow");
+ container.appendChild(fieldrow(data));
+ if (data.subconfig) {
+ for (const subfeature of data.subconfig) {
+ container.appendChild(fieldrow(subfeature));
+ }
+ }
+ fieldset.appendChild(container);
+ }
+ return fieldset;
+ }
+ getChangedFormData() {
+ return $$("[data-name]", this.contentElement).reduce((acc, el) => {
+ const name = el.dataset["name"];
+ const type = el.dataset["type"];
+ const initialValue = CONFIG_SERVICE.getConfigValue(name);
+ let currentValueEl = $$("fieldset.rgthree-checklist-group, input, textarea, select", el)[0];
+ let currentValue = null;
+ if (type === String(ConfigType.BOOLEAN)) {
+ currentValue = currentValueEl.checked;
+ el.classList.toggle("-checked", currentValue);
+ }
+ else {
+ currentValue = currentValueEl === null || currentValueEl === void 0 ? void 0 : currentValueEl.value;
+ if (currentValueEl.nodeName === "SELECT") {
+ currentValue = JSON.parse(currentValue).value;
+ }
+ else if (currentValueEl.classList.contains('rgthree-checklist-group')) {
+ currentValue = [];
+ for (const check of $$('input[type="checkbox"]', currentValueEl)) {
+ if (check.checked) {
+ currentValue.push(check.value);
+ }
+ }
+ }
+ else if (type === String(ConfigType.NUMBER)) {
+ currentValue = Number(currentValue) || initialValue;
+ }
+ }
+ if (JSON.stringify(currentValue) !== JSON.stringify(initialValue)) {
+ acc[name] = currentValue;
+ }
+ return acc;
+ }, {});
+ }
+}
+app.ui.settings.addSetting({
+ id: "rgthree.config",
+ defaultValue: null,
+ name: "Open rgthree-comfy config",
+ type: () => {
+ return $el("tr.rgthree-comfyui-settings-row", {
+ children: [
+ $el("td", {
+ child: `${logoRgthree} [rgthree-comfy] configuration / settings
`,
+ }),
+ $el("td", {
+ child: $el('button.rgthree-button.-blue[text="rgthree-comfy settings"]', {
+ events: {
+ click: (e) => {
+ new RgthreeConfigDialog().show();
+ },
+ },
+ }),
+ }),
+ ],
+ });
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/constants.js b/custom_nodes/rgthree-comfy/web/comfyui/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..b05bf4bda40f45921483f291808aa8809b0b6a9f
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/constants.js
@@ -0,0 +1,62 @@
+import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
+export function addRgthree(str) {
+ return str + " (rgthree)";
+}
+export function stripRgthree(str) {
+ return str.replace(/\s*\(rgthree\)$/, "");
+}
+export const NodeTypesString = {
+ ANY_SWITCH: addRgthree("Any Switch"),
+ CONTEXT: addRgthree("Context"),
+ CONTEXT_BIG: addRgthree("Context Big"),
+ CONTEXT_SWITCH: addRgthree("Context Switch"),
+ CONTEXT_SWITCH_BIG: addRgthree("Context Switch Big"),
+ CONTEXT_MERGE: addRgthree("Context Merge"),
+ CONTEXT_MERGE_BIG: addRgthree("Context Merge Big"),
+ DYNAMIC_CONTEXT: addRgthree("Dynamic Context"),
+ DYNAMIC_CONTEXT_SWITCH: addRgthree("Dynamic Context Switch"),
+ DISPLAY_ANY: addRgthree("Display Any"),
+ IMAGE_OR_LATENT_SIZE: addRgthree("Image or Latent Size"),
+ NODE_MODE_RELAY: addRgthree("Mute / Bypass Relay"),
+ NODE_MODE_REPEATER: addRgthree("Mute / Bypass Repeater"),
+ FAST_MUTER: addRgthree("Fast Muter"),
+ FAST_BYPASSER: addRgthree("Fast Bypasser"),
+ FAST_GROUPS_MUTER: addRgthree("Fast Groups Muter"),
+ FAST_GROUPS_BYPASSER: addRgthree("Fast Groups Bypasser"),
+ FAST_ACTIONS_BUTTON: addRgthree("Fast Actions Button"),
+ LABEL: addRgthree("Label"),
+ POWER_PRIMITIVE: addRgthree("Power Primitive"),
+ POWER_PROMPT: addRgthree("Power Prompt"),
+ POWER_PROMPT_SIMPLE: addRgthree("Power Prompt - Simple"),
+ POWER_PUTER: addRgthree("Power Puter"),
+ POWER_CONDUCTOR: addRgthree("Power Conductor"),
+ SDXL_EMPTY_LATENT_IMAGE: addRgthree("SDXL Empty Latent Image"),
+ SDXL_POWER_PROMPT_POSITIVE: addRgthree("SDXL Power Prompt - Positive"),
+ SDXL_POWER_PROMPT_NEGATIVE: addRgthree("SDXL Power Prompt - Simple / Negative"),
+ POWER_LORA_LOADER: addRgthree("Power Lora Loader"),
+ KSAMPLER_CONFIG: addRgthree("KSampler Config"),
+ NODE_COLLECTOR: addRgthree("Node Collector"),
+ REROUTE: addRgthree("Reroute"),
+ RANDOM_UNMUTER: addRgthree("Random Unmuter"),
+ SEED: addRgthree("Seed"),
+ BOOKMARK: addRgthree("Bookmark"),
+ IMAGE_COMPARER: addRgthree("Image Comparer"),
+ IMAGE_INSET_CROP: addRgthree("Image Inset Crop"),
+};
+const UNRELEASED_KEYS = {
+ [NodeTypesString.DYNAMIC_CONTEXT]: "dynamic_context",
+ [NodeTypesString.DYNAMIC_CONTEXT_SWITCH]: "dynamic_context",
+ [NodeTypesString.POWER_CONDUCTOR]: "power_conductor",
+};
+export function getNodeTypeStrings() {
+ const unreleasedKeys = Object.keys(UNRELEASED_KEYS);
+ return Object.values(NodeTypesString)
+ .map((i) => stripRgthree(i))
+ .filter((i) => {
+ if (unreleasedKeys.includes(i)) {
+ return !!CONFIG_SERVICE.getConfigValue(`unreleased.${UNRELEASED_KEYS[i]}.enabled`);
+ }
+ return true;
+ })
+ .sort();
+}
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/context.js b/custom_nodes/rgthree-comfy/web/comfyui/context.js
new file mode 100644
index 0000000000000000000000000000000000000000..e6185dada9d8e2d802990b5589104d8a6dc66390
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/context.js
@@ -0,0 +1,322 @@
+import { app } from "../../scripts/app.js";
+import { IoDirection, addConnectionLayoutSupport, addMenuItem, matchLocalSlotsToServer, replaceNode, } from "./utils.js";
+import { RgthreeBaseServerNode } from "./base_node.js";
+import { SERVICE as KEY_EVENT_SERVICE } from "./services/key_events_services.js";
+import { debounce, wait } from "../../rgthree/common/shared_utils.js";
+import { removeUnusedInputsFromEnd } from "./utils_inputs_outputs.js";
+import { NodeTypesString } from "./constants.js";
+function findMatchingIndexByTypeOrName(otherNode, otherSlot, ctxSlots) {
+ const otherNodeType = (otherNode.type || "").toUpperCase();
+ const otherNodeName = (otherNode.title || "").toUpperCase();
+ let otherSlotType = otherSlot.type;
+ if (Array.isArray(otherSlotType) || otherSlotType.includes(",")) {
+ otherSlotType = "COMBO";
+ }
+ const otherSlotName = otherSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
+ let ctxSlotIndex = -1;
+ if (["CONDITIONING", "INT", "STRING", "FLOAT", "COMBO"].includes(otherSlotType)) {
+ ctxSlotIndex = ctxSlots.findIndex((ctxSlot) => {
+ const ctxSlotName = ctxSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
+ let ctxSlotType = ctxSlot.type;
+ if (Array.isArray(ctxSlotType) || ctxSlotType.includes(",")) {
+ ctxSlotType = "COMBO";
+ }
+ if (ctxSlotType !== otherSlotType) {
+ return false;
+ }
+ if (ctxSlotName === otherSlotName ||
+ (ctxSlotName === "SEED" && otherSlotName.includes("SEED")) ||
+ (ctxSlotName === "STEP_REFINER" && otherSlotName.includes("AT_STEP")) ||
+ (ctxSlotName === "STEP_REFINER" && otherSlotName.includes("REFINER_STEP"))) {
+ return true;
+ }
+ if ((otherNodeType.includes("POSITIVE") || otherNodeName.includes("POSITIVE")) &&
+ ((ctxSlotName === "POSITIVE" && otherSlotType === "CONDITIONING") ||
+ (ctxSlotName === "TEXT_POS_G" && otherSlotName.includes("TEXT_G")) ||
+ (ctxSlotName === "TEXT_POS_L" && otherSlotName.includes("TEXT_L")))) {
+ return true;
+ }
+ if ((otherNodeType.includes("NEGATIVE") || otherNodeName.includes("NEGATIVE")) &&
+ ((ctxSlotName === "NEGATIVE" && otherSlotType === "CONDITIONING") ||
+ (ctxSlotName === "TEXT_NEG_G" && otherSlotName.includes("TEXT_G")) ||
+ (ctxSlotName === "TEXT_NEG_L" && otherSlotName.includes("TEXT_L")))) {
+ return true;
+ }
+ return false;
+ });
+ }
+ else {
+ ctxSlotIndex = ctxSlots.map((s) => s.type).indexOf(otherSlotType);
+ }
+ return ctxSlotIndex;
+}
+export class BaseContextNode extends RgthreeBaseServerNode {
+ constructor(title) {
+ super(title);
+ this.___collapsed_width = 0;
+ }
+ get _collapsed_width() {
+ return this.___collapsed_width;
+ }
+ set _collapsed_width(width) {
+ const canvas = app.canvas;
+ const ctx = canvas.canvas.getContext("2d");
+ const oldFont = ctx.font;
+ ctx.font = canvas.title_text_font;
+ let title = this.title.trim();
+ this.___collapsed_width = 30 + (title ? 10 + ctx.measureText(title).width : 0);
+ ctx.font = oldFont;
+ }
+ connectByType(slot, targetNode, targetSlotType, optsIn) {
+ var _a;
+ let canConnect = (_a = super.connectByType) === null || _a === void 0 ? void 0 : _a.call(this, slot, targetNode, targetSlotType, optsIn);
+ if (!super.connectByType) {
+ canConnect = LGraphNode.prototype.connectByType.call(this, slot, targetNode, targetSlotType, optsIn);
+ }
+ if (!canConnect && slot === 0) {
+ const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
+ for (const [index, input] of (targetNode.inputs || []).entries()) {
+ if (input.link && !ctrlKey) {
+ continue;
+ }
+ const thisOutputSlot = findMatchingIndexByTypeOrName(targetNode, input, this.outputs);
+ if (thisOutputSlot > -1) {
+ this.connect(thisOutputSlot, targetNode, index);
+ }
+ }
+ }
+ return null;
+ }
+ connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn) {
+ var _a, _b;
+ let canConnect = (_a = super.connectByTypeOutput) === null || _a === void 0 ? void 0 : _a.call(this, slot, sourceNode, sourceSlotType, optsIn);
+ if (!super.connectByType) {
+ canConnect = LGraphNode.prototype.connectByTypeOutput.call(this, slot, sourceNode, sourceSlotType, optsIn);
+ }
+ if (!canConnect && slot === 0) {
+ const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
+ for (const [index, output] of (sourceNode.outputs || []).entries()) {
+ if (((_b = output.links) === null || _b === void 0 ? void 0 : _b.length) && !ctrlKey) {
+ continue;
+ }
+ const thisInputSlot = findMatchingIndexByTypeOrName(sourceNode, output, this.inputs);
+ if (thisInputSlot > -1) {
+ sourceNode.connect(index, this, thisInputSlot);
+ }
+ }
+ }
+ return null;
+ }
+ static setUp(comfyClass, nodeData, ctxClass) {
+ RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ctxClass);
+ wait(500).then(() => {
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] =
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] || [];
+ LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"].push(comfyClass.comfyClass);
+ });
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ addConnectionLayoutSupport(ctxClass, app, [
+ ["Left", "Right"],
+ ["Right", "Left"],
+ ]);
+ setTimeout(() => {
+ ctxClass.category = comfyClass.category;
+ });
+ }
+}
+class ContextNode extends BaseContextNode {
+ constructor(title = ContextNode.title) {
+ super(title);
+ }
+ static setUp(comfyClass, nodeData) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextNode);
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextNode, app, {
+ name: "Convert To Context Big",
+ callback: (node) => {
+ replaceNode(node, ContextBigNode.type);
+ },
+ });
+ }
+}
+ContextNode.title = NodeTypesString.CONTEXT;
+ContextNode.type = NodeTypesString.CONTEXT;
+ContextNode.comfyClass = NodeTypesString.CONTEXT;
+class ContextBigNode extends BaseContextNode {
+ constructor(title = ContextBigNode.title) {
+ super(title);
+ }
+ static setUp(comfyClass, nodeData) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextBigNode);
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextBigNode, app, {
+ name: "Convert To Context (Original)",
+ callback: (node) => {
+ replaceNode(node, ContextNode.type);
+ },
+ });
+ }
+}
+ContextBigNode.title = NodeTypesString.CONTEXT_BIG;
+ContextBigNode.type = NodeTypesString.CONTEXT_BIG;
+ContextBigNode.comfyClass = NodeTypesString.CONTEXT_BIG;
+class BaseContextMultiCtxInputNode extends BaseContextNode {
+ constructor(title) {
+ super(title);
+ this.stabilizeBound = this.stabilize.bind(this);
+ this.addContextInput(5);
+ }
+ addContextInput(num = 1) {
+ for (let i = 0; i < num; i++) {
+ this.addInput(`ctx_${String(this.inputs.length + 1).padStart(2, "0")}`, "RGTHREE_CONTEXT");
+ }
+ }
+ onConnectionsChange(type, slotIndex, isConnected, link, ioSlot) {
+ var _a;
+ (_a = super.onConnectionsChange) === null || _a === void 0 ? void 0 : _a.apply(this, [...arguments]);
+ if (type === LiteGraph.INPUT) {
+ this.scheduleStabilize();
+ }
+ }
+ scheduleStabilize(ms = 64) {
+ return debounce(this.stabilizeBound, 64);
+ }
+ stabilize() {
+ removeUnusedInputsFromEnd(this, 4);
+ this.addContextInput();
+ }
+}
+class ContextSwitchNode extends BaseContextMultiCtxInputNode {
+ constructor(title = ContextSwitchNode.title) {
+ super(title);
+ }
+ static setUp(comfyClass, nodeData) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchNode);
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextSwitchNode, app, {
+ name: "Convert To Context Switch Big",
+ callback: (node) => {
+ replaceNode(node, ContextSwitchBigNode.type);
+ },
+ });
+ }
+}
+ContextSwitchNode.title = NodeTypesString.CONTEXT_SWITCH;
+ContextSwitchNode.type = NodeTypesString.CONTEXT_SWITCH;
+ContextSwitchNode.comfyClass = NodeTypesString.CONTEXT_SWITCH;
+class ContextSwitchBigNode extends BaseContextMultiCtxInputNode {
+ constructor(title = ContextSwitchBigNode.title) {
+ super(title);
+ }
+ static setUp(comfyClass, nodeData) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchBigNode);
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextSwitchBigNode, app, {
+ name: "Convert To Context Switch",
+ callback: (node) => {
+ replaceNode(node, ContextSwitchNode.type);
+ },
+ });
+ }
+}
+ContextSwitchBigNode.title = NodeTypesString.CONTEXT_SWITCH_BIG;
+ContextSwitchBigNode.type = NodeTypesString.CONTEXT_SWITCH_BIG;
+ContextSwitchBigNode.comfyClass = NodeTypesString.CONTEXT_SWITCH_BIG;
+class ContextMergeNode extends BaseContextMultiCtxInputNode {
+ constructor(title = ContextMergeNode.title) {
+ super(title);
+ }
+ static setUp(comfyClass, nodeData) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextMergeNode);
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextMergeNode, app, {
+ name: "Convert To Context Merge Big",
+ callback: (node) => {
+ replaceNode(node, ContextMergeBigNode.type);
+ },
+ });
+ }
+}
+ContextMergeNode.title = NodeTypesString.CONTEXT_MERGE;
+ContextMergeNode.type = NodeTypesString.CONTEXT_MERGE;
+ContextMergeNode.comfyClass = NodeTypesString.CONTEXT_MERGE;
+class ContextMergeBigNode extends BaseContextMultiCtxInputNode {
+ constructor(title = ContextMergeBigNode.title) {
+ super(title);
+ }
+ static setUp(comfyClass, nodeData) {
+ BaseContextNode.setUp(comfyClass, nodeData, ContextMergeBigNode);
+ }
+ static onRegisteredForOverride(comfyClass, ctxClass) {
+ BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
+ addMenuItem(ContextMergeBigNode, app, {
+ name: "Convert To Context Switch",
+ callback: (node) => {
+ replaceNode(node, ContextMergeNode.type);
+ },
+ });
+ }
+}
+ContextMergeBigNode.title = NodeTypesString.CONTEXT_MERGE_BIG;
+ContextMergeBigNode.type = NodeTypesString.CONTEXT_MERGE_BIG;
+ContextMergeBigNode.comfyClass = NodeTypesString.CONTEXT_MERGE_BIG;
+const contextNodes = [
+ ContextNode,
+ ContextBigNode,
+ ContextSwitchNode,
+ ContextSwitchBigNode,
+ ContextMergeNode,
+ ContextMergeBigNode,
+];
+const contextTypeToServerDef = {};
+function fixBadConfigs(node) {
+ const wrongName = node.outputs.find((o, i) => o.name === "CLIP_HEIGTH");
+ if (wrongName) {
+ wrongName.name = "CLIP_HEIGHT";
+ }
+}
+app.registerExtension({
+ name: "rgthree.Context",
+ async beforeRegisterNodeDef(nodeType, nodeData) {
+ for (const ctxClass of contextNodes) {
+ if (nodeData.name === ctxClass.type) {
+ contextTypeToServerDef[ctxClass.type] = nodeData;
+ ctxClass.setUp(nodeType, nodeData);
+ break;
+ }
+ }
+ },
+ async nodeCreated(node) {
+ const type = node.type || node.constructor.type;
+ const serverDef = type && contextTypeToServerDef[type];
+ if (serverDef) {
+ fixBadConfigs(node);
+ matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
+ if (!type.includes("Switch") && !type.includes("Merge")) {
+ matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
+ }
+ }
+ },
+ async loadedGraphNode(node) {
+ const type = node.type || node.constructor.type;
+ const serverDef = type && contextTypeToServerDef[type];
+ if (serverDef) {
+ fixBadConfigs(node);
+ matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
+ if (!type.includes("Switch") && !type.includes("Merge")) {
+ matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
+ }
+ }
+ },
+});
diff --git a/custom_nodes/rgthree-comfy/web/comfyui/dialog_info.js b/custom_nodes/rgthree-comfy/web/comfyui/dialog_info.js
new file mode 100644
index 0000000000000000000000000000000000000000..f2679b6cfdf40a15e7c1235411a311675b09abe9
--- /dev/null
+++ b/custom_nodes/rgthree-comfy/web/comfyui/dialog_info.js
@@ -0,0 +1,292 @@
+import { RgthreeDialog } from "../../rgthree/common/dialog.js";
+import { createElement as $el, empty, appendChildren, getClosestOrSelf, query, queryAll, setAttributes, } from "../../rgthree/common/utils_dom.js";
+import { logoCivitai, link, pencilColored, diskColored, dotdotdot, } from "../../rgthree/common/media/svgs.js";
+import { CHECKPOINT_INFO_SERVICE, LORA_INFO_SERVICE } from "../../rgthree/common/model_info_service.js";
+import { rgthree } from "./rgthree.js";
+import { MenuButton } from "../../rgthree/common/menu.js";
+import { generateId, injectCss } from "../../rgthree/common/shared_utils.js";
+class RgthreeInfoDialog extends RgthreeDialog {
+ constructor(file) {
+ const dialogOptions = {
+ class: "rgthree-info-dialog",
+ title: `Loading... `,
+ content: "Loading.. ",
+ onBeforeClose: () => {
+ return true;
+ },
+ };
+ super(dialogOptions);
+ this.modifiedModelData = false;
+ this.modelInfo = null;
+ this.init(file);
+ }
+ async init(file) {
+ var _a, _b;
+ const cssPromise = injectCss("rgthree/common/css/dialog_model_info.css");
+ this.modelInfo = await this.getModelInfo(file);
+ await cssPromise;
+ this.setContent(this.getInfoContent());
+ this.setTitle(((_a = this.modelInfo) === null || _a === void 0 ? void 0 : _a["name"]) || ((_b = this.modelInfo) === null || _b === void 0 ? void 0 : _b["file"]) || "Unknown");
+ this.attachEvents();
+ }
+ getCloseEventDetail() {
+ const detail = {
+ dirty: this.modifiedModelData,
+ };
+ return { detail };
+ }
+ attachEvents() {
+ this.contentElement.addEventListener("click", async (e) => {
+ const target = getClosestOrSelf(e.target, "[data-action]");
+ const action = target === null || target === void 0 ? void 0 : target.getAttribute("data-action");
+ if (!target || !action) {
+ return;
+ }
+ await this.handleEventAction(action, target, e);
+ });
+ }
+ async handleEventAction(action, target, e) {
+ var _a, _b;
+ const info = this.modelInfo;
+ if (!(info === null || info === void 0 ? void 0 : info.file)) {
+ return;
+ }
+ if (action === "fetch-civitai") {
+ this.modelInfo = await this.refreshModelInfo(info.file);
+ this.setContent(this.getInfoContent());
+ this.setTitle(((_a = this.modelInfo) === null || _a === void 0 ? void 0 : _a["name"]) || ((_b = this.modelInfo) === null || _b === void 0 ? void 0 : _b["file"]) || "Unknown");
+ }
+ else if (action === "copy-trained-words") {
+ const selected = queryAll(".-rgthree-is-selected", target.closest("tr"));
+ const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
+ await navigator.clipboard.writeText(text);
+ rgthree.showMessage({
+ id: "copy-trained-words-" + generateId(4),
+ type: "success",
+ message: `Successfully copied ${selected.length} key word${selected.length === 1 ? "" : "s"}.`,
+ timeout: 4000,
+ });
+ }
+ else if (action === "toggle-trained-word") {
+ target === null || target === void 0 ? void 0 : target.classList.toggle("-rgthree-is-selected");
+ const tr = target.closest("tr");
+ if (tr) {
+ const span = query("td:first-child > *", tr);
+ let small = query("small", span);
+ if (!small) {
+ small = $el("small", { parent: span });
+ }
+ const num = queryAll(".-rgthree-is-selected", tr).length;
+ small.innerHTML = num
+ ? `${num} selected | Copy `
+ : "";
+ }
+ }
+ else if (action === "edit-row") {
+ const tr = target.closest("tr");
+ const td = query("td:nth-child(2)", tr);
+ const input = td.querySelector("input,textarea");
+ if (!input) {
+ const fieldName = tr.dataset["fieldName"];
+ tr.classList.add("-rgthree-editing");
+ const isTextarea = fieldName === "userNote";
+ const input = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, {
+ value: td.textContent,
+ });
+ input.addEventListener("keydown", (e) => {
+ if (!isTextarea && e.key === "Enter") {
+ const modified = saveEditableRow(info, tr, true);
+ this.modifiedModelData = this.modifiedModelData || modified;
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ else if (e.key === "Escape") {
+ const modified = saveEditableRow(info, tr, false);
+ this.modifiedModelData = this.modifiedModelData || modified;
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ });
+ appendChildren(empty(td), [input]);
+ input.focus();
+ }
+ else if (target.nodeName.toLowerCase() === "button") {
+ const modified = saveEditableRow(info, tr, true);
+ this.modifiedModelData = this.modifiedModelData || modified;
+ }
+ e === null || e === void 0 ? void 0 : e.preventDefault();
+ e === null || e === void 0 ? void 0 : e.stopPropagation();
+ }
+ }
+ getInfoContent() {
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
+ const info = this.modelInfo || {};
+ const civitaiLink = (_a = info.links) === null || _a === void 0 ? void 0 : _a.find((i) => i.includes("civitai.com/models"));
+ const html = `
+
+