Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67afdc8204 | ||
|
|
8a0f2fc412 | ||
|
|
22145befb3 | ||
|
|
8730ffd140 | ||
|
|
32931f09a7 | ||
|
|
86873e7bda | ||
|
|
375f3b77e0 | ||
|
|
450b1ce4ce | ||
|
|
457b3a81e8 | ||
|
|
271685698b | ||
|
|
859af7e7b4 | ||
|
|
8b522f121d | ||
|
|
5b469409bc | ||
|
|
80e1261b1c | ||
|
|
005c57839c | ||
|
|
cf15032ab6 | ||
|
|
ca93381de8 | ||
|
|
58e077a743 | ||
|
|
4de1ab3b66 | ||
|
|
595e0738a9 | ||
|
|
7535cd0dfd | ||
|
|
960862223b | ||
|
|
54d080bf6a | ||
|
|
625efbfa2f | ||
|
|
5618a748c1 | ||
|
|
130c1b5796 | ||
|
|
3cf9ab4e63 | ||
|
|
ff5e3a34fc | ||
|
|
ec4ca6717f | ||
|
|
b82bb48948 | ||
|
|
d08eedabd3 | ||
|
|
8ba21d0b44 | ||
|
|
337a03bb19 | ||
|
|
aef19b8772 | ||
|
|
d60b61d575 | ||
|
|
a3f051f0c3 | ||
|
|
8ca6ace667 | ||
|
|
81c510c06e | ||
|
|
7601371923 | ||
|
|
b11c634872 | ||
|
|
7c470c67d6 | ||
|
|
b5865efd16 | ||
|
|
5ec3b5ef86 |
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
|
||||
Submodule ComfyUI-Easy-Use-Frontend updated: d0b0a207dd...656ae09121
@@ -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崩溃)
|
||||
|
||||
@@ -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!
|
||||
|
||||
[](https://github.com/yolain/ComfyUI-Easy-Use/stargazers)
|
||||
|
||||
|
||||
+13
-1
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+24
-14
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user