feat: add text word replacer node

This commit is contained in:
tianzi
2026-06-25 19:09:56 +01:00
parent fbd550d56c
commit 360eb3435a
5 changed files with 329 additions and 2 deletions
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- add `MOODS_NAME_ONLY` output to `VrchAudioMusic2EmotionNode`
- add `TEXT Word Replacer @ vrch.ai` for rule-based text replacement
### Fixed
+2
View File
@@ -74,6 +74,7 @@ NODE_CLASS_MAPPINGS = {
"VrchTextKeyControlNode": VrchTextKeyControlNode,
"VrchTextSrtPlayerNode": VrchTextSrtPlayerNode,
"VrchTextSwitchOSCControlNode": VrchTextSwitchOSCControlNode,
"VrchTextWordReplacerNode": VrchTextWordReplacerNode,
"VrchTriggerToggleNode": VrchTriggerToggleNode,
"VrchTriggerToggleX4Node": VrchTriggerToggleX4Node,
"VrchTriggerToggleX8Node": VrchTriggerToggleX8Node,
@@ -146,6 +147,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"VrchTextKeyControlNode": "TEXT Key Control @ vrch.ai",
"VrchTextSrtPlayerNode": "TEXT SRT Player @ vrch.ai",
"VrchTextSwitchOSCControlNode": "TEXT Switch OSC Control @ vrch.ai",
"VrchTextWordReplacerNode": "TEXT Word Replacer @ vrch.ai",
"VrchTriggerToggleNode": "Trigger Toggle @ vrch.ai",
"VrchTriggerToggleX4Node": "Trigger Toggle x4 @ vrch.ai",
"VrchTriggerToggleX8Node": "Trigger Toggle x8 @ vrch.ai",
+72 -1
View File
@@ -18,6 +18,77 @@
- Ensure that the URL is accessible and returns valid JSON data.
---
### Node: `TEXT Word Replacer @ vrch.ai` (vrch.ai/text)
1. **Add the `TEXT Word Replacer @ vrch.ai` node to your ComfyUI workflow.**
2. **Configure the Node:**
- **Text:**
- **`text`**: Input text to transform.
- **Rules:**
- **`rules`**: One replacement rule per line.
- Use `source => target` format:
```text
# source => target
happy => joyful
sad => melancholic
high energy => energetic
```
- Empty lines and lines starting with `#` are ignored.
- If the target side is empty, the matched text is removed.
- If the same source appears more than once, the last rule wins.
- **Match Mode:**
- **`whole_word`**: Matches full words or phrases only. This is the default and avoids replacing `sad` inside `sadness`.
- **`literal`**: Matches the source text anywhere, including inside longer words.
- **Case Sensitive:**
- **`case_sensitive`**: Toggle this option to require exact uppercase/lowercase matching.
- **Debug:**
- **`debug`**: Print the replacement report to the console.
3. **Replacement Behavior:**
- The node applies all rules in one pass, so replacements do not cascade.
- Longer source phrases are matched before shorter source phrases. For example, `cat girl` is matched before `cat`.
- Rules are treated as literal text, not regular expressions.
4. **Outputs:**
- `TEXT`: The transformed text.
- `REPLACE_REPORT`: JSON report with rule and replacement counts:
```json
{
"rules_count": 3,
"ignored_rules_count": 0,
"replaced_count": 5,
"matched": {
"happy": 2,
"sad": 3
},
"match_mode": "whole_word",
"case_sensitive": false
}
```
**Example:**
Input text:
```text
happy and sad, high energy music
```
Rules:
```text
happy => joyful
sad => melancholic
high energy => energetic
```
Output text:
```text
joyful and melancholic, energetic music
```
---
### Node: `TEXT SRT Player @ vrch.ai` (vrch.ai/text)
@@ -57,4 +128,4 @@
- You can chain this output into subsequent nodes in your ComfyUI workflow for further processing or display.
**Note:**
- Ensure that your SRT text is correctly formatted (with sequential numbering, time range lines, and subtitle text).
- Ensure that your SRT text is correctly formatted (with sequential numbering, time range lines, and subtitle text).
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Tests for VrchTextWordReplacerNode."""
import sys
import unittest
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from nodes.text_nodes import VrchTextWordReplacerNode # noqa: E402
class TestTextWordReplacerNode(unittest.TestCase):
def setUp(self):
self.node = VrchTextWordReplacerNode()
def replace(self, text, rules, match_mode="whole_word", case_sensitive=False):
return self.node.replace_text(
text=text,
rules=rules,
match_mode=match_mode,
case_sensitive=case_sensitive,
debug=False,
)
def test_basic_replacement_and_report(self):
output, report = self.replace(
"happy and sad",
"happy => joyful\nsad => melancholic",
)
self.assertEqual(output, "joyful and melancholic")
self.assertEqual(report["rules_count"], 2)
self.assertEqual(report["replaced_count"], 2)
self.assertEqual(report["matched"], {"happy": 1, "sad": 1})
def test_whole_word_does_not_replace_inside_words(self):
output, report = self.replace("sad sadness unsad", "sad => blue")
self.assertEqual(output, "blue sadness unsad")
self.assertEqual(report["replaced_count"], 1)
def test_literal_mode_replaces_inside_words(self):
output, report = self.replace("sad sadness", "sad => blue", match_mode="literal")
self.assertEqual(output, "blue blueness")
self.assertEqual(report["replaced_count"], 2)
def test_case_insensitive_matching(self):
output, report = self.replace("Happy happy HAPPY", "happy => joyful")
self.assertEqual(output, "joyful joyful joyful")
self.assertEqual(report["matched"], {"happy": 3})
def test_case_sensitive_matching(self):
output, report = self.replace("Happy happy", "happy => joyful", case_sensitive=True)
self.assertEqual(output, "Happy joyful")
self.assertEqual(report["replaced_count"], 1)
def test_longer_source_matches_before_shorter_source(self):
output, report = self.replace(
"cat girl and cat",
"cat => dog\ncat girl => neko girl",
)
self.assertEqual(output, "neko girl and dog")
self.assertEqual(report["matched"], {"cat girl": 1, "cat": 1})
def test_replacements_do_not_cascade(self):
output, report = self.replace(
"happy sad",
"happy => sad\nsad => dark",
)
self.assertEqual(output, "sad dark")
self.assertEqual(report["matched"], {"happy": 1, "sad": 1})
def test_empty_target_deletes_text(self):
output, report = self.replace("bad mood", "bad =>")
self.assertEqual(output, " mood")
self.assertEqual(report["replaced_count"], 1)
def test_comments_empty_lines_and_invalid_rules_are_ignored(self):
output, report = self.replace(
"happy and sad",
"""
# comment
invalid line
happy => joyful
=> ignored
sad => calm
""",
)
self.assertEqual(output, "joyful and calm")
self.assertEqual(report["rules_count"], 2)
self.assertEqual(report["ignored_rules_count"], 2)
def test_empty_rules_return_original_text(self):
output, report = self.replace("keep this", "")
self.assertEqual(output, "keep this")
self.assertEqual(report["rules_count"], 0)
self.assertEqual(report["replaced_count"], 0)
if __name__ == "__main__":
unittest.main()
+141 -1
View File
@@ -1,5 +1,6 @@
import hashlib
import json
import re
import requests
import srt
from datetime import timedelta
@@ -45,6 +46,145 @@ class VrchJsonUrlLoaderNode:
return (res,)
class VrchTextWordReplacerNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}),
"rules": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}),
"match_mode": (["whole_word", "literal"], {"default": "whole_word"}),
"case_sensitive": ("BOOLEAN", {"default": False}),
"debug": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING", "JSON")
RETURN_NAMES = ("TEXT", "REPLACE_REPORT")
CATEGORY = CATEGORY
FUNCTION = "replace_text"
def __init__(self):
self._rules_cache_key = None
self._rules_cache_value = None
@staticmethod
def _rule_key(source: str, case_sensitive: bool):
return source if case_sensitive else source.casefold()
@classmethod
def _parse_replacement_rules(cls, rules: str, case_sensitive: bool=False):
parsed = {}
ignored_count = 0
for raw_line in (rules or "").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=>" not in line:
ignored_count += 1
continue
source, target = line.split("=>", 1)
source = source.strip()
target = target.strip()
if not source:
ignored_count += 1
continue
parsed[cls._rule_key(source, case_sensitive)] = {
"source": source,
"target": target,
}
return list(parsed.values()), ignored_count
@classmethod
def _compile_replacement_rules(cls, rules: str, match_mode: str, case_sensitive: bool=False):
parsed_rules, ignored_count = cls._parse_replacement_rules(rules, case_sensitive)
if not parsed_rules:
return None, {}, [], ignored_count
sorted_rules = sorted(parsed_rules, key=lambda rule: len(rule["source"]), reverse=True)
pattern_body = "|".join(re.escape(rule["source"]) for rule in sorted_rules)
if match_mode == "whole_word":
pattern_text = rf"(?<!\w)({pattern_body})(?!\w)"
else:
pattern_text = rf"({pattern_body})"
flags = 0 if case_sensitive else re.IGNORECASE
replacement_map = {
cls._rule_key(rule["source"], case_sensitive): rule
for rule in sorted_rules
}
return re.compile(pattern_text, flags), replacement_map, sorted_rules, ignored_count
def _get_compiled_rules(self, rules: str, match_mode: str, case_sensitive: bool):
cache_key = (rules or "", match_mode, bool(case_sensitive))
if self._rules_cache_key == cache_key:
return self._rules_cache_value
compiled = self._compile_replacement_rules(rules, match_mode, case_sensitive)
self._rules_cache_key = cache_key
self._rules_cache_value = compiled
return compiled
def replace_text(self,
text: str,
rules: str,
match_mode: str="whole_word",
case_sensitive: bool=False,
debug: bool=False):
text = text or ""
if match_mode not in {"whole_word", "literal"}:
match_mode = "whole_word"
pattern, replacement_map, parsed_rules, ignored_count = self._get_compiled_rules(
rules, match_mode, bool(case_sensitive)
)
matched = {}
replaced_count = 0
if not pattern:
report = {
"rules_count": 0,
"ignored_rules_count": ignored_count,
"replaced_count": 0,
"matched": {},
"match_mode": match_mode,
"case_sensitive": bool(case_sensitive),
}
return (text, report)
def replace_match(match):
nonlocal replaced_count
source = match.group(0)
key = self._rule_key(source, bool(case_sensitive))
rule = replacement_map.get(key)
if not rule:
return source
matched_source = rule["source"]
target = rule["target"]
matched[matched_source] = matched.get(matched_source, 0) + 1
replaced_count += 1
return target
output_text = pattern.sub(replace_match, text)
report = {
"rules_count": len(parsed_rules),
"ignored_rules_count": ignored_count,
"replaced_count": replaced_count,
"matched": matched,
"match_mode": match_mode,
"case_sensitive": bool(case_sensitive),
}
if debug:
print(f"[VrchTextWordReplacerNode] Report: {json.dumps(report, ensure_ascii=False)}")
return (output_text, report)
class VrchTextSrtPlayerNode:
@classmethod
@@ -109,4 +249,4 @@ class VrchTextSrtPlayerNode:
m.update(placeholder_text.encode('utf-8'))
m.update(str(loop).encode('utf-8'))
m.update(str(current_selection).encode('utf-8'))
return m.hexdigest()
return m.hexdigest()