Feat: Implement Multi Prompts and Text Line Prompt nodes with dynamic UI controls and input handling
This commit is contained in:
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user