feat: add text word replacer node
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user