initial commit

This commit is contained in:
Shaun Johnston
2025-12-16 09:14:51 +10:00
commit 2496f61989
6 changed files with 508 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# ComfyUI Multi-Replace
A ComfyUI custom node package for creating and applying multiple find/replace pairs to text.
## Features
- **Find/Replace Pairs Node**: Create an arbitrary number of find/replace pairs
- Supports up to 50 pairs per node
- Each pair can have values entered directly or connected from other nodes
- Dynamic UI shows only active pairs
- Outputs: pairs object, JSON, and CSV formats
- **Text Replacer Node**: Apply find/replace pairs to text
- Accepts pairs from the FindReplacePairs node
- Supports regular expressions
- Case-sensitive/insensitive matching
- Replace all or first occurrence only
- Outputs: modified text, pairs passthrough (for chaining), changes log, and replacement count
## Installation
1. Clone or copy this folder to your ComfyUI `custom_nodes` directory:
```
cd ComfyUI/custom_nodes
git clone <repo-url> comfyui-multi-replace
```
2. Restart ComfyUI
## Usage
### Basic Usage
1. Add a **Find/Replace Pairs** node
2. Set the number of pairs you need with `pair_count`
3. Enter your find patterns and replacements
4. Connect the `pairs` output to a **Text Replacer** node
5. Enter or connect your input text
6. Get the modified text from the `result` output
### Connecting Dynamic Values
Each find/replace pair has optional input connectors (`find_N_input` and `replace_N_input`) that can receive values from other nodes. Connected inputs take priority over widget values.
### Chaining Replacers
The Text Replacer node passes through the pairs object, allowing you to chain multiple replacers or use the same pairs set multiple times.
### Output Formats
**Find/Replace Pairs outputs:**
- `pairs` - Internal pairs object for use with TextReplacer
- `json_output` - JSON array of pairs: `[{"find": "...", "replace": "...", "index": 1}, ...]`
- `csv_output` - CSV format: `find,replace` per line
**Text Replacer outputs:**
- `result` - The text after all replacements
- `pairs_passthrough` - The pairs object (for chaining)
- `changes_log` - Human-readable log of changes made
- `replacement_count` - Total number of replacements performed
## Node Reference
### Find/Replace Pairs
| Input | Type | Description |
|-------|------|-------------|
| `pair_count` | INT | Number of active pairs (1-50) |
| `find_N` | STRING | Find pattern for pair N |
| `replace_N` | STRING | Replacement for pair N |
| `find_N_input` | STRING (connector) | Optional connected find pattern |
| `replace_N_input` | STRING (connector) | Optional connected replacement |
### Text Replacer
| Input | Type | Description |
|-------|------|-------------|
| `pairs` | FR_PAIRS | Pairs from FindReplacePairs node |
| `input_text` | STRING | Text to perform replacements on |
| `input_text_connected` | STRING (connector) | Optional connected text input |
| `use_regex` | BOOLEAN | Treat patterns as regex |
| `case_sensitive` | BOOLEAN | Case-sensitive matching |
| `replace_all` | BOOLEAN | Replace all vs first occurrence |
## License
MIT License
+21
View File
@@ -0,0 +1,21 @@
"""
ComfyUI Multi-Replace Custom Node
A node package for creating and applying multiple find/replace pairs.
"""
from .nodes.find_replace_pairs import FindReplacePairs
from .nodes.text_replacer import TextReplacer
NODE_CLASS_MAPPINGS = {
"FindReplacePairs": FindReplacePairs,
"TextReplacer": TextReplacer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FindReplacePairs": "Find/Replace Pairs",
"TextReplacer": "Text Replacer",
}
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+104
View File
@@ -0,0 +1,104 @@
import { app } from "../../scripts/app.js";
/**
* ComfyUI Multi-Replace Extension
* Handles dynamic visibility of find/replace pair inputs based on pair_count
*/
const MAX_PAIRS = 50;
app.registerExtension({
name: "comfyui-multi-replace",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "FindReplacePairs") {
// Store original onNodeCreated
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (origOnNodeCreated) {
origOnNodeCreated.apply(this, arguments);
}
// Find the pair_count widget
const pairCountWidget = this.widgets?.find(w => w.name === "pair_count");
if (pairCountWidget) {
// Store original callback
const origCallback = pairCountWidget.callback;
// Override callback to update visibility
pairCountWidget.callback = (value, ...args) => {
if (origCallback) {
origCallback.call(pairCountWidget, value, ...args);
}
this.updatePairVisibility(value);
};
// Initial visibility update
setTimeout(() => {
this.updatePairVisibility(pairCountWidget.value);
}, 100);
}
};
// Method to update visibility of pair widgets
nodeType.prototype.updatePairVisibility = function(pairCount) {
if (!this.widgets) return;
const count = parseInt(pairCount) || 1;
for (const widget of this.widgets) {
const match = widget.name.match(/^(find|replace)_(\d+)$/);
if (match) {
const pairIndex = parseInt(match[2]);
// Show widgets for active pairs + 1 empty pair for new entry
const shouldShow = pairIndex <= count;
// ComfyUI uses different methods to hide widgets
if (shouldShow) {
widget.type = widget._originalType || widget.type;
widget.computeSize = widget._originalComputeSize;
} else {
widget._originalType = widget._originalType || widget.type;
widget._originalComputeSize = widget._originalComputeSize || widget.computeSize;
widget.type = "hidden";
widget.computeSize = () => [0, -4];
}
}
}
// Recalculate node size
this.setSize(this.computeSize());
app.graph.setDirtyCanvas(true, true);
};
// Override onConfigure to restore visibility on load
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(config) {
if (origOnConfigure) {
origOnConfigure.apply(this, arguments);
}
setTimeout(() => {
const pairCountWidget = this.widgets?.find(w => w.name === "pair_count");
if (pairCountWidget) {
this.updatePairVisibility(pairCountWidget.value);
}
}, 100);
};
}
},
async nodeCreated(node) {
if (node.comfyClass === "FindReplacePairs") {
// Additional setup after node is fully created
const pairCountWidget = node.widgets?.find(w => w.name === "pair_count");
if (pairCountWidget) {
setTimeout(() => {
node.updatePairVisibility?.(pairCountWidget.value);
}, 200);
}
}
}
});
+1
View File
@@ -0,0 +1 @@
"""Nodes package for ComfyUI Multi-Replace."""
+148
View File
@@ -0,0 +1,148 @@
"""
Find/Replace Pairs Node
Creates a collection of find/replace pairs with dynamic inputs.
"""
import json
from typing import Any
class FindReplacePairs:
"""
A node that creates an arbitrary number of find/replace pairs.
Each pair can have values entered directly or connected from other nodes.
"""
MAX_PAIRS = 50 # Maximum number of pairs supported
@classmethod
def INPUT_TYPES(cls):
"""
Define dynamic inputs for find/replace pairs.
We define a base set of inputs plus many optional pair inputs.
"""
inputs = {
"required": {},
"optional": {
"pair_count": ("INT", {
"default": 1,
"min": 1,
"max": cls.MAX_PAIRS,
"step": 1,
"display": "number"
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
# Add optional inputs for each potential pair
for i in range(1, cls.MAX_PAIRS + 1):
inputs["optional"][f"find_{i}"] = ("STRING", {
"default": "",
"multiline": False,
"placeholder": f"Find pattern {i}"
})
inputs["optional"][f"replace_{i}"] = ("STRING", {
"default": "",
"multiline": False,
"placeholder": f"Replacement {i}"
})
# Also allow connected string inputs (these override the widget values)
inputs["optional"][f"find_{i}_input"] = ("STRING", {
"forceInput": True,
})
inputs["optional"][f"replace_{i}_input"] = ("STRING", {
"forceInput": True,
})
return inputs
RETURN_TYPES = ("FR_PAIRS", "STRING", "STRING",)
RETURN_NAMES = ("pairs", "json_output", "csv_output",)
OUTPUT_TOOLTIPS = (
"Find/Replace pairs object for use with TextReplacer node",
"JSON representation of the pairs",
"CSV representation of the pairs (find,replace per line)",
)
FUNCTION = "create_pairs"
CATEGORY = "text/replace"
DESCRIPTION = "Creates a collection of find/replace pairs that can be used with the TextReplacer node."
def create_pairs(self, pair_count=1, unique_id=None, **kwargs):
"""
Create the find/replace pairs collection.
Args:
pair_count: Number of active pairs
unique_id: Node's unique ID
**kwargs: Dynamic find_N, replace_N, find_N_input, replace_N_input values
Returns:
Tuple of (pairs_dict, json_string, csv_string)
"""
pairs = []
for i in range(1, pair_count + 1):
# Check for connected input first, fall back to widget value
find_key = f"find_{i}_input"
find_widget_key = f"find_{i}"
replace_key = f"replace_{i}_input"
replace_widget_key = f"replace_{i}"
# Get find value (connected input takes priority)
find_value = kwargs.get(find_key)
if find_value is None or find_value == "":
find_value = kwargs.get(find_widget_key, "")
# Get replace value (connected input takes priority)
replace_value = kwargs.get(replace_key)
if replace_value is None or replace_value == "":
replace_value = kwargs.get(replace_widget_key, "")
# Only add non-empty find patterns
if find_value:
pairs.append({
"find": find_value,
"replace": replace_value,
"index": i
})
# Create the pairs object
pairs_obj = {
"pairs": pairs,
"count": len(pairs),
"source_node": unique_id
}
# Create JSON output
json_output = json.dumps(pairs, indent=2)
# Create CSV output (find,replace per line)
csv_lines = []
for pair in pairs:
# Escape commas and quotes in values for CSV
find_escaped = pair["find"].replace('"', '""')
replace_escaped = pair["replace"].replace('"', '""')
if ',' in find_escaped or '"' in find_escaped:
find_escaped = f'"{find_escaped}"'
if ',' in replace_escaped or '"' in replace_escaped:
replace_escaped = f'"{replace_escaped}"'
csv_lines.append(f"{find_escaped},{replace_escaped}")
csv_output = "\n".join(csv_lines)
return (pairs_obj, json_output, csv_output)
@classmethod
def IS_CHANGED(cls, pair_count=1, **kwargs):
"""Force re-evaluation when inputs change."""
# Create a hash of all input values
values = [str(pair_count)]
for i in range(1, pair_count + 1):
values.append(str(kwargs.get(f"find_{i}", "")))
values.append(str(kwargs.get(f"replace_{i}", "")))
values.append(str(kwargs.get(f"find_{i}_input", "")))
values.append(str(kwargs.get(f"replace_{i}_input", "")))
return hash(tuple(values))
+147
View File
@@ -0,0 +1,147 @@
"""
Text Replacer Node
Applies find/replace pairs to input text.
"""
import re
from typing import Any
class TextReplacer:
"""
A node that applies find/replace pairs to an input string.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pairs": ("FR_PAIRS", {
"tooltip": "Find/Replace pairs from the FindReplacePairs node"
}),
"input_text": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Text to perform replacements on"
}),
},
"optional": {
"input_text_connected": ("STRING", {
"forceInput": True,
"tooltip": "Connected text input (overrides widget)"
}),
"use_regex": ("BOOLEAN", {
"default": False,
"tooltip": "Treat find patterns as regular expressions"
}),
"case_sensitive": ("BOOLEAN", {
"default": True,
"tooltip": "Whether replacements are case-sensitive"
}),
"replace_all": ("BOOLEAN", {
"default": True,
"tooltip": "Replace all occurrences (vs only first)"
}),
}
}
RETURN_TYPES = ("STRING", "FR_PAIRS", "STRING", "INT",)
RETURN_NAMES = ("result", "pairs_passthrough", "changes_log", "replacement_count",)
OUTPUT_TOOLTIPS = (
"The text after all replacements have been applied",
"The find/replace pairs (passed through for chaining)",
"Log of all replacements made",
"Total number of replacements performed",
)
FUNCTION = "apply_replacements"
CATEGORY = "text/replace"
DESCRIPTION = "Applies find/replace pairs to input text. Supports regex and case-insensitive matching."
def apply_replacements(
self,
pairs: dict,
input_text: str = "",
input_text_connected: str = None,
use_regex: bool = False,
case_sensitive: bool = True,
replace_all: bool = True
):
"""
Apply all find/replace pairs to the input text.
Args:
pairs: The pairs object from FindReplacePairs node
input_text: Text widget value
input_text_connected: Connected text input (takes priority)
use_regex: Whether to use regex matching
case_sensitive: Whether matching is case-sensitive
replace_all: Whether to replace all occurrences
Returns:
Tuple of (result_text, pairs_passthrough, changes_log, replacement_count)
"""
# Use connected input if available
text = input_text_connected if input_text_connected is not None else input_text
# Get the pairs list
pairs_list = pairs.get("pairs", [])
total_replacements = 0
log_entries = []
result = text
for pair in pairs_list:
find_pattern = pair.get("find", "")
replace_with = pair.get("replace", "")
if not find_pattern:
continue
# Count occurrences before replacement
if use_regex:
flags = 0 if case_sensitive else re.IGNORECASE
try:
matches = len(re.findall(find_pattern, result, flags))
if replace_all:
result = re.sub(find_pattern, replace_with, result, flags=flags)
else:
result = re.sub(find_pattern, replace_with, result, count=1, flags=flags)
except re.error as e:
log_entries.append(f"Regex error for pattern '{find_pattern}': {e}")
continue
else:
if case_sensitive:
matches = result.count(find_pattern)
if replace_all:
result = result.replace(find_pattern, replace_with)
else:
result = result.replace(find_pattern, replace_with, 1)
else:
# Case-insensitive plain text replacement
pattern = re.escape(find_pattern)
matches = len(re.findall(pattern, result, re.IGNORECASE))
if replace_all:
result = re.sub(pattern, replace_with, result, flags=re.IGNORECASE)
else:
result = re.sub(pattern, replace_with, result, count=1, flags=re.IGNORECASE)
replacements_made = min(matches, 1) if not replace_all else matches
total_replacements += replacements_made
if replacements_made > 0:
log_entries.append(
f"Replaced '{find_pattern}' -> '{replace_with}' ({replacements_made}x)"
)
# Create changes log
changes_log = "\n".join(log_entries) if log_entries else "No replacements made"
return (result, pairs, changes_log, total_replacements)
@classmethod
def IS_CHANGED(cls, pairs, input_text="", input_text_connected=None, **kwargs):
"""Force re-evaluation when inputs change."""
text = input_text_connected if input_text_connected is not None else input_text
pairs_hash = hash(str(pairs))
return hash((text, pairs_hash, str(kwargs)))