new comfyUI

This commit is contained in:
salt
2024-02-29 14:21:08 -08:00
parent 9aad2f3d72
commit e2bcd5bc60
3080 changed files with 949729 additions and 9 deletions
@@ -0,0 +1,3 @@
__pycache__
pysssss.json
user/autocomplete.txt
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 pythongosssss
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.
@@ -0,0 +1,394 @@
# ComfyUI-Custom-Scripts
# Installation
1. Clone the repository:
`git clone https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git`
to your ComfyUI `custom_nodes` directory
The script will then automatically install all custom scripts and nodes.
It will attempt to use symlinks and junctions to prevent having to copy files and keep them up to date.
- For uninstallation:
- Delete the cloned repo in `custom_nodes`
- Ensure `web/extensions/pysssss/CustomScripts` has also been removed
# Update
1. Navigate to the cloned repo e.g. `custom_nodes/ComfyUI-Custom-Scripts`
2. `git pull`
# Features
## Autocomplete
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/b5971135-414f-4f4e-a6cf-2650dc01085f)
Provides embedding and custom word autocomplete. You can view embedding details by clicking on the info icon on the list.
Define your list of custom words via the settings.
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/160ef61c-7d7e-49d0-b60f-5a1501b74c9d)
You can quickly default to danbooru tags using the Load button, or load/manage other custom word lists.
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/cc180b35-5f45-442f-9285-3ddf3fa320d0)
## Auto Arrange Graph
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/04b06081-ca6f-4c0f-8584-d0a157c36747)
Adds a menu option to auto arrange the graph in order of execution, this makes very wide graphs!
## Always Snap to Grid
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/66f36d1f-e579-4959-9880-9a9624922e3a)
Adds a setting to make moving nodes always snap to grid.
## [Testing] "Better" Loader Lists
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/664caa71-f25f-4a96-a04a-1466d6b2b8b4)
Adds custom Lora and Checkpoint loader nodes, these have the ability to show preview images, just place a png or jpg next to the file and it'll display in the list on hover (e.g. sdxl.safetensors and sdxl.png).
Optionally enable subfolders via the settings:
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/e15b5e83-4f9d-4d57-8324-742bedf75439)
Adds an "examples" widget to load sample prompts, triggerwords, etc:
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ad1751e4-4c85-42e7-9490-e94fb1cbc8e7)
These should be stored in a folder matching the name of the model, e.g. if it is `loras/add_detail.safetensors` put your files in as `loras/add_detail/*.txt`
To quickly save a generated image as the preview to use for the model, you can right click on an image on a node, and select Save as Preview and choose the model to save the preview for:
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/9fa8e9db-27b3-45cb-85c2-0860a238fd3a)
## Checkpoint/LoRA/Embedding Info
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/6b67bf40-ee17-4fa6-a0c1-7947066bafc2)
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/32405df6-b367-404f-a5df-2d4347089a9e)
Adds "View Info" menu option to view details about the selected LoRA or Checkpoint. To view embedding details, click the info button when using embedding autocomplete.
## Constrain Image
Adds a node for resizing an image to a max & min size optionally cropping if required.
## Custom Colors
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/fa7883f3-f81c-49f6-9ab6-9526e4debab6)
Adds a custom color picker to nodes & groups
## Favicon Status
![image](https://user-images.githubusercontent.com/125205205/230171227-31f061a6-6324-4976-bed9-723a87500cf3.png)
![image](https://user-images.githubusercontent.com/125205205/230171445-c7202a45-b511-4d69-87fa-945ad44c063f.png)
Adds a favicon and title to the window, favicon changes color while generating and the window title includes the number of prompts in the queue
## Image Feed
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/caea0d48-85b9-4ca9-9771-5c795db35fbc)
Adds a panel showing images that have been generated in the current session, you can control the direction that images are added and the position of the panel via the ComfyUI settings screen and the size of the panel and the images via the sliders at the top of the panel.
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ca093d38-41a3-4647-9223-5bd0b9ee4f1e)
## KSampler (Advanced) denoise helper
Provides a simple method to set custom denoise on the advanced sampler
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/42946bd8-0078-4c7a-bfe9-7adb1382b5e2)
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7cfccb22-f155-4848-934b-a2b2a6efe16f)
## Lock Nodes & Groups
![image](https://user-images.githubusercontent.com/125205205/230172868-5c5a943c-ade1-4799-bf80-cc931da5d4b2.png)
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/cfca09d9-38e5-4ecd-8b73-1455009fcd67)
Adds a lock option to nodes & groups that prevents you from moving them until unlocked
## Math Expression
Allows for evaluating complex expressions using values from the graph. You can input `INT`, `FLOAT`, `IMAGE` and `LATENT` values.
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/1593edde-67b8-45d8-88cb-e75f52dba039)
Other nodes values can be referenced via the `Node name for S&R` via the `Properties` menu item on a node, or the node title.
Supported operators: `+ - * /` (basic ops) `//` (floor division) `**` (power) `^` (xor) `%` (mod)
Supported functions `floor(num, dp?)` `floor(num)` `ceil(num)` `randomint(min,max)`
If using a `LATENT` or `IMAGE` you can get the dimensions using `a.width` or `a.height` where `a` is the input name.
## Node Finder
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/177d2b67-acbc-4ec3-ab31-7c295a98c194)
Adds a menu item for following/jumping to the executing node, and a menu to quickly go to a node of a specific type.
## Preset Text
![image](https://user-images.githubusercontent.com/125205205/230173939-08459efc-785b-46da-93d1-b02f0300c6f4.png)
Adds a node that lets you save and use text presets (e.g. for your 'normal' negatives)
## Quick Nodes
![image](https://user-images.githubusercontent.com/125205205/230174266-5232831a-a03b-4bf7-bc8b-c45466a0bc64.png)
Adds various menu items to some nodes for quickly setting up common parts of graphs
## Play Sound
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/9bcf9fb3-5898-4432-a974-fb1e17d3b7e8)
Plays a sound when the node is executed, either after each prompt or only when the queue is empty for queuing multiple prompts.
You can customize the sound by replacing the mp3 file `web/extensions/pysssss/CustomScripts/assets\notify.mp3`
## [WIP] Repeater
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ec0dac25-14e4-4d44-b975-52193656709d)
Node allows you to either create a list of N repeats of the input node, or create N outputs from the input node.
You can optionally decide if you want to reuse the input node, or create a new instance each time (e.g. a Checkpoint Loader would want to be re-used, but a random number would want to be unique)
TODO: Type safety on the wildcard outputs to require match with input
## Show Text
![image](https://user-images.githubusercontent.com/125205205/230174888-c004fd48-da78-4de9-81c2-93a866fcfcd1.png)
Takes input from a node that produces a string and displays it, useful for things like interrogator, prompt generators, etc.
## Show Image on Menu
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/b6ab58f2-583b-448c-bcfc-f93f5cdab0fc)
Shows the current generating image on the menu at the bottom, you can disable this via the settings menu.
## String Function
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/01107137-8a93-4765-bae0-fcc110a09091)
Supports appending and replacing text
`tidy_tags` will add commas between parts when in `append` mode.
`replace` mode supports regex replace by using `/your regex here/` and you can reference capturing groups using `\number` e.g. `\1`
## Touch Support
Provides basic support for touch screen devices, its not perfect but better than nothing
## Widget Defaults
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/3d675032-2b19-4da8-a7d7-fa2d7c555daa)
Allows you to specify default values for widgets when adding new nodes, the values are configured via the settings menu
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7b57a3d8-98d3-46e9-9b33-6645c0da41e7)
## Workflows
Adds options to the menu for saving + loading workflows:
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7b5a3012-4c59-47c6-8eea-85cf534403ea)
## Workflow Images
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/06453fd2-c020-46ee-a7db-2b8bf5bcba7e)
Adds menu options for importing/exporting the graph as SVG and PNG showing a view of the nodes
## (Testing) Reroute Primitive
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/8b870eef-d572-43f9-b394-cfa7abbd2f98) Provides a node that allows rerouting primitives.
The node can also be collapsed to a single point that you can drag around.
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/a9bd0112-cf8f-44f3-af6d-f9a8fed152a7)
Warning: Don't use normal reroutes or primitives with these nodes, it isn't tested and this node replaces their functionality.
<br>
<br>
## WD14 Tagger
Moved to: https://github.com/pythongosssss/ComfyUI-WD14-Tagger
## Link Render Mode
![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ad3be76b-43b1-455e-a64a-bf2a6571facf)
Allows you to control the rendering of the links between nodes between straight, linear & spline, e.g. Straight.
<br>
<br>
# Changelog
## 2023-09-22
### Minor
- ✨ Use Civitai image as preview
- 🐛 CTRL+Enter on autocomplete will no longer accept the suggestions as it is the shortcut for queuing a prompt.
- 🐛 Fix using numbers in widget defaults
- ✨ Support setting node properties (e.g. title, colors) via widget defaults
## 2023-09-13
### New
- ✨ Ability to "send" an image to a Load Image node in either the current or a different workflow
### Minor
- ✨ Add support for A1111 autocomplete CSV format
- ✨ Allow setting custom node for middle click to add node
## 2023-09-10
### Minor
- 🐛 Fix rendering new lines in workflow image exports
## 2023-09-08
### New
- ✨ Add Load + Save Text file nodes, you can configure the allowed directories in the `user/text_file_dirs.json` file
### Minor
- 🎨 Show autocomplete alias word on popup
- ✨ Add setting to disable middle click from adding a reroute node
- 🎨 Add prompt for setting custom column count on image feed (click the column count label)
## 2023-09-07
### New
- ✨ Support Unicode (e.g. Chinese) and word aliases in autocomplete.
## 2023-09-05
### Minor
- 🎨 Disable autocomplete on math node
- 🐛 Fix Show Text node always resizing on update
### Minor
- 🎨 Better adding of preview image to menu (thanks to @zeroeightysix)
- 🎨 UX improvements for image feed (thanks to @birdddev)
- 🐛 Fix Math Expression expression not showing on updated ComfyUI
-
## 2023-08-30
### Minor
- 🎨 Allow jpeg lora/checkpoint preview images
- ✨ Save ShowText value to embedded image metadata
## 2023-08-29
### Minor
- ✨ Option to auto insert `, ` after autocomplete
- 🎨 Exclude arrow keys from triggering autocomplete
- 🐛 Split paths by `\` and `/` on Windows for submenus
## 2023-08-28
### New
- ✨ Add custom autocomplete word list setting
- ✨ Support autocomplete word priority sorting
- ✨ Support autocomplete matching anywhere in word rather than requiring starts with
## 2023-08-27
### New
- ✨ Add Checkpoint info
- ✨ Add embedding autocomplete
- ✨ Add embedding info
### Major
- ♻️ Refactor LoRA info
## 2023-08-26
### Minor
- 🐛 Fix using text widget values in Math Expression not casting to number
- 🎨 Fix padding on lightbox next arrow
## 2023-08-25
### Minor
- ♻️ Support older versions of python
## 2023-08-24
### Minor
- 🐛 Fix extracting links from LoRA info notes
## 2023-08-23
### Major
- 🚨 Update to use `WEB_DIRECTORY` feature instead of manual linking/copying web files
## 2023-08-22
### New
- ✨ Math Expression now supports IMAGE and LATENT inputs, to access the dimensions use `a.width`, `b.height`
- 🎨 Removed STRING output on Math Expression, now draws the result onto the node
## 2023-08-21
### New
- ✨ Allow custom note (named {file}.txt) to show in LoRA info
- ✨ Query Civita API using the model hash to provide link
## 2023-08-20
### New
- ✨ Add LoRA Info menu option for displaying LoRA metadata
### Minor
- 🐛 Fix crash on preset text replacement (thanks to @sjuxax)
## 2023-08-19
### New
- ✨ Add support for importing JPG files with embedded metadata (e.g. from Civitai)
### Minor
- 🐛 Fix crash on graph arrange where LiteGraph sometimes stores links to deleted nodes
- 🐛 Fix a couple of rendering issues in workflow export
## 2023-08-18
### New
- ✨ Add "example" widget to custom LoRA + Checkpoint loader allowing you to quickly view saved prompts, triggers, etc
- ✨ Add quick "Save as Preview" option on images to save generated images for models
## 2023-08-16
### New
- ✨ Add repeater node for generating lists or quickly duplicating nodes
### Minor
- 🐛 Support quick Add LoRA on custom Checkpoint Loader
- ✨ Support `randomint(min,max)` function in math node
- 🎨 Use relative imports to support proxied urls not on root path (thanks to @mcmonkey4eva)
## 2023-08-13
### Minor
- ✨ Support `round` `floor` `ceil` functions in math node
- 🐛 Fix floor division in math node
## 2023-08-12
### New
- 🎨 Image feed now uses a lightbox for showing images
### Minor
- 🎨 Better loader lists now supports images named `{name}.preview.png`
## 2023-08-11
### Minor
- ✨ Enable filter box on submenus
## 2023-08-05
### Major
- 🚨 The ComfyUI Lora Loader no longer has subfolders, due to compatibility issues you need to use my Lora Loader if you want subfolers, these can be enabled/disabled on the node via a setting (🐍 Enable submenu in custom nodes)
### New
- ✨ Add custom Checkpoint Loader supporting images & subfolders
- ✨ Add Play Sound node for notifying when a prompt is finished
### Minor
- ✨ Quick Nodes supports new LoRA loader ("Add 🐍 LoRA")
- ♻️ Disable link render mode if ComfyUI has native support
## 2023-08-04
### Minor
- ✨ Always snap to grid now applies on node resize
- 🐛 Fix reroute primitive widget value not being restored on reload
- ✨ Workflows now reuse last filename from load & save - save must be done by the submenu
## 2023-08-02
### New
- ✨ Add "Always snap to grid" setting that does the same as holding shift, aligning nodes to the grid
### Minor
- 🚨 No longer populates image feed when its closed
- 🐛 Allow lock/unlock of multiple selected nodes
## 2023-08-01
### Minor
- 🎨 Image feed now uses comfy theme variables for colors
- 🐛 Link render mode redraws graph on change of setting instead of requiring mouse move
## 2023-07-30
- 🎨 Update to image feed to make more user friendly, change image size to column count, various other tweaks (thanks @DrJKL)
## 2023-07-30
### Major
- 🐛 Fix issue with context menu (right click) not working for some users after Lora script updates
### New
- ✨ Add "Custom" option to color menu for nodes & groups
### Minor
- 🐛 Fix String Function values converted to unconnected inputs outputting the text "undefined"
## 2023-07-29
### New
- ✨ Added Reroute Primitive combining the functionality of reroutes + primitives, also allowing collapsing to a single point.
- ✨ Add support for exporting workflow images as PNGs and optional embedding of metadata in PNG and SVG
### Minor
- ✨ Remove new lines in Math Expression node
- ✨ String function is now an output node
- 🐛 Fix conflict between Lora Loader + Lora submenu causing the context menu to be have strangely (#23, #24)
- 🎨 Rename "SVG -> Import/Export" to "Workflow Image" -> Import/Export
## 2023-07-27
### New
- ✨ Added custom Lora Loader that includes image previews
### Minor
- ✨ Add preview output to string function node
- 📄 Updated missing/out of date parts of readme
- 🐛 Fix crash on show image on menu when set to not show (thanks @DrJKL)
- 🐛 Fix incorrect category (util vs utils) for math node (thanks @DrJKL)
## 2023-07-27
### Minor
- ✨ Save Image Feed close state
- 🐛 Fix unlocked group size calculation
## 2023-07-21 + 22
### Minor
- 🐛 Fix preset text incompatibility with Impact Pack (thanks @ltdrdata)
## 2023-07-13
### New
- ✨ Add Math Expression node for evaluating expressions using values from the graph
### Minor
- ✨ Add settings for image feed location + image order
## 2023-06-27
### Minor
- 🐛 Fix unlocking group using incorrect size
- ✨ Save visibility of image feed
## 2023-06-18
### Major Changes
- ✨ Added auto installation of scripts and `__init__` (thanks @TashaSkyUp)
- ♻️ Reworked folder structure
- 🚨 Renamed a number of nodes to include `pysssss` to prevent name conflicts
- 🚨 Remove Latent Upscale By as it is now a built in node in ComfyUI
- 🚨 Removed Anime Segmentation to own repo
### New
- ✨ Add Link Render Mode setting to choose how links are rendered
- ✨ Add Constrain Image node for resizing nodes to a min/max resolution with optional cropping
- ✨ Add Show Image On Menu to include the latest image output on the menu
- ✨ Add KSamplerAdvanced simple denoise prompt for configuring the node using steps + denoise
- 🎨 Add sizing options to Image Feed
### Other
- ♻️ Include [canvas2svg](https://gliffy.github.io/canvas2svg/) for SVG export in assets to prevent downloading at runtime
- 🎨 Add background color (using theme color) to exported SVG
- 🐛 Fix Manage Widget Defaults to work with new ComfyUI settings dialog
- 🐛 Increase Image Feed z-index to prevent node text overlapping
@@ -0,0 +1,25 @@
import importlib.util
import glob
import os
import sys
from .pysssss import init, get_ext_dir
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
if init():
py = get_ext_dir("py")
files = glob.glob(os.path.join(py, "*.py"), recursive=False)
for file in files:
name = os.path.splitext(file)[0]
spec = importlib.util.spec_from_file_location(name, file)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None:
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -0,0 +1,29 @@
from server import PromptServer
from aiohttp import web
import os
import folder_paths
dir = os.path.abspath(os.path.join(__file__, "../../user"))
if not os.path.exists(dir):
os.mkdir(dir)
file = os.path.join(dir, "autocomplete.txt")
@PromptServer.instance.routes.get("/pysssss/autocomplete")
async def get_autocomplete(request):
if os.path.isfile(file):
return web.FileResponse(file)
return web.Response(status=404)
@PromptServer.instance.routes.post("/pysssss/autocomplete")
async def update_autocomplete(request):
with open(file, "w", encoding="utf-8") as f:
f.write(await request.text())
return web.Response(status=200)
@PromptServer.instance.routes.get("/pysssss/loras")
async def get_loras(request):
loras = folder_paths.get_filename_list("loras")
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras)))
@@ -0,0 +1,135 @@
import glob
import os
from nodes import LoraLoader, CheckpointLoaderSimple
import folder_paths
from server import PromptServer
from folder_paths import get_directory_by_type
from aiohttp import web
import shutil
@PromptServer.instance.routes.get("/pysssss/view/{name}")
async def view(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
image_path = folder_paths.get_full_path(
type, name)
if not image_path:
return web.Response(status=404)
filename = os.path.basename(image_path)
return web.FileResponse(image_path, headers={"Content-Disposition": f"filename=\"{filename}\""})
@PromptServer.instance.routes.post("/pysssss/save/{name}")
async def save_preview(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
body = await request.json()
dir = get_directory_by_type(body.get("type", "output"))
subfolder = body.get("subfolder", "")
full_output_folder = os.path.join(dir, os.path.normpath(subfolder))
if os.path.commonpath((dir, os.path.abspath(full_output_folder))) != dir:
return web.Response(status=400)
filepath = os.path.join(full_output_folder, body.get("filename", ""))
image_path = folder_paths.get_full_path(type, name)
image_path = os.path.splitext(
image_path)[0] + os.path.splitext(filepath)[1]
shutil.copyfile(filepath, image_path)
return web.json_response({
"image": type + "/" + os.path.basename(image_path)
})
@PromptServer.instance.routes.get("/pysssss/examples/{name}")
async def get_examples(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
file_path = folder_paths.get_full_path(
type, name)
if not file_path:
return web.Response(status=404)
file_path_no_ext = os.path.splitext(file_path)[0]
examples = []
if os.path.isdir(file_path_no_ext):
examples += map(lambda t: os.path.relpath(t, file_path_no_ext),
glob.glob(file_path_no_ext + "/*.txt"))
return web.json_response(examples)
def populate_items(names, type):
for idx, item_name in enumerate(names):
file_name = os.path.splitext(item_name)[0]
file_path = folder_paths.get_full_path(type, item_name)
if file_path is None:
print(f"(pysssss:better_combos) Unable to get path for {type} {item_name}")
continue
file_path_no_ext = os.path.splitext(file_path)[0]
for ext in ["png", "jpg", "jpeg", "preview.png"]:
has_image = os.path.isfile(file_path_no_ext + "." + ext)
if has_image:
item_image = f"{file_name}.{ext}"
break
names[idx] = {
"content": item_name,
"image": f"{type}/{item_image}" if has_image else None,
}
names.sort(key=lambda i: i["content"].lower())
class LoraLoaderWithImages(LoraLoader):
@classmethod
def INPUT_TYPES(s):
types = super().INPUT_TYPES()
names = types["required"]["lora_name"][0]
populate_items(names, "loras")
return types
def load_lora(self, **kwargs):
kwargs["lora_name"] = kwargs["lora_name"]["content"]
return super().load_lora(**kwargs)
class CheckpointLoaderSimpleWithImages(CheckpointLoaderSimple):
@classmethod
def INPUT_TYPES(s):
types = super().INPUT_TYPES()
names = types["required"]["ckpt_name"][0]
populate_items(names, "checkpoints")
return types
def load_checkpoint(self, **kwargs):
kwargs["ckpt_name"] = kwargs["ckpt_name"]["content"]
return super().load_checkpoint(**kwargs)
NODE_CLASS_MAPPINGS = {
"LoraLoader|pysssss": LoraLoaderWithImages,
"CheckpointLoader|pysssss": CheckpointLoaderSimpleWithImages,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoraLoader|pysssss": "Lora Loader 🐍",
"CheckpointLoader|pysssss": "Checkpoint Loader 🐍",
}
@@ -0,0 +1,71 @@
import torch
import numpy as np
from PIL import Image
class ConstrainImage:
"""
A node that constrains an image to a maximum and minimum size while maintaining aspect ratio.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"max_width": ("INT", {"default": 1024, "min": 0}),
"max_height": ("INT", {"default": 1024, "min": 0}),
"min_width": ("INT", {"default": 0, "min": 0}),
"min_height": ("INT", {"default": 0, "min": 0}),
"crop_if_required": (["yes", "no"], {"default": "no"}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "constrain_image"
CATEGORY = "image"
OUTPUT_IS_LIST = (True,)
def constrain_image(self, images, max_width, max_height, min_width, min_height, crop_if_required):
crop_if_required = crop_if_required == "yes"
results = []
for image in images:
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB")
current_width, current_height = img.size
aspect_ratio = current_width / current_height
constrained_width = max(min(current_width, min_width), max_width)
constrained_height = max(min(current_height, min_height), max_height)
if constrained_width / constrained_height > aspect_ratio:
constrained_width = max(int(constrained_height * aspect_ratio), min_width)
if crop_if_required:
constrained_height = int(current_height / (current_width / constrained_width))
else:
constrained_height = max(int(constrained_width / aspect_ratio), min_height)
if crop_if_required:
constrained_width = int(current_width / (current_height / constrained_height))
resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS)
if crop_if_required and (constrained_width > max_width or constrained_height > max_height):
left = max((constrained_width - max_width) // 2, 0)
top = max((constrained_height - max_height) // 2, 0)
right = min(constrained_width, max_width) + left
bottom = min(constrained_height, max_height) + top
resized_image = resized_image.crop((left, top, right, bottom))
resized_image = np.array(resized_image).astype(np.float32) / 255.0
resized_image = torch.from_numpy(resized_image)[None,]
results.append(resized_image)
return (results,)
NODE_CLASS_MAPPINGS = {
"ConstrainImage|pysssss": ConstrainImage,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ConstrainImage|pysssss": "Constrain Image 🐍",
}
@@ -0,0 +1,72 @@
import torch
import numpy as np
from PIL import Image
class ConstrainImageforVideo:
"""
A node that constrains an image to a maximum and minimum size while maintaining aspect ratio.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"max_width": ("INT", {"default": 1024, "min": 0}),
"max_height": ("INT", {"default": 1024, "min": 0}),
"min_width": ("INT", {"default": 0, "min": 0}),
"min_height": ("INT", {"default": 0, "min": 0}),
"crop_if_required": (["yes", "no"], {"default": "no"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "constrain_image_for_video"
CATEGORY = "image"
def constrain_image_for_video(self, images, max_width, max_height, min_width, min_height, crop_if_required):
crop_if_required = crop_if_required == "yes"
results = []
for image in images:
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB")
current_width, current_height = img.size
aspect_ratio = current_width / current_height
constrained_width = max(min(current_width, min_width), max_width)
constrained_height = max(min(current_height, min_height), max_height)
if constrained_width / constrained_height > aspect_ratio:
constrained_width = max(int(constrained_height * aspect_ratio), min_width)
if crop_if_required:
constrained_height = int(current_height / (current_width / constrained_width))
else:
constrained_height = max(int(constrained_width / aspect_ratio), min_height)
if crop_if_required:
constrained_width = int(current_width / (current_height / constrained_height))
resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS)
if crop_if_required and (constrained_width > max_width or constrained_height > max_height):
left = max((constrained_width - max_width) // 2, 0)
top = max((constrained_height - max_height) // 2, 0)
right = min(constrained_width, max_width) + left
bottom = min(constrained_height, max_height) + top
resized_image = resized_image.crop((left, top, right, bottom))
resized_image = np.array(resized_image).astype(np.float32) / 255.0
resized_image = torch.from_numpy(resized_image)[None,]
results.append(resized_image)
all_images = torch.cat(results, dim=0)
return (all_images, all_images.size(0),)
NODE_CLASS_MAPPINGS = {
"ConstrainImageforVideo|pysssss": ConstrainImageforVideo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ConstrainImageforVideo|pysssss": "Constrain Image for Video 🐍",
}
@@ -0,0 +1,190 @@
import ast
import math
import random
import operator as op
operators = {
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.Div: op.truediv,
ast.FloorDiv: op.floordiv,
ast.Pow: op.pow,
ast.BitXor: op.xor,
ast.USub: op.neg,
ast.Mod: op.mod
}
# TODO: restructure args to provide more info, generate hint based on args to save duplication
functions = {
"round": {
"args": (1, 2),
"call": lambda a, b = None: round(a, b),
"hint": "number, dp? = 0"
},
"ceil": {
"args": (1, 1),
"call": lambda a: math.ceil(a),
"hint": "number"
},
"floor": {
"args": (1, 1),
"call": lambda a: math.floor(a),
"hint": "number"
},
"min": {
"args": (2, None),
"call": lambda *args: min(*args),
"hint": "...numbers"
},
"max": {
"args": (2, None),
"call": lambda *args: max(*args),
"hint": "...numbers"
},
"randomint": {
"args": (2, 2),
"call": lambda a, b: random.randint(a, b),
"hint": "min, max"
},
"randomchoice": {
"args": (2, None),
"call": lambda *args: random.choice(args),
"hint": "...numbers"
},
}
autocompleteWords = list({
"text": x,
"value": f"{x}()",
"showValue": False,
"hint": f"{functions[x]['hint']}",
"caretOffset": -1
} for x in functions.keys())
class MathExpression:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"expression": ("STRING", {"multiline": True, "dynamicPrompts": False, "pysssss.autocomplete": {
"words": autocompleteWords,
"separator": ""
}}),
},
"optional": {
"a": ("INT,FLOAT,IMAGE,LATENT", ),
"b": ("INT,FLOAT,IMAGE,LATENT",),
"c": ("INT,FLOAT,IMAGE,LATENT", ),
},
"hidden": {"extra_pnginfo": "EXTRA_PNGINFO",
"prompt": "PROMPT"},
}
RETURN_TYPES = ("INT", "FLOAT", )
FUNCTION = "evaluate"
CATEGORY = "utils"
OUTPUT_NODE = True
@classmethod
def IS_CHANGED(s, expression, **kwargs):
if "random" in expression:
return float("nan")
return expression
def get_widget_value(self, extra_pnginfo, prompt, node_name, widget_name):
workflow = extra_pnginfo["workflow"] if "workflow" in extra_pnginfo else { "nodes": [] }
node_id = None
for node in workflow["nodes"]:
name = node["type"]
if "properties" in node:
if "Node name for S&R" in node["properties"]:
name = node["properties"]["Node name for S&R"]
if name == node_name:
node_id = node["id"]
break
if "title" in node:
name = node["title"]
if name == node_name:
node_id = node["id"]
break
if node_id is not None:
values = prompt[str(node_id)]
if "inputs" in values:
if widget_name in values["inputs"]:
return values["inputs"][widget_name]
raise NameError(f"Widget not found: {node_name}.{widget_name}")
raise NameError(f"Node not found: {node_name}.{widget_name}")
def get_size(self, target, property):
if isinstance(target, dict) and "samples" in target:
# Latent
if property == "width":
return target["samples"].shape[3] * 8
return target["samples"].shape[2] * 8
else:
# Image
if property == "width":
return target.shape[2]
return target.shape[1]
def evaluate(self, expression, prompt, extra_pnginfo={}, a=None, b=None, c=None):
expression = expression.replace('\n', ' ').replace('\r', '')
node = ast.parse(expression, mode='eval').body
lookup = {"a": a, "b": b, "c": c}
def eval_expr(node):
if isinstance(node, ast.Num):
return node.n
elif isinstance(node, ast.BinOp):
return operators[type(node.op)](float(eval_expr(node.left)), float(eval_expr(node.right)))
elif isinstance(node, ast.UnaryOp):
return operators[type(node.op)](eval_expr(node.operand))
elif isinstance(node, ast.Attribute):
if node.value.id in lookup:
if node.attr == "width" or node.attr == "height":
return self.get_size(lookup[node.value.id], node.attr)
return self.get_widget_value(extra_pnginfo, prompt, node.value.id, node.attr)
elif isinstance(node, ast.Name):
if node.id in lookup:
val = lookup[node.id]
if isinstance(val, (int, float, complex)):
return val
else:
raise TypeError(
f"Compex types (LATENT/IMAGE) need to reference their width/height, e.g. {node.id}.width")
raise NameError(f"Name not found: {node.id}")
elif isinstance(node, ast.Call):
if node.func.id in functions:
fn = functions[node.func.id]
l = len(node.args)
if l < fn["args"][0] or (fn["args"][1] is not None and l > fn["args"][1]):
if fn["args"][1] is None:
toErr = " or more"
else:
toErr = f" to {fn['args'][1]}"
raise SyntaxError(
f"Invalid function call: {node.func.id} requires {fn['args'][0]}{toErr} arguments")
args = []
for arg in node.args:
args.append(eval_expr(arg))
return fn["call"](*args)
raise NameError(f"Invalid function call: {node.func.id}")
else:
raise TypeError(node)
r = eval_expr(node)
return {"ui": {"value": [r]}, "result": (int(r), float(r),)}
NODE_CLASS_MAPPINGS = {
"MathExpression|pysssss": MathExpression,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MathExpression|pysssss": "Math Expression 🐍",
}
@@ -0,0 +1,115 @@
import hashlib
import json
from aiohttp import web
from server import PromptServer
import folder_paths
import os
def get_metadata(filepath):
with open(filepath, "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_size <= 0:
raise BufferError("Invalid header")
header_json = json.loads(header)
return header_json["__metadata__"] if "__metadata__" in header_json else None
@PromptServer.instance.routes.post("/pysssss/metadata/notes/{name}")
async def save_notes(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
file_path = None
if type == "embeddings" or type == "loras":
name = name.lower()
files = folder_paths.get_filename_list(type)
for f in files:
lower_f = f.lower()
if lower_f == name:
file_path = folder_paths.get_full_path(type, f)
else:
n = os.path.splitext(f)[0].lower()
if n == name:
file_path = folder_paths.get_full_path(type, f)
if file_path is not None:
break
else:
file_path = folder_paths.get_full_path(
type, name)
if not file_path:
return web.Response(status=404)
file_no_ext = os.path.splitext(file_path)[0]
info_file = file_no_ext + ".txt"
with open(info_file, "w") as f:
f.write(await request.text())
return web.Response(status=200)
@PromptServer.instance.routes.get("/pysssss/metadata/{name}")
async def load_metadata(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
file_path = None
if type == "embeddings" or type == "loras":
name = name.lower()
files = folder_paths.get_filename_list(type)
for f in files:
lower_f = f.lower()
if lower_f == name:
file_path = folder_paths.get_full_path(type, f)
else:
n = os.path.splitext(f)[0].lower()
if n == name:
file_path = folder_paths.get_full_path(type, f)
if file_path is not None:
break
else:
file_path = folder_paths.get_full_path(
type, name)
if not file_path:
return web.Response(status=404)
try:
meta = get_metadata(file_path)
except:
meta = None
if meta is None:
meta = {}
file_no_ext = os.path.splitext(file_path)[0]
info_file = file_no_ext + ".txt"
if os.path.isfile(info_file):
with open(info_file, "r") as f:
meta["pysssss.notes"] = f.read()
hash_file = file_no_ext + ".sha256"
if os.path.isfile(hash_file):
with open(hash_file, "rt") as f:
meta["pysssss.sha256"] = f.read()
else:
with open(file_path, "rb") as f:
meta["pysssss.sha256"] = hashlib.sha256(f.read()).hexdigest()
with open(hash_file, "wt") as f:
f.write(meta["pysssss.sha256"])
return web.json_response(meta)
@@ -0,0 +1,42 @@
# Hack: string type that is always equal in not equal comparisons
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
# Our any instance wants to be a wildcard string
any = AnyType("*")
class PlaySound:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"any": (any, {}),
"mode": (["always", "on empty queue"], {}),
"volume": ("FLOAT", {"min": 0, "max": 1, "step": 0.1, "default": 0.5}),
"file": ("STRING", { "default": "notify.mp3" })
}}
FUNCTION = "nop"
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
OUTPUT_NODE = True
RETURN_TYPES = (any,)
CATEGORY = "utils"
def IS_CHANGED(self, **kwargs):
return float("NaN")
def nop(self, any, mode, volume, file):
return {"ui": {"a": []}, "result": (any,)}
NODE_CLASS_MAPPINGS = {
"PlaySound|pysssss": PlaySound,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PlaySound|pysssss": "PlaySound 🐍",
}
@@ -0,0 +1,46 @@
# Hack: string type that is always equal in not equal comparisons
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
# Our any instance wants to be a wildcard string
any = AnyType("*")
class Repeater:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"source": (any, {}),
"repeats": ("INT", {"min": 0, "max": 5000, "default": 2}),
"output": (["single", "multi"], {}),
"node_mode": (["reuse", "create"], {}),
}}
RETURN_TYPES = (any,)
FUNCTION = "repeat"
OUTPUT_NODE = False
OUTPUT_IS_LIST = (True,)
CATEGORY = "utils"
def repeat(self, repeats, output, node_mode, **kwargs):
if output == "multi":
# Multi outputs are split to indiviual nodes on the frontend when serializing
return ([kwargs["source"]],)
elif node_mode == "reuse":
# When reusing we have a single input node, repeat that N times
return ([kwargs["source"]] * repeats,)
else:
# When creating new nodes, they'll be added dynamically when the graph is serialized
return ((list(kwargs.values())),)
NODE_CLASS_MAPPINGS = {
"Repeater|pysssss": Repeater,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Repeater|pysssss": "Repeater 🐍",
}
@@ -0,0 +1,59 @@
# Hack: string type that is always equal in not equal comparisons
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
# Our any instance wants to be a wildcard string
any = AnyType("*")
class ReroutePrimitive:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"value": (any, )},
}
@classmethod
def VALIDATE_INPUTS(s, **kwargs):
return True
RETURN_TYPES = (any,)
FUNCTION = "route"
CATEGORY = "utils"
def route(self, value):
return (value,)
class MultiPrimitive:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {"value": (any, )},
}
@classmethod
def VALIDATE_INPUTS(s, **kwargs):
return True
RETURN_TYPES = (any,)
FUNCTION = "listify"
CATEGORY = "utils"
OUTPUT_IS_LIST = (True,)
def listify(self, **kwargs):
return (list(kwargs.values()),)
NODE_CLASS_MAPPINGS = {
"ReroutePrimitive|pysssss": ReroutePrimitive,
# "MultiPrimitive|pysssss": MultiPrimitive,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ReroutePrimitive|pysssss": "Reroute Primitive 🐍",
# "MultiPrimitive|pysssss": "Multi Primitive 🐍",
}
@@ -0,0 +1,37 @@
class ShowText:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING",)
FUNCTION = "notify"
OUTPUT_NODE = True
OUTPUT_IS_LIST = (True,)
CATEGORY = "utils"
def notify(self, text, unique_id = None, extra_pnginfo=None):
if unique_id and extra_pnginfo and "workflow" in extra_pnginfo[0]:
workflow = extra_pnginfo[0]["workflow"]
node = next((x for x in workflow["nodes"] if str(x["id"]) == unique_id[0]), None)
if node:
node["widgets_values"] = [text]
return {"ui": {"text": text}, "result": (text,)}
NODE_CLASS_MAPPINGS = {
"ShowText|pysssss": ShowText,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ShowText|pysssss": "Show Text 🐍",
}
@@ -0,0 +1,54 @@
import re
class StringFunction:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"action": (["append", "replace"], {}),
"tidy_tags": (["yes", "no"], {}),
"text_a": ("STRING", {"multiline": True}),
"text_b": ("STRING", {"multiline": True}),
},
"optional": {
"text_c": ("STRING", {"multiline": True})
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "exec"
CATEGORY = "utils"
OUTPUT_NODE = True
def exec(self, action, tidy_tags, text_a, text_b, text_c=""):
# Converted inputs are sent as the string of 'undefined' if not connected
if text_a == "undefined":
text_a = ""
if text_b == "undefined":
text_b = ""
if text_c == "undefined":
text_c = ""
tidy_tags = tidy_tags == "yes"
out = ""
if action == "append":
out = (", " if tidy_tags else "").join(filter(None, [text_a, text_b, text_c]))
else:
if text_c is None:
text_c = ""
if text_b.startswith("/") and text_b.endswith("/"):
regex = text_b[1:-1]
out = re.sub(regex, text_c, text_a)
else:
out = text_a.replace(text_b, text_c)
if tidy_tags:
out = out.replace(" ", " ").replace(" ,", ",").replace(",,", ",").replace(",,", ",")
return {"ui": {"text": (out,)}, "result": (out,)}
NODE_CLASS_MAPPINGS = {
"StringFunction|pysssss": StringFunction,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"StringFunction|pysssss": "String Function 🐍",
}
@@ -0,0 +1,198 @@
import os
import folder_paths
import json
from server import PromptServer
import glob
from aiohttp import web
def get_allowed_dirs():
dir = os.path.abspath(os.path.join(__file__, "../../user"))
file = os.path.join(dir, "text_file_dirs.json")
with open(file, "r") as f:
return json.loads(f.read())
def get_valid_dirs():
return get_allowed_dirs().keys()
def get_dir_from_name(name):
dirs = get_allowed_dirs()
if name not in dirs:
raise KeyError(name + " dir not found")
path = dirs[name]
path = path.replace("$input", folder_paths.get_input_directory())
path = path.replace("$output", folder_paths.get_output_directory())
path = path.replace("$temp", folder_paths.get_temp_directory())
return path
def is_child_dir(parent_path, child_path):
parent_path = os.path.abspath(parent_path)
child_path = os.path.abspath(child_path)
return os.path.commonpath([parent_path]) == os.path.commonpath([parent_path, child_path])
def get_real_path(dir):
dir = dir.replace("/**/", "/")
dir = os.path.abspath(dir)
dir = os.path.split(dir)[0]
return dir
@PromptServer.instance.routes.get("/pysssss/text-file/{name}")
async def get_files(request):
name = request.match_info["name"]
dir = get_dir_from_name(name)
recursive = "/**/" in dir
# Ugh cant use root_path on glob... lazy hack..
pre = get_real_path(dir)
files = list(map(lambda t: os.path.relpath(t, pre),
glob.glob(dir, recursive=recursive)))
if len(files) == 0:
files = ["[none]"]
return web.json_response(files)
def get_file(root_dir, file):
if file == "[none]" or not file or not file.strip():
raise ValueError("No file")
root_dir = get_dir_from_name(root_dir)
root_dir = get_real_path(root_dir)
if not os.path.exists(root_dir):
os.mkdir(root_dir)
full_path = os.path.join(root_dir, file)
if not is_child_dir(root_dir, full_path):
raise ReferenceError()
return full_path
class TextFileNode:
RETURN_TYPES = ("STRING",)
CATEGORY = "utils"
@classmethod
def VALIDATE_INPUTS(self, root_dir, file, **kwargs):
if file == "[none]" or not file or not file.strip():
return True
get_file(root_dir, file)
return True
def load_text(self, **kwargs):
self.file = get_file(kwargs["root_dir"], kwargs["file"])
with open(self.file, "r") as f:
return (f.read(), )
class LoadText(TextFileNode):
@classmethod
def IS_CHANGED(self, **kwargs):
return os.path.getmtime(self.file)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"root_dir": (list(get_valid_dirs()), {}),
"file": (["[none]"], {
"pysssss.binding": [{
"source": "root_dir",
"callback": [{
"type": "set",
"target": "$this.disabled",
"value": True
}, {
"type": "fetch",
"url": "/pysssss/text-file/{$source.value}",
"then": [{
"type": "set",
"target": "$this.options.values",
"value": "$result"
}, {
"type": "validate-combo"
}, {
"type": "set",
"target": "$this.disabled",
"value": False
}]
}],
}]
})
},
}
FUNCTION = "load_text"
class SaveText(TextFileNode):
@classmethod
def IS_CHANGED(self, **kwargs):
return float("nan")
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"root_dir": (list(get_valid_dirs()), {}),
"file": ("STRING", {"default": "file.txt"}),
"append": (["append", "overwrite", "new only"], {}),
"insert": ("BOOLEAN", {
"default": True, "label_on": "new line", "label_off": "none",
"pysssss.binding": [{
"source": "append",
"callback": [{
"type": "if",
"condition": [{
"left": "$source.value",
"op": "eq",
"right": '"append"'
}],
"true": [{
"type": "set",
"target": "$this.disabled",
"value": False
}],
"false": [{
"type": "set",
"target": "$this.disabled",
"value": True
}],
}]
}]
}),
"text": ("STRING", {"forceInput": True, "multiline": True})
},
}
FUNCTION = "write_text"
def write_text(self, **kwargs):
self.file = get_file(kwargs["root_dir"], kwargs["file"])
if kwargs["append"] == "new only" and os.path.exists(self.file):
raise FileExistsError(
self.file + " already exists and 'new only' is selected.")
with open(self.file, "a+" if kwargs["append"] == "append" else "w") as f:
is_append = f.tell() != 0
if is_append and kwargs["insert"]:
f.write("\n")
f.write(kwargs["text"])
return super().load_text(**kwargs)
NODE_CLASS_MAPPINGS = {
"LoadText|pysssss": LoadText,
"SaveText|pysssss": SaveText,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoadText|pysssss": "Load Text 🐍",
"SaveText|pysssss": "Save Text 🐍",
}
@@ -0,0 +1,59 @@
from server import PromptServer
from aiohttp import web
import os
import inspect
import json
import importlib
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import pysssss
root_directory = os.path.dirname(inspect.getfile(PromptServer))
workflows_directory = os.path.join(root_directory, "pysssss-workflows")
workflows_directory = pysssss.get_config_value(
"workflows.directory", workflows_directory)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@PromptServer.instance.routes.get("/pysssss/workflows")
async def get_workflows(request):
files = []
for dirpath, directories, file in os.walk(workflows_directory):
for file in file:
if (file.endswith(".json")):
files.append(os.path.relpath(os.path.join(
dirpath, file), workflows_directory))
return web.json_response(list(map(lambda f: os.path.splitext(f)[0].replace("\\", "/"), files)))
@PromptServer.instance.routes.get("/pysssss/workflows/{name:.+}")
async def get_workflow(request):
file = os.path.abspath(os.path.join(
workflows_directory, request.match_info["name"] + ".json"))
if os.path.commonpath([file, workflows_directory]) != workflows_directory:
return web.Response(status=403)
return web.FileResponse(file)
@PromptServer.instance.routes.post("/pysssss/workflows")
async def save_workflow(request):
json_data = await request.json()
file = os.path.abspath(os.path.join(
workflows_directory, json_data["name"] + ".json"))
if os.path.commonpath([file, workflows_directory]) != workflows_directory:
return web.Response(status=403)
if os.path.exists(file) and ("overwrite" not in json_data or json_data["overwrite"] == False):
return web.Response(status=409)
sub_path = os.path.dirname(file)
if not os.path.exists(sub_path):
os.makedirs(sub_path)
with open(file, "w") as f:
f.write(json.dumps(json_data["workflow"]))
return web.Response(status=201)
@@ -0,0 +1,4 @@
{
"name": "CustomScripts",
"logging": false
}
@@ -0,0 +1,7 @@
{
"name": "CustomScripts",
"logging": false,
"workflows": {
"directory": "C:\\ComfyUI-Workflows"
}
}
@@ -0,0 +1,4 @@
{
"name": "CustomScripts",
"logging": false
}
@@ -0,0 +1,300 @@
import asyncio
import os
import json
import shutil
import inspect
import aiohttp
from server import PromptServer
from tqdm import tqdm
config = None
def is_logging_enabled():
config = get_extension_config()
if "logging" not in config:
return False
return config["logging"]
def log(message, type=None, always=False, name=None):
if not always and not is_logging_enabled():
return
if type is not None:
message = f"[{type}] {message}"
if name is None:
name = get_extension_config()["name"]
print(f"(pysssss:{name}) {message}")
def get_ext_dir(subpath=None, mkdir=False):
dir = os.path.dirname(__file__)
if subpath is not None:
dir = os.path.join(dir, subpath)
dir = os.path.abspath(dir)
if mkdir and not os.path.exists(dir):
os.makedirs(dir)
return dir
def get_comfy_dir(subpath=None, mkdir=False):
dir = os.path.dirname(inspect.getfile(PromptServer))
if subpath is not None:
dir = os.path.join(dir, subpath)
dir = os.path.abspath(dir)
if mkdir and not os.path.exists(dir):
os.makedirs(dir)
return dir
def get_web_ext_dir():
config = get_extension_config()
name = config["name"]
dir = get_comfy_dir("web/extensions/pysssss")
if not os.path.exists(dir):
os.makedirs(dir)
dir = os.path.join(dir, name)
return dir
def get_extension_config(reload=False):
global config
if reload == False and config is not None:
return config
config_path = get_ext_dir("pysssss.json")
default_config_path = get_ext_dir("pysssss.default.json")
if not os.path.exists(config_path):
if os.path.exists(default_config_path):
shutil.copy(default_config_path, config_path)
if not os.path.exists(config_path):
log(f"Failed to create config at {config_path}", type="ERROR", always=True, name="???")
print(f"Extension path: {get_ext_dir()}")
return {"name": "Unknown", "version": -1}
else:
log("Missing pysssss.default.json, this extension may not work correctly. Please reinstall the extension.",
type="ERROR", always=True, name="???")
print(f"Extension path: {get_ext_dir()}")
return {"name": "Unknown", "version": -1}
with open(config_path, "r") as f:
config = json.loads(f.read())
return config
def link_js(src, dst):
src = os.path.abspath(src)
dst = os.path.abspath(dst)
if os.name == "nt":
try:
import _winapi
_winapi.CreateJunction(src, dst)
return True
except:
pass
try:
os.symlink(src, dst)
return True
except:
import logging
logging.exception('')
return False
def is_junction(path):
if os.name != "nt":
return False
try:
return bool(os.readlink(path))
except OSError:
return False
def install_js():
src_dir = get_ext_dir("web/js")
if not os.path.exists(src_dir):
log("No JS")
return
should_install = should_install_js()
if should_install:
log("it looks like you're running an old version of ComfyUI that requires manual setup of web files, it is recommended you update your installation.", "warning", True)
dst_dir = get_web_ext_dir()
linked = os.path.islink(dst_dir) or is_junction(dst_dir)
if linked or os.path.exists(dst_dir):
if linked:
if should_install:
log("JS already linked")
else:
os.unlink(dst_dir)
log("JS unlinked, PromptServer will serve extension")
elif not should_install:
shutil.rmtree(dst_dir)
log("JS deleted, PromptServer will serve extension")
return
if not should_install:
log("JS skipped, PromptServer will serve extension")
return
if link_js(src_dir, dst_dir):
log("JS linked")
return
log("Copying JS files")
shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
def should_install_js():
return not hasattr(PromptServer.instance, "supports") or "custom_nodes_from_web" not in PromptServer.instance.supports
def init(check_imports=None):
log("Init")
if check_imports is not None:
import importlib.util
for imp in check_imports:
spec = importlib.util.find_spec(imp)
if spec is None:
log(f"{imp} is required, please check requirements are installed.",
type="ERROR", always=True)
return False
install_js()
return True
def get_async_loop():
loop = None
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def get_http_session():
loop = get_async_loop()
return aiohttp.ClientSession(loop=loop)
async def download(url, stream, update_callback=None, session=None):
close_session = False
if session is None:
close_session = True
session = get_http_session()
try:
async with session.get(url) as response:
size = int(response.headers.get('content-length', 0)) or None
with tqdm(
unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size,
) as progressbar:
perc = 0
async for chunk in response.content.iter_chunked(2048):
stream.write(chunk)
progressbar.update(len(chunk))
if update_callback is not None and progressbar.total is not None and progressbar.total != 0:
last = perc
perc = round(progressbar.n / progressbar.total, 2)
if perc != last:
last = perc
await update_callback(perc)
finally:
if close_session and session is not None:
await session.close()
async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None):
if is_ext_subpath:
destination = get_ext_dir(destination)
with open(destination, mode='wb') as f:
download(url, f, update_callback, session)
def wait_for_async(async_fn, loop=None):
res = []
async def run_async():
r = await async_fn()
res.append(r)
if loop is None:
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(run_async())
return res[0]
def update_node_status(client_id, node, text, progress=None):
if client_id is None:
client_id = PromptServer.instance.client_id
if client_id is None:
return
PromptServer.instance.send_sync("pysssss/update_status", {
"node": node,
"progress": progress,
"text": text
}, client_id)
async def update_node_status_async(client_id, node, text, progress=None):
if client_id is None:
client_id = PromptServer.instance.client_id
if client_id is None:
return
await PromptServer.instance.send("pysssss/update_status", {
"node": node,
"progress": progress,
"text": text
}, client_id)
def get_config_value(key, default=None, throw=False):
split = key.split(".")
obj = get_extension_config()
for s in split:
if s in obj:
obj = obj[s]
else:
if throw:
raise KeyError("Configuration key missing: " + key)
else:
return default
return obj
def is_inside_dir(root_dir, check_path):
root_dir = os.path.abspath(root_dir)
if not os.path.isabs(check_path):
check_path = os.path.abspath(os.path.join(root_dir, check_path))
return os.path.commonpath([check_path, root_dir]) == root_dir
def get_child_dir(root_dir, child_path, throw_if_outside=True):
child_path = os.path.abspath(os.path.join(root_dir, child_path))
if is_inside_dir(root_dir, child_path):
return child_path
if throw_if_outside:
raise NotADirectoryError(
"Saving outside the target folder is not allowed.")
return None
@@ -0,0 +1,5 @@
{
"input": "$input/**/*.txt",
"output": "$output/**/*.txt",
"temp": "$temp/**/*.txt"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

@@ -0,0 +1,560 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { api } from "../../../../scripts/api.js";
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { TextAreaAutoComplete } from "./common/autocomplete.js";
import { ModelInfoDialog } from "./common/modelInfoDialog.js";
import { LoraInfoDialog } from "./modelInfo.js";
function parseCSV(csvText) {
const rows = [];
const delimiter = ",";
const quote = '"';
let currentField = "";
let inQuotedField = false;
function pushField() {
rows[rows.length - 1].push(currentField);
currentField = "";
inQuotedField = false;
}
rows.push([]); // Initialize the first row
for (let i = 0; i < csvText.length; i++) {
const char = csvText[i];
const nextChar = csvText[i + 1];
// Special handling for backslash escaped quotes
if (char === "\\" && nextChar === quote) {
currentField += quote;
i++;
}
if (!inQuotedField) {
if (char === quote) {
inQuotedField = true;
} else if (char === delimiter) {
pushField();
} else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
pushField();
if (nextChar === "\n") {
i++; // Handle Windows line endings (\r\n)
}
rows.push([]); // Start a new row
} else {
currentField += char;
}
} else {
if (char === quote && nextChar === quote) {
currentField += quote;
i++; // Skip the next quote
} else if (char === quote) {
inQuotedField = false;
} else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
// Dont allow new lines in quoted text, assume its wrong
const parsed = parseCSV(currentField);
rows.pop();
rows.push(...parsed);
inQuotedField = false;
currentField = "";
rows.push([]);
} else {
currentField += char;
}
}
}
if (currentField || csvText[csvText.length - 1] === ",") {
pushField();
}
// Remove the last row if it's empty
if (rows[rows.length - 1].length === 0) {
rows.pop();
}
return rows;
}
async function getCustomWords() {
const resp = await api.fetchApi("/pysssss/autocomplete", { cache: "no-store" });
if (resp.status === 200) {
return await resp.text();
}
return undefined;
}
async function addCustomWords(text) {
if (!text) {
text = await getCustomWords();
}
if (text) {
TextAreaAutoComplete.updateWords(
"pysssss.customwords",
parseCSV(text).reduce((p, n) => {
let text;
let priority;
let value;
let num;
switch (n.length) {
case 0:
return;
case 1:
// Single word
text = n[0];
break;
case 2:
// Word,[priority|alias]
num = +n[1];
if (isNaN(num)) {
text = n[0] + "🔄️" + n[1];
value = n[0];
} else {
text = n[0];
priority = num;
}
break;
case 4:
// a1111 csv format?
value = n[0];
priority = +n[2];
const aliases = n[3]?.trim();
if (aliases && aliases !== "null") { // Weird null in an example csv, maybe they are JSON.parsing the last column?
const split = aliases.split(",");
for (const text of split) {
p[text] = { text, priority, value };
}
}
text = value;
break;
default:
// Word,alias,priority
text = n[1];
value = n[0];
priority = +n[2];
break;
}
p[text] = { text, priority, value };
return p;
}, {})
);
}
}
function toggleLoras() {
[TextAreaAutoComplete.globalWords, TextAreaAutoComplete.globalWordsExclLoras] = [
TextAreaAutoComplete.globalWordsExclLoras,
TextAreaAutoComplete.globalWords,
];
}
class EmbeddingInfoDialog extends ModelInfoDialog {
async addInfo() {
super.addInfo();
const info = await this.addCivitaiInfo();
if (info) {
$el("div", {
parent: this.content,
innerHTML: info.description,
style: {
maxHeight: "250px",
overflow: "auto",
},
});
}
}
}
class CustomWordsDialog extends ComfyDialog {
async show() {
const text = await getCustomWords();
this.words = $el("textarea", {
textContent: text,
style: {
width: "70vw",
height: "70vh",
},
});
const input = $el("input", {
style: {
flex: "auto",
},
value:
"https://gist.githubusercontent.com/pythongosssss/1d3efa6050356a08cea975183088159a/raw/a18fb2f94f9156cf4476b0c24a09544d6c0baec6/danbooru-tags.txt",
});
super.show(
$el(
"div",
{
style: {
display: "flex",
flexDirection: "column",
overflow: "hidden",
maxHeight: "100%",
},
},
[
$el("h2", {
textContent: "Custom Autocomplete Words",
style: {
color: "#fff",
marginTop: 0,
textAlign: "center",
fontFamily: "sans-serif",
},
}),
$el(
"div",
{
style: {
color: "#fff",
fontFamily: "sans-serif",
display: "flex",
alignItems: "center",
gap: "5px",
},
},
[
$el("label", { textContent: "Load Custom List: " }),
input,
$el("button", {
textContent: "Load",
onclick: async () => {
try {
const res = await fetch(input.value);
if (res.status !== 200) {
throw new Error("Error loading: " + res.status + " " + res.statusText);
}
this.words.value = await res.text();
} catch (error) {
alert("Error loading custom list, try manually copy + pasting the list");
}
},
}),
]
),
this.words,
]
)
);
}
createButtons() {
const btns = super.createButtons();
const save = $el("button", {
type: "button",
textContent: "Save",
onclick: async (e) => {
try {
const res = await api.fetchApi("/pysssss/autocomplete", { method: "POST", body: this.words.value });
if (res.status !== 200) {
throw new Error("Error saving: " + res.status + " " + res.statusText);
}
save.textContent = "Saved!";
addCustomWords(this.words.value);
setTimeout(() => {
save.textContent = "Save";
}, 500);
} catch (error) {
alert("Error saving word list!");
console.error(error);
}
},
});
btns.unshift(save);
return btns;
}
}
const id = "pysssss.AutoCompleter";
app.registerExtension({
name: id,
init() {
async function addEmbeddings() {
const embeddings = await api.getEmbeddings();
const words = {};
words["embedding:"] = { text: "embedding:" };
for (const emb of embeddings) {
const v = `embedding:${emb}`;
words[v] = {
text: v,
info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb),
use_replacer: false,
};
}
TextAreaAutoComplete.updateWords("pysssss.embeddings", words);
}
async function addLoras() {
const loras = await api
.fetchApi("/pysssss/loras", { cache: "no-store" })
.then(res => res.json());
const words = {};
words["lora:"] = { text: "lora:" };
for (const lora of loras) {
const v = `<lora:${lora}:1.0>`;
words[v] = {
text: v,
info: () => new LoraInfoDialog(lora).show("loras", lora),
use_replacer: false,
};
}
TextAreaAutoComplete.updateWords("pysssss.loras", words);
}
// store global words with/without loras
Promise.all([addEmbeddings(), addCustomWords()])
.then(() => {
TextAreaAutoComplete.globalWordsExclLoras = Object.assign(
{},
TextAreaAutoComplete.globalWords
);
})
.then(addLoras)
.then(() => {
if (!TextAreaAutoComplete.lorasEnabled) {
toggleLoras(); // off by default
}
});
const STRING = ComfyWidgets.STRING;
const SKIP_WIDGETS = new Set(["ttN xyPlot.x_values", "ttN xyPlot.y_values"]);
ComfyWidgets.STRING = function (node, inputName, inputData) {
const r = STRING.apply(this, arguments);
if (inputData[1]?.multiline) {
// Disabled on this input
const config = inputData[1]?.["pysssss.autocomplete"];
if (config === false) return r;
// In list of widgets to skip
const id = `${node.comfyClass}.${inputName}`;
if (SKIP_WIDGETS.has(id)) return r;
let words;
let separator;
if (typeof config === "object") {
separator = config.separator;
words = {};
if (config.words) {
// Custom wordlist, this will have been registered on setup
Object.assign(words, TextAreaAutoComplete.groups[node.comfyClass + "." + inputName] ?? {});
}
for (const item of config.groups ?? []) {
if (item === "*") {
// This widget wants all global words included
Object.assign(words, TextAreaAutoComplete.globalWords);
} else {
// This widget wants a specific group included
Object.assign(words, TextAreaAutoComplete.groups[item] ?? {});
}
}
}
new TextAreaAutoComplete(r.widget.inputEl, words, separator);
}
return r;
};
TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", ";
const enabledSetting = app.ui.settings.addSetting({
id,
name: "🐍 Text Autocomplete",
defaultValue: true,
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: name,
}),
]),
$el("td", [
$el(
"label",
{
textContent: "Enabled ",
style: {
display: "block",
},
},
[
$el("input", {
id: id.replaceAll(".", "-"),
type: "checkbox",
checked: value,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.enabled = checked;
setter(checked);
},
}),
]
),
$el(
"label.comfy-tooltip-indicator",
{
title: "This requires other ComfyUI nodes/extensions that support using LoRAs in the prompt.",
textContent: "Loras enabled ",
style: {
display: "block",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.lorasEnabled,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.lorasEnabled = checked;
toggleLoras();
localStorage.setItem(id + ".ShowLoras", TextAreaAutoComplete.lorasEnabled);
},
}),
]
),
$el(
"label",
{
textContent: "Auto-insert comma ",
style: {
display: "block",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.globalSeparator,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.globalSeparator = checked ? ", " : "";
localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.globalSeparator);
},
}),
]
),
$el(
"label",
{
textContent: "Replace _ with space ",
style: {
display: "block",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.replacer,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.replacer = checked ? (v) => v.replaceAll("_", " ") : undefined;
localStorage.setItem(id + ".ReplaceUnderscore", checked);
},
}),
]
),
$el(
"label",
{
textContent: "Insert suggestion on: ",
style: {
display: "block",
},
},
[
$el(
"label",
{
textContent: "Tab",
style: {
display: "block",
marginLeft: "20px",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.insertOnTab,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.insertOnTab = checked;
localStorage.setItem(id + ".InsertOnTab", checked);
},
}),
]
),
$el(
"label",
{
textContent: "Enter",
style: {
display: "block",
marginLeft: "20px",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.insertOnEnter,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.insertOnEnter = checked;
localStorage.setItem(id + ".InsertOnEnter", checked);
},
}),
]
),
]
),
$el("button", {
textContent: "Manage Custom Words",
onclick: () => {
app.ui.settings.element.close();
new CustomWordsDialog().show();
},
style: {
fontSize: "14px",
display: "block",
marginTop: "5px",
},
}),
]),
]);
},
});
TextAreaAutoComplete.enabled = enabledSetting.value;
TextAreaAutoComplete.replacer = localStorage.getItem(id + ".ReplaceUnderscore") === "true" ? (v) => v.replaceAll("_", " ") : undefined;
TextAreaAutoComplete.insertOnTab = localStorage.getItem(id + ".InsertOnTab") !== "false";
TextAreaAutoComplete.insertOnEnter = localStorage.getItem(id + ".InsertOnEnter") !== "false";
TextAreaAutoComplete.lorasEnabled = localStorage.getItem(id + ".ShowLoras") === "true";
},
beforeRegisterNodeDef(_, def) {
// Process each input to see if there is a custom word list for
// { input: { required: { something: ["STRING", { "pysssss.autocomplete": ["groupid", ["custom", "words"] ] }] } } }
const inputs = { ...def.input?.required, ...def.input?.optional };
for (const input in inputs) {
const config = inputs[input][1]?.["pysssss.autocomplete"];
if (!config) continue;
if (typeof config === "object" && config.words) {
const words = {};
for (const text of config.words || []) {
const obj = typeof text === "string" ? { text } : text;
words[obj.text] = obj;
}
TextAreaAutoComplete.updateWords(def.name + "." + input, words, false);
}
}
},
});
@@ -0,0 +1,345 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { $el } from "../../../scripts/ui.js";
import { api } from "../../../scripts/api.js";
const CHECKPOINT_LOADER = "CheckpointLoader|pysssss";
const LORA_LOADER = "LoraLoader|pysssss";
function getType(node) {
if (node.comfyClass === CHECKPOINT_LOADER) {
return "checkpoints";
}
return "loras";
}
app.registerExtension({
name: "pysssss.Combo++",
init() {
$el("style", {
textContent: `
.litemenu-entry:hover .pysssss-combo-image {
display: block;
}
.pysssss-combo-image {
display: none;
position: absolute;
left: 0;
top: 0;
transform: translate(-100%, 0);
width: 384px;
height: 384px;
background-size: contain;
background-position: top right;
background-repeat: no-repeat;
filter: brightness(65%);
}
`,
parent: document.body,
});
const submenuSetting = app.ui.settings.addSetting({
id: "pysssss.Combo++.Submenu",
name: "🐍 Enable submenu in custom nodes",
defaultValue: true,
type: "boolean",
});
// Ensure hook callbacks are available
const getOrSet = (target, name, create) => {
if (name in target) return target[name];
return (target[name] = create());
};
const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__"));
const store = getOrSet(window, symbol, () => ({}));
const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({}));
for (const e of ["ctor", "preAddItem", "addItem"]) {
if (!contextMenuHook[e]) {
contextMenuHook[e] = [];
}
}
// // Checks if this is a custom combo item
const isCustomItem = (value) => value && typeof value === "object" && "image" in value && value.content;
// Simple check for what separator to split by
const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//;
contextMenuHook["ctor"].push(function (values, options) {
// Copy the class from the parent so if we are dark we are also dark
// this enables the filter box
if (options.parentMenu?.options?.className === "dark") {
options.className = "dark";
}
});
// After an element is created for an item, add an image if it has one
contextMenuHook["addItem"].push(function (el, menu, [name, value, options]) {
if (el && isCustomItem(value) && value?.image && !value.submenu) {
el.textContent += " *";
$el("div.pysssss-combo-image", {
parent: el,
style: {
backgroundImage: `url(/pysssss/view/${encodeURIComponent(value.image)})`,
},
});
}
});
function buildMenu(widget, values) {
const lookup = {
"": { options: [] },
};
// Split paths into menu structure
for (const value of values) {
const split = value.content.split(splitBy);
let path = "";
for (let i = 0; i < split.length; i++) {
const s = split[i];
const last = i === split.length - 1;
if (last) {
// Leaf node, manually add handler that sets the lora
lookup[path].options.push({
...value,
title: s,
callback: () => {
widget.value = value;
widget.callback(value);
app.graph.setDirtyCanvas(true);
},
});
} else {
const prevPath = path;
path += s + splitBy;
if (!lookup[path]) {
const sub = {
title: s,
submenu: {
options: [],
title: s,
},
};
// Add to tree
lookup[path] = sub.submenu;
lookup[prevPath].options.push(sub);
}
}
}
}
return lookup[""].options;
}
// Override COMBO widgets to patch their values
const combo = ComfyWidgets["COMBO"];
ComfyWidgets["COMBO"] = function (node, inputName, inputData) {
const type = inputData[0];
const res = combo.apply(this, arguments);
if (isCustomItem(type[0])) {
let value = res.widget.value;
let values = res.widget.options.values;
let menu = null;
// Override the option values to check if we should render a menu structure
Object.defineProperty(res.widget.options, "values", {
get() {
let v = values;
if (submenuSetting.value) {
if (!menu) {
// Only build the menu once
menu = buildMenu(res.widget, values);
}
v = menu;
}
const valuesIncludes = v.includes;
v.includes = function (searchElement) {
const includesFromMenuItems = function (items) {
for (const item of items) {
if (includesFromMenuItem(item)) {
return true;
}
}
return false;
}
const includesFromMenuItem = function (item) {
if (item.submenu) {
return includesFromMenuItems(item.submenu.options)
} else {
return item.content === searchElement.content;
}
}
const includes = valuesIncludes.apply(this, arguments) || includesFromMenuItems(this);
return includes;
}
return v;
},
set(v) {
// Options are changing (refresh) so reset the menu so it can be rebuilt if required
values = v;
menu = null;
},
});
Object.defineProperty(res.widget, "value", {
get() {
// HACK: litegraph supports rendering items with "content" in the menu, but not on the widget
// This detects when its being called by the widget drawing and just returns the text
// Also uses the content for the same image replacement value
if (res.widget) {
const stack = new Error().stack;
if (stack.includes("drawNodeWidgets") || stack.includes("saveImageExtraOutput")) {
return (value || type[0]).content;
}
}
return value;
},
set(v) {
if (v?.submenu) {
// Dont allow selection of submenus
return;
}
value = v;
},
});
}
return res;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const isCkpt = nodeType.comfyClass === CHECKPOINT_LOADER;
const isLora = nodeType.comfyClass === LORA_LOADER;
if (isCkpt || isLora) {
const onAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
onAdded?.apply(this, arguments);
const { widget: exampleList } = ComfyWidgets["COMBO"](this, "example", [[""]], app);
let exampleWidget;
const get = async (route, suffix) => {
const url = encodeURIComponent(`${getType(nodeType)}${suffix || ""}`);
return await api.fetchApi(`/pysssss/${route}/${url}`);
};
const getExample = async () => {
if (exampleList.value === "[none]") {
if (exampleWidget) {
exampleWidget.inputEl.remove();
exampleWidget = null;
this.widgets.length -= 1;
}
return;
}
const v = this.widgets[0].value.content;
const pos = v.lastIndexOf(".");
const name = v.substr(0, pos);
const example = await (await get("view", `/${name}/${exampleList.value}`)).text();
if (!exampleWidget) {
exampleWidget = ComfyWidgets["STRING"](this, "prompt", ["STRING", { multiline: true }], app).widget;
exampleWidget.inputEl.readOnly = true;
exampleWidget.inputEl.style.opacity = 0.6;
}
exampleWidget.value = example;
};
const exampleCb = exampleList.callback;
exampleList.callback = function () {
getExample();
return exampleCb?.apply(this, arguments) ?? exampleList.value;
};
const listExamples = async () => {
exampleList.disabled = true;
exampleList.options.values = ["[none]"];
exampleList.value = "[none]";
let examples = [];
if (this.widgets[0].value?.content) {
try {
examples = await (await get("examples", `/${this.widgets[0].value.content}`)).json();
} catch (error) {}
}
exampleList.options.values = ["[none]", ...examples];
exampleList.callback();
exampleList.disabled = !examples.length;
app.graph.setDirtyCanvas(true, true);
};
const modelWidget = this.widgets[0];
const modelCb = modelWidget.callback;
let prev = undefined;
modelWidget.callback = function () {
const ret = modelCb?.apply(this, arguments) ?? modelWidget.value;
let v = ret;
if (ret?.content) {
v = ret.content;
}
if (prev !== v) {
listExamples();
prev = v;
}
return ret;
};
setTimeout(() => {
modelWidget.callback();
}, 30);
};
}
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
if (this.imgs) {
// If this node has images then we add an open in new tab item
let img;
if (this.imageIndex != null) {
// An image is selected so select that
img = this.imgs[this.imageIndex];
} else if (this.overIndex != null) {
// No image is selected but one is hovered
img = this.imgs[this.overIndex];
}
if (img) {
const nodes = app.graph._nodes.filter(
(n) => n.comfyClass === LORA_LOADER || n.comfyClass === CHECKPOINT_LOADER
);
if (nodes.length) {
options.unshift({
content: "Save as Preview",
submenu: {
options: nodes.map((n) => ({
content: n.widgets[0].value.content,
callback: async () => {
const url = new URL(img.src);
const { image } = await api.fetchApi(
"/pysssss/save/" + encodeURIComponent(`${getType(n)}/${n.widgets[0].value.content}`),
{
method: "POST",
body: JSON.stringify({
filename: url.searchParams.get("filename"),
subfolder: url.searchParams.get("subfolder"),
type: url.searchParams.get("type"),
}),
headers: {
"content-type": "application/json",
},
}
);
n.widgets[0].value.image = image;
app.refreshComboInNodes();
},
})),
},
});
}
}
}
return getExtraMenuOptions?.apply(this, arguments);
};
},
});
@@ -0,0 +1,61 @@
.pysssss-autocomplete {
color: var(--descrip-text);
background-color: var(--comfy-menu-bg);
position: absolute;
font-family: sans-serif;
box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
z-index: 9999;
}
.pysssss-autocomplete-item {
cursor: pointer;
padding: 3px 7px;
display: flex;
border-left: 3px solid transparent;
align-items: center;
}
.pysssss-autocomplete-item--selected {
border-left-color: dodgerblue;
}
.pysssss-autocomplete-highlight {
font-weight: bold;
text-decoration: underline;
text-decoration-color: dodgerblue;
}
.pysssss-autocomplete-pill {
margin-left: auto;
font-size: 10px;
color: #fff;
padding: 2px 4px 2px 14px;
position: relative;
}
.pysssss-autocomplete-pill::after {
content: "";
display: block;
background: rgba(255, 255, 255, 0.25);
width: calc(100% - 10px);
height: 100%;
position: absolute;
left: 10px;
top: 0;
border-radius: 5px;
}
.pysssss-autocomplete-pill + .pysssss-autocomplete-pill {
margin-left: 0;
}
.pysssss-autocomplete-item-info {
margin-left: auto;
transition: filter 0.2s;
will-change: filter;
text-decoration: none;
padding-left: 10px;
}
.pysssss-autocomplete-item-info:hover {
filter: invert(1);
}
@@ -0,0 +1,642 @@
import { $el } from "../../../../scripts/ui.js";
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
/*
https://github.com/component/textarea-caret-position
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
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.
*/
const getCaretCoordinates = (function () {
// We'll copy the properties below into the mirror div.
// Note that some browsers, such as Firefox, do not concatenate properties
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
// so we have to list every single property explicitly.
var properties = [
"direction", // RTL support
"boxSizing",
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
"height",
"overflowX",
"overflowY", // copy the scrollbar for IE
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderStyle",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontSizeAdjust",
"lineHeight",
"fontFamily",
"textAlign",
"textTransform",
"textIndent",
"textDecoration", // might not make a difference, but better be safe
"letterSpacing",
"wordSpacing",
"tabSize",
"MozTabSize",
];
var isBrowser = typeof window !== "undefined";
var isFirefox = isBrowser && window.mozInnerScreenX != null;
return function getCaretCoordinates(element, position, options) {
if (!isBrowser) {
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
}
var debug = (options && options.debug) || false;
if (debug) {
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
if (el) el.parentNode.removeChild(el);
}
// The mirror div will replicate the textarea's style
var div = document.createElement("div");
div.id = "input-textarea-caret-position-mirror-div";
document.body.appendChild(div);
var style = div.style;
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
var isInput = element.nodeName === "INPUT";
// Default textarea styles
style.whiteSpace = "pre-wrap";
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
// Position off-screen
style.position = "absolute"; // required to return coordinates properly
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
// Transfer the element's properties to the div
properties.forEach(function (prop) {
if (isInput && prop === "lineHeight") {
// Special case for <input>s because text is rendered centered and line height may be != height
if (computed.boxSizing === "border-box") {
var height = parseInt(computed.height);
var outerHeight =
parseInt(computed.paddingTop) +
parseInt(computed.paddingBottom) +
parseInt(computed.borderTopWidth) +
parseInt(computed.borderBottomWidth);
var targetHeight = outerHeight + parseInt(computed.lineHeight);
if (height > targetHeight) {
style.lineHeight = height - outerHeight + "px";
} else if (height === targetHeight) {
style.lineHeight = computed.lineHeight;
} else {
style.lineHeight = 0;
}
} else {
style.lineHeight = computed.height;
}
} else {
style[prop] = computed[prop];
}
});
if (isFirefox) {
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
} else {
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
}
div.textContent = element.value.substring(0, position);
// The second special handling for input type="text" vs textarea:
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
var span = document.createElement("span");
// Wrapping must be replicated *exactly*, including when a long word gets
// onto the next line, with whitespace at the end of the line before (#7).
// The *only* reliable way to do that is to copy the *entire* rest of the
// textarea's content into the <span> created at the caret position.
// For inputs, just '.' would be enough, but no need to bother.
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
div.appendChild(span);
var coordinates = {
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
height: parseInt(computed["lineHeight"]),
};
if (debug) {
span.style.backgroundColor = "#aaa";
} else {
document.body.removeChild(div);
}
return coordinates;
};
})();
/*
Key functions from:
https://github.com/yuku/textcomplete
© Yuku Takahashi - This software is licensed under the MIT license.
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
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.
*/
const CHAR_CODE_ZERO = "0".charCodeAt(0);
const CHAR_CODE_NINE = "9".charCodeAt(0);
class TextAreaCaretHelper {
constructor(el, getScale) {
this.el = el;
this.getScale = getScale;
}
#calculateElementOffset() {
const rect = this.el.getBoundingClientRect();
const owner = this.el.ownerDocument;
if (owner == null) {
throw new Error("Given element does not belong to document");
}
const { defaultView, documentElement } = owner;
if (defaultView == null) {
throw new Error("Given element does not belong to window");
}
const offset = {
top: rect.top + defaultView.pageYOffset,
left: rect.left + defaultView.pageXOffset,
};
if (documentElement) {
offset.top -= documentElement.clientTop;
offset.left -= documentElement.clientLeft;
}
return offset;
}
#isDigit(charCode) {
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
}
#getLineHeightPx() {
const computedStyle = getComputedStyle(this.el);
const lineHeight = computedStyle.lineHeight;
// If the char code starts with a digit, it is either a value in pixels,
// or unitless, as per:
// https://drafts.csswg.org/css2/visudet.html#propdef-line-height
// https://drafts.csswg.org/css2/cascade.html#computed-value
if (this.#isDigit(lineHeight.charCodeAt(0))) {
const floatLineHeight = parseFloat(lineHeight);
// In real browsers the value is *always* in pixels, even for unit-less
// line-heights. However, we still check as per the spec.
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
? floatLineHeight * parseFloat(computedStyle.fontSize)
: floatLineHeight;
}
// Otherwise, the value is "normal".
// If the line-height is "normal", calculate by font-size
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
}
/**
* Returns calculated line-height of the given node in pixels.
*/
#calculateLineHeightPx(nodeName, computedStyle) {
const body = document.body;
if (!body) return 0;
const tempNode = document.createElement(nodeName);
tempNode.innerHTML = "&nbsp;";
Object.assign(tempNode.style, {
fontSize: computedStyle.fontSize,
fontFamily: computedStyle.fontFamily,
padding: "0",
});
body.appendChild(tempNode);
// Make sure textarea has only 1 row
if (tempNode instanceof HTMLTextAreaElement) {
tempNode.rows = 1;
}
// Assume the height of the element is the line-height
const height = tempNode.offsetHeight;
body.removeChild(tempNode);
return height;
}
getCursorOffset() {
const scale = this.getScale();
const elOffset = this.#calculateElementOffset();
const elScroll = this.#getElScroll();
const cursorPosition = this.#getCursorPosition();
const lineHeight = this.#getLineHeightPx();
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
const left = elOffset.left - elScroll.left + cursorPosition.left;
const clientTop = this.el.getBoundingClientRect().top;
if (this.el.dir !== "rtl") {
return { top, left, lineHeight, clientTop };
} else {
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
return { top, right, lineHeight, clientTop };
}
}
#getElScroll() {
return { top: this.el.scrollTop, left: this.el.scrollLeft };
}
#getCursorPosition() {
return getCaretCoordinates(this.el, this.el.selectionEnd);
}
getBeforeCursor() {
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
}
getAfterCursor() {
return this.el.value.substring(this.el.selectionEnd);
}
insertAtCursor(value, offset, finalOffset) {
if (this.el.selectionStart != null) {
const startPos = this.el.selectionStart;
const endPos = this.el.selectionEnd;
this.el.value = this.el.value.substring(0, startPos + offset) + value + this.el.value.substring(endPos, this.el.value.length);
this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
} else {
this.el.value += value;
}
}
}
/*********************/
/**
* @typedef {{
* text: string,
* priority?: number,
* info?: Function,
* hint?: string,
* showValue?: boolean,
* caretOffset?: number
* }} AutoCompleteEntry
*/
export class TextAreaAutoComplete {
static globalSeparator = "";
static enabled = true;
static insertOnTab = true;
static insertOnEnter = true;
static replacer = undefined;
static lorasEnabled = false;
/** @type {Record<string, Record<string, AutoCompleteEntry>>} */
static groups = {};
/** @type {Set<string>} */
static globalGroups = new Set();
/** @type {Record<string, AutoCompleteEntry>} */
static globalWords = {};
/** @type {Record<string, AutoCompleteEntry>} */
static globalWordsExclLoras = {};
/** @type {HTMLTextAreaElement} */
el;
/** @type {Record<string, AutoCompleteEntry>} */
overrideWords;
overrideSeparator = "";
get words() {
return this.overrideWords ?? TextAreaAutoComplete.globalWords;
}
get separator() {
return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator;
}
/**
* @param {HTMLTextAreaElement} el
*/
constructor(el, words = null, separator = null) {
this.el = el;
this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale);
this.dropdown = $el("div.pysssss-autocomplete");
this.overrideWords = words;
this.overrideSeparator = separator;
this.#setup();
}
#setup() {
this.el.addEventListener("keydown", this.#keyDown.bind(this));
this.el.addEventListener("keypress", this.#keyPress.bind(this));
this.el.addEventListener("keyup", this.#keyUp.bind(this));
this.el.addEventListener("click", this.#hide.bind(this));
this.el.addEventListener("blur", () => setTimeout(() => this.#hide(), 150));
}
/**
* @param {KeyboardEvent} e
*/
#keyDown(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "ArrowUp":
e.preventDefault();
if (this.selected.index) {
this.#setSelected(this.currentWords[this.selected.index - 1].wordInfo);
} else {
this.#setSelected(this.currentWords[this.currentWords.length - 1].wordInfo);
}
break;
case "ArrowDown":
e.preventDefault();
if (this.selected.index === this.currentWords.length - 1) {
this.#setSelected(this.currentWords[0].wordInfo);
} else {
this.#setSelected(this.currentWords[this.selected.index + 1].wordInfo);
}
break;
case "Tab":
if (TextAreaAutoComplete.insertOnTab) {
this.#insertItem();
e.preventDefault();
}
break;
}
}
}
/**
* @param {KeyboardEvent} e
*/
#keyPress(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "Enter":
if (!e.ctrlKey) {
if (TextAreaAutoComplete.insertOnEnter) {
this.#insertItem();
e.preventDefault();
}
}
break;
}
}
if (!e.defaultPrevented) {
this.#update();
}
}
#keyUp(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "Escape":
e.preventDefault();
this.#hide();
break;
}
} else if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowRight") {
return;
}
if (!e.defaultPrevented) {
this.#update();
}
}
#setSelected(item) {
if (this.selected) {
this.selected.el.classList.remove("pysssss-autocomplete-item--selected");
}
this.selected = item;
this.selected.el.classList.add("pysssss-autocomplete-item--selected");
}
#insertItem() {
if (!this.selected) return;
this.selected.el.click();
}
#getFilteredWords(term) {
term = term.toLocaleLowerCase();
const priorityMatches = [];
const prefixMatches = [];
const includesMatches = [];
for (const word of Object.keys(this.words)) {
const lowerWord = word.toLocaleLowerCase();
if (lowerWord === term) {
// Dont include exact matches
continue;
}
const pos = lowerWord.indexOf(term);
if (pos === -1) {
// No match
continue;
}
const wordInfo = this.words[word];
if (wordInfo.priority) {
priorityMatches.push({ pos, wordInfo });
} else if (pos) {
includesMatches.push({ pos, wordInfo });
} else {
prefixMatches.push({ pos, wordInfo });
}
}
priorityMatches.sort(
(a, b) =>
b.wordInfo.priority - a.wordInfo.priority ||
a.wordInfo.text.length - b.wordInfo.text.length ||
a.wordInfo.text.localeCompare(b.wordInfo.text)
);
const top = priorityMatches.length * 0.2;
return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, 20);
}
#update() {
let before = this.helper.getBeforeCursor();
if (before?.length) {
const m = before.match(/([^\s|,|;|"]+)$/);
if (m) {
before = m[0];
} else {
before = null;
}
}
if (!before) {
this.#hide();
return;
}
this.currentWords = this.#getFilteredWords(before);
if (!this.currentWords.length) {
this.#hide();
return;
}
this.dropdown.style.display = "";
let hasSelected = false;
const items = this.currentWords.map(({ wordInfo, pos }, i) => {
const parts = [
$el("span", {
textContent: wordInfo.text.substr(0, pos),
}),
$el("span.pysssss-autocomplete-highlight", {
textContent: wordInfo.text.substr(pos, before.length),
}),
$el("span", {
textContent: wordInfo.text.substr(pos + before.length),
}),
];
if (wordInfo.hint) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.hint,
})
);
}
if (wordInfo.priority) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.priority,
})
);
}
if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.value,
})
);
}
if (wordInfo.info) {
parts.push(
$el("a.pysssss-autocomplete-item-info", {
textContent: "ℹ️",
title: "View info...",
onclick: (e) => {
e.stopPropagation();
wordInfo.info();
e.preventDefault();
},
})
);
}
const item = $el(
"div.pysssss-autocomplete-item",
{
onclick: () => {
this.el.focus();
let value = wordInfo.value ?? wordInfo.text;
const use_replacer = wordInfo.use_replacer ?? true;
if (TextAreaAutoComplete.replacer && use_replacer) {
value = TextAreaAutoComplete.replacer(value);
}
this.helper.insertAtCursor(value + this.separator, -before.length, wordInfo.caretOffset);
setTimeout(() => {
this.#update();
}, 150);
},
onmousemove: () => {
this.#setSelected(wordInfo);
},
},
parts
);
if (wordInfo === this.selected) {
hasSelected = true;
}
wordInfo.index = i;
wordInfo.el = item;
return item;
});
this.#setSelected(hasSelected ? this.selected : this.currentWords[0].wordInfo);
this.dropdown.replaceChildren(...items);
if (!this.dropdown.parentElement) {
document.body.append(this.dropdown);
}
const position = this.helper.getCursorOffset();
this.dropdown.style.left = (position.left ?? 0) + "px";
this.dropdown.style.top = (position.top ?? 0) + "px";
}
#hide() {
this.selected = null;
this.dropdown.remove();
}
static updateWords(id, words, addGlobal = true) {
const isUpdate = id in TextAreaAutoComplete.groups;
TextAreaAutoComplete.groups[id] = words;
if (addGlobal) {
TextAreaAutoComplete.globalGroups.add(id);
}
if (isUpdate) {
// Remerge all words
TextAreaAutoComplete.globalWords = Object.assign(
{},
...Object.keys(TextAreaAutoComplete.groups)
.filter((k) => TextAreaAutoComplete.globalGroups.has(k))
.map((k) => TextAreaAutoComplete.groups[k])
);
} else if (addGlobal) {
// Just insert the new words
Object.assign(TextAreaAutoComplete.globalWords, words);
}
}
}
@@ -0,0 +1,244 @@
// @ts-check
// @ts-ignore
import { ComfyWidgets } from "../../../../scripts/widgets.js";
// @ts-ignore
import { api } from "../../../../scripts/api.js";
// @ts-ignore
import { app } from "../../../../scripts/app.js";
const PathHelper = {
get(obj, path) {
if (typeof path !== "string") {
// Hardcoded value
return path;
}
if (path[0] === '"' && path[path.length - 1] === '"') {
// Hardcoded string
return JSON.parse(path);
}
// Evaluate the path
path = path.split(".").filter(Boolean);
for (const p of path) {
const k = isNaN(+p) ? p : +p;
obj = obj[k];
}
return obj;
},
set(obj, path, value) {
// https://stackoverflow.com/a/54733755
if (Object(obj) !== obj) return obj; // When obj is not an object
// If not yet an array, get the keys from the string-path
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
path.slice(0, -1).reduce(
(
a,
c,
i // Iterate all of them except the last one
) =>
Object(a[c]) === a[c] // Does the key exist and is its value an object?
? // Yes: then follow that path
a[c]
: // No: create the key. Is the next key a potential array-index?
(a[c] =
Math.abs(path[i + 1]) >> 0 === +path[i + 1]
? [] // Yes: assign a new array object
: {}), // No: assign a new plain object
obj
)[path[path.length - 1]] = value; // Finally assign the value to the last key
return obj; // Return the top-level object to allow chaining
},
};
/***
@typedef { {
left: string;
op: "eq" | "ne",
right: string
} } IfCondition
@typedef { {
type: "if",
condition: Array<IfCondition>,
true?: Array<BindingCallback>,
false?: Array<BindingCallback>
} } IfCallback
@typedef { {
type: "fetch",
url: string,
then: Array<BindingCallback>
} } FetchCallback
@typedef { {
type: "set",
target: string,
value: string
} } SetCallback
@typedef { {
type: "validate-combo",
} } ValidateComboCallback
@typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback
@typedef { {
source: string,
callback: Array<BindingCallback>
} } Binding
***/
/**
* @param {IfCondition} condition
*/
function evaluateCondition(condition, state) {
const left = PathHelper.get(state, condition.left);
const right = PathHelper.get(state, condition.right);
let r;
if (condition.op === "eq") {
r = left === right;
} else {
r = left !== right;
}
return r;
}
/**
* @type { Record<BindingCallback["type"], (cb: any, state: Record<string, any>) => Promise<void>> }
*/
const callbacks = {
/**
* @param {IfCallback} cb
*/
async if(cb, state) {
// For now only support ANDs
let success = true;
for (const condition of cb.condition) {
const r = evaluateCondition(condition, state);
if (!r) {
success = false;
break;
}
}
for (const m of cb[success + ""] ?? []) {
await invokeCallback(m, state);
}
},
/**
* @param {FetchCallback} cb
*/
async fetch(cb, state) {
const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => {
return PathHelper.get(state, v);
});
const res = await (await api.fetchApi(url)).json();
state["$result"] = res;
for (const m of cb.then) {
await invokeCallback(m, state);
}
},
/**
* @param {SetCallback} cb
*/
async set(cb, state) {
const value = PathHelper.get(state, cb.value);
PathHelper.set(state, cb.target, value);
},
async "validate-combo"(cb, state) {
const w = state["$this"];
const valid = w.options.values.includes(w.value);
if (!valid) {
w.value = w.options.values[0];
}
},
};
async function invokeCallback(callback, state) {
if (callback.type in callbacks) {
// @ts-ignore
await callbacks[callback.type](callback, state);
} else {
console.warn(
"%c[🐍 pysssss]",
"color: limegreen",
`[binding ${state.$node.comfyClass}.${state.$this.name}]`,
"unsupported binding callback type:",
callback.type
);
}
}
app.registerExtension({
name: "pysssss.Binding",
beforeRegisterNodeDef(node, nodeData) {
const hasBinding = (v) => {
if (!v) return false;
return Object.values(v).find((c) => c[1]?.["pysssss.binding"]);
};
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
if (hasBinding(inputs)) {
const onAdded = node.prototype.onAdded;
node.prototype.onAdded = function () {
const r = onAdded?.apply(this, arguments);
for (const widget of this.widgets || []) {
const bindings = inputs[widget.name][1]?.["pysssss.binding"];
if (!bindings) continue;
for (const binding of bindings) {
/**
* @type {import("../../../../../web/types/litegraph.d.ts").IWidget}
*/
const source = this.widgets.find((w) => w.name === binding.source);
if (!source) {
console.warn(
"%c[🐍 pysssss]",
"color: limegreen",
`[binding ${node.comfyClass}.${widget.name}]`,
"unable to find source binding widget:",
binding.source,
binding
);
continue;
}
let lastValue;
async function valueChanged() {
const state = {
$this: widget,
$source: source,
$node: node,
};
for (const callback of binding.callback) {
await invokeCallback(callback, state);
}
app.graph.setDirtyCanvas(true, false);
}
const cb = source.callback;
source.callback = function () {
const v = cb?.apply(this, arguments) ?? source.value;
if (v !== lastValue) {
lastValue = v;
valueChanged();
}
return v;
};
lastValue = source.value;
valueChanged();
}
}
return r;
};
}
},
});
@@ -0,0 +1,98 @@
.pysssss-lightbox {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
transition: opacity 0.2s;
}
.pysssss-lightbox-prev,
.pysssss-lightbox-next {
height: 60px;
display: flex;
align-items: center;
}
.pysssss-lightbox-prev:after,
.pysssss-lightbox-next:after {
border-style: solid;
border-width: 0.25em 0.25em 0 0;
display: inline-block;
height: 0.45em;
left: 0.15em;
position: relative;
top: 0.15em;
transform: rotate(-135deg) scale(0.75);
vertical-align: top;
width: 0.45em;
padding: 10px;
font-size: 20px;
margin: 0 10px 0 20px;
transition: color 0.2s;
flex-shrink: 0;
content: "";
}
.pysssss-lightbox-next:after {
transform: rotate(45deg) scale(0.75);
margin: 0 20px 0 0px;
}
.pysssss-lightbox-main {
flex: auto;
text-align: center;
}
.pysssss-lightbox-link {
display: inline-block;
position: relative;
}
.pysssss-lightbox .lds-ring {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.pysssss-lightbox-img {
max-height: 90vh;
max-width: calc(100vw - 130px);
height: auto;
object-fit: contain;
border: 3px solid white;
border-radius: 4px;
transition: opacity 0.2s;
user-select: none;
}
.pysssss-lightbox-img:hover {
border-color: dodgerblue;
}
.pysssss-lightbox-close {
font-size: 80px;
line-height: 1ch;
height: 1ch;
width: 1ch;
position: absolute;
right: 10px;
top: 10px;
padding: 5px;
}
.pysssss-lightbox-close:after {
content: "\00d7";
}
.pysssss-lightbox-close:hover,
.pysssss-lightbox-prev:hover,
.pysssss-lightbox-next:hover {
color: dodgerblue;
cursor: pointer;
}
@@ -0,0 +1,132 @@
import { $el } from "../../../../scripts/ui.js";
import { addStylesheet, getUrl, loadImage } from "./utils.js";
import { createSpinner } from "./spinner.js";
addStylesheet(getUrl("lightbox.css", import.meta.url));
const $$el = (tag, name, ...args) => {
if (name) name = "-" + name;
return $el(tag + ".pysssss-lightbox" + name, ...args);
};
const ani = async (a, t, b) => {
a();
await new Promise((r) => setTimeout(r, t));
b();
};
export class Lightbox {
constructor() {
this.el = $$el("div", "", {
parent: document.body,
onclick: (e) => {
e.stopImmediatePropagation();
this.close();
},
style: {
display: "none",
opacity: 0,
},
});
this.closeBtn = $$el("div", "close", {
parent: this.el,
});
this.prev = $$el("div", "prev", {
parent: this.el,
onclick: (e) => {
this.update(-1);
e.stopImmediatePropagation();
},
});
this.main = $$el("div", "main", {
parent: this.el,
});
this.next = $$el("div", "next", {
parent: this.el,
onclick: (e) => {
this.update(1);
e.stopImmediatePropagation();
},
});
this.link = $$el("a", "link", {
parent: this.main,
target: "_blank",
});
this.spinner = createSpinner();
this.link.appendChild(this.spinner);
this.img = $$el("img", "img", {
style: {
opacity: 0,
},
parent: this.link,
onclick: (e) => {
e.stopImmediatePropagation();
},
});
}
close() {
ani(
() => (this.el.style.opacity = 0),
200,
() => (this.el.style.display = "none")
);
}
async show(images, index) {
this.images = images;
this.index = index || 0;
await this.update(0);
}
async update(shift) {
if (shift < 0 && this.index <= 0) {
return;
}
if (shift > 0 && this.index >= this.images.length - 1) {
return;
}
this.index += shift;
this.prev.style.visibility = this.index ? "unset" : "hidden";
this.next.style.visibility = this.index === this.images.length - 1 ? "hidden" : "unset";
const img = this.images[this.index];
this.el.style.display = "flex";
this.el.clientWidth; // Force a reflow
this.el.style.opacity = 1;
this.img.style.opacity = 0;
this.spinner.style.display = "inline-block";
try {
await loadImage(img);
} catch (err) {
console.error('failed to load image', img, err);
}
this.spinner.style.display = "none";
this.link.href = img;
this.img.src = img;
this.img.style.opacity = 1;
}
}
export const lightbox = new Lightbox();
addEventListener('keydown', (event) => {
if (lightbox.el.style.display === 'none') {
return;
}
const { key } = event;
switch (key) {
case 'ArrowLeft':
case 'a':
lightbox.update(-1);
break;
case 'ArrowRight':
case 'd':
lightbox.update(1);
break;
case 'Escape':
lightbox.close();
break;
}
});
@@ -0,0 +1,104 @@
.pysssss-model-info {
color: white;
font-family: sans-serif;
max-width: 90vw;
}
.pysssss-model-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.pysssss-model-info h2 {
text-align: center;
margin: 0 0 10px 0;
}
.pysssss-model-info p {
margin: 5px 0;
}
.pysssss-model-info a {
color: dodgerblue;
}
.pysssss-model-info a:hover {
text-decoration: underline;
}
.pysssss-model-tags-list {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
max-height: 200px;
overflow: auto;
margin: 10px 0;
padding: 0;
}
.pysssss-model-tag {
background-color: rgb(128, 213, 247);
color: #000;
display: flex;
align-items: center;
gap: 5px;
border-radius: 5px;
padding: 2px 5px;
cursor: pointer;
}
.pysssss-model-tag--selected span::before {
content: "✅";
position: absolute;
background-color: dodgerblue;
left: 0;
top: 0;
right: 0;
bottom: 0;
text-align: center;
}
.pysssss-model-tag:hover {
outline: 2px solid dodgerblue;
}
.pysssss-model-tag p {
margin: 0;
}
.pysssss-model-tag span {
text-align: center;
border-radius: 5px;
background-color: dodgerblue;
color: #fff;
padding: 2px;
position: relative;
min-width: 20px;
overflow: hidden;
}
.pysssss-model-metadata .comfy-modal-content {
max-width: 100%;
}
.pysssss-model-metadata label {
margin-right: 1ch;
color: #ccc;
}
.pysssss-model-metadata span {
color: dodgerblue;
}
.pysssss-preview {
max-width: 50%;
margin-left: 10px;
position: relative;
}
.pysssss-preview img {
max-height: 300px;
}
.pysssss-preview button {
position: absolute;
font-size: 12px;
bottom: 10px;
right: 10px;
}
.pysssss-model-notes {
background-color: rgba(0, 0, 0, 0.25);
padding: 5px;
margin-top: 5px;
}
.pysssss-model-notes:empty {
display: none;
}
@@ -0,0 +1,303 @@
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { api } from "../../../../scripts/api.js";
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
class MetadataDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("pysssss-model-metadata");
}
show(metadata) {
super.show(
$el(
"div",
Object.keys(metadata).map((k) =>
$el("div", [$el("label", { textContent: k }), $el("span", { textContent: metadata[k] })])
)
)
);
}
}
export class ModelInfoDialog extends ComfyDialog {
constructor(name) {
super();
this.name = name;
this.element.classList.add("pysssss-model-info");
}
get customNotes() {
return this.metadata["pysssss.notes"];
}
set customNotes(v) {
this.metadata["pysssss.notes"] = v;
}
get hash() {
return this.metadata["pysssss.sha256"];
}
async show(type, value) {
this.type = type;
const req = api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`${type}/${value}`));
this.info = $el("div", { style: { flex: "auto" } });
this.img = $el("img", { style: { display: "none" } });
this.imgWrapper = $el("div.pysssss-preview", [this.img]);
this.main = $el("main", { style: { display: "flex" } }, [this.info, this.imgWrapper]);
this.content = $el("div.pysssss-model-content", [$el("h2", { textContent: this.name }), this.main]);
const loading = $el("div", { textContent: "ℹ️ Loading...", parent: this.content });
super.show(this.content);
this.metadata = await (await req).json();
this.viewMetadata.style.cursor = this.viewMetadata.style.opacity = "";
this.viewMetadata.removeAttribute("disabled");
loading.remove();
this.addInfo();
}
createButtons() {
const btns = super.createButtons();
this.viewMetadata = $el("button", {
type: "button",
textContent: "View raw metadata",
disabled: "disabled",
style: {
opacity: 0.5,
cursor: "not-allowed",
},
onclick: (e) => {
if (this.metadata) {
new MetadataDialog().show(this.metadata);
}
},
});
btns.unshift(this.viewMetadata);
return btns;
}
getNoteInfo() {
function parseNote() {
if (!this.customNotes) return [];
let notes = [];
// Extract links from notes
const r = new RegExp("(\\bhttps?:\\/\\/[^\\s]+)", "g");
let end = 0;
let m;
do {
m = r.exec(this.customNotes);
let pos;
let fin = 0;
if (m) {
pos = m.index;
fin = m.index + m[0].length;
} else {
pos = this.customNotes.length;
}
let pre = this.customNotes.substring(end, pos);
if (pre) {
pre = pre.replaceAll("\n", "<br>");
notes.push(
$el("span", {
innerHTML: pre,
})
);
}
if (m) {
notes.push(
$el("a", {
href: m[0],
textContent: m[0],
target: "_blank",
})
);
}
end = fin;
} while (m);
return notes;
}
let textarea;
let notesContainer;
const editText = "✏️ Edit";
const edit = $el("a", {
textContent: editText,
href: "#",
style: {
float: "right",
color: "greenyellow",
textDecoration: "none",
},
onclick: async (e) => {
e.preventDefault();
if (textarea) {
this.customNotes = textarea.value;
const resp = await api.fetchApi(
"/pysssss/metadata/notes/" + encodeURIComponent(`${this.type}/${this.name}`),
{
method: "POST",
body: this.customNotes,
}
);
if (resp.status !== 200) {
console.error(resp);
alert(`Error saving notes (${req.status}) ${req.statusText}`);
return;
}
e.target.textContent = editText;
textarea.remove();
textarea = null;
notesContainer.replaceChildren(...parseNote.call(this));
} else {
e.target.textContent = "💾 Save";
textarea = $el("textarea", {
style: {
width: "100%",
minWidth: "200px",
minHeight: "50px",
},
textContent: this.customNotes,
});
e.target.after(textarea);
notesContainer.replaceChildren();
textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px";
}
},
});
notesContainer = $el("div.pysssss-model-notes", parseNote.call(this));
return $el(
"div",
{
style: { display: "contents" },
},
[edit, notesContainer]
);
}
addInfo() {
this.addInfoEntry("Notes", this.getNoteInfo());
}
addInfoEntry(name, value) {
return $el(
"p",
{
parent: this.info,
},
[
typeof name === "string" ? $el("label", { textContent: name + ": " }) : name,
typeof value === "string" ? $el("span", { textContent: value }) : value,
]
);
}
async getCivitaiDetails() {
const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash);
if (req.status === 200) {
return await req.json();
} else if (req.status === 404) {
throw new Error("Model not found");
} else {
throw new Error(`Error loading info (${req.status}) ${req.statusText}`);
}
}
addCivitaiInfo() {
const promise = this.getCivitaiDetails();
const content = $el("span", { textContent: "ℹ️ Loading..." });
this.addInfoEntry(
$el("label", [
$el("img", {
style: {
width: "18px",
position: "relative",
top: "3px",
margin: "0 5px 0 0",
},
src: "https://civitai.com/favicon.ico",
}),
$el("span", { textContent: "Civitai: " }),
]),
content
);
return promise
.then((info) => {
content.replaceChildren(
$el("a", {
href: "https://civitai.com/models/" + info.modelId,
textContent: "View " + info.model.name,
target: "_blank",
})
);
if (info.images?.length) {
this.img.src = info.images[0].url;
this.img.style.display = "";
this.imgSave = $el("button", {
textContent: "Use as preview",
parent: this.imgWrapper,
onclick: async () => {
// Convert the preview to a blob
const blob = await (await fetch(this.img.src)).blob();
// Store it in temp
const name = "temp_preview." + new URL(this.img.src).pathname.split(".")[1];
const body = new FormData();
body.append("image", new File([blob], name));
body.append("overwrite", "true");
body.append("type", "temp");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status !== 200) {
console.error(resp);
alert(`Error saving preview (${req.status}) ${req.statusText}`);
return;
}
// Use as preview
await api.fetchApi("/pysssss/save/" + encodeURIComponent(`${this.type}/${this.name}`), {
method: "POST",
body: JSON.stringify({
filename: name,
type: "temp",
}),
headers: {
"content-type": "application/json",
},
});
app.refreshComboInNodes();
},
});
}
return info;
})
.catch((err) => {
content.textContent = "⚠️ " + err.message;
});
}
}
@@ -0,0 +1,35 @@
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 5px solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@@ -0,0 +1,9 @@
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
}
@@ -0,0 +1,30 @@
import { $el } from "../../../../scripts/ui.js";
export function addStylesheet(url) {
if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css";
}
$el("link", {
parent: document.head,
rel: "stylesheet",
type: "text/css",
href: url.startsWith("http") ? url : getUrl(url),
});
}
export function getUrl(path, baseUrl) {
if (baseUrl) {
return new URL(path, baseUrl).toString();
} else {
return new URL("../" + path, import.meta.url).toString();
}
}
export async function loadImage(url) {
return new Promise((res, rej) => {
const img = new Image();
img.onload = res;
img.onerror = rej;
img.src = url;
});
}
@@ -0,0 +1,90 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.ContextMenuHook",
init() {
const getOrSet = (target, name, create) => {
if (name in target) return target[name];
return (target[name] = create());
};
const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__"));
const store = getOrSet(window, symbol, () => ({}));
const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({}));
for (const e of ["ctor", "preAddItem", "addItem"]) {
if (!contextMenuHook[e]) {
contextMenuHook[e] = [];
}
}
// Big ol' hack to get allow customizing the context menu
// Replace the addItem function with our own that wraps the context of "this" with a proxy
// That proxy then replaces the constructor with another proxy
// That proxy then calls the custom ContextMenu that supports filters
const ctorProxy = new Proxy(LiteGraph.ContextMenu, {
construct(target, args) {
return new LiteGraph.ContextMenu(...args);
},
});
function triggerCallbacks(name, getArgs, handler) {
const callbacks = contextMenuHook[name];
if (callbacks && callbacks instanceof Array) {
for (const cb of callbacks) {
const r = cb(...getArgs());
handler?.call(this, r);
}
} else {
console.warn("[pysssss 🐍]", `invalid ${name} callbacks`, callbacks, name in contextMenuHook);
}
}
const addItem = LiteGraph.ContextMenu.prototype.addItem;
LiteGraph.ContextMenu.prototype.addItem = function () {
const proxy = new Proxy(this, {
get(target, prop) {
if (prop === "constructor") {
return ctorProxy;
}
return target[prop];
},
});
proxy.__target__ = this;
let el;
let args = arguments;
triggerCallbacks(
"preAddItem",
() => [el, this, args],
(r) => {
if (r !== undefined) el = r;
}
);
if (el === undefined) {
el = addItem.apply(proxy, arguments);
}
triggerCallbacks(
"addItem",
() => [el, this, args],
(r) => {
if (r !== undefined) el = r;
}
);
return el;
};
// We also need to patch the ContextMenu constructor to unwrap the parent else it fails a LiteGraph type check
const ctxMenu = LiteGraph.ContextMenu;
LiteGraph.ContextMenu = function (values, options) {
if (options?.parentMenu) {
if (options.parentMenu.__target__) {
options.parentMenu = options.parentMenu.__target__;
}
}
triggerCallbacks("ctor", () => [values, options]);
ctxMenu.call(this, values, options);
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
},
});
@@ -0,0 +1,98 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
const colorShade = (col, amt) => {
col = col.replace(/^#/, "");
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
let [r, g, b] = col.match(/.{2}/g);
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};
app.registerExtension({
name: "pysssss.CustomColors",
setup() {
let picker;
let activeNode;
const onMenuNodeColors = LGraphCanvas.onMenuNodeColors;
LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) {
const r = onMenuNodeColors.apply(this, arguments);
requestAnimationFrame(() => {
const menus = document.querySelectorAll(".litecontextmenu");
for (let i = menus.length - 1; i >= 0; i--) {
if (menus[i].firstElementChild.textContent.includes("No color")) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!picker) {
picker = $el("input", {
type: "color",
parent: document.body,
style: {
display: "none",
},
});
picker.onchange = () => {
if (activeNode) {
const fApplyColor = function(node){
if (picker.value) {
if (node.constructor === LiteGraph.LGraphGroup) {
node.color = picker.value;
} else {
node.color = colorShade(picker.value, 20);
node.bgcolor = picker.value;
}
}
}
const graphcanvas = LGraphCanvas.active_canvas;
if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1){
fApplyColor(activeNode);
} else {
for (let i in graphcanvas.selected_nodes) {
fApplyColor(graphcanvas.selected_nodes[i]);
}
}
activeNode.setDirtyCanvas(true, true);
}
};
}
activeNode = null;
picker.value = node.bgcolor;
activeNode = node;
picker.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🎨 Custom",
}),
]
);
break;
}
}
});
return r;
};
},
});
@@ -0,0 +1,44 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
// Simple script that adds the current queue size to the window title
// Adds a favicon that changes color while active
app.registerExtension({
name: "pysssss.FaviconStatus",
setup() {
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
let executing = false;
const update = () => (link.href = new URL(`assets/favicon${executing ? "-active" : ""}.ico`, import.meta.url));
for (const e of ["execution_start", "progress"]) {
api.addEventListener(e, () => {
executing = true;
update();
});
}
api.addEventListener("executing", ({ detail }) => {
// null will be sent when it's finished
executing = !!detail;
update();
});
api.addEventListener("status", ({ detail }) => {
let title = "ComfyUI";
if (detail && detail.exec_info.queue_remaining) {
title = `(${detail.exec_info.queue_remaining}) ${title}`;
}
document.title = title;
update();
executing = false;
});
update();
},
});
@@ -0,0 +1,91 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.GraphArrange",
setup(app) {
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push({ content: "Arrange (float left)", callback: () => graph.arrange() });
options.push({
content: "Arrange (float right)",
callback: () => {
(function () {
var margin = 50;
var layout;
const nodes = this.computeExecutionOrder(false, true);
const columns = [];
// Find node first use
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
let max = null;
for (const out of node.outputs || []) {
if (out.links) {
for (const link of out.links) {
const outNode = app.graph.getNodeById(app.graph.links[link].target_id);
if (!outNode) continue;
var l = outNode._level - 1;
if (max === null) max = l;
else if (l < max) max = l;
}
}
}
if (max != null) node._level = max;
}
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i];
const col = node._level || 1;
if (!columns[col]) {
columns[col] = [];
}
columns[col].push(node);
}
let x = margin;
for (let i = 0; i < columns.length; ++i) {
const column = columns[i];
if (!column) {
continue;
}
column.sort((a, b) => {
var as = !(a.type === "SaveImage" || a.type === "PreviewImage");
var bs = !(b.type === "SaveImage" || b.type === "PreviewImage");
var r = as - bs;
if (r === 0) r = (a.inputs?.length || 0) - (b.inputs?.length || 0);
if (r === 0) r = (a.outputs?.length || 0) - (b.outputs?.length || 0);
return r;
});
let max_size = 100;
let y = margin + LiteGraph.NODE_TITLE_HEIGHT;
for (let j = 0; j < column.length; ++j) {
const node = column[j];
node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x;
node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y;
const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0;
if (node.size[max_size_index] > max_size) {
max_size = node.size[max_size_index];
}
const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1;
y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + j;
}
// Right align in column
for (let j = 0; j < column.length; ++j) {
const node = column[j];
node.pos[0] += max_size - node.size[0];
}
x += max_size + margin;
}
this.setDirtyCanvas(true, true);
}).apply(app.graph);
},
});
return options;
};
},
});
@@ -0,0 +1,445 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
import { lightbox } from "./common/lightbox.js";
$el("style", {
textContent: `
.pysssss-image-feed {
position: absolute;
background: var(--comfy-menu-bg);
color: var(--fg-color);
z-index: 99;
font-family: sans-serif;
font-size: 12px;
display: flex;
flex-direction: column;
}
.pysssss-image-feed--top, .pysssss-image-feed--bottom {
width: 100vw;
min-height: 30px;
max-height: calc(var(--max-size, 20) * 1vh);
}
.pysssss-image-feed--top {
top: 0;
}
.pysssss-image-feed--bottom {
bottom: 0;
flex-direction: column-reverse;
padding-top: 5px;
}
.pysssss-image-feed--left, .pysssss-image-feed--right {
top: 0;
height: 100vh;
min-width: 200px;
max-width: calc(var(--max-size, 10) * 1vw);
}
.pysssss-image-feed--left {
left: 0;
}
.pysssss-image-feed--right {
right: 0;
}
.pysssss-image-feed--left .pysssss-image-feed-menu, .pysssss-image-feed--right .pysssss-image-feed-menu {
flex-direction: column;
}
.pysssss-image-feed-menu {
position: relative;
flex: 0 1 min-content;
display: flex;
gap: 5px;
padding: 5px;
justify-content: space-between;
}
.pysssss-image-feed-btn-group {
align-items: stretch;
display: flex;
gap: .5rem;
flex: 0 1 fit-content;
justify-content: flex-end;
}
.pysssss-image-feed-btn {
background-color:var(--comfy-input-bg);
border-radius:5px;
border:2px solid var(--border-color);
color: var(--fg-color);
cursor:pointer;
display:inline-block;
flex: 0 1 fit-content;
text-decoration:none;
}
.pysssss-image-feed-btn.sizing-btn:checked {
filter: invert();
}
.pysssss-image-feed-btn.clear-btn {
padding: 5px 20px;
}
.pysssss-image-feed-btn.hide-btn {
padding: 5px;
aspect-ratio: 1 / 1;
}
.pysssss-image-feed-btn:hover {
filter: brightness(1.2);
}
.pysssss-image-feed-btn:active {
position:relative;
top:1px;
}
.pysssss-image-feed-menu section {
border-radius: 5px;
background: rgba(0,0,0,0.6);
padding: 0 5px;
display: flex;
gap: 5px;
align-items: center;
position: relative;
}
.pysssss-image-feed-menu section span {
white-space: nowrap;
}
.pysssss-image-feed-menu section input {
flex: 1 1 100%;
background: rgba(0,0,0,0.6);
border-radius: 5px;
overflow: hidden;
z-index: 100;
}
.sizing-menu {
position: relative;
}
.size-controls-flyout {
position: absolute;
transform: scaleX(0%);
transition: 200ms ease-out;
transition-delay: 500ms;
z-index: 101;
width: 300px;
}
.sizing-menu:hover .size-controls-flyout {
transform: scale(1, 1);
transition: 200ms linear;
transition-delay: 0;
}
.pysssss-image-feed--bottom .size-controls-flyout {
transform: scale(1,0);
transform-origin: bottom;
bottom: 0;
left: 0;
}
.pysssss-image-feed--top .size-controls-flyout {
transform: scale(1,0);
transform-origin: top;
top: 0;
left: 0;
}
.pysssss-image-feed--left .size-controls-flyout {
transform: scale(0, 1);
transform-origin: left;
top: 0;
left: 0;
}
.pysssss-image-feed--right .size-controls-flyout {
transform: scale(0, 1);
transform-origin: right;
top: 0;
right: 0;
}
.pysssss-image-feed-menu > * {
min-height: 24px;
}
.pysssss-image-feed-list {
flex: 1 1 auto;
overflow-y: auto;
display: grid;
align-items: center;
justify-content: center;
gap: 4px;
grid-auto-rows: min-content;
grid-template-columns: repeat(var(--img-sz, 3), 1fr);
transition: 100ms linear;
scrollbar-gutter: stable both-edges;
padding: 5px;
background: var(--comfy-input-bg);
border-radius: 5px;
margin: 5px;
margin-top: 0px;
}
.pysssss-image-feed-list:empty {
display: none;
}
.pysssss-image-feed-list div {
height: 100%;
text-align: center;
}
.pysssss-image-feed-list::-webkit-scrollbar {
background: var(--comfy-input-bg);
border-radius: 5px;
}
.pysssss-image-feed-list::-webkit-scrollbar-thumb {
background:var(--comfy-menu-bg);
border: 5px solid transparent;
border-radius: 8px;
background-clip: content-box;
}
.pysssss-image-feed-list::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
background-clip: content-box;
}
.pysssss-image-feed-list img {
object-fit: var(--img-fit, contain);
max-width: 100%;
max-height: calc(var(--max-size) * 1vh);
border-radius: 4px;
}
.pysssss-image-feed-list img:hover {
filter: brightness(1.2);
}`,
parent: document.body,
});
app.registerExtension({
name: "pysssss.ImageFeed",
setup() {
let visible = true;
const showButton = $el("button.comfy-settings-btn", {
textContent: "🖼️",
style: {
right: "16px",
cursor: "pointer",
display: "none",
},
});
const getVal = (n, d) => {
const v = localStorage.getItem("pysssss.ImageFeed." + n);
if (v && !isNaN(+v)) {
return v;
}
return d;
};
const saveVal = (n, v) => {
localStorage.setItem("pysssss.ImageFeed." + n, v);
};
const imageFeed = $el("div.pysssss-image-feed", {
parent: document.body,
});
const imageList = $el("div.pysssss-image-feed-list");
const feedLocation = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.Location",
name: "🐍 Image Feed Location",
defaultValue: "bottom",
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "🐍 Image Feed Location:",
}),
]),
$el("td", [
$el(
"select",
{
style: {
fontSize: "14px",
},
oninput: (e) => {
feedLocation.value = e.target.value;
imageFeed.className = `pysssss-image-feed pysssss-image-feed--${feedLocation.value}`;
},
},
["left", "top", "right", "bottom"].map((m) =>
$el("option", {
value: m,
textContent: m,
selected: feedLocation.value === m,
})
)
),
]),
]);
},
onChange(value) {
imageFeed.className = `pysssss-image-feed pysssss-image-feed--${value}`;
},
});
const feedDirection = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.Direction",
name: "🐍 Image Feed Direction",
defaultValue: "newest first",
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "🐍 Image Feed Direction:",
}),
]),
$el("td", [
$el(
"select",
{
style: {
fontSize: "14px",
},
oninput: (e) => {
feedDirection.value = e.target.value;
imageList.replaceChildren(...[...imageList.childNodes].reverse());
},
},
["newest first", "oldest first"].map((m) =>
$el("option", {
value: m,
textContent: m,
selected: feedDirection.value === m,
})
)
),
]),
]);
},
});
const clearButton = $el("button.pysssss-image-feed-btn.clear-btn", {
textContent: "Clear",
onclick: () => imageList.replaceChildren(),
});
const hideButton = $el("button.pysssss-image-feed-btn.hide-btn", {
textContent: "❌",
onclick: () => {
imageFeed.style.display = "none";
showButton.style.display = "unset";
saveVal("Visible", 0);
visible = false;
},
});
let columnInput;
function updateColumnCount(v) {
columnInput.parentElement.title = `Controls the number of columns in the feed (${v} columns).\nClick label to set custom value.`;
imageFeed.style.setProperty("--img-sz", v);
saveVal("ImageSize", v);
columnInput.max = Math.max(10, v, columnInput.max);
columnInput.value = v;
}
imageFeed.append(
$el("div.pysssss-image-feed-menu", [
$el("section.sizing-menu", {}, [
$el("label.size-control-handle", { textContent: "↹ Resize Feed" }),
$el("div.size-controls-flyout", {}, [
$el("section.size-control.feed-size-control", {}, [
$el("span", {
textContent: "Feed Size...",
}),
$el("input", {
type: "range",
min: 10,
max: 80,
oninput: (e) => {
e.target.parentElement.title = `Controls the maximum size of the image feed panel (${e.target.value}vh)`;
imageFeed.style.setProperty("--max-size", e.target.value);
saveVal("FeedSize", e.target.value);
},
$: (el) => {
requestAnimationFrame(() => {
el.value = getVal("FeedSize", 25);
el.oninput({ target: el });
});
},
}),
]),
$el("section.size-control.image-size-control", {}, [
$el("a", {
textContent: "Column count...",
style: {
cursor: "pointer",
textDecoration: "underline",
},
onclick: () => {
const v = +prompt("Enter custom column count", 20);
if (!isNaN(v)) {
updateColumnCount(v);
}
},
}),
$el("input", {
type: "range",
min: 1,
max: 10,
step: 1,
oninput: (e) => {
updateColumnCount(e.target.value);
},
$: (el) => {
columnInput = el;
requestAnimationFrame(() => {
updateColumnCount(getVal("ImageSize", 4));
});
},
}),
]),
]),
]),
$el("div.pysssss-image-feed-btn-group", {}, [clearButton, hideButton]),
]),
imageList
);
showButton.onclick = () => {
imageFeed.style.display = "block";
showButton.style.display = "none";
saveVal("Visible", 1);
visible = true;
};
document.querySelector(".comfy-settings-btn").after(showButton);
if (!+getVal("Visible", 1)) {
hideButton.onclick();
}
api.addEventListener("executed", ({ detail }) => {
if (visible && detail?.output?.images) {
if(detail.node?.includes?.(":")) {
// Ignore group nodes
const n = app.graph.getNodeById(detail.node.split(":")[0]);
if(n?.getInnerNodes) return;
}
for (const src of detail.output.images) {
const href = `./view?filename=${encodeURIComponent(src.filename)}&type=${
src.type
}&subfolder=${encodeURIComponent(src.subfolder)}&t=${+new Date()}`;
const method = feedDirection.value === "newest first" ? "prepend" : "append";
imageList[method](
$el("div", [
$el(
"a",
{
target: "_blank",
href,
onclick: (e) => {
const imgs = [...imageList.querySelectorAll("img")].map((img) => img.getAttribute("src"));
lightbox.show(imgs, imgs.indexOf(href));
e.preventDefault();
},
},
[$el("img", { src: href })]
),
])
);
}
}
});
},
});
@@ -0,0 +1,54 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.KSamplerAdvDenoise",
async beforeRegisterNodeDef(nodeType) {
// Add menu options to conver to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = origGetExtraMenuOptions?.apply?.(this, arguments);
let stepsWidget = null;
let startAtWidget = null;
let endAtWidget = null;
for (const w of this.widgets || []) {
if (w.name === "steps") {
stepsWidget = w;
} else if (w.name === "start_at_step") {
startAtWidget = w;
} else if (w.name === "end_at_step") {
endAtWidget = w;
}
}
if (stepsWidget && startAtWidget && endAtWidget) {
options.push(
{
content: "Set Denoise",
callback: () => {
const steps = +prompt("How many steps do you want?", 15);
if (isNaN(steps)) {
return;
}
const denoise = +prompt("How much denoise? (0-1)", 0.5);
if (isNaN(denoise)) {
return;
}
stepsWidget.value = Math.floor(steps / Math.max(0, Math.min(1, denoise)));
stepsWidget.callback?.(stepsWidget.value);
startAtWidget.value = stepsWidget.value - steps;
startAtWidget.callback?.(startAtWidget.value);
endAtWidget.value = stepsWidget.value;
endAtWidget.callback?.(endAtWidget.value);
},
},
null
);
}
return r;
};
},
});
@@ -0,0 +1,57 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
const id = "pysssss.LinkRenderMode";
const ext = {
name: id,
async setup(app) {
if (app.extensions.find((ext) => ext.name === "Comfy.LinkRenderMode")) {
console.log("%c[🐍 pysssss]", "color: limegreen", "Skipping LinkRenderMode as core extension found");
return;
}
const setting = app.ui.settings.addSetting({
id,
name: "🐍 Link Render Mode",
defaultValue: 2,
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: "🐍 Link Render Mode:",
}),
]),
$el("td", [
$el(
"select",
{
textContent: "Manage",
style: {
fontSize: "14px",
},
oninput: (e) => {
setting.value = e.target.value;
app.canvas.links_render_mode = +e.target.value;
app.graph.setDirtyCanvas(true, true);
},
},
LiteGraph.LINK_RENDER_MODES.map((m, i) =>
$el("option", {
value: i,
textContent: m,
selected: i == app.canvas.links_render_mode,
})
)
),
]),
]);
},
onChange(value) {
app.canvas.links_render_mode = +value;
app.graph.setDirtyCanvas(true);
},
});
},
};
app.registerExtension(ext);
@@ -0,0 +1,185 @@
import { app } from "../../../scripts/app.js";
// Adds lock/unlock menu item for nodes + groups to prevent moving / resizing them
const LOCKED = Symbol();
function lockArray(arr, isLocked) {
const v = [];
for (let i = 0; i < 2; i++) {
v[i] = arr[i];
Object.defineProperty(arr, i, {
get() {
return v[i];
},
set(value) {
if (!isLocked()) {
v[i] = value;
}
},
});
}
}
app.registerExtension({
name: "pysssss.Locking",
init() {
function lockGroup(node) {
node[LOCKED] = true;
}
// Add the locked flag to serialization
const serialize = LGraphGroup.prototype.serialize;
LGraphGroup.prototype.serialize = function () {
const o = serialize.apply(this, arguments);
o.locked = !!this[LOCKED];
return o;
};
// On initial configure lock group if required
const configure = LGraphGroup.prototype.configure;
LGraphGroup.prototype.configure = function (o) {
configure.apply(this, arguments);
if (o.locked) {
lockGroup(this);
}
};
// Allow click through locked groups
const getGroupOnPos = LGraph.prototype.getGroupOnPos;
LGraph.prototype.getGroupOnPos = function () {
const r = getGroupOnPos.apply(this, arguments);
if (r && r[LOCKED] && !new Error().stack.includes("processContextMenu")) return null;
return r;
};
// Add menu options for lock/unlock
const getGroupMenuOptions = LGraphCanvas.prototype.getGroupMenuOptions;
LGraphCanvas.prototype.getGroupMenuOptions = function (node) {
const opts = getGroupMenuOptions.apply(this, arguments);
opts.unshift(
node[LOCKED]
? {
content: "Unlock",
callback: () => {
delete node[LOCKED];
},
}
: {
content: "Lock",
callback: () => lockGroup(node),
},
null
);
return opts;
};
},
setup() {
const drawNodeShape = LGraphCanvas.prototype.drawNodeShape;
LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) {
const res = drawNodeShape.apply(this, arguments);
if (node[LOCKED]) {
ctx.fillText("🔒", node.size[0] - 20, -10);
}
return res;
};
},
async beforeRegisterNodeDef(nodeType) {
const nodesArray = (nodes) => {
if (nodes) {
if (nodes instanceof Array) {
return nodes;
}
return [nodes];
}
return Object.values(app.canvas.selected_nodes);
};
function unlockNode(nodes) {
nodes = nodesArray(nodes);
for (const node of nodes) {
delete node[LOCKED];
}
app.graph.setDirtyCanvas(true, false);
}
function lockNode(nodes) {
nodes = nodesArray(nodes);
for (const node of nodes) {
if (node[LOCKED]) continue;
node[LOCKED] = true;
// Same hack as above
lockArray(node.pos, () => !!node[LOCKED]);
// Size is set by both replacing the value and setting individual values
// So define a new property that can prevent reassignment
const sz = [node.size[0], node.size[1]];
Object.defineProperty(node, "size", {
get() {
return sz;
},
set(value) {
if (!node[LOCKED]) {
sz[0] = value[0];
sz[1] = value[1];
}
},
});
// And then lock each element if required
lockArray(sz, () => !!node[LOCKED]);
}
app.graph.setDirtyCanvas(true, false);
}
// Add menu options for lock/unlock
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = getExtraMenuOptions ? getExtraMenuOptions.apply(this, arguments) : undefined;
options.splice(
options.findIndex((o) => o?.content === "Properties") + 1,
0,
null,
this[LOCKED]
? {
content: "Unlock",
callback: () => {
unlockNode();
},
}
: {
content: "Lock",
callback: () => lockNode(),
}
);
return r;
};
// Add the locked flag to serialization
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (o) {
if (onSerialize) {
onSerialize.apply(this, arguments);
}
o.locked = this[LOCKED];
};
// On initial configure lock node if required
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (o) {
if (onConfigure) {
onConfigure.apply(this, arguments);
}
if (o.locked) {
lockNode(this);
}
};
},
});
@@ -0,0 +1,35 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "pysssss.MathExpression",
init() {
const STRING = ComfyWidgets.STRING;
ComfyWidgets.STRING = function (node, inputName, inputData) {
const r = STRING.apply(this, arguments);
r.widget.dynamicPrompts = inputData?.[1].dynamicPrompts;
return r;
};
},
beforeRegisterNodeDef(nodeType) {
if (nodeType.comfyClass === "MathExpression|pysssss") {
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
const r = onDrawForeground?.apply?.(this, arguments);
const v = app.nodeOutputs?.[this.id + ""];
if (!this.flags.collapsed && v) {
const text = v.value[0] + "";
ctx.save();
ctx.font = "bold 12px sans-serif";
ctx.fillStyle = "dodgerblue";
const sz = ctx.measureText(text);
ctx.fillText(text, this.size[0] - sz.width - 5, LiteGraph.NODE_SLOT_HEIGHT * 3);
ctx.restore();
}
return r;
};
}
},
});
@@ -0,0 +1,49 @@
import { app } from "../../../scripts/app.js";
const id = "pysssss.MiddleClickAddDefaultNode";
const ext = {
name: id,
async setup(app) {
app.ui.settings.addSetting({
id,
name: "🐍 Middle click slot to add",
defaultValue: "Reroute",
type: "combo",
options: (value) =>
[
...Object.keys(LiteGraph.registered_node_types)
.filter((k) => k.includes("Reroute"))
.sort((a, b) => {
if (a === "Reroute") return -1;
if (b === "Reroute") return 1;
return a.localeCompare(b);
}),
"[None]",
].map((m) => ({
value: m,
text: m,
selected: !value ? m === "[None]" : m === value,
})),
onChange(value) {
const enable = value && value !== "[None]";
if (value === true) {
value = "Reroute";
}
LiteGraph.middle_click_slot_add_default_node = enable;
if (enable) {
for (const arr of Object.values(LiteGraph.slot_types_default_in).concat(
Object.values(LiteGraph.slot_types_default_out)
)) {
const idx = arr.indexOf(value);
if (idx !== 0) {
arr.splice(idx, 1);
}
arr.unshift(value);
}
}
},
});
},
};
app.registerExtension(ext);
@@ -0,0 +1,268 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
import { ModelInfoDialog } from "./common/modelInfoDialog.js";
const MAX_TAGS = 500;
export class LoraInfoDialog extends ModelInfoDialog {
getTagFrequency() {
if (!this.metadata.ss_tag_frequency) return [];
const datasets = JSON.parse(this.metadata.ss_tag_frequency);
const tags = {};
for (const setName in datasets) {
const set = datasets[setName];
for (const t in set) {
if (t in tags) {
tags[t] += set[t];
} else {
tags[t] = set[t];
}
}
}
return Object.entries(tags).sort((a, b) => b[1] - a[1]);
}
getResolutions() {
let res = [];
if (this.metadata.ss_bucket_info) {
const parsed = JSON.parse(this.metadata.ss_bucket_info);
if (parsed?.buckets) {
for (const { resolution, count } of Object.values(parsed.buckets)) {
res.push([count, `${resolution.join("x")} * ${count}`]);
}
}
}
res = res.sort((a, b) => b[0] - a[0]).map((a) => a[1]);
let r = this.metadata.ss_resolution;
if (r) {
const s = r.split(",");
const w = s[0].replace("(", "");
const h = s[1].replace(")", "");
res.push(`${w.trim()}x${h.trim()} (Base res)`);
} else if ((r = this.metadata["modelspec.resolution"])) {
res.push(r + " (Base res");
}
if (!res.length) {
res.push("⚠️ Unknown");
}
return res;
}
getTagList(tags) {
return tags.map((t) =>
$el(
"li.pysssss-model-tag",
{
dataset: {
tag: t[0],
},
$: (el) => {
el.onclick = () => {
el.classList.toggle("pysssss-model-tag--selected");
};
},
},
[
$el("p", {
textContent: t[0],
}),
$el("span", {
textContent: t[1],
}),
]
)
);
}
addTags() {
let tags = this.getTagFrequency();
let hasMore;
if (tags?.length) {
const c = tags.length;
let list;
if (c > MAX_TAGS) {
tags = tags.slice(0, MAX_TAGS);
hasMore = $el("p", [
$el("span", { textContent: `⚠️ Only showing first ${MAX_TAGS} tags ` }),
$el("a", {
href: "#",
textContent: `Show all ${c}`,
onclick: () => {
list.replaceChildren(...this.getTagList(this.getTagFrequency()));
hasMore.remove();
},
}),
]);
}
list = $el("ol.pysssss-model-tags-list", this.getTagList(tags));
this.tags = $el("div", [list]);
} else {
this.tags = $el("p", { textContent: "⚠️ No tag frequency metadata found" });
}
this.content.append(this.tags);
if (hasMore) {
this.content.append(hasMore);
}
}
async addInfo() {
this.addInfoEntry("Name", this.metadata.ss_output_name || "⚠️ Unknown");
this.addInfoEntry("Base Model", this.metadata.ss_sd_model_name || "⚠️ Unknown");
this.addInfoEntry("Clip Skip", this.metadata.ss_clip_skip || "⚠️ Unknown");
this.addInfoEntry(
"Resolution",
$el(
"select",
this.getResolutions().map((r) => $el("option", { textContent: r }))
)
);
super.addInfo();
const p = this.addCivitaiInfo();
this.addTags();
const info = await p;
if (info) {
$el(
"p",
{
parent: this.content,
textContent: "Trained Words: ",
},
[
$el("pre", {
textContent: info.trainedWords.join(", "),
style: {
whiteSpace: "pre-wrap",
margin: "10px 0",
background: "#222",
padding: "5px",
borderRadius: "5px",
maxHeight: "250px",
overflow: "auto",
},
}),
]
);
$el("div", {
parent: this.content,
innerHTML: info.description,
style: {
maxHeight: "250px",
overflow: "auto",
},
});
}
}
createButtons() {
const btns = super.createButtons();
function copyTags(e, tags) {
const textarea = $el("textarea", {
parent: document.body,
style: {
position: "fixed",
},
textContent: tags.map((el) => el.dataset.tag).join(", "),
});
textarea.select();
try {
document.execCommand("copy");
if (!e.target.dataset.text) {
e.target.dataset.text = e.target.textContent;
}
e.target.textContent = "Copied " + tags.length + " tags";
setTimeout(() => {
e.target.textContent = e.target.dataset.text;
}, 1000);
} catch (ex) {
prompt("Copy to clipboard: Ctrl+C, Enter", text);
} finally {
document.body.removeChild(textarea);
}
}
btns.unshift(
$el("button", {
type: "button",
textContent: "Copy Selected",
onclick: (e) => {
copyTags(e, [...this.tags.querySelectorAll(".pysssss-model-tag--selected")]);
},
}),
$el("button", {
type: "button",
textContent: "Copy All",
onclick: (e) => {
copyTags(e, [...this.tags.querySelectorAll(".pysssss-model-tag")]);
},
})
);
return btns;
}
}
class CheckpointInfoDialog extends ModelInfoDialog {
async addInfo() {
super.addInfo();
const info = await this.addCivitaiInfo();
if (info) {
this.addInfoEntry("Base Model", info.baseModel || "⚠️ Unknown");
$el("div", {
parent: this.content,
innerHTML: info.description,
style: {
maxHeight: "250px",
overflow: "auto",
},
});
}
}
}
const infoHandler = {
LoraLoader: "loras",
"LoraLoader|pysssss": "loras",
CheckpointLoader: "checkpoints",
CheckpointLoaderSimple: "checkpoints",
"CheckpointLoader|pysssss": "checkpoints",
"Efficient Loader": "checkpoints",
"Eff. Loader SDXL": "checkpoints",
};
app.registerExtension({
name: "pysssss.ModelInfo",
beforeRegisterNodeDef(nodeType) {
const type = infoHandler[nodeType.comfyClass];
if (type) {
const cls = type === "loras" ? LoraInfoDialog : CheckpointInfoDialog;
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
let value = this.widgets[0].value;
if (!value) {
return;
}
if (value.content) {
value = value.content;
}
options.unshift({
content: "View info...",
callback: async () => {
new cls(value).show(type, value);
},
});
return getExtraMenuOptions?.apply(this, arguments);
};
}
},
});
@@ -0,0 +1,82 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// Adds a menu option to toggle follow the executing node
// Adds a menu option to go to the currently executing node
// Adds a menu option to go to a node by type
app.registerExtension({
name: "pysssss.NodeFinder",
setup() {
let followExecution = false;
const centerNode = (id) => {
if (!followExecution || !id) return;
const node = app.graph.getNodeById(id);
if (!node) return;
app.canvas.centerOnNode(node);
};
api.addEventListener("executing", ({ detail }) => centerNode(detail));
// Add canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push(null, {
content: followExecution ? "Stop following execution" : "Follow execution",
callback: () => {
if ((followExecution = !followExecution)) {
centerNode(app.runningNodeId);
}
},
});
if (app.runningNodeId) {
options.push({
content: "Show executing node",
callback: () => {
const node = app.graph.getNodeById(app.runningNodeId);
if (!node) return;
app.canvas.centerOnNode(node);
},
});
}
const nodes = app.graph._nodes;
const types = nodes.reduce((p, n) => {
if (n.type in p) {
p[n.type].push(n);
} else {
p[n.type] = [n];
}
return p;
}, {});
options.push({
content: "Go to node",
has_submenu: true,
submenu: {
options: Object.keys(types)
.sort()
.map((t) => ({
content: t,
has_submenu: true,
submenu: {
options: types[t]
.sort((a, b) => {
return a.pos[0] - b.pos[0];
})
.map((n) => ({
content: `${n.getTitle()} - #${n.id} (${n.pos[0]}, ${n.pos[1]})`,
callback: () => {
app.canvas.centerOnNode(n);
},
})),
},
})),
},
});
return options;
};
},
});
@@ -0,0 +1,36 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.PlaySound",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "PlaySound|pysssss") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = async function () {
onExecuted?.apply(this, arguments);
if (this.widgets[0].value === "on empty queue") {
if (app.ui.lastQueueSize !== 0) {
await new Promise((r) => setTimeout(r, 500));
}
if (app.ui.lastQueueSize !== 0) {
return;
}
}
let file = this.widgets[2].value;
if (!file) {
file = "notify.mp3";
}
if (!file.startsWith("http")) {
if (!file.includes("/")) {
file = "assets/" + file;
}
file = new URL(file, import.meta.url)
}
const url = new URL(file);
const audio = new Audio(url);
audio.volume = this.widgets[1].value;
audio.play();
};
}
},
});
@@ -0,0 +1,239 @@
import { app } from "../../../scripts/app.js";
// Allows you to manage preset tags for e.g. common negative prompt
// Also performs replacements on any text field e.g. allowing you to use preset text in CLIP Text encode fields
let replaceRegex;
const id = "pysssss.PresetText.Presets";
const getPresets = () => {
let items;
try {
items = JSON.parse(localStorage.getItem(id));
} catch (error) {}
if (!items || !items.length) {
items = [{ name: "default negative", value: "worst quality" }];
}
return items;
};
let presets = getPresets();
app.registerExtension({
name: "pysssss.PresetText",
setup() {
app.ui.settings.addSetting({
id: "pysssss.PresetText.ReplacementRegex",
name: "🐍 Preset Text Replacement Regex",
type: "text",
defaultValue: "(?:^|[^\\w])(?<replace>@(?<id>[\\w-]+))",
tooltip:
"The regex should return two named capture groups: id (the name of the preset text to use), replace (the matched text to replace)",
attrs: {
style: {
fontFamily: "monospace",
},
},
onChange(value) {
if (!value) {
replaceRegex = null;
return;
}
try {
replaceRegex = new RegExp(value, "g");
} catch (error) {
alert("Error creating regex for preset text replacement, no replacements will be performed.");
replaceRegex = null;
}
},
});
},
registerCustomNodes() {
class PresetTextNode {
constructor() {
this.isVirtualNode = true;
this.serialize_widgets = true;
this.addOutput("text", "STRING");
const widget = this.addWidget("combo", "value", presets[0].name, () => {}, {
values: presets.map((p) => p.name),
});
this.addWidget("button", "Manage", "Manage", () => {
const container = document.createElement("div");
Object.assign(container.style, {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "10px",
});
const addNew = document.createElement("button");
addNew.textContent = "Add New";
addNew.classList.add("pysssss-presettext-addnew");
Object.assign(addNew.style, {
fontSize: "13px",
gridColumn: "1 / 3",
color: "dodgerblue",
width: "auto",
textAlign: "center",
});
addNew.onclick = () => {
addRow({ name: "", value: "" });
};
container.append(addNew);
function addRow(p) {
const name = document.createElement("input");
const nameLbl = document.createElement("label");
name.value = p.name;
nameLbl.textContent = "Name:";
nameLbl.append(name);
const value = document.createElement("input");
const valueLbl = document.createElement("label");
value.value = p.value;
valueLbl.textContent = "Value:";
valueLbl.append(value);
addNew.before(nameLbl, valueLbl);
}
for (const p of presets) {
addRow(p);
}
const help = document.createElement("span");
help.textContent = "To remove a preset set the name or value to blank";
help.style.gridColumn = "1 / 3";
container.append(help);
dialog.show("");
dialog.textElement.append(container);
});
const dialog = new app.ui.dialog.constructor();
dialog.element.classList.add("comfy-settings");
const closeButton = dialog.element.querySelector("button");
closeButton.textContent = "CANCEL";
const saveButton = document.createElement("button");
saveButton.textContent = "SAVE";
saveButton.onclick = function () {
const inputs = dialog.element.querySelectorAll("input");
const p = [];
for (let i = 0; i < inputs.length; i += 2) {
const n = inputs[i];
const v = inputs[i + 1];
if (!n.value.trim() || !v.value.trim()) {
continue;
}
p.push({ name: n.value, value: v.value });
}
widget.options.values = p.map((p) => p.name);
if (!widget.options.values.includes(widget.value)) {
widget.value = widget.options.values[0];
}
presets = p;
localStorage.setItem(id, JSON.stringify(presets));
dialog.close();
};
closeButton.before(saveButton);
this.applyToGraph = function (workflow) {
// For each output link copy our value over the original widget value
if (this.outputs[0].links && this.outputs[0].links.length) {
for (const l of this.outputs[0].links) {
const link_info = app.graph.links[l];
const outNode = app.graph.getNodeById(link_info.target_id);
const outIn = outNode && outNode.inputs && outNode.inputs[link_info.target_slot];
if (outIn.widget) {
const w = outNode.widgets.find((w) => w.name === outIn.widget.name);
if (!w) continue;
const preset = presets.find((p) => p.name === widget.value);
if (!preset) {
const msg = `Preset text '${widget.value}' not found. Please fix this and queue again.`;
alert(msg);
throw new Error(msg);
}
w.value = preset.value;
}
}
}
};
}
}
LiteGraph.registerNodeType(
"PresetText|pysssss",
Object.assign(PresetTextNode, {
title: "Preset Text 🐍",
})
);
PresetTextNode.category = "utils";
},
nodeCreated(node) {
if (node.widgets) {
// Locate dynamic prompt text widgets
const widgets = node.widgets.filter((n) => n.type === "customtext" || n.type === "text");
for (const widget of widgets) {
const callbacks = [
() => {
let prompt = widget.value;
if (replaceRegex && typeof prompt.replace !== 'undefined') {
prompt = prompt.replace(replaceRegex, (match, p1, p2, index, text, groups) => {
if (!groups.replace || !groups.id) return match; // No match, bad regex?
const preset = presets.find((p) => p.name.replaceAll(/\s/g, "-") === groups.id);
if (!preset) return match; // Invalid name
const pos = match.indexOf(groups.replace);
return match.substring(0, pos) + preset.value;
});
}
return prompt;
},
];
let inheritedSerializeValue = widget.serializeValue || null;
let called = false;
const serializeValue = async (workflowNode, widgetIndex) => {
const origWidgetValue = widget.value;
if (called) return origWidgetValue;
called = true;
let allCallbacks = [...callbacks];
if (inheritedSerializeValue) {
allCallbacks.push(inheritedSerializeValue)
}
let valueIsUndefined = false;
for (const cb of allCallbacks) {
let value = await cb(workflowNode, widgetIndex);
// Need to check the callback return value before it is set on widget.value as it coerces it to a string (even for undefined)
if (value === undefined) valueIsUndefined = true;
widget.value = value;
}
const prompt = valueIsUndefined ? undefined : widget.value;
widget.value = origWidgetValue;
called = false;
return prompt;
};
Object.defineProperty(widget, "serializeValue", {
get() {
return serializeValue;
},
set(cb) {
inheritedSerializeValue = cb;
},
});
}
}
},
});
@@ -0,0 +1,196 @@
import { app } from "../../../scripts/app.js";
// Adds a bunch of context menu entries for quickly adding common steps
function addMenuHandler(nodeType, cb) {
const getOpts = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function () {
const r = getOpts.apply(this, arguments);
cb.apply(this, arguments);
return r;
};
}
function getOrAddVAELoader(node) {
let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader");
if (!vaeNode) {
vaeNode = addNode("VAELoader", node);
}
return vaeNode;
}
function addNode(name, nextTo, options) {
options = { select: true, shiftY: 0, before: false, ...(options || {}) };
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.pos = [
options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30,
nextTo.pos[1] + options.shiftY,
];
if (options.select) {
app.canvas.selectNode(node, false);
}
return node;
}
app.registerExtension({
name: "pysssss.QuickNodes",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.input && nodeData.input.required) {
const keys = Object.keys(nodeData.input.required);
for (let i = 0; i < keys.length; i++) {
if (nodeData.input.required[keys[i]][0] === "VAE") {
addMenuHandler(nodeType, function (_, options) {
options.unshift({
content: "Use VAE",
callback: () => {
getOrAddVAELoader(this).connect(0, this, i);
},
});
});
break;
}
}
}
if (nodeData.name === "KSampler") {
addMenuHandler(nodeType, function (_, options) {
options.unshift(
{
content: "Add Blank Input",
callback: () => {
const imageNode = addNode("EmptyLatentImage", this, { before: true });
imageNode.connect(0, this, 3);
},
},
{
content: "Add Hi-res Fix",
callback: () => {
const upscaleNode = addNode("LatentUpscale", this);
this.connect(0, upscaleNode, 0);
const sampleNode = addNode("KSampler", upscaleNode);
for (let i = 0; i < 3; i++) {
const l = this.getInputLink(i);
if (l) {
app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i);
}
}
upscaleNode.connect(0, sampleNode, 3);
},
},
{
content: "Add 2nd Pass",
callback: () => {
const upscaleNode = addNode("LatentUpscale", this);
this.connect(0, upscaleNode, 0);
const ckptNode = addNode("CheckpointLoaderSimple", this);
const sampleNode = addNode("KSampler", ckptNode);
const positiveLink = this.getInputLink(1);
const negativeLink = this.getInputLink(2);
const positiveNode = positiveLink
? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone())
: addNode("CLIPTextEncode");
const negativeNode = negativeLink
? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone())
: addNode("CLIPTextEncode");
ckptNode.connect(0, sampleNode, 0);
ckptNode.connect(1, positiveNode, 0);
ckptNode.connect(1, negativeNode, 0);
positiveNode.connect(0, sampleNode, 1);
negativeNode.connect(0, sampleNode, 2);
upscaleNode.connect(0, sampleNode, 3);
},
},
{
content: "Add Save Image",
callback: () => {
const decodeNode = addNode("VAEDecode", this);
this.connect(0, decodeNode, 0);
getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1);
const saveNode = addNode("SaveImage", decodeNode);
decodeNode.connect(0, saveNode, 0);
},
}
);
});
}
if (nodeData.name === "CheckpointLoaderSimple") {
addMenuHandler(nodeType, function (_, options) {
options.unshift({
content: "Add Clip Skip",
callback: () => {
const clipSkipNode = addNode("CLIPSetLastLayer", this);
const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
this.disconnectOutput(1);
this.connect(1, clipSkipNode, 0);
for (const clipLink of clipLinks) {
clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot);
}
},
});
});
}
if (
nodeData.name === "CheckpointLoaderSimple" ||
nodeData.name === "CheckpointLoader" ||
nodeData.name === "CheckpointLoader|pysssss" ||
nodeData.name === "LoraLoader" ||
nodeData.name === "LoraLoader|pysssss"
) {
addMenuHandler(nodeType, function (_, options) {
function addLora(type) {
const loraNode = addNode(type, this);
const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : [];
const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
this.disconnectOutput(0);
this.disconnectOutput(1);
this.connect(0, loraNode, 0);
this.connect(1, loraNode, 1);
for (const modelLink of modelLinks) {
loraNode.connect(0, modelLink.target_id, modelLink.target_slot);
}
for (const clipLink of clipLinks) {
loraNode.connect(1, clipLink.target_id, clipLink.target_slot);
}
}
options.unshift(
{
content: "Add LoRA",
callback: () => addLora.call(this, "LoraLoader"),
},
{
content: "Add 🐍 LoRA",
callback: () => addLora.call(this, "LoraLoader|pysssss"),
},
{
content: "Add Prompts",
callback: () => {
const positiveNode = addNode("CLIPTextEncode", this);
const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 });
this.connect(1, positiveNode, 0);
this.connect(1, negativeNode, 0);
},
}
);
});
}
},
});
@@ -0,0 +1,123 @@
import { app } from "../../../scripts/app.js";
const REPEATER = "Repeater|pysssss";
app.registerExtension({
name: "pysssss.Repeater",
init() {
const graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await graphToPrompt.apply(this, arguments);
const id = Date.now() + "_";
let u = 0;
let newNodes = {};
const newRepeaters = {};
for (const nodeId in res.output) {
let output = res.output[nodeId];
if (output.class_type === REPEATER) {
const isMulti = output.inputs.output === "multi";
if (output.inputs.node_mode === "create") {
// We need to clone the input for every repeat
const orig = res.output[output.inputs.source[0]];
if (isMulti) {
if (!newRepeaters[nodeId]) {
newRepeaters[nodeId] = [];
newRepeaters[nodeId][output.inputs.repeats - 1] = nodeId;
}
}
for (let i = 0; i < output.inputs.repeats - 1; i++) {
const clonedInputId = id + ++u;
if (isMulti) {
// If multi create we need to clone the repeater too
newNodes[clonedInputId] = structuredClone(orig);
output = structuredClone(output);
const clonedRepeaterId = id + ++u;
newNodes[clonedRepeaterId] = output;
output.inputs["source"][0] = clonedInputId;
newRepeaters[nodeId][i] = clonedRepeaterId;
} else {
newNodes[clonedInputId] = orig;
}
output.inputs[clonedInputId] = [clonedInputId, output.inputs.source[1]];
}
} else if (isMulti) {
newRepeaters[nodeId] = Array(output.inputs.repeats).fill(nodeId);
}
}
}
Object.assign(res.output, newNodes);
newNodes = {};
for (const nodeId in res.output) {
const output = res.output[nodeId];
for (const k in output.inputs) {
const v = output.inputs[k];
if (v instanceof Array) {
const repeaterId = v[0];
const source = newRepeaters[repeaterId];
if (source) {
v[0] = source.pop();
v[1] = 0;
}
}
}
}
// Object.assign(res.output, newNodes);
return res;
};
},
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === REPEATER) {
const SETUP_OUTPUTS = Symbol();
nodeType.prototype[SETUP_OUTPUTS] = function (repeats) {
if (repeats == null) {
repeats = this.widgets[0].value;
}
while (this.outputs.length > repeats) {
this.removeOutput(repeats);
}
const id = Date.now() + "_";
let u = 0;
while (this.outputs.length < repeats) {
this.addOutput(id + ++u, "*", { label: "*" });
}
};
const onAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
const self = this;
const repeatsCb = this.widgets[0].callback;
this.widgets[0].callback = async function () {
const v = (await repeatsCb?.apply(this, arguments)) ?? this.value;
if (self.widgets[1].value === "multi") {
self[SETUP_OUTPUTS](v);
}
return v;
};
const outputCb = this.widgets[1].callback;
this.widgets[1].callback = async function () {
const v = (await outputCb?.apply(this, arguments)) ?? this.value;
if (v === "single") {
self.outputs[0].shape = 6;
self[SETUP_OUTPUTS](1);
} else {
delete self.outputs[0].shape;
self[SETUP_OUTPUTS]();
}
return v;
};
return onAdded?.apply(this, arguments);
};
}
},
});
@@ -0,0 +1,342 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
const REROUTE_PRIMITIVE = "ReroutePrimitive|pysssss";
const MULTI_PRIMITIVE = "MultiPrimitive|pysssss";
const LAST_TYPE = Symbol("LastType");
app.registerExtension({
name: "pysssss.ReroutePrimitive",
init() {
// On graph configure, fire onGraphConfigured to create widgets
const graphConfigure = LGraph.prototype.configure;
LGraph.prototype.configure = function () {
const r = graphConfigure.apply(this, arguments);
for (const n of app.graph._nodes) {
if (n.type === REROUTE_PRIMITIVE) {
n.onGraphConfigured();
}
}
return r;
};
const graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await graphToPrompt.apply(this, arguments);
const multiOutputs = [];
for (const nodeId in res.output) {
const output = res.output[nodeId];
if (output.class_type === MULTI_PRIMITIVE) {
multiOutputs.push({ id: nodeId, inputs: output.inputs });
}
}
function permute(outputs) {
function generatePermutations(inputs, currentIndex, currentPermutation, result) {
if (currentIndex === inputs.length) {
result.push({ ...currentPermutation });
return;
}
const input = inputs[currentIndex];
for (const k in input) {
currentPermutation[currentIndex] = input[k];
generatePermutations(inputs, currentIndex + 1, currentPermutation, result);
}
}
const inputs = outputs.map((output) => output.inputs);
const result = [];
const current = new Array(inputs.length);
generatePermutations(inputs, 0, current, result);
return outputs.map((output, index) => ({
...output,
inputs: result.reduce((p, permutation) => {
const count = Object.keys(p).length;
p["value" + (count || "")] = permutation[index];
return p;
}, {}),
}));
}
const permutations = permute(multiOutputs);
for (let i = 0; i < permutations.length; i++) {
res.output[multiOutputs[i].id].inputs = permutations[i].inputs;
}
return res;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
function addOutputHandler() {
// Finds the first non reroute output node down the chain
nodeType.prototype.getFirstReroutedOutput = function (slot) {
if (nodeData.name === MULTI_PRIMITIVE) {
slot = 0;
}
const links = this.outputs[slot].links;
if (!links) return null;
const search = [];
for (const l of links) {
const link = app.graph.links[l];
if (!link) continue;
const node = app.graph.getNodeById(link.target_id);
if (node.type !== REROUTE_PRIMITIVE && node.type !== MULTI_PRIMITIVE) {
return { node, link };
}
search.push({ node, link });
}
for (const { link, node } of search) {
const r = node.getFirstReroutedOutput(link.target_slot);
if (r) {
return r;
}
}
};
}
if (nodeData.name === REROUTE_PRIMITIVE) {
const configure = nodeType.prototype.configure || LGraphNode.prototype.configure;
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
const onAdded = nodeType.prototype.onAdded;
nodeType.title_mode = LiteGraph.NO_TITLE;
function hasAnyInput(node) {
for (const input of node.inputs) {
if (input.link) {
return true;
}
}
return false;
}
// Remove input text
nodeType.prototype.onAdded = function () {
onAdded?.apply(this, arguments);
this.inputs[0].label = "";
this.outputs[0].label = "value";
this.setSize(this.computeSize());
};
// Restore any widgets
nodeType.prototype.onGraphConfigured = function () {
if (hasAnyInput(this)) return;
const outputNode = this.getFirstReroutedOutput(0);
if (outputNode) {
this.checkPrimitiveWidget(outputNode);
}
};
// Check if we need to create (or remove) a widget on the node
nodeType.prototype.checkPrimitiveWidget = function ({ node, link }) {
let widgetType = link.type;
let targetLabel = widgetType;
const input = node.inputs[link.target_slot];
if (input.widget?.config?.[0] instanceof Array) {
targetLabel = input.widget.name;
widgetType = "COMBO";
}
if (widgetType in ComfyWidgets) {
if (!this.widgets?.length) {
let v;
if (this.widgets_values?.length) {
v = this.widgets_values[0];
}
let config = [link.type, {}];
if (input.widget?.config) {
config = input.widget.config;
}
const { widget } = ComfyWidgets[widgetType](this, "value", config, app);
if (v !== undefined && (!this[LAST_TYPE] || this[LAST_TYPE] === widgetType)) {
widget.value = v;
}
this[LAST_TYPE] = widgetType;
}
} else if (this.widgets) {
this.widgets.length = 0;
}
return targetLabel;
};
// Finds all input nodes from the current reroute
nodeType.prototype.getReroutedInputs = function (slot) {
let nodes = [{ node: this }];
let node = this;
while (node?.type === REROUTE_PRIMITIVE) {
const input = node.inputs[slot];
if (input.link) {
const link = app.graph.links[input.link];
node = app.graph.getNodeById(link.origin_id);
slot = link.origin_slot;
nodes.push({
node,
link,
});
} else {
node = null;
}
}
return nodes;
};
addOutputHandler();
// Update the type of all reroutes in a chain
nodeType.prototype.changeRerouteType = function (slot, type, label) {
const color = LGraphCanvas.link_type_colors[type];
const output = this.outputs[slot];
this.inputs[slot].label = " ";
output.label = label || (type === "*" ? "value" : type);
output.type = type;
// Process all linked outputs
for (const linkId of output.links || []) {
const link = app.graph.links[linkId];
if (!link) continue;
link.color = color;
const node = app.graph.getNodeById(link.target_id);
if (node.changeRerouteType) {
// Recursively update reroutes
node.changeRerouteType(link.target_slot, type, label);
} else {
// Validate links to 'real' nodes
const theirType = node.inputs[link.target_slot].type;
if (theirType !== type && theirType !== "*") {
node.disconnectInput(link.target_slot);
}
}
}
if (this.inputs[slot].link) {
const link = app.graph.links[this.inputs[slot].link];
if (link) link.color = color;
}
};
// Override configure so we can flag that we are configuring to avoid link validation breaking
let configuring = false;
nodeType.prototype.configure = function () {
configuring = true;
const r = configure?.apply(this, arguments);
configuring = false;
return r;
};
Object.defineProperty(nodeType, "title_mode", {
get() {
return app.canvas.current_node?.widgets?.length ? LiteGraph.NORMAL_TITLE : LiteGraph.NO_TITLE;
},
});
nodeType.prototype.onConnectionsChange = function (type, _, connected, link_info) {
// If configuring treat everything as OK as links may not be set by litegraph yet
if (configuring) return;
const isInput = type === LiteGraph.INPUT;
const slot = isInput ? link_info.target_slot : link_info.origin_slot;
let targetLabel = null;
let targetNode = null;
let targetType = "*";
let targetSlot = slot;
const inputPath = this.getReroutedInputs(slot);
const rootInput = inputPath[inputPath.length - 1];
const outputNode = this.getFirstReroutedOutput(slot);
if (rootInput.node.type === REROUTE_PRIMITIVE) {
// Our input node is a reroute, so see if we have an output
if (outputNode) {
targetType = outputNode.link.type;
} else if (rootInput.node.widgets) {
rootInput.node.widgets.length = 0;
}
targetNode = rootInput;
targetSlot = rootInput.link?.target_slot ?? slot;
} else {
// We have a real input, so we want to use that type
targetNode = inputPath[inputPath.length - 2];
targetType = rootInput.node.outputs[rootInput.link.origin_slot].type;
targetSlot = rootInput.link.target_slot;
}
if (this.widgets && inputPath.length > 1) {
// We have an input node so remove our widget
this.widgets.length = 0;
}
if (outputNode && rootInput.node.checkPrimitiveWidget) {
// We have an output, check if we need to create a widget
targetLabel = rootInput.node.checkPrimitiveWidget(outputNode);
}
// Trigger an update of the type to all child nodes
targetNode.node.changeRerouteType(targetSlot, targetType, targetLabel);
return onConnectionsChange?.apply(this, arguments);
};
// When collapsed fix the size to just the dot
const computeSize = nodeType.prototype.computeSize || LGraphNode.prototype.computeSize;
nodeType.prototype.computeSize = function () {
const r = computeSize.apply(this, arguments);
if (this.flags?.collapsed) {
return [1, 25];
} else if (this.widgets?.length) {
return r;
} else {
let w = 75;
if (this.outputs?.[0]?.label) {
const t = LiteGraph.NODE_TEXT_SIZE * this.outputs[0].label.length * 0.6 + 30;
if (t > w) {
w = t;
}
}
return [w, r[1]];
}
};
// On collapse shrink the node to just a dot
const collapse = nodeType.prototype.collapse || LGraphNode.prototype.collapse;
nodeType.prototype.collapse = function () {
collapse.apply(this, arguments);
this.setSize(this.computeSize());
requestAnimationFrame(() => {
this.setDirtyCanvas(true, true);
});
};
// Shift the bounding area up slightly as LiteGraph miscalculates it for collapsed nodes
nodeType.prototype.onBounding = function (area) {
if (this.flags?.collapsed) {
area[1] -= 15;
}
};
} else if (nodeData.name === MULTI_PRIMITIVE) {
addOutputHandler();
nodeType.prototype.onConnectionsChange = function (type, _, connected, link_info) {
for (let i = 0; i < this.inputs.length - 1; i++) {
if (!this.inputs[i].link) {
this.removeInput(i--);
}
}
if (this.inputs[this.inputs.length - 1].link) {
this.addInput("v" + +new Date(), this.inputs[0].type).label = "value";
}
};
}
},
});
@@ -0,0 +1,81 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el } from "../../../scripts/ui.js";
const id = "pysssss.ShowImageOnMenu";
const ext = {
name: id,
async setup(app) {
let enabled = true;
let nodeId = null;
const img = $el("img", {
style: {
width: "100%",
height: "150px",
objectFit: "contain",
},
});
const link = $el(
"a",
{
style: {
width: "100%",
height: "150px",
marginTop: "10px",
order: 100, // Place this item last (until someone else has a higher order)
display: "none",
},
href: "#",
onclick: (e) => {
e.stopPropagation();
e.preventDefault();
const node = app.graph.getNodeById(nodeId);
if (!node) return;
app.canvas.centerOnNode(node);
app.canvas.setZoom(1);
},
},
[img]
);
app.ui.menuContainer.append(link);
const show = (src, node) => {
img.src = src;
nodeId = Number(node);
link.style.display = "unset";
};
api.addEventListener("executed", ({ detail }) => {
if (!enabled) return;
const images = detail?.output?.images;
if (!images || !images.length) return;
const format = app.getPreviewFormatParam();
const src = [
`./view?filename=${encodeURIComponent(images[0].filename)}`,
`type=${images[0].type}`,
`subfolder=${encodeURIComponent(images[0].subfolder)}`,
`t=${+new Date()}${format}`,].join('&');
show(src, detail.node);
});
api.addEventListener("b_preview", ({ detail }) => {
if (!enabled) return;
show(URL.createObjectURL(detail), app.runningNodeId);
});
app.ui.settings.addSetting({
id,
name: "🐍 Show Image On Menu",
defaultValue: true,
type: "boolean",
onChange(value) {
enabled = value;
if (!enabled) link.style.display = "none";
},
});
},
};
app.registerExtension(ext);
@@ -0,0 +1,57 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
app.registerExtension({
name: "pysssss.ShowText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "ShowText|pysssss") {
function populate(text) {
if (this.widgets) {
for (let i = 1; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = 1;
}
const v = [...text];
if (!v[0]) {
v.shift();
}
for (const list of v) {
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = list;
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text, display this in the widget
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
populate.call(this, message.text);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
if (this.widgets_values?.length) {
populate.call(this, this.widgets_values);
}
};
}
},
});
@@ -0,0 +1,58 @@
import { app } from "../../../scripts/app.js";
let setting;
const id = "pysssss.SnapToGrid";
const ext = {
name: id,
init() {
setting = app.ui.settings.addSetting({
id,
name: "🐍 Always snap to grid",
defaultValue: false,
type: "boolean",
onChange(value) {
app.canvas.align_to_grid = value;
},
});
// We need to register our hooks after the core snap to grid extension runs
// Do this from the graph configure function so we still get onNodeAdded calls
const configure = LGraph.prototype.configure;
LGraph.prototype.configure = function () {
// Override drawNode to draw the drop position
const drawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function () {
if (setting?.value) {
const shift = app.shiftDown;
app.shiftDown = true;
const r = drawNode.apply(this, arguments);
app.shiftDown = shift;
return r;
}
return drawNode.apply(this, arguments);
};
// Override node added to add a resize handler to force grid alignment
const onNodeAdded = app.graph.onNodeAdded;
app.graph.onNodeAdded = function (node) {
const r = onNodeAdded?.apply(this, arguments);
const onResize = node.onResize;
node.onResize = function () {
if (setting?.value) {
const shift = app.shiftDown;
app.shiftDown = true;
const r = onResize?.apply(this, arguments);
app.shiftDown = shift;
return r;
}
return onResize?.apply(this, arguments);
};
return r;
};
return configure.apply(this, arguments);
};
},
};
app.registerExtension(ext);
@@ -0,0 +1,33 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
app.registerExtension({
name: "pysssss.StringFunction",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "StringFunction|pysssss") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (this.widgets) {
const pos = this.widgets.findIndex((w) => w.name === "result");
if (pos !== -1) {
for (let i = pos; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = pos;
}
}
const w = ComfyWidgets["STRING"](this, "result", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = message.text;
this.onResize?.(this.size);
};
}
},
});
@@ -0,0 +1,30 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.SwapResolution",
async beforeRegisterNodeDef(nodeType, nodeData) {
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
if (inputs.width && inputs.height) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = origGetExtraMenuOptions?.apply?.(this, arguments);
options.push(
{
content: "Swap width/height",
callback: () => {
const w = this.widgets.find((w) => w.name === "width");
const h = this.widgets.find((w) => w.name === "height");
const a = w.value;
w.value = h.value;
h.value = a;
app.graph.setDirtyCanvas(true);
},
},
null
);
return r;
};
}
},
});
@@ -0,0 +1,36 @@
import { app } from "../../../scripts/app.js";
const id = "pysssss.UseNumberInputPrompt";
const ext = {
name: id,
async setup(app) {
const prompt = LGraphCanvas.prototype.prompt;
const setting = app.ui.settings.addSetting({
id,
name: "🐍 Use number input on value entry",
defaultValue: false,
type: "boolean",
});
LGraphCanvas.prototype.prompt = function () {
const dialog = prompt.apply(this, arguments);
if (setting.value && typeof arguments[1] === "number") {
// If this should be a number then update the imput
const input = dialog.querySelector("input");
input.type = "number";
// Add constraints
const widget = app.canvas.node_widget?.[1];
if (widget?.options) {
for (const prop of ["min", "max", "step"]) {
if (widget.options[prop]) input[prop] = widget.options[prop];
}
}
}
return dialog;
};
},
};
app.registerExtension(ext);
@@ -0,0 +1,251 @@
import { app } from "../../../scripts/app.js";
import { $el, ComfyDialog } from "../../../scripts/ui.js";
// Allows you to specify custom default values for any widget on any node
const id = "pysssss.WidgetDefaults";
const nodeDataKey = Symbol();
app.registerExtension({
name: id,
beforeRegisterNodeDef(nodeType, nodeData) {
nodeType[nodeDataKey] = nodeData;
},
setup() {
let defaults;
let setting;
const applyDefaults = (defaults) => {
for (const node of Object.values(LiteGraph.registered_node_types)) {
const nodeData = node[nodeDataKey];
if (!nodeData) continue;
const nodeDefaults = defaults[node.type];
if (!nodeDefaults) continue;
const inputs = { ...(nodeData.input?.required || {}), ...(nodeData.input?.optional || {}) };
for (const w in nodeDefaults) {
const widgetDef = inputs[w];
if (widgetDef) {
let v = nodeDefaults[w];
if (widgetDef[0] === "INT" || widgetDef[0] === "FLOAT") {
v = +v;
}
if (widgetDef[1]) {
widgetDef[1].default = v;
} else {
widgetDef[1] = { default: v };
}
}
}
}
};
const getDefaults = () => {
let items;
try {
items = JSON.parse(setting.value);
items = items.reduce((p, n) => {
if (!p[n.node]) p[n.node] = {};
p[n.node][n.widget] = n.value;
return p;
}, {});
} catch (error) {}
if (!items) {
items = {};
}
applyDefaults(items);
return items;
};
const onNodeAdded = app.graph.onNodeAdded;
app.graph.onNodeAdded = function (node) {
onNodeAdded?.apply?.(this, arguments);
// See if we have any defaults for this type of node
const nodeDefaults = defaults[node.constructor.type];
if (!nodeDefaults) return;
// Dont run if they are pre-configured nodes from load/pastes
const stack = new Error().stack;
if (stack.includes("pasteFromClipboard") || stack.includes("loadGraphData")) {
return;
}
for (const k in nodeDefaults) {
if (k.startsWith("property.")) {
const name = k.substring(9);
let v = nodeDefaults[k];
// Special handling for some built in values
if (name in node || ["color", "bgcolor", "title"].includes(name)) {
node[name] = v;
} else {
// Try using the correct type
if (!node.properties) node.properties = {};
if (typeof node.properties[name] === "number") v = +v;
else if (typeof node.properties[name] === "boolean") v = v === "true";
else if (v === "true") v = true;
node.properties[name] = v;
}
}
}
};
class WidgetDefaultsDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("comfy-manage-templates");
this.grid = $el(
"div",
{
style: {
display: "grid",
gridTemplateColumns: "1fr auto auto auto",
gap: "5px",
},
className: "pysssss-widget-defaults",
},
[
$el("label", {
textContent: "Node Class",
}),
$el("label", {
textContent: "Widget Name",
}),
$el("label", {
textContent: "Default Value",
}),
$el("label"),
(this.rows = $el("div", {
style: {
display: "contents",
},
})),
]
);
}
createButtons() {
const btns = super.createButtons();
btns[0].textContent = "Cancel";
btns.unshift(
$el("button", {
type: "button",
textContent: "Add New",
onclick: () => this.addRow(),
}),
$el("button", {
type: "button",
textContent: "Save",
onclick: () => this.save(),
})
);
return btns;
}
addRow(node = "", widget = "", value = "") {
let nameInput;
this.rows.append(
$el(
"div",
{
style: {
display: "contents",
},
className: "pysssss-widget-defaults-row",
},
[
$el("input", {
placeholder: "e.g. CheckpointLoaderSimple",
value: node,
}),
$el("input", {
placeholder: "e.g. ckpt_name",
value: widget,
$: (el) => (nameInput = el),
}),
$el("input", {
placeholder: "e.g. myBestModel.safetensors",
value,
}),
$el("button", {
textContent: "Delete",
style: {
fontSize: "12px",
color: "red",
fontWeight: "normal",
},
onclick: (e) => {
nameInput.value = "";
e.target.parentElement.style.display = "none";
},
}),
]
)
);
}
save() {
const rows = this.rows.children;
const items = [];
for (const row of rows) {
const inputs = row.querySelectorAll("input");
const node = inputs[0].value.trim();
const widget = inputs[1].value.trim();
const value = inputs[2].value;
if (node && widget) {
items.push({ node, widget, value });
}
}
setting.value = JSON.stringify(items);
defaults = getDefaults();
this.close();
}
show() {
this.rows.replaceChildren();
for (const nodeName in defaults) {
const node = defaults[nodeName];
for (const widgetName in node) {
this.addRow(nodeName, widgetName, node[widgetName]);
}
}
this.addRow();
super.show(this.grid);
}
}
setting = app.ui.settings.addSetting({
id,
name: "🐍 Widget Defaults",
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: "🐍 Widget & Property Defaults:",
}),
]),
$el("td", [
$el("button", {
textContent: "Manage",
onclick: () => {
app.ui.settings.element.close();
const dialog = new WidgetDefaultsDialog();
dialog.show();
},
style: {
fontSize: "14px",
},
}),
]),
]);
},
});
defaults = getDefaults();
},
});
@@ -0,0 +1,633 @@
import { app } from "../../../scripts/app.js";
import { importA1111 } from "../../../scripts/pnginfo.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
let getDrawTextConfig = null;
let fileInput;
class WorkflowImage {
static accept = "";
getBounds() {
// Calculate the min max bounds for the nodes on the graph
const bounds = app.graph._nodes.reduce(
(p, n) => {
if (n.pos[0] < p[0]) p[0] = n.pos[0];
if (n.pos[1] < p[1]) p[1] = n.pos[1];
const bounds = n.getBounding();
const r = n.pos[0] + bounds[2];
const b = n.pos[1] + bounds[3];
if (r > p[2]) p[2] = r;
if (b > p[3]) p[3] = b;
return p;
},
[99999, 99999, -99999, -99999]
);
bounds[0] -= 100;
bounds[1] -= 100;
bounds[2] += 100;
bounds[3] += 100;
return bounds;
}
saveState() {
this.state = {
scale: app.canvas.ds.scale,
width: app.canvas.canvas.width,
height: app.canvas.canvas.height,
offset: app.canvas.ds.offset,
};
}
restoreState() {
app.canvas.ds.scale = this.state.scale;
app.canvas.canvas.width = this.state.width;
app.canvas.canvas.height = this.state.height;
app.canvas.ds.offset = this.state.offset;
}
updateView(bounds) {
app.canvas.ds.scale = 1;
app.canvas.canvas.width = bounds[2] - bounds[0];
app.canvas.canvas.height = bounds[3] - bounds[1];
app.canvas.ds.offset = [-bounds[0], -bounds[1]];
}
getDrawTextConfig(_, widget) {
return {
x: 10,
y: widget.last_y + 10,
resetTransform: false,
};
}
async export(includeWorkflow) {
// Save the current state of the canvas
this.saveState();
// Update to render the whole workflow
this.updateView(this.getBounds());
// Flag that we are saving and render the canvas
getDrawTextConfig = this.getDrawTextConfig;
app.canvas.draw(true, true);
getDrawTextConfig = null;
// Generate a blob of the image containing the workflow
const blob = await this.getBlob(includeWorkflow ? JSON.stringify(app.graph.serialize()) : undefined);
// Restore initial state and redraw
this.restoreState();
app.canvas.draw(true, true);
// Download the generated image
this.download(blob);
}
download(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
Object.assign(a, {
href: url,
download: "workflow." + this.extension,
style: "display: none",
});
document.body.append(a);
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
static import() {
if (!fileInput) {
fileInput = document.createElement("input");
Object.assign(fileInput, {
type: "file",
style: "display: none",
onchange: () => {
app.handleFile(fileInput.files[0]);
},
});
document.body.append(fileInput);
}
fileInput.accept = WorkflowImage.accept;
fileInput.click();
}
}
class PngWorkflowImage extends WorkflowImage {
static accept = ".png,image/png";
extension = "png";
n2b(n) {
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
}
joinArrayBuffer(...bufs) {
const result = new Uint8Array(bufs.reduce((totalSize, buf) => totalSize + buf.byteLength, 0));
bufs.reduce((offset, buf) => {
result.set(buf, offset);
return offset + buf.byteLength;
}, 0);
return result;
}
crc32(data) {
const crcTable =
PngWorkflowImage.crcTable ||
(PngWorkflowImage.crcTable = (() => {
let c;
const crcTable = [];
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crcTable[n] = c;
}
return crcTable;
})());
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
}
async getBlob(workflow) {
return new Promise((r) => {
app.canvasEl.toBlob(async (blob) => {
if (workflow) {
// If we have a workflow embed it in the PNG
const buffer = await blob.arrayBuffer();
const typedArr = new Uint8Array(buffer);
const view = new DataView(buffer);
const data = new TextEncoder().encode(`tEXtworkflow\0${workflow}`);
const chunk = this.joinArrayBuffer(this.n2b(data.byteLength - 4), data, this.n2b(this.crc32(data)));
const sz = view.getUint32(8) + 20;
const result = this.joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
blob = new Blob([result], { type: "image/png" });
}
r(blob);
});
});
}
}
class DataReader {
/** @type {DataView} */
view;
/** @type {boolean | undefined} */
littleEndian;
offset = 0;
/**
* @param {DataView} view
*/
constructor(view) {
this.view = view;
}
/**
* Reads N bytes and increments the offset
* @param {1 | 2 | 4 | 8} size
*/
read(size, signed = false, littleEndian = undefined) {
const v = this.peek(size, signed, littleEndian);
this.offset += size;
return v;
}
/**
* Reads N bytes
* @param {1 | 2 | 4 | 8} size
*/
peek(size, signed = false, littleEndian = undefined) {
this.view.getBigInt64;
let m = "";
if (size === 8) m += "Big";
m += signed ? "Int" : "Uint";
m += size * 8;
m = "get" + m;
if (!this.view[m]) {
throw new Error("Method not found: " + m);
}
return this.view[m](this.offset, littleEndian == null ? this.littleEndian : littleEndian);
}
/**
* Seeks to the specified position or by the number of bytes specified relative to the current offset
* @param {number} pos
* @param {boolean} relative
*/
seek(pos, relative = true) {
if (relative) {
this.offset += pos;
} else {
this.offset = pos;
}
}
}
class Tiff {
/** @type {DataReader} */
#reader;
#start;
readExif(reader) {
const TIFF_MARKER = 0x2a;
const EXIF_IFD = 0x8769;
this.#reader = reader;
this.#start = this.#reader.offset;
this.#readEndianness();
if (!this.#reader.read(2) === TIFF_MARKER) {
throw new Error("Invalid TIFF: Marker not found.");
}
const dirOffset = this.#reader.read(4);
this.#reader.seek(this.#start + dirOffset, false);
for (const t of this.#readTags()) {
if (t.id === EXIF_IFD) {
return this.#readExifTag(t);
}
}
throw new Error("No EXIF: TIFF Exif IFD tag not found");
}
#readUserComment(tag) {
this.#reader.seek(this.#start + tag.offset, false);
const encoding = this.#reader.read(8);
if (encoding !== 0x45444f43494e55n) {
throw new Error("Unable to read non-Unicode data");
}
const decoder = new TextDecoder("utf-16be");
return decoder.decode(new DataView(this.#reader.view.buffer, this.#reader.offset, tag.count - 8));
}
#readExifTag(exifTag) {
const EXIF_USER_COMMENT = 0x9286;
this.#reader.seek(this.#start + exifTag.offset, false);
for (const t of this.#readTags()) {
if (t.id === EXIF_USER_COMMENT) {
return this.#readUserComment(t);
}
}
throw new Error("No embedded data: UserComment Exif tag not found");
}
*#readTags() {
const count = this.#reader.read(2);
for (let i = 0; i < count; i++) {
yield {
id: this.#reader.read(2),
type: this.#reader.read(2),
count: this.#reader.read(4),
offset: this.#reader.read(4),
};
}
}
#readEndianness() {
const II = 0x4949;
const MM = 0x4d4d;
const endianness = this.#reader.read(2);
if (endianness === II) {
this.#reader.littleEndian = true;
} else if (endianness === MM) {
this.#reader.littleEndian = false;
} else {
throw new Error("Invalid JPEG: Endianness marker not found.");
}
}
}
class Jpeg {
/** @type {DataReader} */
#reader;
/**
* @param {ArrayBuffer} buffer
*/
readExif(buffer) {
const JPEG_MARKER = 0xffd8;
const EXIF_SIG = 0x45786966;
this.#reader = new DataReader(new DataView(buffer));
if (!this.#reader.read(2) === JPEG_MARKER) {
throw new Error("Invalid JPEG: SOI not found.");
}
const app0 = this.#readAppMarkerId();
if (app0 !== 0) {
throw new Error(`Invalid JPEG: APP0 not found [found: ${app0}].`);
}
this.#consumeAppSegment();
const app1 = this.#readAppMarkerId();
if (app1 !== 1) {
throw new Error(`No EXIF: APP1 not found [found: ${app0}].`);
}
// Skip size
this.#reader.seek(2);
if (this.#reader.read(4) !== EXIF_SIG) {
throw new Error(`No EXIF: Invalid EXIF header signature.`);
}
if (this.#reader.read(2) !== 0) {
throw new Error(`No EXIF: Invalid EXIF header.`);
}
return new Tiff().readExif(this.#reader);
}
#readAppMarkerId() {
const APP0_MARKER = 0xffe0;
return this.#reader.read(2) - APP0_MARKER;
}
#consumeAppSegment() {
this.#reader.seek(this.#reader.read(2) - 2);
}
}
class SvgWorkflowImage extends WorkflowImage {
static accept = ".svg,image/svg+xml";
extension = "svg";
static init() {
// Override file handling to allow drag & drop of SVG
const handleFile = app.handleFile;
app.handleFile = async function (file) {
if (file && (file.type === "image/svg+xml" || file.name?.endsWith(".svg"))) {
const reader = new FileReader();
reader.onload = () => {
// Extract embedded workflow from desc tags
const descEnd = reader.result.lastIndexOf("</desc>");
if (descEnd !== -1) {
const descStart = reader.result.lastIndexOf("<desc>", descEnd);
if (descStart !== -1) {
const json = reader.result.substring(descStart + 6, descEnd);
this.loadGraphData(JSON.parse(SvgWorkflowImage.unescapeXml(json)));
}
}
};
reader.readAsText(file);
return;
} else if (file && (file.type === "image/jpeg" || file.name?.endsWith(".jpg") || file.name?.endsWith(".jpeg"))) {
if (
await new Promise((resolve) => {
try {
// This shouldnt go in here but it's easier than refactoring handleFile
const reader = new FileReader();
reader.onload = async () => {
try {
const value = new Jpeg().readExif(reader.result);
importA1111(app.graph, value);
resolve(true);
} catch (error) {
resolve(false);
}
};
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file);
} catch (error) {
resolve(false);
}
})
) {
return;
}
}
return handleFile.apply(this, arguments);
};
}
static escapeXml(unsafe) {
return unsafe.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
static unescapeXml(safe) {
return safe.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
}
getDrawTextConfig(_, widget) {
return {
x: parseInt(widget.inputEl.style.left),
y: parseInt(widget.inputEl.style.top),
resetTransform: true,
};
}
saveState() {
super.saveState();
this.state.ctx = app.canvas.ctx;
}
restoreState() {
super.restoreState();
app.canvas.ctx = this.state.ctx;
}
updateView(bounds) {
super.updateView(bounds);
this.createSvgCtx(bounds);
}
createSvgCtx(bounds) {
const ctx = this.state.ctx;
const svgCtx = (this.svgCtx = new C2S(bounds[2] - bounds[0], bounds[3] - bounds[1]));
svgCtx.canvas.getBoundingClientRect = function () {
return { width: svgCtx.width, height: svgCtx.height };
};
// Override the c2s handling of images to draw images as canvases
const drawImage = svgCtx.drawImage;
svgCtx.drawImage = function (...args) {
const image = args[0];
// If we are an image node and not a datauri then we need to replace with a canvas
// we cant convert to data uri here as it is an async process
if (image.nodeName === "IMG" && !image.src.startsWith("data:image/")) {
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const imgCtx = canvas.getContext("2d");
imgCtx.drawImage(image, 0, 0);
args[0] = canvas;
}
return drawImage.apply(this, args);
};
// Implement missing required functions
svgCtx.getTransform = function () {
return ctx.getTransform();
};
svgCtx.resetTransform = function () {
return ctx.resetTransform();
};
svgCtx.roundRect = svgCtx.rect;
app.canvas.ctx = svgCtx;
}
getBlob(workflow) {
let svg = this.svgCtx
.getSerializedSvg(true)
.replace("<svg ", `<svg style="background: ${app.canvas.clear_background_color}" `);
if (workflow) {
svg = svg.replace("</svg>", `<desc>${SvgWorkflowImage.escapeXml(workflow)}</desc></svg>`);
}
return new Blob([svg], { type: "image/svg+xml" });
}
}
app.registerExtension({
name: "pysssss.WorkflowImage",
init() {
// https://codepen.io/peterhry/pen/nbMaYg
function wrapText(context, text, x, y, maxWidth, lineHeight) {
var words = text.split(" "),
line = "",
i,
test,
metrics;
for (i = 0; i < words.length; i++) {
test = words[i];
metrics = context.measureText(test);
while (metrics.width > maxWidth) {
// Determine how much of the word will fit
test = test.substring(0, test.length - 1);
metrics = context.measureText(test);
}
if (words[i] != test) {
words.splice(i + 1, 0, words[i].substr(test.length));
words[i] = test;
}
test = line + words[i] + " ";
metrics = context.measureText(test);
if (metrics.width > maxWidth && i > 0) {
context.fillText(line, x, y);
line = words[i] + " ";
y += lineHeight;
} else {
line = test;
}
}
context.fillText(line, x, y);
}
const stringWidget = ComfyWidgets.STRING;
// Override multiline string widgets to draw text using canvas while saving as svg
ComfyWidgets.STRING = function () {
const w = stringWidget.apply(this, arguments);
if (w.widget && w.widget.type === "customtext") {
const draw = w.widget.draw;
w.widget.draw = function (ctx) {
draw.apply(this, arguments);
if (this.inputEl.hidden) return;
if (getDrawTextConfig) {
const config = getDrawTextConfig(ctx, this);
const t = ctx.getTransform();
ctx.save();
if (config.resetTransform) {
ctx.resetTransform();
}
const style = document.defaultView.getComputedStyle(this.inputEl, null);
const x = config.x;
const y = config.y;
const w = parseInt(this.inputEl.style.width);
const h = parseInt(this.inputEl.style.height);
ctx.fillStyle = style.getPropertyValue("background-color");
ctx.fillRect(x, y, w, h);
ctx.fillStyle = style.getPropertyValue("color");
ctx.font = style.getPropertyValue("font");
const line = t.d * 12;
const split = this.inputEl.value.split("\n");
let start = y;
for (const l of split) {
start += line;
wrapText(ctx, l, x + 4, start, w, line);
}
ctx.restore();
}
};
}
return w;
};
},
setup() {
const script = document.createElement("script");
script.onload = function () {
const formats = [SvgWorkflowImage, PngWorkflowImage];
for (const f of formats) {
f.init?.call();
WorkflowImage.accept += (WorkflowImage.accept ? "," : "") + f.accept;
}
// Add canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push(null, {
content: "Workflow Image",
submenu: {
options: [
{
content: "Import",
callback: () => {
WorkflowImage.import();
},
},
{
content: "Export",
submenu: {
options: formats.flatMap((f) => [
{
content: f.name.replace("WorkflowImage", "").toLocaleLowerCase(),
callback: () => {
new f().export(true);
},
},
{
content: f.name.replace("WorkflowImage", "").toLocaleLowerCase() + " (no embedded workflow)",
callback: () => {
new f().export();
},
},
]),
},
},
],
},
});
return options;
};
};
script.src = new URL(`assets/canvas2svg.js`, import.meta.url);
document.body.append(script);
},
});
@@ -0,0 +1,342 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el } from "../../../scripts/ui.js";
// Adds workflow management
// Original implementation by https://github.com/i-h4x
// Thanks for permission to reimplement as an extension
const style = `
#comfy-save-button, #comfy-load-button {
position: relative;
overflow: hidden;
}
.pysssss-workflow-arrow {
position: absolute;
top: 0;
bottom: 0;
right: 0;
font-size: 12px;
display: flex;
align-items: center;
width: 24px;
justify-content: center;
background: rgba(255,255,255,0.1);
}
.pysssss-workflow-arrow:after {
content: "▼";
}
.pysssss-workflow-arrow:hover {
filter: brightness(1.6);
background-color: var(--comfy-menu-bg);
}
.pysssss-workflow-load .litemenu-entry:not(.has_submenu):before,
.pysssss-workflow-load ~ .litecontextmenu .litemenu-entry:not(.has_submenu):before {
content: "🎛️";
padding-right: 5px;
}
.pysssss-workflow-load .litemenu-entry.has_submenu:before,
.pysssss-workflow-load ~ .litecontextmenu .litemenu-entry.has_submenu:before {
content: "📂";
padding-right: 5px;
position: relative;
top: -1px;
}
.pysssss-workflow-popup ~ .litecontextmenu {
transform: scale(1.3);
}
`;
async function getWorkflows() {
const response = await api.fetchApi("/pysssss/workflows", { cache: "no-store" });
return await response.json();
}
async function getWorkflow(name) {
const response = await api.fetchApi(`/pysssss/workflows/${encodeURIComponent(name)}`, { cache: "no-store" });
return await response.json();
}
async function saveWorkflow(name, workflow, overwrite) {
try {
const response = await api.fetchApi("/pysssss/workflows", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, workflow, overwrite }),
});
if (response.status === 201) {
return true;
}
if (response.status === 409) {
return false;
}
throw new Error(response.statusText);
} catch (error) {
console.error(error);
}
}
class PysssssWorkflows {
async load() {
this.workflows = await getWorkflows();
if(this.workflows.length) {
this.workflows.sort();
}
this.loadMenu.style.display = this.workflows.length ? "flex" : "none";
}
getMenuOptions(callback) {
const menu = [];
const directories = new Map();
for (const workflow of this.workflows || []) {
const path = workflow.split("/");
let parent = menu;
let currentPath = "";
for (let i = 0; i < path.length - 1; i++) {
currentPath += "/" + path[i];
let newParent = directories.get(currentPath);
if (!newParent) {
newParent = {
title: path[i],
has_submenu: true,
submenu: {
options: [],
},
};
parent.push(newParent);
newParent = newParent.submenu.options;
directories.set(currentPath, newParent);
}
parent = newParent;
}
parent.push({
title: path[path.length - 1],
callback: () => callback(workflow),
});
}
return menu;
}
constructor() {
function addWorkflowMenu(type, getOptions) {
return $el("div.pysssss-workflow-arrow", {
parent: document.getElementById(`comfy-${type}-button`),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
LiteGraph.closeAllContextMenus();
const menu = new LiteGraph.ContextMenu(
getOptions(),
{
event: e,
scale: 1.3,
},
window
);
menu.root.classList.add("pysssss-workflow-popup");
menu.root.classList.add(`pysssss-workflow-${type}`);
},
});
}
this.loadMenu = addWorkflowMenu("load", () =>
this.getMenuOptions(async (workflow) => {
const json = await getWorkflow(workflow);
app.loadGraphData(json);
})
);
addWorkflowMenu("save", () => {
return [
{
title: "Save as",
callback: () => {
let filename = prompt("Enter filename", this.workflowName || "workflow");
if (filename) {
if (!filename.toLowerCase().endsWith(".json")) {
filename += ".json";
}
this.workflowName = filename;
const json = JSON.stringify(app.graph.serialize(), null, 2); // convert the data to a JSON string
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = $el("a", {
href: url,
download: filename,
style: { display: "none" },
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
},
},
{
title: "Save to workflows",
callback: async () => {
const name = prompt("Enter filename", this.workflowName || "workflow");
if (name) {
this.workflowName = name;
const data = app.graph.serialize();
if (!(await saveWorkflow(name, data))) {
if (confirm("A workspace with this name already exists, do you want to overwrite it?")) {
await saveWorkflow(name, app.graph.serialize(), true);
} else {
return;
}
}
await this.load();
}
},
},
];
});
this.load();
const handleFile = app.handleFile;
const self = this;
app.handleFile = function (file) {
if (file?.name?.endsWith(".json")) {
self.workflowName = file.name;
} else {
self.workflowName = null;
}
return handleFile.apply(this, arguments);
};
}
}
const refreshComboInNodes = app.refreshComboInNodes;
let workflows;
async function sendToWorkflow(img, workflow) {
const graph = !workflow ? app.graph.serialize() : await getWorkflow(workflow);
const nodes = graph.nodes.filter((n) => n.type === "LoadImage");
let targetNode;
if (nodes.length === 0) {
alert("To send the image to another workflow, that workflow must have a LoadImage node.");
return;
} else if (nodes.length > 1) {
targetNode = nodes.find((n) => n.title?.toLowerCase().includes("input"));
if (!targetNode) {
targetNode = nodes[0];
alert(
"The target workflow has multiple LoadImage nodes, include 'input' in the name of the one you want to use. The first one will be used here."
);
}
} else {
targetNode = nodes[0];
}
const blob = await (await fetch(img.src)).blob();
const name =
(workflow || "sendtoworkflow").replace(/\//g, "_") +
"-" +
+new Date() +
new URLSearchParams(img.src.split("?")[1]).get("filename");
const body = new FormData();
body.append("image", new File([blob], name));
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status === 200) {
await refreshComboInNodes.call(app);
targetNode.widgets_values[0] = name;
app.loadGraphData(graph);
app.graph.getNodeById(targetNode.id);
} else {
alert(resp.status + " - " + resp.statusText);
}
}
app.registerExtension({
name: "pysssss.Workflows",
init() {
$el("style", {
textContent: style,
parent: document.head,
});
},
async setup() {
workflows = new PysssssWorkflows();
app.refreshComboInNodes = function () {
workflows.load();
refreshComboInNodes.apply(this, arguments);
};
const comfyDefault = "[ComfyUI Default]";
const defaultWorkflow = app.ui.settings.addSetting({
id: "pysssss.Workflows.Default",
name: "🐍 Default Workflow",
defaultValue: comfyDefault,
type: "combo",
options: (value) =>
[comfyDefault, ...workflows.workflows].map((m) => ({
value: m,
text: m,
selected: m === value,
})),
});
document.getElementById("comfy-load-default-button").onclick = async function () {
if (
localStorage["Comfy.Settings.Comfy.ConfirmClear"] === "false" ||
confirm(`Load default workflow (${defaultWorkflow.value})?`)
) {
if (defaultWorkflow.value === comfyDefault) {
app.loadGraphData();
} else {
const json = await getWorkflow(defaultWorkflow.value);
app.loadGraphData(json);
}
}
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = getExtraMenuOptions?.apply?.(this, arguments);
let img;
if (this.imageIndex != null) {
// An image is selected so select that
img = this.imgs[this.imageIndex];
} else if (this.overIndex != null) {
// No image is selected but one is hovered
img = this.imgs[this.overIndex];
}
if (img) {
let pos = options.findIndex((o) => o.content === "Save Image");
if (pos === -1) {
pos = 0;
} else {
pos++;
}
options.splice(pos, 0, {
content: "Send to workflow",
has_submenu: true,
submenu: {
options: [
{ callback: () => sendToWorkflow(img), title: "[Current workflow]" },
...workflows.getMenuOptions(sendToWorkflow.bind(null, img)),
],
},
});
}
return r;
};
},
});