268 lines
13 KiB
HTML
268 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ComfyUI Dynamic Dropdowns Node Generator</title>
|
|
<style>
|
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #1e1e2e; color: #cdd6f4; margin: 0; padding: 20px; }
|
|
.container { max-width: 850px; margin: 0 auto; background: #252538; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3); }
|
|
h1, h2 { color: #89b4fa; margin-top: 0; }
|
|
.meta-group { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 25px; }
|
|
.form-group { margin-bottom: 15px; flex-direction: column; display: flex; }
|
|
label { margin-bottom: 5px; font-weight: bold; font-size: 0.9em; color: #a6adc8; }
|
|
input { padding: 10px; background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; font-size: 1rem; }
|
|
input:focus { border-color: #89b4fa; outline: none; }
|
|
.dropdown-item { background: #11111b; padding: 15px; border-radius: 6px; margin-bottom: 15px; border-left: 4px solid #f38ba8; position: relative; }
|
|
.dropdown-item .remove-btn { position: absolute; right: 15px; top: 15px; background: #f38ba8; color: #11111b; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-weight: bold; }
|
|
.upload-row { display: flex; gap: 10px; align-items: center; }
|
|
.upload-row input[type="text"] { flex-grow: 1; }
|
|
.file-label { display: inline-block; padding: 10px 15px; background: #cdd6f4; color: #11111b; font-weight: bold; border-radius: 4px; cursor: pointer; text-align: center; }
|
|
.file-input { display: none; }
|
|
.btn { padding: 12px 20px; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 1rem; transition: background 0.2s; }
|
|
.btn-add { background: #a6e3a1; color: #11111b; margin-bottom: 20px; }
|
|
.btn-build { background: #89b4fa; color: #11111b; width: 100%; font-size: 1.1em; margin-top: 10px; margin-bottom: 20px; }
|
|
.btn-action { background: #a6e3a1; color: #11111b; margin-right: 10px; }
|
|
.btn-download { background: #abe9b3; color: #11111b; }
|
|
.btn:hover { opacity: 0.9; }
|
|
.action-group { display: none; margin-top: 15px; }
|
|
pre { background: #11111b; padding: 15px; border-radius: 6px; overflow-x: auto; max-height: 400px; border: 1px solid #45475a; margin-top: 10px; }
|
|
code { font-family: 'Courier New', Courier, monospace; color: #a6e3a1; }
|
|
|
|
/* Help Instructions Styles */
|
|
.help-panel { background: #181825; border: 1px solid #45475a; border-radius: 6px; padding: 20px; margin-bottom: 30px; }
|
|
.help-panel ul { padding-left: 20px; margin: 5px 0; }
|
|
.help-panel li { margin-bottom: 8px; color: #cdd6f4; }
|
|
.example-box { background: #313244; padding: 10px; border-radius: 4px; font-family: monospace; color: #f9e2af; margin-top: 5px; white-space: pre-line; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="container">
|
|
<h1>Universal Dropdown Node Generator</h1>
|
|
|
|
<div class="help-panel">
|
|
<h2 style="color: #f9e2af; font-size: 1.2rem; margin-bottom: 10px;">📋 Self-Contained Folder Structure & Formatting</h2>
|
|
<p style="font-size: 0.95em; color: #a6adc8;">The generated node aggregates all dropdown choices into a single comma-separated output. The node scans a subfolder named <code>lists</code> inside your custom node directory:</p>
|
|
<div class="example-box" style="color: #a6e3a1;">ComfyUI/
|
|
└── custom_nodes/
|
|
└── ComfyUI-Dynamic-Dropdowns/
|
|
├── your_node_file.py
|
|
└── lists/
|
|
├── hair_length.txt
|
|
├── hair_color.txt
|
|
└── hairstyle.txt</div>
|
|
<ul style="margin-top: 15px;">
|
|
<li><strong>Formatting Rule:</strong> Your text files must feature exactly <strong>one option per line</strong>.</li>
|
|
<li><strong>Restrictions:</strong> No empty lines, no double quotes (<code>"</code>), and no backslashes (<code>\</code>). Validated on upload.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="meta-group">
|
|
<div class="form-group">
|
|
<label for="displayName">UI Display Name</label>
|
|
<input type="text" id="displayName" value="Universal Text Selector" oninput="autoGenerateClassName(this.value)">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="nodeName">Python Class Name (Automatically Generated)</label>
|
|
<input type="text" id="nodeName" value="UniversalTextSelectorNode" readonly style="background: #2b2b3c; color: #b4befe; cursor: not-allowed;">
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Dropdown Elements</h2>
|
|
<div id="dropdownsContainer"></div>
|
|
<button class="btn btn-add" onclick="addDropdown()">+ Add Dropdown Menu</button>
|
|
<button class="btn btn-build" onclick="generateNodeCode()">Build Custom Node</button>
|
|
|
|
<div id="resultSection" class="action-group">
|
|
<h2>Generated Code</h2>
|
|
<button class="btn btn-action" onclick="copyCode()">Copy Code</button>
|
|
<button class="btn btn-action btn-download" onclick="downloadCode()">Download Code</button>
|
|
<pre><code id="codeOutput"></code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let dropdownCount = 0;
|
|
|
|
function autoGenerateClassName(val) {
|
|
let sanitized = val.replace(/[^a-zA-Z0-9 ]/g, '');
|
|
let camelCase = sanitized.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('');
|
|
if(!camelCase.endsWith('Node')) {
|
|
camelCase += 'Node';
|
|
}
|
|
document.getElementById('nodeName').value = camelCase || "MyDropdownNode";
|
|
}
|
|
|
|
function addDropdown() {
|
|
dropdownCount++;
|
|
const container = document.getElementById('dropdownsContainer');
|
|
const div = document.createElement('div');
|
|
div.className = 'dropdown-item';
|
|
div.id = `item_${dropdownCount}`;
|
|
div.innerHTML = `
|
|
<button class="remove-btn" onclick="removeDropdown(${dropdownCount})">X</button>
|
|
<div class="form-group" style="margin-bottom: 10px;">
|
|
<label>Dropdown Label Name (e.g., hair_length, hair_color)</label>
|
|
<input type="text" class="input-label" value="attribute_${dropdownCount}" oninput="updateFileName(this)">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Associated Text File Name (Must match your file in custom_node/lists/)</label>
|
|
<div class="upload-row">
|
|
<input type="text" class="input-file" value="attribute_${dropdownCount}.txt">
|
|
<label class="file-label">
|
|
Upload
|
|
<input type="file" accept=".txt" class="file-input" onchange="handleFileUpload(this, ${dropdownCount})">
|
|
</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
}
|
|
|
|
function removeDropdown(id) {
|
|
document.getElementById(`item_${id}`).remove();
|
|
}
|
|
|
|
function updateFileName(inputElement) {
|
|
const item = inputElement.closest('.dropdown-item');
|
|
const fileInput = item.querySelector('.input-file');
|
|
const sanitized = inputElement.value.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
fileInput.value = sanitized ? `${sanitized}.txt` : '';
|
|
}
|
|
|
|
function handleFileUpload(fileInput, id) {
|
|
if (fileInput.files.length > 0) {
|
|
const file = fileInput.files[0];
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = function(e) {
|
|
const textContent = e.target.result;
|
|
const lines = textContent.split(/\r?\n/);
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const currentLine = lines[i].trim();
|
|
if (lines[i] === "" && i < lines.length - 1) {
|
|
alert(`❌ File Rejected! Empty line detected on line ${i + 1}.`);
|
|
fileInput.value = "";
|
|
return;
|
|
}
|
|
if (currentLine.includes('"') || currentLine.includes('\\')) {
|
|
alert(`❌ File Rejected! Illegal character (quotes or backslash) on line ${i + 1}.`);
|
|
fileInput.value = "";
|
|
return;
|
|
}
|
|
}
|
|
|
|
const fileName = file.name;
|
|
const item = document.getElementById(`item_${id}`);
|
|
item.querySelector('.input-file').value = fileName;
|
|
|
|
const baseName = fileName.replace(/\.[^/.]+$/, "");
|
|
const cleanLabel = baseName.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
item.querySelector('.input-label').value = cleanLabel;
|
|
};
|
|
|
|
reader.readAsText(file);
|
|
}
|
|
}
|
|
|
|
function generateNodeCode() {
|
|
const nodeName = document.getElementById('nodeName').value;
|
|
const displayName = document.getElementById('displayName').value;
|
|
const items = document.querySelectorAll('.dropdown-item');
|
|
|
|
if(items.length === 0) {
|
|
alert("Please add at least one dropdown element before building.");
|
|
return;
|
|
}
|
|
|
|
let configLines = [];
|
|
let readLines = [' "clip": ("CLIP",),'];
|
|
let params = ['clip'];
|
|
let variableNames = [];
|
|
|
|
items.forEach(item => {
|
|
const label = item.querySelector('.input-label').value.trim().replace(/[^a-zA-Z0-9_]/g, '_');
|
|
const file = item.querySelector('.input-file').value.trim();
|
|
|
|
configLines.push(` # Setup list for ${label}\n ${label}_list = ["default_val"]\n ${label}_path = os.path.join(lists_dir, "${file}")\n if os.path.exists(${label}_path):\n with open(${label}_path, "r", encoding="utf-8") as f:\n lines = [l.strip() for l in f.readlines() if l.strip()]\n if lines: ${label}_list = lines`);
|
|
|
|
readLines.push(` "${label}": (${label}_list, {"default": ${label}_list[0]}),`);
|
|
params.push(label);
|
|
variableNames.push(label);
|
|
});
|
|
|
|
const pythonCode = `import os
|
|
|
|
class ${nodeName}:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
current_dir = os.path.dirname(os.path.realpath(__file__))
|
|
lists_dir = os.path.join(current_dir, "lists")
|
|
|
|
if not os.path.exists(lists_dir):
|
|
os.makedirs(lists_dir, exist_ok=True)
|
|
|
|
${configLines.join('\n\n')}
|
|
|
|
return {
|
|
"required": {
|
|
${readLines.join('\n')}
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "CONDITIONING",)
|
|
RETURN_NAMES = ("PROMPT_STRING", "CONDITIONING",)
|
|
FUNCTION = "process_selection"
|
|
CATEGORY = "Custom Selection"
|
|
|
|
def process_selection(self, ${params.join(', ')}):
|
|
selections = [${variableNames.join(', ')}]
|
|
combined_text = ", ".join([str(item) for item in selections if item])
|
|
|
|
tokens = clip.tokenize(combined_text)
|
|
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
|
|
conditioning_output = [[cond, {"pooled_output": pooled}]]
|
|
|
|
return (combined_text, conditioning_output,)
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"${nodeName}": ${nodeName}
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"${nodeName}": "${displayName}"
|
|
}
|
|
`;
|
|
|
|
document.getElementById('codeOutput').textContent = pythonCode;
|
|
document.getElementById('resultSection').style.display = 'block';
|
|
}
|
|
|
|
function copyCode() {
|
|
const codeText = document.getElementById('codeOutput').textContent;
|
|
navigator.clipboard.writeText(codeText).then(() => {
|
|
alert("Code copied to clipboard!");
|
|
});
|
|
}
|
|
|
|
function downloadCode() {
|
|
const codeText = document.getElementById('codeOutput').textContent;
|
|
const nodeName = document.getElementById('nodeName').value;
|
|
const blob = new Blob([codeText], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${nodeName.toLowerCase()}.py`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
addDropdown();
|
|
</script>
|
|
</body>
|
|
</html>
|