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
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:
13
custom_nodes/rgthree-comfy/.gitignore
vendored
Normal file
13
custom_nodes/rgthree-comfy/.gitignore
vendored
Normal 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/
|
||||
21
custom_nodes/rgthree-comfy/LICENSE
Normal file
21
custom_nodes/rgthree-comfy/LICENSE
Normal 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.
|
||||
434
custom_nodes/rgthree-comfy/README.md
Normal file
434
custom_nodes/rgthree-comfy/README.md
Normal 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> | <a href="#-improvements--features">Improvements & Features</a> | <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 :)
|
||||
|
||||

|
||||
|
||||
# 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.
|
||||
>
|
||||
> 
|
||||
> </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.
|
||||
>
|
||||
> 
|
||||
> </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.
|
||||
>
|
||||
> 
|
||||
> </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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
151
custom_nodes/rgthree-comfy/__build__.py
Normal file
151
custom_nodes/rgthree-comfy/__build__.py
Normal 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')
|
||||
55
custom_nodes/rgthree-comfy/__commit__.py
Normal file
55
custom_nodes/rgthree-comfy/__commit__.py
Normal 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')
|
||||
126
custom_nodes/rgthree-comfy/__init__.py
Normal file
126
custom_nodes/rgthree-comfy/__init__.py
Normal 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)
|
||||
57
custom_nodes/rgthree-comfy/__update_comfy__.py
Normal file
57
custom_nodes/rgthree-comfy/__update_comfy__.py
Normal 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))
|
||||
BIN
custom_nodes/rgthree-comfy/docs/rgthree_advanced.png
Normal file
BIN
custom_nodes/rgthree-comfy/docs/rgthree_advanced.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
BIN
custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png
Normal file
BIN
custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
BIN
custom_nodes/rgthree-comfy/docs/rgthree_context.png
Normal file
BIN
custom_nodes/rgthree-comfy/docs/rgthree_context.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 531 KiB |
BIN
custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png
Normal file
BIN
custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
custom_nodes/rgthree-comfy/docs/rgthree_router.png
Normal file
BIN
custom_nodes/rgthree-comfy/docs/rgthree_router.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
custom_nodes/rgthree-comfy/docs/rgthree_seed.png
Normal file
BIN
custom_nodes/rgthree-comfy/docs/rgthree_seed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
568
custom_nodes/rgthree-comfy/package-lock.json
generated
Normal file
568
custom_nodes/rgthree-comfy/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
custom_nodes/rgthree-comfy/package.json
Normal file
12
custom_nodes/rgthree-comfy/package.json
Normal 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
507
custom_nodes/rgthree-comfy/pnpm-lock.yaml
generated
Normal 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: {}
|
||||
5
custom_nodes/rgthree-comfy/prestartup_script.py
Normal file
5
custom_nodes/rgthree-comfy/prestartup_script.py
Normal 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']))
|
||||
0
custom_nodes/rgthree-comfy/py/__init__.py
Normal file
0
custom_nodes/rgthree-comfy/py/__init__.py
Normal file
38
custom_nodes/rgthree-comfy/py/any_switch.py
Normal file
38
custom_nodes/rgthree-comfy/py/any_switch.py
Normal 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,)
|
||||
111
custom_nodes/rgthree-comfy/py/config.py
Normal file
111
custom_nodes/rgthree-comfy/py/config.py
Normal 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()
|
||||
11
custom_nodes/rgthree-comfy/py/constants.py
Normal file
11
custom_nodes/rgthree-comfy/py/constants.py
Normal 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)
|
||||
33
custom_nodes/rgthree-comfy/py/context.py
Normal file
33
custom_nodes/rgthree-comfy/py/context.py
Normal 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)
|
||||
31
custom_nodes/rgthree-comfy/py/context_big.py
Normal file
31
custom_nodes/rgthree-comfy/py/context_big.py
Normal 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)
|
||||
37
custom_nodes/rgthree-comfy/py/context_merge.py
Normal file
37
custom_nodes/rgthree-comfy/py/context_merge.py
Normal 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)
|
||||
16
custom_nodes/rgthree-comfy/py/context_merge_big.py
Normal file
16
custom_nodes/rgthree-comfy/py/context_merge_big.py
Normal 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)
|
||||
36
custom_nodes/rgthree-comfy/py/context_switch.py
Normal file
36
custom_nodes/rgthree-comfy/py/context_switch.py
Normal 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)
|
||||
16
custom_nodes/rgthree-comfy/py/context_switch_big.py
Normal file
16
custom_nodes/rgthree-comfy/py/context_switch_big.py
Normal 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)
|
||||
118
custom_nodes/rgthree-comfy/py/context_utils.py
Normal file
118
custom_nodes/rgthree-comfy/py/context_utils.py
Normal 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())
|
||||
77
custom_nodes/rgthree-comfy/py/display_any.py
Normal file
77
custom_nodes/rgthree-comfy/py/display_any.py
Normal 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,)}}
|
||||
56
custom_nodes/rgthree-comfy/py/dynamic_context.py
Normal file
56
custom_nodes/rgthree-comfy/py/dynamic_context.py
Normal 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)
|
||||
39
custom_nodes/rgthree-comfy/py/dynamic_context_switch.py
Normal file
39
custom_nodes/rgthree-comfy/py/dynamic_context_switch.py
Normal 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)
|
||||
42
custom_nodes/rgthree-comfy/py/image_comparer.py
Normal file
42
custom_nodes/rgthree-comfy/py/image_comparer.py
Normal 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
|
||||
93
custom_nodes/rgthree-comfy/py/image_inset_crop.py
Normal file
93
custom_nodes/rgthree-comfy/py/image_inset_crop.py
Normal 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,)
|
||||
31
custom_nodes/rgthree-comfy/py/image_or_latent_size.py
Normal file
31
custom_nodes/rgthree-comfy/py/image_or_latent_size.py
Normal 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)
|
||||
117
custom_nodes/rgthree-comfy/py/image_resize.py
Normal file
117
custom_nodes/rgthree-comfy/py/image_resize.py
Normal 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])
|
||||
56
custom_nodes/rgthree-comfy/py/ksampler_config.py
Normal file
56
custom_nodes/rgthree-comfy/py/ksampler_config.py
Normal 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,
|
||||
)
|
||||
100
custom_nodes/rgthree-comfy/py/log.py
Normal file
100
custom_nodes/rgthree-comfy/py/log.py
Normal 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)
|
||||
46
custom_nodes/rgthree-comfy/py/lora_stack.py
Normal file
46
custom_nodes/rgthree-comfy/py/lora_stack.py
Normal 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)
|
||||
|
||||
101
custom_nodes/rgthree-comfy/py/power_lora_loader.py
Normal file
101
custom_nodes/rgthree-comfy/py/power_lora_loader.py
Normal 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
|
||||
83
custom_nodes/rgthree-comfy/py/power_primitive.py
Normal file
83
custom_nodes/rgthree-comfy/py/power_primitive.py
Normal 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,)
|
||||
95
custom_nodes/rgthree-comfy/py/power_prompt.py
Normal file
95
custom_nodes/rgthree-comfy/py/power_prompt.py
Normal 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)
|
||||
42
custom_nodes/rgthree-comfy/py/power_prompt_simple.py
Normal file
42
custom_nodes/rgthree-comfy/py/power_prompt_simple.py
Normal 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)
|
||||
|
||||
104
custom_nodes/rgthree-comfy/py/power_prompt_utils.py
Normal file
104
custom_nodes/rgthree-comfy/py/power_prompt_utils.py
Normal 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
|
||||
842
custom_nodes/rgthree-comfy/py/power_puter.py
Normal file
842
custom_nodes/rgthree-comfy/py/power_puter.py
Normal 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)
|
||||
70
custom_nodes/rgthree-comfy/py/pyproject.py
Normal file
70
custom_nodes/rgthree-comfy/py/pyproject.py
Normal 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
|
||||
63
custom_nodes/rgthree-comfy/py/sdxl_empty_latent_image.py
Normal file
63
custom_nodes/rgthree-comfy/py/sdxl_empty_latent_image.py
Normal 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),
|
||||
)
|
||||
178
custom_nodes/rgthree-comfy/py/sdxl_power_prompt_postive.py
Normal file
178
custom_nodes/rgthree-comfy/py/sdxl_power_prompt_postive.py
Normal 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
|
||||
106
custom_nodes/rgthree-comfy/py/sdxl_power_prompt_simple.py
Normal file
106
custom_nodes/rgthree-comfy/py/sdxl_power_prompt_simple.py
Normal 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)
|
||||
123
custom_nodes/rgthree-comfy/py/seed.py
Normal file
123
custom_nodes/rgthree-comfy/py/seed.py
Normal 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,)
|
||||
48
custom_nodes/rgthree-comfy/py/server/rgthree_server.py
Normal file
48
custom_nodes/rgthree-comfy/py/server/rgthree_server.py
Normal 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({})
|
||||
67
custom_nodes/rgthree-comfy/py/server/routes_config.py
Normal file
67
custom_nodes/rgthree-comfy/py/server/routes_config.py
Normal 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)
|
||||
199
custom_nodes/rgthree-comfy/py/server/routes_model_info.py
Normal file
199
custom_nodes/rgthree-comfy/py/server/routes_model_info.py
Normal 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
|
||||
452
custom_nodes/rgthree-comfy/py/server/utils_info.py
Normal file
452
custom_nodes/rgthree-comfy/py/server/utils_info.py
Normal 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)
|
||||
56
custom_nodes/rgthree-comfy/py/server/utils_server.py
Normal file
56
custom_nodes/rgthree-comfy/py/server/utils_server.py
Normal 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)
|
||||
168
custom_nodes/rgthree-comfy/py/utils.py
Normal file
168
custom_nodes/rgthree-comfy/py/utils.py
Normal 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)
|
||||
50
custom_nodes/rgthree-comfy/py/utils_userdata.py
Normal file
50
custom_nodes/rgthree-comfy/py/utils_userdata.py
Normal 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
|
||||
14
custom_nodes/rgthree-comfy/pyproject.toml
Normal file
14
custom_nodes/rgthree-comfy/pyproject.toml
Normal 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"
|
||||
0
custom_nodes/rgthree-comfy/requirements.txt
Normal file
0
custom_nodes/rgthree-comfy/requirements.txt
Normal file
65
custom_nodes/rgthree-comfy/rgthree_config.json.default
Normal file
65
custom_nodes/rgthree-comfy/rgthree_config.json.default
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
103
custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts
Normal file
103
custom_nodes/rgthree-comfy/src_web/comfyui/any_switch.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
504
custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts
Normal file
504
custom_nodes/rgthree-comfy/src_web/comfyui/base_node.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
366
custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts
Normal file
366
custom_nodes/rgthree-comfy/src_web/comfyui/base_power_prompt.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
163
custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts
Normal file
163
custom_nodes/rgthree-comfy/src_web/comfyui/bookmark.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
52
custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts
Normal file
52
custom_nodes/rgthree-comfy/src_web/comfyui/bypasser.ts
Normal 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];
|
||||
}
|
||||
},
|
||||
});
|
||||
264
custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts
Normal file
264
custom_nodes/rgthree-comfy/src_web/comfyui/comfy_ui_bar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
407
custom_nodes/rgthree-comfy/src_web/comfyui/config.ts
Normal file
407
custom_nodes/rgthree-comfy/src_web/comfyui/config.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
72
custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts
Normal file
72
custom_nodes/rgthree-comfy/src_web/comfyui/constants.ts
Normal 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();
|
||||
}
|
||||
488
custom_nodes/rgthree-comfy/src_web/comfyui/context.ts
Normal file
488
custom_nodes/rgthree-comfy/src_web/comfyui/context.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
436
custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts
Normal file
436
custom_nodes/rgthree-comfy/src_web/comfyui/dialog_info.ts
Normal 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>` : "";
|
||||
}
|
||||
71
custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts
Normal file
71
custom_nodes/rgthree-comfy/src_web/comfyui/display_any.ts
Normal 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);
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
});
|
||||
302
custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts
Normal file
302
custom_nodes/rgthree-comfy/src_web/comfyui/dynamic_context.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
542
custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts
Normal file
542
custom_nodes/rgthree-comfy/src_web/comfyui/fast_groups_muter.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
480
custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts
Normal file
480
custom_nodes/rgthree-comfy/src_web/comfyui/image_comparer.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
229
custom_nodes/rgthree-comfy/src_web/comfyui/label.ts
Normal file
229
custom_nodes/rgthree-comfy/src_web/comfyui/label.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
156
custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts
Normal file
156
custom_nodes/rgthree-comfy/src_web/comfyui/menu_auto_nest.ts
Normal 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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 [];
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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) `,
|
||||
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) `,
|
||||
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;
|
||||
};
|
||||
},
|
||||
});
|
||||
51
custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts
Normal file
51
custom_nodes/rgthree-comfy/src_web/comfyui/muter.ts
Normal 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];
|
||||
}
|
||||
},
|
||||
});
|
||||
168
custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts
Normal file
168
custom_nodes/rgthree-comfy/src_web/comfyui/node_collector.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
280
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts
Normal file
280
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_relay.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
216
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts
Normal file
216
custom_nodes/rgthree-comfy/src_web/comfyui/node_mode_repeater.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
137
custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts
Normal file
137
custom_nodes/rgthree-comfy/src_web/comfyui/power_conductor.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
851
custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts
Normal file
851
custom_nodes/rgthree-comfy/src_web/comfyui/power_lora_loader.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
254
custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts
Normal file
254
custom_nodes/rgthree-comfy/src_web/comfyui/power_primitive.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
48
custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts
Normal file
48
custom_nodes/rgthree-comfy/src_web/comfyui/power_prompt.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
461
custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts
Normal file
461
custom_nodes/rgthree-comfy/src_web/comfyui/power_puter.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
117
custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts
Normal file
117
custom_nodes/rgthree-comfy/src_web/comfyui/random_unmuter.ts
Normal 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];
|
||||
}
|
||||
},
|
||||
});
|
||||
1285
custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts
Normal file
1285
custom_nodes/rgthree-comfy/src_web/comfyui/reroute.ts
Normal file
File diff suppressed because it is too large
Load Diff
328
custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss
Normal file
328
custom_nodes/rgthree-comfy/src_web/comfyui/rgthree.scss
Normal 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
Reference in New Issue
Block a user