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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)";
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user