feat: add intelligent UI for XYZ Plot Controller
- Dynamic value selection widgets for models, VAEs, LoRAs, samplers - Multi-select dialogs with search functionality - Context-sensitive examples and usage hints for each parameter type - Real-time validation for numeric inputs with range syntax support - Visual feedback with proper styling and animations - Connection hints showing where to connect outputs - Parameter-specific placeholders and tooltips
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/* XYZ Grid Styles */
|
||||
|
||||
.xyz-value-selector {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.xyz-value-selector button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-value-selector button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3);
|
||||
}
|
||||
|
||||
.xyz-value-selector button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Multi-select dialog animations */
|
||||
.xyz-multiselect-overlay {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-multiselect-dialog {
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Example helper styles */
|
||||
.xyz-example-helper {
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xyz-example-helper::before {
|
||||
content: "Example";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.xyz-example-helper pre {
|
||||
margin: 0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Value input validation styles */
|
||||
.xyz-value-input {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-value-input.valid {
|
||||
border-color: #4ecdc4 !important;
|
||||
}
|
||||
|
||||
.xyz-value-input.invalid {
|
||||
border-color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
/* Progress indicator */
|
||||
.xyz-progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #4ecdc4 0%, #44a3aa 100%);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
/* Grid info display */
|
||||
.xyz-grid-info {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xyz-grid-info .dimensions {
|
||||
color: #4ecdc4;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.xyz-grid-info .warning {
|
||||
color: #ff6b6b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Checkbox styling in multi-select */
|
||||
.xyz-checkbox-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-checkbox-option:hover {
|
||||
background-color: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.xyz-checkbox-option input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xyz-checkbox-option label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Search input styling */
|
||||
.xyz-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||
}
|
||||
|
||||
.xyz-search-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Button group styling */
|
||||
.xyz-button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.xyz-button-group button {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-button-primary {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xyz-button-primary:hover {
|
||||
background: #357abd;
|
||||
}
|
||||
|
||||
.xyz-button-secondary {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xyz-button-secondary:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Tooltip for parameter info */
|
||||
.xyz-param-tooltip {
|
||||
position: absolute;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #ddd;
|
||||
z-index: 10001;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.xyz-param-tooltip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xyz-param-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 5px solid #444;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import "./xyz_grid_widgets.js";
|
||||
import "./xyz_grid_ui.js";
|
||||
|
||||
// Helper function to trigger re-execution
|
||||
async function queueNextExecution(nodeId, batchId) {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Load CSS
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.type = "text/css";
|
||||
link.href = "extensions/ComfyUI-KikoTools/css/xyz_grid.css";
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Parameter type information and examples
|
||||
export const parameterInfo = {
|
||||
none: {
|
||||
description: "No parameter selected",
|
||||
example: null
|
||||
},
|
||||
model: {
|
||||
description: "Checkpoint/Model files",
|
||||
example: "Select from available models using the button below",
|
||||
connection: "Connect to CheckpointLoaderSimple → ckpt_name",
|
||||
outputType: "STRING"
|
||||
},
|
||||
vae: {
|
||||
description: "VAE (Variational Autoencoder) models",
|
||||
example: "Select from available VAEs or use 'Automatic'",
|
||||
connection: "Connect to VAELoader → vae_name",
|
||||
outputType: "STRING"
|
||||
},
|
||||
lora: {
|
||||
description: "LoRA (Low-Rank Adaptation) models",
|
||||
example: "Select LoRAs to apply (use 'None' for no LoRA)",
|
||||
connection: "Connect to LoraLoader → lora_name",
|
||||
outputType: "STRING"
|
||||
},
|
||||
sampler: {
|
||||
description: "Sampling algorithms",
|
||||
example: "Select from available samplers",
|
||||
connection: "Connect to KSampler → sampler_name",
|
||||
outputType: "STRING"
|
||||
},
|
||||
scheduler: {
|
||||
description: "Noise schedulers",
|
||||
example: "Select scheduler types",
|
||||
connection: "Connect to KSampler → scheduler",
|
||||
outputType: "STRING"
|
||||
},
|
||||
cfg_scale: {
|
||||
description: "Classifier-Free Guidance scale",
|
||||
example: "Examples:\n• Single values: 5, 7.5, 10, 12.5\n• Range: 5:15:2.5 (from 5 to 15, step 2.5)\n• Mixed: 1, 3, 5:10:1",
|
||||
connection: "Connect y_float or x_float to KSampler → cfg",
|
||||
outputType: "FLOAT",
|
||||
range: { min: 0, max: 30, default: 7.5 }
|
||||
},
|
||||
steps: {
|
||||
description: "Number of denoising steps",
|
||||
example: "Examples:\n• List: 20, 30, 40, 50\n• Range: 10:50:10 (from 10 to 50, step 10)\n• Quick test: 10, 20, 50",
|
||||
connection: "Connect y_int or x_int to KSampler → steps",
|
||||
outputType: "INT",
|
||||
range: { min: 1, max: 150, default: 20 }
|
||||
},
|
||||
clip_skip: {
|
||||
description: "CLIP layers to skip from the end",
|
||||
example: "Examples:\n• Common values: 1, 2\n• Full test: 1, 2, 3, 4\n• SDXL typically uses 1 or 2",
|
||||
connection: "Connect to CLIPSetLastLayer → stop_at_clip_layer",
|
||||
outputType: "INT",
|
||||
range: { min: 1, max: 12, default: 1 }
|
||||
},
|
||||
seed: {
|
||||
description: "Random seed for generation",
|
||||
example: "Examples:\n• Specific seeds: 42, 123, 456, 789\n• Range: 0:1000:100\n• Random set: 12345, 67890, 11111",
|
||||
connection: "Connect y_int or x_int to KSampler → seed",
|
||||
outputType: "INT",
|
||||
range: { min: 0, max: "0xffffffffffffffff", default: 0 }
|
||||
},
|
||||
denoise: {
|
||||
description: "Denoising strength (0=no change, 1=full denoise)",
|
||||
example: "Examples:\n• Light: 0.3, 0.5, 0.7\n• Full range: 0.2:1.0:0.2\n• img2img: 0.4, 0.6, 0.8, 1.0",
|
||||
connection: "Connect y_float or x_float to KSampler → denoise",
|
||||
outputType: "FLOAT",
|
||||
range: { min: 0, max: 1, default: 1 }
|
||||
},
|
||||
flux_guidance: {
|
||||
description: "Flux model guidance strength",
|
||||
example: "Examples:\n• Low to high: 1.0, 2.0, 3.5, 5.0\n• Fine steps: 1:5:0.5\n• Recommended: 2.5, 3.5, 4.5",
|
||||
connection: "Connect to FluxGuidance → guidance",
|
||||
outputType: "FLOAT",
|
||||
range: { min: 0, max: 10, default: 3.5 }
|
||||
},
|
||||
prompt: {
|
||||
description: "Text prompts (one per line)",
|
||||
example: "Enter different prompts, one per line:\n\na serene lake at sunset\na bustling city street\na mystical forest path\n\nTip: Keep prompts similar length for better grid layout",
|
||||
connection: "Connect y_string or x_string to CLIPTextEncode → text",
|
||||
outputType: "STRING"
|
||||
}
|
||||
};
|
||||
|
||||
// Create info panel for parameter type
|
||||
function createInfoPanel(node, paramType) {
|
||||
const info = parameterInfo[paramType];
|
||||
if (!info || paramType === 'none') return null;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xyz-grid-info";
|
||||
panel.innerHTML = `
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${paramType.toUpperCase()}</div>
|
||||
<div style="font-size: 11px; color: #999; margin-bottom: 4px;">${info.description}</div>
|
||||
${info.connection ? `<div style="font-size: 10px; color: #4ecdc4;">↗ ${info.connection}</div>` : ''}
|
||||
</div>
|
||||
${info.range ? `
|
||||
<div style="text-align: right; font-size: 10px; color: #888;">
|
||||
Range: ${info.range.min}-${info.range.max}<br>
|
||||
Default: ${info.range.default}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
// Show example usage in a nice overlay
|
||||
function showExampleOverlay(paramType, targetElement) {
|
||||
const info = parameterInfo[paramType];
|
||||
if (!info || !info.example) return;
|
||||
|
||||
// Remove any existing overlay
|
||||
const existing = document.querySelector('.xyz-example-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "xyz-example-overlay";
|
||||
overlay.style.cssText = `
|
||||
position: absolute;
|
||||
background: #1e1e1e;
|
||||
border: 2px solid #4a90e2;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const title = document.createElement("h4");
|
||||
title.style.cssText = "margin: 0 0 10px 0; color: #4a90e2;";
|
||||
title.textContent = `${paramType.toUpperCase()} - Usage Examples`;
|
||||
|
||||
const content = document.createElement("pre");
|
||||
content.style.cssText = `
|
||||
margin: 0;
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
content.textContent = info.example;
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
closeBtn.onclick = () => overlay.remove();
|
||||
|
||||
overlay.appendChild(title);
|
||||
overlay.appendChild(content);
|
||||
overlay.appendChild(closeBtn);
|
||||
|
||||
// Position near the target element
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
overlay.style.left = rect.left + "px";
|
||||
overlay.style.top = (rect.bottom + 5) + "px";
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Auto-close on click outside
|
||||
setTimeout(() => {
|
||||
const closeOnClickOutside = (e) => {
|
||||
if (!overlay.contains(e.target)) {
|
||||
overlay.remove();
|
||||
document.removeEventListener('click', closeOnClickOutside);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', closeOnClickOutside);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Enhance the value input widget based on parameter type
|
||||
export function enhanceValueWidget(node, widgetName, paramType) {
|
||||
const widget = node.widgets.find(w => w.name === widgetName);
|
||||
if (!widget || !widget.inputEl) return;
|
||||
|
||||
const info = parameterInfo[paramType];
|
||||
if (!info) return;
|
||||
|
||||
// Add info panel above the input
|
||||
const infoPanel = createInfoPanel(node, paramType);
|
||||
if (infoPanel && widget.inputEl.parentNode) {
|
||||
widget.inputEl.parentNode.insertBefore(infoPanel, widget.inputEl);
|
||||
}
|
||||
|
||||
// Add example button if there's an example
|
||||
if (info.example) {
|
||||
const exampleBtn = document.createElement("button");
|
||||
exampleBtn.textContent = "Show Examples";
|
||||
exampleBtn.style.cssText = `
|
||||
margin: 5px 0;
|
||||
padding: 4px 10px;
|
||||
background: #3a3a3a;
|
||||
color: #aaa;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
`;
|
||||
exampleBtn.onclick = () => showExampleOverlay(paramType, widget.inputEl);
|
||||
|
||||
if (widget.inputEl.parentNode) {
|
||||
widget.inputEl.parentNode.insertBefore(exampleBtn, widget.inputEl.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
// Add validation for numeric types
|
||||
if (['cfg_scale', 'steps', 'clip_skip', 'seed', 'denoise', 'flux_guidance'].includes(paramType)) {
|
||||
widget.inputEl.addEventListener('input', () => {
|
||||
const value = widget.inputEl.value.trim();
|
||||
let isValid = true;
|
||||
|
||||
if (value) {
|
||||
// Check for range syntax or comma-separated numbers
|
||||
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
|
||||
const listPattern = /^[\d\.\s,]+$/;
|
||||
|
||||
isValid = rangePattern.test(value) || listPattern.test(value);
|
||||
}
|
||||
|
||||
widget.inputEl.classList.toggle('xyz-value-input', true);
|
||||
widget.inputEl.classList.toggle('valid', isValid);
|
||||
widget.inputEl.classList.toggle('invalid', !isValid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI enhancements
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.XYZGridUI",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "XYZPlotController") {
|
||||
setupUIEnhancements(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setupUIEnhancements(node) {
|
||||
// Monitor all axis type widgets
|
||||
['x_axis_type', 'y_axis_type', 'z_axis_type'].forEach(axisName => {
|
||||
const widget = node.widgets.find(w => w.name === axisName);
|
||||
if (!widget) return;
|
||||
|
||||
const axisPrefix = axisName.split('_')[0];
|
||||
const valueWidgetName = `${axisPrefix}_values`;
|
||||
|
||||
// Enhance callback to update UI
|
||||
const originalCallback = widget.callback;
|
||||
widget.callback = function(value) {
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value);
|
||||
}
|
||||
|
||||
// Clear previous enhancements
|
||||
const valueWidget = node.widgets.find(w => w.name === valueWidgetName);
|
||||
if (valueWidget && valueWidget.inputEl) {
|
||||
// Remove any info panels or buttons
|
||||
const parent = valueWidget.inputEl.parentNode;
|
||||
const infoPanels = parent.querySelectorAll('.xyz-grid-info');
|
||||
const buttons = parent.querySelectorAll('button');
|
||||
infoPanels.forEach(el => el.remove());
|
||||
buttons.forEach(el => {
|
||||
if (el.textContent === 'Show Examples') el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Add new enhancements
|
||||
if (value && value !== 'none') {
|
||||
setTimeout(() => {
|
||||
enhanceValueWidget(node, valueWidgetName, value);
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger for initial values
|
||||
if (widget.value && widget.value !== 'none') {
|
||||
setTimeout(() => {
|
||||
enhanceValueWidget(node, valueWidgetName, widget.value);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
|
||||
// Helper to get available options for different parameter types
|
||||
const parameterHelpers = {
|
||||
model: {
|
||||
getOptions: async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/models');
|
||||
const data = await resp.json();
|
||||
return data.checkpoints || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch models:", e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
placeholder: "Select models...",
|
||||
multiselect: true
|
||||
},
|
||||
|
||||
vae: {
|
||||
getOptions: async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/models');
|
||||
const data = await resp.json();
|
||||
return ["Automatic", ...(data.vae || [])];
|
||||
} catch (e) {
|
||||
return ["Automatic"];
|
||||
}
|
||||
},
|
||||
placeholder: "Select VAEs...",
|
||||
multiselect: true
|
||||
},
|
||||
|
||||
lora: {
|
||||
getOptions: async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/models');
|
||||
const data = await resp.json();
|
||||
return ["None", ...(data.loras || [])];
|
||||
} catch (e) {
|
||||
return ["None"];
|
||||
}
|
||||
},
|
||||
placeholder: "Select LoRAs...",
|
||||
multiselect: true
|
||||
},
|
||||
|
||||
sampler: {
|
||||
getOptions: async () => {
|
||||
// Get samplers from KSampler node definition
|
||||
const nodeData = LiteGraph.registered_node_types["KSampler"];
|
||||
if (nodeData && nodeData.nodeData && nodeData.nodeData.input) {
|
||||
const samplerInput = nodeData.nodeData.input.required.sampler_name;
|
||||
if (samplerInput && samplerInput[0]) {
|
||||
return samplerInput[0];
|
||||
}
|
||||
}
|
||||
// Fallback list
|
||||
return ["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"];
|
||||
},
|
||||
placeholder: "Select samplers...",
|
||||
multiselect: true
|
||||
},
|
||||
|
||||
scheduler: {
|
||||
getOptions: async () => {
|
||||
const nodeData = LiteGraph.registered_node_types["KSampler"];
|
||||
if (nodeData && nodeData.nodeData && nodeData.nodeData.input) {
|
||||
const schedulerInput = nodeData.nodeData.input.required.scheduler;
|
||||
if (schedulerInput && schedulerInput[0]) {
|
||||
return schedulerInput[0];
|
||||
}
|
||||
}
|
||||
return ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"];
|
||||
},
|
||||
placeholder: "Select schedulers...",
|
||||
multiselect: true
|
||||
},
|
||||
|
||||
cfg_scale: {
|
||||
example: "5, 7.5, 10, 12.5\n-- or --\n5:15:2.5",
|
||||
placeholder: "Enter CFG values or range (start:stop:step)",
|
||||
validate: (value) => {
|
||||
// Check if it's a valid number list or range
|
||||
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
|
||||
const listPattern = /^[\d\.\s,]+$/;
|
||||
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
|
||||
}
|
||||
},
|
||||
|
||||
steps: {
|
||||
example: "20, 30, 40, 50\n-- or --\n10:50:10",
|
||||
placeholder: "Enter step counts or range",
|
||||
validate: (value) => {
|
||||
const rangePattern = /^\d+:\d+(:\d+)?$/;
|
||||
const listPattern = /^[\d\s,]+$/;
|
||||
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
|
||||
}
|
||||
},
|
||||
|
||||
clip_skip: {
|
||||
example: "1, 2, 3, 4",
|
||||
placeholder: "Enter clip skip values (typically 1-4)",
|
||||
validate: (value) => {
|
||||
const listPattern = /^[\d\s,]+$/;
|
||||
return listPattern.test(value.trim());
|
||||
}
|
||||
},
|
||||
|
||||
seed: {
|
||||
example: "42, 123, 456, 789\n-- or --\n0:1000:100",
|
||||
placeholder: "Enter seed values or range",
|
||||
validate: (value) => {
|
||||
const rangePattern = /^\d+:\d+(:\d+)?$/;
|
||||
const listPattern = /^[\d\s,]+$/;
|
||||
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
|
||||
}
|
||||
},
|
||||
|
||||
denoise: {
|
||||
example: "0.5, 0.75, 1.0\n-- or --\n0.4:1.0:0.2",
|
||||
placeholder: "Enter denoise values (0.0-1.0)",
|
||||
validate: (value) => {
|
||||
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
|
||||
const listPattern = /^[\d\.\s,]+$/;
|
||||
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
|
||||
}
|
||||
},
|
||||
|
||||
flux_guidance: {
|
||||
example: "1.0, 2.0, 3.5, 5.0\n-- or --\n1:5:0.5",
|
||||
placeholder: "Enter Flux guidance values",
|
||||
validate: (value) => {
|
||||
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
|
||||
const listPattern = /^[\d\.\s,]+$/;
|
||||
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
|
||||
}
|
||||
},
|
||||
|
||||
prompt: {
|
||||
example: "a beautiful sunset over the ocean\na mystical forest at dawn\na futuristic city skyline\na cozy cottage in the mountains",
|
||||
placeholder: "Enter prompts (one per line)",
|
||||
multiline: true,
|
||||
validate: (value) => value.trim().length > 0
|
||||
}
|
||||
};
|
||||
|
||||
// Create a custom widget for value selection
|
||||
function createValueSelectorWidget(node, widgetName, axisType) {
|
||||
const widget = node.widgets.find(w => w.name === widgetName);
|
||||
if (!widget) return;
|
||||
|
||||
const helper = parameterHelpers[axisType];
|
||||
if (!helper) return;
|
||||
|
||||
// Store original widget
|
||||
const originalWidget = widget;
|
||||
|
||||
// Create container for enhanced UI
|
||||
const container = document.createElement("div");
|
||||
container.className = "xyz-value-selector";
|
||||
container.style.cssText = `
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
`;
|
||||
|
||||
if (helper.getOptions) {
|
||||
// Create multi-select for file-based options
|
||||
createMultiSelect(container, helper, originalWidget);
|
||||
} else if (helper.example) {
|
||||
// Show example for numeric/text inputs
|
||||
createExampleHelper(container, helper, originalWidget);
|
||||
}
|
||||
|
||||
// Replace widget element
|
||||
if (widget.inputEl && widget.inputEl.parentNode) {
|
||||
widget.inputEl.parentNode.insertBefore(container, widget.inputEl);
|
||||
container.appendChild(widget.inputEl);
|
||||
}
|
||||
}
|
||||
|
||||
function createMultiSelect(container, helper, widget) {
|
||||
// Create select button
|
||||
const selectBtn = document.createElement("button");
|
||||
selectBtn.textContent = "Select Values...";
|
||||
selectBtn.style.cssText = `
|
||||
margin: 5px 0;
|
||||
padding: 5px 10px;
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
selectBtn.onclick = async () => {
|
||||
const options = await helper.getOptions();
|
||||
showMultiSelectDialog(options, widget, helper.placeholder);
|
||||
};
|
||||
|
||||
container.appendChild(selectBtn);
|
||||
}
|
||||
|
||||
function createExampleHelper(container, helper, widget) {
|
||||
// Create example display
|
||||
const exampleDiv = document.createElement("div");
|
||||
exampleDiv.style.cssText = `
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const exampleText = document.createElement("pre");
|
||||
exampleText.style.cssText = `
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
exampleText.textContent = `Example:\n${helper.example}`;
|
||||
exampleDiv.appendChild(exampleText);
|
||||
|
||||
// Show/hide example based on focus
|
||||
widget.inputEl.addEventListener("focus", () => {
|
||||
exampleDiv.style.display = "block";
|
||||
});
|
||||
|
||||
widget.inputEl.addEventListener("blur", () => {
|
||||
setTimeout(() => {
|
||||
exampleDiv.style.display = "none";
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Update placeholder
|
||||
if (helper.placeholder) {
|
||||
widget.inputEl.placeholder = helper.placeholder;
|
||||
}
|
||||
|
||||
// Add validation
|
||||
if (helper.validate) {
|
||||
widget.inputEl.addEventListener("input", () => {
|
||||
const isValid = helper.validate(widget.inputEl.value);
|
||||
widget.inputEl.style.borderColor = isValid ? "" : "#ff6b6b";
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(exampleDiv);
|
||||
}
|
||||
|
||||
function showMultiSelectDialog(options, widget, placeholder) {
|
||||
// Create modal overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
// Create dialog
|
||||
const dialog = document.createElement("div");
|
||||
dialog.style.cssText = `
|
||||
background: #1e1e1e;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
// Title
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = placeholder || "Select Values";
|
||||
title.style.cssText = "margin: 0 0 15px 0; color: #fff;";
|
||||
dialog.appendChild(title);
|
||||
|
||||
// Search input
|
||||
const searchInput = document.createElement("input");
|
||||
searchInput.type = "text";
|
||||
searchInput.placeholder = "Search...";
|
||||
searchInput.style.cssText = `
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
`;
|
||||
dialog.appendChild(searchInput);
|
||||
|
||||
// Options container
|
||||
const optionsContainer = document.createElement("div");
|
||||
optionsContainer.style.cssText = `
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: #2a2a2a;
|
||||
`;
|
||||
|
||||
// Get current values
|
||||
const currentValues = widget.value.split(',').map(v => v.trim()).filter(v => v);
|
||||
|
||||
// Create checkboxes
|
||||
const checkboxes = [];
|
||||
options.forEach(option => {
|
||||
const label = document.createElement("label");
|
||||
label.style.cssText = `
|
||||
display: block;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
color: #ddd;
|
||||
`;
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = option;
|
||||
checkbox.checked = currentValues.includes(option);
|
||||
checkbox.style.marginRight = "8px";
|
||||
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(document.createTextNode(option));
|
||||
|
||||
checkboxes.push({ checkbox, label, option });
|
||||
optionsContainer.appendChild(label);
|
||||
});
|
||||
|
||||
dialog.appendChild(optionsContainer);
|
||||
|
||||
// Search functionality
|
||||
searchInput.addEventListener("input", () => {
|
||||
const search = searchInput.value.toLowerCase();
|
||||
checkboxes.forEach(({ label, option }) => {
|
||||
label.style.display = option.toLowerCase().includes(search) ? "block" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// Buttons
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.style.cssText = `
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const selectAllBtn = document.createElement("button");
|
||||
selectAllBtn.textContent = "Select All";
|
||||
selectAllBtn.style.cssText = `
|
||||
padding: 8px 15px;
|
||||
background: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
selectAllBtn.onclick = () => {
|
||||
checkboxes.forEach(({ checkbox, label }) => {
|
||||
if (label.style.display !== "none") {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.textContent = "Clear";
|
||||
clearBtn.style.cssText = selectAllBtn.style.cssText;
|
||||
clearBtn.onclick = () => {
|
||||
checkboxes.forEach(({ checkbox }) => checkbox.checked = false);
|
||||
};
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.style.cssText = selectAllBtn.style.cssText;
|
||||
cancelBtn.onclick = () => document.body.removeChild(overlay);
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.textContent = "Apply";
|
||||
applyBtn.style.cssText = `
|
||||
padding: 8px 15px;
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
applyBtn.onclick = () => {
|
||||
const selected = checkboxes
|
||||
.filter(({ checkbox }) => checkbox.checked)
|
||||
.map(({ checkbox }) => checkbox.value);
|
||||
widget.value = selected.join(", ");
|
||||
widget.callback?.(widget.value);
|
||||
document.body.removeChild(overlay);
|
||||
};
|
||||
|
||||
buttonContainer.appendChild(selectAllBtn);
|
||||
buttonContainer.appendChild(clearBtn);
|
||||
buttonContainer.appendChild(cancelBtn);
|
||||
buttonContainer.appendChild(applyBtn);
|
||||
dialog.appendChild(buttonContainer);
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Focus search
|
||||
searchInput.focus();
|
||||
|
||||
// Close on escape
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.body.removeChild(overlay);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
}
|
||||
|
||||
// Register the extension
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.XYZGridWidgets",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "XYZPlotController") {
|
||||
// Wait for widgets to be created
|
||||
setTimeout(() => {
|
||||
setupEnhancedWidgets(node);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setupEnhancedWidgets(node) {
|
||||
const axisTypes = ['x_axis_type', 'y_axis_type', 'z_axis_type'];
|
||||
|
||||
axisTypes.forEach(axisType => {
|
||||
const typeWidget = node.widgets.find(w => w.name === axisType);
|
||||
if (!typeWidget) return;
|
||||
|
||||
const axisPrefix = axisType.split('_')[0]; // 'x', 'y', or 'z'
|
||||
const valueWidgetName = `${axisPrefix}_values`;
|
||||
|
||||
// Monitor axis type changes
|
||||
const originalCallback = typeWidget.callback;
|
||||
typeWidget.callback = function(value) {
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value);
|
||||
}
|
||||
|
||||
// Clear any existing enhancements
|
||||
const valueWidget = node.widgets.find(w => w.name === valueWidgetName);
|
||||
if (valueWidget && valueWidget.inputEl) {
|
||||
const container = valueWidget.inputEl.parentNode;
|
||||
if (container && container.className === "xyz-value-selector") {
|
||||
// Remove enhancements
|
||||
while (container.firstChild !== valueWidget.inputEl) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
while (container.lastChild !== valueWidget.inputEl) {
|
||||
container.removeChild(container.lastChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new enhancements based on type
|
||||
if (value && value !== "none") {
|
||||
setTimeout(() => {
|
||||
createValueSelectorWidget(node, valueWidgetName, value);
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user