From 009707a1cd65d055befca602d29f71512dc7490c Mon Sep 17 00:00:00 2001 From: opvelll Date: Tue, 11 Aug 2026 00:26:46 +0900 Subject: [PATCH] feat: Add new nodes for text list processing and update documentation --- README.md | 78 ++++++++++++++++++++++++++--- __init__.py | 9 ++++ pyproject.toml | 4 +- src/MultilineStringToList.py | 33 ++++++++++++ src/StringsToList.py | 24 +++++++++ src/TextListToSequence.py | 36 +++++++++++++ tests/test_MultilineStringToList.py | 66 ++++++++++++++++++++++++ tests/test_NodeRegistration.py | 3 ++ tests/test_StringsToList.py | 39 +++++++++++++++ tests/test_TextListToSequence.py | 45 +++++++++++++++++ web/docs/MultilineStringToList.md | 21 ++++++++ web/docs/StringsToList.md | 18 +++++++ web/docs/TextListToSequence.md | 26 ++++++++++ 13 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 src/MultilineStringToList.py create mode 100644 src/StringsToList.py create mode 100644 src/TextListToSequence.py create mode 100644 tests/test_MultilineStringToList.py create mode 100644 tests/test_StringsToList.py create mode 100644 tests/test_TextListToSequence.py create mode 100644 web/docs/MultilineStringToList.md create mode 100644 web/docs/StringsToList.md create mode 100644 web/docs/TextListToSequence.md diff --git a/README.md b/README.md index 576c40e..2036bf1 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ This is a custom node for Comfy UI. It mainly wraps `itertools.product` and can be used to create patterns by combining prompts. -It is recommended to install this custom node in combination with the nodes from the [WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui). +This package includes nodes for creating `LIST` values from strings, so no other custom node suite is required for basic list creation and prompt combination. ![ProductedString](doc/producted-string.png) -In the screenshot above, `[1girl, 1boy]` and `[blonde_hair, crown]` and `[beach, futuristic City]` are combined to create an 8-line (2 * 2 * 2) multiline string. +In the screenshot above, `[1girl, 1boy]` and `[blonde_hair, crown]` and `[beach, futuristic City]` are combined to create an 8-line (2 * 2 * 2) multiline string. The screenshot also shows an optional workflow using WAS Node Suite nodes. -By connecting it to the WAS Node Suite's "Text Load Line From File" or the [CR Prompt List](https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes/wiki/List-Nodes#cr-prompt-list) node from [ComfyUI_Comfyroll_CustomNodes](https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes), you can generate 8 different patterns. +The [WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui) and [ComfyUI_Comfyroll_CustomNodes](https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes) remain compatible optional integrations for file input, saving, and other text utilities. ## Installation @@ -23,6 +23,32 @@ It can be installed via Install Custom Nodes in the [ComfyUI Manager](https://gi ## Nodes +### ・ MultilineStringToList + +Converts a multiline string into a `LIST`, with one item per line. + +- `trim_whitespace` removes whitespace around each line. It is enabled by default. +- `keep_empty_lines` preserves empty lines as empty string items. It is disabled by default. +- Empty input returns an empty list, and comment-like lines are kept as normal text. + +For example, entering `red`, `green`, and `blue` on separate lines produces `["red", "green", "blue"]`. + +### ・ StringsToList + +Collects up to seven connected string inputs (`text_a` through `text_g`) into one `LIST` in input order. Unconnected inputs are skipped, while connected empty strings are preserved. + +Use this node when text values already come from separate nodes. It provides a direct replacement for the list-building part of the WAS Node Suite's Text List node. + +### ・ TextListToSequence + +Converts this package's custom `LIST` value into ComfyUI's sequential `STRING` output. Downstream nodes that normally accept one string are executed once for each list item. + +The input must contain at least one item, and every item must be a string. Empty string items are valid and cause one downstream execution with an empty string. An empty list raises a clear error because ComfyUI cannot safely process an empty sequential list. + +For sequential prompt generation, connect nodes as follows: + +`TextListProduct → Text List to Sequence → CLIP Text Encode` + ### ・ TextListProduct ![TextListProduct](doc/text-list-product.png) @@ -68,7 +94,11 @@ Takes two lists of prompts as input and combines each corresponding pair of elem You can use it to add a specified string at the beginning of the strings in the list. -Combine expression patterns * pose patterns * camera work patterns * ... and use the Save Text File node from the WAS Node Suite to generate a text file. +Enter expression patterns, pose patterns, and camera-work patterns in separate `Multiline String to List` nodes, then connect those lists to `Producted String` to generate every combination. + +To process each generated combination separately in ComfyUI, use `TextListProduct` instead of `Producted String`, then connect its output through `Text List to Sequence` to a normal string input such as `CLIP Text Encode`. + +If needed, the resulting text can still be passed to optional external nodes such as Save Text File from the WAS Node Suite. ## License @@ -82,13 +112,13 @@ Comfy UI のカスタムノードです。 主にitertools.productをラップしたもので、プロンプトをかけ合わせてパターンを作ることに利用できます。 -[WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui)のノードと組み合わせることを想定しているので、こちらのカスタムノードもインストールすることを推奨します。 +文字列から `LIST` を作るノードも含まれているため、基本的なリスト作成とプロンプトの組み合わせは、ほかのカスタムノード集を追加せずに利用できます。 ![ProductedString](doc/producted-string.png) -上のスクリーンショットでは、`[ 1girl, 1boy ]` と `[ blonde_hair, crown ]` と `[ beach, futuristic City ]` を掛け合わせて、8行(2 * 2 * 2)のマルチライン文字列を作っている様子です。 +上のスクリーンショットでは、`[ 1girl, 1boy ]` と `[ blonde_hair, crown ]` と `[ beach, futuristic City ]` を掛け合わせて、8行(2 * 2 * 2)のマルチライン文字列を作っている様子です。この画像には、任意連携の例としてWAS Node Suiteのノードも含まれています。 -あとは、WAS Node SuiteのText Load Line From Fileや[ComfyUI_Comfyroll_CustomNodes](https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes)の[CR Prompt List](https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes/wiki/List-Nodes#cr-prompt-list)につなげることで、8パターンを生成することができます。 +[WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui)や[ComfyUI_Comfyroll_CustomNodes](https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes)は、ファイル入出力、保存、その他のテキスト処理に任意で組み合わせられます。 ## インストール [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager)のInstall Custom Nodesからインストールできます。 @@ -96,6 +126,32 @@ Comfy UI のカスタムノードです。 ## ノード +### ・ MultilineStringToList + +複数行の文字列を、1行につき1要素の `LIST` に変換します。 + +- `trim_whitespace` は各行の前後空白を除去します。既定で有効です。 +- `keep_empty_lines` は空行を空文字列の要素として保持します。既定では無効です。 +- 入力が空なら空LISTを返し、コメントのように見える行も通常の文字列として保持します。 + +たとえば、`red`、`green`、`blue` を別々の行に入力すると、`["red", "green", "blue"]` を返します。 + +### ・ StringsToList + +接続された最大7個の文字列入力(`text_a`~`text_g`)を、入力順に1つの `LIST` へまとめます。未接続の入力は無視し、接続された空文字列は保持します。 + +文字列が別々のノードから渡される場合に使用します。WAS Node SuiteのText Listノードが担っていたリスト作成部分を直接置き換えられます。 + +### ・ TextListToSequence + +このパッケージ独自の `LIST` を、ComfyUI標準の逐次処理用 `STRING` 出力へ変換します。通常は1つの文字列を受け取る後段ノードが、LISTの各要素について1回ずつ実行されます。 + +入力には1つ以上の要素が必要で、すべて文字列でなければなりません。空文字列の要素は有効で、後段を空文字列で1回実行します。空LISTはComfyUIが安全に処理できないため、分かりやすいエラーを返します。 + +プロンプトを逐次処理する場合は、次のように接続します。 + +`TextListProduct → Text List to Sequence → CLIP Text Encode` + ### ・ TextListProduct ![TextListProduct](doc/text-list-product.png) @@ -140,12 +196,18 @@ Comfy UI のカスタムノードです。 リストの文字列の先頭に指定の文字列を加えたいとか。 -表情パターン * ポーズパターン * カメラワークパターン * ... と組み合わせて、WAS Node SuiteのSave Text Fileノードを使って、文字列ファイルを生成するとか。 +表情、ポーズ、カメラワークの各パターンを別々の `Multiline String to List` ノードへ入力し、それぞれを `Producted String` へ接続すると、すべての組み合わせを生成できます。 + +生成した組み合わせをComfyUIで1件ずつ処理する場合は、`Producted String` ではなく `TextListProduct` を使い、その出力を `Text List to Sequence` 経由で `CLIP Text Encode` などの通常の文字列入力へ接続します。 + +必要であれば、生成した文字列をWAS Node SuiteのSave Text Fileなど、任意の外部ノードへ渡すこともできます。 ## Workflow ![Workflow](doc/workflow_textlistproduct.png) +この画像はWAS Node Suiteとの任意連携例です。LISTの作成自体は、このパッケージの `Multiline String to List` または `Strings to List` だけで行えます。 + ## License MIT diff --git a/__init__.py b/__init__.py index a95ad49..8f6b83e 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,9 @@ from .src.TextListProductWithSingleA import TextListProductWithSingleA from .src.TextListProductWithSingleB import TextListProductWithSingleB from .src.TextListProductWithSingleBoth import TextListProductWithSingleBoth from .src.ProductedString import ProductedString +from .src.MultilineStringToList import MultilineStringToList +from .src.StringsToList import StringsToList +from .src.TextListToSequence import TextListToSequence from .src.PromptPairConcat import PromptPairConcat @@ -15,6 +18,9 @@ NODE_CLASS_MAPPINGS = { "TextListProductWithSingleBoth": TextListProductWithSingleBoth, "ProductedString": ProductedString, "PromptPairConcat": PromptPairConcat, + "MultilineStringToList": MultilineStringToList, + "StringsToList": StringsToList, + "TextListToSequence": TextListToSequence, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -24,4 +30,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TextListProductWithSingleBoth": "Text List Product With Single Both", "ProductedString": "Producted String", "PromptPairConcat": "Prompt Pair Concat", + "MultilineStringToList": "Multiline String to List", + "StringsToList": "Strings to List", + "TextListToSequence": "Text List to Sequence", } diff --git a/pyproject.toml b/pyproject.toml index 670f343..6bb2878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_TextListProduct" -description = "This is a custom node for Comfy UI. It mainly wraps itertools.product and can be used to create patterns by combining prompts. It is recommended to install this custom node in combination with the nodes from the WAS Node Suite." -version = "1.0.1" +description = "ComfyUI custom nodes for creating text lists and combining prompt patterns with cartesian products." +version = "1.1.0" license = {file = "LICENSE.md"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/src/MultilineStringToList.py b/src/MultilineStringToList.py new file mode 100644 index 0000000..8eb68df --- /dev/null +++ b/src/MultilineStringToList.py @@ -0,0 +1,33 @@ +class MultilineStringToList: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ( + "STRING", + { + "default": "", + "multiline": True, + "dynamicPrompts": False, + }, + ), + "trim_whitespace": ("BOOLEAN", {"default": True}), + "keep_empty_lines": ("BOOLEAN", {"default": False}), + }, + } + + def convert_to_list(self, text, trim_whitespace=True, keep_empty_lines=False): + lines = text.splitlines() + + if trim_whitespace: + lines = [line.strip() for line in lines] + + if not keep_empty_lines: + lines = [line for line in lines if line != ""] + + return (lines,) + + RETURN_NAMES = ("list",) + RETURN_TYPES = ("LIST",) + FUNCTION = "convert_to_list" + CATEGORY = "TextListProduct" diff --git a/src/StringsToList.py b/src/StringsToList.py new file mode 100644 index 0000000..adac3f0 --- /dev/null +++ b/src/StringsToList.py @@ -0,0 +1,24 @@ +class StringsToList: + INPUT_NAMES = tuple(f"text_{suffix}" for suffix in "abcdefg") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + name: ("STRING", {"forceInput": True}) for name in cls.INPUT_NAMES + }, + } + + def collect_strings(self, **kwargs): + strings = [ + kwargs[name] + for name in self.INPUT_NAMES + if name in kwargs and isinstance(kwargs[name], str) + ] + return (strings,) + + RETURN_NAMES = ("list",) + RETURN_TYPES = ("LIST",) + FUNCTION = "collect_strings" + CATEGORY = "TextListProduct" diff --git a/src/TextListToSequence.py b/src/TextListToSequence.py new file mode 100644 index 0000000..3f44c0e --- /dev/null +++ b/src/TextListToSequence.py @@ -0,0 +1,36 @@ +class TextListToSequence: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text_list": ("LIST", {"forceInput": True}), + }, + } + + def convert_to_sequence(self, text_list): + if not isinstance(text_list, list): + raise TypeError( + "TextListToSequence expected a list, " + f"got {type(text_list).__name__}." + ) + + if not text_list: + raise ValueError( + "TextListToSequence requires at least one item; " + "ComfyUI cannot process an empty sequential list." + ) + + for index, item in enumerate(text_list): + if not isinstance(item, str): + raise TypeError( + "TextListToSequence expected a string at " + f"index {index}, got {type(item).__name__}." + ) + + return (text_list,) + + RETURN_NAMES = ("string",) + RETURN_TYPES = ("STRING",) + OUTPUT_IS_LIST = (True,) + FUNCTION = "convert_to_sequence" + CATEGORY = "TextListProduct" diff --git a/tests/test_MultilineStringToList.py b/tests/test_MultilineStringToList.py new file mode 100644 index 0000000..2cb8e81 --- /dev/null +++ b/tests/test_MultilineStringToList.py @@ -0,0 +1,66 @@ +import unittest + +from src.MultilineStringToList import MultilineStringToList + + +class TestMultilineStringToList(unittest.TestCase): + def setUp(self): + self.node = MultilineStringToList() + + def test_converts_multiline_text_with_default_cleanup(self): + result = self.node.convert_to_list(" alpha \n\n beta\n \ngamma ") + self.assertEqual(result, (["alpha", "beta", "gamma"],)) + + def test_supports_crlf_and_cr_line_endings(self): + result = self.node.convert_to_list("alpha\r\nbeta\rgamma") + self.assertEqual(result, (["alpha", "beta", "gamma"],)) + + def test_trailing_line_ending_does_not_add_empty_item(self): + result = self.node.convert_to_list("alpha\nbeta\n", keep_empty_lines=True) + self.assertEqual(result, (["alpha", "beta"],)) + + def test_empty_text_returns_empty_list(self): + result = self.node.convert_to_list("") + self.assertEqual(result, ([],)) + + def test_comment_like_lines_are_preserved(self): + result = self.node.convert_to_list("# comment\n// comment\nvalue") + self.assertEqual(result, (["# comment", "// comment", "value"],)) + + def test_can_preserve_whitespace(self): + result = self.node.convert_to_list( + " alpha \n beta ", trim_whitespace=False + ) + self.assertEqual(result, ([" alpha ", " beta "],)) + + def test_can_preserve_empty_lines_after_trimming(self): + result = self.node.convert_to_list( + " alpha \n \nbeta", keep_empty_lines=True + ) + self.assertEqual(result, (["alpha", "", "beta"],)) + + def test_can_preserve_whitespace_and_empty_lines(self): + result = self.node.convert_to_list( + " alpha \n\n \nbeta", + trim_whitespace=False, + keep_empty_lines=True, + ) + self.assertEqual(result, ([" alpha ", "", " ", "beta"],)) + + def test_node_definition(self): + input_types = self.node.INPUT_TYPES() + self.assertEqual(input_types["required"]["text"][0], "STRING") + self.assertTrue(input_types["required"]["text"][1]["multiline"]) + self.assertTrue( + input_types["required"]["trim_whitespace"][1]["default"] + ) + self.assertFalse( + input_types["required"]["keep_empty_lines"][1]["default"] + ) + self.assertEqual(self.node.RETURN_TYPES, ("LIST",)) + self.assertEqual(self.node.CATEGORY, "TextListProduct") + self.assertFalse(hasattr(self.node, "OUTPUT_IS_LIST")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_NodeRegistration.py b/tests/test_NodeRegistration.py index d72d88e..89b81a0 100644 --- a/tests/test_NodeRegistration.py +++ b/tests/test_NodeRegistration.py @@ -18,6 +18,9 @@ class TestNodeRegistration(unittest.TestCase): "TextListProductWithSingleBoth", "ProductedString", "PromptPairConcat", + "MultilineStringToList", + "StringsToList", + "TextListToSequence", } self.assertEqual(set(PLUGIN_INIT.NODE_CLASS_MAPPINGS.keys()), expected_keys) self.assertEqual( diff --git a/tests/test_StringsToList.py b/tests/test_StringsToList.py new file mode 100644 index 0000000..029566c --- /dev/null +++ b/tests/test_StringsToList.py @@ -0,0 +1,39 @@ +import unittest + +from src.StringsToList import StringsToList + + +class TestStringsToList(unittest.TestCase): + def setUp(self): + self.node = StringsToList() + + def test_collects_strings_in_input_order(self): + result = self.node.collect_strings( + text_g="seven", text_a="one", text_d="four" + ) + self.assertEqual(result, (["one", "four", "seven"],)) + + def test_preserves_connected_empty_strings(self): + result = self.node.collect_strings(text_a="alpha", text_b="", text_c="gamma") + self.assertEqual(result, (["alpha", "", "gamma"],)) + + def test_no_connected_inputs_returns_empty_list(self): + result = self.node.collect_strings() + self.assertEqual(result, ([],)) + + def test_node_definition(self): + input_types = self.node.INPUT_TYPES() + self.assertEqual(input_types["required"], {}) + self.assertEqual( + list(input_types["optional"].keys()), + [f"text_{suffix}" for suffix in "abcdefg"], + ) + for input_type in input_types["optional"].values(): + self.assertEqual(input_type, ("STRING", {"forceInput": True})) + self.assertEqual(self.node.RETURN_TYPES, ("LIST",)) + self.assertEqual(self.node.CATEGORY, "TextListProduct") + self.assertFalse(hasattr(self.node, "OUTPUT_IS_LIST")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_TextListToSequence.py b/tests/test_TextListToSequence.py new file mode 100644 index 0000000..540bafd --- /dev/null +++ b/tests/test_TextListToSequence.py @@ -0,0 +1,45 @@ +import unittest + +from src.TextListToSequence import TextListToSequence + + +class TestTextListToSequence(unittest.TestCase): + def setUp(self): + self.node = TextListToSequence() + + def test_preserves_order_and_empty_strings(self): + text_list = ["alpha", "", "gamma"] + result = self.node.convert_to_sequence(text_list) + self.assertEqual(result, (text_list,)) + self.assertIs(result[0], text_list) + + def test_empty_list_raises_clear_error(self): + with self.assertRaisesRegex( + ValueError, "requires at least one item.*empty sequential list" + ): + self.node.convert_to_sequence([]) + + def test_non_string_item_reports_its_index(self): + with self.assertRaisesRegex(TypeError, "string at index 1, got int"): + self.node.convert_to_sequence(["alpha", 42, "gamma"]) + + def test_non_list_input_reports_its_type(self): + with self.assertRaisesRegex(TypeError, "expected a list, got tuple"): + self.node.convert_to_sequence(("alpha", "beta")) + + def test_node_definition(self): + input_types = self.node.INPUT_TYPES() + self.assertEqual( + input_types["required"]["text_list"], + ("LIST", {"forceInput": True}), + ) + self.assertEqual(self.node.RETURN_NAMES, ("string",)) + self.assertEqual(self.node.RETURN_TYPES, ("STRING",)) + self.assertEqual(self.node.OUTPUT_IS_LIST, (True,)) + self.assertEqual(self.node.FUNCTION, "convert_to_sequence") + self.assertEqual(self.node.CATEGORY, "TextListProduct") + self.assertFalse(hasattr(self.node, "INPUT_IS_LIST")) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/docs/MultilineStringToList.md b/web/docs/MultilineStringToList.md new file mode 100644 index 0000000..787caeb --- /dev/null +++ b/web/docs/MultilineStringToList.md @@ -0,0 +1,21 @@ +# MultilineStringToList + +Convert a multiline string into a `LIST` with one item per line. + +## Inputs + +- `text`: The multiline string to split +- `trim_whitespace`: Remove whitespace around each line; enabled by default +- `keep_empty_lines`: Keep empty lines as empty string items; disabled by default + +## Behavior + +- Supports LF, CRLF, and CR line endings. +- Empty input returns an empty list. +- A trailing line ending does not add an empty item. +- Lines that look like comments are kept as normal text. +- When trimming is enabled, whitespace-only lines become empty before empty-line filtering. + +## Usage + +Use this node to enter prompt candidates directly in one text box and connect the resulting list to any TextListProduct node. diff --git a/web/docs/StringsToList.md b/web/docs/StringsToList.md new file mode 100644 index 0000000..41e2e5e --- /dev/null +++ b/web/docs/StringsToList.md @@ -0,0 +1,18 @@ +# StringsToList + +Collect up to seven connected strings into one `LIST`. + +## Inputs + +- `text_a` through `text_g`: Optional connected string inputs + +## Behavior + +- Connected inputs are returned in alphabetical input order. +- Unconnected inputs are skipped. +- Connected empty strings are preserved. +- If no inputs are connected, the node returns an empty list. + +## Usage + +Use this node when prompt candidates come from separate string-producing nodes. It replaces the list-building role of WAS Node Suite's Text List node without requiring WAS Node Suite. diff --git a/web/docs/TextListToSequence.md b/web/docs/TextListToSequence.md new file mode 100644 index 0000000..ecf7c1c --- /dev/null +++ b/web/docs/TextListToSequence.md @@ -0,0 +1,26 @@ +# TextListToSequence + +Convert a custom text `LIST` into ComfyUI's sequential `STRING` output. + +## Inputs + +- `text_list`: A connected `LIST` containing one or more strings + +## Output + +- `string`: A sequential `STRING` output. A downstream node is executed once for each item. + +## Behavior + +- Preserves item order and empty string items. +- Rejects an empty list with a clear error because ComfyUI cannot safely process an empty sequential list. +- Rejects non-string items and reports the invalid item's index. +- Does not enable `INPUT_IS_LIST`; the input remains this package's custom list container. + +## Usage + +For sequential prompt processing, connect: + +`TextListProduct → Text List to Sequence → CLIP Text Encode` + +Use the regular `LIST` output directly when another TextListProduct node needs the whole list as one value. Add this conversion only at the point where downstream nodes should run once per string.