Compare commits

..
43 Commits
Author SHA1 Message Date
yolain 67afdc8204 fix: make reboot routes awaitable (#1045) 2026-09-29 12:23:08 +08:00
yolain 8a0f2fc412 fix: harden model metadata and preview routes (#1045)
Refs #1045
2026-09-29 12:14:30 +08:00
yolain 22145befb3 fix: app mode rendering race condition 2026-09-29 11:33:08 +08:00
yolain 8730ffd140 Fix:a conflict between the easyuse and the new frontend that affecting ComfyUI-Easy-Media 2026-09-21 15:31:43 +08:00
altoiddealer 32931f09a7 Add a "default" input to PassOrNone node (#1038)
* Add a "default" input to PassOrNone node

* Fix tooltip
2026-09-20 12:07:55 +08:00
yolain 86873e7bda Merge branch 'main' of https://github.com/yolain/ComfyUI-Easy-Use 2026-09-20 12:04:39 +08:00
yolain 375f3b77e0 fix:Disabled Alt+1–9 template shortcuts still block browser Alt+number shortcuts #1039 2026-09-20 11:57:04 +08:00
御坂桜 450b1ce4ce Add Anima and Krea2 diffusion model support (#1033) 2026-09-07 12:51:49 +08:00
altoiddealer 457b3a81e8 Add PassOrNone including locales and add tooltip/description to existing IsNone node. (#1035) 2026-09-07 12:49:51 +08:00
yolain 271685698b feat: bump version to 1.4.1 2026-09-05 00:37:55 +08:00
Aleksey Smolenchuk 859af7e7b4 fix: confine save text outputs to output directory (#1032) 2026-09-05 00:13:34 +08:00
yolain 8b522f121d fix: add missing permissions for contents in publish workflow 2026-09-03 15:58:31 +08:00
yolain 5b469409bc feat: bump version 1.4.0 with new features and bug fixes 2026-09-03 15:34:58 +08:00
yolain 80e1261b1c fix: prevent EasyUse from preempting prompt errors
Stop wrapping ComfyUI's queuePrompt so disconnected nodes, invalid connections, and validation failures remain under native frontend error handling. Move global seed metadata injection to workflow generation.
2026-09-03 01:31:00 +08:00
御坂桜 005c57839c fix: use native VAEDecodeTiled for tiled decode (Qwen Image VAE support) (#1028) 2026-09-01 11:21:44 +08:00
Tai An cf15032ab6 fix(pixart): use pe_interpolation instead of the removed lewei_scale in the ControlNet wrappers (#1030)
ControlPixArtHalf.forward_c and ControlPixArtMSHalf.forward_raw still use the
pre-rename name lewei_scale, both for the attribute and for the
get_2d_sincos_pos_embed keyword. Neither exists any more, so both PixArt
ControlNet targets fail on the first forward pass.

Signed-off-by: Anai-Guo <antai12232931@outlook.com>
2026-09-01 11:21:08 +08:00
Tai An ca93381de8 fix(preSampling): make vae/pixels optional on samplerCustomSettings.ip2p (#1029)
The latent-only call site passes neither vae nor pixels, so the IP2P
guider raises TypeError whenever a latent is supplied instead of an
image. samplerFull.ip2p in samplers.py already has the optional form.
2026-09-01 11:19:56 +08:00
max-russellandmaxru 58e077a743 fix(forLoopStart): allow total to be zero, preventing unnecessary loop execution (#1024)
Co-authored-by: maxru <m@m.com>
2026-08-27 03:07:18 +08:00
yolain 4de1ab3b66 fix: connection lines were missing from the "Refresh Nodes" feature in the new comfyui frontend 2026-08-14 21:35:08 +08:00
YseraJYandsherjy 595e0738a9 fix(promptConcat): TypeError when an input is a list (#1019)
Normalize prompt1/prompt2/separator to strings before concatenation,
joining lists with ", ", so outputs from nodes that return lists of
strings (e.g. WD14 tagger pipelines) no longer raise
"can only concatenate list (not str) to list" and the node always
outputs a plain string.

Fixes #993

Co-authored-by: sherjy <23369551+sherjy@users.noreply.github.com>
2026-07-28 18:29:14 +08:00
Joly0 7535cd0dfd Use a dedicated RNG for global seed generation (#1017)
Custom nodes calling random.seed() while they run reset the global RNG,
which makes the generated seeds deterministic and repeat after a workflow
reload or a server restart.
2026-07-28 18:28:23 +08:00
ImpactFramesandClaude Fable 5 960862223b fix(imageDetailTransfer): multi-frame masks crash on channel broadcast (#1015)
The optional mask is resized to (B, H, W) but then passed to
torch.lerp against channels-first (B, C, H, W) tensors. With a
single-frame mask (B=1) broadcasting happens to work, but any
batch/video mask (B>1) raises:

  RuntimeError: The size of tensor a (3) must match the size of
  tensor b (B) at non-singleton dimension 1

Add the missing channel dimension so per-frame video mattes
(e.g. SAM2 alpha over a clip) work.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:47:53 +08:00
kannkyo 54d080bf6a Specify UTF-8 encoding for file reads (#1007)
Added UTF-8 encoding to file reads for YAML and JSON files.
2026-06-19 23:07:20 +08:00
yolain 625efbfa2f Fixed an issue where the PrimeVue dialog overlay was not being cleared in a Windows environment #1000 2026-05-30 11:57:49 +08:00
yolain 5618a748c1 Fix: TypeError: object of type 'LockedMeta' has no len() #994 2026-05-28 09:58:17 +08:00
yolain 130c1b5796 fix(math): enhance evaluate_formula to handle list inputs and return results accordingly 2026-04-30 03:24:51 +08:00
yolain 3cf9ab4e63 remove unnecessary print statements 2026-04-23 12:08:38 +08:00
Ralkey ff5e3a34fc fixed detailer not working in subgraphs (#989) 2026-04-21 10:28:59 +08:00
xmarreandxmarre ec4ca6717f Fix Clean VRAM teardown ordering and clear Easy-Use cache in place (#982)
* Remove unused repo metadata and frontend submodule

* Remove repo metadata and frontend submodule

* Add .codex to .gitignore

* Delete .codex

---------

Co-authored-by: xmarre <mmquant1@gmail.com>
2026-04-09 08:12:22 +08:00
yolain b82bb48948 fix: avoid recursive group refs in nodes map 2026-04-08 12:56:30 +08:00
Mark Cockram d08eedabd3 fix(xyplot): fix crashes in Seeds++ Batch and tuple X/Y inputs (#984)
Three bugs in pipeXYPlotAdvanced.plot():

1. X/Y inputs can arrive as tuples in certain ComfyUI configurations,
   causing `AttributeError: 'tuple' object has no attribute 'get'`.
   Added isinstance check to unwrap single-element tuples.

2. Seeds++ Batch used `if new_pipe['seed']:` which:
   - Raises KeyError when 'seed' key is absent
   - Silently skips seed generation when seed is 0 (a valid seed)
   Changed to `new_pipe.get('seed') or 0` for safe fallback.

3. `!= None` → `is not None` per PEP 8.
2026-04-08 12:51:51 +08:00
Z-nonymous 8ba21d0b44 XY plot issue with XY Inputs Lora (#976)
* Fixed the LoRA handling bug in [`py/libs/xyplot.py:365-395`](py/libs/xyplot.py:365).

The original code used an if/else pattern that only processed one LoRA at a time:
```python
xy_values = x_value if self.x_type == "Lora" else y_value
```

This caused issues when both X and Y axes contained LoRAs - only the X axis LoRA was processed, and when only Y had LoRAs, the latent array wasn't populated correctly.

The fix now:
1. Creates an empty `lora_stack` list
2. Adds the X axis LoRA to the stack if `self.x_type == "Lora"`
3. Adds the Y axis LoRA to the stack if `self.y_type == "Lora"`
4. Appends any existing `plot_image_vars['lora_stack']` to the combined stack
5. Applies all LoRAs in sequence

This ensures both X and Y LoRAs are properly combined and applied when both axes contain LoRA values, fixing the `IndexError` in `rearrange_tensors()` that occurred due to mismatched latent array dimensions.

* Fixed: get_labels_and_sample loop structure (lines 588-623)

The IndexError persisted because the latent array length didn't match the expected grid dimensions. The issue is in the nested loop structure of get_labels_and_sample()
When only Y-axis has LoRA values (X is "None"), the nested loops don't generate the correct number of latents:
This results in 0 latents instead of len(y_values) latents, causing the IndexError in rearrange_tensors()

Replaced the nested loop structure with three explicit cases:

X-only variation (self.y_type == 'None'): Iterates over X values only
Y-only variation (self.x_type == 'None'): Iterates over Y values only
Both X and Y variation: Nested iteration over both axes
This ensures the correct number of latents are generated for all scenarios, fixing the IndexError in rearrange_tensors() that occurred when only Y-axis had values (like LoRAs).

* Fix for the LoRA label generation logic in [`py/libs/xyplot.py`](py/libs/xyplot.py:63-69). The changes made to the `define_variable()` method:

1. **Reduced model name truncation** from 30 to 25 characters to leave room for weight information
2. **Changed weight format** from `(0.50)` to ` w:0.50` for better visibility
3. **Only show weight when it differs from default** (1.0) - this ensures weight is displayed for non-default values
4. **Added bounds check** for `len(arr) > 3` before accessing `arr[3]` to prevent potential IndexError

Now when using the same LoRA with different weights (e.g., LoRA A at 0.5 and 1.0), each variation will have a distinct label in the axis, making it clear which weight is being applied in each column/row of the XY plot.
2026-03-31 15:11:24 +08:00
yolain 337a03bb19 Rollback the cycle node to version v1 2026-03-19 18:01:59 +08:00
yolain aef19b8772 Fix NodesMap #969 2026-03-19 14:26:21 +08:00
j2gg0s d60b61d575 fix: use getattr for flipped_img_txt to support newer ComfyUI versions (#968)
ComfyUI removed the `flipped_img_txt` attribute from `DoubleStreamBlock`
in a recent refactor (commit e1add563f, "Use torch RMSNorm for flux
models and refactor hunyuan video code"). This causes an AttributeError
when IPAdapter Flux nodes are executed.

Use `getattr` with a default of `False` (matching the original default)
to maintain compatibility with both old and new ComfyUI versions.
2026-03-13 12:18:51 +08:00
0AA01A0F1 a3f051f0c3 [*] Small Readme corrections (#963) 2026-03-05 11:39:14 +08:00
stuttlepress 8ca6ace667 Fix for loraStack/loraSwitcher/controlnetStack disable erases upstream stack (#962) 2026-03-03 23:45:35 +08:00
yolain 81c510c06e Optimize easy tableEditor display 2026-02-23 17:17:21 +08:00
yolain 7601371923 Add easy tableEditor 2026-02-23 11:53:28 +08:00
yolain b11c634872 Fix showAnything can not working on latest comfyui frontend 2026-02-13 21:56:09 +08:00
rjgoif 7c470c67d6 Update logic.py (#949)
Fixed Range(Float) node which would sometimes truncate the list of values by 1 due to rounding error.
2026-01-31 12:21:35 +08:00
yolain b5865efd16 Fix multiAnglePrompt settings failed to save 2026-01-30 19:01:11 +08:00
yolain 5ec3b5ef86 Fix easy showAnything #933 2026-01-25 17:42:53 +08:00
52 changed files with 2989 additions and 3163 deletions
@@ -1,605 +0,0 @@
---
applyTo: "**/*.py"
description: "ComfyUI v3 Node Examples"
---
# ComfyUI v3 Node Examples
Real-world examples of v3 nodes demonstrating various features and patterns.
## Basic Examples
### Simple Image Processor
```python
from comfy_api.latest import io, ui
import torch
class ImageInvertV3(io.ComfyNode):
"""Simple node that inverts image colors."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ImageInvert_v3",
display_name="Invert Image",
category="image/filters",
description="Inverts the colors of an image",
inputs=[
io.Image.Input("image", tooltip="Image to invert")
],
outputs=[
io.Image.Output("inverted", tooltip="Inverted image")
]
)
@classmethod
def execute(cls, image):
# Invert: 1.0 - image
inverted = 1.0 - image
return io.NodeOutput(inverted, ui=ui.PreviewImage(inverted))
```
### Math Operations
```python
class MathOperationV3(io.ComfyNode):
"""Performs math operations on two values."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MathOperation_v3",
display_name="Math Operation",
category="utils/math",
inputs=[
io.Float.Input("a", default=0.0),
io.Float.Input("b", default=0.0),
io.Combo.Input("operation",
options=["add", "subtract", "multiply", "divide", "power"],
default="add"
)
],
outputs=[
io.Float.Output("result")
]
)
@classmethod
def execute(cls, a, b, operation):
operations = {
"add": a + b,
"subtract": a - b,
"multiply": a * b,
"divide": a / b if b != 0 else 0,
"power": a ** b
}
result = operations[operation]
return io.NodeOutput(result)
```
## Async Examples
### API Integration
```python
import aiohttp
class TextGeneratorV3(io.ComfyNode):
"""Generates text using external API."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TextGenerator_v3",
display_name="AI Text Generator",
category="text/generation",
inputs=[
io.String.Input("prompt", multiline=True),
io.String.Input("api_url", default="http://localhost:11434/api/generate"),
io.String.Input("model", default="llama2"),
io.Float.Input("temperature", default=0.7, min=0.0, max=2.0)
],
outputs=[
io.String.Output("generated_text")
]
)
@classmethod
async def execute(cls, prompt, api_url, model, temperature):
async with aiohttp.ClientSession() as session:
payload = {
"model": model,
"prompt": prompt,
"temperature": temperature,
"stream": False
}
async with session.post(api_url, json=payload) as response:
if response.status == 200:
data = await response.json()
text = data.get("response", "")
return io.NodeOutput(text)
else:
raise RuntimeError(f"API error: {response.status}")
```
### Batch Processing with Progress
```python
from comfy.utils import ProgressBar
import asyncio
class BatchImageProcessorV3(io.ComfyNode):
"""Processes images in batch with progress tracking."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="BatchImageProcessor_v3",
display_name="Batch Image Processor",
category="image/batch",
inputs=[
io.Image.Input("images"),
io.Float.Input("process_time", default=0.1, min=0.01, max=1.0,
tooltip="Simulated processing time per image")
],
outputs=[
io.Image.Output("processed")
],
hidden=[io.Hidden.unique_id]
)
@classmethod
async def execute(cls, images, process_time, **kwargs):
batch_size = images.shape[0]
pbar = ProgressBar(batch_size, node_id=cls.hidden.unique_id)
processed = []
for i in range(batch_size):
# Simulate async processing
await asyncio.sleep(process_time)
# Example: Apply blur
import torch.nn.functional as F
blurred = F.gaussian_blur(images[i:i+1], kernel_size=5)
processed.append(blurred)
pbar.update(1)
result = torch.cat(processed, dim=0)
return io.NodeOutput(result, ui=ui.PreviewImage(result))
```
## Advanced Examples
### Model Loader with Resources
```python
import folder_paths
import comfy.utils
import comfy.sd
class CheckpointLoaderV3(io.ComfyNode):
"""Loads checkpoint models with caching."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="CheckpointLoader_v3",
display_name="Load Checkpoint",
category="loaders",
inputs=[
io.Combo.Input("ckpt_name",
options=folder_paths.get_filename_list("checkpoints"),
tooltip="Select checkpoint to load"
)
],
outputs=[
io.Model.Output("model"),
io.Clip.Output("clip"),
io.Vae.Output("vae")
]
)
@classmethod
def execute(cls, ckpt_name):
# Use resource caching
ckpt = cls.resources.get(
resources.TorchDictFolderFilename("checkpoints", ckpt_name)
)
# Load components
model, clip, vae = comfy.sd.load_checkpoint_guess_config(
ckpt,
embedding_directory=folder_paths.get_folder_paths("embeddings")
)
return io.NodeOutput(model, clip, vae)
```
### State Management Example
```python
class IterativeRefinerV3(io.ComfyNode):
"""Refines images iteratively with state tracking."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="IterativeRefiner_v3",
display_name="Iterative Refiner",
category="image/processing",
inputs=[
io.Image.Input("image"),
io.Int.Input("iterations", default=3, min=1, max=10),
io.Boolean.Input("reset", default=False,
tooltip="Reset refinement history")
],
outputs=[
io.Image.Output("refined"),
io.Int.Output("total_iterations")
]
)
@classmethod
def execute(cls, image, iterations, reset):
# Initialize or reset state
if reset or cls.state.history is None:
cls.state.history = []
cls.state.total_iterations = 0
# Get last refined image or use input
current = cls.state.history[-1] if cls.state.history else image
# Iterative refinement
for i in range(iterations):
# Example: Progressive sharpening
import torch.nn.functional as F
kernel = torch.tensor([[-1,-1,-1],
[-1, 9,-1],
[-1,-1,-1]], dtype=torch.float32)
kernel = kernel.view(1, 1, 3, 3)
kernel = kernel.repeat(current.shape[-1], 1, 1, 1)
current = current.permute(0, 3, 1, 2)
sharpened = F.conv2d(current, kernel, padding=1, groups=current.shape[1])
current = sharpened.permute(0, 2, 3, 1)
current = torch.clamp(current, 0, 1)
# Update state
cls.state.history.append(current)
cls.state.total_iterations += iterations
# Keep history size manageable
if len(cls.state.history) > 10:
cls.state.history.pop(0)
return io.NodeOutput(
current,
cls.state.total_iterations,
ui=ui.PreviewImage(current)
)
```
### Dynamic Inputs Example
```python
class ImageBlenderV3(io.ComfyNode):
"""Blends multiple images with weights."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ImageBlender_v3",
display_name="Image Blender",
category="image/blend",
inputs=[
io.AutoGrowDynamicInput("images",
template_input=io.Image.Input("image"),
min=2,
max=8
),
io.Combo.Input("mode",
options=["average", "weighted", "max", "min"],
default="average"
)
],
outputs=[
io.Image.Output("blended")
]
)
@classmethod
def execute(cls, mode, **kwargs):
# Collect all image inputs
images = []
for key, value in sorted(kwargs.items()):
if key.startswith("image"):
images.append(value)
if not images:
raise ValueError("No images provided")
# Stack images
stacked = torch.stack(images, dim=0)
# Blend based on mode
if mode == "average":
blended = torch.mean(stacked, dim=0)
elif mode == "weighted":
# Simple linear weighting
weights = torch.linspace(1, 0.1, len(images))
weights = weights / weights.sum()
weights = weights.view(-1, 1, 1, 1, 1)
blended = (stacked * weights).sum(dim=0)
elif mode == "max":
blended = torch.max(stacked, dim=0)[0]
elif mode == "min":
blended = torch.min(stacked, dim=0)[0]
return io.NodeOutput(blended, ui=ui.PreviewImage(blended))
```
### Multi-Type Input Example
```python
class UniversalInverterV3(io.ComfyNode):
"""Inverts images, masks, or conditioning."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="UniversalInverter_v3",
display_name="Universal Inverter",
category="utils/invert",
inputs=[
io.MultiType.Input("input",
types=[io.Image, io.Mask, io.Conditioning]
),
io.Float.Input("strength", default=1.0, min=0.0, max=1.0)
],
outputs=[
io.MultiType.Output("inverted",
types=[io.Image, io.Mask, io.Conditioning]
)
]
)
@classmethod
def execute(cls, input, strength):
# Detect input type and process accordingly
if isinstance(input, torch.Tensor):
# Image or Mask
if input.dim() == 4: # Image [B,H,W,C]
inverted = 1.0 - input
inverted = input + (inverted - input) * strength
return io.NodeOutput(inverted, ui=ui.PreviewImage(inverted))
else: # Mask [H,W] or [B,H,W]
inverted = 1.0 - input
inverted = input + (inverted - input) * strength
return io.NodeOutput(inverted, ui=ui.PreviewMask(inverted))
elif isinstance(input, list): # Conditioning
# Invert conditioning strength
inverted = []
for cond, data in input:
new_data = data.copy()
if 'strength' in new_data:
new_data['strength'] = 1.0 - new_data['strength']
inverted.append((cond, new_data))
return io.NodeOutput(inverted)
else:
raise ValueError(f"Unsupported input type: {type(input)}")
```
### Custom Type Example
```python
class CustomDataProcessorV3(io.ComfyNode):
"""Processes custom data types."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="CustomDataProcessor_v3",
display_name="Custom Data Processor",
category="utils/custom",
inputs=[
io.Custom(io_type="MY_CUSTOM_TYPE").Input("custom_data",,
tooltip="Custom data type input"
),
io.Float.Input("scale", default=1.0, min=0.1, max=10.0)
],
outputs=[
io.Custom(io_type="MY_CUSTOM_TYPE").Output("processed_data",
tooltip="Processed custom data"
)
]
)
@classmethod
def execute(cls, custom_data, scale):
# Process custom data type
# Assuming custom_data is a dict with 'value' and 'metadata'
processed = {
'value': custom_data.get('value', 0) * scale,
'metadata': custom_data.get('metadata', {}),
'processed': True
}
return io.NodeOutput(processed)
```
## Process Isolation Example
### Node with Specific Dependencies
```python
# manifest.yaml
"""
name: scientific_processor
version: 1.0.0
dependencies:
- numpy==1.24.0 # Specific older version needed
- scipy==1.10.0
- scikit-image==0.20.0
isolated: true
share_torch: true
"""
# __init__.py
from comfy_api.latest import io, io.ComfyNode, io.Schema
import numpy as np
from skimage import filters
class ScientificProcessorV3(io.ComfyNode):
"""Image processing with scientific libraries."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ScientificProcessor_v3",
display_name="Scientific Processor",
category="image/scientific",
inputs=[
io.Image.Input("image"),
io.Combo.Input("filter_type",
options=["gaussian", "sobel", "laplacian", "butterworth"],
default="gaussian"
),
io.Float.Input("sigma", default=1.0, min=0.1, max=10.0)
],
outputs=[
io.Image.Output("filtered")
]
)
@classmethod
def execute(cls, image, filter_type, sigma):
# Convert to numpy
img_np = image.cpu().numpy()
batch_size = img_np.shape[0]
results = []
for i in range(batch_size):
img = img_np[i]
if filter_type == "gaussian":
filtered = filters.gaussian(img, sigma=sigma, channel_axis=-1)
elif filter_type == "sobel":
gray = np.mean(img, axis=-1)
filtered = filters.sobel(gray)
filtered = np.stack([filtered]*3, axis=-1)
elif filter_type == "laplacian":
gray = np.mean(img, axis=-1)
filtered = filters.laplace(gray)
filtered = np.stack([filtered]*3, axis=-1)
elif filter_type == "butterworth":
# Frequency domain filtering
for c in range(3):
channel = img[:,:,c]
fft = np.fft.fft2(channel)
fft_shift = np.fft.fftshift(fft)
# Apply Butterworth filter
H = 1 / (1 + (D/sigma)**4) # Simplified
filtered_fft = fft_shift * H
filtered[:,:,c] = np.real(np.fft.ifft2(np.fft.ifftshift(filtered_fft)))
results.append(filtered)
# Convert back to tensor
result = torch.from_numpy(np.stack(results)).float()
return io.NodeOutput(result, ui=ui.PreviewImage(result))
# Entry point for pyisolate
from pyisolate import ExtensionBase
class ScientificExtension(ExtensionBase):
def on_module_loaded(self, module):
self.nodes = {
"ScientificProcessor_v3": ScientificProcessorV3
}
def create_extension():
return ScientificExtension()
```
## Complete Workflow Example
```python
class TextToImageWorkflowV3(io.ComfyNode):
"""Complete text-to-image workflow in one node."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TextToImageWorkflow_v3",
display_name="Text to Image Workflow",
category="workflows",
description="All-in-one text to image generation",
inputs=[
io.String.Input("positive_prompt", multiline=True),
io.String.Input("negative_prompt", multiline=True, default=""),
io.Model.Input("model"),
io.Clip.Input("clip"),
io.Vae.Input("vae"),
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff),
io.Int.Input("steps", default=20, min=1, max=150),
io.Float.Input("cfg", default=7.0, min=0.0, max=30.0),
io.Combo.Input("sampler_name",
options=comfy.samplers.KSampler.SAMPLERS,
default="euler"
),
io.Combo.Input("scheduler",
options=comfy.samplers.KSampler.SCHEDULERS,
default="normal"
),
io.Int.Input("width", default=1024, min=64, max=8192, step=8),
io.Int.Input("height", default=1024, min=64, max=8192, step=8),
io.Int.Input("batch_size", default=1, min=1, max=64)
],
outputs=[
io.Image.Output("images", is_output_list=True),
io.Latent.Output("latents")
],
is_output_node=True
)
@classmethod
async def execute(cls, positive_prompt, negative_prompt, model, clip, vae,
seed, steps, cfg, sampler_name, scheduler,
width, height, batch_size):
import comfy.samplers
# Encode prompts
positive_cond = clip.encode_from_text(positive_prompt)
negative_cond = clip.encode_from_text(negative_prompt)
# Create empty latent
latent = torch.zeros([batch_size, 4, height // 8, width // 8])
# Set up sampler
sampler = comfy.samplers.KSampler(
model, steps, cfg, sampler_name, scheduler,
positive_cond, negative_cond, latent,
denoise=1.0, seed=seed
)
# Sample with progress callback
def callback(step, x0, x, total_steps):
# Could update progress here
pass
samples = sampler.sample(latent, callback=callback)
# Decode latents
images = vae.decode(samples["samples"])
return io.NodeOutput(
images,
samples,
ui=ui.PreviewImage(images)
)
```
@@ -1,530 +0,0 @@
---
applyTo: "**/*.py"
description: "ComfyUI v3 Migration Guide"
---
# ComfyUI v3 Migration Guide
This guide helps developers migrate existing v1 nodes to the new v3 schema and take advantage of async execution and process isolation.
## Quick Start: The Core Changes
1. **Inherit from `io.ComfyNode`**: Your node class now subclasses `io.ComfyNode`.
2. **Use `define_schema`**: All metadata (`INPUT_TYPES`, `CATEGORY`, etc.) moves into a single `@classmethod def define_schema(cls)` that returns an `io.Schema` object.
3. **Use `execute`**: The main logic function is now always a `@classmethod def execute(cls, ...)` method.
4. **Use Typed I/O**: Inputs and outputs are now strongly-typed objects from the `io` module (e.g., `io.Image.Input(...)`).
5. **Return `NodeOutput`**: The `execute` method must return an `io.NodeOutput` instance.
6. **Use `NODES_LIST`**: Node registration is done by adding the class to a `NODES_LIST` at the end of the file, replacing `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`.
## Step-by-Step Migration
### Step 1: Class Definition and Schema
**V1:**
```python
class Canny:
CATEGORY = "image/preprocessors"
FUNCTION = "detect_edge"
RETURN_TYPES = ("IMAGE",)
@classmethod
def INPUT_TYPES(s):
return {"required": {
"image": ("IMAGE",),
"low_threshold": ("FLOAT", {"default": 0.4}),
"high_threshold": ("FLOAT", {"default": 0.8}),
}}
def detect_edge(self, image, low_threshold, high_threshold):
# ... logic ...
return (img_out,)
NODE_CLASS_MAPPINGS = {"Canny": Canny}
```
**V3:**
```python
from comfy_api.latest import io
class Canny(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="Canny_V3",
category="image/preprocessors",
inputs=[
io.Image.Input("image"),
io.Float.Input("low_threshold", default=0.4),
io.Float.Input("high_threshold", default=0.8),
],
outputs=[io.Image.Output()],
)
@classmethod
def execute(cls, image, low_threshold, high_threshold):
# ... logic ...
return io.NodeOutput(img_out)
NODES_LIST = [Canny]
```
### Step 2: Naming and Registration (`node_id`, `display_name`, `NODES_LIST`)
This is a critical step for ensuring your V3 node coexists with or replaces the V1 version correctly.
1. **Remove Old Mappings**: Delete the `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` dictionaries.
2. **Create `NODES_LIST`**: Create a new list called `NODES_LIST` and add your V3 class to it.
3. **Set `node_id`**: The `node_id` in `Schema` **must** be the key from the old `NODE_CLASS_MAPPINGS`.
4. **Set `display_name` (Conditionally)**:
- Check if a key existed in the old `NODE_DISPLAY_NAME_MAPPINGS`.
- **If yes**: Set `display_name` to that value.
- **If no**: **Omit** the `display_name` parameter from `Schema` entirely.
**Example:**
**V1 Registration:**
```python
NODE_CLASS_MAPPINGS = {
"APG": APG,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"APG": "Adaptive Projected Guidance",
}
```
**V3 `define_schema`:**
```python
@classmethod
def define_schema(cls):
return io.Schema(
node_id="APG_V3", # From MAPPINGS key + "_V3"
display_name="Adaptive Projected Guidance _V3", # From DISPLAY MAPPINGS value + " _V3"
# ... other parameters
)
NODES_LIST = [APG] # ... at end of file
```
### Step 3: Converting I/O
| V1 Type (`string`) | V3 Class (`io.<Type>`) | Common `Input()` Options (as keyword arguments) |
|:-----------------------|:------------------------|:--------------------------------------------------------------------------|
| `STRING` | `io.String` | `default`, `multiline`, `dynamic_prompts`, `placeholder` |
| `INT` | `io.Int` | `default`, `min`, `max`, `step`, `display_mode`, `control_after_generate` |
| `FLOAT` | `io.Float` | `default`, `min`, `max`, `step`, `round`, `display_mode` |
| `BOOLEAN` | `io.Boolean` | `default`, `label_on`, `label_off` |
| `COMBO` | `io.Combo` | `options`, `default`, `upload`, `image_folder`, `remote` |
| (custom) | `io.MultiCombo` | `options`, `default`, `placeholder`, `chip` |
| `IMAGE` | `io.Image` | |
| `MASK` | `io.Mask` | |
| `MESH` | `io.Mesh` | |
| `HOOKS` | `io.Hooks` | |
| `HOOK_KEYFRAMES` | `io.HookKeyframes` | |
| `LATENT` | `io.Latent` | |
| `LATENT_OPERATION` | `io.LatentOperation` | |
| `LOAD3D_CAMERA` | `io.Load3DCamera` | |
| `LOAD_3D` | `io.Load3D` | |
| `LOAD_3D_ANIMATION` | `io.Load3DAnimation` | |
| `LOSS_MAP` | `io.LossMap` | |
| `LORA_MODEL` | `io.LoraModel` | |
| `CONDITIONING` | `io.Conditioning` | |
| `CLIP` | `io.Clip` | |
| `CLIP_VISION_OUTPUT` | `io.ClipVisionOutput` | |
| `NOISE` | `io.Noise` | |
| `VAE` | `io.Vae` | |
| `MODEL` | `io.Model` | |
| `CONTROL_NET` | `io.ControlNet` | |
| `SAMPLER` | `io.Sampler` | |
| `SIGMAS` | `io.Sigmas` | |
| `GUIDER` | `io.Guider` | |
| `CLIP_VISION` | `io.ClipVision` | |
| `UPSCALE_MODEL` | `io.UpscaleModel` | |
| `AUDIO` | `io.Audio` | |
| `VIDEO` | `io.Video` | |
| `VOXEL` | `io.Voxel` | |
| `WAN_CAMERA_EMBEDDING` | `io.WanCameraEmbedding` | |
| `WEBCAM` | `io.Webcam` | `default`, `socketless` |
| `*` | `io.AnyType` | Used for inputs that can accept any type, like the PreviewAny node. |
#### Advanced Input Types
**MultiType Input (accepts multiple types):**
```python
io.MultiType.Input("input", types=[io.Mask, io.Float, io.Int], optional=True)
```
**Combo with Remote Options:**
```python
io.Combo.Input(
"lora_name",
options=folder_paths.get_filename_list("loras"),
tooltip="The name of the LoRA."
)
```
**Optional Parameters:**
```python
io.Boolean.Input(
"case_sensitive",
default=True,
optional=True, # Makes this input optional
tooltip="Whether to use case-sensitive matching"
)
```
### Step 4: Migrating Logic
- **Execution Method**: Rename your old `FUNCTION` to `execute` and make it a `@classmethod`.
- **Return Value**: Wrap your return tuple in `io.NodeOutput()`. For UI updates, use the `ui` keyword argument: `io.NodeOutput(ui=ui.PreviewImage(image))`.
- **State**: Replace `self.variable` with `cls.state.variable`.
- **Hidden Inputs**: Replace `prompt` and `unique_id` parameters with `cls.hidden.prompt` and `cls.hidden.unique_id`. Request them in the schema with `hidden=[io.Hidden.prompt, io.Hidden.unique_id]`.
- **Optional Methods**: `IS_CHANGED` becomes `fingerprint_inputs`, and `VALIDATE_INPUTS` becomes `validate_inputs`. Both should be `@classmethod`.
## Common Migration Patterns
### 1. Hidden Inputs
**V1:**
```python
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID"
}
def execute(self, ..., prompt=None, unique_id=None):
... # Use hidden inputs
```
**V3:**
```python
hidden=[
io.Hidden.prompt,
io.Hidden.unique_id
]
@classmethod
def execute(cls, ...):
# Access via **cls**
prompt = cls.hidden.prompt
unique_id = cls.hidden.unique_id
```
### 2. State Management
**V1:**
```python
def __init__(self):
self.last_seed = None
self.cache = {}
def execute(self, seed, ...):
if seed != self.last_seed:
self.cache.clear()
self.last_seed = seed
```
**V3:**
```python
@classmethod
def execute(cls, seed, ...):
if cls.state.last_seed != seed:
cls.state.cache = {}
cls.state.last_seed = seed
```
### 3. UI Output
**V1:**
```python
def execute(self, image):
# Save preview manually
preview = save_temp_image(image)
return {"ui": {"images": preview}, "result": (image,)}
```
**V3:**
```python
@classmethod
def execute(cls, image):
return io.NodeOutput(image, ui=ui.PreviewImage(image))
```
### 4. Dynamic Inputs
**V1:**
```python
@classmethod
def INPUT_TYPES(s):
# Complex logic to generate dynamic inputs
inputs = {"required": {}}
for i in range(get_dynamic_count()):
inputs["required"][f"input_{i}"] = ("IMAGE",)
return inputs
```
**V3:**
```python
inputs=[
io.AutoGrowDynamic.Input("images",
template_input=io.Image.Input("image"),
min=1,
max=10
)
]
```
### 5. Resource Loading
**V1:**
```python
def execute(self, model_name):
# Direct file loading
model_path = folder_paths.get_full_path("checkpoints", model_name)
model = comfy.utils.load_torch_file(model_path)
```
**V3:**
```python
from comfy_api.latest import resources
@classmethod
def execute(cls, model_name):
# Cached resource loading
model = cls.resources.get(
resources.TorchDictFolderFilename("checkpoints", model_name)
)
```
## Making Nodes Async
### Basic Async Node
```python
class AsyncNodeV3(io.ComfyNode):
@classmethod
async def execute(cls, image, url):
# Network request without blocking
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
# Process with the data
result = process_image_with_data(image, data)
return io.NodeOutput(result)
```
### Progress Tracking
```python
@classmethod
async def execute(cls, images, unique_id):
from comfy.utils import ProgressBar
batch_size = images.shape[0]
pbar = ProgressBar(batch_size, node_id=unique_id)
results = []
for i in range(batch_size):
# Async processing
result = await process_single(images[i])
results.append(result)
pbar.update(1)
return io.NodeOutput(torch.cat(results))
```
## Enabling Process Isolation
### 1. Create manifest.yaml
```yaml
name: my_custom_nodes
version: 1.0.0
description: My custom node collection
author: Your Name
dependencies:
- numpy==1.26.4
- scikit-image>=0.22.0
- opencv-python
isolated: true
share_torch: true
```
### 2. Update __init__.py
```python
from pyisolate import ExtensionBase
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
class MyNodesExtension(ExtensionBase):
def on_module_loaded(self, module):
# Nodes are automatically registered
pass
async def get_node_mappings(self):
return NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# Extension entry point
def create_extension():
return MyNodesExtension()
```
## Practical Migration Examples
### Complete String Node Conversion
This example shows a full conversion of the StringConcatenate node from v1 to v3:
**V1 Implementation:**
```python
class StringConcatenate():
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"string_a": (IO.STRING, {"multiline": True}),
"string_b": (IO.STRING, {"multiline": True}),
"delimiter": (IO.STRING, {"multiline": False, "default": ""})
}
}
RETURN_TYPES = (IO.STRING,)
FUNCTION = "execute"
CATEGORY = "utils/string"
def execute(self, string_a, string_b, delimiter, **kwargs):
return delimiter.join((string_a, string_b)),
```
**V3 Implementation:**
```python
from comfy_api.latest import io, ui
class StringConcatenate(io.ComfyNode):
"""Concatenates two strings with an optional delimiter between them."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="StringConcatenate",
display_name="String Concatenate",
category="utils/string",
description="Concatenates two strings together with an optional delimiter between them.",
inputs=[
io.String.Input(
"string_a",
display_name="String A",
multiline=True,
tooltip="The first string to concatenate"
),
io.String.Input(
"string_b",
display_name="String B",
multiline=True,
tooltip="The second string to concatenate"
),
io.String.Input(
"delimiter",
display_name="Delimiter",
default="",
multiline=False,
tooltip="The delimiter to insert between the two strings (empty by default)"
),
],
outputs=[
io.String.Output(
"concatenated",
display_name="Concatenated String",
tooltip="The result of concatenating string_a and string_b with the delimiter"
),
],
)
@classmethod
def execute(cls, string_a: str, string_b: str, delimiter: str) -> io.NodeOutput:
"""Concatenates two strings with an optional delimiter."""
result = delimiter.join((string_a, string_b))
return io.NodeOutput(result)
```
### Replacing V1 Nodes Strategy
When replacing v1 nodes with v3 implementations:
1. **Keep Original Node Names**: Don't add "V3" suffix to maintain compatibility
2. **Preserve All Parameters**: Keep same parameter names and defaults
3. **Maintain Return Structure**: v3 automatically generates v1-compatible returns
4. **Test Workflow Compatibility**: Ensure existing workflows continue to work
Example migration workflow:
```bash
# 1. Create new branch
git checkout -b v3-node-migration
# 2. Backup original
cp nodes_original.py nodes_original.py.bak
# 3. Replace with v3 version
cp nodes_v3.py nodes_original.py
# 4. Test with existing workflows
comfy-cli test-workflows ./test-workflows/
```
## Testing Your Migration
### 1. Backward Compatibility Test
```python
# Your v3 node should work with v1 calls
def test_v1_compatibility():
node = MyNodeV3()
inputs = node.INPUT_TYPES()
assert "required" in inputs
assert hasattr(node, "FUNCTION")
assert hasattr(node, "RETURN_TYPES")
```
### 2. Async Execution Test
```python
import asyncio
async def test_async_execution():
result = await MyAsyncNode.execute(image=test_image)
assert result is not None
```
### 3. Isolation Test
```bash
# Test with conflicting dependencies
comfy-cli test-node --isolated my_custom_nodes
```
## Best Practices
1. **Keep nodes stateless** - Use `cls.state` for any mutable data
2. **Make I/O operations async** - Network, disk, database operations
3. **Use resource caching** - Via `cls.resources.get()`
4. **Declare all dependencies** - In manifest.yaml
5. **Test both sync and async** - Ensure compatibility
6. **Document type changes** - Help users update workflows
## Common Issues
### Issue: State not persisting
**Solution:** Use `cls.state` instead of instance variables
### Issue: Hidden inputs not working
**Solution:** Access via `cls.hidden.unique_id` not function parameters
### Issue: Async not executing
**Solution:** Ensure method is `async def` and use `await` for async calls
### Issue: Import errors in isolation
**Solution:** Add all dependencies to manifest.yaml
### Issue: Tensors not sharing
**Solution:** Enable `share_torch: true` in manifest.yaml
@@ -1,635 +0,0 @@
---
applyTo: "**/*.py"
description: "ComfyUI v3 API Reference"
---
# ComfyUI v3 API Reference
Complete reference for the ComfyUI v3 node API, including all types, methods, and decorators.
## Core Classes
### ComfyNodeV3
Base class for all v3 nodes.
```python
from comfy_api.latest import io
class CustomNode(io.ComfyNode):
# Class properties set during execution
state: NodeState # Persistent state storage
resources: Resources # Resource loader with caching
hidden: HiddenHolder # Access to hidden inputs
@classmethod
@abstractmethod
def define_schema(cls) -> io.ComfyNode:
"""Define node schema. Must be overridden."""
pass
@classmethod
@abstractmethod
def execute(cls, **kwargs) -> io.NodeOutput:
"""Execute node logic. Can be async."""
pass
@classmethod
def validate_inputs(cls, **kwargs) -> bool:
"""Optional: Validate inputs before execution."""
pass
@classmethod
def fingerprint_inputs(cls, **kwargs) -> Any:
"""Optional: Generate a fingerprint for caching."""
pass
@classmethod
def GET_SERIALIZERS(cls) -> list[Serializer]:
"""Optional: Define custom serializers."""
return []
```
### io.ComfyNode
Node definition schema.
```python
@dataclass
class io.ComfyNode:
node_id: str # Globally unique ID
display_name: str = None # UI display name
category: str = "sd" # Node category
inputs: list[InputV3] = None # Input definitions
outputs: list[OutputV3] = None # Output definitions
hidden: list[Hidden] = None # Hidden inputs
description: str = "" # Tooltip description
is_input_list: bool = False # Handle list inputs
is_output_node: bool = False # Force execution
is_deprecated: bool = False # Mark as deprecated
is_experimental: bool = False # Mark as experimental
is_api_node: bool = False # API node flag
not_idempotent: bool = False # Disable caching
```
### NodeOutput
Structured return value from `execute`.
```python
class NodeOutput:
def __init__(
self,
*args: Any, # Output values
ui: UIOutput | dict = None, # UI elements
expand: dict = None, # Subgraph expansion
block_execution: str = None # Execution blocker
):
pass
```
## Input Types
### Basic Inputs
```python
# Integer input
io.Int.Input(
id: str,
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
default: int = None,
min: int = None,
max: int = None,
step: int = None,
control_after_generate: bool = None,
display_mode: NumberDisplay = None,
socketless: bool = None,
force_input: bool = None
)
# Float input
io.Float.Input(
id: str,
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
default: float = None,
min: float = None,
max: float = None,
step: float = None,
round: float = None,
display_mode: NumberDisplay = None,
socketless: bool = None,
force_input: bool = None
)
# String input
io.String.Input(
id: str,
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
multiline: bool = False,
placeholder: str = None,
default: str = None,
dynamic_prompts: bool = None,
socketless: bool = None,
force_input: bool = None
)
# Boolean input
io.Boolean.Input(
id: str,
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
default: bool = None,
label_on: str = None,
label_off: str = None,
socketless: bool = None,
force_input: bool = None
)
# Combo (dropdown) input
io.Combo.Input(
id: str,
options: list[str] = None,
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
default: str = None,
control_after_generate: bool = None,
upload: UploadType = None,
image_folder: FolderType = None,
remote: RemoteOptions = None,
socketless: bool = None
)
# Multi-select combo
io.MultiCombo.Input(
id: str,
options: list[str],
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
default: list[str] = None,
placeholder: str = None,
chip: bool = None,
control_after_generate: bool = None,
socketless: bool = None
)
# cusotm type
io.Custom(io_type="MY_TYPE").Input(
id: str,
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None,
placeholder: str = None,
)
```
### ComfyUI Types
```python
# Core types
io.Image.Input(id, ...) # Type: torch.Tensor [B,H,W,C]
io.Mask.Input(id, ...) # Type: torch.Tensor [H,W] or [B,H,W]
io.Latent.Input(id, ...) # Type: dict with 'samples' tensor
io.Conditioning.Input(id, ...) # Type: list[tuple[tensor, dict]]
io.Model.Input(id, ...) # Type: ModelPatcher
io.Clip.Input(id, ...) # Type: CLIP
io.Vae.Input(id, ...) # Type: VAE
io.ControlNet.Input(id, ...) # Type: ControlNet
# Sampling types
io.Sampler.Input(id, ...) # Type: Sampler
io.Sigmas.Input(id, ...) # Type: torch.Tensor
io.Noise.Input(id, ...) # Type: torch.Tensor
io.Guider.Input(id, ...) # Type: CFGGuider
# Additional types
io.ClipVision.Input(id, ...) # Type: ClipVisionModel
io.ClipVisionOutput.Input(id, ...) # Type: ClipVisionOutput
io.StyleModel.Input(id, ...) # Type: StyleModel
io.Gligen.Input(id, ...) # Type: ModelPatcher
io.UpscaleModel.Input(id, ...) # Type: ImageModelDescriptor
io.Audio.Input(id, ...) # Type: dict with 'waveform' and 'sample_rate'
io.Video.Input(id, ...) # Type: VideoInput
io.Webcam.Input(id, ...) # Type: str (filepath)
io.WanCameraEmbedding.Input(id, ...) # Type: torch.Tensor
io.LoraModel.Input(id, ...) # Type: dict[str, Tensor]
io.Hooks.Input(id, ...) # Type: HookGroup
io.HookKeyframes.Input(id, ...) # Type: HookKeyframeGroup
io.SVG.Input(id, ...) # Type: SVG (custom class)
io.Voxel.Input(id, ...) # Type: Voxel data (custom class)
io.Mesh.Input(id, ...) # Type: Mesh data (custom class)
```
### Advanced Inputs
```python
# Multi-type input (accepts multiple types)
io.MultiType.Input(
id: str | InputV3, # Can override from existing input
types: list[type[ComfyType]],
display_name: str = None,
optional: bool = False,
tooltip: str = None,
lazy: bool = None
)
# Dynamic growing input
io.AutogrowDynamic.Input(
id: str,
template_input: InputV3, # Template for each new input
min: int = 1, # Minimum inputs
max: int = None # Maximum inputs
)
# Custom type
@io.comfytype(io_type="MY_CUSTOM")
class MyCustom:
Type = MyDataClass
class Input(io.InputV3):
...
class Output(io.OutputV3):
...
```
## Output Types
```python
# Basic output
io.Image.Output(
id: str = None,
display_name: str = None,
tooltip: str = None,
is_output_list: bool = False # Output is list
)
# All ComfyUI types have corresponding outputs
io.Mask.Output(id, ...)
io.Latent.Output(id, ...)
io.Model.Output(id, ...)
io.Clip.Output(id, ...)
io.Vae.Output(id, ...)
io.Conditioning.Output(id, ...)
io.String.Output(id, ...)
io.Int.Output(id, ...)
io.Float.Output(id, ...)
io.Boolean.Output(id, ...)
# ... etc
```
## Hidden Inputs
```python
from comfy_api.latest import Hidden
# Available hidden inputs
Hidden.unique_id # Node's unique ID
Hidden.prompt # Complete prompt
Hidden.extra_pnginfo # PNG metadata dict
Hidden.dynprompt # Dynamic prompt object
Hidden.auth_token_comfy_org # ComfyOrg auth token
Hidden.api_key_comfy_org # ComfyOrg API key
# Usage in schema
hidden=[
Hidden.unique_id,
Hidden.prompt
]
# Access in execute
unique_id = cls.hidden.unique_id
prompt = cls.hidden.prompt
```
## State Management
```python
# NodeState interface
class NodeState:
def get_value(self, key: str) -> Any
def set_value(self, key: str, value: Any)
def pop(self, key: str) -> Any
def __contains__(self, key: str) -> bool
# Attribute access
cls.state.my_value = 42
value = cls.state.my_value
# Dictionary access
cls.state["key"] = "value"
value = cls.state["key"]
```
## Practical Input/Output Examples
### Enhanced Documentation with Tooltips and Display Names
```python
# String input with full documentation
io.String.Input(
"prompt",
display_name="Text Prompt",
multiline=True,
default="A beautiful landscape",
tooltip="Enter the text description for image generation",
placeholder="Type your prompt here..."
)
# Integer with constraints and UI hints
io.Int.Input(
"steps",
display_name="Sampling Steps",
default=20,
min=1,
max=150,
tooltip="Number of denoising steps. Higher values take longer but may produce better results",
display_mode=io.NumberDisplay.slider
)
# Combo with dynamic options
io.Combo.Input(
"checkpoint",
options=folder_paths.get_filename_list("checkpoints"),
display_name="Model Checkpoint",
tooltip="Select the AI model to use for generation"
)
# Output with documentation
io.Image.Output(
"generated_image",
display_name="Generated Image",
tooltip="The final generated image based on your prompt"
)
# Combo with dynamic options and file upload
io.Combo.Input(
"audio_file",
options=sorted(folder_paths.filter_files_content_types(os.listdir(folder_paths.get_input_directory()), ["audio", "video"])),
display_name="Audio File",
tooltip="Select an audio file or upload a new one",
upload=io.UploadType.audio
)
```
### Return Pattern with NodeOutput
```python
@classmethod
def execute(cls, text: str, count: int) -> io.NodeOutput:
# Single output
result = process_text(text, count)
return io.NodeOutput(result)
# Multiple outputs
image, mask = generate_image_and_mask(text)
return io.NodeOutput(image, mask)
# With UI preview
image = generate_image(text)
return io.NodeOutput(image, ui=ui.PreviewImage(image))
# With multiple UI elements
images = batch_generate(text, count)
previews = [ui.PreviewImage(img) for img in images]
return io.NodeOutput(images, ui={"images": previews})
```
## Resource Management
```python
# Load cached resources
from comfy_api.latest import resources
# Load torch file
model = cls.resources.get(
resources.TorchDictFolderFilename(
folder_name="checkpoints", # Folder category
file_name="model.safetensors"
)
)
# With default value
model = cls.resources.get(key, default=None)
# Custom resource types (future)
class MyResourceKey(ResourceKey):
Type = MyResourceType
def __init__(self, ...):
pass
```
## UI Output Classes
```python
from comfy_api.latest import ui
# Image preview
ui.PreviewImage(
image: torch.Tensor,
animated: bool = False
)
# Mask preview
ui.PreviewMask(
mask: torch.Tensor,
animated: bool = False
)
# Audio preview
ui.PreviewAudio(
values: list[SavedResult | dict]
)
# Text output
ui.PreviewText(
value: str
)
# 3D preview
ui.PreviewUI3D(
values: list[SavedResult | dict]
)
```
## Decorators and Helpers
```python
# Create custom ComfyType
@io.comfytype(io_type="CUSTOM_TYPE")
class CustomType:
Type = CustomClass
class Input(io.InputV3):
...
class Output(io.OutputV3):
...
# Custom serializer
class MySerializer(Serializer, io_type="MY_TYPE"):
@classmethod
def serialize(cls, obj: Any) -> str:
return json.dumps(obj)
@classmethod
def deserialize(cls, s: str) -> Any:
return json.loads(s)
```
## Async Support
```python
# Async execute
class AsyncNode(io.ComfyNode):
@classmethod
async def execute(cls, **kwargs):
result = await async_operation()
return io.NodeOutput(result)
# Async validation
@classmethod
async def VALIDATE_INPUTS(cls, **kwargs):
is_valid = await check_validity()
return True if is_valid else "Error message"
# Async lazy check
async def check_lazy_status(cls, **kwargs):
needed = await determine_needed_inputs()
return needed # List of input names
```
## Complete Example
```python
from comfy_api.latest import io, ui, resources, io.ComfyNode, io.ComfyNode
import torch
class AdvancedNodeV3(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.ComfyNode(
node_id="AdvancedNode",
display_name="Advanced Node",
category="examples/advanced",
description="Demonstrates v3 features",
inputs=[
# Basic inputs
io.Image.Input("image", tooltip="Input image"),
io.Model.Input("model", tooltip="Model to use"),
# Configured inputs
io.Float.Input("strength",
default=0.75,
min=0.0,
max=1.0,
step=0.05,
display_mode=io.NumberDisplay.slider
),
# Multi-type
io.MultiType.Input("flexible",
types=[io.Image, io.Mask, io.Latent],
optional=True
),
# Dynamic
io.AutoGrowDynamic.Input("extra_images",
template_input=io.Image.Input("img"),
min=0,
max=5
)
],
outputs=[
io.Image.Output("result", tooltip="Processed image"),
io.Latent.Output("latent", is_output_list=True)
],
hidden=[
io.Hidden.unique_id,
io.Hidden.prompt
],
is_output_node=True,
is_experimental=True
)
@classmethod
async def execute(cls, image, model, strength, flexible=None, **kwargs):
# Access state
if cls.state.last_model != model:
cls.state.cache = {}
cls.state.last_model = model
# Load resources
weights = cls.resources.get(
resources.TorchDictFolderFilename("loras", "style.safetensors"),
default=None
)
# Access hidden
node_id = cls.hidden.unique_id
# Process async
result = await process_with_model(image, model, strength)
# Handle dynamic inputs
extra_images = [v for k, v in kwargs.items() if k.startswith("extra_")]
# Return with UI
return io.NodeOutput(
result,
[latent],
ui=ui.PreviewImage(result)
)
@classmethod
async def fingerprint_inputs(cls, strength, **kwargs):
if strength < 0.1:
return "Strength too low for good results"
return True
```
## Type Reference
### Type Mappings
| v3 Type | Python Type | Shape/Format |
|---------|------------|--------------|
| `io.Image.Type` | `torch.Tensor` | `[B,H,W,C]` float32 0-1 |
| `io.Mask.Type` | `torch.Tensor` | `[H,W]` or `[B,H,W]` float32 |
| `io.Latent.Type` | `dict` | `{"samples": tensor, ...}` |
| `io.Conditioning.Type` | `list` | `[(tensor, dict), ...]` |
| `io.Audio.Type` | `dict` | `{"waveform": tensor, "sample_rate": int}` |
| `io.Int.Type` | `int` | Python integer |
| `io.Float.Type` | `float` | Python float |
| `io.String.Type` | `str` | Python string |
| `io.Boolean.Type` | `bool` | Python boolean |
### Enum Types
```python
# Number display modes
io.NumberDisplay.number # Standard input
io.NumberDisplay.slider # Slider widget
io.NumberDisplay.color # Color picker widget
# Folder types
io.FolderType.input # Input folder
io.FolderType.output # Output folder
io.FolderType.temp # Temp folder
# Upload types
io.UploadType.image
io.UploadType.audio
io.UploadType.video
io.UploadType.model
```
+17
View File
@@ -9,6 +9,7 @@ on:
permissions:
issues: write
contents: write
jobs:
publish-node:
@@ -18,6 +19,22 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Extract version from pyproject.toml
id: version
run: |
VERSION=$(grep -E '^\s*version\s*=' pyproject.toml | head -1 | sed -E 's/.*version\s*=\s*"([^"]+)".*/\1/')
if [ -z "$VERSION" ]; then
echo "ERROR: Could not extract version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
generate_release_notes: true
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
+4 -1
View File
@@ -11,10 +11,13 @@ web_beta/**
web_version/dev/**
docs/**
.vscode/
.vs/
.idea/
.claude/**
mmb-preset.custom.txt
config.yaml
node.tar.gz
.codex
.cursorrules
tools/ComfyUI-Easy-Use.json
tools/ComfyUI-Easy-Use.json
+30
View File
@@ -39,6 +39,7 @@
- 支持 kolors 模型
- 支持 flux 模型
- 支持 惰性条件判断(ifElse)和 for循环
- 支持 Anima 与 Krea2 diffusion 模型,可通过 `easy diffusionModelLoader` 加载(需显式选择文本编码器与 VAE),并使用 `easy XYInputs: DiffusionModel` 进行 XY 对比
## 👨🏻‍🔧 安装
@@ -52,6 +53,35 @@ git clone https://github.com/yolain/ComfyUI-Easy-Use
## 📜 更新日志
**v1.4.1**
- 修复 `easy saveText` 将文本输出限制在输出目录 #1032
**v1.4.0**
- 添加 `easy tableEditor` 节点 - 用于编辑和显示表格数据的节点
- 修复 `easy showAnything` 在最新版 ComfyUI 前端无法工作的问题
- 修复 `easy multiAnglePrompt` 设置保存失败的问题
- 修复 `easy detailer` 在子图中无法工作的问题
- 修复新版 ComfyUI 前端中"刷新节点"功能连接线丢失的问题
- 使用原生 `VAEDecodeTiled` 进行分块解码(支持 Qwen Image VAE)
- 修复 `easy forLoopStart` - 允许 `total=0` 以防止不必要的循环执行
- 修复 `easy preSampling` - `samplerCustomSettings.ip2p` 中 `vae`/`pixels` 现在是可选的
- 修复 `easy pixart` ControlNet 包装器 - 使用 `pe_interpolation` 替代已移除的 `lewei_scale`
- 修复 `easy promptConcat` - 当输入为列表时的 TypeError 问题
- 使用专用 RNG 进行全局种子生成
- 修复 `easy imageDetailTransfer` - 多帧蒙版在通道广播时崩溃
- 修复 Windows 环境下 PrimeVue 对话框遮罩未清除的问题
- 修复 `LockedMeta` 对象 TypeError(`object of type 'LockedMeta' has no len()`)
- 增强 `easy simpleMath` `evaluate_formula` 以处理列表输入
- 修复 `loraStack`/`controlnetStack` - 禁用时不再清除上游堆栈
- 修复 XYPlot 在 `Seeds++ Batch` 和元组 X/Y 输入时崩溃的问题
- 回滚循环节点到 v1 版本
- 修复 `easy NodesMap` - 避免递归组引用
- 修复 `easy CleanVRAM` 清理顺序并正确清除 Easy-Use 缓存
- 指定文件读取的 UTF-8 编码
- 移除不必要的 print 语句
**v1.3.6**
- 恢复 `easy showAnything` 对于列表类型的支持(但一些情况下展示庞大数据时仍会导致ComfyUI崩溃)
+49 -18
View File
@@ -12,29 +12,30 @@
**ComfyUI-Easy-Use** is an efficiency custom nodes integration package, which is extended on the basis of [TinyTerraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes). It has been integrated and optimized for many popular awesome custom nodes to achieve the purpose of faster and more convenient use of ComfyUI. While ensuring the degree of freedom, it restores the ultimate smooth image production experience that belongs to Stable Diffusion.
## 👨🏻‍🎨 Introduce
## 👨🏻‍🎨 Introduction
- Inspire by [tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes), which greatly reduces the time cost of tossing workflows。
- Inspired by [tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes), which greatly reduces the time cost of tossing workflows。
- UI interface beautification, the first time you install the user, if you need to use the UI theme, please switch the theme in Settings -> Color Palette and refresh page.
- Added a node for pre-sampling parameter configuration, which can be separated from the sampling node for easier previewing
- Wildcards and lora's are supported, for Lora Block Weight usage, ensure that the custom node package has the [ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack)
- Multi-selectable styled cue word selector, default is Fooocus style json, custom json can be placed under styles, samples folder can be placed in the preview image (name and name consistent, image file name such as spaces need to be converted to underscores '_')
- Added a node for pre-sampling parameter configuration, which can be separated from the sampling node for easier previewing.
- Wildcards and lora's are supported, for Lora Block Weight usage, ensure that the custom node package has the [ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack).
- Multi-selectable styled cue word selector, default is Fooocus style json, custom json can be placed under styles, samples folder can be placed in the preview image (name and name consistent, image file name such as spaces need to be converted to underscores '_').
- The loader enables the A1111 prompt mode, which reproduces nearly identical images to those generated by webui.
- Noise injection into the latent space can be achieved using the `easy latentNoisy` or `easy preSamplingNoiseIn` node
- Simplified processes for SD1.x, SD2.x, SDXL, SVD, Zero123, etc. [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableDiffusion)
- Simplified Stable Cascade [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableCascade)
- Noise injection into the latent space can be achieved using the `easy latentNoisy` or `easy preSamplingNoiseIn` node.
- Simplified processes for SD1.x, SD2.x, SDXL, SVD, Zero123, etc. [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableDiffusion).
- Simplified Stable Cascade [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableCascade).
- Simplified Layer Diffuse [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#LayerDiffusion),The first time you use it you may need to run `pip install -r requirements.txt` to install the required dependencies.
- Simplified InstantID [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#InstantID), You need to make sure that the custom node package has the [ComfyUI_InstantID](https://github.com/cubiq/ComfyUI_InstantID)
- Extending the usability of XYplot
- Fooocus Inpaint integration
- Simplified InstantID [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#InstantID), You need to make sure that the custom node package has the [ComfyUI_InstantID](https://github.com/cubiq/ComfyUI_InstantID).
- Extending the usability of XYplot.
- Fooocus Inpaint integration.
- Integration of common logical calculations, conversion of types, display of all types, etc.
- Background removal nodes for the RMBG-1.4 model supporting BriaAI, [BriaAI Guide](https://huggingface.co/briaai/RMBG-1.4)
- Forcibly cleared the memory usage of the comfy UI model are supported
- Stable Diffusion 3 multi-account API nodes are supported
- Support SD3's model
- Support Kolors‘s model
- Support Flux's model
- Support lazy if else and for loops
- Background removal nodes for the RMBG-1.4 model supporting BriaAI, [BriaAI Guide](https://huggingface.co/briaai/RMBG-1.4).
- Forcibly cleared the memory usage of the comfy UI model are supported.
- Stable Diffusion 3 multi-account API nodes are supported.
- Support SD3's model.
- Support Kolors‘s model.
- Support Flux's model.
- Support lazy if else and for loops.
- Support Anima and Krea2 diffusion models with `easy diffusionModelLoader` and `easy XYInputs: DiffusionModel`. The loader requires an explicit text encoder and VAE selection.
## 👨🏻‍🔧 Installation
Clone the repo into the **custom_nodes** directory and install the requirements:
@@ -47,6 +48,35 @@ Double-click install.bat to install the required dependencies
## 📜 Changelog
**v1.4.1**
- Fix `easy saveText` to confine text outputs to output directory #1032
**v1.4.0**
- Add `easy tableEditor` node - A node for editing and displaying table data
- Fix `easy showAnything` not working on latest ComfyUI frontend
- Fix `easy multiAnglePrompt` settings failed to save
- Fix `easy detailer` not working in subgraphs
- Fix connection lines missing from "Refresh Nodes" feature in new ComfyUI frontend
- Use native `VAEDecodeTiled` for tiled decode (Qwen Image VAE support)
- Fix `easy forLoopStart` - allow `total=0` to prevent unnecessary loop execution
- Fix `easy preSampling` - `vae`/`pixels` now optional on `samplerCustomSettings.ip2p`
- Fix `easy pixart` ControlNet wrappers - use `pe_interpolation` instead of removed `lewei_scale`
- Fix `easy promptConcat` - TypeError when an input is a list
- Use a dedicated RNG for global seed generation
- Fix `easy imageDetailTransfer` - multi-frame masks crash on channel broadcast
- Fix PrimeVue dialog overlay not being cleared in Windows environment
- Fix `LockedMeta` object TypeError (`object of type 'LockedMeta' has no len()`)
- Enhance `easy simpleMath` `evaluate_formula` to handle list inputs
- Fix `loraStack`/`controlnetStack` - disable no longer erases upstream stack
- Fix XYPlot crashes in `Seeds++ Batch` and tuple X/Y inputs
- Rollback cycle node to version v1
- Fix `easy NodesMap` - avoid recursive group refs
- Fix `easy CleanVRAM` teardown ordering and clear Easy-Use cache properly
- Specify UTF-8 encoding for file reads
- Remove unnecessary print statements
**v1.3.6**
- Restored `easy showAnything` support for list types (but displaying large data in some cases may still cause ComfyUI to crash)
@@ -553,3 +583,4 @@ If my custom nodes has added value to your day, consider indulging in a coffee t
My gratitude extends to the generous souls who bestow a star. Your support is much appreciated!
[![Stargazers repo roster for @yolain/ComfyUI-Easy-Use](https://reporoster.com/stars/yolain/ComfyUI-Easy-Use)](https://github.com/yolain/ComfyUI-Easy-Use/stargazers)
+13 -1
View File
@@ -1,4 +1,4 @@
__version__ = "1.3.6"
__version__ = "1.4.1"
import yaml
import json
@@ -12,6 +12,18 @@ comfy_path = folder_paths.base_path
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
try:
import comfy.supported_models as _supported_models
_HAS_DIFFUSION_XY_SUPPORT = (
hasattr(_supported_models, "Anima")
and hasattr(_supported_models, "Krea2")
)
except Exception:
_HAS_DIFFUSION_XY_SUPPORT = False
if not _HAS_DIFFUSION_XY_SUPPORT:
print("[ComfyUI-Easy-Use] Anima/Krea2 XY nodes need comfy.supported_models.Anima and Krea2")
importlib.import_module('.py.routes', __name__)
importlib.import_module('.py.server', __name__)
nodes_list = ["util", "seed", "prompt", "loaders", "adapter", "inpaint", "preSampling", "samplers", "fix", "pipe", "xyplot", "image", "logic", "api", "deprecated"]
+186
View File
@@ -1202,6 +1202,173 @@
}
}
},
"easy diffusionModelLoader": {
"display_name": "EasyDiffusionModelLoader",
"inputs": {
"model_name": {
"name": "model_name"
},
"vae_name": {
"name": "vae_name"
},
"clip_name": {
"name": "clip_name"
},
"resolution": {
"name": "resolution"
},
"empty_latent_width": {
"name": "empty_latent_width"
},
"empty_latent_height": {
"name": "empty_latent_height"
},
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"batch_size": {
"name": "batch_size"
},
"model_override": {
"name": "model_override"
},
"clip_override": {
"name": "clip_override"
},
"vae_override": {
"name": "vae_override"
}
},
"outputs": {
"0": {
"name": "pipe"
},
"1": {
"name": "model"
},
"2": {
"name": "vae"
},
"3": {
"name": "clip"
},
"4": {
"name": "positive"
},
"5": {
"name": "negative"
},
"6": {
"name": "latent"
}
}
},
"easy XYInputs: DiffusionModel": {
"display_name": "XY Inputs: Diffusion Model //EasyUse",
"inputs": {
"model_count": {
"name": "model_count"
},
"model_name_1": {
"name": "model_name_1"
},
"clip_name_1": {
"name": "clip_name_1"
},
"vae_name_1": {
"name": "vae_name_1"
},
"model_name_2": {
"name": "model_name_2"
},
"clip_name_2": {
"name": "clip_name_2"
},
"vae_name_2": {
"name": "vae_name_2"
},
"model_name_3": {
"name": "model_name_3"
},
"clip_name_3": {
"name": "clip_name_3"
},
"vae_name_3": {
"name": "vae_name_3"
},
"model_name_4": {
"name": "model_name_4"
},
"clip_name_4": {
"name": "clip_name_4"
},
"vae_name_4": {
"name": "vae_name_4"
},
"model_name_5": {
"name": "model_name_5"
},
"clip_name_5": {
"name": "clip_name_5"
},
"vae_name_5": {
"name": "vae_name_5"
},
"model_name_6": {
"name": "model_name_6"
},
"clip_name_6": {
"name": "clip_name_6"
},
"vae_name_6": {
"name": "vae_name_6"
},
"model_name_7": {
"name": "model_name_7"
},
"clip_name_7": {
"name": "clip_name_7"
},
"vae_name_7": {
"name": "vae_name_7"
},
"model_name_8": {
"name": "model_name_8"
},
"clip_name_8": {
"name": "clip_name_8"
},
"vae_name_8": {
"name": "vae_name_8"
},
"model_name_9": {
"name": "model_name_9"
},
"clip_name_9": {
"name": "clip_name_9"
},
"vae_name_9": {
"name": "vae_name_9"
},
"model_name_10": {
"name": "model_name_10"
},
"clip_name_10": {
"name": "clip_name_10"
},
"vae_name_10": {
"name": "vae_name_10"
}
},
"outputs": {
"0": {
"name": "X or Y"
}
}
},
"easy loraStack": {
"display_name": "EasyLoraStack",
"inputs": {
@@ -6271,6 +6438,25 @@
}
}
},
"easy PassOrNone": {
"display_name": "Pass or None",
"inputs": {
"any": {
"name": "anything"
},
"default": {
"name": "default"
}
},
"outputs": {
"0": {
"name": "output"
},
"1": {
"name": "is_none"
}
}
},
"easy isNone": {
"display_name": "Is None",
"inputs": {
+204 -1
View File
@@ -904,6 +904,173 @@
}
}
},
"easy diffusionModelLoader": {
"display_name": "简易加载器(扩散模型)",
"inputs": {
"model_name": {
"name": "扩散模型"
},
"vae_name": {
"name": "VAE"
},
"clip_name": {
"name": "文本编码器"
},
"resolution": {
"name": "分辨率"
},
"empty_latent_width": {
"name": "宽度"
},
"empty_latent_height": {
"name": "高度"
},
"positive": {
"name": "正面提示词"
},
"negative": {
"name": "负面提示词"
},
"batch_size": {
"name": "批次大小"
},
"model_override": {
"name": "模型(可选)"
},
"clip_override": {
"name": "CLIP(可选)"
},
"vae_override": {
"name": "VAE(可选)"
}
},
"outputs": {
"0": {
"name": "节点束"
},
"1": {
"name": "模型"
},
"2": {
"name": "VAE"
},
"3": {
"name": "CLIP"
},
"4": {
"name": "正面提示词"
},
"5": {
"name": "负面提示词"
},
"6": {
"name": "潜空间"
}
}
},
"easy XYInputs: DiffusionModel": {
"display_name": "XY输入: Diffusion Model",
"inputs": {
"model_count": {
"name": "模型数量"
},
"model_name_1": {
"name": "扩散模型1"
},
"clip_name_1": {
"name": "文本编码器1"
},
"vae_name_1": {
"name": "VAE1"
},
"model_name_2": {
"name": "扩散模型2"
},
"clip_name_2": {
"name": "文本编码器2"
},
"vae_name_2": {
"name": "VAE2"
},
"model_name_3": {
"name": "扩散模型3"
},
"clip_name_3": {
"name": "文本编码器3"
},
"vae_name_3": {
"name": "VAE3"
},
"model_name_4": {
"name": "扩散模型4"
},
"clip_name_4": {
"name": "文本编码器4"
},
"vae_name_4": {
"name": "VAE4"
},
"model_name_5": {
"name": "扩散模型5"
},
"clip_name_5": {
"name": "文本编码器5"
},
"vae_name_5": {
"name": "VAE5"
},
"model_name_6": {
"name": "扩散模型6"
},
"clip_name_6": {
"name": "文本编码器6"
},
"vae_name_6": {
"name": "VAE6"
},
"model_name_7": {
"name": "扩散模型7"
},
"clip_name_7": {
"name": "文本编码器7"
},
"vae_name_7": {
"name": "VAE7"
},
"model_name_8": {
"name": "扩散模型8"
},
"clip_name_8": {
"name": "文本编码器8"
},
"vae_name_8": {
"name": "VAE8"
},
"model_name_9": {
"name": "扩散模型9"
},
"clip_name_9": {
"name": "文本编码器9"
},
"vae_name_9": {
"name": "VAE9"
},
"model_name_10": {
"name": "扩散模型10"
},
"clip_name_10": {
"name": "文本编码器10"
},
"vae_name_10": {
"name": "VAE10"
}
},
"outputs": {
"0": {
"name": "X或Y"
}
}
},
"easy zero123Loader": {
"display_name": "简易加载器(Zero123)",
"inputs": {
@@ -6088,7 +6255,7 @@
"display_name": "ckpt名称列表",
"inputs": {
"ckpt_name": {
"name": "模型名称"
"name": "模型名称"
}
},
"outputs": {
@@ -6110,6 +6277,23 @@
}
}
},
"easy tableEditor": {
"display_name": "表格编辑器",
"description": "通过可视化表格或 Markdown 语法编辑数据,输出 Markdown 格式的表格字符串及渲染图像。",
"inputs": {
"table_data": {
"name": "表格数据"
}
},
"outputs": {
"0": {
"name": "Markdown"
},
"1": {
"name": "图像"
}
}
},
"easy string": {
"display_name": "字符串",
"inputs": {
@@ -6304,6 +6488,25 @@
}
}
},
"easy PassOrNone": {
"display_name": "传递或为空",
"inputs": {
"any": {
"name": "任何"
},
"default": {
"name": "默认值"
}
},
"outputs": {
"0": {
"name": "输出"
},
"1": {
"name": "是否为空"
}
}
},
"easy isNone": {
"display_name": "是否为空",
"inputs": {
+18
View File
@@ -405,3 +405,21 @@ PROMPT_TEMPLATE = {
}
NEW_SCHEDULERS = ['align_your_steps', 'gits']
DIFFUSION_MODEL_XY_DEFAULTS = {
"anima": {
"clip_name": "qwen_3_06b_base.safetensors",
"clip_type": "anima",
"vae_name": "qwen_image_vae.safetensors",
},
"krea2": {
"clip_name": "Huihui-Qwen3-VL-4B-Instruct-abliterated-fp8_scaled.safetensors",
"clip_type": "krea2",
"vae_name": "qwen_image_vae.safetensors",
},
}
DIFFUSION_MODEL_CLIP_TYPES = {
"anima": "anima",
"krea2": "krea2",
}
+4 -3
View File
@@ -77,10 +77,11 @@ def update_cache(k, tag, v):
else:
cache_count[k] += 1
def remove_cache(key):
global cache
if key == '*':
cache = TaggedCache(cache_settings)
cache.clear()
cache_count.clear()
elif key in cache:
del cache[key]
cache_count.pop(key, None)
else:
print(f"invalid {key}")
print(f"invalid {key}")
+1 -1
View File
@@ -14,7 +14,7 @@ def prompt_to_cond(type, model, clip, clip_skip, lora_stack, text, prompt_token_
if model_type not in ['hydit'] and text is not None and has_chinese(text):
text = zh_to_en([text])[0]
if model_type in ['hydit', 'flux', 'mochi']:
if model_type in ['hydit', 'flux', 'mochi', 'anima', 'krea2']:
log_node_warn(title + "...")
embeddings_final, = CLIPTextEncode().encode(clip, text) if text is not None else (None,)
+83 -3
View File
@@ -8,6 +8,8 @@ from comfy.model_patcher import ModelPatcher
from nodes import NODE_CLASS_MAPPINGS
from collections import defaultdict
from .log import log_node_info, log_node_error
from .utils import get_sd_version
from ..config import DIFFUSION_MODEL_XY_DEFAULTS, DIFFUSION_MODEL_CLIP_TYPES
from ..modules.dit.pixArt.loader import load_pixart
diffusion_loaders = ["easy fullLoader", "easy a1111Loader", "easy fluxLoader", "easy comfyLoader", "easy hunyuanDiTLoader", "easy zero123Loader", "easy svdLoader"]
@@ -145,6 +147,28 @@ class easyLoader:
scale_soft_weights = self.get_input_value(entry, "cn_soft_weights")
desired_controlnet_names.add(f'{control_net_name};{scale_soft_weights}')
elif class_type == "easy diffusionModelLoader":
desired_unet_names.add(self.get_input_value(entry, "model_name", prompt))
clip_name = self.get_input_value(entry, "clip_name", prompt)
vae_name = self.get_input_value(entry, "vae_name", prompt)
if clip_name not in ("None", "Auto"):
desired_clip_names.add(clip_name)
if vae_name not in ("None", "Auto"):
desired_vae_names.add(vae_name)
elif class_type == "easy XYInputs: DiffusionModel":
model_count = int(self.get_input_value(entry, "model_count", prompt) or 0)
for i in range(1, model_count + 1):
model_name = self.get_input_value(entry, f"model_name_{i}", prompt)
if model_name and model_name != "None":
desired_unet_names.add(model_name)
clip_name = self.get_input_value(entry, f"clip_name_{i}", prompt)
if clip_name not in ("None", "Auto"):
desired_clip_names.add(clip_name)
vae_name = self.get_input_value(entry, f"vae_name_{i}", prompt)
if vae_name not in ("None", "Auto"):
desired_vae_names.add(vae_name)
elif class_type in model_merge_node:
desired_ckpt_names.add(self.get_input_value(entry, "ckpt_name_1"))
desired_ckpt_names.add(self.get_input_value(entry, "ckpt_name_2"))
@@ -282,6 +306,57 @@ class easyLoader:
return model
def load_diffusion_model(self, model_name):
if model_name in self.loaded_objects["unet"]:
log_node_info("Load Diffusion Model", f"{model_name} cached")
return self.loaded_objects["unet"][model_name][0]
model_path = folder_paths.get_full_path("diffusion_models", model_name)
if not model_path:
raise FileNotFoundError(f"[EasyUse] diffusion model not found: {model_name}")
model = comfy.sd.load_diffusion_model(model_path)
self.add_to_cache("unet", model_name, model)
self.eviction_based_on_memory()
return model
def load_diffusion_xy_model(self, model_name, clip_name, vae_name):
model = self.load_diffusion_model(model_name)
family = get_sd_version(model)
defaults = DIFFUSION_MODEL_XY_DEFAULTS.get(family)
if defaults is None:
raise RuntimeError(f"[EasyUse] unsupported diffusion model family: {family}")
if clip_name in ("Auto", None):
clip_name = defaults["clip_name"]
if vae_name in ("Auto", None):
vae_name = defaults["vae_name"]
clip = self.load_clip(clip_name, type=defaults["clip_type"])
vae = self.load_vae(vae_name)
return model, clip, vae, family
def load_diffusion_model_required(self, model_name, clip_name, vae_name):
if clip_name in ("None", None):
raise RuntimeError("[EasyUse] clip_name is required: please select a text encoder")
if vae_name in ("None", None):
raise RuntimeError("[EasyUse] vae_name is required: please select a VAE")
model = self.load_diffusion_model(model_name)
family = get_sd_version(model)
clip_type = DIFFUSION_MODEL_CLIP_TYPES.get(family)
if clip_type is None:
raise RuntimeError(f"[EasyUse] unsupported diffusion model family: {family}")
clip = self.load_clip(clip_name, type=clip_type)
vae = self.load_vae(vae_name)
return model, clip, vae, family
def load_controlnet(self, control_net_name, scale_soft_weights=1, use_cache=True):
unique_id = f'{control_net_name};{str(scale_soft_weights)}'
if use_cache and unique_id in self.loaded_objects["controlnet"]:
@@ -303,8 +378,9 @@ class easyLoader:
return control_net
def load_clip(self, clip_name, type='stable_diffusion', load_clip=None):
if clip_name in self.loaded_objects["clip"]:
return self.loaded_objects["clip"][clip_name][0]
cache_key = f"{clip_name}::{type}"
if cache_key in self.loaded_objects["clip"]:
return self.loaded_objects["clip"][cache_key][0]
if type == 'stable_diffusion':
clip_type = comfy.sd.CLIPType.STABLE_DIFFUSION
@@ -316,9 +392,13 @@ class easyLoader:
clip_type = comfy.sd.CLIPType.FLUX
elif type == 'stable_audio':
clip_type = comfy.sd.CLIPType.STABLE_AUDIO
elif type == 'krea2':
clip_type = comfy.sd.CLIPType.KREA2
elif type == 'anima':
clip_type = comfy.sd.CLIPType.STABLE_DIFFUSION
clip_path = folder_paths.get_full_path("clip", clip_name)
load_clip = comfy.sd.load_clip(ckpt_paths=[clip_path], embedding_directory=folder_paths.get_folder_paths("embeddings"), clip_type=clip_type)
self.add_to_cache("clip", clip_name, load_clip)
self.add_to_cache("clip", cache_key, load_clip)
self.eviction_based_on_memory()
return load_clip
+29 -14
View File
@@ -4,7 +4,7 @@ Math utility functions for formula evaluation
import math
import re
def evaluate_formula(formula: str, a=0, b=0, c=0, d=0) -> float:
def evaluate_formula(formula: str, a=0, b=0, c=0, d=0):
"""
计算字符串数学公式
@@ -23,7 +23,7 @@ def evaluate_formula(formula: str, a=0, b=0, c=0, d=0) -> float:
d: 变量d的值
Returns:
计算结果
如果任意输入为list则返回list[float],否则返回float
Examples:
>>> evaluate_formula("a + b", 1, 2)
@@ -60,19 +60,34 @@ def evaluate_formula(formula: str, a=0, b=0, c=0, d=0) -> float:
# 常量
'pi': math.pi,
'e': math.e,
# 变量
'a': float(a),
'b': float(b),
'c': float(c),
'd': float(d),
}
try:
# 使用eval计算公式,限制可用的函数和变量
result = eval(formula, {"__builtins__": {}}, safe_dict)
return float(result)
except Exception as e:
raise ValueError(f"公式计算错误: {str(e)}")
# 判断是否有 list 输入
list_inputs = {k: v for k, v in {'a': a, 'b': b, 'c': c, 'd': d}.items() if isinstance(v, (list, tuple))}
scalar_inputs = {k: v for k, v in {'a': a, 'b': b, 'c': c, 'd': d}.items() if not isinstance(v, (list, tuple))}
def _eval_single(vals: dict) -> float:
env = dict(safe_dict)
env.update({k: float(v) for k, v in vals.items()})
try:
result = eval(formula, {"__builtins__": {}}, env)
return float(result)
except Exception as e:
raise ValueError(f"公式计算错误: {str(e)}")
if not list_inputs:
# 全是标量
return _eval_single({k: v for k, v in {'a': a, 'b': b, 'c': c, 'd': d}.items()})
# 有 list 输入,逐元素计算
max_len = max(len(v) for v in list_inputs.values())
results = []
for i in range(max_len):
vals = {k: float(v) for k, v in scalar_inputs.items()}
for k, v in list_inputs.items():
vals[k] = float(v[i] if i < len(v) else v[-1])
results.append(_eval_single(vals))
return results
def ceil_value(value: float) -> int:
+29
View File
@@ -0,0 +1,29 @@
import os
def resolve_output_file_path(output_root, output_file_path, file_name, file_extension):
"""Resolve a workflow-provided output path beneath ``output_root``.
Relative output directories remain supported, but are interpreted relative
to ComfyUI's configured output directory rather than the process working
directory. Resolving both paths prevents ``..`` components and existing
symlinks from escaping the allowed root.
"""
output_root = os.path.realpath(output_root)
requested_directory = output_file_path
if not os.path.isabs(requested_directory):
requested_directory = os.path.join(output_root, requested_directory)
candidate = os.path.realpath(
os.path.join(requested_directory, f"{file_name}.{file_extension}")
)
try:
is_within_output = os.path.commonpath((output_root, candidate)) == output_root
except ValueError:
# Different Windows drives and paths containing null bytes are unsafe.
is_within_output = False
if not is_within_output:
raise ValueError("Saving outside the ComfyUI output directory is not allowed")
return candidate
+5 -2
View File
@@ -65,6 +65,9 @@ class easySampler:
elif model_type == 'mochi':
latent = torch.zeros([batch_size, 12, ((video_length - 1) // 6) + 1, empty_latent_height // 8, empty_latent_width // 8], device=self.device)
samples = {"samples": latent}
elif model_type in ("anima", "krea2"):
latent = torch.zeros([batch_size, 16, 1, empty_latent_height // 8, empty_latent_width // 8], device=self.device)
samples = {"samples": latent}
elif compression == 0:
latent = torch.zeros([batch_size, 4, empty_latent_height // 8, empty_latent_width // 8], device=self.device)
samples = {"samples": latent}
@@ -84,7 +87,7 @@ class easySampler:
"""
latent_size = latent_image.size()
latent_size_1batch = [1, latent_size[1], latent_size[2], latent_size[3]]
latent_size_1batch = [1] + list(latent_size[1:])
if variation_strength is not None and variation_strength > 0 or incremental_seed_mode.startswith(
"variation str inc"):
@@ -108,7 +111,7 @@ class easySampler:
if strength_up is not None:
strength += strength_up
variation_noise = variation_latent.expand(input_latent.size()[0], -1, -1, -1)
variation_noise = variation_latent.expand(input_latent.size()[0], *([-1] * (variation_latent.dim() - 1)))
result = (1 - strength) * input_latent + strength * variation_noise
return result
+22
View File
@@ -35,9 +35,13 @@ import sys
import importlib.util
import importlib.metadata
import comfy.model_management as mm
import logging
import gc
from packaging import version
from server import PromptServer
LOG = logging.getLogger(__name__)
def is_package_installed(package):
try:
module = importlib.util.find_spec(package)
@@ -124,6 +128,10 @@ def get_sd_version(model):
return 'flux'
elif isinstance(model_config, comfy.supported_models.GenmoMochi):
return 'mochi'
elif isinstance(model_config, comfy.supported_models.Anima):
return 'anima'
elif isinstance(model_config, comfy.supported_models.Krea2):
return 'krea2'
else:
return 'unknown'
@@ -277,6 +285,20 @@ def getMetadata(filepath):
return header
def cleanGPUUsedForce():
from .cache import remove_cache
remove_cache("*")
gc.collect()
try:
import torch
except (ImportError, OSError, RuntimeError) as exc:
LOG.debug("Skipping CUDA synchronize during cleanGPUUsedForce: torch import failed: %s", exc)
else:
try:
if torch.cuda.is_available():
torch.cuda.synchronize()
except (AttributeError, OSError, RuntimeError) as exc:
LOG.debug("Skipping CUDA synchronize during cleanGPUUsedForce: %s", exc)
mm.unload_all_models()
mm.soft_empty_cache()
+2 -2
View File
@@ -47,7 +47,7 @@ def read_wildcard_dict(wildcard_path):
easy_wildcard_dict[key] = lines
elif file.endswith('.yaml'):
file_path = os.path.join(root, file)
with open(file_path, 'r') as f:
with open(file_path, 'r', encoding="utf-8") as f:
yaml_data = yaml.load(f, Loader=yaml.FullLoader)
for k, v in yaml_data.items():
@@ -55,7 +55,7 @@ def read_wildcard_dict(wildcard_path):
elif file.endswith('.json'):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r') as f:
with open(file_path, 'r', encoding="utf-8") as f:
json_data = json.load(f)
for key, value in json_data.items():
key = wildcard_normalize(key)
+100 -26
View File
@@ -63,10 +63,14 @@ class easyXYPlot():
if value_type in ['Lora', 'Checkpoint']:
arr = value.split(',')
model_name = os.path.basename(os.path.splitext(arr[0])[0])
trigger_words = ' ' + arr[3] if value_type == 'Lora' and len(arr[3]) > 2 else ''
trigger_words = ' ' + arr[3] if value_type == 'Lora' and len(arr) > 3 and len(arr[3]) > 2 else ''
lora_weight = float(arr[1]) if value_type == 'Lora' and len(arr) > 1 else 0
lora_weight_desc = f"({lora_weight:.2f})" if lora_weight > 0 else ''
value_label = f"{model_name[:30]}{lora_weight_desc} {trigger_words}"
lora_weight_desc = f" w:{lora_weight:.2f}" if value_type == 'Lora' and lora_weight != 1.0 else ''
value_label = f"{model_name[:25]}{lora_weight_desc}{trigger_words}"
if value_type == "DiffusionModel":
model_name = os.path.basename(os.path.splitext(value.split(",")[0])[0])
value_label = model_name[:25]
if value_type in ["ModelMergeBlocks"]:
if ":" in value:
@@ -98,6 +102,32 @@ class easyXYPlot():
return plot_image_vars, value_label
@staticmethod
def _ensure_latent_for_model(model, vae, samples, plot_image_vars):
fmt = model.model.latent_format
x = samples["samples"]
expected_ndim = 2 + fmt.latent_dimensions
if x.ndim == expected_ndim and x.shape[1] == fmt.latent_channels:
return samples
if fmt.latent_dimensions == 3 and x.ndim == 4:
if x.count_nonzero() == 0:
x = torch.zeros(
[x.shape[0], fmt.latent_channels, 1, x.shape[2], x.shape[3]],
dtype=x.dtype, device=x.device)
elif plot_image_vars.get("images") is not None:
x = vae.encode(plot_image_vars["images"][..., :3])
else:
raise RuntimeError(
"Switching to a 3D-latent model requires an input image "
"or an empty latent"
)
return {**samples, "samples": x}
return samples
@staticmethod
def get_font(font_size, font_path=None):
if font_path is None:
@@ -362,14 +392,46 @@ class easyXYPlot():
if "negative_cond" in plot_image_vars:
negative = negative + plot_image_vars["negative_cond"]
# DiffusionModel
if self.x_type == "DiffusionModel" or self.y_type == "DiffusionModel":
xy_values = x_value if self.x_type == "DiffusionModel" else y_value
model_name, clip_name, vae_name = xy_values.split(",")
model, clip, vae, family = self.easyCache.load_diffusion_xy_model(
model_name.replace("*", ","),
clip_name.replace("*", ","),
vae_name.replace("*", ","),
)
sd_version = family
positive = plot_image_vars["positive"]
negative = plot_image_vars["negative"]
if positive is not None:
positive, = CLIPTextEncode().encode(clip, positive)
if negative is not None:
negative, = CLIPTextEncode().encode(clip, negative)
samples = self._ensure_latent_for_model(
model, vae, samples, plot_image_vars
)
# Lora
if self.x_type == "Lora" or self.y_type == "Lora":
# print(f"Lora: {x_value} {y_value}")
model = model if model is not None else plot_image_vars["model"]
clip = clip if clip is not None else plot_image_vars["clip"]
xy_values = x_value if self.x_type == "Lora" else y_value
lora_name, lora_model_strength, lora_clip_strength, _ = xy_values.split(",")
lora_stack = [{"lora_name": lora_name, "model": model, "clip" :clip, "model_strength": float(lora_model_strength), "clip_strength": float(lora_clip_strength)}]
# Build lora_stack from both X and Y axes if both are LoRA types
lora_stack = []
# Add X axis LoRA if present
if self.x_type == "Lora":
lora_name, lora_model_strength, lora_clip_strength, _ = x_value.split(",")
lora_stack.append({"lora_name": lora_name, "model": model, "clip": clip, "model_strength": float(lora_model_strength), "clip_strength": float(lora_clip_strength)})
# Add Y axis LoRA if present
if self.y_type == "Lora":
lora_name, lora_model_strength, lora_clip_strength, _ = y_value.split(",")
lora_stack.append({"lora_name": lora_name, "model": model, "clip": clip, "model_strength": float(lora_model_strength), "clip_strength": float(lora_clip_strength)})
# print(f"new_lora_stack: {new_lora_stack}")
@@ -389,7 +451,7 @@ class easyXYPlot():
if self.x_type == 'Positive Prompt S/R' or self.y_type == 'Positive Prompt S/R':
positive = x_value if self.x_type == "Positive Prompt S/R" else y_value
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
positive, = CLIPTextEncode().encode(clip, positive)
else:
positive = advanced_encode(clip, positive,
@@ -405,7 +467,7 @@ class easyXYPlot():
if self.x_type == 'Negative Prompt S/R' or self.y_type == 'Negative Prompt S/R':
negative = x_value if self.x_type == "Negative Prompt S/R" else y_value
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
negative, = CLIPTextEncode().encode(clip, negative)
else:
negative = advanced_encode(clip, negative,
@@ -473,7 +535,7 @@ class easyXYPlot():
clip = clip.clone()
clip.clip_layer(plot_image_vars['clip_skip'])
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
positive, = CLIPTextEncode().encode(clip, positive)
else:
positive = advanced_encode(clip, plot_image_vars['positive'],
@@ -481,7 +543,7 @@ class easyXYPlot():
plot_image_vars['positive_weight_interpretation'], w_max=1.0,
apply_to_pooled="enable",a1111_prompt_style=a1111_prompt_style, steps=steps)
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
negative, = CLIPTextEncode().encode(clip, negative)
else:
negative = advanced_encode(clip, plot_image_vars['negative'],
@@ -577,28 +639,40 @@ class easyXYPlot():
def get_labels_and_sample(self, plot_image_vars, latent_image, preview_latent, start_step, last_step,
force_full_denoise, disable_noise):
for x_index, x_value in enumerate(self.x_values):
plot_image_vars, x_value_label = self.define_variable(plot_image_vars, self.x_type, x_value,
x_index)
self.x_label = self.update_label(self.x_label, x_value_label, len(self.x_values))
if self.y_type != 'None':
# Handle X-only variation (Y is "None")
if self.y_type == 'None':
for x_index, x_value in enumerate(self.x_values):
plot_image_vars, x_value_label = self.define_variable(plot_image_vars, self.x_type, x_value, x_index)
self.x_label = self.update_label(self.x_label, x_value_label, len(self.x_values))
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list,
disable_noise, start_step, last_step, force_full_denoise, x_value)
self.num += 1
# Handle Y-only variation (X is "None")
elif self.x_type == 'None':
for y_index, y_value in enumerate(self.y_values):
plot_image_vars, y_value_label = self.define_variable(plot_image_vars, self.y_type, y_value, y_index)
self.y_label = self.update_label(self.y_label, y_value_label, len(self.y_values))
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list,
disable_noise, start_step, last_step, force_full_denoise, y_value=y_value)
self.num += 1
# Handle both X and Y variation
else:
for x_index, x_value in enumerate(self.x_values):
plot_image_vars, x_value_label = self.define_variable(plot_image_vars, self.x_type, x_value, x_index)
self.x_label = self.update_label(self.x_label, x_value_label, len(self.x_values))
for y_index, y_value in enumerate(self.y_values):
plot_image_vars, y_value_label = self.define_variable(plot_image_vars, self.y_type, y_value,
y_index)
plot_image_vars, y_value_label = self.define_variable(plot_image_vars, self.y_type, y_value, y_index)
self.y_label = self.update_label(self.y_label, y_value_label, len(self.y_values))
# ttNl(f'{CC.GREY}X: {x_value_label}, Y: {y_value_label}').t(
# f'Plot Values {self.num}/{self.total} ->').p()
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list,
disable_noise, start_step, last_step, force_full_denoise, x_value, y_value)
self.num += 1
else:
# ttNl(f'{CC.GREY}X: {x_value_label}').t(f'Plot Values {self.num}/{self.total} ->').p()
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list, disable_noise,
start_step, last_step, force_full_denoise, x_value)
self.num += 1
# Rearrange latent array to match preview image grid
self.latents_plot = self.rearrange_tensors(self.latents_plot, self.num_cols, self.num_rows)
@@ -77,7 +77,7 @@ class ControlPixArtHalf(Module):
def forward_c(self, c):
self.h, self.w = c.shape[-2]//self.patch_size, c.shape[-1]//self.patch_size
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), lewei_scale=self.lewei_scale, base_size=self.base_size)).unsqueeze(0).to(c.device).to(self.dtype)
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), pe_interpolation=self.pe_interpolation, base_size=self.base_size)).unsqueeze(0).to(c.device).to(self.dtype)
return self.x_embedder(c) + pos_embed if c is not None else c
# def forward(self, x, t, c, **kwargs):
@@ -225,7 +225,7 @@ class ControlPixArtMSHalf(ControlPixArtHalf):
c_size, ar = data_info['img_hw'].to(self.dtype), data_info['aspect_ratio'].to(self.dtype)
self.h, self.w = x.shape[-2]//self.patch_size, x.shape[-1]//self.patch_size
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), lewei_scale=self.lewei_scale, base_size=self.base_size)).unsqueeze(0).to(x.device).to(self.dtype)
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), pe_interpolation=self.pe_interpolation, base_size=self.base_size)).unsqueeze(0).to(x.device).to(self.dtype)
x = self.x_embedder(x) + pos_embed # (N, T, D), where T = H * W / patch_size ** 2
t = self.t_embedder(timestep) # (N, D)
csize = self.csize_embedder(c_size, bs) # (N, D)
+1 -1
View File
@@ -28,7 +28,7 @@ class DoubleStreamBlockIPA(nn.Module):
self.txt_norm2 = original_block.txt_norm2
self.txt_mlp = original_block.txt_mlp
self.flipped_img_txt = original_block.flipped_img_txt
self.flipped_img_txt = getattr(original_block, 'flipped_img_txt', False)
self.ip_adapter = ip_adapter
self.image_emb = image_emb
+6 -2
View File
@@ -438,7 +438,11 @@ class detailerFix:
# Clean loaded_objects
easyCache.update_loaded_objects(prompt)
my_unique_id = int(my_unique_id)
# my_unique_id can be a composite ID (e.g. `101:134`) when put inside a sub-graph; this fixes it so that it can support sub-graphs
try:
my_unique_id = int(my_unique_id)
except (ValueError, TypeError):
my_unique_id = int(str(my_unique_id).split(':')[-1])
model = model or (pipe["model"] if "model" in pipe else None)
if model is None:
@@ -640,4 +644,4 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy ultralyticsDetectorPipe": "UltralyticsDetector (Pipe)",
"easy samLoaderPipe": "SAMLoader (Pipe)",
"easy detailerFix": "DetailerFix",
}
}
+2
View File
@@ -1218,6 +1218,8 @@ class imageDetailTransfer:
new_image = torch.lerp(target_tensor, new_image, blend_factor)
if mask is not None:
mask = mask.to(device)
if mask.dim() == 3: # (B,H,W) batch/video mask -> (B,1,H,W) so it broadcasts over channels
mask = mask.unsqueeze(1)
new_image = torch.lerp(target_tensor, new_image, mask)
new_image = torch.clamp(new_image, 0, 1)
new_image = new_image.permute(0, 2, 3, 1).cpu().float()
+100 -5
View File
@@ -1146,6 +1146,90 @@ class mochiLoader(fullLoader):
batch_size, model_override, clip_override, vae_override, a1111_prompt_style=False, video_length=length, prompt=prompt,
my_unique_id=my_unique_id
)
# Diffusion model loader
class diffusionModelLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": (folder_paths.get_filename_list("diffusion_models"),),
"vae_name": (["None"] + folder_paths.get_filename_list("vae"), {"default": "None"}),
"clip_name": (["None"] + folder_paths.get_filename_list("text_encoders"), {"default": "None"}),
"resolution": (resolution_strings, {"default": "1024 x 1024"}),
"empty_latent_width": ("INT", {"default": 1024, "min": 64, "max": MAX_RESOLUTION, "step": 8}),
"empty_latent_height": ("INT", {"default": 1024, "min": 64, "max": MAX_RESOLUTION, "step": 8}),
"positive": ("STRING", {"default": "", "multiline": True}),
"negative": ("STRING", {"default": "", "multiline": True}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
},
"optional": {
"model_override": ("MODEL",),
"clip_override": ("CLIP",),
"vae_override": ("VAE",),
},
"hidden": {"prompt": "PROMPT", "my_unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("PIPE_LINE", "MODEL", "VAE", "CLIP", "CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("pipe", "model", "vae", "clip", "positive", "negative", "latent")
FUNCTION = "adv_pipeloader"
CATEGORY = "EasyUse/Loaders"
def adv_pipeloader(self, model_name, vae_name, clip_name, resolution,
empty_latent_width, empty_latent_height, positive, negative,
batch_size, model_override=None, clip_override=None,
vae_override=None, prompt=None, my_unique_id=None):
easyCache.update_loaded_objects(prompt)
model, clip, vae, family = easyCache.load_diffusion_model_required(
model_name, clip_name, vae_name
)
if model_override is not None:
model = model_override
if clip_override is not None:
clip = clip_override
if vae_override is not None:
vae = vae_override
samples = sampler.emptyLatent(resolution, empty_latent_width,
empty_latent_height, batch_size,
model_type=family)
positive_cond, positive_wildcard, model, clip = prompt_to_cond(
"positive", model, clip, 0, [], positive, "none", "comfy",
False, my_unique_id, prompt, easyCache, model_type=family)
negative_cond, negative_wildcard, model, clip = prompt_to_cond(
"negative", model, clip, 0, [], negative, "none", "comfy",
False, my_unique_id, prompt, easyCache, model_type=family)
if negative_cond is None:
negative_cond, = ConditioningZeroOut().zero_out(positive_cond)
pipe = {
"model": model,
"positive": positive_cond,
"negative": negative_cond,
"vae": vae,
"clip": clip,
"samples": samples,
"images": None,
"loader_settings": {
"model_name": model_name,
"clip_name": clip_name,
"vae_name": vae_name,
"model_type": family,
"positive": positive,
"negative": negative,
"resolution": resolution,
"empty_latent_width": empty_latent_width,
"empty_latent_height": empty_latent_height,
"batch_size": batch_size,
},
}
return pipe, model, vae, clip, positive_cond, negative_cond, samples
# lora
class loraSwitcher:
@classmethod
@@ -1175,9 +1259,12 @@ class loraSwitcher:
CATEGORY = "EasyUse/Loaders"
def stack(self, toggle, select,num_loras, lora_strength, optional_lora_stack=None, **kwargs):
if (toggle in [False, None, "False"]) or not kwargs:
return (None,'')
def stack(self, toggle, select,num_loras, lora_strength, optional_lora_stack=None, **kwargs):
if toggle in [False, None, "False"]:
return (optional_lora_stack, '')
if not kwargs and optional_lora_stack is None:
return (None, '')
loras = []
@@ -1234,7 +1321,10 @@ class loraStack:
CATEGORY = "EasyUse/Loaders"
def stack(self, toggle, mode, num_loras, optional_lora_stack=None, **kwargs):
if (toggle in [False, None, "False"]) or not kwargs:
if toggle in [False, None, "False"]:
return (optional_lora_stack,)
if not kwargs and optional_lora_stack is None:
return (None,)
loras = []
@@ -1291,7 +1381,10 @@ class controlnetStack:
CATEGORY = "EasyUse/Loaders"
def stack(self, toggle, mode, num_controlnet, optional_controlnet_stack=None, **kwargs):
if (toggle in [False, None, "False"]) or not kwargs:
if toggle in [False, None, "False"]:
return (optional_controlnet_stack,)
if not kwargs and optional_controlnet_stack is None:
return (None,)
controlnets = []
@@ -1533,6 +1626,7 @@ NODE_CLASS_MAPPINGS = {
"easy hunyuanDiTLoader": hunyuanDiTLoader,
"easy pixArtLoader": pixArtLoader,
"easy mochiLoader": mochiLoader,
"easy diffusionModelLoader": diffusionModelLoader,
"easy loraSwitcher": loraSwitcher,
"easy loraStack": loraStack,
"easy controlnetStack": controlnetStack,
@@ -1555,6 +1649,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy hunyuanDiTLoader": "EasyLoader (HunyuanDiT)",
"easy pixArtLoader": "EasyLoader (PixArt)",
"easy mochiLoader": "EasyLoader (Mochi)",
"easy diffusionModelLoader": "EasyDiffusionModelLoader",
"easy loraSwitcher": "EasyLoraSwitcher",
"easy loraStack": "EasyLoraStack",
"easy controlnetStack": "EasyControlnetStack",
+1006 -1207
View File
File diff suppressed because it is too large Load Diff
+24 -14
View File
@@ -554,13 +554,17 @@ class pipeXYPlotAdvanced:
if font_path and not os.path.exists(font_path):
font_path = os.path.join(self.user_font_dir, font)
if X != None:
if X is not None:
if isinstance(X, tuple):
X = X[0]
x_axis = X.get('axis')
x_values = X.get('values')
else:
x_axis = "Nothing"
x_values = [""]
if Y != None:
if Y is not None:
if isinstance(Y, tuple):
Y = Y[0]
y_axis = Y.get('axis')
y_values = Y.get('values')
else:
@@ -625,20 +629,26 @@ class pipeXYPlotAdvanced:
"lora_stack": lora_stack,
}
if x_axis == "advanced: DiffusionModel":
x_values = "; ".join(x_values)
if y_axis == "advanced: DiffusionModel":
y_values = "; ".join(y_values)
if x_axis == 'advanced: Seeds++ Batch':
if new_pipe['seed']:
value = x_values
x_values = []
for index in range(value):
x_values.append(str(new_pipe['seed'] + index))
x_values = "; ".join(x_values)
seed = new_pipe.get('seed') or 0
value = x_values
x_values = []
for index in range(value):
x_values.append(str(seed + index))
x_values = "; ".join(x_values)
if y_axis == 'advanced: Seeds++ Batch':
if new_pipe['seed']:
value = y_values
y_values = []
for index in range(value):
y_values.append(str(new_pipe['seed'] + index))
y_values = "; ".join(y_values)
seed = new_pipe.get('seed') or 0
value = y_values
y_values = []
for index in range(value):
y_values.append(str(seed + index))
y_values = "; ".join(y_values)
if x_axis == 'advanced: Positive Prompt S/R':
if positive:
+5 -4
View File
@@ -262,15 +262,16 @@ class samplerSettingsNoiseIn:
model = pipe["model"]
# generate base noise
batch_size, _, height, width = latent["samples"].shape
sample_shape = latent["samples"].shape
batch_size = sample_shape[0]
generator = torch.manual_seed(seed)
base_noise = torch.randn((1, 4, height, width), dtype=torch.float32, device="cpu", generator=generator).repeat(batch_size, 1, 1, 1).cpu()
base_noise = torch.randn((1, *sample_shape[1:]), dtype=torch.float32, device="cpu", generator=generator).repeat(batch_size, *([1] * (len(sample_shape) - 1))).cpu()
# generate variation noise
if optional_noise_seed is None or optional_noise_seed == seed:
optional_noise_seed = seed+1
generator = torch.manual_seed(optional_noise_seed)
variation_noise = torch.randn((batch_size, 4, height, width), dtype=torch.float32, device="cpu",
variation_noise = torch.randn(sample_shape, dtype=torch.float32, device="cpu",
generator=generator).cpu()
slerp_noise = self.slerp(factor, base_noise, variation_noise)
@@ -367,7 +368,7 @@ class samplerCustomSettings:
FUNCTION = "settings"
CATEGORY = "EasyUse/PreSampling"
def ip2p(self, positive, negative, vae, pixels, latent=None):
def ip2p(self, positive, negative, vae=None, pixels=None, latent=None):
if latent is not None:
concat_latent = latent
else:
+6 -1
View File
@@ -396,7 +396,12 @@ class promptConcat(io.ComfyNode):
@classmethod
def execute(cls, prompt1="", prompt2="", separator=""):
return io.NodeOutput(prompt1 + separator + prompt2)
def to_string(value):
if isinstance(value, (list, tuple)):
return ", ".join(to_string(v) for v in value)
return str(value)
return io.NodeOutput(to_string(prompt1) + to_string(separator) + to_string(prompt2))
class promptReplace(io.ComfyNode):
+13 -11
View File
@@ -8,7 +8,7 @@ import comfy_extras.nodes_custom_sampler as custom_samplers
from tqdm import trange
from server import PromptServer
from nodes import RepeatLatentBatch, NODE_CLASS_MAPPINGS as ALL_NODE_CLASS_MAPPINGS, VAEEncodeForInpaint, InpaintModelConditioning
from nodes import RepeatLatentBatch, NODE_CLASS_MAPPINGS as ALL_NODE_CLASS_MAPPINGS, VAEEncodeForInpaint, InpaintModelConditioning, VAEDecodeTiled
from ..modules.layer_diffuse import LayerDiffuse
from ..config import *
@@ -138,6 +138,14 @@ class samplerFull:
to["model_patch"] = {}
return to
def get_align_your_steps_sigmas(self, model, steps, denoise):
model_type = get_sd_version(model)
# Anima/Krea2 have no dedicated AYS table; keep the SDXL table they used before these families were recognized.
if model_type in ("anima", "krea2", "unknown"):
model_type = "sdxl"
sigmas, = alignYourStepsScheduler().get_sigmas(model_type.upper(), steps, denoise)
return sigmas
def get_sampler_custom(self, model, positive, negative, loader_settings):
_guider = None
middle = loader_settings['middle'] if "middle" in loader_settings else negative
@@ -174,10 +182,7 @@ class samplerFull:
elif scheduler == 'sdturbo':
sigmas, = self.get_custom_cls('SDTurboScheduler').execute(model, steps, denoise)
elif scheduler == 'alignYourSteps':
model_type = get_sd_version(model)
if model_type == 'unknown':
model_type = 'sdxl'
sigmas, = alignYourStepsScheduler().get_sigmas(model_type.upper(), steps, denoise)
sigmas = self.get_align_your_steps_sigmas(model, steps, denoise)
elif scheduler == 'gits':
sigmas, = gitsScheduler().get_sigmas(coeff, steps, denoise)
else:
@@ -340,10 +345,7 @@ class samplerFull:
_guider, _sampler, sigmas = self.get_sampler_custom(samp_model, samp_positive, samp_negative, samp_custom)
samp_samples, samp_blend_samples = sampler.custom_advanced_ksampler(_guider, _sampler, sigmas, samp_samples, add_noise, samp_seed, preview_latent=preview_latent)
elif scheduler == 'align_your_steps':
model_type = get_sd_version(samp_model)
if model_type == 'unknown':
model_type = 'sdxl'
sigmas, = alignYourStepsScheduler().get_sigmas(model_type.upper(), steps, denoise)
sigmas = self.get_align_your_steps_sigmas(samp_model, steps, denoise)
_sampler = comfy.samplers.sampler_object(sampler_name)
samp_samples = sampler.custom_ksampler(samp_model, samp_seed, steps, cfg, _sampler, sigmas, samp_positive, samp_negative, samp_samples, disable_noise=disable_noise, preview_latent=preview_latent, noise_device=noise_device)
elif scheduler == 'gits':
@@ -362,7 +364,7 @@ class samplerFull:
spent_time = 'Diffusion:' + str((end_time - start_time) / 1000) + '″'
else:
if tile_size is not None:
samp_images = samp_vae.decode_tiled(latent, tile_x=tile_size // 8, tile_y=tile_size // 8, )
samp_images, = VAEDecodeTiled().decode(samp_vae, {"samples": latent}, tile_size)
else:
samp_images = samp_vae.decode(latent).cpu()
if len(samp_images.shape) == 5: # Combine batches
@@ -980,7 +982,7 @@ class samplerSDTurbo:
# 解码图片
if tile_size is not None:
samp_images = samp_vae.decode_tiled(latent, tile_x=tile_size // 8, tile_y=tile_size // 8, )
samp_images, = VAEDecodeTiled().decode(samp_vae, {"samples": latent}, tile_size)
else:
samp_images = samp_vae.decode(latent).cpu()
+282
View File
@@ -1,5 +1,7 @@
import os
import re
import folder_paths
import json
from ..libs.utils import AlwaysEqualProxy
class showLoaderSettingsNames:
@@ -125,12 +127,291 @@ class setLoraName:
return (lora_name,)
def _markdown_table_to_image(markdown: str, font_path: str):
"""将 Markdown 表格字符串渲染为 PIL.Image(RGB),支持单元格自动换行。"""
from PIL import Image, ImageDraw, ImageFont
# 解析行,过滤分隔行(如 |---|---|)
lines = [l for l in (markdown or '').strip().splitlines() if l.strip()]
table_rows = []
for line in lines:
if re.match(r'^\|[\s\-:|]+\|$', line.strip()):
continue
cells = [re.sub(r'\*\*(.+?)\*\*', lambda m: '\x01' + m.group(1) + '\x02',
re.sub(r'<br\s*/?>', '\n', c.strip(), flags=re.IGNORECASE))
for c in line.strip().strip('|').split('|')]
table_rows.append(cells)
if not table_rows:
return Image.new("RGB", (400, 80), (255, 255, 255))
num_cols = max(len(r) for r in table_rows)
table_rows = [r + [''] * (num_cols - len(r)) for r in table_rows]
# 加载字体
font_size = 16
try:
font = ImageFont.truetype(font_path, font_size)
except Exception:
font = ImageFont.load_default()
pad_x, pad_y = 14, 10
border = 1
max_cell_text_width = 200 # 单元格文字区域最大宽度(像素)
def get_text_width(text):
clean = re.sub('[\x01\x02]', '', text)
try:
bbox = font.getbbox(clean)
return bbox[2] - bbox[0]
except Exception:
return len(clean) * 9
def get_line_height():
try:
bbox = font.getbbox('Ag\u4e2d')
return bbox[3] - bbox[1]
except Exception:
return font_size + 2
def wrap_text(text, max_width):
"""换行:先按 \\n 切段,每段再按英文单词边界 / CJK 字符换行。"""
if not text:
return ['']
# 先按显式换行符切段,再对每段分别软换行
hard_lines = text.split('\n')
if len(hard_lines) > 1:
result = []
for hl in hard_lines:
result.extend(wrap_text(hl, max_width))
return result if result else ['']
# 将文本拆分为:CJK 单字符 / 空白序列 / 非CJK非空白序列(英文单词/标点等)
tokens = re.findall(
r'[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]'
r'|[ \t]+'
r'|[^ \t\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+',
text
)
result, current = [], ''
for token in tokens:
is_space = token.strip() == ''
test = current + token
if get_text_width(test) <= max_width:
if is_space and not current:
continue # 跳过行首空格
current = test
else:
if is_space:
# 空白处换行,丢弃该空白
if current:
result.append(current)
current = ''
elif get_text_width(token) <= max_width:
# 整个 token 能放一行,整体移到下一行
if current:
result.append(current)
current = token
else:
# token 本身超宽(极长单词),逐字符强拆
for char in token:
if get_text_width(current + char) <= max_width:
current += char
else:
if current:
result.append(current)
current = char
if current:
result.append(current)
return result if result else ['']
line_h = get_line_height()
def parse_line_segments(line):
"""将含 \\x01..\\x02 粗体标记的行拆分为 (text, is_bold) 片段列表。"""
result, bold = [], False
for part in re.split('([\x01\x02])', line):
if part == '\x01':
bold = True
elif part == '\x02':
bold = False
elif part:
result.append((part, bold))
return result or [('', False)]
def balance_bold_markers(lines):
"""确保每行粗体标记自成一对:跨行时在行首补开、行尾补关标记。"""
result, in_bold = [], False
for line in lines:
if in_bold:
line = '\x01' + line
for ch in line:
if ch == '\x01': in_bold = True
elif ch == '\x02': in_bold = False
if in_bold:
line = line + '\x02'
result.append(line)
return result
# 第一遍:计算各列宽度(不超过 max_cell_text_width,按子行分别测量)
col_text_widths = []
for col_idx in range(num_cols):
max_w = 0
for row in table_rows:
cell = row[col_idx] if col_idx < len(row) else ''
for seg_line in cell.split('\n'):
max_w = max(max_w, min(get_text_width(seg_line), max_cell_text_width))
col_text_widths.append(max_w)
col_widths = [w + pad_x * 2 for w in col_text_widths]
# 第二遍:对每行每格换行,计算各行高度
wrapped_rows = []
row_heights = []
for row in table_rows:
wrapped_cells = []
max_lines = 1
for col_idx in range(num_cols):
cell = row[col_idx] if col_idx < len(row) else ''
wrapped = balance_bold_markers(wrap_text(cell, col_text_widths[col_idx]))
wrapped_cells.append(wrapped)
max_lines = max(max_lines, len(wrapped))
wrapped_rows.append(wrapped_cells)
row_heights.append(max_lines * line_h + pad_y * 2)
# 每列左边缘 x 坐标(每列前留 1px 边框)
col_x = [border]
for cw in col_widths:
col_x.append(col_x[-1] + cw + border)
total_width = col_x[-1]
total_height = border + sum(rh + border for rh in row_heights)
# 配色
header_bg = (52, 73, 94)
header_fg = (255, 255, 255)
even_bg = (248, 249, 252)
odd_bg = (255, 255, 255)
border_color = (180, 185, 195)
text_color = (50, 54, 62)
# 以边框色填充整张图,格线自然显现
img = Image.new("RGB", (total_width, total_height), border_color)
draw = ImageDraw.Draw(img)
def render_line(x, y, line, fg):
"""逐片段渲染一行文字;粗体通过向右偏移 1px 再描一遍来模拟加粗。"""
try:
text_offset = -font.getbbox(re.sub('[\x01\x02]', '', line) or 'A')[1]
except Exception:
text_offset = 0
sx = x
for seg, is_bold in parse_line_segments(line):
draw.text((sx, y + text_offset), seg, font=font, fill=fg)
if is_bold:
draw.text((sx + 1, y + text_offset), seg, font=font, fill=fg)
try:
w = font.getbbox(seg)[2] - font.getbbox(seg)[0]
except Exception:
w = len(seg) * 9
sx += w + (1 if is_bold else 0)
row_y = border
for row_idx, (wrapped_cells, rh) in enumerate(zip(wrapped_rows, row_heights)):
is_header = row_idx == 0
bg = header_bg if is_header else (odd_bg if row_idx % 2 == 1 else even_bg)
fg = header_fg if is_header else text_color
for col_idx in range(num_cols):
cx, cw = col_x[col_idx], col_widths[col_idx]
# 填充单元格背景
draw.rectangle([cx, row_y, cx + cw - 1, row_y + rh - 1], fill=bg)
cell_lines = wrapped_cells[col_idx] if col_idx < len(wrapped_cells) else ['']
total_text_h = len(cell_lines) * line_h
ty = row_y + (rh - total_text_h) // 2 # 垂直居中起点
for line_text in cell_lines:
render_line(cx + pad_x, ty, line_text, fg)
ty += line_h
row_y += rh + border
# 最长边不小于 1280,等比放大
min_long_side = 1280
long_side = max(img.width, img.height)
if long_side < min_long_side:
scale = min_long_side / long_side
new_w = round(img.width * scale)
new_h = round(img.height * scale)
img = img.resize((new_w, new_h), Image.LANCZOS)
return img
class tableEditor:
"""表格编辑器节点 —— 通过可视化表格或 Markdown 语法编辑数据,输出 Markdown 字符串。"""
CATEGORY = "EasyUse/Util"
RETURN_TYPES = ("STRING", "IMAGE")
RETURN_NAMES = ("markdown", "image")
FUNCTION = "execute"
DESCRIPTION = "通过可视化表格或 Markdown 语法编辑数据,输出 Markdown 格式的表格字符串。"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"table_data": ("EASY_TABLE_EDITOR",),
},
}
def execute(self, table_data):
# 表格数据可能是纯 Markdown 字符串,也可能是序列化后的 JSON
if isinstance(table_data, str) and table_data.strip().startswith('{'):
try:
obj = json.loads(table_data)
markdown = obj.get('markdown', '')
if not markdown:
# 重新从 headers/rows 生成
headers = obj.get('headers', [])
rows = obj.get('rows', [])
col_widths = [max(len(str(h)), 3) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
header_line = '| ' + ' | '.join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + ' |'
sep_line = '| ' + ' | '.join('-' * w for w in col_widths) + ' |'
row_lines = [
'| ' + ' | '.join(str(row[i] if i < len(row) else '').ljust(col_widths[i]) for i in range(len(headers))) + ' |'
for row in rows
]
markdown = '\n'.join([header_line, sep_line] + row_lines)
except Exception:
markdown = table_data
else:
markdown = table_data
# 将 Markdown 表格渲染为图像
font_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'resources', 'wenquan.ttf'
)
from ..libs.image import pil2tensor
img_tensor = pil2tensor(_markdown_table_to_image(markdown, font_path).convert("RGB"))
return (markdown, img_tensor)
NODE_CLASS_MAPPINGS = {
"easy showLoaderSettingsNames": showLoaderSettingsNames,
"easy sliderControl": sliderControl,
"easy ckptNames": setCkptName,
"easy controlnetNames": setControlName,
"easy loraNames": setLoraName,
"easy tableEditor": tableEditor,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -139,4 +420,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy ckptNames": "Ckpt Names",
"easy controlnetNames": "ControlNet Names",
"easy loraNames": "Lora Names",
"easy tableEditor": "Table Editor",
}
+41
View File
@@ -528,6 +528,45 @@ class XYplot_Checkpoint:
xy_values = {"axis": axis, "values": values, "lora_stack": optional_lora_stack}
return (xy_values,)
# Diffusion Models
class XYplot_DiffusionModel:
@classmethod
def INPUT_TYPES(cls):
models = ["None"] + folder_paths.get_filename_list("diffusion_models")
clips = ["Auto"] + folder_paths.get_filename_list("text_encoders")
vaes = ["Auto"] + folder_paths.get_filename_list("vae")
inputs = {
"required": {
"model_count": ("INT", {"default": 3, "min": 0, "max": 10, "step": 1}),
}
}
for i in range(1, 11):
inputs["required"][f"model_name_{i}"] = (models,)
inputs["required"][f"clip_name_{i}"] = (clips, {"default": "Auto"})
inputs["required"][f"vae_name_{i}"] = (vaes, {"default": "Auto"})
return inputs
RETURN_TYPES = ("X_Y",)
RETURN_NAMES = ("X or Y",)
FUNCTION = "xy_value"
CATEGORY = "EasyUse/XY Inputs"
def xy_value(self, model_count, **kwargs):
values = []
for i in range(1, model_count + 1):
model_name = kwargs.get(f"model_name_{i}")
if not model_name or model_name == "None":
continue
clip_name = kwargs.get(f"clip_name_{i}", "Auto")
vae_name = kwargs.get(f"vae_name_{i}", "Auto")
values.append(
model_name.replace(",", "*") + ","
+ clip_name.replace(",", "*") + ","
+ vae_name.replace(",", "*")
)
return ({"axis": "advanced: DiffusionModel", "values": values},)
#Loras
class XYplot_Lora:
@@ -670,6 +709,7 @@ NODE_CLASS_MAPPINGS = {
"easy XYInputs: Sampler/Scheduler": XYplot_Sampler_Scheduler,
"easy XYInputs: Denoise": XYplot_Denoise,
"easy XYInputs: Checkpoint": XYplot_Checkpoint,
"easy XYInputs: DiffusionModel": XYplot_DiffusionModel,
"easy XYInputs: Lora": XYplot_Lora,
"easy XYInputs: ModelMergeBlocks": XYplot_ModelMergeBlocks,
"easy XYInputs: PromptSR": XYplot_PromptSR,
@@ -688,6 +728,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy XYInputs: Sampler/Scheduler": "XY Inputs: Sampler/Scheduler //EasyUse",
"easy XYInputs: Denoise": "XY Inputs: Denoise //EasyUse",
"easy XYInputs: Checkpoint": "XY Inputs: Checkpoint //EasyUse",
"easy XYInputs: DiffusionModel": "XY Inputs: Diffusion Model //EasyUse",
"easy XYInputs: Lora": "XY Inputs: Lora //EasyUse",
"easy XYInputs: ModelMergeBlocks": "XY Inputs: ModelMergeBlocks //EasyUse",
"easy XYInputs: PromptSR": "XY Inputs: PromptSR //EasyUse",
+123 -39
View File
@@ -1,10 +1,16 @@
import os
import hashlib
import hmac
import sys
import json
import shutil
import secrets
import tempfile
from functools import lru_cache
from urllib.parse import urlsplit
import folder_paths
from aiohttp import web
from PIL import Image, UnidentifiedImageError
from server import PromptServer
from .config import RESOURCES_DIR, FOOOCUS_STYLES_DIR, FOOOCUS_STYLES_SAMPLES
from .libs.model import easyModelManager
@@ -25,7 +31,6 @@ def get_version(request):
def cleanGPU(request):
try:
cleanGPUUsedForce()
remove_cache('*')
return web.Response(status=200)
except Exception as e:
return web.Response(status=500)
@@ -51,8 +56,30 @@ async def translate(request):
else:
return web.json_response({"text": text})
@PromptServer.instance.routes.get("/easyuse/reboot")
def reboot(request):
_reboot_token = secrets.token_urlsafe(32)
def _same_origin_request(request):
fetch_site = request.headers.get("Sec-Fetch-Site")
if fetch_site and fetch_site not in ("same-origin", "none"):
return False
origin = request.headers.get("Origin")
return not origin or urlsplit(origin).netloc == request.host
@PromptServer.instance.routes.get("/easyuse/reboot-token")
async def get_reboot_token(request):
if not _same_origin_request(request):
return web.Response(status=403)
return web.json_response({"token": _reboot_token}, headers={"Cache-Control": "no-store"})
@PromptServer.instance.routes.post("/easyuse/reboot")
async def reboot(request):
token = request.headers.get("X-EasyUse-Reboot-Token", "")
if not _same_origin_request(request) or not hmac.compare_digest(token, _reboot_token):
return web.Response(status=403)
try:
sys.stdout.close_log()
except Exception as e:
@@ -170,9 +197,9 @@ async def getModelsList(request):
@PromptServer.instance.routes.post("/easyuse/metadata/notes/{name}")
async def save_notes(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
type, separator, name = name.partition("/")
if not separator or type not in ("checkpoints", "loras", "embeddings"):
return web.Response(status=400)
file_path = None
if type == "embeddings" or type == "loras":
@@ -190,24 +217,34 @@ async def save_notes(request):
if file_path is not None:
break
else:
file_path = folder_paths.get_full_path(
type, name)
if name in folder_paths.get_filename_list(type):
file_path = folder_paths.get_full_path(type, name)
if not file_path:
return web.Response(status=404)
file_no_ext = os.path.splitext(file_path)[0]
info_file = file_no_ext + ".txt"
with open(info_file, "w") as f:
f.write(await request.text())
staged_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=os.path.dirname(info_file),
prefix=".easyuse-notes-", delete=False
) as staged:
staged_path = staged.name
staged.write(await request.text())
os.replace(staged_path, info_file)
finally:
if staged_path and os.path.exists(staged_path):
os.unlink(staged_path)
return web.Response(status=200)
@PromptServer.instance.routes.get("/easyuse/metadata/{name}")
async def load_metadata(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
type, separator, name = name.partition("/")
if not separator or type not in ("checkpoints", "loras", "embeddings"):
return web.Response(status=400)
file_path = None
if type == "embeddings":
@@ -225,7 +262,8 @@ async def load_metadata(request):
if file_path is not None:
break
else:
file_path = folder_paths.get_full_path(type, name)
if name in folder_paths.get_filename_list(type):
file_path = folder_paths.get_full_path(type, name)
if not file_path:
return web.Response(status=404)
@@ -242,47 +280,93 @@ async def load_metadata(request):
file_no_ext = os.path.splitext(file_path)[0]
info_file = file_no_ext + ".txt"
if os.path.isfile(info_file):
if os.path.isfile(info_file) and not os.path.islink(info_file):
with open(info_file, "r") as f:
meta["easyuse.notes"] = f.read()
hash_file = file_no_ext + ".sha256"
if os.path.isfile(hash_file):
with open(hash_file, "rt") as f:
meta["easyuse.sha256"] = f.read()
else:
with open(file_path, "rb") as f:
meta["easyuse.sha256"] = hashlib.sha256(f.read()).hexdigest()
with open(hash_file, "wt") as f:
f.write(meta["easyuse.sha256"])
# Sidecar hashes are user-controlled; never use them as proof of the model's hash.
stat = os.stat(file_path)
meta["easyuse.sha256"] = _model_sha256(
file_path, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns
)
return web.json_response(meta)
@lru_cache(maxsize=128)
def _model_sha256(path, size, mtime_ns, ctime_ns):
digest = hashlib.sha256()
with open(path, "rb") as model:
for chunk in iter(lambda: model.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
_PREVIEW_FORMATS = {
".png": "PNG",
".jpg": "JPEG",
".jpeg": "JPEG",
".webp": "WEBP",
".gif": "GIF",
}
@PromptServer.instance.routes.post("/easyuse/save/{name}")
async def save_preview(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
model_type, separator, model_name = name.partition("/")
if not separator or model_type not in ("checkpoints", "loras"):
return web.Response(status=400)
if model_name not in folder_paths.get_filename_list(model_type):
return web.Response(status=404)
model_path = folder_paths.get_full_path(model_type, model_name)
if not model_path:
return web.Response(status=404)
body = await request.json()
dir = folder_paths.get_directory_by_type(body.get("type", "output"))
subfolder = body.get("subfolder", "")
full_output_folder = os.path.join(dir, os.path.normpath(subfolder))
if os.path.commonpath((dir, os.path.abspath(full_output_folder))) != dir:
filename = body.get("filename")
if (body.get("type") != "temp" or body.get("subfolder", "") != ""
or not isinstance(filename, str) or not filename
or os.path.basename(filename) != filename or filename in (".", "..")):
return web.Response(status=400)
filepath = os.path.join(full_output_folder, body.get("filename", ""))
image_path = folder_paths.get_full_path(type, name)
image_path = os.path.splitext(
image_path)[0] + os.path.splitext(filepath)[1]
extension = os.path.splitext(filename)[1].lower()
if extension not in _PREVIEW_FORMATS:
return web.Response(status=400)
shutil.copyfile(filepath, image_path)
temp_dir = folder_paths.get_directory_by_type("temp")
filepath = os.path.join(temp_dir, filename)
if (os.path.commonpath((os.path.realpath(temp_dir), os.path.realpath(filepath)))
!= os.path.realpath(temp_dir) or not os.path.isfile(filepath)):
return web.Response(status=400)
image_path = os.path.splitext(model_path)[0] + extension
if (os.path.islink(image_path)
or os.path.realpath(os.path.dirname(image_path))
!= os.path.realpath(os.path.dirname(model_path))):
return web.Response(status=400)
staged_path = None
try:
with tempfile.NamedTemporaryFile(
dir=os.path.dirname(image_path), prefix=".easyuse-preview-", delete=False
) as staged:
staged_path = staged.name
with open(filepath, "rb") as source:
shutil.copyfileobj(source, staged)
with Image.open(staged_path) as image:
if image.format != _PREVIEW_FORMATS[extension]:
return web.Response(status=400)
image.verify()
os.replace(staged_path, image_path)
except (OSError, ValueError, UnidentifiedImageError):
return web.Response(status=400)
finally:
if staged_path and os.path.exists(staged_path):
os.unlink(staged_path)
return web.json_response({
"image": type + "/" + os.path.basename(image_path)
"image": model_type + "/" + os.path.basename(image_path)
})
@PromptServer.instance.routes.post("/easyuse/model/download")
+7 -3
View File
@@ -2,6 +2,10 @@ import random
import server
from enum import Enum
# Dedicated RNG for seed generation. Many custom nodes call random.seed() while they run,
# which resets the global RNG and makes the generated seeds repeat.
_seed_rng = random.Random()
class SGmode(Enum):
FIX = 1
INCR = 2
@@ -34,7 +38,7 @@ class SeedGenerator:
if self.base_value < 0:
self.base_value = 1125899906842624
elif self.action == SGmode.RAND:
self.base_value = random.randint(0, 1125899906842624)
self.base_value = _seed_rng.randint(0, 1125899906842624)
return seed
@@ -52,7 +56,7 @@ def control_seed(v, action, seed_is_global):
if value < 0:
value = 1125899906842624
elif action == 'randomize' or action == 'randomize for each node':
value = random.randint(0, 1125899906842624)
value = _seed_rng.randint(0, 1125899906842624)
if seed_is_global:
v['inputs']['value'] = value
@@ -163,4 +167,4 @@ def onprompt(json_data):
return json_data
server.PromptServer.instance.add_on_prompt_handler(onprompt)
server.PromptServer.instance.add_on_prompt_handler(onprompt)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-easy-use"
description = "To enhance the usability of ComfyUI, optimizations and integrations have been implemented for several commonly used nodes."
version = "1.3.6"
version = "1.4.1"
license = { file = "LICENSE" }
dependencies = ["diffusers", "accelerate", "clip_interrogator", "sentencepiece", "lark", "onnxruntime", "spandrel", "opencv-python-headless", "matplotlib", "peft"]
Binary file not shown.
+275
View File
@@ -0,0 +1,275 @@
import importlib.util
import os
import sys
import tempfile
import types
import unittest
from contextlib import contextmanager
from pathlib import Path
import torch
PLUGIN_ROOT = Path(__file__).parents[1]
UTILS_PATH = PLUGIN_ROOT / "py" / "libs" / "utils.py"
CONFIG_PATH = PLUGIN_ROOT / "py" / "config.py"
LOADER_PATH = PLUGIN_ROOT / "py" / "libs" / "loader.py"
XYPLOT_NODE_PATH = PLUGIN_ROOT / "py" / "nodes" / "xyplot.py"
XYPLOT_LIB_PATH = PLUGIN_ROOT / "py" / "libs" / "xyplot.py"
@contextmanager
def installed_modules(modules):
added = []
for name, module in modules.items():
if name not in sys.modules:
added.append(name)
sys.modules[name] = module
try:
yield
finally:
for name in added:
sys.modules.pop(name, None)
def load_module(name, path, package=None):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
if package is not None:
module.__package__ = package
spec.loader.exec_module(module)
return module
def make_package(name, **attrs):
package = types.ModuleType(name)
package.__path__ = []
for key, value in attrs.items():
setattr(package, key, value)
return package
def comfy_stubs():
model_management = types.ModuleType("comfy.model_management")
model_base = types.ModuleType("comfy.model_base")
model_base.BaseModel = object
supported_models_base = types.ModuleType("comfy.supported_models_base")
supported_models_base.BASE = object
supported_models = types.ModuleType("comfy.supported_models")
supported_models.supported_models_base = supported_models_base
comfy = make_package(
"comfy",
model_management=model_management,
model_base=model_base,
supported_models_base=supported_models_base,
supported_models=supported_models,
)
for name in (
"SDXL", "SDXLRefiner", "SD15", "SD20", "SVD_img2vid", "SD3",
"HunyuanDiT", "Flux", "GenmoMochi", "Anima", "Krea2",
):
setattr(supported_models, name, type(name, (), {}))
server = types.ModuleType("server")
server.PromptServer = object
return {
"comfy": comfy,
"comfy.model_management": model_management,
"comfy.model_base": model_base,
"comfy.supported_models_base": supported_models_base,
"comfy.supported_models": supported_models,
"server": server,
}
def xyplot_node_stubs():
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_filename_list = lambda folder: []
return {
"comfy": make_package("comfy"),
"folder_paths": folder_paths,
"fake_py": make_package("fake_py"),
"fake_py.nodes": make_package("fake_py.nodes"),
"fake_py.libs": make_package("fake_py.libs"),
"fake_py.config": make_package("fake_py.config", RESOURCES_DIR="resources"),
"fake_py.libs.utils": make_package("fake_py.libs.utils", getMetadata=lambda *args, **kwargs: None),
}
def xyplot_lib_stubs():
fake_utils = make_package("fake_py.utils", easySave=object, get_sd_version=lambda model: "unknown")
fake_adv_encode = make_package("fake_py.libs.adv_encode", advanced_encode=object)
fake_controlnet = make_package("fake_py.libs.controlnet", easyControlnet=object)
fake_log = make_package("fake_py.libs.log", log_node_warn=lambda *args, **kwargs: None)
return {
"nodes": make_package("nodes", CLIPTextEncode=object),
"fake_py": make_package("fake_py"),
"fake_py.libs": make_package("fake_py.libs"),
"fake_py.modules": make_package("fake_py.modules"),
"fake_py.utils": fake_utils,
"fake_py.libs.utils": fake_utils,
"fake_py.libs.adv_encode": fake_adv_encode,
"fake_py.libs.controlnet": fake_controlnet,
"fake_py.libs.log": fake_log,
"fake_py.modules.layer_diffuse": make_package("fake_py.modules.layer_diffuse", LayerDiffuse=object),
"fake_py.config": make_package("fake_py.config", RESOURCES_DIR="resources"),
}
def loader_stubs():
comfy = make_package("comfy")
comfy.utils = make_package("comfy.utils")
comfy.sd = make_package("comfy.sd")
comfy.controlnet = make_package("comfy.controlnet")
comfy.model_patcher = make_package("comfy.model_patcher", ModelPatcher=type("ModelPatcher", (), {}))
folder_paths = make_package("folder_paths")
folder_paths.get_full_path = lambda folder, name: None
folder_paths.get_folder_paths = lambda folder: []
folder_paths.get_filename_list = lambda folder: []
fake_log = make_package("fake_py.libs.log", log_node_info=lambda *args, **kwargs: None, log_node_error=lambda *args, **kwargs: None)
fake_utils = make_package("fake_py.libs.utils", get_sd_version=lambda model: "unknown")
fake_config = make_package(
"fake_py.config",
DIFFUSION_MODEL_XY_DEFAULTS={},
DIFFUSION_MODEL_CLIP_TYPES={"anima": "anima", "krea2": "krea2"},
)
fake_pixart = make_package("fake_py.modules.dit.pixArt.loader", load_pixart=object)
return {
"comfy": comfy,
"comfy.utils": comfy.utils,
"comfy.sd": comfy.sd,
"comfy.controlnet": comfy.controlnet,
"comfy.model_patcher": comfy.model_patcher,
"folder_paths": folder_paths,
"nodes": make_package("nodes", NODE_CLASS_MAPPINGS={}),
"fake_py": make_package("fake_py"),
"fake_py.libs": make_package("fake_py.libs"),
"fake_py.modules": make_package("fake_py.modules"),
"fake_py.modules.dit": make_package("fake_py.modules.dit"),
"fake_py.modules.dit.pixArt": make_package("fake_py.modules.dit.pixArt"),
"fake_py.libs.log": fake_log,
"fake_py.libs.utils": fake_utils,
"fake_py.config": fake_config,
"fake_py.modules.dit.pixArt.loader": fake_pixart,
}
class FakeModelPatcher:
def __init__(self, model_config=None, latent_format=None):
self.model = types.SimpleNamespace(model_config=model_config, latent_format=latent_format)
class FakeLatentFormat:
latent_dimensions = 3
latent_channels = 16
class DiffusionXYHelperTests(unittest.TestCase):
def test_get_sd_version_anima_and_krea2(self):
with installed_modules(comfy_stubs()):
utils = load_module("diffusion_xy_test_utils", UTILS_PATH)
anima_config = utils.comfy.supported_models.Anima()
self.assertEqual(utils.get_sd_version(FakeModelPatcher(anima_config)), "anima")
krea2_config = utils.comfy.supported_models.Krea2()
self.assertEqual(utils.get_sd_version(FakeModelPatcher(krea2_config)), "krea2")
def test_diffusion_model_xy_defaults_are_complete(self):
with tempfile.TemporaryDirectory() as models_dir:
folder_paths = types.ModuleType("folder_paths")
folder_paths.models_dir = models_dir
with installed_modules({"folder_paths": folder_paths}):
config = load_module("diffusion_xy_test_config", CONFIG_PATH)
for family in ("anima", "krea2"):
defaults = config.DIFFUSION_MODEL_XY_DEFAULTS[family]
self.assertTrue(defaults["clip_name"])
self.assertTrue(defaults["clip_type"])
self.assertTrue(defaults["vae_name"])
self.assertEqual(config.DIFFUSION_MODEL_CLIP_TYPES[family], family)
def test_load_diffusion_model_required_rejects_missing_clip_and_vae(self):
with installed_modules(loader_stubs()):
loader_module = load_module("fake_py.libs.loader", LOADER_PATH, "fake_py.libs")
loader = loader_module.easyLoader.__new__(loader_module.easyLoader)
loader.load_diffusion_model = lambda model_name: ("model", model_name)
loader.load_clip = lambda clip_name, type='stable_diffusion': ("clip", clip_name, type)
loader.load_vae = lambda vae_name: ("vae", vae_name)
loader_module.get_sd_version = lambda model: "krea2"
with self.assertRaisesRegex(RuntimeError, "clip_name is required"):
loader.load_diffusion_model_required("model.safetensors", "None", "vae.safetensors")
with self.assertRaisesRegex(RuntimeError, "vae_name is required"):
loader.load_diffusion_model_required("model.safetensors", "clip.safetensors", None)
model, clip, vae, family = loader.load_diffusion_model_required(
"model.safetensors", "clip.safetensors", "vae.safetensors"
)
self.assertEqual(family, "krea2")
self.assertEqual(clip[2], "krea2")
loader_module.get_sd_version = lambda model: "flux"
with self.assertRaisesRegex(RuntimeError, "unsupported diffusion model family: flux"):
loader.load_diffusion_model_required("model.safetensors", "clip.safetensors", "vae.safetensors")
def test_xyplot_diffusion_model_value_format(self):
with installed_modules(xyplot_node_stubs()):
node_module = load_module("fake_py.nodes.xyplot", XYPLOT_NODE_PATH, "fake_py.nodes")
node = node_module.XYplot_DiffusionModel()
result = node.xy_value(
2,
model_name_1="waiANIMA_v10Base10.safetensors",
clip_name_1="qwen_3_06b_base.safetensors",
vae_name_1="qwen_image_vae.safetensors",
model_name_2="moodyKrea2Mix,v70.safetensors",
clip_name_2="Auto",
vae_name_2="Auto",
)
self.assertEqual(result[0]["axis"], "advanced: DiffusionModel")
self.assertEqual(
result[0]["values"],
[
"waiANIMA_v10Base10.safetensors,qwen_3_06b_base.safetensors,qwen_image_vae.safetensors",
"moodyKrea2Mix*v70.safetensors,Auto,Auto",
],
)
model_name, clip_name, vae_name = result[0]["values"][0].split(",")
self.assertEqual(model_name.replace("*", ","), "waiANIMA_v10Base10.safetensors")
self.assertEqual(clip_name.replace("*", ","), "qwen_3_06b_base.safetensors")
self.assertEqual(vae_name.replace("*", ","), "qwen_image_vae.safetensors")
model_name, clip_name, vae_name = result[0]["values"][1].split(",")
self.assertEqual(model_name.replace("*", ","), "moodyKrea2Mix,v70.safetensors")
self.assertEqual(clip_name, "Auto")
self.assertEqual(vae_name, "Auto")
def test_ensure_latent_raises_for_nonempty_4d_latent_without_image(self):
with installed_modules(xyplot_lib_stubs()):
xyplot_module = load_module("fake_py.libs.xyplot", XYPLOT_LIB_PATH, "fake_py.libs")
model = FakeModelPatcher(latent_format=FakeLatentFormat())
vae = types.SimpleNamespace(encode=lambda pixels: torch.zeros([pixels.shape[0], 16, 1, pixels.shape[2], pixels.shape[3]]))
samples = {"samples": torch.ones([1, 4, 64, 64])}
with self.assertRaisesRegex(RuntimeError, "requires an input image"):
xyplot_module.easyXYPlot._ensure_latent_for_model(model, vae, samples, {})
def test_ensure_latent_expands_empty_4d_latent_to_5d(self):
with installed_modules(xyplot_lib_stubs()):
xyplot_module = load_module("fake_py.libs.xyplot", XYPLOT_LIB_PATH, "fake_py.libs")
model = FakeModelPatcher(latent_format=FakeLatentFormat())
vae = types.SimpleNamespace(encode=lambda pixels: torch.zeros([pixels.shape[0], 16, 1, pixels.shape[2], pixels.shape[3]]))
samples = {"samples": torch.zeros([1, 4, 64, 64])}
result = xyplot_module.easyXYPlot._ensure_latent_for_model(model, vae, samples, {})
self.assertEqual(result["samples"].shape, torch.Size([1, 16, 1, 64, 64]))
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
MODULE_PATH = Path(__file__).parents[1] / "py" / "libs" / "path_utils.py"
SPEC = importlib.util.spec_from_file_location("easyuse_path_utils", MODULE_PATH)
path_utils = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(path_utils)
class ResolveOutputFilePathTests(unittest.TestCase):
def test_relative_subdirectory_is_resolved_under_output_root(self):
with tempfile.TemporaryDirectory() as output_root:
result = path_utils.resolve_output_file_path(
output_root, "metadata", "prompt", "txt"
)
self.assertEqual(
result,
os.path.join(os.path.realpath(output_root), "metadata", "prompt.txt"),
)
def test_absolute_directory_inside_output_root_is_allowed(self):
with tempfile.TemporaryDirectory() as output_root:
inside = os.path.join(output_root, "metadata")
result = path_utils.resolve_output_file_path(
output_root, inside, "prompt", "txt"
)
self.assertEqual(result, os.path.join(inside, "prompt.txt"))
def test_absolute_directory_outside_output_root_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with tempfile.TemporaryDirectory() as outside:
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, outside, "marker", "txt"
)
def test_output_directory_traversal_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, "../outside", "marker", "txt"
)
def test_file_name_traversal_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, ".", "../../marker", "txt"
)
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable")
def test_symlink_escape_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with tempfile.TemporaryDirectory() as outside:
os.symlink(outside, os.path.join(output_root, "linked"))
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, "linked", "marker", "txt"
)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
import ast
import asyncio
import hashlib
import inspect
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlsplit
from unittest.mock import patch
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from PIL import Image, UnidentifiedImageError
ROUTES_PATH = Path(__file__).parents[1] / "py" / "routes.py"
def load_handlers(folder_paths, get_metadata):
"""Load the actual handlers without importing ComfyUI's GPU dependencies."""
names = {
"_same_origin_request", "get_reboot_token", "reboot", "_model_sha256",
"load_metadata", "save_notes", "save_preview",
}
tree = ast.parse(ROUTES_PATH.read_text())
functions = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names:
node.decorator_list = []
functions.append(node)
namespace = {
"os": os, "sys": sys, "hashlib": hashlib, "hmac": __import__("hmac"),
"json": json, "shutil": shutil, "tempfile": tempfile,
"lru_cache": lru_cache, "urlsplit": urlsplit, "web": web,
"Image": Image, "UnidentifiedImageError": UnidentifiedImageError,
"folder_paths": folder_paths, "getMetadata": get_metadata,
"_reboot_token": "test-reboot-token",
"_PREVIEW_FORMATS": {
".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG",
".webp": "WEBP", ".gif": "GIF",
},
}
exec(compile(ast.Module(body=functions, type_ignores=[]), str(ROUTES_PATH), "exec"), namespace)
return SimpleNamespace(**namespace)
class SecurityRouteTests(unittest.TestCase):
def setUp(self):
self.workspace = tempfile.TemporaryDirectory()
self.addCleanup(self.workspace.cleanup)
self.root = Path(self.workspace.name)
self.model_dir = self.root / "models"
self.temp_dir = self.root / "temp"
self.model_dir.mkdir()
self.temp_dir.mkdir()
self.model_path = self.model_dir / "sample.safetensors"
self.model_path.write_bytes(b"model data")
paths = SimpleNamespace(
get_filename_list=lambda kind: [self.model_path.name],
get_full_path=lambda kind, name: str(self.model_path),
get_directory_by_type=lambda kind: str(self.temp_dir),
)
self.handlers = load_handlers(
paths,
lambda path: json.dumps({"__metadata__": {"easyuse.notes": "<img onerror=alert(1)>"}}),
)
def request(self, name="loras/sample.safetensors", filename="preview.png", **body):
payload = {"type": "temp", "filename": filename, **body}
return SimpleNamespace(
match_info={"name": name},
json=lambda: asyncio.sleep(0, result=payload),
headers={}, host="localhost:8188",
)
def test_save_rejects_script_and_custom_node_target(self):
(self.temp_dir / "payload.py").write_text("print('sentinel')")
response = asyncio.run(self.handlers.save_preview(self.request(filename="payload.py")))
self.assertEqual(response.status, 400)
response = asyncio.run(self.handlers.save_preview(
self.request(name="custom_nodes/package/__init__.py", filename="payload.py")
))
self.assertEqual(response.status, 400)
def test_save_accepts_real_image_and_rejects_disguised_script(self):
Image.new("RGB", (1, 1)).save(self.temp_dir / "preview.png")
response = asyncio.run(self.handlers.save_preview(self.request()))
self.assertEqual(response.status, 200)
with Image.open(self.model_dir / "sample.png") as saved:
self.assertEqual(saved.format, "PNG")
(self.temp_dir / "preview.png").write_text("print('sentinel')")
response = asyncio.run(self.handlers.save_preview(self.request()))
self.assertEqual(response.status, 400)
with Image.open(self.model_dir / "sample.png") as saved:
self.assertEqual(saved.format, "PNG")
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable")
def test_save_does_not_follow_preview_symlink(self):
Image.new("RGB", (1, 1)).save(self.temp_dir / "preview.png")
protected = self.root / "protected.txt"
protected.write_text("untouched")
os.symlink(protected, self.model_dir / "sample.png")
response = asyncio.run(self.handlers.save_preview(self.request()))
self.assertEqual(response.status, 400)
self.assertEqual(protected.read_text(), "untouched")
def test_metadata_ignores_forged_hash_sidecar(self):
(self.model_dir / "sample.sha256").write_text("0" * 64)
response = asyncio.run(self.handlers.load_metadata(self.request()))
self.assertEqual(
json.loads(response.text)["easyuse.sha256"],
hashlib.sha256(self.model_path.read_bytes()).hexdigest(),
)
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable")
def test_notes_reject_custom_nodes_and_do_not_follow_symlinks(self):
request = self.request(name="custom_nodes/package/__init__.py")
request.text = lambda: asyncio.sleep(0, result="new notes")
self.assertEqual(asyncio.run(self.handlers.save_notes(request)).status, 400)
protected = self.root / "protected.txt"
protected.write_text("untouched")
os.symlink(protected, self.model_dir / "sample.txt")
request = self.request()
request.text = lambda: asyncio.sleep(0, result="new notes")
self.assertEqual(asyncio.run(self.handlers.save_notes(request)).status, 200)
self.assertEqual(protected.read_text(), "untouched")
self.assertEqual((self.model_dir / "sample.txt").read_text(), "new notes")
def test_reboot_requires_token_and_same_origin(self):
self.assertTrue(inspect.iscoroutinefunction(self.handlers.get_reboot_token))
self.assertTrue(inspect.iscoroutinefunction(self.handlers.reboot))
request = self.request()
request.headers = {"Sec-Fetch-Site": "cross-site"}
self.assertEqual(asyncio.run(self.handlers.get_reboot_token(request)).status, 403)
request.headers = {"Sec-Fetch-Site": "same-origin"}
self.assertEqual(json.loads(asyncio.run(self.handlers.get_reboot_token(request)).text)["token"], "test-reboot-token")
request.headers = {}
with patch.object(self.handlers.os, "execv", return_value="restarted") as restart:
self.assertEqual(asyncio.run(self.handlers.reboot(request)).status, 403)
request.headers = {"X-EasyUse-Reboot-Token": "test-reboot-token", "Origin": "http://other.test"}
self.assertEqual(asyncio.run(self.handlers.reboot(request)).status, 403)
restart.assert_not_called()
request.headers["Origin"] = "http://localhost:8188"
request.headers["Sec-Fetch-Site"] = "same-site"
self.assertEqual(asyncio.run(self.handlers.reboot(request)).status, 403)
request.headers["Sec-Fetch-Site"] = "same-origin"
self.assertEqual(asyncio.run(self.handlers.reboot(request)), "restarted")
restart.assert_called_once()
def test_reboot_routes_return_http_responses(self):
async def exercise_routes():
app = web.Application()
app.router.add_get("/easyuse/reboot-token", self.handlers.get_reboot_token)
app.router.add_post("/easyuse/reboot", self.handlers.reboot)
async with TestClient(TestServer(app)) as client:
token_response = await client.get("/easyuse/reboot-token")
self.assertEqual(token_response.status, 200)
self.assertEqual((await token_response.json())["token"], "test-reboot-token")
reboot_response = await client.post("/easyuse/reboot")
self.assertEqual(reboot_response.status, 403)
asyncio.run(exercise_routes())
if __name__ == "__main__":
unittest.main()
+14 -8
View File
@@ -114,10 +114,10 @@ export class ModelInfoDialog extends ComfyDialog {
let pre = this.customNotes.substring(end, pos);
if (pre) {
pre = pre.replaceAll("\n", "<br>");
notes.push(
$el("span", {
innerHTML: pre,
textContent: pre,
style: { whiteSpace: "pre-line" },
})
);
}
@@ -127,6 +127,7 @@ export class ModelInfoDialog extends ComfyDialog {
href: m[0],
textContent: m[0],
target: "_blank",
rel: "noopener noreferrer",
})
);
}
@@ -335,7 +336,9 @@ export class ModelInfoDialog extends ComfyDialog {
const blob = await (await fetch(cate.url)).blob();
// Store it in temp
const name = "temp_preview." + new URL(cate.url).pathname.split(".")[1];
const extension = ({"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"})[blob.type]
|| new URL(cate.url).pathname.split(".").pop().toLowerCase();
const name = "temp_preview." + extension;
const body = new FormData();
body.append("image", new File([blob], name));
body.append("overwrite", "true");
@@ -365,10 +368,13 @@ export class ModelInfoDialog extends ComfyDialog {
headers: {
"content-type": "application/json",
},
}).then(_=>{
toast.success($t('Saving Succeed'))
toast.hideLoading()
});
}).then(response => {
if (!response.ok) throw new Error(`Error saving preview (${response.status})`);
toast.success($t('Saving Succeed'));
}).catch(error => {
console.error(error);
toast.error($t('Saving Failed'));
}).finally(() => toast.hideLoading());
this.isSaving = false
app.refreshComboInNodes();
},
@@ -680,4 +686,4 @@ export class LoraInfoDialog extends ModelInfoDialog {
return btns;
}
}
}
+9 -3
View File
@@ -486,10 +486,16 @@ app.registerExtension({
// Only show the reboot option if the server is running on a local network 仅在本地或局域网环境可重启服务
isLocalNetwork(window.location.host) ? {
content: rebootIcon.replace('currentColor','var(--error-color)') + ' '+ $t('Reboot ComfyUI') + ' (EasyUse)',
callback: _ =>{
callback: async _ =>{
if (confirm($t("Are you sure you'd like to reboot the server?"))){
try {
api.fetchApi("/easyuse/reboot");
const tokenResponse = await api.fetchApi("/easyuse/reboot-token");
if (!tokenResponse.ok) throw new Error("Could not get reboot token");
const {token} = await tokenResponse.json();
await api.fetchApi("/easyuse/reboot", {
method: "POST",
headers: {"X-EasyUse-Reboot-Token": token},
});
} catch (exception) {}
}
}
@@ -607,4 +613,4 @@ app.registerExtension({
};
}
},
});
});
+1 -1
View File
@@ -1149,7 +1149,7 @@ app.registerExtension({
for (const list of text) {
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
// w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = list;
}
+18 -7
View File
@@ -1,4 +1,5 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
// 全局Seed
function globalSeedHandler(event) {
@@ -27,21 +28,31 @@ function globalSeedHandler(event) {
api.addEventListener("easyuse-global-seed", globalSeedHandler);
const original_queuePrompt = api.queuePrompt;
async function queuePrompt_with_seed(number, { output, workflow }) {
function addSeedWidgetsToWorkflow(prompt, graph) {
const workflow = prompt?.workflow;
if (!workflow || typeof workflow !== 'object') return prompt;
workflow.seed_widgets = {};
for(let i in app.graph._nodes_by_id) {
let widgets = app.graph._nodes_by_id[i].widgets;
const nodes = graph?._nodes_by_id || {};
for(let i in nodes) {
let widgets = nodes[i].widgets;
if(widgets) {
for(let j in widgets) {
if((widgets[j].name == 'seed_num' || widgets[j].name == 'seed' || widgets[j].name == 'noise_seed') && widgets[j].type != 'converted-widget')
workflow.seed_widgets[i] = parseInt(j);
}
}
}
}
return await original_queuePrompt.call(api, number, { output, workflow });
return prompt;
}
api.queuePrompt = queuePrompt_with_seed;
// Keep ComfyUI's queuePrompt untouched so its validation errors and evolving
// execution options remain owned by the core frontend.
const original_graphToPrompt = app.graphToPrompt;
app.graphToPrompt = function graphToPrompt_with_seed(...args) {
const graph = args[0] || app.rootGraph || app.graph;
return Promise.resolve(original_graphToPrompt.apply(this, args))
.then(prompt => addSeedWidgetsToWorkflow(prompt, graph));
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long