diff --git a/CHANGELOG.md b/CHANGELOG.md index e379461..fe36833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__init__.py b/__init__.py index bba0fdb..21c5070 100644 --- a/__init__.py +++ b/__init__.py @@ -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", diff --git a/docs/text_nodes.md b/docs/text_nodes.md index 66c0dfc..4dfaf79 100644 --- a/docs/text_nodes.md +++ b/docs/text_nodes.md @@ -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). \ No newline at end of file +- Ensure that your SRT text is correctly formatted (with sequential numbering, time range lines, and subtitle text). diff --git a/nodes/tests/text_word_replacer_node_test.py b/nodes/tests/text_word_replacer_node_test.py new file mode 100644 index 0000000..5fbe60b --- /dev/null +++ b/nodes/tests/text_word_replacer_node_test.py @@ -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() diff --git a/nodes/text_nodes.py b/nodes/text_nodes.py index 725de2d..8b3345f 100644 --- a/nodes/text_nodes.py +++ b/nodes/text_nodes.py @@ -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"(?