Feat: Implement Multi Prompts and Text Line Prompt nodes with dynamic UI controls and input handling

This commit is contained in:
KATO Kanryu
2026-05-24 19:33:30 +09:00
parent 9b7c966763
commit ec0c83b9bb
4 changed files with 540 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
from comfy_api.latest import io
class MultiPromptsTextDriven(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
# Define general inputs first
inputs = [
io.Combo.Input("num_prompts", options=["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], default="3"),
io.String.Input("optional_text_in", optional=True, force_input=True),
]
# Append controls sequentially to maintain the layout order (Text -> Multiplier -> Active)
for i in range(1, 11):
inputs.append(io.String.Input(f"text_{i}", multiline=True))
inputs.append(io.Float.Input(f"multiplier_{i}", default=1.0, min=0.0, max=10.0, step=0.1))
inputs.append(io.Boolean.Input(f"active_{i}", default=True))
return io.Schema(
node_id="TextDrivenMultiPrompts",
display_name="Multi Prompts (Text-Driven)",
category="Text-Driven Workflows",
description=(
"Allows simultaneous editing and management of multiple independent prompts within a single node. "
"The text input fields dynamically expand or shrink based on the specified number, "
"and you can toggle individual weights and output active states for each slot. "
"All active prompts are automatically joined into a single comma-separated string output."
),
inputs=inputs,
outputs=[io.String.Output("string_out")]
)
@classmethod
def execute(cls, num_prompts, **kwargs) -> tuple:
num = int(num_prompts)
prompts = []
# Prepend optional text input if provided
optional_text_in = kwargs.get("optional_text_in")
if optional_text_in and isinstance(optional_text_in, str) and optional_text_in.strip():
prompts.append(optional_text_in.strip())
# Process each active slot up to the selected number
for i in range(1, num + 1):
active = kwargs.get(f"active_{i}", True)
if not active:
continue
text = kwargs.get(f"text_{i}", "")
if not isinstance(text, str) or not text.strip():
continue
text = text.strip()
multiplier = kwargs.get(f"multiplier_{i}", 1.0)
if multiplier == 1.0:
prompts.append(text)
else:
# Round to 2 decimal places and strip trailing zeros (e.g., 1.10 -> 1.1)
formatted_multiplier = f"{round(multiplier, 2):g}"
prompts.append(f"({text}:{formatted_multiplier})")
# Join prompts with a comma
result_text = ",".join(prompts)
# Remove spaces around punctuation marks
result_text = result_text.replace(" ,", ",").replace(", ", ",")
result_text = result_text.replace(" .", ".").replace(". ", ".")
return (result_text,)
+119
View File
@@ -0,0 +1,119 @@
from comfy_api.latest import io
import re
class TextLinePromptTextDriven(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
# Condense the sample into exactly 4 lines as initial defaults
default_text = (
"1girl, solo, emilia \\(re:zero\\), blonde hair,\n"
"(abstract background:1.5),\n"
"three quarter view, speed lines,\n"
"(by carnelian:0.6),"
)
# Formatted initial options matching the startup state
initial_options = [
"1: 1girl, solo, emilia \\(re:zero\\), blonde hair",
"2: (abstract background:1.5)",
"3: three quarter view, speed lines",
"4: (by carnelian:0.6)"
]
return io.Schema(
node_id="TextDrivenTextLinePrompt",
display_name="Text Line Prompt (Text-Driven)",
category="Text-Driven Workflows",
description=(
"An innovative bi-directional editing node that allows intuitive control of extensive prompt assets "
"or line-separated concepts within a single text area. Selecting a line via the combobox lets you "
"directly modify its multiplier or active state in real-time, rewriting the text content. "
"Deactivated lines are commented out with '//' on the fly and automatically skipped during execution "
"to yield a clean, concatenated string output."
),
inputs=[
io.String.Input("text", multiline=True, default=default_text),
io.Combo.Input("selected_line", options=initial_options, default=initial_options[0]),
io.Float.Input("multiplier", default=1.0, min=0.0, max=10.0, step=0.1),
io.Boolean.Input("active", default=True),
io.String.Input("optional_text_in", optional=True, force_input=True),
],
outputs=[io.String.Output("string_out")]
)
@classmethod
def validate_inputs(cls, **kwargs) -> bool:
# Bypass strict backend list check to allow any dynamic string from JavaScript frontend
return True
@classmethod
def execute(cls, text, selected_line, multiplier, active, **kwargs) -> tuple:
prompts = []
# Prepend optional text input if provided
optional_text_in = kwargs.get("optional_text_in")
if optional_text_in and isinstance(optional_text_in, str) and optional_text_in.strip():
prompts.append(optional_text_in.strip())
if text:
lines = text.splitlines()
for line in lines:
cleaned = line.strip()
if not cleaned:
continue
# Check active status based on comment prefix '//'
is_comment = cleaned.startswith("//")
if is_comment:
cleaned = cleaned[2:].strip()
if not cleaned:
continue
# Strip trailing commas and surrounding spaces from the end of the line
cleaned = re.sub(r',\s*$', '', cleaned).strip()
# Skip commented-out lines for the final prompt string output
if is_comment:
continue
# Separate label if custom delimiter '|-|' exists
if "|-|" in cleaned:
_, prompt_part = cleaned.split("|-|", 1)
prompt_part = prompt_part.strip()
else:
prompt_part = cleaned
if not prompt_part:
continue
# Count only UNESCAPED open parentheses using lookbehind regex
unescaped_open_count = len(re.findall(r'(?<!\\)\(', prompt_part))
if unescaped_open_count >= 2:
# Multiple unescaped parentheses case - turn off multiplier logic, treat as 1.0
prompts.append(prompt_part)
else:
# Try to extract global multiplier wrapper like (prompt:weight)
match = re.match(r'^\((.*):(\d+(?:\.\d+)?)\)$', prompt_part)
if match:
inner_content = match.group(1)
weight = float(match.group(2))
inner_unescaped_count = len(re.findall(r'(?<!\\)\(', inner_content))
if inner_unescaped_count == 0:
# Round and strip trailing zeros for output string (e.g., 1.10 -> 1.1)
formatted_weight = f"{round(weight, 2):g}"
prompts.append(inner_content if weight == 1.0 else f"({inner_content}:{formatted_weight})")
else:
prompts.append(prompt_part)
else:
prompts.append(prompt_part)
# Concatenate valid prompts into a unified comma-separated block
result_text = ",".join(prompts)
# Rigorously trim spaces around core punctuation marks
result_text = result_text.replace(" ,", ",").replace(", ", ",")
result_text = result_text.replace(" .", ".").replace(". ", ".")
return (result_text,)
+98
View File
@@ -0,0 +1,98 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "ComfyUI-Text-Driven-Workflows.MultiPromptsTextDriven",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "TextDrivenMultiPrompts") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const self = this;
// Function to toggle visibility and layout based on selected slot count
self.updateWidgets = function() {
const numWidget = self.widgets.find(w => w.name === "num_prompts");
if (!numWidget) return;
const num = parseInt(numWidget.value, 10);
for (let i = 1; i <= 10; i++) {
const show = i <= num;
const textWidget = self.widgets.find(w => w.name === `text_${i}`);
const multWidget = self.widgets.find(w => w.name === `multiplier_${i}`);
const actWidget = self.widgets.find(w => w.name === `active_${i}`);
const toggleWidget = (w, show) => {
if (!w) return;
if (show) {
if (w.type === "hidden") {
w.type = w.origType || "customtext";
// FIXED: Re-instated the critical property deletion to restore prototype logic for standard widgets
if (w.origComputeSize) {
w.computeSize = w.origComputeSize;
} else {
delete w.computeSize;
}
if (w.origDraw) {
w.draw = w.origDraw;
} else {
delete w.draw;
}
}
if (w.inputEl) w.inputEl.style.display = "";
if (w.element) w.element.style.display = "";
} else {
if (w.type !== "hidden") {
w.origType = w.type;
w.origComputeSize = w.computeSize;
w.origDraw = w.draw;
w.type = "hidden";
w.computeSize = () => [0, -4];
w.draw = () => {};
}
if (w.inputEl) w.inputEl.style.display = "none";
if (w.element) w.element.style.display = "none";
}
};
toggleWidget(textWidget, show);
toggleWidget(multWidget, show);
toggleWidget(actWidget, show);
}
// Collapse node height dynamically to fit the current UI state
self.setSize(self.computeSize());
app.graph.setDirtyCanvas(true, true);
};
// Inject visibility updates into the dropdown callback
const numWidget = self.widgets.find(w => w.name === "num_prompts");
if (numWidget) {
const origCallback = numWidget.callback;
numWidget.callback = (value) => {
if (origCallback) origCallback(value);
self.updateWidgets();
};
}
// Apply initial UI shrinking for the default value (3) on creation
setTimeout(() => self.updateWidgets(), 10);
return r;
};
// Re-apply layout adjustment when loading saved workflows
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const r = onConfigure ? onConfigure.apply(this, [info]) : undefined;
this.updateWidgets();
return r;
};
}
}
});
+254
View File
@@ -0,0 +1,254 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "ComfyUI-Text-Driven-Workflows.TextLinePrompt",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "TextDrivenTextLinePrompt") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const self = this;
self.is_updating_widgets = false;
const textWidget = self.widgets.find(w => w.name === "text");
const comboWidget = self.widgets.find(w => w.name === "selected_line");
const multWidget = self.widgets.find(w => w.name === "multiplier");
const actWidget = self.widgets.find(w => w.name === "active");
// Helper to safely extract the actual textarea DOM element across different ComfyUI V3 structures
const getTextareaElement = (w) => {
if (!w) return null;
if (w.inputEl) return w.inputEl;
if (w.element) {
if (w.element.tagName === "TEXTAREA") return w.element;
return w.element.querySelector("textarea");
}
return null;
};
// Helper to cleanly deconstruct a line into semantic metadata components
const parseLine = (line) => {
let trimmed = line.trim();
const active = !trimmed.startsWith("//");
let content = active ? trimmed : trimmed.replace(/^\s*\/\//, "").trim();
content = content.replace(/,\s*$/, "").trim();
let label = "";
let prompt = "";
if (content.includes("|-|")) {
const parts = content.split("|-|");
label = parts[0].trim();
prompt = parts.slice(1).join("|-|").trim();
} else {
label = content;
prompt = content;
}
const unescapedOpenCount = (prompt.match(/(?<!\\)\(/g) || []).length;
let multiplier = 1.0;
let isMultiParentheses = unescapedOpenCount >= 2;
if (!isMultiParentheses && unescapedOpenCount === 1) {
const multMatch = prompt.match(/^\((.*):(\d+(?:\.\d+)?)\)$/);
if (multMatch) {
const innerContent = multMatch[1];
const innerUnescapedCount = (innerContent.match(/(?<!\\)\(/g) || []).length;
if (innerUnescapedCount === 0) {
prompt = innerContent.trim();
multiplier = parseFloat(multMatch[2]);
} else {
isMultiParentheses = true;
}
}
}
return { active, label, prompt, multiplier, isMultiParentheses };
};
// Parse text block and dynamically populate combobox options list
self.updateComboOptions = function() {
if (!textWidget || !comboWidget) return;
const textValue = textWidget.value || "";
const lines = textValue.split("\n");
const options = [];
lines.forEach((line, index) => {
if (line.trim() === "") return;
const { active, label } = parseLine(line);
const prefix = active ? "" : "// ";
options.push(`${index + 1}: ${prefix}${label || "(Empty)"}`);
});
if (options.length === 0) options.push("none");
const currentVal = comboWidget.value;
comboWidget.options.values = options;
if (options.includes(currentVal)) {
comboWidget.value = currentVal;
} else {
// Trace and lock onto the row index prefix (e.g. '3:') to ensure robust follow-through when strings mutate
const match = currentVal ? currentVal.match(/^(\d+):/) : null;
if (match) {
const prefix = `${match[1]}:`;
const found = options.find(v => v.startsWith(prefix));
if (found) {
comboWidget.value = found;
return;
}
}
comboWidget.value = options[0];
}
};
// Read row string data and update standalone UI widgets values
self.syncWidgetsFromSelectedLine = function() {
if (self.is_updating_widgets || !textWidget || !comboWidget || !multWidget || !actWidget) return;
const selected = comboWidget.value;
if (!selected || selected === "none") return;
const match = selected.match(/^(\d+):/);
if (!match) return;
const lineIndex = parseInt(match[1], 10) - 1;
const lines = (textWidget.value || "").split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
const { active, multiplier, isMultiParentheses } = parseLine(lines[lineIndex]);
self.is_updating_widgets = true;
multWidget.value = isMultiParentheses ? 1.0 : multiplier;
actWidget.value = active;
self.is_updating_widgets = false;
app.graph.setDirtyCanvas(true, false);
};
// Directly rewrite rows within the multiline element when controls trigger
self.updateTextFromControls = function() {
if (self.is_updating_widgets || !textWidget || !comboWidget || !multWidget || !actWidget) return;
const selected = comboWidget.value;
if (!selected || selected === "none") return;
const match = selected.match(/^(\d+):/);
if (!match) return;
const lineIndex = parseInt(match[1], 10) - 1;
const lines = (textWidget.value || "").split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
const { label, prompt, isMultiParentheses } = parseLine(lines[lineIndex]);
const newActive = actWidget.value;
let newMultiplier = parseFloat(multWidget.value);
// Scope isolation turned on immediately before firing dispatch events to safely block feedback loops
self.is_updating_widgets = true;
if (isMultiParentheses) {
newMultiplier = 1.0;
multWidget.value = 1.0;
}
let promptPart = prompt;
if (!isMultiParentheses && newMultiplier !== 1.0) {
// Convert to string via parseFloat to automatically drop trailing zeros (e.g., 1.10 -> 1.1)
const formattedMultiplier = parseFloat(newMultiplier.toFixed(2)).toString();
promptPart = `(${prompt}:${formattedMultiplier})`;
}
const hasTrailingComma = lines[lineIndex].trim().endsWith(",");
let newLine = lines[lineIndex].includes("|-|") ? `${label}|-|${promptPart}` : promptPart;
if (hasTrailingComma) {
newLine = `${newLine},`;
}
if (!newActive) {
newLine = `//${newLine}`;
}
lines[lineIndex] = newLine;
const newTextValue = lines.join("\n");
textWidget.value = newTextValue;
if (textWidget.callback) {
textWidget.callback(newTextValue);
}
const textarea = getTextareaElement(textWidget);
if (textarea) {
textarea.value = newTextValue;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
textarea.dispatchEvent(new Event("change", { bubbles: true }));
}
self.updateComboOptions();
const updatedSelected = comboWidget.options.values.find(v => v.startsWith(`${lineIndex + 1}:`));
if (updatedSelected) comboWidget.value = updatedSelected;
// Release scope guard only after dropdown re-selection completes flawlessly
self.is_updating_widgets = false;
app.graph.setDirtyCanvas(true, true);
};
setTimeout(() => {
const textarea = getTextareaElement(textWidget);
if (textarea) {
textarea.addEventListener("input", () => {
if (!self.is_updating_widgets) {
self.updateComboOptions();
self.syncWidgetsFromSelectedLine();
}
});
}
}, 100);
if (comboWidget) {
const origComboCallback = comboWidget.callback;
comboWidget.callback = function (value) {
if (origComboCallback) origComboCallback.call(this, value);
self.syncWidgetsFromSelectedLine();
};
}
if (multWidget) {
const origMultCallback = multWidget.callback;
multWidget.callback = function (value) {
if (origMultCallback) origMultCallback.call(this, value);
self.updateTextFromControls();
};
}
if (actWidget) {
const origActCallback = actWidget.callback;
actWidget.callback = function (value) {
if (origActCallback) origActCallback.call(this, value);
self.updateTextFromControls();
};
}
setTimeout(() => {
self.updateComboOptions();
self.syncWidgetsFromSelectedLine();
}, 30);
return r;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const r = onConfigure ? onConfigure.apply(this, [info]) : undefined;
if (this.updateComboOptions) this.updateComboOptions();
return r;
};
}
}
});