26 Commits
Author SHA1 Message Date
tusharbhutt 78414cd7c8 Merge branch 'main' into dev 2025-07-25 16:07:31 -06:00
tusharbhutt c0204b3304 Update __init__.py 2025-07-25 16:05:14 -06:00
tusharbhutt 8da70aa94a Added minimap and node spawner
Added minimap and node spawner
2025-07-25 15:58:03 -06:00
tusharbhutt edc4fa2eb6 Merge pull request #29 from tusharbhutt/dev
Add JS folder via init
2025-07-20 08:54:28 -06:00
tusharbhutt 564e5e7135 Merge branch 'main' into dev 2025-07-20 08:54:16 -06:00
tusharbhutt 37a5de9d1a Add files via upload
Update pytoml
2025-07-20 08:51:47 -06:00
tusharbhutt a82ab2e253 Add files via upload
Forgot to add javascript web folder location
2025-07-20 08:50:10 -06:00
tusharbhutt eb76509f12 Add files via upload
Forgot to upload the proper init that finds the web dev
2025-07-20 08:44:50 -06:00
tusharbhutt 88c4b84816 Update pyproject.toml
version mismatch
2025-07-19 16:31:20 -06:00
tusharbhutt 8a84dcef01 Merge pull request #28 from tusharbhutt/dev
Try #2 to get font change script added
2025-07-19 16:28:37 -06:00
tusharbhutt 4bc085ba39 Merge branch 'main' into dev 2025-07-19 16:28:06 -06:00
tusharbhutt 05ebcdc8a4 Update README.md 2025-07-19 16:15:54 -06:00
tusharbhutt 363b4bce1f Add files via upload
Uploading V1.3.0 with ability to change font sizes
2025-07-19 16:14:53 -06:00
tusharbhutt b0964fb39f Update pyproject.toml 2025-07-09 22:34:58 -06:00
tusharbhutt 98d62792b8 Update pyproject.toml 2025-07-09 22:34:17 -06:00
tusharbhutt 4ecd92c0d5 Merge pull request #26 from tusharbhutt/main
Merge pull request #25 from tusharbhutt/dev
2025-07-09 22:25:32 -06:00
tusharbhutt c6562524a2 Merge pull request #25 from tusharbhutt/dev
Fixed image saver so it does not force the user to connect "prompt_list"
2025-07-09 22:15:53 -06:00
tusharbhutt 76c87b0932 Add files via upload
Fixed image saver so it does not force the user to connect "prompt_list"
2025-07-09 22:08:46 -06:00
tusharbhutt 588f6b8055 Update pyproject.toml
Added Flux Kontext to batch multiprompter
2025-07-07 13:51:51 -06:00
tusharbhutt b9ba64095b Update README.md 2025-07-07 13:50:44 -06:00
tusharbhutt bac249b2b1 Add files via upload
Update documents
2025-07-07 13:47:46 -06:00
tusharbhutt 91ac2eb06c Add files via upload
Adding Flux Kontext functionality
2025-07-07 13:46:47 -06:00
tusharbhutt 5dd1efb150 Add files via upload
JSON for Kontext workflow
2025-07-07 13:45:55 -06:00
tusharbhutt 5af7f3d92c Add files via upload 2025-07-07 13:45:13 -06:00
tusharbhutt 1d96be7963 Update pyproject.toml
Fix error #24, JSON does not contain workflow
2025-07-06 21:01:51 -06:00
tusharbhutt ef7a68527f Add files via upload
Addresses issue of JSON not holding the workflow.
2025-07-06 21:00:14 -06:00
26 changed files with 4374 additions and 506 deletions
+151 -3
View File
@@ -2,7 +2,8 @@
Some basic custom nodes for the ComfyUI user interface for Stable Diffusion. Features:
+ **True batch multiprompting capability for ComfyUI**
+ **True batch multi-prompting capability for ComfyUI**
+ **Quality of Life scripts such as multiple node spawning and node minimap**
+ An image saver for images and JSON files to base folder, custom folders for one, or custom folders for both. Also allows for Python timestamping
+ Switches for text and numbers
+ Random prompt selectors
@@ -14,12 +15,45 @@ When using the [ComfyUI](https://github.com/comfyanonymous/ComfyUI) interface fo
Rightly or wrongly, I was teaching myself a bit of Python back in 2023 to get some nodes up and running to do what I'd like, and I am starting to do that again. Yes, I am using ChatGPT, Copilot, Claude and others, and yes, I am a glutton for punishment. There are no promises that these nodes will work for you or that I will maintain them. Feel free to do with them as you wish, according to the license model.
***
**UPDATE: JUL 25, 2025**
**New for Version 1.5:**
+ Added the Endless 🌊✨ Node Spawner, which accesses your node library and allows you to select nodes to places
+ Categorizes your nodes based on the ComfyUI menu system
+ Places nodes with intelligent collision avoidance so it will not overlap existing ones
+ Real time search and filtering off nodes to adda
+ Added the Endless 🌊✨ Minimap, which shows the current workflow landscape in a floating map
+ Colour coded nodes by category
+ Click on any node in the map to jump to it
+ Respects window aspect ratio
+ Places nodes with intelligent collision avoidance so it will not overlap existing ones
+ Real time search and filtering off nodes to add
+ Updated the Endless 🌊✨ Fontifier to allow for persistent state saving.
**UPDATE: JUL 18, 2025**
**Version 1.3 introduces the Endless 🌊✨ Fontifier, a little button on your taskbar that allows you to dynamically change fonts and sizes.**
+ No need to dive into CSS to change text size
+ Allows changes to the title areas, connector text, widgets, and more
+ Adjust the higher of the title bar and other areas too, to accommodate the new font size
***
**UPDATE: JUL 8, 2025**
**Updated Batch prompt nodes to handle Flux Kontext. You can set multiple prompts in the node and they will be processed simultaneously by the GPU. Corrected JSON export capability so a saved JSON properly loads the workflow when dragged and dropped onto the UI.**
+ You can now use the batch prompt node with Flux Kontext Dev. The process works the same way as the other nodes, except here the prompts are used to make changes to the image(s). You cannot iterate within the prompt set (e.g, set up a list of prompts for a sequence of changes), it is designed to allow you to process multiple scenarios at once.
+ Corrected the JSON files that were being exported. They will now load the workflow when dragged back to the UI. As a bonus, if the PNGINfo is also selected, the JSON will remove that, lowering the size of the file.
***
**UPDATE: JUN 28, 2025**
**Updated Batch Flux node to handle separate Clip and T5 text encoders along with separate Flux weight files. Updated the SDXL node to handle separate Clip-L and CLip-G encoders**
+ Corrected Prompt Counter Node so it can accept either pipe (**\|**) or newline characters as line breaks, allowing it to be connected immediately to the Batch Prompt node (unlike before), or somewhere else downstream from the prompt creation node(s).
+ Uploaded JSON files for SD1*x* and SD2*x*, SDXL, and updated Flux batch workflow with standardized loader nodes appropriate to the model. Aso uploade dynamci workflow JSON.
+ Uploaded JSON files for SD1*x* and SD2*x*, SDXL, and updated Flux batch workflow with standardized loader nodes appropriate to the model. Also uploaded dynamic workflow JSON.
+ Added an example dynamic prompt workflow for Flux that can be repurposed for other models. Added images with embedded workflows for the above.
***
@@ -56,6 +90,116 @@ I am not a programmer, nor do I care to be. I have a fulltime job that eats up
If you have issues, ask me **nicely** for help. Your tone matters; I'm too old and tired to pay attention to people who think I blew up their machines, and if how I react to you if you are difficult bothers you, some self-reflection is in order on your part. You are not "forthright" or "honest" or "direct", you're merely an ass if you think badgering people is justifiable to get what you want. The world has too many assholes, don't make me think you're another one.
***
## Button List
A helper script places a button on your task bar called "Endless 🌊✨ Tools":
![endlesstools](./img/endlesstools.png)
**IF YOU DO NOT SEE IT, PRESS CTRL-ALT-E TO BRING UP A FLOATING TOOLBAR THAT CAN BE MOVED AROUND** This is the central toolbar that contains all the scripts I have made for this collection. Get rid of it by pressing the ESC key.
### Endless 🌊✨ Node Spawner
I find that sometimes I need to create a few nodes for a workflow and creating them one at a time is painful for me. So, I made the Endless 🌊✨ Node Spawner. The spawner has a searchable, categorized interface that supports batch operations and maintains usage history for improved efficiency. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Node Spawner". Clicking it shows the dialog box below:
![spawnerdialog](./img/spawnerdialog.png)
The node spawner has the following features:
+ Hierarchical categorization of all available nodes
+ Real-time search and filtering capabilities
+ Search history with dropdown suggestions
+ Batch node selection and spawning
+ Intelligent collision detection for node placement
+ Category-level selection controls
+ Persistent usage tracking and search history
Here's a quick overview of how to use the spawner:
+ Open the Node Loader from the Endless Tools menu
+ Browse categories or use the search filter to find specific nodes
+ Select nodes individually or use category selection buttons
+ Review selections in the counter display
+ Click Spawn Nodes to add selected nodes to your workflow
+ Recently used nodes appear as clickable chips for quick access
Once you have made your selections and applied them, all the nodes you created will appear. How fast is it? My system can create 950 nodes in less than two seconds.
![spawnerresult](./img/spawnerresult.png)
### Endless 🌊✨ Minimap
When you have large workflows, it can be hard to keep tack of everything on the screen. The ComfyUI web interface does have a button to resize the nodes to your screen, but I thought a minimap would be of use to some people. The minimap displays a scaled overview of all nodes with visual indicators for the current viewport and support for direct navigation. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Minimap". Clicking it shows the dialog box below:
![minimapbox](./img/minimapbox.png)
The minimap has the following features:
+ Dynamic aspect ratio adjustment based on canvas dimensions
+ Real-time viewport highlighting with theme-aware colors
+ Interactive click-to-navigate functionality
+ Zoom and pan controls for detailed exploration
+ Color-coded node types with optional legend display
+ Responsive resizing based on window dimensions
+ Drag-and-drop repositioning of the minimap window
Drag the box around by clicking and holding the title. To cancel, you can simply click outside the dialog box or press the escape key. With this dialog box, you can do the following:
+ Use the minimap to understand your workflow's overall structure
+ Click anywhere on the minimap to jump to that location
+ Click a node to jump to the node
+ Use zoom controls (+/-) or mouse wheel for detailed viewing
+ Toggle the legend (🎨) to identify node types by color
The drag bar has the following icons:
![minimapicons](./img/minimapicons.png)
The icons correspond to:
🎨 Toggle legend
▫ Zoom out
🏠 Reset zoom and pan
⬜ Zoom in
❌ Close minimap
When you zoom in to the minimap, you can see the labels of the nodes as well, like shown below. The minimap should also change shape to respect the browser window shape, and works with ComfyUI themes.
![minimaplabel](./img/minimaplabel.png)
### Endless 🌊✨ Fontifier
I always found it odd that in the early days of ComfyUI, you could not change the font size for various node elements. Sure you could manually go into the CSS styling in a user file, but that is not user friendly. Later versions have allowed you to change the widget text size, but that's it. Yes, you can zoom in, but... now you've lost your larger view of the workflow. If you have a 4K monitor and old eyes, too bad so sad for you. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Fontifier". Clicking it shows the dialog box below:
![fontifierbox](./img/fontifierbox.png)
Drag the box around by clicking and holding the title. To cancel, you can simply click outside the dialog box or press the escape key. With this dialog box, you can do the following:
+ Globally change the font size for all text elements
+ Change the fonts themselves
+ Instead of a global change, select various elements to resize
+ Adjust the height of the title bar or connectors and other input areas
+ Save the current state for persistent use, or revert to ComfyUI defaults
Once you make your changes, you can preview them and then choose to apply or cancel. Changed your mind? Load the box again and press the reset key. You can also save your settings for persistent use.
![fontifiernode](./img/fontifiernode.png)
## Node List
### Batch Multiprompt Node for SD, SDXL, and FLUX
@@ -92,7 +236,11 @@ Output:
+ Prompt List: For downstream node usage
+ Prompt Count: Counts how many prompts you had
The images below should have workflows built in but if not, check out the workflows folder too. The image of the beaver is set up for the SD1.*x*/SD2.*x* node (called the "Simple Batch Prompts" node), the goose superhero is for SDXL, the moose overlord is for Flux, and the pink sock monkey is an example of using the Flux batcher with Dynamic Prompts. That (sadly,abandoned) custom node is available [here if you need it](https://github.com/adieyal/comfyui-dynamicprompts)
The images below should have workflows built in but if not, check out the workflows folder too. The image of the beaver is set up for the SD1.*x*/SD2.*x* node (called the "Simple Batch Prompts" node), the goose superhero is for SDXL, the moose overlord is for Flux, and Vermeer's Girl With the Pearl Earring is for Kontext. If you want to try your hand with randomness, the pink sock monkey is an example of using the Flux batcher with Dynamic Prompts. That (sadly,abandoned) custom node is available [here if you need it](https://github.com/adieyal/comfyui-dynamicprompts)
![kontextbatchworkflow](./img/kontextworkflow.png)
Image with embedded workflow for Flux Kontext
![fluxbatchworkflow](./img/fluxworkflow.png)
Image with embedded Flux workflow
+10 -1
View File
@@ -1,5 +1,14 @@
# nodes/__init__.py
# nodes/__init__.py
# =========================
# 💉 Set JavaScript
# =========================
WEB_DIRECTORY = "./web/"
# Import NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS from each submodule if present
try:
from .batchers import NODE_CLASS_MAPPINGS as BATCH_PROCESSOR_CLASSES, NODE_DISPLAY_NAME_MAPPINGS as BATCH_PROCESSOR_NAMES
@@ -85,7 +94,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(TEXT_SWITCH_NAMES)
NODE_DISPLAY_NAME_MAPPINGS.update(TYPE_CONVERTER_NAMES)
# Version info
__version__ = "1.2.2"
__version__ = "1.5.0"
print(f"Endless Sea of Stars Custom Nodes v{__version__} loaded successfully!")
print("Nodes available under 'Endless 🌊✨' menu")
+7 -1
View File
@@ -9,6 +9,8 @@ from .endless_batchers import (
EndlessNode_SDXLBatchPrompts,
EndlessNode_BatchNegativePrompts,
EndlessNode_PromptCounter,
EndlessNode_FluxKontextBatchPrompts,
EndlessNode_ReplicateLatents,
# IGNORE ME, I AM NOT READY!!
# from .endless_fluxlatent import (
# EndlessNode_FluxLatentReplicator,
@@ -22,6 +24,8 @@ NODE_CLASS_MAPPINGS = {
"SDXLBatchPrompts": EndlessNode_SDXLBatchPrompts,
"BatchNegativePrompts": EndlessNode_BatchNegativePrompts,
"PromptCounter": EndlessNode_PromptCounter,
"FluxKontextBatchPrompts": EndlessNode_FluxKontextBatchPrompts,
"EndlessReplicateLatents": EndlessNode_ReplicateLatents,
# IGNORE ME, I AM NOT READY!!
# "LatentReplicator": EndlessNode_FluxLatentReplicator,
# "LatentReplicatorPrompts": EndlessNode_FluxLatentReplicatorFromPrompts,
@@ -34,7 +38,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SDXLBatchPrompts": "SDXL Batch Prompts",
"BatchNegativePrompts": "Batch Negative Prompts",
"PromptCounter": "Prompt Counter",
# IGNORE ME, I AM NOT READY!!
"FluxKontextBatchPrompts": "FLUX Kontext Batch Prompts",
"EndlessReplicateLatents": "Replicate Latents",
# IGNORE ME, I AM NOT READY!!
# "LatentReplicator": "Latent Replicator",
# "LatentReplicatorPrompts": "Latent Replicator from Prompts",
}
+231 -59
View File
@@ -108,7 +108,8 @@ class EndlessNode_SimpleBatchPrompts:
class EndlessNode_FluxBatchPrompts:
"""
Specialized batch prompt encoder for FLUX models
Handles FLUX-specific conditioning requirements including guidance and T5 text encoding
Handles FLUX-specific conditioning requirements with proper dual encoder support
Maintains true batch processing for both unified and separate encoder setups
"""
@classmethod
def INPUT_TYPES(s):
@@ -150,7 +151,36 @@ class EndlessNode_FluxBatchPrompts:
for i, prompt in enumerate(prompt_lines):
print(f" {i+1}: {prompt}")
# Encode each prompt with FLUX-specific conditioning
# Try true batch encoding first (works with unified CLIP)
try:
# Create a single multi-line prompt for batch tokenization
batch_prompt = "\n".join(prompt_lines)
# Try to tokenize the entire batch at once
tokens = clip.tokenize(batch_prompt)
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
# Check if we got proper batch dimensions
expected_batch_size = len(prompt_lines)
if cond.shape[0] == expected_batch_size:
# Success! We have proper batched encoding
conditioning = [[cond, {
"pooled_output": pooled,
"guidance": guidance,
"guidance_scale": guidance
}]]
if print_output:
print(f"✓ True batch encoding successful: {cond.shape}, pooled: {pooled.shape}")
prompt_list_str = "|".join(prompt_lines)
return (conditioning, prompt_list_str, prompt_count)
except Exception as e:
if print_output:
print(f"Batch encoding failed, trying individual encoding: {e}")
# Fallback to individual encoding (for dual encoders or other issues)
cond_tensors = []
pooled_tensors = []
@@ -160,9 +190,13 @@ class EndlessNode_FluxBatchPrompts:
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
cond_tensors.append(cond)
pooled_tensors.append(pooled)
if print_output and i == 0:
print(f"Individual encoding shapes - cond: {cond.shape}, pooled: {pooled.shape}")
except Exception as e:
print(f"Error encoding FLUX prompt {i+1} '{prompt}': {e}")
# Use a fallback empty prompt
# Use fallback empty prompt
try:
tokens = clip.tokenize("")
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
@@ -172,35 +206,57 @@ class EndlessNode_FluxBatchPrompts:
except Exception as fallback_error:
raise ValueError(f"Failed to encode FLUX prompt {i+1} and fallback failed: {fallback_error}")
# Batch the conditioning tensors properly for FLUX
# Now try to batch the individual encodings
try:
# Stack the conditioning tensors along batch dimension
batched_cond = torch.cat(cond_tensors, dim=0)
batched_pooled = torch.cat(pooled_tensors, dim=0)
# Check tensor shapes for compatibility
first_cond_shape = cond_tensors[0].shape[1:] # Skip batch dimension
first_pooled_shape = pooled_tensors[0].shape[1:] # Skip batch dimension
if print_output:
print(f"Created FLUX batched conditioning: {batched_cond.shape}")
print(f"Created FLUX batched pooled: {batched_pooled.shape}")
shapes_compatible = all(
tensor.shape[1:] == first_cond_shape for tensor in cond_tensors
) and all(
tensor.shape[1:] == first_pooled_shape for tensor in pooled_tensors
)
# FLUX-specific conditioning with guidance
conditioning = [[batched_cond, {
"pooled_output": batched_pooled,
"guidance": guidance,
"guidance_scale": guidance # Some FLUX implementations use this key
}]]
if shapes_compatible:
# Concatenate along batch dimension
batched_cond = torch.cat(cond_tensors, dim=0)
batched_pooled = torch.cat(pooled_tensors, dim=0)
conditioning = [[batched_cond, {
"pooled_output": batched_pooled,
"guidance": guidance,
"guidance_scale": guidance
}]]
if print_output:
print(f"✓ Individual->Batch concatenation successful: {batched_cond.shape}")
else:
# Shapes incompatible - use list format but still maintain batch structure
conditioning = []
for i in range(len(cond_tensors)):
conditioning.append([cond_tensors[i], {
"pooled_output": pooled_tensors[i],
"guidance": guidance,
"guidance_scale": guidance
}])
if print_output:
print(f"⚠ Using list format due to incompatible shapes (dual encoder setup)")
print(f" Cond shapes: {[t.shape for t in cond_tensors[:3]]}") # Show first 3
print(f" Pooled shapes: {[t.shape for t in pooled_tensors[:3]]}")
except Exception as e:
print(f"Error creating FLUX batched conditioning: {e}")
print("Falling back to list format...")
# Fallback to list format if batching fails
print(f"Error during tensor batching: {e}")
# Final fallback to individual list
conditioning = []
for i in range(len(cond_tensors)):
flux_conditioning = [cond_tensors[i], {
conditioning.append([cond_tensors[i], {
"pooled_output": pooled_tensors[i],
"guidance": guidance,
"guidance_scale": guidance
}]
conditioning.append(flux_conditioning)
}])
prompt_list_str = "|".join(prompt_lines)
return (conditioning, prompt_list_str, prompt_count)
@@ -209,7 +265,7 @@ class EndlessNode_FluxBatchPrompts:
class EndlessNode_SDXLBatchPrompts:
"""
Specialized batch prompt encoder for SDXL models
Handles dual text encoders and SDXL-specific conditioning requirements
Handles dual text encoders with proper batch processing
"""
@classmethod
def INPUT_TYPES(s):
@@ -250,7 +306,27 @@ class EndlessNode_SDXLBatchPrompts:
for i, prompt in enumerate(prompt_lines):
print(f" {i+1}: {prompt}")
# Encode each prompt with SDXL-specific conditioning
# Try true batch encoding first
try:
batch_prompt = "\n".join(prompt_lines)
tokens = clip.tokenize(batch_prompt)
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
expected_batch_size = len(prompt_lines)
if cond.shape[0] == expected_batch_size:
conditioning = [[cond, {"pooled_output": pooled}]]
if print_output:
print(f"✓ SDXL batch encoding successful: {cond.shape}")
prompt_list_str = "|".join(prompt_lines)
return (conditioning, prompt_list_str, prompt_count)
except Exception as e:
if print_output:
print(f"SDXL batch encoding failed, trying individual: {e}")
# Individual encoding fallback
cond_tensors = []
pooled_tensors = []
@@ -262,7 +338,6 @@ class EndlessNode_SDXLBatchPrompts:
pooled_tensors.append(pooled)
except Exception as e:
print(f"Error encoding SDXL prompt {i+1} '{prompt}': {e}")
# Use a fallback empty prompt
try:
tokens = clip.tokenize("")
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
@@ -272,27 +347,37 @@ class EndlessNode_SDXLBatchPrompts:
except Exception as fallback_error:
raise ValueError(f"Failed to encode SDXL prompt {i+1} and fallback failed: {fallback_error}")
# Batch the conditioning tensors properly for SDXL
# Try to batch the results
try:
# Stack the conditioning tensors along batch dimension
batched_cond = torch.cat(cond_tensors, dim=0)
batched_pooled = torch.cat(pooled_tensors, dim=0)
first_cond_shape = cond_tensors[0].shape[1:]
first_pooled_shape = pooled_tensors[0].shape[1:]
if print_output:
print(f"Created SDXL batched conditioning: {batched_cond.shape}")
print(f"Created SDXL batched pooled: {batched_pooled.shape}")
shapes_compatible = all(
tensor.shape[1:] == first_cond_shape for tensor in cond_tensors
) and all(
tensor.shape[1:] == first_pooled_shape for tensor in pooled_tensors
)
# SDXL-specific conditioning - simplified without size parameters
conditioning = [[batched_cond, {"pooled_output": batched_pooled}]]
if shapes_compatible:
batched_cond = torch.cat(cond_tensors, dim=0)
batched_pooled = torch.cat(pooled_tensors, dim=0)
conditioning = [[batched_cond, {"pooled_output": batched_pooled}]]
if print_output:
print(f"✓ SDXL individual->batch successful: {batched_cond.shape}")
else:
conditioning = []
for i in range(len(cond_tensors)):
conditioning.append([cond_tensors[i], {"pooled_output": pooled_tensors[i]}])
if print_output:
print(f"⚠ SDXL using list format due to incompatible shapes")
except Exception as e:
print(f"Error creating SDXL batched conditioning: {e}")
print("Falling back to list format...")
# Fallback to list format if batching fails
print(f"SDXL batching error: {e}")
conditioning = []
for i in range(len(cond_tensors)):
sdxl_conditioning = [cond_tensors[i], {"pooled_output": pooled_tensors[i]}]
conditioning.append(sdxl_conditioning)
conditioning.append([cond_tensors[i], {"pooled_output": pooled_tensors[i]}])
prompt_list_str = "|".join(prompt_lines)
return (conditioning, prompt_list_str, prompt_count)
@@ -394,7 +479,6 @@ class EndlessNode_PromptCounter:
Utility node to count prompts from input text and display a preview.
The preview will be shown in the console output and returned as a string output.
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -403,41 +487,129 @@ class EndlessNode_PromptCounter:
"print_to_console": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("count", "preview")
FUNCTION = "count_prompts"
CATEGORY = "Endless 🌊✨/BatchProcessing"
def count_prompts(self, prompts, print_to_console):
prompt_lines = [line.strip() for line in prompts.split('\n') if line.strip()]
count = len(prompt_lines)
# Handle both pipe-separated (from batch nodes) and newline-separated formats
if '|' in prompts and '\n' not in prompts.strip():
# Pipe-separated format from batch node
prompt_lines = [line.strip() for line in prompts.split('|') if line.strip()]
else:
# Newline-separated format from text input
prompt_lines = [line.strip() for line in prompts.split('\n') if line.strip()]
count = len(prompt_lines)
preview = f"Found {count} prompt{'s' if count != 1 else ''}:\n"
for i, prompt in enumerate(prompt_lines[:5]):
preview += f"{i+1}. {prompt}\n"
if count > 5:
preview += f"... and {count - 5} more"
if print_to_console:
print(f"\n=== Prompt Counter ===")
print(preview)
print("======================\n")
return (count, preview)
NODE_CLASS_MAPPINGS = {
"EndlessNode_SimpleBatchPrompts": EndlessNode_SimpleBatchPrompts,
"EndlessNode_FluxBatchPrompts": EndlessNode_FluxBatchPrompts,
"EndlessNode_SDXLBatchPrompts": EndlessNode_SDXLBatchPrompts,
"EndlessNode_BatchNegativePrompts": EndlessNode_BatchNegativePrompts,
"EndlessNode_PromptCounter": EndlessNode_PromptCounter,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EndlessNode_SimpleBatchPrompts": "Simple Batch Prompts",
"EndlessNode_FluxBatchPrompts": "Flux Batch Prompts",
"EndlessNode_SDXLBatchPrompts": "SDXL Batch Prompts",
"EndlessNode_BatchNegativePrompts": "Batch Negative Prompts",
"EndlessNode_PromptCounter": "Prompt Counter",
}
class EndlessNode_ReplicateLatents:
"""
Replicates latents to match prompt batch size (for use with Kontext-style workflows)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
"count": ("INT", {"default": 1, "min": 1, "max": 64}),
}
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "replicate_latent"
CATEGORY = "Endless 🌊✨/BatchProcessing"
def replicate_latent(self, latent, count):
if not isinstance(latent, dict) or "samples" not in latent:
raise ValueError("Expected latent input to be a dict with 'samples' key")
samples = latent["samples"]
if not hasattr(samples, "unsqueeze"):
raise ValueError("Latent 'samples' tensor invalid")
replicated = samples.repeat(count, 1, 1, 1)
return ({"samples": replicated},)
class EndlessNode_FluxKontextBatchPrompts:
"""
Specialized batch prompt encoder for FLUX Kontext editing.
Handles simultaneous edit prompts and outputs batched conditioning for each.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompts": ("STRING", {"multiline": True, "default": "change sky to sunset\nadd rainbow\nmake it night"}),
"clip": ("CLIP", ),
"guidance": ("FLOAT", {"default": 3.5, "min": 0.0, "max": 100.0, "step": 0.1}),
"print_output": ("BOOLEAN", {"default": True}),
"max_batch_size": ("INT", {"default": 0, "min": 0, "max": 64}),
}
}
RETURN_TYPES = ("CONDITIONING", "STRING", "INT")
RETURN_NAMES = ("CONDITIONING", "PROMPT_LIST", "PROMPT_COUNT")
FUNCTION = "batch_encode"
CATEGORY = "Endless 🌊✨/BatchProcessing"
def batch_encode(self, prompts, clip, guidance, print_output, max_batch_size=0):
prompt_lines = [line.strip() for line in prompts.split('\n') if line.strip()]
prompt_count = len(prompt_lines)
if not prompt_lines:
raise ValueError("No valid prompts found.")
if max_batch_size > 0:
if max_batch_size < prompt_count:
prompt_lines = prompt_lines[:max_batch_size]
elif max_batch_size > prompt_count:
original = list(prompt_lines)
while len(prompt_lines) < max_batch_size:
prompt_lines.extend(original[:max_batch_size - len(prompt_lines)])
cond_tensors = []
pooled_tensors = []
for i, prompt in enumerate(prompt_lines):
try:
tokens = clip.tokenize(prompt)
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
cond_tensors.append(cond)
pooled_tensors.append(pooled)
except Exception as e:
tokens = clip.tokenize("")
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
cond_tensors.append(cond)
pooled_tensors.append(pooled)
try:
batched_cond = torch.cat(cond_tensors, dim=0)
batched_pooled = torch.cat(pooled_tensors, dim=0)
conditioning = [[batched_cond, {
"pooled_output": batched_pooled,
"guidance": guidance,
"guidance_scale": guidance
}]]
except:
conditioning = []
for c, p in zip(cond_tensors, pooled_tensors):
conditioning.append([c, {
"pooled_output": p,
"guidance": guidance,
"guidance_scale": guidance
}])
prompt_list_str = "|".join(prompt_lines)
return (conditioning, prompt_list_str, len(prompt_lines))
+11 -1
View File
@@ -1,4 +1,14 @@
June 28/25: V1.2.2: Corrected Prompt Counter Node so it can accept pipe or newline characters as line breaks, allowing it to be connected immediately to the Batch Prompt node (unlike before), or somewhere else downstream from the prompt creation node(s). Uploaded JSON files for SD1x and SD2x, SDXL, and updated Flux batch workflow was standardized loader nodes appropriate to the model. Added an example dynamic prompt workflow for Flux that can be repurposed for other models. Added images with embedded workflows for the above.
July 25/25, V1.5: Adds the Node Spawner to create multiple nodes at once,and a node minimap
July 19/25, V1.3: Introducing the Endless Fontifier, a javascript file that adds allows the user to change font sizes and fonts for various text elements on the ComfyUI interface.
July 8/25, V1.2.5: Fixed bug in Image Saver that forced a connection for prompts. That is now optional
July 7/25, V1.2.4: You can now use the batch prompt node with Flux Kontext Dev. The process works the same way as the other nodes, except here the prompts are used to make changes to the image(s). You cannot iterate within the prompt set (e.g, set up a list of prompts for a sequence of changes), it is designed to allow you to process multiple scenarios at once.
July 6/25, V1.2.3: Corrected the JSON files that were being exported. They will now load the workflow when dragged back to the UI. As a bonus, if the PNGINfo is also selected, the JSON will remove that, lowering the size of the file.
June 28/25, V1.2.2: Corrected Prompt Counter Node so it can accept pipe or newline characters as line breaks, allowing it to be connected immediately to the Batch Prompt node (unlike before), or somewhere else downstream from the prompt creation node(s). Uploaded JSON files for SD1x and SD2x, SDXL, and updated Flux batch workflow was standardized loader nodes appropriate to the model. Added an example dynamic prompt workflow for Flux that can be repurposed for other models. Added images with embedded workflows for the above.
Jun 24/23, V1.2.1: Added parent level requirements.txt file
+120 -35
View File
@@ -1,7 +1,13 @@
# =============================
# 🤓 Module-Level Code
# =============================
import os
import json
import re
from server import PromptServer
from datetime import datetime
from PIL import Image, PngImagePlugin
import numpy as np
import torch
@@ -9,6 +15,42 @@ import folder_paths
from PIL.PngImagePlugin import PngInfo
import platform
# =============================
# 🛠️ Workflow helper
# =============================
def get_workflow(prompt=None, extra_pnginfo=None):
from server import PromptServer
# First: try directly from prompt
if isinstance(prompt, dict) and "workflow" in prompt:
return prompt["workflow"]
# Second: try from extra_pnginfo
if isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo:
print("[INFO] Workflow recovered from extra_pnginfo.")
return extra_pnginfo["workflow"]
# Third: fallback from PromptServer
last = getattr(PromptServer.instance, "last_prompt", {})
workflow = last.get("workflow")
if workflow:
print("[INFO] Workflow recovered from PromptServer.last_prompt.")
return workflow
raise ValueError("🚫 No workflow found in prompt, extra_pnginfo, or PromptServer context.")
# =============================
# 🚧 Node Construction
# =============================
CATEGORY = "Endless 🌊✨/IO"
# =============================
# 💾 Image Saver
# =============================
class EndlessNode_Imagesaver:
"""
Enhanced batch image saver with comprehensive metadata support
@@ -37,7 +79,6 @@ class EndlessNode_Imagesaver:
def INPUT_TYPES(s):
return {"required":
{"images": ("IMAGE", ),
"prompt_list": ("STRING", {"forceInput": True}),
"include_timestamp": ("BOOLEAN", {"default": True}),
"timestamp_format": ("STRING", {"default": "%Y-%m-%d_%H-%M-%S", "description": "Use Python strftime format.\nExample: %Y-%m-%d %H-%M-%S\nSee: strftime.org for full options."}),
"image_format": (["PNG", "JPEG", "WEBP"], {"default": "PNG"}),
@@ -56,6 +97,7 @@ class EndlessNode_Imagesaver:
},
"optional":
{"output_path": ("STRING", {"default": ""}),
"prompt_list": ("STRING", {"forceInput": True, "default": ""}),
"filename_prefix": ("STRING", {"default": "Batch"}),
"negative_prompt_list": ("STRING", {"default": ""}),
"json_folder": ("STRING", {"default": ""}),
@@ -70,7 +112,7 @@ class EndlessNode_Imagesaver:
RETURN_NAMES = ("saved_paths",)
FUNCTION = "save_batch_images"
OUTPUT_NODE = True
CATEGORY = "Endless 🌊✨/IO"
CATEGORY = CATEGORY
def encode_emoji(self, obj):
"""Properly encode emojis and special characters"""
@@ -190,25 +232,60 @@ class EndlessNode_Imagesaver:
counter += 1
def save_json_metadata(self, json_path, prompt_text, negative_text,
batch_index, creation_time, prompt=None, extra_pnginfo=None):
"""Save JSON metadata file"""
metadata = {
"prompt": prompt_text,
"negative_prompt": negative_text,
"batch_index": batch_index,
"creation_time": creation_time,
"workflow_prompt": prompt,
"extra_pnginfo": extra_pnginfo
}
batch_index, creation_time, prompt=None, extra_pnginfo=None):
"""Exports a drag-and-drop-compatible ComfyUI workflow JSON with optional metadata."""
import json
import math
def sanitize_json(obj):
if isinstance(obj, dict):
return {k: sanitize_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize_json(v) for v in obj]
elif isinstance(obj, float) and math.isnan(obj):
return None
else:
return obj
try:
with open(json_path, 'w', encoding='utf-8', newline='\n') as f:
json.dump(metadata, f, indent=2, default=self.encode_emoji, ensure_ascii=False)
workflow = get_workflow(prompt)
# Ensure core fields
workflow.setdefault("version", 1)
workflow.setdefault("nodes", [])
workflow.setdefault("links", [])
if "state" not in workflow:
max_id = max((n.get("id", 0) for n in workflow["nodes"]), default=0)
workflow["state"] = {"idCounter": max_id + 1}
# Remove sidecar metadata if present
workflow.pop("extra_pnginfo", None)
# Embed custom metadata
workflow["extra"] = workflow.get("extra", {})
workflow["extra"].update({
"prompt": prompt_text,
"negative_prompt": negative_text,
"batch_index": batch_index,
"creation_time": creation_time,
"source": "FluxSaver"
})
clean_json = sanitize_json(workflow)
with open(json_path, "w", encoding="utf-8", newline="\n") as f:
json.dump(clean_json, f, indent=2, default=self.encode_emoji, ensure_ascii=False)
print(f"[SUCCESS] Workflow JSON saved: {json_path}")
return True
except Exception as e:
print(f"Failed to save JSON metadata: {e}")
print(f"[ERROR] Failed to save workflow JSON: {e}")
return False
def generate_numbered_filename(self, filename_prefix, delimiter, counter,
filename_number_padding, filename_number_start,
enable_filename_numbering, date_str, clean_prompt, ext):
@@ -242,19 +319,29 @@ class EndlessNode_Imagesaver:
return filename
def save_batch_images(self, images, prompt_list, include_timestamp=True,
timestamp_format="%Y-%m-%d_%H-%M-%S", image_format="PNG",
jpeg_quality=95, delimiter="_",
prompt_words_limit=8, embed_workflow=True, save_json_metadata=False,
enable_filename_numbering=True, filename_number_padding=2,
filename_number_start=False, embed_png_metadata=True,
output_path="", filename_prefix="batch",
negative_prompt_list="", json_folder="", prompt=None, extra_pnginfo=None):
def save_batch_images(self, images, prompt_list="", include_timestamp=True,
timestamp_format="%Y-%m-%d_%H-%M-%S", image_format="PNG",
jpeg_quality=95, delimiter="_",
prompt_words_limit=8, embed_workflow=True, save_json_metadata=False,
enable_filename_numbering=True, filename_number_padding=2,
filename_number_start=False, embed_png_metadata=True,
output_path="", filename_prefix="batch",
negative_prompt_list="", json_folder="", prompt=None, extra_pnginfo=None):
# Debug: Print tensor information
print(f"DEBUG: Images tensor shape: {images.shape}")
print(f"DEBUG: Images tensor type: {type(images)}")
# Fallback: repair prompt if missing or partial
if prompt is None or not isinstance(prompt, dict) or "workflow" not in prompt:
if extra_pnginfo and "workflow" in extra_pnginfo:
print("[INFO] Workflow recovered from extra_pnginfo.")
prompt = {"workflow": extra_pnginfo["workflow"]}
else:
print("[INFO] Workflow recovered from PromptServer.last_prompt.")
prompt = PromptServer.instance.last_prompt or {}
# Process output path with date/time validation (always process regardless of timestamp toggle)
processed_output_path = self.validate_and_process_path(output_path, delimiter)
@@ -389,27 +476,25 @@ class EndlessNode_Imagesaver:
if image_format == "PNG":
# ITEM #3: Conditional PNG metadata embedding
if embed_png_metadata:
# Prepare PNG metadata
metadata = PngImagePlugin.PngInfo()
metadata.add_text("prompt", prompt_text)
metadata.add_text("negative_prompt", negative_text)
metadata.add_text("batch_index", str(i+1))
metadata.add_text("batch_index", str(i + 1))
metadata.add_text("creation_time", now.isoformat())
# Add workflow data if requested
if embed_workflow:
if prompt is not None:
metadata.add_text("workflow", json.dumps(prompt, default=self.encode_emoji))
if extra_pnginfo is not None:
for key, value in extra_pnginfo.items():
metadata.add_text(key, json.dumps(value, default=self.encode_emoji))
if embed_workflow and prompt and "workflow" in prompt:
metadata.add_text("workflow", json.dumps(prompt, default=self.encode_emoji))
if extra_pnginfo:
for key, value in extra_pnginfo.items():
metadata.add_text(key, json.dumps(value, default=self.encode_emoji))
img.save(file_path, format="PNG", optimize=True,
compress_level=self.compress_level, pnginfo=metadata)
compress_level=self.compress_level, pnginfo=metadata)
else:
# ITEM #3: Save clean PNG without metadata
img.save(file_path, format="PNG", optimize=True,
compress_level=self.compress_level)
compress_level=self.compress_level)
elif image_format == "JPEG":
# Convert RGBA to RGB for JPEG
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "endless-nodes"
description = "A small set of nodes I created for myself. Features multiple simultaneous prompts in batches, an image saver with ability to have JSON saved to separate folder, image analysis nodes, switches for text and numbers, and more."
version = "1.2.2"
version = "1.5.0"
license = { file = "LICENSE" }
dependencies = ""
@@ -11,5 +11,5 @@ Repository = "https://github.com/tusharbhutt/Endless-Nodes"
[tool.comfy]
PublisherId = "tusharbhutt"
DisplayName = "Endless-Nodes"
DisplayName = "Endless 🌊✨ Nodes"
Icon = "https://raw.githubusercontent.com/tusharbhutt/Endless-Nodes/main/img/icon.png"
+469
View File
@@ -0,0 +1,469 @@
// ComfyUI Endless 🌊✨ Fontifier - Fully Fixed Version
(function waitForHelpers() {
if (typeof window.EndlessHelpers === 'undefined') {
console.warn("⏳ Waiting for EndlessHelpers to be ready...");
setTimeout(waitForHelpers, 100); // Retry every 100ms
return;
}
// Load helpers from window
const {
registerEndlessTool,
injectEndlessToolsButton,
showEndlessToolMenu,
onThemeChange,
getComfyUIColors,
toRGBA,
blendColors,
addButtonHoverEffects,
makeDraggable
} = window.EndlessHelpers;
// === ORIGINAL COMFYUI DEFAULTS ===
const originalValues = {
NODE_TEXT_SIZE: LiteGraph.NODE_TEXT_SIZE || 14,
NODE_SUBTEXT_SIZE: LiteGraph.NODE_SUBTEXT_SIZE || 12,
NODE_TITLE_HEIGHT: LiteGraph.NODE_TITLE_HEIGHT || 30,
DEFAULT_GROUP_FONT: LiteGraph.DEFAULT_GROUP_FONT || 24,
NODE_FONT: LiteGraph.NODE_FONT || 'Arial',
NODE_SLOT_HEIGHT: LiteGraph.NODE_SLOT_HEIGHT || 20,
NODE_WIDGET_HEIGHT: LiteGraph.NODE_WIDGET_HEIGHT || 20,
WIDGET_TEXT_SIZE: 12,
GLOBAL_SCALE: 1
};
const saved = localStorage.getItem("endless_fontifier_defaults");
let currentValues = saved ? JSON.parse(saved) : { ...originalValues };
let dialogOpenValues = null;
let currentDialog = null;
let handlersSetup = false;
let escHandler = null;
let unregisterThemeCallback = null;
let isPreviewMode = false;
function createFontifierDialog() {
if (currentDialog) return;
const colors = getComfyUIColors();
const dialog = document.createElement("div");
dialog.id = "fontifier-dialog";
dialog.style.cssText = `
position: absolute;
z-index: 9999;
top: 100px;
left: 100px;
width: 320px;
background: ${colors.dialogBg || colors.menu || 'rgba(20, 20, 20, 0.95)'};
color: ${colors.inputText || '#fff'};
font-family: sans-serif;
border: 1px solid ${colors.border};
border-radius: 10px;
box-shadow: ${colors.shadow || '0 0 20px rgba(0,0,0,0.5)'};
padding: 10px;
`;
// Clean up any existing style tags
const existingStyle = document.getElementById('fontifier-dialog-style');
if (existingStyle) existingStyle.remove();
// Themed style block
const style = document.createElement("style");
style.id = "fontifier-dialog-style";
style.textContent = createStyleCSS(colors);
document.head.appendChild(style);
dialog.innerHTML = `
<div id="drag-bar" style="text-align:center; padding:6px; background:${colors.menuSecondary || '#2a2a2a'}; cursor:move; border-radius:10px 10px 0 0;">Endless 🌊✨ Drag Bar</div>
<h2 style="margin: 8px 0; text-align: center;">Fontifier Settings</h2>
<div class="fontifier-setting">
<label>Global Scale</label>
<div class="fontifier-row">
<input type="range" id="global-scale" min="0.5" max="2" step="0.01" value="1" title="Overall scaling factor for all font sizes">
<input type="number" id="global-scale-num" min="0.5" max="2" step="0.01" value="1" title="Overall scaling factor for all font sizes">
</div>
</div>
<div class="fontifier-setting">
<label>Text Size</label>
<div class="fontifier-row">
<input type="range" id="node-text-size" min="8" max="32" value="14" title="Font size for node text content and labels">
<input type="number" id="node-text-size-num" min="8" max="32" value="14" title="Font size for node text content and labels">
</div>
</div>
<div class="fontifier-setting">
<label>Subtext Size</label>
<div class="fontifier-row">
<input type="range" id="node-subtext-size" min="8" max="32" value="12" title="Font size for secondary text and descriptions">
<input type="number" id="node-subtext-size-num" min="8" max="32" value="12" title="Font size for secondary text and descriptions">
</div>
</div>
<div class="fontifier-setting">
<label>Title Height</label>
<div class="fontifier-row">
<input type="range" id="title-height" min="20" max="60" value="30" title="Height of node title bars">
<input type="number" id="title-height-num" min="20" max="60" value="30" title="Height of node title bars">
</div>
</div>
<div class="fontifier-setting">
<label>Slot Height</label>
<div class="fontifier-row">
<input type="range" id="slot-height" min="10" max="40" value="20" title="Height of input/output connection slots">
<input type="number" id="slot-height-num" min="10" max="40" value="20" title="Height of input/output connection slots">
</div>
</div>
<div class="fontifier-setting">
<label>Group Font Size</label>
<div class="fontifier-row">
<input type="range" id="group-font-size" min="8" max="32" value="24" title="Font size for group labels and titles">
<input type="number" id="group-font-size-num" min="8" max="32" value="24" title="Font size for group labels and titles">
</div>
</div>
<div class="fontifier-setting">
<label>Widget Font Size</label>
<div class="fontifier-row">
<input type="range" id="widget-text-size" min="8" max="32" value="12" title="Font size for input widgets and controls">
<input type="number" id="widget-text-size-num" min="8" max="32" value="12" title="Font size for input widgets and controls">
</div>
</div>
<div class="fontifier-setting">
<label>Font Family</label>
<select id="font-family" style="width: 100%;" title="Choose the font family for all text elements">
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Tahoma">Tahoma</option>
<option value="Courier New">Courier New</option>
<option value="Georgia">Georgia</option>
</select>
</div>
<div id="preview-indicator" style="display: none; text-align: center; color: ${colors.accent}; font-size: 12px; margin: 8px 0;">
🔍 Preview Mode - Changes not saved
</div>
<div style="margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; justify-content: space-between;">
<button id="apply-btn" title="Apply changes permanently and close dialog">Apply</button>
<button id="preview-btn" title="Preview changes temporarily without saving">Preview</button>
<button id="reset-btn" title="Reset to ComfyUI defaults">Reset</button>
<button id="save-defaults-btn" title="Save current settings as defaults">💾 Save as Default</button>
<button id="cancel-btn" title="Cancel changes and close dialog">Cancel</button>
</div>
`;
document.body.appendChild(dialog);
makeDraggable(dialog, dialog.querySelector('#drag-bar'));
setupDialogHandlers(dialog);
currentDialog = dialog;
// Store the values when dialog opens for cancel functionality
dialogOpenValues = { ...currentValues };
// Set current values in the dialog
updateDialogValues(dialog);
// Live theme updating without closing dialog
unregisterThemeCallback = onThemeChange(() => {
if (currentDialog) {
updateDialogTheme();
}
});
}
function createStyleCSS(colors) {
return `
.fontifier-setting {
margin-bottom: 10px;
}
.fontifier-row {
display: flex;
align-items: center;
gap: 6px;
}
.fontifier-row input[type="range"] {
flex-grow: 1;
}
.fontifier-row input[type="number"] {
width: 50px;
background: ${colors.inputBg || '#222'};
border: 1px solid ${colors.border || '#999'};
color: ${colors.inputText || '#ddd'};
border-radius: 4px;
padding: 2px;
}
#fontifier-dialog button {
flex-grow: 1;
padding: 6px;
background: ${colors.inputBg || '#222'};
border: 1px solid ${colors.border || '#999'};
color: ${colors.inputText || '#ddd'};
border-radius: 4px;
cursor: pointer;
}
#fontifier-dialog button:hover {
background: ${colors.buttonHoverBg || colors.hoverBg || blendColors(colors.inputBg || '#222', '#ffffff', 0.1)};
}
#fontifier-dialog #apply-btn {
background: ${toRGBA(colors.accent || '#4CAF50', 0.3)};
border-color: ${colors.accent || '#4CAF50'};
}
#fontifier-dialog #cancel-btn {
background: ${toRGBA(colors.errorText || '#f44336', 0.3)};
border-color: ${colors.errorText || '#f44336'};
}
#fontifier-dialog select {
background: ${colors.inputBg || '#222'};
border: 1px solid ${colors.border || '#999'};
color: ${colors.inputText || '#ddd'};
border-radius: 4px;
padding: 4px;
}
`;
}
function updateDialogTheme() {
if (!currentDialog) return;
const newColors = getComfyUIColors();
// Update main dialog styling
currentDialog.style.background = newColors.dialogBg || newColors.menu || 'rgba(20, 20, 20, 0.95)';
currentDialog.style.color = newColors.inputText || '#fff';
currentDialog.style.borderColor = newColors.border || '#999';
currentDialog.style.boxShadow = newColors.shadow || '0 0 20px rgba(0,0,0,0.5)';
// Update drag bar
const dragBar = currentDialog.querySelector('#drag-bar');
if (dragBar) {
dragBar.style.background = newColors.menuSecondary || '#2a2a2a';
}
// Update preview indicator
const previewIndicator = currentDialog.querySelector('#preview-indicator');
if (previewIndicator) {
previewIndicator.style.color = newColors.accent || '#4CAF50';
}
// Update the style tag with new colors
const styleTag = document.getElementById('fontifier-dialog-style');
if (styleTag) {
styleTag.textContent = createStyleCSS(newColors);
}
}
function updateDialogValues(dialog) {
dialog.querySelector('#global-scale').value = currentValues.GLOBAL_SCALE || 1;
dialog.querySelector('#global-scale-num').value = currentValues.GLOBAL_SCALE || 1;
dialog.querySelector('#node-text-size').value = currentValues.NODE_TEXT_SIZE;
dialog.querySelector('#node-text-size-num').value = currentValues.NODE_TEXT_SIZE;
dialog.querySelector('#node-subtext-size').value = currentValues.NODE_SUBTEXT_SIZE;
dialog.querySelector('#node-subtext-size-num').value = currentValues.NODE_SUBTEXT_SIZE;
dialog.querySelector('#title-height').value = currentValues.NODE_TITLE_HEIGHT;
dialog.querySelector('#title-height-num').value = currentValues.NODE_TITLE_HEIGHT;
dialog.querySelector('#slot-height').value = currentValues.NODE_SLOT_HEIGHT;
dialog.querySelector('#slot-height-num').value = currentValues.NODE_SLOT_HEIGHT;
dialog.querySelector('#group-font-size').value = currentValues.DEFAULT_GROUP_FONT;
dialog.querySelector('#group-font-size-num').value = currentValues.DEFAULT_GROUP_FONT;
dialog.querySelector('#widget-text-size').value = currentValues.WIDGET_TEXT_SIZE;
dialog.querySelector('#widget-text-size-num').value = currentValues.WIDGET_TEXT_SIZE;
dialog.querySelector('#font-family').value = currentValues.NODE_FONT;
}
function setupDialogHandlers(dialog) {
if (handlersSetup) return;
handlersSetup = true;
addButtonHoverEffects(dialog);
const elements = [
'global-scale',
'node-text-size',
'node-subtext-size',
'title-height',
'slot-height',
'group-font-size',
'widget-text-size'
];
elements.forEach(id => {
const slider = dialog.querySelector(`#${id}`);
const numberInput = dialog.querySelector(`#${id}-num`);
if (slider && numberInput) {
slider.oninput = () => {
numberInput.value = slider.value;
if (isPreviewMode) showPreviewIndicator();
};
numberInput.oninput = () => {
// Enforce min/max constraints
const min = parseFloat(numberInput.min);
const max = parseFloat(numberInput.max);
let value = parseFloat(numberInput.value);
if (value < min) value = min;
if (value > max) value = max;
numberInput.value = value;
slider.value = value;
if (isPreviewMode) showPreviewIndicator();
};
}
});
const saveBtn = dialog.querySelector('#save-defaults-btn');
if (saveBtn) {
saveBtn.onclick = () => {
localStorage.setItem("endless_fontifier_defaults", JSON.stringify(currentValues));
alert("🌊 Fontifier defaults saved! They'll auto-load next time.");
};
}
dialog.querySelector('#apply-btn').onclick = () => {
applyChanges(dialog, true);
hidePreviewIndicator();
closeDialog();
};
dialog.querySelector('#preview-btn').onclick = () => {
applyChanges(dialog, false);
showPreviewIndicator();
};
dialog.querySelector('#reset-btn').onclick = () => {
localStorage.removeItem("endless_fontifier_defaults");
currentValues = { ...originalValues };
applySettingsToComfyUI(originalValues);
updateDialogValues(dialog);
hidePreviewIndicator();
alert("🔁 Fontifier reset to ComfyUI defaults.");
};
dialog.querySelector('#cancel-btn').onclick = () => {
applySettingsToComfyUI(dialogOpenValues);
hidePreviewIndicator();
closeDialog();
};
escHandler = e => {
if (e.key === 'Escape') {
applySettingsToComfyUI(dialogOpenValues);
hidePreviewIndicator();
closeDialog();
}
};
document.addEventListener('keydown', escHandler);
}
function showPreviewIndicator() {
if (!currentDialog) return;
isPreviewMode = true;
const indicator = currentDialog.querySelector('#preview-indicator');
if (indicator) indicator.style.display = 'block';
}
function hidePreviewIndicator() {
if (!currentDialog) return;
isPreviewMode = false;
const indicator = currentDialog.querySelector('#preview-indicator');
if (indicator) indicator.style.display = 'none';
}
function applyChanges(dialog, permanent = false) {
const globalScale = parseFloat(dialog.querySelector('#global-scale').value);
const baseValues = {
NODE_TEXT_SIZE: parseInt(dialog.querySelector('#node-text-size').value),
NODE_SUBTEXT_SIZE: parseInt(dialog.querySelector('#node-subtext-size').value),
NODE_TITLE_HEIGHT: parseInt(dialog.querySelector('#title-height').value),
NODE_SLOT_HEIGHT: parseInt(dialog.querySelector('#slot-height').value),
DEFAULT_GROUP_FONT: parseInt(dialog.querySelector('#group-font-size').value),
FONT_FAMILY: dialog.querySelector('#font-family').value,
NODE_FONT: dialog.querySelector('#font-family').value,
WIDGET_TEXT_SIZE: parseInt(dialog.querySelector('#widget-text-size').value),
GLOBAL_SCALE: globalScale
};
// Apply global scaling to font sizes
const scaledValues = {
...baseValues,
NODE_TEXT_SIZE: Math.round(baseValues.NODE_TEXT_SIZE * globalScale),
NODE_SUBTEXT_SIZE: Math.round(baseValues.NODE_SUBTEXT_SIZE * globalScale),
DEFAULT_GROUP_FONT: Math.round(baseValues.DEFAULT_GROUP_FONT * globalScale),
WIDGET_TEXT_SIZE: Math.round(baseValues.WIDGET_TEXT_SIZE * globalScale)
};
applySettingsToComfyUI(scaledValues);
if (permanent) {
currentValues = { ...baseValues }; // Store unscaled values
isPreviewMode = false;
}
}
function applySettingsToComfyUI(settings) {
LiteGraph.NODE_TEXT_SIZE = settings.NODE_TEXT_SIZE;
LiteGraph.NODE_SUBTEXT_SIZE = settings.NODE_SUBTEXT_SIZE;
LiteGraph.NODE_TITLE_HEIGHT = settings.NODE_TITLE_HEIGHT;
LiteGraph.NODE_SLOT_HEIGHT = settings.NODE_SLOT_HEIGHT;
LiteGraph.DEFAULT_GROUP_FONT = settings.DEFAULT_GROUP_FONT;
LiteGraph.DEFAULT_GROUP_FONT_SIZE = settings.DEFAULT_GROUP_FONT;
LiteGraph.NODE_FONT = settings.NODE_FONT;
LiteGraph.DEFAULT_FONT = settings.NODE_FONT;
LiteGraph.GROUP_FONT = settings.NODE_FONT;
if (window.app?.canvas) {
window.app.canvas.setDirty(true, true);
setTimeout(() => window.app.canvas.draw(true, true), 100);
}
const styleId = "fontifier-widget-text-style";
let styleTag = document.getElementById(styleId);
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = styleId;
document.head.appendChild(styleTag);
}
styleTag.textContent = `
.litegraph input, .litegraph select, .litegraph textarea {
font-size: ${settings.WIDGET_TEXT_SIZE}px !important;
font-family: ${settings.NODE_FONT} !important;
}
#fontifier-dialog input, #fontifier-dialog select, #fontifier-dialog textarea {
font-size: 14px !important;
font-family: Arial !important;
}
`;
}
function closeDialog() {
if (currentDialog) currentDialog.remove();
if (escHandler) document.removeEventListener('keydown', escHandler);
if (unregisterThemeCallback) unregisterThemeCallback();
// Clean up the style tag
const styleTag = document.getElementById('fontifier-dialog-style');
if (styleTag) styleTag.remove();
currentDialog = null;
handlersSetup = false;
escHandler = null;
unregisterThemeCallback = null;
isPreviewMode = false;
}
// Wait for app to be ready before initializing
function waitForApp() {
if (typeof window.app !== 'undefined' && window.app?.canvas) {
// Initialize with saved defaults once app is ready
applySettingsToComfyUI(currentValues);
return;
}
setTimeout(waitForApp, 100);
}
waitForApp();
// Register into Endless Tools menu
registerEndlessTool("Fontifier", createFontifierDialog);
})();
+764
View File
@@ -0,0 +1,764 @@
// ComfyUI Endless 🌊✨ Minimap - Optimized Version
(function waitForHelpers() {
if (typeof window.EndlessHelpers === 'undefined') {
console.warn("⏳ Waiting for EndlessHelpers to be ready...");
setTimeout(waitForHelpers, 100);
return;
}
const {
registerEndlessTool,
onThemeChange,
getComfyUIColors,
toRGBA,
makeDraggable
} = window.EndlessHelpers;
console.log("✅ Endless Minimap loaded.");
// State variables
let currentDialog = null;
let animationId = null;
let unregisterThemeCallback = null;
let resizeObserver = null;
// Canvas state
let panX = 0, panY = 0, zoom = 1;
let isDragging = false;
let dragStartTime = 0, dragStartX = 0, dragStartY = 0;
const DRAG_THRESHOLD = 5;
// Size constants
const BASE_WIDTH = 300, BASE_HEIGHT = 400;
const MAX_WIDTH = 500, MAX_HEIGHT = 600;
const MIN_WIDTH = 200, MIN_HEIGHT = 150;
// Node type colors - comprehensive mapping
const NODE_COLORS = {
// Image Processing (Blue family)
'LoadImage': '#5DADE2', 'SaveImage': '#3498DB', 'PreviewImage': '#2E86AB',
'ImageScale': '#85C1E9', 'ImageCrop': '#7FB3D3', 'ImageBlend': '#6BB6FF',
// Latent Processing (Purple family)
'KSampler': '#8E44AD', 'KSamplerAdvanced': '#9B59B6', 'EmptyLatentImage': '#A569BD',
'LatentUpscale': '#BB8FCE', 'LatentBlend': '#D2B4DE',
// VAE (Green family)
'VAEDecode': '#27AE60', 'VAEEncode': '#2ECC71', 'VAELoader': '#58D68D',
// Model/Checkpoint (Teal family)
'CheckpointLoaderSimple': '#17A2B8', 'CheckpointLoader': '#148A99',
'ModelMergeSimple': '#1ABC9C', 'UNETLoader': '#5DADE2',
// CLIP/Text (Orange family)
'CLIPTextEncode': '#E67E22', 'CLIPTextEncodeSDXL': '#F39C12',
'CLIPLoader': '#F8C471', 'CLIPVisionEncode': '#D68910',
// LoRA (Yellow family)
'LoraLoader': '#F1C40F', 'LoraLoaderModelOnly': '#F4D03F',
// ControlNet (Pink family)
'ControlNetLoader': '#E91E63', 'ControlNetApply': '#F06292',
'CannyEdgePreprocessor': '#EC407A', 'OpenposePreprocessor': '#F8BBD9',
// Conditioning (Coral family)
'ConditioningAverage': '#FF6B35', 'ConditioningCombine': '#FF8C42',
// Utility (Gray family)
'PrimitiveNode': '#95A5A6', 'Note': '#BDC3C7', 'Reroute': '#85929E',
// Upscaling (Lime family)
'UpscaleModelLoader': '#7FB069', 'ImageUpscaleWithModel': '#8BC34A',
// Masks (Red family)
'MaskComposite': '#E53935', 'MaskToImage': '#F44336', 'ImageToMask': '#EF5350',
'default': 'rgba(200, 200, 200, 0.7)'
};
function getApp() {
return window.app || window.comfyApp || document.querySelector('#app')?.__vue__?.$root || null;
}
function getCanvasAspectRatio() {
const mainCanvas = document.querySelector('canvas') ||
document.querySelector('#graph-canvas') ||
document.querySelector('.litegraph');
if (mainCanvas) {
const rect = mainCanvas.getBoundingClientRect();
return rect.width / rect.height;
}
return window.innerWidth / window.innerHeight;
}
function calculateDimensions() {
const aspectRatio = getCanvasAspectRatio();
let containerWidth, containerHeight, canvasWidth, canvasHeight;
if (aspectRatio > 1) {
containerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, BASE_WIDTH * aspectRatio));
containerHeight = BASE_HEIGHT;
canvasWidth = containerWidth;
canvasHeight = BASE_HEIGHT - 50;
} else {
containerWidth = BASE_WIDTH;
containerHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, BASE_HEIGHT / aspectRatio));
canvasWidth = containerWidth;
canvasHeight = containerHeight - 50;
}
return { containerWidth, containerHeight, canvasWidth, canvasHeight };
}
function getNodeColor(node) {
const nodeType = node.type || node.constructor?.name || 'default';
if (NODE_COLORS[nodeType]) return NODE_COLORS[nodeType];
// Pattern matching for common types
const patterns = [
['Sampler', NODE_COLORS['KSampler']],
['CLIP', NODE_COLORS['CLIPTextEncode']],
['VAE', NODE_COLORS['VAEDecode']],
['ControlNet', NODE_COLORS['ControlNetLoader']],
['Lora', NODE_COLORS['LoraLoader']],
['Image.*Load', NODE_COLORS['LoadImage']],
['Image.*Save', NODE_COLORS['SaveImage']],
['Checkpoint', NODE_COLORS['CheckpointLoaderSimple']],
['Upscale', NODE_COLORS['UpscaleModelLoader']],
['Mask', NODE_COLORS['MaskComposite']]
];
for (const [pattern, color] of patterns) {
if (new RegExp(pattern, 'i').test(nodeType)) return color;
}
return NODE_COLORS.default;
}
function getNodes() {
const app = getApp();
if (!app) {
console.log("App not found, trying DOM fallback...");
const nodeElements = document.querySelectorAll('[class*="node"], .comfy-node, .litegraph-node');
if (nodeElements.length > 0) {
return Array.from(nodeElements).map((el, i) => ({
pos: [i * 150, i * 100],
size: [100, 60],
type: 'Unknown',
title: `Node ${i + 1}`
}));
}
return null;
}
const nodes = app.graph?._nodes ||
app.graph?.nodes ||
app.canvas?.graph?._nodes ||
app.canvas?.graph?.nodes ||
[];
return nodes;
}
function createStyleCSS(colors) {
return `
#endless-minimap button {
background: none;
border: none;
color: ${colors.inputText};
cursor: pointer;
padding: 2px 6px;
font-size: 18px;
border-radius: 3px;
transition: background 0.2s ease;
}
#endless-minimap button:hover {
background: ${toRGBA(colors.inputText, 0.1)};
}
#endless-minimap .drag-bar {
padding: 4px 8px;
background: ${toRGBA(colors.inputText, 0.05)};
cursor: move;
font-size: 14px;
user-select: none;
border-bottom: 1px solid ${colors.border};
flex-shrink: 0;
}
#endless-minimap .legend {
position: absolute;
top: 5px;
left: 5px;
background: ${colors.menu};
color: ${colors.inputText};
padding: 8px;
border: 1px solid ${colors.border};
border-radius: 4px;
font-size: 10px;
max-height: 200px;
overflow-y: auto;
display: none;
z-index: 1;
}
#endless-minimap .pan-info {
padding: 2px 8px;
font-size: 10px;
background: ${colors.menuSecondary};
color: ${colors.inputText};
border-top: 1px solid ${colors.border};
text-align: center;
flex-shrink: 0;
}
`;
}
function updateTheme() {
if (!currentDialog) return;
const colors = getComfyUIColors();
// Update container colors only, not size
currentDialog.style.background = colors.menu;
currentDialog.style.color = colors.inputText;
currentDialog.style.borderColor = colors.accent;
// Update style tag
const styleTag = document.getElementById('minimap-style');
if (styleTag) {
styleTag.textContent = createStyleCSS(colors);
}
drawMinimap();
}
function updateLegend() {
const legend = currentDialog.querySelector('.legend');
if (!legend || legend.style.display === 'none') return;
const nodes = getNodes();
if (!nodes) return;
const typeCounts = {};
nodes.forEach(n => {
const nodeType = n.type || n.constructor?.name || 'default';
typeCounts[nodeType] = (typeCounts[nodeType] || 0) + 1;
});
legend.innerHTML = Object.entries(typeCounts)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => {
const color = NODE_COLORS[type] || NODE_COLORS.default;
return `<div style="margin: 2px 0; display: flex; align-items: center;">
<div style="width: 12px; height: 12px; background: ${color}; margin-right: 6px; border-radius: 2px;"></div>
<span>${type} (${count})</span>
</div>`;
}).join('');
}
// Get current transform state for coordinate conversions
function getTransformState() {
const canvas = currentDialog?.querySelector('canvas');
if (!canvas) return null;
const nodes = getNodes();
if (!nodes?.length) return null;
// Calculate bounds (same as in drawMinimap)
const bounds = nodes.reduce((acc, n) => {
const x = n.pos?.[0] ?? n.x ?? 0;
const y = n.pos?.[1] ?? n.y ?? 0;
const w = n.size?.[0] ?? n.width ?? 100;
const h = n.size?.[1] ?? n.height ?? 60;
return {
minX: Math.min(acc.minX, x),
minY: Math.min(acc.minY, y),
maxX: Math.max(acc.maxX, x + w),
maxY: Math.max(acc.maxY, y + h)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
if (width <= 0 || height <= 0) return null;
const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
const scale = baseScale * zoom;
return {
bounds,
width,
height,
scale,
canvas
};
}
// Convert canvas coordinates to world coordinates
function canvasToWorld(canvasX, canvasY) {
const transform = getTransformState();
if (!transform) return null;
const { bounds, width, height, scale, canvas } = transform;
// Inverse of the transform used in drawMinimap
const worldX = (canvasX - canvas.width / 2 - panX) / scale + (bounds.minX + width / 2);
const worldY = (canvasY - canvas.height / 2 - panY) / scale + (bounds.minY + height / 2);
return { x: worldX, y: worldY };
}
// Convert world coordinates to canvas coordinates
function worldToCanvas(worldX, worldY) {
const transform = getTransformState();
if (!transform) return null;
const { bounds, width, height, scale, canvas } = transform;
// Same transform as used in drawMinimap
const canvasX = (worldX - (bounds.minX + width / 2)) * scale + canvas.width / 2 + panX;
const canvasY = (worldY - (bounds.minY + height / 2)) * scale + canvas.height / 2 + panY;
return { x: canvasX, y: canvasY };
}
function drawMinimap() {
if (!currentDialog) return;
const canvas = currentDialog.querySelector('canvas');
const panInfo = currentDialog.querySelector('.pan-info');
if (!canvas || !panInfo) return;
const ctx = canvas.getContext('2d');
const colors = getComfyUIColors();
ctx.clearRect(0, 0, canvas.width, canvas.height);
const nodes = getNodes();
if (!nodes || !nodes.length) {
ctx.fillStyle = colors.inputText;
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText('No nodes found', canvas.width / 2, canvas.height / 2);
ctx.fillText('or graph not loaded', canvas.width / 2, canvas.height / 2 + 15);
return;
}
// Calculate bounds
const bounds = nodes.reduce((acc, n) => {
const x = n.pos?.[0] ?? n.x ?? 0;
const y = n.pos?.[1] ?? n.y ?? 0;
const w = n.size?.[0] ?? n.width ?? 100;
const h = n.size?.[1] ?? n.height ?? 60;
return {
minX: Math.min(acc.minX, x),
minY: Math.min(acc.minY, y),
maxX: Math.max(acc.maxX, x + w),
maxY: Math.max(acc.maxY, y + h)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
if (width <= 0 || height <= 0) return;
const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
const scale = baseScale * zoom;
ctx.save();
ctx.translate(canvas.width / 2 + panX, canvas.height / 2 + panY);
ctx.scale(scale, scale);
ctx.translate(-(bounds.minX + width/2), -(bounds.minY + height/2));
// Draw grid
ctx.strokeStyle = toRGBA(colors.inputText, 0.1);
ctx.lineWidth = 1 / scale;
const gridSize = 100;
for (let x = Math.floor(bounds.minX / gridSize) * gridSize; x <= bounds.maxX; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, bounds.minY);
ctx.lineTo(x, bounds.maxY);
ctx.stroke();
}
for (let y = Math.floor(bounds.minY / gridSize) * gridSize; y <= bounds.maxY; y += gridSize) {
ctx.beginPath();
ctx.moveTo(bounds.minX, y);
ctx.lineTo(bounds.maxX, y);
ctx.stroke();
}
// Draw nodes
nodes.forEach((n, index) => {
const x = n.pos?.[0] ?? n.x ?? 0;
const y = n.pos?.[1] ?? n.y ?? 0;
const w = n.size?.[0] ?? n.width ?? 100;
const h = n.size?.[1] ?? n.height ?? 60;
ctx.fillStyle = getNodeColor(n);
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = toRGBA(colors.inputText, 0.8);
ctx.lineWidth = 1 / scale;
ctx.strokeRect(x, y, w, h);
if (scale > 0.3) {
ctx.fillStyle = colors.inputText;
ctx.font = `${Math.max(10, 12 / scale)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const title = n.title || n.type || `Node ${index + 1}`;
ctx.fillText(title.substring(0, 15), x + w / 2, y + h / 2);
}
});
// Draw viewport indicator
const app = getApp();
if (app?.canvas?.ds) {
const ds = app.canvas.ds;
const mainCanvas = document.querySelector('canvas');
if (mainCanvas) {
const viewportX = -ds.offset[0];
const viewportY = -ds.offset[1];
const viewportW = mainCanvas.width / ds.scale;
const viewportH = mainCanvas.height / ds.scale;
ctx.fillStyle = toRGBA(colors.accent || '#4a90e2', 0.12);
ctx.fillRect(viewportX, viewportY, viewportW, viewportH);
ctx.strokeStyle = toRGBA(colors.accent || '#4a90e2', 0.8);
ctx.lineWidth = 2 / scale;
ctx.strokeRect(viewportX, viewportY, viewportW, viewportH);
}
}
ctx.restore();
panInfo.textContent = `Nodes: ${nodes.length} | Zoom: ${(zoom * 100).toFixed(0)}% | Pan: ${panX.toFixed(0)}, ${panY.toFixed(0)}`;
}
function navigateToPosition(canvasX, canvasY) {
const worldPos = canvasToWorld(canvasX, canvasY);
if (!worldPos) return;
const app = getApp();
const mainCanvas = document.querySelector('canvas');
if (!app?.canvas?.ds || !mainCanvas) return;
try {
// Center the main canvas on the clicked world position
app.canvas.ds.offset[0] = -worldPos.x + mainCanvas.width / 2;
app.canvas.ds.offset[1] = -worldPos.y + mainCanvas.height / 2;
app.canvas.setDirty(true, true);
// Update minimap to reflect the change
setTimeout(() => drawMinimap(), 50);
} catch (err) {
console.log("❌ Navigation error:", err);
}
}
function isClickInViewport(canvasX, canvasY) {
const app = getApp();
if (!app?.canvas?.ds) return false;
const ds = app.canvas.ds;
const mainCanvas = document.querySelector('canvas');
if (!mainCanvas) return false;
const worldPos = canvasToWorld(canvasX, canvasY);
if (!worldPos) return false;
// Check if click is inside viewport rectangle in world coordinates
const viewportX = -ds.offset[0];
const viewportY = -ds.offset[1];
const viewportW = mainCanvas.width / ds.scale;
const viewportH = mainCanvas.height / ds.scale;
return worldPos.x >= viewportX && worldPos.x <= viewportX + viewportW &&
worldPos.y >= viewportY && worldPos.y <= viewportY + viewportH;
}
function adjustPanToKeepNodesVisible() {
const nodes = getNodes();
if (!nodes?.length) return;
const canvas = currentDialog?.querySelector('canvas');
if (!canvas) return;
// Calculate bounds
const bounds = nodes.reduce((acc, n) => {
const x = n.pos?.[0] ?? n.x ?? 0;
const y = n.pos?.[1] ?? n.y ?? 0;
const w = n.size?.[0] ?? n.width ?? 100;
const h = n.size?.[1] ?? n.height ?? 60;
return {
minX: Math.min(acc.minX, x),
minY: Math.min(acc.minY, y),
maxX: Math.max(acc.maxX, x + w),
maxY: Math.max(acc.maxY, y + h)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
if (width <= 0 || height <= 0) return;
const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
const scale = baseScale * zoom;
// If zoomed in too much, adjust pan to keep nodes centered
if (zoom > 2) {
const maxPanX = canvas.width / 4;
const maxPanY = canvas.height / 4;
panX = Math.max(-maxPanX, Math.min(maxPanX, panX));
panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
}
}
function setupEventHandlers() {
const canvas = currentDialog.querySelector('canvas');
let isViewportDragging = false;
// Mouse handlers
canvas.addEventListener('mousedown', (e) => {
dragStartTime = Date.now();
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = canvas.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
// Check if clicking inside viewport indicator
isViewportDragging = isClickInViewport(canvasX, canvasY);
if (isViewportDragging) {
canvas.style.cursor = 'grab';
}
});
canvas.addEventListener('mousemove', (e) => {
if (!dragStartTime) return;
const totalDelta = Math.abs(e.clientX - dragStartX) + Math.abs(e.clientY - dragStartY);
if (totalDelta > DRAG_THRESHOLD && !isDragging) {
isDragging = true;
if (isViewportDragging) {
canvas.style.cursor = 'grabbing';
} else {
canvas.style.cursor = 'move';
}
}
if (isDragging) {
if (isViewportDragging) {
// Move the viewport - convert movement to world coordinates
const app = getApp();
if (app?.canvas?.ds) {
const transform = getTransformState();
if (transform) {
// Scale movement by the inverse of the minimap scale
const movementScale = 1 / transform.scale;
app.canvas.ds.offset[0] -= e.movementX * movementScale;
app.canvas.ds.offset[1] -= e.movementY * movementScale;
app.canvas.setDirty(true, true);
}
}
} else {
// Pan the minimap view
panX += e.movementX;
panY += e.movementY;
}
drawMinimap();
}
});
canvas.addEventListener('mouseup', (e) => {
const clickDuration = Date.now() - dragStartTime;
const totalMovement = Math.abs(e.clientX - dragStartX) + Math.abs(e.clientY - dragStartY);
if (!isDragging && clickDuration < 500 && totalMovement < DRAG_THRESHOLD) {
if (!isViewportDragging) {
// Regular click-to-navigate (only if not clicking viewport)
const rect = canvas.getBoundingClientRect();
navigateToPosition(e.clientX - rect.left, e.clientY - rect.top);
}
}
isDragging = false;
isViewportDragging = false;
dragStartTime = 0;
canvas.style.cursor = 'crosshair';
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const oldZoom = zoom;
zoom = Math.max(0.1, Math.min(5, zoom * (e.deltaY > 0 ? 0.9 : 1.1)));
// Adjust pan to keep content centered when zooming
if (zoom !== oldZoom) {
adjustPanToKeepNodesVisible();
}
drawMinimap();
});
// Button handlers
currentDialog.querySelector('#close-btn').onclick = () => closeDialog();
currentDialog.querySelector('#legend-btn').onclick = () => {
const legend = currentDialog.querySelector('.legend');
const isVisible = legend.style.display !== 'none';
legend.style.display = isVisible ? 'none' : 'block';
if (!isVisible) updateLegend();
};
currentDialog.querySelector('#zoom-in').onclick = () => {
zoom = Math.min(zoom * 1.2, 5);
adjustPanToKeepNodesVisible();
drawMinimap();
};
currentDialog.querySelector('#zoom-out').onclick = () => {
zoom = Math.max(zoom / 1.2, 0.1);
adjustPanToKeepNodesVisible();
drawMinimap();
};
currentDialog.querySelector('#zoom-reset').onclick = () => {
zoom = 1;
panX = panY = 0;
drawMinimap();
};
// ESC key
const escHandler = (e) => e.key === 'Escape' && closeDialog();
document.addEventListener('keydown', escHandler);
return escHandler;
}
function createMinimapDialog() {
if (currentDialog) return;
const colors = getComfyUIColors();
const { containerWidth, containerHeight, canvasWidth, canvasHeight } = calculateDimensions();
// Clean up existing style
document.getElementById('minimap-style')?.remove();
// Create style tag
const style = document.createElement('style');
style.id = 'minimap-style';
style.textContent = createStyleCSS(colors);
document.head.appendChild(style);
// Create container
const container = document.createElement('div');
container.id = 'endless-minimap';
container.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
width: ${containerWidth}px;
height: ${containerHeight}px;
background: ${colors.menu};
color: ${colors.inputText};
border: 1px solid ${colors.accent};
border-radius: 8px;
box-shadow: ${colors.shadow || '0 4px 12px rgba(0, 0, 0, 0.25)'};
z-index: 99999;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
`;
container.innerHTML = `
<div class="drag-bar">
<span>Endless 🌊✨ Minimap</span>
<div style="float: right; margin-top: -2px;">
<button id="legend-btn" title="Toggle legend">🎨</button>
<button id="zoom-out" title="Zoom out">▫️</button>
<button id="zoom-reset" title="Reset zoom and pan">🏠</button>
<button id="zoom-in" title="Zoom in">⬜</button>
<button id="close-btn" title="Close minimap">❌</button>
</div>
</div>
<div style="flex: 1; position: relative; overflow: hidden;">
<canvas width="${canvasWidth}" height="${canvasHeight}" style="display: block; cursor: crosshair; position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
<div class="legend"></div>
</div>
<div class="pan-info">Nodes: 0 | Zoom: 100% | Pan: 0, 0</div>
`;
document.body.appendChild(container);
currentDialog = container;
// Setup dragging
makeDraggable(container, container.querySelector('.drag-bar'));
// Setup event handlers
const escHandler = setupEventHandlers();
// Setup resize observer
resizeObserver = new ResizeObserver(() => {
const newAspectRatio = getCanvasAspectRatio();
const canvas = container.querySelector('canvas');
const currentAspectRatio = canvas.width / canvas.height;
if (Math.abs(newAspectRatio - currentAspectRatio) > 0.1) {
const { containerWidth: newContainerWidth, containerHeight: newContainerHeight, canvasWidth: newCanvasWidth, canvasHeight: newCanvasHeight } = calculateDimensions();
// Update container size
container.style.width = `${newContainerWidth}px`;
container.style.height = `${newContainerHeight}px`;
// Update canvas size
canvas.width = newCanvasWidth;
canvas.height = newCanvasHeight;
drawMinimap();
}
});
const mainCanvas = document.querySelector('canvas');
if (mainCanvas) resizeObserver.observe(mainCanvas);
resizeObserver.observe(document.body);
// Setup theme updates
unregisterThemeCallback = onThemeChange(updateTheme);
// Start animation loop
function updateLoop() {
drawMinimap();
animationId = setTimeout(updateLoop, 1000);
}
updateLoop();
// Setup cleanup
const originalRemove = container.remove.bind(container);
container.remove = function() {
clearTimeout(animationId);
document.removeEventListener('keydown', escHandler);
resizeObserver?.disconnect();
unregisterThemeCallback?.();
document.getElementById('minimap-style')?.remove();
currentDialog = null;
animationId = null;
unregisterThemeCallback = null;
resizeObserver = null;
originalRemove();
};
}
function closeDialog() {
currentDialog?.remove();
}
// Register tool
registerEndlessTool("Minimap", createMinimapDialog);
})();
+870
View File
@@ -0,0 +1,870 @@
// ComfyUI Endless 🌊✨ Node Spawner - Optimized Version
(function waitForHelpers() {
if (typeof window.EndlessHelpers === 'undefined') {
console.warn("⏳ Waiting for EndlessHelpers to be ready...");
setTimeout(waitForHelpers, 100);
return;
}
const {
registerEndlessTool,
onThemeChange,
getComfyUIColors,
toRGBA,
makeDraggable,
addButtonHoverEffects
} = window.EndlessHelpers;
console.log("✅ Endless Node Spawner loaded.");
// State management
let currentDialog = null;
let unregisterThemeCallback = null;
let allNodesData = [];
let currentFilter = '';
let searchTimeout = null;
let hoverTimeout = null;
// Persistent data
let recentlyUsedNodes = JSON.parse(localStorage.getItem('endlessNodeLoader_recentlyUsed') || '[]');
let searchHistory = JSON.parse(localStorage.getItem('endlessNodeLoader_searchHistory') || '[]');
// Constants
const MAX_RECENT = 15;
const MAX_HISTORY = 15;
const DEFAULT_SPACING = { x: 300, y: 150 };
const NODE_PADDING = 20;
function createStyleCSS(colors) {
return `
.dialog-container {
display: flex;
flex-direction: column;
height: 60vh;
width: 35vw;
min-width: 400px;
min-height: 300px;
background: ${colors.menu};
color: ${colors.inputText};
padding: 10px;
border: 1px solid ${colors.border};
border-radius: 8px;
box-shadow: ${colors.shadow || '0 4px 20px rgba(0,0,0,0.5)'};
z-index: 9999;
overflow: hidden;
box-sizing: border-box;
}
.dialog-title {
margin: 0 0 15px 0;
cursor: move;
user-select: none;
padding: 6px;
background: ${colors.menuSecondary};
color: ${colors.inputText};
border-radius: 4px;
font-size: 14px;
border-bottom: 1px solid ${colors.border};
}
.filter-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid ${colors.border};
}
.filter-row {
position: relative;
display: flex;
gap: 8px;
align-items: center;
}
.filter-input {
flex: 1;
background: ${colors.inputBg};
color: ${colors.inputText};
border: 1px solid ${colors.border};
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
}
.filter-input:focus {
outline: none;
border-color: ${colors.accent};
}
.expand-btn {
background: ${colors.inputBg};
color: ${colors.inputText};
border: 1px solid ${colors.border};
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.expand-btn:hover {
background: ${colors.hoverBg};
border-color: ${colors.accent};
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 80px;
background: ${colors.menu};
border: 1px solid ${colors.border};
border-radius: 4px;
max-height: 150px;
overflow-y: auto;
z-index: 10000;
display: none;
}
.search-item {
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px solid ${colors.border};
}
.search-item:last-child { border-bottom: none; }
.search-item:hover { background: ${colors.hoverBg}; }
.counters {
display: flex;
justify-content: space-between;
font-size: 11px;
color: ${colors.descriptionText};
}
.counter-selected {
color: ${colors.accent};
font-weight: bold;
}
.recent-section {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 30px;
max-height: 15%;
overflow-y: auto;
border-bottom: 1px solid ${colors.border};
padding-bottom: 6px;
margin-bottom: 6px;
}
.recent-chip {
background: ${toRGBA(colors.accent, 0.1)};
color: ${colors.inputText};
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.recent-chip:hover {
border-color: ${colors.accent};
background: ${toRGBA(colors.accent, 0.2)};
}
.node-list {
flex: 1 1 auto;
overflow-y: auto;
border-bottom: 1px solid ${colors.border};
padding-bottom: 6px;
margin-bottom: 6px;
}
.category {
margin-bottom: 4px;
}
.category > summary {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
list-style: none;
color: ${colors.inputText};
}
.category > summary::-webkit-details-marker { display: none; }
.category > summary::before {
content: "▶";
width: 12px;
text-align: center;
color: ${colors.descriptionText};
font-size: 10px;
transition: transform 0.2s ease;
}
.category[open] > summary::before {
transform: rotate(90deg);
color: ${colors.inputText};
}
.category > summary:hover {
background: ${colors.hoverBg};
border-radius: 4px;
}
.category ul {
margin: 4px 0;
padding-left: 1em;
}
.category li:hover {
background: ${colors.hoverBg};
border-radius: 4px;
}
.category input[type="checkbox"] {
accent-color: ${colors.accent};
}
.cat-btn {
background: ${colors.inputBg};
color: ${colors.inputText};
border: 1px solid ${colors.border};
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
}
.cat-btn:hover {
background: ${colors.hoverBg};
border-color: ${colors.accent};
}
.cat-btn.select { background: ${toRGBA('#4CAF50', 0.1)}; }
.cat-btn.deselect { background: ${toRGBA('#f44336', 0.1)}; }
.footer {
display: flex;
justify-content: space-between;
gap: 8px;
}
.btn-group {
display: flex;
gap: 8px;
}
.dialog-btn {
background: ${colors.inputBg};
color: ${colors.inputText};
border: 1px solid ${colors.border};
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.dialog-btn:hover {
background: ${colors.hoverBg};
border-color: ${colors.accent};
}
.dialog-btn.primary {
background: ${toRGBA(colors.accent || '#4CAF50', 0.2)};
border-color: ${colors.accent || '#4CAF50'};
}
.dialog-btn.secondary {
background: ${toRGBA(colors.errorText || '#f44336', 0.2)};
border-color: ${colors.errorText || '#f44336'};
}
.hidden { display: none !important; }
`;
}
function updateTheme() {
if (!currentDialog) return;
const colors = getComfyUIColors();
const styleTag = document.getElementById('node-loader-style');
if (styleTag) {
styleTag.textContent = createStyleCSS(colors);
}
}
function getApp() {
return window.app || window.comfyApp;
}
function getExistingNodePositions() {
const app = getApp();
const positions = [];
if (app?.graph?.nodes) {
app.graph.nodes.forEach(node => {
if (node.pos) {
positions.push({
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] || 200,
height: node.size?.[1] || 100
});
}
});
}
return positions;
}
function findNonOverlappingPosition(startX, startY, width, height, existingPositions, spacingX, spacingY) {
let x = startX;
let y = startY;
while (true) {
let overlaps = false;
for (const pos of existingPositions) {
if (!(x + width + NODE_PADDING < pos.x ||
x - NODE_PADDING > pos.x + pos.width ||
y + height + NODE_PADDING < pos.y ||
y - NODE_PADDING > pos.y + pos.height)) {
overlaps = true;
break;
}
}
if (!overlaps) return { x, y };
x += spacingX;
if (x > startX + spacingX * 5) {
x = startX;
y += spacingY;
}
}
}
function spawnNodes(types, spacingX = DEFAULT_SPACING.x, spacingY = DEFAULT_SPACING.y) {
const app = getApp();
if (!app?.graph?.add) {
alert("ComfyUI graph not available.");
return;
}
const startX = -app.canvas.ds.offset[0] + 50;
const startY = -app.canvas.ds.offset[1] + 50;
const existingPositions = getExistingNodePositions();
const spawnedNodes = [];
types.forEach((type, i) => {
const node = LiteGraph.createNode(type);
if (node) {
const nodeWidth = node.size?.[0] || 200;
const nodeHeight = node.size?.[1] || 100;
const position = findNonOverlappingPosition(
startX + (i % 5) * spacingX,
startY + Math.floor(i / 5) * spacingY,
nodeWidth,
nodeHeight,
existingPositions,
spacingX,
spacingY
);
node.pos = [position.x, position.y];
app.graph.add(node);
spawnedNodes.push(type);
existingPositions.push({
x: position.x,
y: position.y,
width: nodeWidth,
height: nodeHeight
});
} else {
console.warn(`Could not create node: ${type}`);
}
});
updateRecentlyUsedNodes(spawnedNodes);
app.graph.setDirtyCanvas(true, true);
}
function updateRecentlyUsedNodes(newNodes) {
newNodes.forEach(nodeType => {
const index = recentlyUsedNodes.indexOf(nodeType);
if (index > -1) recentlyUsedNodes.splice(index, 1);
recentlyUsedNodes.unshift(nodeType);
});
recentlyUsedNodes = recentlyUsedNodes.slice(0, MAX_RECENT);
localStorage.setItem('endlessNodeLoader_recentlyUsed', JSON.stringify(recentlyUsedNodes));
if (currentDialog) updateRecentChips();
}
function updateRecentChips() {
const recentSection = currentDialog.querySelector('.recent-section');
if (!recentSection) return;
recentSection.innerHTML = '';
recentlyUsedNodes.forEach(nodeType => {
const chip = document.createElement('button');
chip.className = 'recent-chip';
const nodeClass = LiteGraph.registered_node_types[nodeType];
const displayName = nodeClass?.title || nodeClass?.name || nodeType.split("/").pop();
chip.textContent = displayName;
chip.title = nodeType;
chip.onclick = () => {
const checkbox = Array.from(currentDialog.querySelectorAll('.node-checkbox')).find(cb => {
return cb.closest('li').dataset.nodeType === nodeType;
});
if (checkbox) {
checkbox.checked = true;
updateSelectedCounter();
}
};
recentSection.appendChild(chip);
});
}
function addToSearchHistory(searchTerm) {
if (!searchTerm.trim() || searchHistory.includes(searchTerm)) return;
searchHistory.unshift(searchTerm);
searchHistory = searchHistory.slice(0, MAX_HISTORY);
localStorage.setItem('endlessNodeLoader_searchHistory', JSON.stringify(searchHistory));
}
function showSearchHistory(inputElement) {
const dropdown = inputElement.parentElement.querySelector('.search-dropdown');
if (!dropdown || searchHistory.length === 0) {
if (dropdown) dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = '';
searchHistory.forEach(term => {
const item = document.createElement('div');
item.className = 'search-item';
item.textContent = term;
item.onclick = () => {
inputElement.value = term;
applyFilter(term, true);
hideSearchHistory(dropdown);
};
dropdown.appendChild(item);
});
dropdown.style.display = 'block';
setTimeout(() => {
if (dropdown.style.display === 'block') {
hideSearchHistory(dropdown);
}
}, 10000);
}
function hideSearchHistory(dropdown) {
dropdown.style.display = 'none';
}
function applyFilter(filterText, saveToHistory = true) {
currentFilter = filterText.toLowerCase();
const nodeList = currentDialog.querySelector('.node-list');
if (!currentFilter) {
nodeList.querySelectorAll('.category, .category li').forEach(el => {
el.classList.remove('hidden');
});
updateTotalCounter();
return;
}
nodeList.querySelectorAll('.category').forEach(details => {
const categoryName = details.querySelector('summary span').textContent.toLowerCase();
const categoryMatches = categoryName.includes(currentFilter);
let hasMatchingNodes = false;
const nodeItems = details.querySelectorAll('li');
nodeItems.forEach(li => {
const nodeText = li.textContent.toLowerCase();
const nodeType = li.dataset.nodeType?.toLowerCase() || '';
const matches = nodeText.includes(currentFilter) || nodeType.includes(currentFilter);
if (matches) {
li.classList.remove('hidden');
hasMatchingNodes = true;
} else {
li.classList.add('hidden');
}
});
if (categoryMatches || hasMatchingNodes) {
details.classList.remove('hidden');
if (hasMatchingNodes && !categoryMatches) {
details.open = true;
}
} else {
details.classList.add('hidden');
}
});
updateTotalCounter();
}
function updateSelectedCounter() {
const counter = currentDialog.querySelector('.counter-selected');
if (!counter) return;
const checkedBoxes = currentDialog.querySelectorAll('.node-checkbox:checked');
counter.textContent = `Selected: ${checkedBoxes.length}`;
}
function updateTotalCounter() {
const counter = currentDialog.querySelector('.counter-total');
if (!counter) return;
const visibleNodes = currentDialog.querySelectorAll('.category li:not(.hidden)');
counter.textContent = `Total: ${visibleNodes.length}/${allNodesData.length}`;
}
function toggleAllCategories(expand) {
const details = currentDialog.querySelectorAll('.category:not(.hidden)');
details.forEach(detail => {
detail.open = expand;
});
}
function buildHierarchy(nodes) {
const root = {};
nodes.forEach(n => {
let current = root;
n.pathParts.forEach((part, idx) => {
if (!current[part]) {
current[part] = { _nodes: [], _subcategories: {} };
}
if (idx === n.pathParts.length - 1) {
current[part]._nodes.push(n);
} else {
current = current[part]._subcategories;
}
});
});
return root;
}
function countNodesInCategory(categoryObj) {
let count = categoryObj._nodes?.length || 0;
if (categoryObj._subcategories) {
Object.values(categoryObj._subcategories).forEach(sub => {
count += countNodesInCategory(sub);
});
}
return count;
}
function selectAllInCategory(categoryDetails, select = true) {
const checkboxes = categoryDetails.querySelectorAll("input[type='checkbox']");
checkboxes.forEach(checkbox => {
checkbox.checked = select;
});
updateSelectedCounter();
}
function renderCategory(categoryObj, depth = 0) {
return Object.entries(categoryObj)
.sort(([a], [b]) => a.localeCompare(b))
.map(([cat, obj]) => {
const totalNodes = countNodesInCategory(obj);
const details = document.createElement("details");
details.className = "category";
details.style.paddingLeft = `${depth * 1.2}em`;
const summary = document.createElement("summary");
const categoryName = document.createElement("span");
categoryName.textContent = `${cat} (${totalNodes})`;
const selectAllBtn = document.createElement("button");
selectAllBtn.textContent = "All";
selectAllBtn.className = "cat-btn select";
selectAllBtn.onclick = (e) => {
e.stopPropagation();
selectAllInCategory(details, true);
};
const selectNoneBtn = document.createElement("button");
selectNoneBtn.textContent = "None";
selectNoneBtn.className = "cat-btn deselect";
selectNoneBtn.onclick = (e) => {
e.stopPropagation();
selectAllInCategory(details, false);
};
summary.appendChild(categoryName);
summary.appendChild(selectAllBtn);
summary.appendChild(selectNoneBtn);
details.appendChild(summary);
const list = document.createElement("ul");
(obj._nodes || []).forEach(node => {
const li = document.createElement("li");
li.dataset.nodeType = node.type;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "node-checkbox";
checkbox.onchange = updateSelectedCounter;
const label = document.createElement("label");
label.textContent = node.displayName;
li.appendChild(checkbox);
li.appendChild(label);
list.appendChild(li);
});
details.appendChild(list);
const subCategories = renderCategory(obj._subcategories || {}, depth + 1);
subCategories.forEach(sub => details.appendChild(sub));
return details;
});
}
function getSelectedNodeTypes() {
const selected = [];
currentDialog.querySelectorAll('.node-checkbox:checked').forEach(checkbox => {
const nodeType = checkbox.closest('li').dataset.nodeType;
if (nodeType) selected.push(nodeType);
});
return selected;
}
function clearSelectedNodes() {
currentDialog.querySelectorAll('.node-checkbox:checked').forEach(checkbox => {
checkbox.checked = false;
});
updateSelectedCounter();
}
function setupEventHandlers() {
const filterInput = currentDialog.querySelector('.filter-input');
const searchDropdown = currentDialog.querySelector('.search-dropdown');
const expandBtn = currentDialog.querySelector('.expand-btn');
let isExpanded = false;
// Filter input handlers
filterInput.oninput = (e) => applyFilter(e.target.value, false);
filterInput.onkeydown = (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
addToSearchHistory(e.target.value.trim());
hideSearchHistory(searchDropdown);
} else if (e.key === 'ArrowDown' && searchHistory.length > 0) {
showSearchHistory(filterInput);
e.preventDefault();
}
};
filterInput.onfocus = (e) => {
if (!e.target.value.trim()) {
showSearchHistory(filterInput);
}
};
filterInput.onblur = (e) => {
if (e.target.value.trim()) {
addToSearchHistory(e.target.value.trim());
}
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
setTimeout(() => hideSearchHistory(searchDropdown), 150);
};
filterInput.onmouseenter = () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
hoverTimeout = setTimeout(() => {
if (searchHistory.length > 0) {
showSearchHistory(filterInput);
}
}, 1000);
};
filterInput.onmouseleave = () => {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
searchTimeout = setTimeout(() => {
hideSearchHistory(searchDropdown);
}, 10000);
};
// Expand button
expandBtn.onclick = () => {
isExpanded = !isExpanded;
toggleAllCategories(isExpanded);
expandBtn.textContent = isExpanded ? "Collapse All" : "Expand All";
};
// Button handlers
currentDialog.querySelector('#spawn-btn').onclick = () => {
const selectedTypes = getSelectedNodeTypes();
if (selectedTypes.length === 0) {
alert("Please select at least one node to spawn.");
return;
}
spawnNodes(selectedTypes);
closeDialog();
};
currentDialog.querySelector('#clear-btn').onclick = clearSelectedNodes;
currentDialog.querySelector('#cancel-btn').onclick = closeDialog;
currentDialog.querySelector('#clear-history-btn').onclick = () => {
searchHistory = [];
localStorage.setItem('endlessNodeLoader_searchHistory', JSON.stringify(searchHistory));
const dropdown = currentDialog.querySelector('.search-dropdown');
if (dropdown) dropdown.style.display = 'none';
};
currentDialog.querySelector('#clear-recent-btn').onclick = () => {
recentlyUsedNodes = [];
localStorage.setItem('endlessNodeLoader_recentlyUsed', JSON.stringify(recentlyUsedNodes));
updateRecentChips(); // This will clear the chips
};
// ESC key handler
const escHandler = (e) => e.key === 'Escape' && closeDialog();
document.addEventListener('keydown', escHandler);
return escHandler;
}
function createNodeLoaderDialog() {
if (currentDialog) return;
const colors = getComfyUIColors();
// Clean up existing style
document.getElementById('node-loader-style')?.remove();
// Create style tag
const style = document.createElement('style');
style.id = 'node-loader-style';
style.textContent = createStyleCSS(colors);
document.head.appendChild(style);
// Create container
const container = document.createElement("div");
// Calculate max size based on viewport
const maxWidth = Math.min(window.innerWidth * 0.4, 1536); // 40% of window width, max 1536px
const maxHeight = Math.min(window.innerHeight * 0.6, 1296); // 60% of window height, max 1296px
container.className = "dialog-container";
container.style.cssText = `
position: fixed;
top: 10%;
left: 50%;
transform: translateX(-50%);
max-width: ${maxWidth}px;
max-height: ${maxHeight}px;
`;
container.innerHTML = `
<h3 class="dialog-title"> Endless 🌊✨ Node Spawner Drag Bar</h3>
<div class="filter-section">
<div class="filter-row">
<input type="text" class="filter-input" placeholder="Filter nodes..." title="Type to filter nodes, ↓ for history">
<div class="search-dropdown"></div>
<button class="expand-btn" title="Expand/collapse all categories">Expand All</button>
</div>
<div class="counters">
<span class="counter-selected">Selected: 0</span>
<span class="counter-total">Total: 0</span>
</div>
</div>
<div class="recent-section"></div>
<div class="node-list"></div>
<div class="footer">
<div class="btn-group">
<button id="spawn-btn" class="dialog-btn primary" title="Spawn selected nodes">🌊 Spawn Nodes</button>
<button id="clear-btn" class="dialog-btn" title="Clear all selections">Clear Selected</button>
</div>
<div class="btn-group">
<button id="clear-history-btn" class="dialog-btn" title="Clear search history">Clear Search</button>
<button id="clear-recent-btn" class="dialog-btn" title="Clear recent nodes">Clear Recent</button>
<button id="cancel-btn" class="dialog-btn secondary" title="Close dialog">❌ Cancel</button>
</div>
</div>
`;
document.body.appendChild(container);
currentDialog = container;
// Setup dragging
makeDraggable(container, container.querySelector('.dialog-title'));
// Build node data
const nodes = Object.entries(LiteGraph.registered_node_types)
.filter(([key, value]) => key && value)
.map(([type, nodeClass]) => {
const category = nodeClass.category || "Other";
const displayName = nodeClass.title || nodeClass.name || type.split("/").pop();
return {
type,
category,
pathParts: category.split("/"),
displayName,
description: nodeClass.desc || nodeClass.description || "",
fullPath: category + "/" + displayName
};
})
.sort((a, b) => a.category.localeCompare(b.category) || a.displayName.localeCompare(b.displayName));
allNodesData = nodes;
// Render hierarchy
const hierarchy = buildHierarchy(nodes);
const tree = renderCategory(hierarchy);
const nodeList = container.querySelector('.node-list');
tree.forEach(section => nodeList.appendChild(section));
// Add hover effects
addButtonHoverEffects(container);
// Setup event handlers
const escHandler = setupEventHandlers();
// Setup theme updates
unregisterThemeCallback = onThemeChange(updateTheme);
// Initialize
updateRecentChips();
updateSelectedCounter();
updateTotalCounter();
// Focus filter input
container.querySelector('.filter-input').focus();
// Setup cleanup
const originalRemove = container.remove.bind(container);
container.remove = function() {
document.removeEventListener('keydown', escHandler);
unregisterThemeCallback?.();
document.getElementById('node-loader-style')?.remove();
if (searchTimeout) clearTimeout(searchTimeout);
if (hoverTimeout) clearTimeout(hoverTimeout);
currentDialog = null;
unregisterThemeCallback = null;
searchTimeout = null;
hoverTimeout = null;
originalRemove();
};
}
function closeDialog() {
currentDialog?.remove();
}
// Register tool
registerEndlessTool("Node Spawner", createNodeLoaderDialog);
})();
+276
View File
@@ -0,0 +1,276 @@
// === Endless 🌊✨ Tools UI Helper ===
const endlessToolsRegistry = [];
export function registerEndlessTool(name, callback) {
endlessToolsRegistry.push({ name, callback });
}
export function injectEndlessToolsButton() {
const toolbar = findToolbar();
if (!toolbar || document.getElementById("endless-tools-button")) return;
const btn = document.createElement("button");
btn.id = "endless-tools-button";
btn.textContent = "Endless 🌊✨ Tools";
btn.className = "comfyui-button";
btn.style.marginLeft = "8px";
btn.onclick = showEndlessToolMenu;
toolbar.appendChild(btn);
}
export function showEndlessToolMenu() {
document.getElementById("endless-tools-float")?.remove();
const colors = getComfyUIColors();
const menu = document.createElement("div");
menu.id = "endless-tools-float";
menu.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors.menu};
color: ${colors.inputText};
padding: 12px;
border: 1px solid ${colors.accent};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 99999;
opacity: 1;
width: fit-content;
transition: opacity 0.2s ease;
`;
const dragBar = document.createElement("div");
dragBar.textContent = "Endless 🌊✨ Tools Drag Bar";
dragBar.style.cssText = `
padding: 4px;
background: ${toRGBA(colors.inputText, 0.05)};
cursor: move;
font-size: 12px;
text-align: center;
user-select: none;
border-bottom: 1px solid ${colors.border};
`;
menu.appendChild(dragBar);
endlessToolsRegistry.sort((a, b) => a.name.localeCompare(b.name)).forEach(tool => {
const btn = document.createElement("div");
btn.textContent = `🌊✨ ${tool.name}`;
btn.style.cssText = `
padding: 6px 10px;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s ease;
`;
btn.onmouseover = () => btn.style.background = toRGBA(colors.inputText, 0.1);
btn.onmouseout = () => btn.style.background = "transparent";
btn.onclick = () => {
tool.callback();
menu.remove();
};
menu.appendChild(btn);
});
makeDraggable(menu, dragBar);
// Live theme updater
function updateMenuTheme(newColors = getComfyUIColors()) {
menu.style.background = newColors.menu;
menu.style.color = newColors.inputText;
menu.style.borderColor = newColors.accent;
menu.style.boxShadow = newColors.shadow;
dragBar.style.background = toRGBA(newColors.inputText, 0.05);
dragBar.style.borderBottomColor = newColors.border;
}
const unregister = onThemeChange(updateMenuTheme);
menu.remove = ((orig => function () {
unregister();
orig.call(this);
})(menu.remove));
document.body.appendChild(menu);
}
// === Hotkeys ===
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'e') {
showEndlessToolMenu();
e.preventDefault();
}
if (e.key === "Escape") {
document.getElementById("endless-tools-float")?.remove();
}
});
console.log("Endless 🌊✨ Tools menu: press Ctrl+Alt+E if toolbar button is missing.");
function waitForToolbarAndInject() {
if (document.querySelector('.comfyui-menu')) {
injectEndlessToolsButton();
return;
}
const observer = new MutationObserver(() => {
if (document.querySelector('.comfyui-menu')) {
injectEndlessToolsButton();
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
waitForToolbarAndInject();
function findToolbar() {
return (
document.querySelector('.comfyui-menu, .comfy-menu, [class*="menu"], [class*="toolbar"]') ||
Array.from(document.querySelectorAll('[class*="button-group"], [class*="btn-group"], .comfyui-button-group'))
.find(g => g.querySelectorAll('button').length > 0) ||
Array.from(document.querySelectorAll('*'))
.find(el => {
const buttons = el.querySelectorAll('button');
return buttons.length >= 2 && buttons.length <= 10;
}) ||
Array.from(document.querySelectorAll(".comfyui-button-group"))
.find(div => Array.from(div.querySelectorAll("button")).some(btn => btn.title === "Share"))
);
}
// === Live Theme Monitoring ===
let themeObserver = null;
const themeCallbacks = new Set();
export function onThemeChange(callback) {
themeCallbacks.add(callback);
if (themeCallbacks.size === 1) startThemeObserver();
return () => {
themeCallbacks.delete(callback);
if (themeCallbacks.size === 0) stopThemeObserver();
};
}
function startThemeObserver() {
if (themeObserver) return;
themeObserver = new MutationObserver(() => {
clearTimeout(window.themeChangeTimeout);
window.themeChangeTimeout = setTimeout(() => {
const newColors = getComfyUIColors();
themeCallbacks.forEach(cb => cb(newColors));
}, 100);
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
}
function stopThemeObserver() {
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
}
export function getComfyUIColors() {
const computed = getComputedStyle(document.documentElement);
const getVar = name => computed.getPropertyValue(name).trim() || null;
return {
fg: getVar("--fg-color") || "#ddd",
bg: getVar("--bg-color") || "#353535",
menu: getVar("--comfy-menu-bg") || "#353535",
menuSecondary: getVar("--comfy-menu-secondary-bg") || "#222",
inputBg: getVar("--comfy-input-bg") || "#222",
inputText: getVar("--input-text") || "#ddd",
descriptionText: getVar("--descrip-text") || "#999",
dragText: getVar("--drag-text") || "#ddd",
errorText: getVar("--error-text") || "#f44336",
border: getVar("--border-color") || "#999",
accent: getVar("--comfy-accent") || getVar("--comfy-accent-color") || "#4a90e2",
hoverBg: getVar("--content-hover-bg") || "rgba(255,255,255,0.1)",
hoverFg: getVar("--content-hover-fg") || "#fff",
shadow: getVar("--bar-shadow") || "0 2px 10px rgba(0,0,0,0.3)",
dialogBg: getVar("--comfy-menu-bg") || getVar("--bg-color") || "#353535",
buttonHoverBg: getVar("--content-hover-bg") || "rgba(255,255,255,0.1)"
};
}
export function toRGBA(color, alpha = 0.2) {
if (!color) return `rgba(128,128,128,${alpha})`;
color = color.trim();
if (color.startsWith('#')) {
const hex = color.slice(1);
const fullHex = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex;
const bigint = parseInt(fullHex, 16);
const r = (bigint >> 16) & 255, g = (bigint >> 8) & 255, b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (color.startsWith('rgb')) {
const rgb = color.match(/\d+/g);
if (rgb?.length >= 3) return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
}
return `rgba(128,128,128,${alpha})`;
}
export function blendColors(color1, color2, ratio) {
const c1 = toRGBA(color1, 1).match(/\d+/g);
const c2 = toRGBA(color2, 1).match(/\d+/g);
if (!c1 || !c2) return color1;
const r = Math.round(c1[0] * (1 - ratio) + c2[0] * ratio);
const g = Math.round(c1[1] * (1 - ratio) + c2[1] * ratio);
const b = Math.round(c1[2] * (1 - ratio) + c2[2] * ratio);
return `rgb(${r}, ${g}, ${b})`;
}
export function addButtonHoverEffects(container) {
container?.querySelectorAll('button').forEach(button => {
button.addEventListener('mouseenter', () => {
button.style.boxShadow = '0 0 0 1px currentColor';
button.style.filter = 'brightness(1.1)';
button.style.transform = 'translateY(-1px)';
});
button.addEventListener('mouseleave', () => {
button.style.boxShadow = 'none';
button.style.filter = 'brightness(1)';
button.style.transform = 'translateY(0px)';
});
});
}
export function makeDraggable(element, handle = element) {
let offsetX = 0, offsetY = 0, isDown = false;
handle.onmousedown = (e) => {
isDown = true;
if (element.style.position !== 'fixed') {
element.style.position = 'fixed';
element.style.right = 'auto';
}
const rect = element.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
element.style.cursor = 'move';
document.onmousemove = (e) => {
if (!isDown) return;
element.style.left = `${e.clientX - offsetX}px`;
element.style.top = `${e.clientY - offsetY}px`;
element.style.transform = 'none';
};
document.onmouseup = () => {
isDown = false;
element.style.cursor = 'default';
document.onmousemove = null;
document.onmouseup = null;
};
};
}
// === Global exposure for F12 ===
window.EndlessHelpers = {
registerEndlessTool,
injectEndlessToolsButton,
showEndlessToolMenu,
onThemeChange,
getComfyUIColors,
toRGBA,
blendColors,
addButtonHoverEffects,
makeDraggable
};
File diff suppressed because it is too large Load Diff