diff --git a/nodes/text_nodes_multi_prompts.py b/nodes/text_nodes_multi_prompts.py new file mode 100644 index 0000000..a9c56d5 --- /dev/null +++ b/nodes/text_nodes_multi_prompts.py @@ -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,) \ No newline at end of file diff --git a/nodes/text_nodes_text_line_prompt.py b/nodes/text_nodes_text_line_prompt.py new file mode 100644 index 0000000..4382732 --- /dev/null +++ b/nodes/text_nodes_text_line_prompt.py @@ -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'(?= 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'(? 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,) \ No newline at end of file diff --git a/web/multi_prompts.js b/web/multi_prompts.js new file mode 100644 index 0000000..56974d2 --- /dev/null +++ b/web/multi_prompts.js @@ -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; + }; + } + } +}); \ No newline at end of file diff --git a/web/text_line_prompt.js b/web/text_line_prompt.js new file mode 100644 index 0000000..6f5c207 --- /dev/null +++ b/web/text_line_prompt.js @@ -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(/(?= 2; + + if (!isMultiParentheses && unescapedOpenCount === 1) { + const multMatch = prompt.match(/^\((.*):(\d+(?:\.\d+)?)\)$/); + if (multMatch) { + const innerContent = multMatch[1]; + const innerUnescapedCount = (innerContent.match(/(? { + 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; + }; + } + } +}); \ No newline at end of file