diff --git a/docs/RGTHREE_WIDGET_FRAMEWORK.md b/docs/RGTHREE_WIDGET_FRAMEWORK.md new file mode 100644 index 0000000..352c322 --- /dev/null +++ b/docs/RGTHREE_WIDGET_FRAMEWORK.md @@ -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. \ No newline at end of file diff --git a/docs/RGTHREE_WIDGET_FRAMEWORK_EXAMPLE.md b/docs/RGTHREE_WIDGET_FRAMEWORK_EXAMPLE.md new file mode 100644 index 0000000..be2fd7f --- /dev/null +++ b/docs/RGTHREE_WIDGET_FRAMEWORK_EXAMPLE.md @@ -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. \ No newline at end of file diff --git a/examples/xyz_grid/advanced_xyz_controller.json b/examples/xyz_grid/advanced_xyz_controller.json new file mode 100644 index 0000000..9bceaea --- /dev/null +++ b/examples/xyz_grid/advanced_xyz_controller.json @@ -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 +} \ No newline at end of file diff --git a/kikotools/tools/xyz_grid/IMPLEMENTATION.md b/kikotools/tools/xyz_grid/IMPLEMENTATION.md new file mode 100644 index 0000000..9c3bf7e --- /dev/null +++ b/kikotools/tools/xyz_grid/IMPLEMENTATION.md @@ -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 \ No newline at end of file diff --git a/kikotools/tools/xyz_grid/README.md b/kikotools/tools/xyz_grid/README.md new file mode 100644 index 0000000..938ef2a --- /dev/null +++ b/kikotools/tools/xyz_grid/README.md @@ -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 \ No newline at end of file diff --git a/kikotools/tools/xyz_grid/__init__.py b/kikotools/tools/xyz_grid/__init__.py index 04c857b..70904d2 100644 --- a/kikotools/tools/xyz_grid/__init__.py +++ b/kikotools/tools/xyz_grid/__init__.py @@ -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 = { diff --git a/kikotools/tools/xyz_grid/controller/dynamic_node.py b/kikotools/tools/xyz_grid/controller/dynamic_node.py new file mode 100644 index 0000000..91ff79f --- /dev/null +++ b/kikotools/tools/xyz_grid/controller/dynamic_node.py @@ -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) \ No newline at end of file diff --git a/kikotools/tools/xyz_grid/controller/power_node.py b/kikotools/tools/xyz_grid/controller/power_node.py new file mode 100644 index 0000000..5cd50bf --- /dev/null +++ b/kikotools/tools/xyz_grid/controller/power_node.py @@ -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 \ No newline at end of file diff --git a/kikotools/tools/xyz_grid/controller/xyz_plot_advanced.py b/kikotools/tools/xyz_grid/controller/xyz_plot_advanced.py new file mode 100644 index 0000000..223e286 --- /dev/null +++ b/kikotools/tools/xyz_grid/controller/xyz_plot_advanced.py @@ -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) \ No newline at end of file diff --git a/test_simple_workflow.json b/test_simple_workflow.json new file mode 100644 index 0000000..652a916 --- /dev/null +++ b/test_simple_workflow.json @@ -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 +} \ No newline at end of file diff --git a/test_widget_persistence.json b/test_widget_persistence.json new file mode 100644 index 0000000..fcab7b0 --- /dev/null +++ b/test_widget_persistence.json @@ -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 +} \ No newline at end of file diff --git a/web/xyz_plot_controller.css b/web/xyz_plot_controller.css new file mode 100644 index 0000000..91819bd --- /dev/null +++ b/web/xyz_plot_controller.css @@ -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; +} \ No newline at end of file diff --git a/web/xyz_plot_controller.js b/web/xyz_plot_controller.js new file mode 100644 index 0000000..fcb56fe --- /dev/null +++ b/web/xyz_plot_controller.js @@ -0,0 +1,1155 @@ +import { app } from "../../scripts/app.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; + +// Counter for unique widget names +let widgetCounter = 0; + +// Helper text for different parameter types +const PARAM_HELP = { + 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.XYZPlotController", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "XYZPlotController") { + 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(); + + // Override widget value methods to handle our custom widgets + const origComputeSize = this.computeSize; + this.computeSize = function() { + const size = origComputeSize ? origComputeSize.call(this) : [200, 100]; + // Don't auto-resize based on widgets + return [this.size[0] || size[0], this.size[1] || size[1]]; + }; + + // Override setWidgetValue to protect our custom widgets + const origSetWidgetValue = this.setWidgetValue; + this.setWidgetValue = function(index, value) { + const widget = this.widgets[index]; + if (widget && widget.type === "xyz_dynamic_widget") { + // Only set if value is not null/undefined + if (value !== null && value !== undefined) { + widget.value = value; + } + return; + } + if (origSetWidgetValue) { + origSetWidgetValue.call(this, index, value); + } + }; + + // Override getWidgetValue + const origGetWidgetValue = this.getWidgetValue; + this.getWidgetValue = function(index) { + const widget = this.widgets[index]; + if (widget && widget.type === "xyz_dynamic_widget") { + return widget.value; + } + return origGetWidgetValue ? origGetWidgetValue.call(this, index) : undefined; + }; + + // Initialize storage for dynamic widgets if not already done + if (!node.dynamicWidgets) { + node.dynamicWidgets = { + x: [], + y: [], + z: [] + }; + } + + // Store references to add buttons + if (!node.addButtons) { + node.addButtons = {}; + } + + // Store references to text widgets + if (!node.textWidgets) { + node.textWidgets = {}; + } + + // Store last mouse event for context menu positioning + node.lastMouseEvent = null; + + // Setup axis type callbacks + ["x_type", "y_type", "z_type"].forEach(widgetName => { + const widget = node.widgets.find(w => w.name === widgetName); + if (widget) { + // Set default value to 'none' + widget.value = "none"; + const originalCallback = widget.callback; + widget.callback = function(value) { + if (originalCallback) originalCallback.call(this, value); + updateAxisWidgets(node, widgetName.split("_")[0], value); + updateNodeTitle(node); + }; + } + }); + + // Add header widget for each axis + node.headerWidgets = {}; + + // Initial setup - only run if not already configured + setTimeout(() => { + // Check if we're in initial creation (not restoration) + if (!node._configured) { + ["x", "y", "z"].forEach(axis => { + const typeWidget = node.widgets.find(w => w.name === `${axis}_type`); + if (typeWidget) { + updateAxisWidgets(node, axis, typeWidget.value); + } + }); + updateNodeTitle(node); + } else { + } + }, 100); + }; + + // Override onSerialize to ensure our custom widgets are saved + const origOnSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function(info) { + name: w.name, + type: w.type, + value: w.value + }))); + + // Let original serialization run first + if (origOnSerialize) { + origOnSerialize.call(this, info); + } + + // Log what ComfyUI serialized + + // Fix empty text widget values by getting them directly from the widgets + 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 the serialized value is empty/null but the widget has a value, use the widget's value + if ((serializedValue === '' || serializedValue === null || serializedValue === undefined) && + widget && widget.value !== '' && widget.value !== null && widget.value !== undefined) { + info.widgets_values[i] = widget.value; + } + + // Also handle the case where the widget has an inputEl with a value + if (widget && widget.inputEl && widget.inputEl.value && + (serializedValue === '' || serializedValue === null || serializedValue === undefined)) { + info.widgets_values[i] = widget.inputEl.value; + } + } + } + + }; + + // Override mouse events to track position + const onMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function(e, localPos, graphCanvas) { + this.lastMouseEvent = e; + return onMouseDown ? onMouseDown.apply(this, arguments) : undefined; + }; + + const onMouseUp = nodeType.prototype.onMouseUp; + nodeType.prototype.onMouseUp = function(e, localPos, graphCanvas) { + if (e.button === 2) { // Right click + this.lastMouseEvent = e; + } + return onMouseUp ? onMouseUp.apply(this, arguments) : undefined; + }; + + // Override configuration to restore dynamic widgets + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function(info) { + + // Mark as configured to prevent initial setup from running + this._configured = true; + + // Store the widgets_values before anything modifies them + const savedWidgetValues = [...(info.widgets_values || [])]; + + // Clear dynamic widget tracking for fresh restoration + if (!this.hiddenWidgets) { + this.hiddenWidgets = new Set(); + } + this.dynamicWidgets = { x: [], y: [], z: [] }; + this.addButtons = {}; + this.textWidgets = {}; + + // Let ComfyUI restore base widgets first + if (onConfigure) { + onConfigure.call(this, info); + } + + name: w.name, + type: w.type, + value: w.value + }))); + + // Remove any existing dynamic widgets that might have been restored incorrectly + if (this.widgets) { + // Remove widgets in reverse order to avoid index issues + for (let i = this.widgets.length - 1; i >= 0; i--) { + const widget = this.widgets[i]; + if (widget && (widget.type === "xyz_dynamic_widget" || + widget.name?.includes("Add ") || + widget.name?.includes("_numeric") || + widget.name?.includes("_prompt"))) { + this.widgets.splice(i, 1); + if (widget.onRemove) { + widget.onRemove(); + } + } + } + } + + // Restore dynamic widgets from saved values + // Use our saved copy instead of info.widgets_values which might have been modified + const widgetValues = savedWidgetValues; + + // Log each widget value for debugging + widgetValues.forEach((val, idx) => { + }); + + // Group saved widget values by axis (using the stored _axis property) + const widgetsByAxis = { x: [], y: [], z: [] }; + + for (const widgetValue of widgetValues) { + if (widgetValue && typeof widgetValue === 'object' && + 'on' in widgetValue && 'value' in widgetValue && widgetValue.value) { + + // Use the stored axis info if available + if (widgetValue._axis && widgetsByAxis[widgetValue._axis]) { + widgetsByAxis[widgetValue._axis].push(widgetValue); + } else { + // Fallback: try to guess which axis based on current configuration + for (const axis of ['x', 'y', 'z']) { + const typeWidget = this.widgets.find(w => w.name === `${axis}_type`); + if (typeWidget && canAddDynamicWidget(typeWidget.value)) { + widgetsByAxis[axis].push(widgetValue); + break; + } + } + } + } + } + + // First setup axis type callbacks again since we removed all widgets + ["x_type", "y_type", "z_type"].forEach(widgetName => { + const widget = this.widgets.find(w => w.name === widgetName); + if (widget) { + const originalCallback = widget.callback; + widget.callback = function(value) { + if (originalCallback) originalCallback.call(this, value); + updateAxisWidgets(node, widgetName.split("_")[0], value); + updateNodeTitle(node); + }; + } + }); + + // Now create widgets for each axis based on restored type values + for (const axis of ['x', 'y', 'z']) { + const typeWidget = this.widgets.find(w => w.name === `${axis}_type`); + const axisType = typeWidget?.value; + + if (axisType && axisType !== "none") { + // First update axis widgets to create add buttons and text widgets + // Pass true to skip clearing dynamic widgets during restoration + updateAxisWidgets(this, axis, axisType, true); + + // Then restore dynamic widgets if applicable + if (canAddDynamicWidget(axisType)) { + const savedWidgets = widgetsByAxis[axis]; + + for (const savedValue of savedWidgets) { + // Pass the item name, not the full value object + const itemName = savedValue.value || ""; + const widget = createDynamicWidget(this, axis, axisType, itemName); + if (widget) { + // Now restore the full value including on/off state and strength + // Use Object.assign to ensure the value is properly set + const fullValue = Object.assign({}, savedValue, { + _axis: axis, + _type: axisType + }); + widget.value = fullValue; + } + } + } + } + } + + // Manually restore all widget values from saved widget values + // This is needed because ComfyUI creates the widgets but doesn't always restore their values properly + for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) { + const widget = this.widgets[i]; + const savedValue = savedWidgetValues[i]; + + + // Skip if no saved value + if (savedValue === null || savedValue === undefined) { + continue; + } + + // For string values (text widgets) + if (widget && typeof savedValue === 'string' && savedValue !== '') { + // Check various widget types that might contain text + if (widget.type === "text" || widget.type === "string" || widget.type === "customtext" || + widget.name?.includes("numeric") || widget.name?.includes("prompt")) { + widget.value = savedValue; + + // Also update the input element if it exists + if (widget.inputEl) { + widget.inputEl.value = savedValue; + } + // Check for callback to trigger updates + if (widget.callback) { + widget.callback(savedValue); + } + } + } + } + + updateNodeTitle(this); + + // Log final state + + // Add a slight delay to check if values are being cleared after configure + setTimeout(() => { + name: w.name, + type: w.type, + value: w.type === "xyz_dynamic_widget" ? w.value : w.value + }))); + + // Final attempt to restore text widget values if they're still empty + 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 || widget.value === '')) { + widget.value = savedValue; + if (widget.inputEl) { + widget.inputEl.value = savedValue; + } + } + } + }, 100); + }; + + // Override onExecuted to see if something is clearing values there + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function(message) { + if (onExecuted) { + onExecuted.call(this, message); + } + }; + } + } +}); + +// Update widgets for an axis based on selected type +function updateAxisWidgets(node, axis, type, skipClearDynamic = false) { + // During restoration, we don't want to remove anything + if (!skipClearDynamic) { + // Hide all widgets for this axis first + const widgetPrefixes = [`${axis}_numeric`, `${axis}_prompt`, `➕ Add`]; + + // Hide text widgets and buttons for this axis + node.widgets?.forEach(widget => { + if (widget.name && widgetPrefixes.some(prefix => widget.name.includes(prefix) || + (widget.name.includes(axis) && (widget.name.includes('numeric') || widget.name.includes('prompt'))))) { + widget.hidden = true; + widget.computeSize = () => [0, 0]; + node.hiddenWidgets?.add(widget.name); + } + }); + + // Remove existing add button + if (node.addButtons[axis]) { + const index = node.widgets.indexOf(node.addButtons[axis]); + if (index > -1) { + node.widgets.splice(index, 1); + } + node.addButtons[axis] = null; + } + + // Clear dynamic widgets for this axis + if (node.dynamicWidgets[axis]) { + while (node.dynamicWidgets[axis].length > 0) { + const widget = node.dynamicWidgets[axis].pop(); + const index = node.widgets.indexOf(widget); + if (index > -1) { + node.widgets.splice(index, 1); + } + } + } + } + + if (type === "none") { + // Don't resize - respect user's manual sizing + return; + } + + // Add widgets based on type + if (canAddDynamicWidget(type)) { + // Add button for dynamic widget types if it doesn't exist + if (!node.addButtons[axis]) { + const buttonLabel = `➕ Add ${type.charAt(0).toUpperCase() + type.slice(1, -1)}`; + const button = node.addWidget("button", buttonLabel, null, (value, canvas, node, pos, event) => { + showResourceChooser(node, axis, type, event || window.event); + return true; + }); + node.addButtons[axis] = button; + } + } else if (["cfg_scale", "steps", "seed", "denoise", "clip_skip"].includes(type)) { + // Check if text widget already exists + const widgetName = `${axis}_numeric`; + let existingWidget = node.widgets?.find(w => w.name === widgetName); + + if (!existingWidget) { + // Only create new widget if it doesn't exist + const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", { + default: "", + multiline: true, + placeholder: PARAM_HELP[type] || "Enter values" + }]); + textWidget.widget.inputEl.style.fontFamily = "monospace"; + textWidget.widget.inputEl.rows = 3; + node.textWidgets[axis] = textWidget.widget; + + // Add input listener for live updates + textWidget.widget.inputEl.addEventListener("input", () => { + updateNodeTitle(node); + }); + } else { + // Widget already exists, just track it and unhide it + node.textWidgets[axis] = existingWidget; + existingWidget.hidden = false; + existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT]; + node.hiddenWidgets?.delete(existingWidget.name); + + // Re-add input listener in case it was lost + if (existingWidget.inputEl && !existingWidget._hasUpdateListener) { + existingWidget.inputEl.addEventListener("input", () => { + updateNodeTitle(node); + }); + existingWidget._hasUpdateListener = true; + } + } + } else if (type === "prompt") { + // Check if prompt widget already exists + const widgetName = `${axis}_prompt`; + let existingWidget = node.widgets?.find(w => w.name === widgetName); + + if (!existingWidget) { + // Only create new widget if it doesn't exist + const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", { + default: "", + multiline: true, + placeholder: PARAM_HELP.prompt + }]); + textWidget.widget.inputEl.style.fontFamily = "monospace"; + textWidget.widget.inputEl.rows = 4; + node.textWidgets[axis] = textWidget.widget; + + // Add input listener for live updates + textWidget.widget.inputEl.addEventListener("input", () => { + updateNodeTitle(node); + }); + } else { + // Widget already exists, just track it and unhide it + node.textWidgets[axis] = existingWidget; + existingWidget.hidden = false; + existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT]; + node.hiddenWidgets?.delete(existingWidget.name); + + // Re-add input listener in case it was lost + if (existingWidget.inputEl && !existingWidget._hasUpdateListener) { + existingWidget.inputEl.addEventListener("input", () => { + updateNodeTitle(node); + }); + existingWidget._hasUpdateListener = true; + } + } + } + + // Don't auto-resize - respect user's manual sizing +} + +// Check if a type can have dynamic widgets +function canAddDynamicWidget(type) { + return ["models", "vaes", "loras", "samplers", "schedulers"].includes(type); +} + +// Show resource chooser based on type +function showResourceChooser(node, axis, type, event) { + // Get available options based on type + let options = []; + let title = "Choose a " + type.slice(0, -1); // Remove 's' for singular + + // Try to get options from existing nodes or use defaults + if (type === "models") { + // Try to find a checkpoint loader node to get model list + const checkpointNode = app.graph._nodes.find(n => n.type === "CheckpointLoaderSimple"); + if (checkpointNode && checkpointNode.widgets) { + const modelWidget = checkpointNode.widgets.find(w => w.type === "combo"); + if (modelWidget && modelWidget.options && modelWidget.options.values) { + options = [...modelWidget.options.values]; + } + } + if (options.length === 0) { + options = ["No models found"]; + } + } else if (type === "vaes") { + options = []; + // Try to find a VAE loader node + const vaeNode = app.graph._nodes.find(n => n.type === "VAELoader"); + if (vaeNode && vaeNode.widgets) { + const vaeWidget = vaeNode.widgets.find(w => w.type === "combo"); + if (vaeWidget && vaeWidget.options && vaeWidget.options.values) { + options = [...vaeWidget.options.values]; + } + } + if (options.length === 0) { + options = ["Automatic"]; + } + } else if (type === "loras") { + options = []; + // Try multiple ways to find LoRA options + + // Method 1: Look for any LoraLoader node + const loraNodes = app.graph._nodes.filter(n => + n.type === "LoraLoader" || + n.type === "Power Lora Loader (rgthree)" || + n.type?.includes("Lora") || + n.type?.includes("lora") + ); + + for (const node of loraNodes) { + if (node.widgets) { + const loraWidget = node.widgets.find(w => + (w.type === "combo" && (w.name === "lora_name" || w.name?.includes("lora"))) || + (w.options?.values && Array.isArray(w.options.values) && w.options.values.length > 0) + ); + if (loraWidget && loraWidget.options && loraWidget.options.values) { + options = [...loraWidget.options.values]; + break; + } + } + } + + // Method 2: Check if we can get from node definitions + if (options.length === 0) { + try { + const nodeData = LiteGraph.registered_node_types["LoraLoader"]; + if (nodeData && nodeData.nodeData && nodeData.nodeData.input) { + const loraInput = nodeData.nodeData.input.required?.lora_name; + if (loraInput && Array.isArray(loraInput[0])) { + options = [...loraInput[0]]; + } + } + } catch (e) { + } + } + + // If still no options, show a message + if (options.length === 0) { + options = ["No LoRAs found - Add a LoraLoader node first"]; + } else { + } + } else if (type === "samplers") { + // Try to find a KSampler node + const samplerNode = app.graph._nodes.find(n => n.type === "KSampler"); + if (samplerNode && samplerNode.widgets) { + const samplerWidget = samplerNode.widgets.find(w => w.name === "sampler"); + if (samplerWidget && samplerWidget.options && samplerWidget.options.values) { + options = [...samplerWidget.options.values]; + } else { + options = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", + "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", + "dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"]; + } + } else { + options = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", + "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", + "dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"]; + } + } else if (type === "schedulers") { + // Try to find a KSampler node + const samplerNode = app.graph._nodes.find(n => n.type === "KSampler"); + if (samplerNode && samplerNode.widgets) { + const schedulerWidget = samplerNode.widgets.find(w => w.name === "scheduler"); + if (schedulerWidget && schedulerWidget.options && schedulerWidget.options.values) { + options = [...schedulerWidget.options.values]; + } else { + options = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]; + } + } else { + options = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]; + } + } + + // Show context menu for selection + const canvas = app.canvas; + if (!event) { + event = canvas.last_mouse_event || new MouseEvent('click', { + clientX: canvas.mouse[0], + clientY: canvas.mouse[1] + }); + } + + new LiteGraph.ContextMenu(options, { + event: event, + title: title, + scale: Math.max(1, canvas.ds?.scale || 1), + callback: (value) => { + if (value && value !== "None" && !value.includes("No") && !value.includes("found")) { + createDynamicWidget(node, axis, type, value); + updateNodeTitle(node); + } + } + }); +} + +// Create a dynamic widget for the given axis and type with selected value +function createDynamicWidget(node, axis, type, selectedValue) { + widgetCounter++; + const widgetName = `${axis}_${type}_${widgetCounter}`; + + // Create a custom widget that ComfyUI will properly serialize + class XYZDynamicWidget { + constructor(name, value) { + this.name = name; + this._value = value; + this.type = "xyz_dynamic_widget"; + this.y = 0; + this.options = {}; + } + + // Use getter/setter to ensure value persistence + get value() { + return this._value; + } + + set value(v) { + this._value = v; + } + + draw(ctx, node, width, y) { + // Will be replaced + } + + mouse(event, pos, node) { + // Will be replaced + return false; + } + + computeSize() { + return [node.size[0], LiteGraph.NODE_WIDGET_HEIGHT]; + } + + serialize() { + return this._value; + } + + serializeValue(node, index) { + // Return a deep copy to prevent modification + return this._value ? { ...this._value } : null; + } + + onRemove() {} + } + + const widget = new XYZDynamicWidget(widgetName, null); + + // Add widget to node + if (!node.widgets) { + node.widgets = []; + } + node.widgets.push(widget); + + // Store value with on/off state and strength for loras + // Add axis and type info to help with restoration + if (type === "loras") { + widget.value = { + on: true, + value: selectedValue, + strength: 1.0, + _axis: axis, // Store axis info + _type: type // Store type info + }; + } else { + widget.value = { + on: true, + value: selectedValue, + _axis: axis, // Store axis info + _type: type // Store type info + }; + } + + // Widget is already added to node in the constructor above + + // Track mouse movement for strength adjustment + widget.mouseState = { + dragging: false, + startX: 0, + startValue: 0, + lastClickTime: 0 + }; + + // Custom draw function to show toggle and value with strength for loras + widget.draw = function(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 rounded rectangle + 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 Loader 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 = widget.value.on ? posX + height : posX + height * 0.5; + ctx.fillStyle = widget.value.on ? "#89B" : "#888"; + ctx.beginPath(); + ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2); + ctx.fill(); + + widget.toggleBounds = [posX, toggleBgWidth]; + posX += toggleBgWidth + innerMargin; + + // Apply opacity if disabled + if (!widget.value.on) { + ctx.globalAlpha = app.canvas.editor_alpha * 0.4; + } + + // Draw strength controls for loras + let strengthX = width - margin - innerMargin; + if (type === "loras" && widget.value.strength !== undefined) { + // Draw strength value and controls + const arrowWidth = 9; + const numberWidth = 32; + + // Right arrow â–ļ + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.beginPath(); + const rightArrowX = strengthX - arrowWidth; + ctx.moveTo(rightArrowX + arrowWidth, midY); + ctx.lineTo(rightArrowX, midY - 5); + ctx.lineTo(rightArrowX, midY + 5); + ctx.closePath(); + ctx.fill(); + widget.incBounds = [rightArrowX, arrowWidth]; + + // Strength value + strengthX = rightArrowX - innerMargin - numberWidth; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(widget.value.strength.toFixed(2), strengthX + numberWidth/2, midY); + widget.valBounds = [strengthX, numberWidth]; + + // Left arrow ◀ + strengthX = strengthX - innerMargin - arrowWidth; + ctx.beginPath(); + ctx.moveTo(strengthX, midY); + ctx.lineTo(strengthX + arrowWidth, midY - 5); + ctx.lineTo(strengthX + arrowWidth, midY + 5); + ctx.closePath(); + ctx.fill(); + widget.decBounds = [strengthX, arrowWidth]; + + strengthX -= innerMargin; + } + + // Draw the value text and track its bounds for right-click + const textWidth = strengthX - posX - innerMargin; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + // Fit text to available space + let text = widget.value.value || ""; + ctx.font = "12px Arial"; + const metrics = ctx.measureText(text); + if (metrics.width > textWidth) { + // Truncate with ellipsis + while (text.length > 0 && ctx.measureText(text + "...").width > textWidth) { + text = text.substring(0, text.length - 1); + } + text += "..."; + } + ctx.fillText(text, posX, midY); + + // Store the lora name bounds for right-click detection + widget.loraBounds = [posX, textWidth]; + + ctx.restore(); + }; + + // Handle mouse events - pos is relative to widget + widget.mouse = function(event, pos, node) { + const height = LiteGraph.NODE_WIDGET_HEIGHT; + + if (event.type === "mousedown" || event.type === "pointerdown") { + // Check toggle bounds + if (widget.toggleBounds && pos[0] >= widget.toggleBounds[0] && + pos[0] <= widget.toggleBounds[0] + widget.toggleBounds[1]) { + widget.value.on = !widget.value.on; + updateNodeTitle(node); + node.setDirtyCanvas(true, true); + return true; + } + + // Check strength controls for loras + if (type === "loras" && widget.value.strength !== undefined) { + // Decrease button + if (widget.decBounds && pos[0] >= widget.decBounds[0] && + pos[0] <= widget.decBounds[0] + widget.decBounds[1]) { + widget.value.strength = Math.max(-3, widget.value.strength - 0.1); + widget.value.strength = Math.round(widget.value.strength * 10) / 10; + updateNodeTitle(node); + node.setDirtyCanvas(true, true); + return true; + } + + // Increase button + if (widget.incBounds && pos[0] >= widget.incBounds[0] && + pos[0] <= widget.incBounds[0] + widget.incBounds[1]) { + widget.value.strength = Math.min(3, widget.value.strength + 0.1); + widget.value.strength = Math.round(widget.value.strength * 10) / 10; + updateNodeTitle(node); + node.setDirtyCanvas(true, true); + return true; + } + + // Double-click or drag on value + if (widget.valBounds && pos[0] >= widget.valBounds[0] && + pos[0] <= widget.valBounds[0] + widget.valBounds[1]) { + + // Check for double-click + const currentTime = Date.now(); + if (currentTime - widget.mouseState.lastClickTime < 300) { + // Double-click detected - show input dialog + const currentValue = widget.value.strength; + app.canvas.prompt("Value", currentValue.toFixed(2), (v) => { + const value = Number(v); + if (!isNaN(value)) { + widget.value.strength = Math.max(-3, Math.min(3, value)); + updateNodeTitle(node); + node.setDirtyCanvas(true, true); + } + }, event); + widget.mouseState.lastClickTime = 0; // Reset to prevent triple-click + } else { + // Single click - start dragging + widget.mouseState.dragging = true; + widget.mouseState.startX = pos[0]; + widget.mouseState.startValue = widget.value.strength; + widget.mouseState.lastClickTime = currentTime; + } + return true; + } + } + } else if ((event.type === "mousemove" || event.type === "pointermove") && widget.mouseState.dragging) { + // Drag to adjust strength + const delta = (pos[0] - widget.mouseState.startX) / 50; + widget.value.strength = Math.max(-3, Math.min(3, widget.mouseState.startValue + delta)); + widget.value.strength = Math.round(widget.value.strength * 100) / 100; + updateNodeTitle(node); + node.setDirtyCanvas(true, true); + return true; + } else if (event.type === "mouseup" || event.type === "pointerup") { + widget.mouseState.dragging = false; + } + + return false; + }; + + // Override getSlotInPosition to handle widget clicks only on lora name area + const originalGetSlotInPosition = node.getSlotInPosition; + if (!node._xyzGetSlotInPositionPatched) { + node._xyzGetSlotInPositionPatched = true; + 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 lora name area + const localX = x - this.pos[0]; + const localY = y - this.pos[1]; + + for (const w of this.widgets || []) { + if (w.type === "xyz_dynamic_widget" && w.y && localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) { + // Check if click is within lora name bounds + if (w.loraBounds && localX >= w.loraBounds[0] && localX <= w.loraBounds[0] + w.loraBounds[1]) { + return { widget: w, output: { type: "XYZ_DYNAMIC_WIDGET" } }; + } + } + } + } + return slot; + }; + } + + // Override getSlotMenuOptions for right-click menu + const originalGetSlotMenuOptions = node.getSlotMenuOptions; + if (!node._xyzGetSlotMenuOptionsPatched) { + node._xyzGetSlotMenuOptionsPatched = true; + node.getSlotMenuOptions = function(slot) { + if (slot?.output?.type === "XYZ_DYNAMIC_WIDGET" && slot.widget?.type === "xyz_dynamic_widget") { + const widget = slot.widget; + const parts = widget.name.split("_"); + const axis = parts[0]; + const type = parts[1]; + // Find widget in dynamicWidgets array + const widgetIndex = this.dynamicWidgets[axis] ? this.dynamicWidgets[axis].indexOf(widget) : -1; + if (widgetIndex === -1) { + console.warn(`[XYZ Plot] Widget not found in dynamicWidgets[${axis}]`); + } + + // Check if we can move up/down + const canMoveUp = widgetIndex > 0; + const canMoveDown = widgetIndex >= 0 && this.dynamicWidgets[axis] && widgetIndex < this.dynamicWidgets[axis].length - 1; + + const menuItems = []; + + // Show Info for loras + if (type === "loras") { + menuItems.push({ + content: `â„šī¸ Show Info`, + disabled: true, // For now, until we implement lora info dialog + callback: () => { + // TODO: Implement lora info dialog + } + }); + menuItems.push(null); // separator + } + + // Toggle on/off + menuItems.push({ + content: `${widget.value.on ? "âšĢ" : "đŸŸĸ"} Toggle ${widget.value.on ? "Off" : "On"}`, + callback: () => { + widget.value.on = !widget.value.on; + updateNodeTitle(node); + node.setDirtyCanvas(true, true); + } + }); + + // Move up + menuItems.push({ + content: `âŦ†ī¸ Move Up`, + disabled: !canMoveUp, + callback: () => { + if (canMoveUp) { + // Swap in dynamic widgets array + const temp = this.dynamicWidgets[axis][widgetIndex]; + this.dynamicWidgets[axis][widgetIndex] = this.dynamicWidgets[axis][widgetIndex - 1]; + this.dynamicWidgets[axis][widgetIndex - 1] = temp; + + // Find positions in main widgets array + const currentIndex = this.widgets.indexOf(widget); + const prevWidget = this.dynamicWidgets[axis][widgetIndex]; + const prevIndex = this.widgets.indexOf(prevWidget); + + // Swap in main widgets array + if (currentIndex > -1 && prevIndex > -1) { + this.widgets.splice(currentIndex, 1); + this.widgets.splice(prevIndex, 0, widget); + } + + this.setDirtyCanvas(true, true); + } + } + }); + + // Move down + menuItems.push({ + content: `âŦ‡ī¸ Move Down`, + disabled: !canMoveDown, + callback: () => { + if (canMoveDown) { + // Swap in dynamic widgets array + const temp = this.dynamicWidgets[axis][widgetIndex]; + this.dynamicWidgets[axis][widgetIndex] = this.dynamicWidgets[axis][widgetIndex + 1]; + this.dynamicWidgets[axis][widgetIndex + 1] = temp; + + // Find positions in main widgets array + const currentIndex = this.widgets.indexOf(widget); + const nextWidget = this.dynamicWidgets[axis][widgetIndex]; + const nextIndex = this.widgets.indexOf(nextWidget); + + // Swap in main widgets array + if (currentIndex > -1 && nextIndex > -1) { + this.widgets.splice(currentIndex, 1); + this.widgets.splice(nextIndex + 1, 0, widget); + } + + this.setDirtyCanvas(true, true); + } + } + }); + + // Remove + menuItems.push({ + content: `đŸ—‘ī¸ Remove`, + callback: () => { + // Remove from dynamic widgets + const idx = this.dynamicWidgets[axis] ? this.dynamicWidgets[axis].indexOf(widget) : -1; + if (idx > -1) { + this.dynamicWidgets[axis].splice(idx, 1); + } + + // Remove from main widgets + const widgetIdx = this.widgets.indexOf(widget); + if (widgetIdx > -1) { + this.widgets.splice(widgetIdx, 1); + } + + updateNodeTitle(this); + this.setDirtyCanvas(true, true); + } + }); + + // Show the context menu + // We need to use the canvas mouse event that has proper coordinates + const canvasEvent = app.canvas.last_mouse_event || window.event; + if (!canvasEvent) { + // Create a synthetic event at the node position if we don't have a real event + const nodePos = node.pos || [100, 100]; + const widgetY = slot.widget.y || 0; + canvasEvent = new MouseEvent('contextmenu', { + clientX: nodePos[0] + 100, + clientY: nodePos[1] + widgetY + 20, + bubbles: true + }); + } + + new LiteGraph.ContextMenu(menuItems, { + title: type === "loras" ? "LORA WIDGET" : type.toUpperCase().slice(0, -1) + " WIDGET", + event: canvasEvent, + parentMenu: null + }); + + return null; // Prevent default menu + } + + // Call original if not our widget + return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null; + }; + } + + // Add to dynamic widgets array - IMPORTANT for tracking + if (!node.dynamicWidgets) { + console.warn(`[XYZ Plot] dynamicWidgets was null, reinitializing`); + node.dynamicWidgets = { x: [], y: [], z: [] }; + } + if (!node.dynamicWidgets[axis]) { + node.dynamicWidgets[axis] = []; + } + node.dynamicWidgets[axis].push(widget); + + // Position widget after add button + if (node.addButtons[axis]) { + const buttonIndex = node.widgets.indexOf(node.addButtons[axis]); + if (buttonIndex > -1) { + node.widgets.splice(node.widgets.indexOf(widget), 1); + node.widgets.splice(buttonIndex + 1, 0, widget); + } + } + + // Don't auto-resize - respect user's manual sizing + return widget; +} + +// Update node title with image count +function updateNodeTitle(node) { + const counts = { x: 0, y: 0, z: 0 }; + + ["x", "y", "z"].forEach(axis => { + const typeWidget = node.widgets.find(w => w.name === `${axis}_type`); + if (!typeWidget || typeWidget.value === "none") { + counts[axis] = 1; + return; + } + + const type = typeWidget.value; + + if (canAddDynamicWidget(type)) { + // Count enabled dynamic widgets + counts[axis] = node.dynamicWidgets[axis] + .filter(w => { + if (typeof w.value === 'object' && w.value) { + return w.value.on; + } + return false; + }) + .length; + } else if (["cfg_scale", "steps", "seed", "denoise", "clip_skip"].includes(type)) { + // Count numeric values + const textWidget = node.textWidgets[axis]; + if (textWidget && textWidget.value) { + const values = textWidget.value.trim(); + if (values.includes(':')) { + const parts = values.split(':'); + if (parts.length >= 2) { + const start = parseFloat(parts[0]); + const stop = parseFloat(parts[1]); + const step = parts[2] ? parseFloat(parts[2]) : 1; + counts[axis] = Math.floor((stop - start) / step) + 1; + } + } else { + counts[axis] = values.split(',').filter(v => v.trim()).length; + } + } + } else if (type === "prompt") { + // Count prompts + const textWidget = node.textWidgets[axis]; + if (textWidget && textWidget.value) { + counts[axis] = textWidget.value.split('\n').filter(p => p.trim()).length; + } + } + + counts[axis] = Math.max(1, counts[axis]); + }); + + const totalImages = counts.x * counts.y * counts.z; + node.title = `XYZ Plot Controller (${totalImages} images)`; + + if (totalImages > 100) { + node.title += " âš ī¸"; + } +} \ No newline at end of file diff --git a/web/xyz_plot_helper.js b/web/xyz_plot_helper.js deleted file mode 100644 index aca8212..0000000 --- a/web/xyz_plot_helper.js +++ /dev/null @@ -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)"; - }; - } - } -}); \ No newline at end of file