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:
Vito Sansevero
2025-08-04 14:26:52 -07:00
parent b3c510b90d
commit c45c9c0b91
4 changed files with 335 additions and 735 deletions
+3 -3
View File
@@ -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
-732
View File
@@ -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);
}
};
}
}
});
+114
View File
@@ -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)";
};
}
}
});