feat: implement XYZ Plot Controller with RGThree-style widget framework

- Created XYZ Plot Controller node with dynamic widget management
- Implemented full widget persistence across page refreshes
- Added RGThree-style UI with toggles and strength controls
- Fixed text widget serialization issues
- Implemented hide/show pattern for widget management
- Added comprehensive right-click context menus
- Created detailed documentation of the widget framework
- Removed all debug console.log statements for production
This commit is contained in:
Vito Sansevero
2025-08-04 19:17:54 -07:00
parent 7b87b01535
commit 40bfccc952
14 changed files with 3325 additions and 172 deletions
+394
View File
@@ -0,0 +1,394 @@
# RGThree-Style Dynamic Widget Framework for ComfyUI
This document explains how to implement RGThree's Power Lora Loader-style dynamic widget system in your own ComfyUI nodes. This framework provides a clean UI with toggles, dynamic widget management, and proper persistence across page refreshes.
## Key Features
- **Dynamic widget addition/removal** - Users can add/remove items at runtime
- **Toggle switches** - Clean circular toggles instead of checkboxes
- **Strength controls** - Arrow buttons with editable values for fine control
- **Right-click context menus** - Only on the item name area
- **Full persistence** - All values persist across page refreshes
- **Hide/show widgets** - Proper cleanup when switching between types
## Core Implementation Pattern
### 1. Node Setup in JavaScript
```javascript
app.registerExtension({
name: "YourExtension.YourNode",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "YourNodeName") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Enable widget serialization
this.serialize_widgets = true;
// Track widget visibility
this.hiddenWidgets = new Set();
// Initialize storage for dynamic widgets
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
category1: [],
category2: []
};
}
// Store references to buttons and text widgets
if (!node.addButtons) {
node.addButtons = {};
}
if (!node.textWidgets) {
node.textWidgets = {};
}
};
}
}
});
```
### 2. Custom Widget Class
```javascript
class DynamicWidget {
constructor(name, value) {
this.name = name;
this._value = value;
this.type = "custom_dynamic_widget";
this.y = 0;
this.options = {};
// Mouse tracking for drag operations
this.mouseState = {
dragging: false,
startX: 0,
startValue: 0,
lastClickTime: 0
};
}
get value() {
return this._value;
}
set value(v) {
this._value = v;
}
serializeValue(node, index) {
// Return a deep copy to prevent modification
return this._value ? { ...this._value } : null;
}
draw(ctx, node, width, y) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = y + height / 2;
let posX = margin;
ctx.save();
// Draw background
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
// Draw toggle (Power Lora style)
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
// Toggle background
ctx.beginPath();
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
// Toggle circle
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
ctx.fillStyle = this.value.on ? "#89B" : "#888";
ctx.beginPath();
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
ctx.fill();
this.toggleBounds = [posX, toggleBgWidth];
posX += toggleBgWidth + innerMargin;
// Apply opacity if disabled
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Draw strength controls (if applicable)
if (this.value.strength !== undefined) {
let strengthX = width - margin - innerMargin;
// Draw arrows and value
// ... (implement arrow drawing as shown in xyz_plot_controller.js)
}
// Draw item name
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(this.value.name || "None", posX, midY);
ctx.restore();
}
mouse(event, pos, node) {
// Handle mouse events for toggle and controls
if (event.type === "mousedown") {
// Check toggle bounds
if (pos[0] >= this.toggleBounds[0] &&
pos[0] <= this.toggleBounds[0] + this.toggleBounds[1]) {
this.value.on = !this.value.on;
node.setDirtyCanvas(true, true);
return true;
}
// Handle other controls...
}
return false;
}
}
```
### 3. Configuration and Restoration
```javascript
// Override onConfigure for proper restoration
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
// Mark as configured to prevent duplicate initialization
this._configured = true;
// Store widget values before ComfyUI modifies them
const savedWidgetValues = [...(info.widgets_values || [])];
// Clear tracking for fresh restoration
if (!this.hiddenWidgets) {
this.hiddenWidgets = new Set();
}
this.dynamicWidgets = { /* categories */ };
this.addButtons = {};
this.textWidgets = {};
// Let ComfyUI restore base widgets
if (onConfigure) {
onConfigure.call(this, info);
}
// Restore dynamic widgets from saved values
// ... (implement restoration logic)
// Manually restore text widget values
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
if (widget && typeof savedValue === 'string' && savedValue !== '') {
widget.value = savedValue;
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
}
}
};
```
### 4. Serialization Override
```javascript
// Override onSerialize to fix widget value persistence
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
// Let ComfyUI serialize first
if (origOnSerialize) {
origOnSerialize.call(this, info);
}
// Fix empty text widget values
if (info.widgets_values && this.widgets) {
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
const widget = this.widgets[i];
const serializedValue = info.widgets_values[i];
// If serialized value is empty but widget has value, fix it
if ((serializedValue === '' || serializedValue === null) &&
widget && widget.value !== '' && widget.value !== null) {
info.widgets_values[i] = widget.value;
}
// Also check inputEl for text widgets
if (widget && widget.inputEl && widget.inputEl.value &&
(serializedValue === '' || serializedValue === null)) {
info.widgets_values[i] = widget.inputEl.value;
}
}
}
};
```
### 5. Right-Click Context Menu
```javascript
// Override getSlotInPosition to detect clicks on widget areas
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(x, y) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
if (!slot) {
// Check if we clicked on a dynamic widget's name area
const localX = x - this.pos[0];
const localY = y - this.pos[1];
for (const w of this.widgets || []) {
if (w.type === "custom_dynamic_widget" && w.y &&
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
// Check if click is within name bounds
if (w.nameBounds && localX >= w.nameBounds[0] &&
localX <= w.nameBounds[0] + w.nameBounds[1]) {
return { widget: w, output: { type: "DYNAMIC_WIDGET" } };
}
}
}
}
return slot;
};
// Override getSlotMenuOptions for context menu
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot?.output?.type === "DYNAMIC_WIDGET") {
const widget = slot.widget;
const menuItems = [
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
}
},
{
content: `⬆️ Move Up`,
disabled: !canMoveUp,
callback: () => { /* implement move */ }
},
{
content: `⬇️ Move Down`,
disabled: !canMoveDown,
callback: () => { /* implement move */ }
},
{
content: `🗑️ Remove`,
callback: () => { /* implement remove */ }
}
];
new LiteGraph.ContextMenu(menuItems, {
title: "WIDGET OPTIONS",
event: app.canvas.last_mouse_event || window.event
});
return null; // Prevent default menu
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
```
### 6. Widget Visibility Management
```javascript
function updateWidgets(node, category, type, skipClear = false) {
// Hide/show widgets instead of removing them
if (!skipClear) {
// Hide all widgets for this category
node.widgets?.forEach(widget => {
if (widget.name?.includes(category)) {
widget.hidden = true;
widget.computeSize = () => [0, 0];
node.hiddenWidgets?.add(widget.name);
}
});
// Clear dynamic widgets
if (node.dynamicWidgets[category]) {
while (node.dynamicWidgets[category].length > 0) {
const widget = node.dynamicWidgets[category].pop();
const index = node.widgets.indexOf(widget);
if (index > -1) {
node.widgets.splice(index, 1);
}
}
}
}
// Add or unhide widgets based on type
if (needsTextWidget(type)) {
const widgetName = `${category}_text`;
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
// Create new widget
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true
}]);
node.textWidgets[category] = textWidget.widget;
} else {
// Unhide existing widget
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
node.textWidgets[category] = existingWidget;
}
}
}
```
## Best Practices
1. **Always use hide/show instead of remove/add** for text widgets to preserve values
2. **Track widget state** in dedicated objects (dynamicWidgets, textWidgets, etc.)
3. **Override serialization** to ensure ComfyUI properly saves widget values
4. **Use skipClear flags** during restoration to prevent widget clearing
5. **Implement proper mouse bounds checking** for custom controls
6. **Store metadata** (_axis, _type) with widget values for easier restoration
7. **Don't auto-resize nodes** - respect user's manual sizing
## Common Pitfalls to Avoid
1. **Don't remove widgets during configure** - this loses their values
2. **Don't rely on widget indices** - they can change
3. **Don't forget to handle inputEl** for text widgets
4. **Don't create widgets without checking if they exist** first
5. **Always deep copy values** when serializing to prevent modification
## Testing Checklist
- [ ] Widgets persist across page refresh
- [ ] Toggle states are maintained
- [ ] Strength/value controls work with click and drag
- [ ] Right-click menu only appears on name area
- [ ] Moving widgets up/down works correctly
- [ ] Removing widgets works without errors
- [ ] Switching between types doesn't leave artifacts
- [ ] All text input types persist (numbers, ranges, prompts)
- [ ] Hidden widgets don't take up visual space
- [ ] Widget values serialize correctly in workflow JSON
This framework provides a robust foundation for creating professional, user-friendly ComfyUI nodes with dynamic widget management that matches the quality of RGThree's implementations.
+684
View File
@@ -0,0 +1,684 @@
# RGThree Widget Framework - Complete Example Implementation
This file provides a complete, working example of implementing the RGThree-style widget framework for a hypothetical "Advanced Sampler Controller" node.
## Complete Implementation Example
```javascript
// File: web/advanced_sampler_controller.js
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
// Widget counter for unique names
let widgetCounter = 0;
// Custom dynamic widget class
class SamplerDynamicWidget {
constructor(name, value) {
this.name = name;
this._value = value;
this.type = "sampler_dynamic_widget";
this.y = 0;
this.options = {};
// Mouse state for drag operations
this.mouseState = {
dragging: false,
startX: 0,
startValue: 0,
lastClickTime: 0
};
}
get value() {
return this._value;
}
set value(v) {
this._value = v;
}
serializeValue(node, index) {
return this._value ? { ...this._value } : null;
}
draw(ctx, node, width, y) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = y + height / 2;
let posX = margin;
ctx.save();
// Background
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
// Toggle
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
// Toggle background
ctx.beginPath();
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
// Toggle circle
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
ctx.fillStyle = this.value.on ? "#89B" : "#888";
ctx.beginPath();
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
ctx.fill();
// Store bounds for mouse interaction
this.toggleBounds = [posX, toggleBgWidth];
posX += toggleBgWidth + innerMargin;
// Apply opacity if disabled
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Strength controls and value
let strengthX = width - margin - innerMargin;
// Down arrow
const arrowSize = 10;
const arrowX = strengthX - arrowSize;
ctx.fillStyle = "#666";
ctx.beginPath();
ctx.moveTo(arrowX + arrowSize/2, midY + 3);
ctx.lineTo(arrowX + 2, midY - 3);
ctx.lineTo(arrowX + arrowSize - 2, midY - 3);
ctx.closePath();
ctx.fill();
this.downArrowBounds = [arrowX, arrowSize];
strengthX = arrowX - innerMargin;
// Up arrow
const upArrowX = strengthX - arrowSize;
ctx.beginPath();
ctx.moveTo(upArrowX + arrowSize/2, midY - 3);
ctx.lineTo(upArrowX + 2, midY + 3);
ctx.lineTo(upArrowX + arrowSize - 2, midY + 3);
ctx.closePath();
ctx.fill();
this.upArrowBounds = [upArrowX, arrowSize];
strengthX = upArrowX - innerMargin;
// Strength value
const strengthText = this.value.strength.toFixed(2);
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.font = `${ctx.font}`;
const textMetrics = ctx.measureText(strengthText);
const strengthTextX = strengthX - textMetrics.width/2 - 4;
// Draggable background
ctx.fillStyle = "rgba(255,255,255,0.1)";
ctx.beginPath();
ctx.roundRect(strengthTextX - textMetrics.width/2 - 2, y + 4,
textMetrics.width + 4, height - 8, [3]);
ctx.fill();
// Value text
ctx.fillStyle = this.value.on ? "#FFF" : "#AAA";
ctx.fillText(strengthText, strengthTextX, midY);
this.strengthBounds = [strengthTextX - textMetrics.width/2 - 2, textMetrics.width + 4];
// Name
const nameX = posX;
const maxNameWidth = strengthTextX - textMetrics.width/2 - nameX - 10;
ctx.textAlign = "left";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
// Clip long names
const displayName = this.value.name || "None";
let truncatedName = displayName;
if (ctx.measureText(displayName).width > maxNameWidth) {
while (truncatedName.length > 0 &&
ctx.measureText(truncatedName + "...").width > maxNameWidth) {
truncatedName = truncatedName.slice(0, -1);
}
truncatedName += "...";
}
ctx.fillText(truncatedName, nameX, midY);
// Store name bounds for right-click detection
this.nameBounds = [nameX, ctx.measureText(truncatedName).width];
ctx.restore();
}
mouse(event, pos, node) {
const margin = 10;
const localX = pos[0] - margin;
if (event.type === "mousedown") {
// Toggle click
if (localX >= this.toggleBounds[0] &&
localX <= this.toggleBounds[0] + this.toggleBounds[1]) {
this.value.on = !this.value.on;
node.setDirtyCanvas(true, true);
return true;
}
// Up arrow
if (localX >= this.upArrowBounds[0] &&
localX <= this.upArrowBounds[0] + this.upArrowBounds[1]) {
this.value.strength = Math.min(this.value.strength + 0.1, 10);
node.setDirtyCanvas(true, true);
return true;
}
// Down arrow
if (localX >= this.downArrowBounds[0] &&
localX <= this.downArrowBounds[0] + this.downArrowBounds[1]) {
this.value.strength = Math.max(this.value.strength - 0.1, -10);
node.setDirtyCanvas(true, true);
return true;
}
// Strength drag start
if (localX >= this.strengthBounds[0] &&
localX <= this.strengthBounds[0] + this.strengthBounds[1]) {
this.mouseState.dragging = true;
this.mouseState.startX = pos[0];
this.mouseState.startValue = this.value.strength;
// Double-click detection
const now = Date.now();
if (now - this.mouseState.lastClickTime < 300) {
// Double-click - show input dialog
const newValue = prompt("Enter strength value:", this.value.strength);
if (newValue !== null && !isNaN(parseFloat(newValue))) {
this.value.strength = Math.max(-10, Math.min(10, parseFloat(newValue)));
node.setDirtyCanvas(true, true);
}
this.mouseState.dragging = false;
}
this.mouseState.lastClickTime = now;
return true;
}
}
else if (event.type === "mousemove" && this.mouseState.dragging) {
const deltaX = pos[0] - this.mouseState.startX;
const sensitivity = 0.01;
this.value.strength = Math.max(-10, Math.min(10,
this.mouseState.startValue + deltaX * sensitivity));
node.setDirtyCanvas(true, true);
return true;
}
else if (event.type === "mouseup") {
this.mouseState.dragging = false;
}
return false;
}
computeSize() {
return [node.size[0], LiteGraph.NODE_WIDGET_HEIGHT];
}
}
// Main extension registration
app.registerExtension({
name: "Example.AdvancedSamplerController",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "AdvancedSamplerController") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Enable widget serialization
this.serialize_widgets = true;
// Initialize tracking
this.hiddenWidgets = new Set();
// Initialize storage
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
samplers: [],
schedulers: []
};
}
if (!node.addButtons) {
node.addButtons = {};
}
if (!node.textWidgets) {
node.textWidgets = {};
}
// Override configuration
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
this._configured = true;
// Save widget values before ComfyUI modifies them
const savedWidgetValues = [...(info.widgets_values || [])];
// Clear for fresh restoration
if (!this.hiddenWidgets) {
this.hiddenWidgets = new Set();
}
this.dynamicWidgets = {
samplers: [],
schedulers: []
};
this.addButtons = {};
this.textWidgets = {};
// Let ComfyUI restore base widgets
if (onConfigure) {
onConfigure.call(this, info);
}
// Restore dynamic widgets
let widgetIndex = this.widgets.length;
for (let i = widgetIndex; i < savedWidgetValues.length; i++) {
const value = savedWidgetValues[i];
if (value && typeof value === 'object' && value._type) {
const widget = new SamplerDynamicWidget(
`dynamic_${widgetCounter++}`,
value
);
this.addCustomWidget(widget);
if (this.dynamicWidgets[value._type]) {
this.dynamicWidgets[value._type].push(widget);
}
}
}
// Restore text widget values
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
if (widget && typeof savedValue === 'string' && savedValue !== '') {
widget.value = savedValue;
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
}
}
// Update UI based on restored state
if (this.widgets?.length > 0) {
const typeWidget = this.widgets.find(w => w.name === "sampler_type");
if (typeWidget) {
updateTypeWidgets(this, typeWidget.value, true);
}
}
};
// Override serialization
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
if (origOnSerialize) {
origOnSerialize.call(this, info);
}
// Fix empty text widget values
if (info.widgets_values && this.widgets) {
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
const widget = this.widgets[i];
const serializedValue = info.widgets_values[i];
if ((serializedValue === '' || serializedValue === null) &&
widget && widget.value !== '' && widget.value !== null) {
info.widgets_values[i] = widget.value;
}
if (widget && widget.inputEl && widget.inputEl.value &&
(serializedValue === '' || serializedValue === null)) {
info.widgets_values[i] = widget.inputEl.value;
}
}
}
};
// Implement right-click context menu
implementContextMenu(node);
// Widget change handlers
const samplerWidget = this.widgets.find(w => w.name === "sampler_type");
if (samplerWidget) {
const origCallback = samplerWidget.callback;
samplerWidget.callback = function() {
if (origCallback) {
origCallback.apply(this, arguments);
}
updateTypeWidgets(node, samplerWidget.value);
};
}
};
}
}
});
// Helper function to update widgets based on type
function updateTypeWidgets(node, type, skipClear = false) {
if (!skipClear) {
// Hide text widgets
node.widgets?.forEach(widget => {
if (widget.name?.includes("custom_values")) {
widget.hidden = true;
widget.computeSize = () => [0, 0];
node.hiddenWidgets?.add(widget.name);
}
});
// Clear dynamic widgets
if (node.dynamicWidgets.samplers) {
while (node.dynamicWidgets.samplers.length > 0) {
const widget = node.dynamicWidgets.samplers.pop();
const index = node.widgets.indexOf(widget);
if (index > -1) {
node.widgets.splice(index, 1);
}
}
}
}
// Add or unhide widgets based on type
if (type === "custom") {
const widgetName = "custom_values";
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true
}]);
node.textWidgets.custom = textWidget.widget;
} else {
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
node.textWidgets.custom = existingWidget;
}
} else if (type === "samplers") {
// Add button for samplers
if (!node.addButtons.samplers) {
const button = node.addWidget("button", "+ Add Sampler", null, () => {
addDynamicWidget(node, "samplers");
});
node.addButtons.samplers = button;
}
}
}
// Helper function to add dynamic widgets
function addDynamicWidget(node, type) {
const widget = new SamplerDynamicWidget(
`dynamic_${widgetCounter++}`,
{
on: true,
name: type === "samplers" ? "euler" : "normal",
strength: 1.0,
_type: type
}
);
node.addCustomWidget(widget);
node.dynamicWidgets[type].push(widget);
}
// Helper function to implement context menu
function implementContextMenu(node) {
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(x, y) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
if (!slot) {
const localX = x - this.pos[0];
const localY = y - this.pos[1];
for (const w of this.widgets || []) {
if (w.type === "sampler_dynamic_widget" && w.y &&
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
if (w.nameBounds && localX >= w.nameBounds[0] &&
localX <= w.nameBounds[0] + w.nameBounds[1]) {
return { widget: w, output: { type: "SAMPLER_WIDGET" } };
}
}
}
}
return slot;
};
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot?.output?.type === "SAMPLER_WIDGET") {
const widget = slot.widget;
const arrayName = widget.value._type;
const array = this.dynamicWidgets[arrayName];
const currentIndex = array.indexOf(widget);
const menuItems = [
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
}
},
{
content: `⬆️ Move Up`,
disabled: currentIndex === 0,
callback: () => {
if (currentIndex > 0) {
// Swap in array
[array[currentIndex - 1], array[currentIndex]] =
[array[currentIndex], array[currentIndex - 1]];
// Swap in widgets
const widgetIndex = this.widgets.indexOf(widget);
const prevWidget = array[currentIndex];
const prevIndex = this.widgets.indexOf(prevWidget);
if (widgetIndex > -1 && prevIndex > -1) {
[this.widgets[prevIndex], this.widgets[widgetIndex]] =
[this.widgets[widgetIndex], this.widgets[prevIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
{
content: `⬇️ Move Down`,
disabled: currentIndex === array.length - 1,
callback: () => {
if (currentIndex < array.length - 1) {
// Swap in array
[array[currentIndex], array[currentIndex + 1]] =
[array[currentIndex + 1], array[currentIndex]];
// Swap in widgets
const widgetIndex = this.widgets.indexOf(widget);
const nextWidget = array[currentIndex];
const nextIndex = this.widgets.indexOf(nextWidget);
if (widgetIndex > -1 && nextIndex > -1) {
[this.widgets[widgetIndex], this.widgets[nextIndex]] =
[this.widgets[nextIndex], this.widgets[widgetIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
null, // Separator
{
content: `🗑️ Remove`,
callback: () => {
const index = array.indexOf(widget);
if (index > -1) {
array.splice(index, 1);
}
const wIndex = this.widgets.indexOf(widget);
if (wIndex > -1) {
this.widgets.splice(wIndex, 1);
}
this.setDirtyCanvas(true, true);
}
}
];
new LiteGraph.ContextMenu(menuItems, {
title: "SAMPLER OPTIONS",
event: app.canvas.last_mouse_event || window.event
});
return null;
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
}
```
## Python Node Definition
```python
# File: kikotools/tools/advanced_sampler_controller/node.py
class AdvancedSamplerController:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sampler_type": (["samplers", "custom", "schedulers"], {
"default": "samplers"
}),
"enabled": ("BOOLEAN", {"default": True}),
},
"optional": {
"custom_values": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = ("SAMPLER_CONFIG",)
RETURN_NAMES = ("config",)
FUNCTION = "process"
CATEGORY = "ComfyAssets"
def process(self, sampler_type, enabled, custom_values="", **kwargs):
config = {
"type": sampler_type,
"enabled": enabled,
"samplers": [],
"custom": custom_values
}
# Process dynamic widgets
for key, value in kwargs.items():
if isinstance(value, dict) and value.get("_type") == "samplers":
if value.get("on", False):
config["samplers"].append({
"name": value.get("name"),
"strength": value.get("strength", 1.0)
})
return (config,)
```
## Key Implementation Points
1. **Widget Class Design**
- Custom widget class with proper value getter/setter
- `serializeValue` method for persistence
- Complete `draw` and `mouse` methods
- Proper bounds tracking for all interactive elements
2. **Node Setup**
- `serialize_widgets = true` in onNodeCreated
- Tracking objects for dynamic widgets, buttons, and text widgets
- Hidden widgets set for visibility management
3. **Configuration Override**
- Save widget values before ComfyUI modifies them
- Clear tracking objects for fresh restoration
- Restore dynamic widgets from saved values
- Manually restore text widget values
4. **Serialization Override**
- Fix empty text widget values
- Check both widget.value and widget.inputEl.value
- Ensure all widget types persist correctly
5. **Context Menu Implementation**
- Override getSlotInPosition to detect widget clicks
- Check name bounds for right-click detection
- Return custom slot type for menu trigger
- Override getSlotMenuOptions for menu items
6. **Widget Management**
- Hide/show pattern instead of remove/add
- Proper cleanup when switching types
- Dynamic widget arrays for organization
- Button widgets for adding new items
## Testing Your Implementation
1. **Create Test Workflow**
```json
{
"nodes": [{
"type": "AdvancedSamplerController",
"widgets_values": [
"samplers",
true,
"",
{
"on": true,
"name": "euler",
"strength": 0.8,
"_type": "samplers"
}
]
}]
}
```
2. **Test Checklist**
- [ ] Add dynamic widgets with button
- [ ] Toggle on/off states persist
- [ ] Strength values persist after refresh
- [ ] Right-click menu only on name area
- [ ] Move up/down works correctly
- [ ] Remove widget works
- [ ] Switch types doesn't leave artifacts
- [ ] Text values persist
- [ ] Double-click to edit strength works
3. **Debug Tips**
- Add console.log in key methods
- Check browser console for errors
- Verify widget array contents
- Test with workflow JSON export/import
This complete example demonstrates all aspects of the RGThree widget framework and can be adapted for any custom node that needs dynamic widget management with professional UI/UX.
@@ -0,0 +1,64 @@
{
"last_node_id": 5,
"last_link_id": 6,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [400, 300],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [1]},
{"name": "x_string", "type": "STRING", "links": null},
{"name": "x_int", "type": "INT", "links": null},
{"name": "x_float", "type": "FLOAT", "links": null},
{"name": "y_string", "type": "STRING", "links": null},
{"name": "y_int", "type": "INT", "links": null},
{"name": "y_float", "type": "FLOAT", "links": null},
{"name": "z_string", "type": "STRING", "links": null},
{"name": "z_int", "type": "INT", "links": null},
{"name": "z_float", "type": "FLOAT", "links": null},
{"name": "batch_id", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [
"models",
"cfg_scale",
"none",
true
]
},
{
"id": 2,
"type": "ImageGridCombiner",
"pos": [600, 100],
"size": [315, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": null},
{"name": "grid_data", "type": "XYZ_GRID", "link": 1}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": null},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [20, 10, 30, 30, true]
}
],
"links": [
[1, 1, 0, 2, 1, "XYZ_GRID"]
],
"groups": [],
"config": {},
"extra": {
"info": "Example workflow showing the new advanced XYZ Plot Controller with dynamic widget addition."
},
"version": 0.4
}
@@ -0,0 +1,65 @@
# XYZ Plot Controller - Advanced Implementation
## Overview
This is a complete reimplementation of the XYZ Plot Controller using the Power Lora Loader architecture from rgthree. The implementation provides dynamic widget management with an intuitive interface.
## Key Features
### Dynamic Widget System
- **"➕ Add [Type]" Buttons**: When you select models, vaes, loras, samplers, or schedulers for an axis, a button appears to add selections
- **Toggle On/Off**: Each dynamic widget has a checkbox to enable/disable it without removing
- **Right-Click Menu**: Right-click any dynamic widget to remove or toggle it
- **Live Count Updates**: Node title shows total image count in real-time
### Supported Axis Types
- **Models**: Dynamic dropdown widgets with available checkpoints
- **VAEs**: Dynamic dropdown widgets (includes "Automatic" option)
- **LoRAs**: Dynamic dropdown widgets (includes "None" option)
- **Samplers**: Dynamic dropdown widgets with all sampler options
- **Schedulers**: Dynamic dropdown widgets with scheduler options
- **Numeric Parameters**: Text areas with helpful placeholders
- CFG Scale
- Steps
- Seed
- Denoise
- CLIP Skip
- **Prompts**: Multi-line text area for prompt variations
### Technical Implementation
#### Python Backend (`xyz_plot_advanced.py`)
- Uses `FlexibleOptionalInputType` to accept any number of dynamic inputs
- Processes kwargs to extract widget values in format: `{axis}_{type}_{id}`
- Each dynamic widget sends: `{ "on": bool, "value": string }`
#### JavaScript Frontend (`xyz_plot_rgthree.js`)
- Manages dynamic widget creation/removal
- Custom widget drawing with toggle checkboxes
- Serialization/deserialization for workflow saving
- Real-time validation and counting
## Usage
1. Add the "XYZ Plot Controller (Advanced)" node
2. Select axis types (X, Y, Z)
3. Click "➕ Add [Type]" to add selections for that axis
4. Toggle widgets on/off with checkboxes
5. Right-click widgets for more options
6. For numeric types, use comma-separated values or ranges (e.g., "5:15:2.5")
7. For prompts, enter one per line
## Architecture Benefits
- **Clean Separation**: Python handles data, JavaScript handles UI
- **Flexible Input System**: Can accept unlimited dynamic widgets
- **Persistent State**: All widget states are saved with the workflow
- **Intuitive Interface**: Matches Power Lora Loader's proven UX patterns
- **Performance**: Only processes enabled widgets
## Future Enhancements
- Model/LoRA info display (CivitAI integration)
- Drag-and-drop reordering
- Preset management
- Batch widget operations
+68
View File
@@ -0,0 +1,68 @@
# XYZ Grid Nodes for ComfyUI
Advanced parameter comparison grid generator for ComfyUI with Power Lora Loader-inspired interface.
## Features
### XYZ Plot Controller
- **Dynamic Multi-Selection**: Native dropdown widgets for selecting multiple models, VAEs, LoRAs, samplers, and schedulers
- **Smart Widget Management**: Widgets automatically show/hide based on selected axis types
- **Visual Organization**: Grouped widgets with headers for better organization
- **Right-Click Context Menu**:
- Clear all selections for a specific type
- Show image count breakdown
- Keyboard shortcuts (Ctrl+Shift+C to clear all)
- **Real-time Image Count**: Node title shows total images that will be generated
- **Warning System**: Visual warning when generating over 100 images
### Supported Parameter Types
- **Models**: Multiple checkpoint selection
- **VAEs**: Multiple VAE selection with "Automatic" option
- **LoRAs**: Multiple LoRA selection with "None" option
- **Samplers**: euler, euler_ancestral, heun, dpm_2, etc.
- **Schedulers**: normal, karras, exponential, etc.
- **Numeric Parameters**:
- CFG Scale
- Steps
- Seed
- Denoise
- CLIP Skip
- Support for ranges (e.g., "5:15:2.5" generates 5, 7.5, 10, 12.5, 15)
- **Prompts**: Multiple prompts (one per line)
### Image Grid Combiner
- Automatic grid assembly with customizable spacing
- Smart labeling with parameter values
- Z-axis support for generating multiple grid pages
- Font size and label customization options
## Usage
1. Add an XYZ Plot Controller node
2. Select axis types (X, Y, and optionally Z)
3. Use the dropdown widgets to select values for each axis
4. Connect to your workflow (models, samplers, etc.)
5. Add Image Grid Combiner at the end to create the labeled grid
## Workflow Example
```
[XYZ Plot Controller] → [Checkpoint Loader] → [Sampling] → [Image Grid Combiner] → [Save Image]
```
The controller outputs the current iteration values which can be connected to corresponding nodes in your workflow.
## Tips
- Use the right-click menu to quickly clear selections
- Check the image count in the node title before running
- For large grids, consider using the Z-axis to split into multiple pages
- Numeric ranges are more efficient than listing each value
## Implementation Details
The implementation uses a hybrid approach:
- Python backend with native ComfyUI widget support
- JavaScript frontend for enhanced UI features
- Inspired by Power Lora Loader's dynamic widget management
- Context menus and keyboard shortcuts for power users
+1 -1
View File
@@ -1,6 +1,6 @@
"""XYZ Grid nodes for ComfyUI parameter comparisons."""
from .controller.multi_select_node import XYZPlotController
from .controller.power_node import XYZPlotController
from .combiner.node import ImageGridCombiner
NODE_CLASS_MAPPINGS = {
@@ -0,0 +1,243 @@
"""XYZ Plot Controller with dynamic widget addition."""
from typing import Dict, List, Any, Tuple, Union
import folder_paths
from ..utils.helpers import create_unique_id
class XYZPlotController:
"""XYZ Plot Controller with dynamic selections like Power Lora Loader."""
# Allow any input to support dynamic widget addition
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def INPUT_TYPES(cls):
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
# Base inputs that are always present
inputs = {
"required": {
# Axis configuration
"x_type": (axis_types, {"default": "none"}),
"y_type": (axis_types, {"default": "none"}),
"z_type": (axis_types, {"default": "none"}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"optional": {
# Single inputs for numeric/prompt values
"numeric_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
}),
"prompt_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For prompts: enter each prompt on a new line"
})
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
return inputs
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, y_type, z_type, auto_queue, unique_id=None, **kwargs):
"""Create grid configuration from dynamic selections."""
# Extract values from kwargs based on type
models = self._extract_values(kwargs, "MODEL_", exclude="none")
vaes = self._extract_values(kwargs, "VAE_", exclude="none")
loras = self._extract_values(kwargs, "LORA_", exclude="none")
samplers = self._extract_values(kwargs, "SAMPLER_", exclude="none")
schedulers = self._extract_values(kwargs, "SCHEDULER_", exclude="none")
# Get numeric and prompt values
numeric_values = kwargs.get("numeric_values", "")
prompt_values = kwargs.get("prompt_values", "")
# Parse values for each axis
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
# 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)
# 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 - {x_parsed}")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _extract_values(self, kwargs: Dict[str, Any], prefix: str, exclude: str = None) -> List[str]:
"""Extract non-empty values from kwargs with given prefix."""
values = []
i = 1
while f"{prefix}{i}" in kwargs:
value = kwargs[f"{prefix}{i}"]
if value and value != exclude:
values.append(value)
i += 1
return values
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
"""Get values for a specific axis type."""
if axis_type == "none":
return []
elif axis_type == "models":
return models
elif axis_type == "vaes":
return vaes
elif axis_type == "loras":
return loras
elif axis_type == "samplers":
return samplers
elif axis_type == "schedulers":
return schedulers
elif axis_type == "prompt":
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, numeric_values)
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
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
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 = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "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 ["models", "vaes", "loras", "samplers", "schedulers", "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)
@@ -0,0 +1,302 @@
"""XYZ Plot Controller with Power Lora Loader-style dynamic widgets."""
from typing import Dict, List, Any, Tuple, Union, Optional
import folder_paths
from ..utils.helpers import create_unique_id
class FlexibleOptionalInputType(dict):
"""Input that allows any input to be connected."""
def __init__(self, input_type):
super().__init__()
self.update({
"input": {
"type": input_type,
"match": ["*"]
}
})
class XYZPlotController:
"""XYZ Plot Controller with dynamic widget management."""
@classmethod
def INPUT_TYPES(cls):
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
inputs = {
"required": {
# Axis configuration
"x_type": (axis_types, {"default": "none"}),
"y_type": (axis_types, {"default": "none"}),
"z_type": (axis_types, {"default": "none"}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"optional": {
# Static inputs for numeric/prompt values
"numeric_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
}),
"prompt_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For prompts: enter each prompt on a new line"
})
},
"hidden": {
"unique_id": "UNIQUE_ID",
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO"
}
}
# Since we will pass any number of selections from the UI,
# this needs to always allow an optional input
inputs["optional"] = FlexibleOptionalInputType("*")
return inputs
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="none", y_type="none", z_type="none",
auto_queue=True, numeric_values="", prompt_values="",
unique_id=None, prompt=None, extra_pnginfo=None, **kwargs):
"""Create grid configuration from dynamic selections."""
# Extract dynamic values from kwargs
models = []
vaes = []
loras = []
samplers = []
schedulers = []
# Process all kwargs to find dynamic widgets
for key, value in kwargs.items():
if key.startswith("x_") or key.startswith("y_") or key.startswith("z_"):
# Handle dynamic widget values
if isinstance(value, dict) and "on" in value and value["on"]:
# Extract the resource type and axis
parts = key.split("_")
if len(parts) >= 3:
axis = parts[0]
resource_type = parts[1]
# Store the value based on type
if resource_type == "models" and value.get("value") != "none":
models.append(value["value"])
elif resource_type == "vaes" and value.get("value") != "none":
vaes.append(value["value"])
elif resource_type == "loras" and value.get("value") != "none":
# For loras, store both name and strength
lora_data = {
"name": value["value"],
"strength": value.get("strength", 1.0)
}
loras.append(lora_data)
elif resource_type == "samplers" and value.get("value") != "none":
samplers.append(value["value"])
elif resource_type == "schedulers" and value.get("value") != "none":
schedulers.append(value["value"])
# Parse values for each axis
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
# 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)
# 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 - {x_parsed}")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
"""Get values for a specific axis type."""
if axis_type == "none":
return []
elif axis_type == "models":
return models
elif axis_type == "vaes":
return vaes
elif axis_type == "loras":
return loras
elif axis_type == "samplers":
return samplers
elif axis_type == "schedulers":
return schedulers
elif axis_type == "prompt":
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, numeric_values)
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
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
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 = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "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 ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
# For loras, return the name string
if axis_type == "loras" and isinstance(value, dict):
return (value.get("name", ""), 0, 0.0)
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)
def _apply_lora(self, model, clip, lora_data: dict):
"""Apply a lora to model and clip."""
try:
# Import LoraLoader from ComfyUI
from nodes import LoraLoader
import folder_paths
lora_name = lora_data.get("name")
strength = lora_data.get("strength", 1.0)
if not lora_name:
return model, clip
# Get the full path to the lora
lora_path = folder_paths.get_full_path("loras", lora_name)
if not lora_path:
print(f"[XYZ Grid] Warning: LoRA '{lora_name}' not found")
return model, clip
# Apply the lora
loader = LoraLoader()
model, clip = loader.load_lora(model, clip, lora_name, strength, strength)
return model, clip
except Exception as e:
print(f"[XYZ Grid] Error applying LoRA: {e}")
return model, clip
@@ -0,0 +1,257 @@
"""XYZ Plot Controller with Power Lora Loader-style dynamic widget management."""
from typing import Dict, List, Any, Tuple, Union, Optional
# Remove complex imports to avoid circular dependencies
import uuid
# Import folder_paths only when needed
try:
import folder_paths
except ImportError:
folder_paths = None
def create_unique_id() -> str:
"""Create unique ID for a grid batch."""
return str(uuid.uuid4())[:8]
class AnyType(str):
"""A special class that is always equal in not equal comparisons."""
def __ne__(self, __value: object) -> bool:
return False
class FlexibleOptionalInputType(dict):
"""
A special class to make flexible nodes that pass data to our python handlers.
This allows dynamic inputs from the JavaScript side.
"""
def __init__(self, input_type):
super().__init__()
self.type = input_type
def __contains__(self, key):
# Always return True to accept any input
return True
def __getitem__(self, key):
# Return a tuple that ComfyUI expects for input types
return (self.type,)
# Create any_type instance
any_type = AnyType("*")
class XYZPlotController:
"""XYZ Plot Controller with dynamic widget management inspired by Power Lora Loader."""
@classmethod
def INPUT_TYPES(cls):
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
return {
"required": {
# Axis configuration
"x_type": (axis_types, {"default": "none"}),
"y_type": (axis_types, {"default": "none"}),
"z_type": (axis_types, {"default": "none"}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
# Accept any number of dynamic inputs from JavaScript
"optional": FlexibleOptionalInputType(any_type),
"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="none", y_type="none", z_type="none", auto_queue=True, unique_id=None, **kwargs):
"""Create grid configuration from dynamic selections."""
# Initialize collections for each axis
axis_values = {
"x": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
"y": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
"z": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""}
}
# Process all kwargs to extract dynamic widget values
for key, value in kwargs.items():
# Handle dynamic model/vae/lora/sampler/scheduler widgets
# Format: x_models_1, y_vaes_2, etc.
parts = key.split("_")
if len(parts) >= 3 and parts[0] in ["x", "y", "z"]:
axis = parts[0]
widget_type = parts[1]
if widget_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
if isinstance(value, dict) and value.get("on", True) and value.get("value"):
axis_values[axis][widget_type].append(value["value"])
elif widget_type == "numeric":
axis_values[axis]["numeric"] = value
elif widget_type == "prompt":
axis_values[axis]["prompt"] = value
# Get parsed values for each axis based on type
x_parsed = self._get_axis_values(x_type, axis_values["x"])
y_parsed = self._get_axis_values(y_type, axis_values["y"])
z_parsed = self._get_axis_values(z_type, axis_values["z"])
# 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)
# 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 _get_axis_values(self, axis_type: str, axis_data: Dict) -> List[Any]:
"""Get values for a specific axis type from collected data."""
if axis_type == "none":
return []
elif axis_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
return axis_data.get(axis_type, [])
elif axis_type == "prompt":
prompt_text = axis_data.get("prompt", "")
return [p.strip() for p in prompt_text.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, axis_data.get("numeric", ""))
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
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
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 = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "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 ["models", "vaes", "loras", "samplers", "schedulers", "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)
+28
View File
@@ -0,0 +1,28 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [400, 300],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": null},
{"name": "x_string", "type": "STRING", "links": null},
{"name": "x_int", "type": "INT", "links": null},
{"name": "x_float", "type": "FLOAT", "links": null}
],
"properties": {},
"widgets_values": ["models", "none", "none", true, "", ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}
+46
View File
@@ -0,0 +1,46 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [400, 300],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": null}
],
"properties": {},
"widgets_values": [
"loras",
"models",
"none",
true,
"",
"",
{
"on": true,
"value": "test_lora_1.safetensors",
"strength": 0.8,
"_axis": "x",
"_type": "loras"
},
{
"on": false,
"value": "test_lora_2.safetensors",
"strength": 1.2,
"_axis": "x",
"_type": "loras"
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}
+18
View File
@@ -0,0 +1,18 @@
/* XYZ Plot Controller Widget Styles */
.xyz-plot-controller-widget {
display: flex;
align-items: center;
gap: 5px;
}
.xyz-plot-controller-toggle {
width: 16px;
height: 16px;
cursor: pointer;
}
/* Ensure combo widgets don't overflow */
.comfy-multiline-input {
font-family: monospace;
resize: vertical;
}
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
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);
}
};
// Update node title with selection counts
const updateTitle = () => {
setTimeout(() => {
let totalImages = 1;
// Count selected models
const modelCount = ['model_1', 'model_2', 'model_3', 'model_4', 'model_5']
.filter(name => {
const w = this.widgets.find(w => w.name === name);
return w && w.value !== 'disabled';
}).length;
// Count selected VAEs
const vaeCount = ['vae_1', 'vae_2', 'vae_3']
.filter(name => {
const w = this.widgets.find(w => w.name === name);
return w && w.value !== 'disabled';
}).length;
// Count selected LoRAs
const loraCount = ['lora_1', 'lora_2', 'lora_3']
.filter(name => {
const w = this.widgets.find(w => w.name === name);
return w && w.value !== 'disabled';
}).length;
// Get axis types and calculate total
const xType = this.widgets.find(w => w.name === 'x_type')?.value;
const yType = this.widgets.find(w => w.name === 'y_type')?.value;
const zType = this.widgets.find(w => w.name === 'z_type')?.value;
const getCounts = (type) => {
if (type === 'models') return modelCount;
if (type === 'vaes') return vaeCount;
if (type === 'loras') return loraCount;
// For other types, would need to parse numeric_values or prompts
return 1;
};
if (xType && xType !== 'none') totalImages *= getCounts(xType);
if (yType && yType !== 'none') totalImages *= getCounts(yType);
if (zType && zType !== 'none') totalImages *= getCounts(zType);
this.title = `XYZ Plot Controller (${totalImages} images)`;
}, 100);
};
// Add callbacks to update title when selections change
this.widgets.forEach(w => {
if (w.name.includes('_type') || w.name.includes('model_') ||
w.name.includes('vae_') || w.name.includes('lora_')) {
const originalCallback = w.callback;
w.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
updateTitle();
};
}
});
// Initial title update
updateTitle();
// 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)";
};
}
}
});