feat: redesign XYZ Plot Controller using native ComfyUI widgets
- Remove complex HTML/JS custom interface - Create simplified node using standard ComfyUI inputs - Add helpful tooltips and placeholder text via minimal JS - Support range notation (start:stop:step) for numeric values - Show total image count in node title - Use multiline text inputs for value entry - Work with ComfyUI's native widget system
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
"""XYZ Grid nodes for ComfyUI parameter comparisons."""
|
||||
|
||||
from .controller.advanced_node import XYZPlotControllerAdvanced
|
||||
from .controller.simple_node import XYZPlotController
|
||||
from .combiner.node import ImageGridCombiner
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"XYZPlotController": XYZPlotControllerAdvanced,
|
||||
"XYZPlotController": XYZPlotController,
|
||||
"ImageGridCombiner": ImageGridCombiner,
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageGridCombiner": "Image Grid Combiner",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "XYZPlotControllerAdvanced", "ImageGridCombiner"]
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "XYZPlotController", "ImageGridCombiner"]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Simplified XYZ Plot Controller using native ComfyUI widgets."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType
|
||||
from ..utils.helpers import (
|
||||
get_available_models, get_available_vaes, get_available_loras,
|
||||
get_sampler_names, get_scheduler_names, parse_value_string,
|
||||
create_unique_id
|
||||
)
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""Simplified XYZ Plot Controller with native widgets."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# For file-based parameters, we'll use a special format in the values field
|
||||
axis_types = [
|
||||
"none",
|
||||
"model",
|
||||
"vae",
|
||||
"lora",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# X Axis
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"x_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Y Axis
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"y_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Z Axis (optional)
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
"z_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, x_values, y_type, y_values, z_type, z_values, auto_queue, unique_id=None):
|
||||
"""Create grid configuration."""
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._parse_axis_values(x_type, x_values) if x_type != "none" else []
|
||||
y_parsed = self._parse_axis_values(y_type, y_values) if y_type != "none" else []
|
||||
z_parsed = self._parse_axis_values(z_type, z_values) if z_type != "none" else []
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Store grid data for execution
|
||||
if hasattr(self, '_grids'):
|
||||
self._grids[batch_id] = grid_data
|
||||
else:
|
||||
self._grids = {batch_id: grid_data}
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _parse_axis_values(self, axis_type: str, values_str: str) -> List[Any]:
|
||||
"""Parse axis values based on type."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str and axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
if axis_type == "prompt":
|
||||
# For prompts, split by newline instead of comma
|
||||
return [v.strip() for v in values_str.split("\n") if v.strip()]
|
||||
else:
|
||||
# For everything else, split by comma
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"model": "",
|
||||
"vae": "Automatic",
|
||||
"lora": "None",
|
||||
"sampler": "euler",
|
||||
"scheduler": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["model", "vae", "lora", "sampler", "scheduler", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
XYZPlotControllerAdvanced = XYZPlotController
|
||||
@@ -1,732 +0,0 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
|
||||
// Parameter definitions
|
||||
const PARAM_INFO = {
|
||||
none: {
|
||||
description: "No parameter selected",
|
||||
hasOptions: false
|
||||
},
|
||||
model: {
|
||||
description: "Checkpoint/Model files",
|
||||
hasOptions: true,
|
||||
inputNames: ['ckpt_name', 'checkpoint', 'model_name', 'unet_name']
|
||||
},
|
||||
vae: {
|
||||
description: "VAE models",
|
||||
hasOptions: true,
|
||||
inputNames: ['vae_name', 'vae'],
|
||||
addDefault: "Automatic"
|
||||
},
|
||||
lora: {
|
||||
description: "LoRA models",
|
||||
hasOptions: true,
|
||||
inputNames: ['lora_name', 'lora'],
|
||||
addDefault: "None"
|
||||
},
|
||||
sampler: {
|
||||
description: "Sampling algorithms",
|
||||
hasOptions: true,
|
||||
inputNames: ['sampler_name', 'sampler']
|
||||
},
|
||||
scheduler: {
|
||||
description: "Noise schedulers",
|
||||
hasOptions: true,
|
||||
inputNames: ['scheduler', 'scheduler_name']
|
||||
},
|
||||
cfg_scale: {
|
||||
description: "CFG Scale values",
|
||||
example: "Examples: 5, 7.5, 10\nRange: 5:15:2.5"
|
||||
},
|
||||
steps: {
|
||||
description: "Sampling steps",
|
||||
example: "Examples: 20, 30, 40\nRange: 10:50:10"
|
||||
},
|
||||
seed: {
|
||||
description: "Random seeds",
|
||||
example: "Examples: 42, 123, 456\nRange: 0:1000:100"
|
||||
},
|
||||
denoise: {
|
||||
description: "Denoising strength",
|
||||
example: "Examples: 0.3, 0.5, 0.7\nRange: 0.2:1.0:0.2"
|
||||
},
|
||||
flux_guidance: {
|
||||
description: "Flux guidance strength",
|
||||
example: "Examples: 1.0, 3.5, 5.0\nRange: 1:5:0.5"
|
||||
},
|
||||
prompt: {
|
||||
description: "Text prompts",
|
||||
example: "Enter one prompt per line:\n\na beautiful sunset\na mystical forest"
|
||||
}
|
||||
};
|
||||
|
||||
// Get options for parameter types
|
||||
async function getParameterOptions(paramType) {
|
||||
if (!PARAM_INFO[paramType]?.hasOptions) return [];
|
||||
|
||||
try {
|
||||
const resp = await fetch('/object_info');
|
||||
const data = await resp.json();
|
||||
|
||||
const inputNames = PARAM_INFO[paramType].inputNames || [paramType];
|
||||
let allOptions = [];
|
||||
|
||||
// Search through all nodes
|
||||
for (const [nodeName, nodeData] of Object.entries(data)) {
|
||||
if (nodeData.input?.required) {
|
||||
for (const [inputName, inputDef] of Object.entries(nodeData.input.required)) {
|
||||
if (inputNames.includes(inputName) && Array.isArray(inputDef[0])) {
|
||||
allOptions = [...new Set([...allOptions, ...inputDef[0]])];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add default option if specified
|
||||
if (PARAM_INFO[paramType].addDefault) {
|
||||
allOptions.unshift(PARAM_INFO[paramType].addDefault);
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
} catch (e) {
|
||||
console.error(`Error fetching ${paramType} options:`, e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create multi-select dialog
|
||||
function showMultiSelectDialog(options, currentValues, paramType, onApply) {
|
||||
// Create overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
// Create dialog
|
||||
const dialog = document.createElement("div");
|
||||
dialog.style.cssText = `
|
||||
background: #1e1e1e;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
// Title
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = `Select ${paramType.charAt(0).toUpperCase() + paramType.slice(1)}s`;
|
||||
title.style.cssText = "margin: 0 0 15px 0; color: #4ecdc4; font-size: 16px;";
|
||||
dialog.appendChild(title);
|
||||
|
||||
// Search box
|
||||
const searchBox = document.createElement("input");
|
||||
searchBox.type = "text";
|
||||
searchBox.placeholder = "Search...";
|
||||
searchBox.style.cssText = `
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
`;
|
||||
dialog.appendChild(searchBox);
|
||||
|
||||
// Options container
|
||||
const optionsContainer = document.createElement("div");
|
||||
optionsContainer.style.cssText = `
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: #2a2a2a;
|
||||
max-height: 400px;
|
||||
`;
|
||||
|
||||
const checkboxes = [];
|
||||
const labels = [];
|
||||
|
||||
// Create checkboxes
|
||||
options.forEach(option => {
|
||||
const label = document.createElement("label");
|
||||
label.style.cssText = `
|
||||
display: block;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #ddd;
|
||||
transition: background 0.2s;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
`;
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = option;
|
||||
checkbox.checked = currentValues.includes(option);
|
||||
checkbox.style.cssText = `
|
||||
margin-right: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(document.createTextNode(option));
|
||||
|
||||
label.onmouseenter = () => label.style.background = "rgba(78, 205, 196, 0.1)";
|
||||
label.onmouseleave = () => label.style.background = "transparent";
|
||||
|
||||
checkboxes.push(checkbox);
|
||||
labels.push(label);
|
||||
optionsContainer.appendChild(label);
|
||||
});
|
||||
|
||||
dialog.appendChild(optionsContainer);
|
||||
|
||||
// Search functionality
|
||||
searchBox.oninput = () => {
|
||||
const searchTerm = searchBox.value.toLowerCase();
|
||||
labels.forEach((label, i) => {
|
||||
const text = options[i].toLowerCase();
|
||||
label.style.display = text.includes(searchTerm) ? "block" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
// Buttons
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.style.cssText = `
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const selectAllBtn = document.createElement("button");
|
||||
selectAllBtn.textContent = "Select All";
|
||||
selectAllBtn.style.cssText = `
|
||||
padding: 8px 15px;
|
||||
background: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
selectAllBtn.onclick = () => {
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb.parentElement.style.display !== "none") {
|
||||
cb.checked = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.textContent = "Clear All";
|
||||
clearBtn.style.cssText = `
|
||||
padding: 8px 15px;
|
||||
background: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
clearBtn.onclick = () => {
|
||||
checkboxes.forEach(cb => cb.checked = false);
|
||||
};
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.style.cssText = `
|
||||
padding: 8px 15px;
|
||||
background: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
cancelBtn.onclick = () => document.body.removeChild(overlay);
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.textContent = "Apply";
|
||||
applyBtn.style.cssText = `
|
||||
padding: 8px 15px;
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
applyBtn.onclick = () => {
|
||||
const selected = checkboxes
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.value);
|
||||
onApply(selected);
|
||||
document.body.removeChild(overlay);
|
||||
};
|
||||
|
||||
buttonContainer.appendChild(selectAllBtn);
|
||||
buttonContainer.appendChild(clearBtn);
|
||||
buttonContainer.appendChild(cancelBtn);
|
||||
buttonContainer.appendChild(applyBtn);
|
||||
dialog.appendChild(buttonContainer);
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Focus search box
|
||||
searchBox.focus();
|
||||
}
|
||||
|
||||
// Create the enhanced UI
|
||||
function createEnhancedUI(node) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "xyz-controller-ui";
|
||||
container.style.cssText = `
|
||||
width: 360px;
|
||||
height: 620px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
container.innerHTML = `
|
||||
<style>
|
||||
.xyz-controller-ui {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 5px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
color: #ddd;
|
||||
}
|
||||
.xyz-axis-group {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
.xyz-axis-header {
|
||||
font-weight: bold;
|
||||
color: #4ecdc4;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.xyz-axis-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.xyz-axis-label {
|
||||
width: 70px;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
.xyz-axis-select {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
height: 24px;
|
||||
}
|
||||
.xyz-axis-values {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 4px 6px;
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
min-height: 35px;
|
||||
max-height: 50px;
|
||||
resize: vertical;
|
||||
}
|
||||
.xyz-select-button {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.xyz-select-button:hover {
|
||||
background: #357abd;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.xyz-info-box {
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
.xyz-info-box strong {
|
||||
color: #4ecdc4;
|
||||
font-size: 10px;
|
||||
}
|
||||
.xyz-info-example {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
margin-top: 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.xyz-total-count {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #4ecdc4;
|
||||
border: 1px solid #333;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.xyz-total-count.warning {
|
||||
color: #ffaa00;
|
||||
}
|
||||
.xyz-total-count.danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- X Axis -->
|
||||
<div class="xyz-axis-group">
|
||||
<div class="xyz-axis-header">X AXIS</div>
|
||||
<div class="xyz-axis-row">
|
||||
<span class="xyz-axis-label">Parameter:</span>
|
||||
<select class="xyz-axis-select" id="x_axis_type">
|
||||
<option value="none">None</option>
|
||||
<option value="model">Model</option>
|
||||
<option value="vae">VAE</option>
|
||||
<option value="lora">LoRA</option>
|
||||
<option value="sampler">Sampler</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
<option value="cfg_scale">CFG Scale</option>
|
||||
<option value="steps">Steps</option>
|
||||
<option value="seed">Seed</option>
|
||||
<option value="denoise">Denoise</option>
|
||||
<option value="flux_guidance">Flux Guidance</option>
|
||||
<option value="prompt">Prompt</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="xyz-axis-values" id="x_values" placeholder="Values will appear here..."></textarea>
|
||||
<button class="xyz-select-button" id="x_select_btn" style="display: none;">Select Values</button>
|
||||
<div class="xyz-info-box" id="x_info" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Y Axis -->
|
||||
<div class="xyz-axis-group">
|
||||
<div class="xyz-axis-header">Y AXIS</div>
|
||||
<div class="xyz-axis-row">
|
||||
<span class="xyz-axis-label">Parameter:</span>
|
||||
<select class="xyz-axis-select" id="y_axis_type">
|
||||
<option value="none">None</option>
|
||||
<option value="model">Model</option>
|
||||
<option value="vae">VAE</option>
|
||||
<option value="lora">LoRA</option>
|
||||
<option value="sampler">Sampler</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
<option value="cfg_scale">CFG Scale</option>
|
||||
<option value="steps">Steps</option>
|
||||
<option value="seed">Seed</option>
|
||||
<option value="denoise">Denoise</option>
|
||||
<option value="flux_guidance">Flux Guidance</option>
|
||||
<option value="prompt">Prompt</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="xyz-axis-values" id="y_values" placeholder="Values will appear here..."></textarea>
|
||||
<button class="xyz-select-button" id="y_select_btn" style="display: none;">Select Values</button>
|
||||
<div class="xyz-info-box" id="y_info" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Z Axis -->
|
||||
<div class="xyz-axis-group">
|
||||
<div class="xyz-axis-header">Z AXIS (Optional)</div>
|
||||
<div class="xyz-axis-row">
|
||||
<span class="xyz-axis-label">Parameter:</span>
|
||||
<select class="xyz-axis-select" id="z_axis_type">
|
||||
<option value="none">None</option>
|
||||
<option value="model">Model</option>
|
||||
<option value="vae">VAE</option>
|
||||
<option value="lora">LoRA</option>
|
||||
<option value="sampler">Sampler</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
<option value="cfg_scale">CFG Scale</option>
|
||||
<option value="steps">Steps</option>
|
||||
<option value="seed">Seed</option>
|
||||
<option value="denoise">Denoise</option>
|
||||
<option value="flux_guidance">Flux Guidance</option>
|
||||
<option value="prompt">Prompt</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="xyz-axis-values" id="z_values" placeholder="Values will appear here..."></textarea>
|
||||
<button class="xyz-select-button" id="z_select_btn" style="display: none;">Select Values</button>
|
||||
<div class="xyz-info-box" id="z_info" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Total Count -->
|
||||
<div class="xyz-total-count" id="xyz_total_count">Total Images: 0</div>
|
||||
`;
|
||||
|
||||
// Setup event handlers
|
||||
function setupAxisHandlers(axis) {
|
||||
const typeSelect = container.querySelector(`#${axis}_axis_type`);
|
||||
const valuesTextarea = container.querySelector(`#${axis}_values`);
|
||||
const selectBtn = container.querySelector(`#${axis}_select_btn`);
|
||||
const infoBox = container.querySelector(`#${axis}_info`);
|
||||
|
||||
typeSelect.addEventListener('change', async () => {
|
||||
const paramType = typeSelect.value;
|
||||
const info = PARAM_INFO[paramType];
|
||||
|
||||
// Update widget values
|
||||
const typeWidget = (node.originalWidgets || node.widgets).find(w => w.name === `${axis}_axis_type`);
|
||||
if (typeWidget) {
|
||||
typeWidget.value = paramType;
|
||||
typeWidget.callback?.(paramType);
|
||||
}
|
||||
|
||||
if (!info || paramType === 'none') {
|
||||
selectBtn.style.display = 'none';
|
||||
infoBox.style.display = 'none';
|
||||
valuesTextarea.placeholder = 'Values will appear here...';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show info
|
||||
infoBox.style.display = 'block';
|
||||
infoBox.innerHTML = `
|
||||
<strong>${info.description}</strong>
|
||||
${info.example ? `<div class="xyz-info-example">${info.example}</div>` : ''}
|
||||
`;
|
||||
|
||||
// Show/hide select button
|
||||
if (info.hasOptions) {
|
||||
selectBtn.style.display = 'block';
|
||||
selectBtn.textContent = `📁 Select ${paramType.charAt(0).toUpperCase() + paramType.slice(1)}s`;
|
||||
valuesTextarea.placeholder = 'Click the button above to select values...';
|
||||
} else {
|
||||
selectBtn.style.display = 'none';
|
||||
valuesTextarea.placeholder = 'Enter values...';
|
||||
}
|
||||
|
||||
updateTotalCount();
|
||||
});
|
||||
|
||||
selectBtn.addEventListener('click', async () => {
|
||||
const paramType = typeSelect.value;
|
||||
const options = await getParameterOptions(paramType);
|
||||
const currentValues = valuesTextarea.value.split(',').map(v => v.trim()).filter(v => v);
|
||||
|
||||
showMultiSelectDialog(options, currentValues, paramType, (selected) => {
|
||||
valuesTextarea.value = selected.join(', ');
|
||||
const valueWidget = (node.originalWidgets || node.widgets).find(w => w.name === `${axis}_values`);
|
||||
if (valueWidget) {
|
||||
valueWidget.value = valuesTextarea.value;
|
||||
valueWidget.callback?.(valuesTextarea.value);
|
||||
}
|
||||
updateTotalCount();
|
||||
});
|
||||
});
|
||||
|
||||
valuesTextarea.addEventListener('input', () => {
|
||||
const valueWidget = (node.originalWidgets || node.widgets).find(w => w.name === `${axis}_values`);
|
||||
if (valueWidget) {
|
||||
valueWidget.value = valuesTextarea.value;
|
||||
valueWidget.callback?.(valuesTextarea.value);
|
||||
}
|
||||
updateTotalCount();
|
||||
});
|
||||
}
|
||||
|
||||
function updateTotalCount() {
|
||||
let xCount = 1, yCount = 1, zCount = 1;
|
||||
|
||||
['x', 'y', 'z'].forEach(axis => {
|
||||
const typeSelect = container.querySelector(`#${axis}_axis_type`);
|
||||
const valuesTextarea = container.querySelector(`#${axis}_values`);
|
||||
|
||||
if (typeSelect.value !== 'none' && valuesTextarea.value.trim()) {
|
||||
const values = valuesTextarea.value.trim();
|
||||
|
||||
// Check for range syntax
|
||||
if (values.includes(':')) {
|
||||
const parts = values.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const start = parseFloat(parts[0]);
|
||||
const stop = parseFloat(parts[1]);
|
||||
const step = parts[2] ? parseFloat(parts[2]) : 1;
|
||||
const count = Math.floor((stop - start) / step) + 1;
|
||||
if (axis === 'x') xCount = count;
|
||||
else if (axis === 'y') yCount = count;
|
||||
else if (axis === 'z') zCount = count;
|
||||
}
|
||||
} else {
|
||||
// Count comma-separated values
|
||||
const count = values.split(',').filter(v => v.trim()).length;
|
||||
if (axis === 'x') xCount = count;
|
||||
else if (axis === 'y') yCount = count;
|
||||
else if (axis === 'z') zCount = count;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const total = xCount * yCount * zCount;
|
||||
const totalDiv = container.querySelector('#xyz_total_count');
|
||||
totalDiv.textContent = `Total Images: ${total}`;
|
||||
|
||||
totalDiv.className = 'xyz-total-count';
|
||||
if (total > 100) {
|
||||
totalDiv.className += ' danger';
|
||||
totalDiv.textContent += ' ⚠️';
|
||||
} else if (total > 50) {
|
||||
totalDiv.className += ' warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
['x', 'y', 'z'].forEach(axis => setupAxisHandlers(axis));
|
||||
|
||||
// Sync with existing widget values
|
||||
function syncFromWidgets() {
|
||||
['x', 'y', 'z'].forEach(axis => {
|
||||
const widgets = node.originalWidgets || node.widgets;
|
||||
const typeWidget = widgets.find(w => w.name === `${axis}_axis_type`);
|
||||
const valueWidget = widgets.find(w => w.name === `${axis}_values`);
|
||||
|
||||
if (typeWidget) {
|
||||
const typeSelect = container.querySelector(`#${axis}_axis_type`);
|
||||
typeSelect.value = typeWidget.value;
|
||||
typeSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
if (valueWidget) {
|
||||
const valuesTextarea = container.querySelector(`#${axis}_values`);
|
||||
valuesTextarea.value = valueWidget.value;
|
||||
}
|
||||
});
|
||||
updateTotalCount();
|
||||
}
|
||||
|
||||
setTimeout(syncFromWidgets, 100);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.XYZPlotControllerV2",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "XYZPlotController") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Set initial size immediately - adjusted for compact layout
|
||||
this.size = [380, 680];
|
||||
|
||||
// Store reference to original widgets but hide them visually
|
||||
this.originalWidgets = this.widgets;
|
||||
|
||||
// Hide all original widgets visually but keep them in the array
|
||||
this.widgets.forEach(w => {
|
||||
// Hide DOM elements
|
||||
if (w.element) {
|
||||
w.element.style.display = 'none';
|
||||
if (w.element.parentElement) {
|
||||
w.element.parentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (w.inputEl) {
|
||||
w.inputEl.style.display = 'none';
|
||||
if (w.inputEl.parentElement) {
|
||||
w.inputEl.parentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create custom UI immediately
|
||||
const ui = createEnhancedUI(this);
|
||||
|
||||
// Create the DOM widget
|
||||
const customWidget = this.addDOMWidget("xyz_enhanced_ui", "div", ui, {
|
||||
serialize: false,
|
||||
hideOnZoom: false
|
||||
});
|
||||
|
||||
// Ensure widget has proper size
|
||||
if (customWidget) {
|
||||
customWidget.computeSize = () => [360, 620];
|
||||
customWidget.size = customWidget.computeSize();
|
||||
|
||||
// Ensure the element has proper height constraint
|
||||
if (customWidget.element) {
|
||||
customWidget.element.style.width = '360px';
|
||||
customWidget.element.style.height = '620px';
|
||||
customWidget.element.style.maxHeight = '620px';
|
||||
customWidget.element.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
// Add our custom widget to the widgets array
|
||||
this.widgets.push(customWidget);
|
||||
|
||||
// Schedule a resize to ensure proper display
|
||||
setTimeout(() => {
|
||||
this.setSize(this.size);
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Override onResize to handle widget sizing
|
||||
const onResize = nodeType.prototype.onResize;
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
if (onResize) {
|
||||
onResize.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Update widget size when node is resized
|
||||
const customWidget = this.widgets?.find(w => w.name === "xyz_enhanced_ui");
|
||||
if (customWidget) {
|
||||
customWidget.size = [size[0] - 20, size[1] - 60];
|
||||
if (customWidget.element) {
|
||||
customWidget.element.style.width = customWidget.size[0] + 'px';
|
||||
customWidget.element.style.maxHeight = customWidget.size[1] + 'px';
|
||||
customWidget.element.style.height = 'auto';
|
||||
customWidget.element.style.overflow = 'hidden';
|
||||
}
|
||||
this.setDirtyCanvas(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Helper information for each parameter type
|
||||
const PARAM_HELP = {
|
||||
model: "Enter model names separated by commas:\nmodel1.safetensors, model2.safetensors",
|
||||
vae: "Enter VAE names separated by commas:\nAutomatic, vae1.safetensors, vae2.pt",
|
||||
lora: "Enter LoRA names separated by commas:\nNone, lora1.safetensors, lora2.safetensors",
|
||||
sampler: "Enter sampler names separated by commas:\neuler, euler_ancestral, dpm_2",
|
||||
scheduler: "Enter scheduler names separated by commas:\nnormal, karras, exponential",
|
||||
cfg_scale: "Enter values separated by commas: 5, 7.5, 10\nOr use range: 5:15:2.5",
|
||||
steps: "Enter values separated by commas: 20, 30, 40\nOr use range: 10:50:10",
|
||||
seed: "Enter values separated by commas: 42, 123, 456\nOr use range: 0:1000:100",
|
||||
denoise: "Enter values separated by commas: 0.3, 0.5, 0.7\nOr use range: 0.2:1.0:0.2",
|
||||
clip_skip: "Enter values separated by commas: 1, 2\nCommon values for SDXL",
|
||||
prompt: "Enter prompts separated by new lines:\nbeautiful sunset\nmystical forest\nfuturistic city"
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.XYZPlotHelper",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "XYZPlotController") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Add widget callbacks to update help text
|
||||
const updateHelp = (axisPrefix) => {
|
||||
const typeWidget = this.widgets.find(w => w.name === `${axisPrefix}_type`);
|
||||
const valuesWidget = this.widgets.find(w => w.name === `${axisPrefix}_values`);
|
||||
|
||||
if (typeWidget && valuesWidget) {
|
||||
// Store original callback
|
||||
const originalCallback = typeWidget.callback;
|
||||
|
||||
// Add our callback
|
||||
typeWidget.callback = function(value) {
|
||||
// Update placeholder with help text
|
||||
if (PARAM_HELP[value]) {
|
||||
valuesWidget.inputEl.placeholder = PARAM_HELP[value];
|
||||
valuesWidget.inputEl.title = PARAM_HELP[value];
|
||||
} else {
|
||||
valuesWidget.inputEl.placeholder = "No parameter selected";
|
||||
valuesWidget.inputEl.title = "";
|
||||
}
|
||||
|
||||
// Call original callback
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger initial update
|
||||
typeWidget.callback(typeWidget.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup help for all axes
|
||||
updateHelp('x');
|
||||
updateHelp('y');
|
||||
updateHelp('z');
|
||||
|
||||
// Add a title to show total combinations
|
||||
const originalOnConnectionsChange = this.onConnectionsChange;
|
||||
this.onConnectionsChange = function(type, index, connected, link_info) {
|
||||
if (originalOnConnectionsChange) {
|
||||
originalOnConnectionsChange.call(this, type, index, connected, link_info);
|
||||
}
|
||||
|
||||
// Update title with total count
|
||||
setTimeout(() => {
|
||||
let totalImages = 1;
|
||||
|
||||
['x', 'y', 'z'].forEach(axis => {
|
||||
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
|
||||
const valuesWidget = this.widgets.find(w => w.name === `${axis}_values`);
|
||||
|
||||
if (typeWidget && valuesWidget && typeWidget.value !== 'none') {
|
||||
const values = valuesWidget.value.trim();
|
||||
if (values) {
|
||||
// Count values
|
||||
let count = 1;
|
||||
if (values.includes(':')) {
|
||||
// Range notation
|
||||
const parts = values.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const start = parseFloat(parts[0]);
|
||||
const stop = parseFloat(parts[1]);
|
||||
const step = parts[2] ? parseFloat(parts[2]) : 1;
|
||||
count = Math.floor((stop - start) / step) + 1;
|
||||
}
|
||||
} else if (typeWidget.value === 'prompt') {
|
||||
count = values.split('\n').filter(v => v.trim()).length;
|
||||
} else {
|
||||
count = values.split(',').filter(v => v.trim()).length;
|
||||
}
|
||||
totalImages *= count;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.title = `XYZ Plot Controller (${totalImages} images)`;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Set default title
|
||||
this.title = "XYZ Plot Controller (1 image)";
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user