Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled

Includes 30 custom nodes committed directly, 7 Civitai-exclusive
loras stored via Git LFS, and a setup script that installs all
dependencies and downloads HuggingFace-hosted models on vast.ai.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 00:55:26 +00:00
parent 2b70ab9ad0
commit f09734b0ee
2274 changed files with 748556 additions and 3 deletions

13
custom_nodes/rgthree-comfy/.gitignore vendored Normal file
View File

@@ -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/

View File

@@ -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.

View File

@@ -0,0 +1,434 @@
<h1 align="center">
rgthree-comfy
<br>
<sub><sup><i>Making ComfyUI more comfortable!</i></sup></sub>
<br>
</h1>
<p align="center">
<a href="#-the-nodes">The Nodes</a> &nbsp; | &nbsp; <a href="#-improvements--features">Improvements & Features</a> &nbsp; | &nbsp; <a href="#-link-fixer">Link Fixer</a>
</p>
<hr>
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 :)
![Context Node](./docs/rgthree_advanced.png)
# 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)_.
<br>
# ✴️ 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.
> <details>
> <summary> <i>See More Information</i></summary>
>
> - 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.
>
> ![Router Node](./docs/rgthree_seed.png)
> </details>
## Reroute
> Keep your workflow neat with this much improved Reroute node with, like, actual rerouting with multiple directions and sizes.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
>
> ![Router Node](./docs/rgthree_router.png)
> </details>
## Bookmark (🔖)
> Place the bookmark node anywhere on screen to quickly navigate to that with a shortcut key.
> <details>
> <summary> <i>See More Information</i></summary>
>
> - 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.
> </details>
## 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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
>
> ![Context Node](./docs/rgthree_context.png)
> </details>
## Image Comparer
> The Image Comparer node compares two images on top of each other.
> <details>
> <summary> <i>More Information</i></summary>
>
> - **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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
> </details>
## ~~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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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 `<lora:...>` tags in the text automatically, without
> needing a separate Lora Loaders
> </details>
## 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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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**
>
> </details>
## Any Switch
> A powerful node to similar to the Context Switch above, that chooses the first input that is not null/empty.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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**
>
> </details>
## 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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
>
> </details>
## 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)
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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)
> ```
>
> </details>
## 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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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._
> </details>
## 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
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
> </details>
## 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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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!
> </details>
## 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.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 💡 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.
> </details>
## 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).
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
> </details>
## Random Unmuter
> An advanced node used to unmute one of its inputs randomly when the graph is queued (and, immediately mute it back).
> <details>
> <summary> <i>More Information</i></summary>
>
> - **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.
> </details>
## Label
> A purely visual node, this allows you to add a floating label to your workflow.
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
> </details>
# 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
![Context Node](./docs/rgthree_advanced.png)
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.
<br>
# ⚡ 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.
>
> <i>You can remove/enable from rgthree-comfy settings, as well as configure the height/size.</i>
## ~~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)`.
>
> <details>
> <summary> <i>More Information</i></summary>
>
> - 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.
>
> </details>
## 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>🎓 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.</i>
## 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)._
<br>
# 📄 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.

View File

@@ -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')

View File

@@ -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')

View File

@@ -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)

View File

@@ -0,0 +1,57 @@
#!/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):
p = Popen(["git", "-C", path, "pull"], 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 output.startswith('Already up to date'):
print(f' \33[32m🗸 {output}\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('../'))
# 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))

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

507
custom_nodes/rgthree-comfy/pnpm-lock.yaml generated Normal file
View File

@@ -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: {}

View File

@@ -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']))

View File

@@ -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,)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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,)}}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,)

View File

@@ -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)

View File

@@ -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])

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,)

View File

@@ -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 '<lora:' in prompt:
prompt, loras, skipped, unfound = get_and_strip_loras(prompt, log_node=NODE_NAME, silent=True)
total_loras = len(loras) + len(skipped) + len(unfound)
if total_loras:
log_node_warn(
NODE_NAME, f'Found {len(loras)} lora tags in prompt but model & clip were not supplied!')
log_node_info(NODE_NAME, 'Loras not processed, keeping for TEXT output.')
conditioning = None
if opt_clip is not None:
conditioning = CLIPTextEncode().encode(opt_clip, prompt)[0]
return (conditioning, opt_model, opt_clip, prompt)

View File

@@ -0,0 +1,42 @@
import os
import folder_paths
from nodes import CLIPTextEncode
from .constants import get_category, get_name
from .power_prompt import RgthreePowerPrompt
class RgthreePowerPromptSimple(RgthreePowerPrompt):
NAME=get_name('Power Prompt - Simple')
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_clip": ("CLIP", ),
'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', 'STRING',)
RETURN_NAMES = ('CONDITIONING', 'TEXT',)
FUNCTION = 'main'
def main(self, prompt, opt_clip=None, insert_embedding=None, insert_saved=None, values_insert_saved=None):
conditioning=None
if opt_clip != None:
conditioning = CLIPTextEncode().encode(opt_clip, prompt)[0]
return (conditioning, prompt)

View File

@@ -0,0 +1,104 @@
"""Utilities for Power Prompt nodes."""
import re
import os
import folder_paths
from .log import log_node_warn, log_node_info
def get_and_strip_loras(prompt, silent=False, log_node="Power Prompt"):
"""Collects and strips lora tags from a prompt."""
pattern = r'<lora:([^:>]*?)(?::(-?\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

View File

@@ -0,0 +1,842 @@
"""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
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]
_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)),
# 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'(?<!input_)(nodes?)\(',]
_OPERATORS = {
# operator
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.MatMult: op.matmul,
ast.Div: op.truediv,
ast.Mod: op.mod,
ast.Pow: op.pow,
ast.RShift: op.rshift,
ast.LShift: op.lshift,
ast.BitOr: op.or_,
ast.BitXor: op.xor,
ast.BitAnd: op.and_,
ast.FloorDiv: op.floordiv,
# boolop
ast.And: lambda a, b: a and b,
ast.Or: lambda a, b: a or b,
# unaryop
ast.Invert: op.invert,
ast.Not: lambda a: 0 if a else 1,
ast.USub: op.neg,
# cmpop
ast.Eq: op.eq,
ast.NotEq: op.ne,
ast.Lt: op.lt,
ast.LtE: op.le,
ast.Gt: op.gt,
ast.GtE: op.ge,
ast.Is: op.is_,
ast.IsNot: op.is_not,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
}
_NODE_NAME = get_name("Power Puter")
def _update_code(code: str, unique_id: str, log=False):
"""Updates the code to either newer syntax or general cleaning."""
# Change usage of `input_node` so the passed variable is a string, if it isn't. So, instead of
# `input_node(a)` it needs to be `input_node('a')`
code = re.sub(r'input_node\(([^\'"].*?)\)', r'input_node("\1")', code)
# Update use of `random_int` to `random.int`
srch = re.compile(r'random_int\(')
if re.search(srch, code):
if log:
log_node_warn(
_NODE_NAME, f"Power Puter node #{unique_id} should update to use the `random.int`"
" built-in instead of `random_int`."
)
code = re.sub(srch, 'random.int(', code)
# Update use of `random_choice` to `random.choice`
srch = re.compile(r'random_choice\(')
if re.search(srch, code):
if log:
log_node_warn(
_NODE_NAME, f"Power Puter node #{unique_id} should update to use the `random.choice`"
" built-in instead of `random_choice`."
)
code = re.sub(srch, 'random.choice(', code)
return code
class RgthreePowerPuter:
"""A powerful node that can compute and evaluate expressions and output as various types."""
NAME = _NODE_NAME
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType(any_type),
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
"prompt": "PROMPT",
"dynprompt": "DYNPROMPT"
},
}
RETURN_TYPES = ByPassTypeTuple((any_type,))
RETURN_NAMES = ByPassTypeTuple(("*",))
FUNCTION = "main"
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Forces a changed state if we could be unaware of data changes (like using `node()`)."""
code = _update_code(kwargs['code'], unique_id=kwargs['unique_id'])
# Strip string literals and comments.
code = re.sub(r"'[^']+?'", "''", code)
code = re.sub(r'"[^"]+?"', '""', code)
code = re.sub(r'#.*\n', '\n', code)
# If we have a non-deterministic function, then we'll always consider ourself changed since we
# cannot be sure that the data would be the same (random, another unconnected node, etc).
for check in _NON_DETERMINISTIC_FUNCTION_CHECKS:
matches = re.search(check, code)
if matches:
log_node_warn(
_NODE_NAME,
f"Note, Power Puter (node #{kwargs['unique_id']}) cannot be cached b/c it's using a"
f" non-deterministic function call. Matches function call for '{matches.group(1)}'."
)
return time.time()
# Advanced checks.
has_rand_seed = re.search(r'random\.seed\(', code)
has_rand_int_or_choice = re.search(r'(?<!\.)(random\.(int|choice))\(', code)
if has_rand_int_or_choice:
if not has_rand_seed or has_rand_seed.span()[0] > 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)

View File

@@ -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 = '<svg></svg>'
return LOGO_SVG

View File

@@ -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),
)

View File

@@ -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 '<lora:' in prompt_g or '<lora:' in prompt_l:
_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
if len(loras):
log_node_warn(
NODE_NAME, f'Found {len(loras)} lora tags in prompt but model & clip were not supplied!')
log_node_info(NODE_NAME, 'Loras not processed, keeping for TEXT output.')
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, opt_model, opt_clip, prompt_g, prompt_l)
def get_conditioning(self, prompt_g, prompt_l, opt_clip, opt_clip_width, opt_clip_height,
target_width, target_height, crop_width, crop_height):
"""Checks the inputs and gets the conditioning."""
conditioning = None
if opt_clip is not None:
do_regular_clip_text_encode = opt_clip_width and opt_clip_height
if do_regular_clip_text_encode:
target_width = target_width if target_width and target_width > 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

View File

@@ -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)

View File

@@ -0,0 +1,123 @@
"""See node."""
import random
from datetime import datetime
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,
'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:
workflow_node = next(
(x for x in extra_pnginfo['workflow']['nodes'] if str(x['id']) == str(unique_id)), None)
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,)

View File

@@ -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({})

View File

@@ -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'(<svg[^\>]*?)width="[^\"]+"', r'\1', resp)
if str(w).isnumeric():
resp = re.sub(r'<svg', f'<svg width="{w}"', resp)
if h is not None:
resp = re.sub(r'(<svg[^\>]*?)height="[^\"]+"', r'\1', resp)
if str(h).isnumeric():
resp = re.sub(r'<svg', f'<svg height="{h}"', resp)
if css_class is not None:
resp = re.sub(r'<svg', f'<svg class="{css_class}"', resp)
if as_markup:
resp = re.sub(r'^.*?<svg', r'<svg', resp, flags=re.DOTALL)
return web.Response(text=resp, content_type='image/svg+xml')
@routes.get('/rgthree/logo_markup.svg')
async def get_logo_markup(request):
""" Returns the rgthree logo svg markup (no doctype) with options. """
return await get_logo(request, as_markup=True)

View File

@@ -0,0 +1,199 @@
import os
import json
from aiohttp import web
from ..log import log
from server import PromptServer
import folder_paths
from ..utils import abspath, path_exists
from .utils_server import get_param, is_param_falsy
from .utils_info import delete_model_info, get_model_info, set_model_info_partial, get_file_info
routes = PromptServer.instance.routes
def _check_valid_model_type(request):
model_type = request.match_info['type']
if model_type not in ['loras', 'checkpoints']:
return web.json_response({'status': 404, 'error': f'Invalid model type: {model_type}'})
return None
@routes.get('/rgthree/api/{type}')
async def api_get_models_list(request):
"""Returns a list of model types from user configuration.
By default, a list of filenames are provided. If `format=details` is specified, a list of objects
with additional _file info_ is provided. This includes modigied time, hasInfoFile, and imageLocal
among others.
"""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
files = folder_paths.get_filename_list(model_type)
format_param = get_param(request, 'format')
if format_param == 'details':
response = []
bad_files_first = None
bad_files_num = 0
for file in files:
file_info = get_file_info(file, model_type)
# Some folks were seeing null in this list, which is odd since it's coming from ComfyUI files.
# See https://github.com/rgthree/rgthree-comfy/issues/574#issuecomment-3494629132 We'll check
# and log if we haven't found, maybe someone will have more info.
if file_info is not None:
response.append(file_info)
else:
bad_files_num += 1
if not bad_files_first:
bad_files_first = file
if bad_files_first:
log(
f"Couldn't get file info for {bad_files_first}"
f"{f' and {bad_files_num} other {model_type}.' if bad_files_num > 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

View File

@@ -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)

View File

@@ -0,0 +1,56 @@
import os
from aiohttp import web
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. """
return web.FileResponse(os.path.join(DIR_WEB, path, request.match_info['file']))
@routes.get(f'/rgthree/{path}/{{subdir}}/{{file}}')
async def get_resource_subdir(request):
""" Returns a resource file. """
return web.FileResponse(
os.path.join(DIR_WEB, path, request.match_info['subdir'], request.match_info['file']))
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)

View File

@@ -0,0 +1,168 @@
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."""
if file_path and not path_exists(file_path):
maybe_path = os.path.abspath(os.path.realpath(os.path.expanduser(file_path)))
file_path = maybe_path if path_exists(maybe_path) else file_path
return file_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)

View File

@@ -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

View File

@@ -0,0 +1,14 @@
[project]
name = "rgthree-comfy"
description = "Making ComfyUI more comfortable."
version = "1.0.2512112053"
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"

View File

@@ -0,0 +1,65 @@
// 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"
}
}
},
"announcements": {
"comfy-nodes-20": {
"incompatible": true
}
}
}

View File

@@ -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);
}
},
});

View File

@@ -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<void> | 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<TProperties extends Partial<INodeInputSlot>>(
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 extends TWidgetType, TValue extends WidgetTypeMap[Type]["value"]>(
type: Type,
name: string,
value: TValue,
callback: IBaseWidget["callback"] | string | null,
options?: IWidgetOptions | string,
):
| IBaseWidget<string | number | boolean | object | undefined, string, IWidgetOptions<unknown>>
| 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
);
};

View File

@@ -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<boolean> {
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<T extends RgthreeBaseNode>(...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<unknown> | null)[],
): (IContextMenuValue<unknown> | 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<any, any>();
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);
};

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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(`<lora:${selected}:1.0>`);
}
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 "<lora:". So work backwards until we
// find something that we know can't be a lora, or a "<".
if (!selectedText) {
const stopOn = "<>()\r\n\t"; // Allow spaces, since they can be in the filename
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);
}
// Bail if this isn't a lora.
if (!selectedText.startsWith("<lora:") || !selectedText.endsWith(">")) {
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);
};
}
}

View File

@@ -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<HTMLInputElement>(".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();
},
});

View File

@@ -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];
}
},
});

View File

@@ -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: "<svg></svg>",
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
: `<i class="mdi mdi-${iconOrMarkup}"></i>`;
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> | 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();
}
}

View File

@@ -0,0 +1,407 @@
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" },
],
},
],
},
],
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<HTMLSelectElement>(`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<HTMLSpanElement>(`span.rgthree-checklist-item`, {
children: [
$el<HTMLInputElement>(`input[type="checkbox"][value="${value}"]`, {
id,
checked: initialValueList.includes(value),
}),
$el<HTMLInputElement>(`label`, {
for: id,
text: label,
})
]
});
}),
});
} else {
input = $el<HTMLSelectElement>(`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<HTMLOptionElement>(`option[value="${valueSerialized}"]`, {
text: label,
selected: valueSerialized === JSON.stringify({ value: initialValue }),
});
}),
});
}
} else if (item.type === ConfigType.BOOLEAN) {
container.classList.toggle("-checked", !!initialValue);
input = $el<HTMLInputElement>(`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["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 + `<h2>Settings - rgthree-comfy</h2>`,
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 $$<HTMLInputElement>('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: `<div>${logoRgthree} [rgthree-comfy] configuration / settings</div>`,
}),
$el("td", {
child: $el('button.rgthree-button.-blue[text="rgthree-comfy settings"]', {
events: {
click: (e: PointerEvent) => {
new RgthreeConfigDialog().show();
},
},
}),
}),
],
});
},
});

View File

@@ -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();
}

View File

@@ -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);
}
}
},
});

View File

@@ -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: `<h2>Loading...</h2>`,
content: "<center>Loading..</center>",
onBeforeClose: () => {
return true;
},
};
super(dialogOptions);
this.init(file);
}
abstract getModelInfo(file: string): Promise<RgthreeModelInfo | null>;
abstract refreshModelInfo(file: string): Promise<RgthreeModelInfo | null>;
abstract clearModelInfo(file: string): Promise<RgthreeModelInfo | null>;
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 | <span role="button" data-action="copy-trained-words">Copy</span>`
: "";
// 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 = `
<ul class="rgthree-info-area">
<li title="Type" class="rgthree-info-tag -type -type-${(
info.type || ""
).toLowerCase()}"><span>${info.type || ""}</span></li>
<li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(
info.baseModel || ""
).toLowerCase()}"><span>${info.baseModel || ""}</span></li>
<li class="rgthree-info-menu" stub="menu"></li>
${
""
// !civitaiLink
// ? ""
// : `
// <li title="Visit on Civitai" class="-link -civitai"><a href="${civitaiLink}" target="_blank">Civitai ${link}</a></li>
// `
}
</ul>
<table class="rgthree-info-table">
${infoTableRow("File", info.file || "")}
${infoTableRow("Hash (sha256)", info.sha256 || "")}
${
civitaiLink
? infoTableRow(
"Civitai",
`<a href="${civitaiLink}" target="_blank">${logoCivitai}View on Civitai</a>`,
)
: info.raw?.civitai?.error === "Model not found"
? infoTableRow(
"Civitai",
'<i>Model not found</i> <span class="-help" title="The model was not found on civitai with the sha256 hash. It\'s possible the model was removed, re-uploaded, or was never on civitai to begin with."></span>',
)
: info.raw?.civitai?.error
? infoTableRow("Civitai", info.raw?.civitai?.error)
: !info.raw?.civitai
? infoTableRow(
"Civitai",
`<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`,
)
: ""
}
${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",
)}
</table>
<ul class="rgthree-info-images">${
info.images
?.map(
(img) => `
<li>
<figure>${
img.type === 'video'
? `<video src="${img.url}" autoplay loop></video>`
: `<img src="${img.url}" />`
}
<figcaption><!--
-->${imgInfoField(
"",
img.civitaiUrl
? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a>`
: 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)}<!--
--><!--${
""
// img.resources?.length
// ? `
// <tr><td>Resources</td><td><ul>
// ${(img.resources || [])
// .map(
// (r) => `
// <li>[${r.type || ""}] ${r.name || ""} ${
// r.weight != null ? `@ ${r.weight}` : ""
// }</li>
// `,
// )
// .join("")}
// </ul></td></tr>
// `
// : ""
}--></figcaption>
</figure>
</li>`,
)
.join("") ?? ""
}</ul>
`;
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 `
<tr class="${editableFieldName ? "editable" : ""}" ${
editableFieldName ? `data-field-name="${editableFieldName}"` : ""
}>
<td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
<td ${editableFieldName ? "" : 'colspan="2"'}>${
String(value).startsWith("<") ? value : `<span>${value}<span>`
}</td>
${
editableFieldName
? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>`
: ""
}
</tr>`;
}
function getTrainedWordsMarkup(words: RgthreeModelInfo["trainedWords"]) {
let markup = `<ul class="rgthree-info-trained-words-list">`;
for (const wordData of words || []) {
markup += `<li title="${wordData.word}" data-word="${
wordData.word
}" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
<span>${wordData.word}</span>
${wordData.civitai ? logoCivitai : ""}
${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
</li>`;
}
markup += `</ul>`;
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<HTMLInputElement>("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 ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
}

View File

@@ -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);
// }
// }
// },
});

View File

@@ -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(
"<span class='name'>Name</span><input autofocus type='text'/><button>OK</button>",
{},
);
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);
}
},
});

View File

@@ -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);
}
});
}
}

View File

@@ -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);
}
},
});

View File

@@ -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<IWidget, {comfy?: ComfyApp; node?: LGraphNode}>();
readonly nodeIdtoFunctionCache = new Map<number, string>();
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];
}
},
});

View File

@@ -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;
}
},
});

View File

@@ -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 `
<p>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.</p>
<ul>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>${PROPERTY_MATCH_COLORS}</code> - 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.
</p></li>
<li><p>
<code>${PROPERTY_MATCH_TITLE}</code> - Filter the list of toggles by title match
(string match, or regular expression).
</p></li>
<li><p>
<code>${PROPERTY_SHOW_NAV}</code> - Add / remove a quick navigation arrow to take you
to the group. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SHOW_ALL_GRAPHS}</code> - Show groups from all [sub]graphs in the
workflow. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SORT}</code> - Sort the toggles' order by "alphanumeric", graph
"position", or "custom alphabet". <i>(default: "position")</i>
</p></li>
<li>
<p>
<code>${PROPERTY_SORT_CUSTOM_ALPHA}</code> - When the
<code>${PROPERTY_SORT}</code> property is "custom alphabet" you can define the
alphabet to use here, which will match the <i>beginning</i> 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.
</p>
<p>
This can be a list of single characters, like "zyxw..." or comma delimited strings
for more control, like "sdxl,pro,sd,n,p".
</p>
<p>
Note, when two group title match the same custom alphabet entry, the <i>normal
alphanumeric alphabet</i> 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").
</p>
<p>
To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the
custom alphabet value of "se,s,f" would work here.
</p>
</li>
<li><p>
<code>${PROPERTY_RESTRICTION}</code> - Optionally, attempt to restrict the number of
widgets that can be enabled to a maximum of one, or always one.
</p>
<p><em><strong>Note:</strong> 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.
</p></li>
</ul>
</li>
</ul>`;
}
}
/**
* 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;
}
},
});

View File

@@ -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
`;
}

View File

@@ -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;
}

View File

@@ -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<typeof Image>[] = [];
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 `
<p>
The ${this.type!.replace("(rgthree)", "")} node compares two images on top of each other.
</p>
<ul>
<li>
<p>
<strong>Notes</strong>
</p>
<ul>
<li><p>
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.
</p></li>
</ul>
</li>
<li>
<p>
<strong>Inputs</strong>
</p>
<ul>
<li><p>
<code>image_a</code> <i>Optional.</i> The first image to use to compare.
image_a.
</p></li>
<li><p>
<code>image_b</code> <i>Optional.</i> The second image to use to compare.
</p></li>
<li><p>
<b>Note</b> <code>image_a</code> and <code>image_b</code> work best when a single
image is provided. However, if each/either are a batch, you can choose which item
from each batch are chosen to be compared. If either <code>image_a</code> or
<code>image_b</code> are not provided, the node will choose the first two from the
provided input if it's a batch, otherwise only show the single image (just as
Preview Image would).
</p></li>
</ul>
</li>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>comparer_mode</code> - Choose between "Slide" and "Click". Defaults to "Slide".
</p></li>
</ul>
</li>
</ul>`;
}
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<RgthreeImageComparerWidgetValue> {
override readonly type = "custom";
private node: RgthreeImageComparer;
protected override hitAreas: RgthreeBaseHitAreas<any> = {
// 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<RgthreeImageComparerWidgetValue> {
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);
}
},
});

View File

@@ -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<void> {
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);
}
},
});

View File

@@ -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);
}
},
});

View File

@@ -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 `
<p>
The rgthree-comfy ${this.type!.replace("(rgthree)", "")} node allows you to add a floating
label to your workflow.
</p>
<p>
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.
<p>
<ul>
<li>
<p>
<strong>Pro tip #1:</strong> You can add multiline text from the properties panel
<i>(because ComfyUI let's you shift + enter there, only)</i>.
</p>
</li>
<li>
<p>
<strong>Pro tip #2:</strong> 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.
</p>
</li>
<li>
<p>
<strong>Pro tip #3:</strong> 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.
</p>
</li>
</ul>`;
}
}
/**
* 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();
},
});

View File

@@ -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<string, {rgthree_doNotNest: boolean}>,
) {
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;
};
},
});

View File

@@ -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<boolean>(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<unknown> | null)[],
): (IContextMenuValue<unknown> | 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 [];
};
}
}
},
});

View File

@@ -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<unknown> | 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) &nbsp;`,
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<unknown> | 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) &nbsp;`,
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<unknown> | null)[],
): (IContextMenuValue<unknown> | 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;
};
},
});

View File

@@ -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];
}
},
});

View File

@@ -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<unknown> | null)[],
): (IContextMenuValue<unknown> | 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;
},
});

View File

@@ -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 `
<p>
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).
</p>
<ul>
<li><p>
When all connected input nodes are muted, the relay will set a connected repeater to
mute (by default).
</p></li>
<li><p>
When all connected input nodes are bypassed, the relay will set a connected repeater to
bypass (by default).
</p></li>
<li><p>
When any connected input nodes are active, the relay will set a connected repeater to
active (by default).
</p></li>
<li><p>
If no inputs are connected, the relay will set a connected repeater to its mode <i>when
its own mode is changed</i>. <b>Note</b>, if any inputs are connected, then the above
will occur and the Relay's mode does not matter.
</p></li>
</ul>
<p>
Note, you can change which signals get sent on the above in the <code>Properties</code>.
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.
</p>
`;
}
}
app.registerExtension({
name: "rgthree.NodeModeRepeaterHelper",
registerCustomNodes() {
addConnectionLayoutSupport(NodeModeRelay, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(NodeModeRelay.type, NodeModeRelay);
NodeModeRelay.category = NodeModeRelay._category;
},
});

View File

@@ -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 `
<p>
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.
</p>
<ul>
<li><p>
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.
</p></li>
<li><p>
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).
</p></li>
</ul>
`;
}
}
app.registerExtension({
name: "rgthree.NodeModeRepeater",
registerCustomNodes() {
addConnectionLayoutSupport(NodeModeRepeater, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(NodeModeRepeater.type, NodeModeRepeater);
NodeModeRepeater.category = NodeModeRepeater._category;
},
});

View File

@@ -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();
}
},
});

View File

@@ -0,0 +1,851 @@
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 {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_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],
};
/** 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;
// 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) => {
rgthreeApi.getLoras().then((lorasDetails) => {
const loras = lorasDetails.map((l) => l.file);
showLoraChooser(
event as MouseEvent,
(value: IContextMenuValue | string) => {
if (typeof 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]);
},
null,
[...loras],
);
});
return true;
},
),
);
}
/**
* 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 `
<p>
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.
</p>
<ul>
<li><p>
Add as many Lora's as you would like by clicking the "+ Add Lora" button.
There's no real limit!
</p></li>
<li><p>
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.
</p></li>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>${PROP_LABEL_SHOW_STRENGTHS}</code> - 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.
</p></li>
</ul>
</li>
</ul>`;
}
}
/**
* 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<PowerLoraLoaderWidgetValue> {
override readonly type = "custom";
/** Whether the strength has changed with mouse move (to cancel mouse up). */
private haveMouseMovedStrength = false;
private loraInfoPromise: Promise<RgthreeModelInfo | null> | 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 ((this.hitAreas as any)["info"]) {
rposX -= innerMargin;
drawInfoIcon(ctx, rposX - infoIconSize, posY + (height - infoIconSize) / 2, infoIconSize);
// ctx.fillText('', posX, midY);
(this.hitAreas as any).info.bounds = [rposX - infoIconSize, infoWidth];
rposX = rposX - infoIconSize - innerMargin;
}
// 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<PowerLoraLoaderWidgetValue> {
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: TLGraphNode) {
showLoraChooser(event, (value: IContextMenuValue) => {
if (typeof value === "string") {
this.value.lora = value;
this.loraInfo = null;
this.getLoraInfo();
}
node.setDirtyCanvas(true, true);
});
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);
}
},
});

View File

@@ -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<unknown> | 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);
}
},
});

View File

@@ -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);
}
},
});

View File

@@ -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 `
<p>
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.
</p>
<ul>
<li><p>
Evaluate almost any kind of input and more, and choose your output from INT, FLOAT,
STRING, or BOOLEAN.
</p></li>
<li><p>
Connect some nodes and do simply math operations like <code>a + b</code> or
<code>ceil(1 / 2)</code>.
</p></li>
<li><p>
Or do more advanced things, like input an image, and get the width like
<code>a.shape[2]</code>.
</p></li>
<li><p>
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
<code>', '.join([v.lora for v in node(5).inputs.values() if 'lora' in v and v.on])</code>.
</p></li>
<li><p>
See more at the <a target="_blank"
href="https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter">rgthree-comfy
wiki</a>.
</p></li>
</ul>`;
}
}
/** 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<OutputsWidgetValue> {
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<null | string> = [...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);
}
},
});

View File

@@ -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 `
<p>
Use this node to unmute on of its inputs randomly when the graph is queued (and, immediately
mute it back).
</p>
<ul>
<li><p>
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.)
</p></li>
<li><p>
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.
</p></li>
</ul>
`;
}
}
app.registerExtension({
name: "rgthree.RandomUnmuter",
registerCustomNodes() {
RandomUnmuterNode.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == RandomUnmuterNode.title) {
(node as any)._tempWidth = node.size[0];
}
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -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%;
}

Some files were not shown because too many files have changed in this diff Show More