commit 82944fe3e9c42fdf212ab9c55a1b6970f211f58a Author: Vivid Date: Tue May 19 10:47:20 2026 +0800 Initial release: ComfyUI Vivid Prompt Analysis Replacement diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..8bcb425 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,23 @@ +name: Python Tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install pytest + run: python -m pip install --upgrade pip pytest + + - name: Run tests + run: python -m pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ab8a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.pyd +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..01846f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2f9d8d --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# ComfyUI Vivid Prompt Analysis Replacement + +A lightweight `ComfyUI` custom node package that exposes the `PromptReplaceParserNode` node for structured Chinese prompt parsing and override merging. + +## What It Does + +This node accepts: +- `source_text`: base prompt text in one of these formats: + - JSON string + - Python dict-like string + - labeled text using `目标/保留/迁移/环境/光影/细节/输出/类型选择` +- `override_text`: partial override text using the same labeled format + +It outputs: +- `prompt_list` +- `prompt_text` +- `style_text` + +## Node Behavior + +The node preserves the current behavior from the original implementation: +- Parses `source_text` using `json.loads`, `ast.literal_eval`, then labeled text fallback +- Parses `override_text` from dict input or labeled text +- Uses override values only when they contain meaningful Chinese content +- Appends sentence punctuation when needed +- Removes whitespace from each final output line +- Returns merged prompt lines, joined prompt text, and style text + +## Installation + +Clone this repository into your `ComfyUI/custom_nodes` directory: + +```powershell +cd \custom_nodes +git clone https://github.com//ComfyUI-Vivid-Prompt-Analysis-Replacement.git +``` + +Then restart `ComfyUI`. + +## Example Inputs + +### `source_text` + +```text +{"转换描述": {"目标": "构建现代建筑方案", "环境": "建筑前方是城市广场"}, "类型选择": "精细模型"} +``` + +### `override_text` + +```text +环境:建筑前方是滨水平台,周边有树木与行人。 +光影:黄昏侧光,玻璃低反射。 +``` + +## Node Info + +- Class type: `PromptReplaceParserNode` +- Display name: `Prompt Replace Parser` +- Category: `PromptTools` + +## Development + +Run tests locally: + +```powershell +cd ComfyUI-Vivid-Prompt-Analysis-Replacement +python -m pytest +``` + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b76bbe7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,12 @@ +try: + from .vivid_solution_prompt_analysis_and_replacement import ( + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, + ) +except ImportError: + from vivid_solution_prompt_analysis_and_replacement import ( + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, + ) + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5c5e8c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "comfyui-vivid-prompt-analysis-replacement" +version = "1.0.0" +description = "ComfyUI custom node for structured prompt analysis and replacement" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "Vivid" } +] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fffa8f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# Runtime dependencies: none diff --git a/tests/test_prompt_replace_parser.py b/tests/test_prompt_replace_parser.py new file mode 100644 index 0000000..f6a0f86 --- /dev/null +++ b/tests/test_prompt_replace_parser.py @@ -0,0 +1,55 @@ +from vivid_solution_prompt_analysis_and_replacement import ( + PromptReplaceParserNode, + parse_override_text, + parse_source, +) + + +def test_parse_source_from_json_string(): + source = '{"转换描述": {"目标": "构建现代建筑", "环境": "城市广场"}, "类型选择": "精细模型"}' + desc, style = parse_source(source) + assert desc["目标"] == "构建现代建筑" + assert desc["环境"] == "城市广场" + assert style == "精细模型" + + +def test_parse_source_from_python_dict_string(): + source = "{'转换描述': {'目标': '建筑重构', '光影': '清晨侧光'}, '类型选择': '体块模型'}" + desc, style = parse_source(source) + assert desc["目标"] == "建筑重构" + assert desc["光影"] == "清晨侧光" + assert style == "体块模型" + + +def test_parse_override_text_partial_labels(): + override_text = "环境:滨水平台。\n光影:黄昏侧光。\n" + result = parse_override_text(override_text) + assert result["环境"] == "滨水平台。" + assert result["光影"] == "黄昏侧光。" + + +def test_run_prefers_valid_chinese_override_and_falls_back_to_source(): + node = PromptReplaceParserNode() + source = "目标:构建现代建筑\n环境:城市广场\n光影:清晨侧光\n类型选择:精细模型" + override = "环境:滨水平台\n" + prompt_list, prompt_text, style_text = node.run(source, override) + assert prompt_list == [ + "目标:构建现代建筑。", + "环境:滨水平台。", + "光影:清晨侧光。", + ] + assert prompt_text == "目标:构建现代建筑。\n环境:滨水平台。\n光影:清晨侧光。" + assert style_text == "精细模型" + + +def test_run_ignores_non_chinese_override_for_field_and_style(): + node = PromptReplaceParserNode() + source = "目标:建筑方案\n环境:城市广场\n类型选择:精细模型" + override = "环境: plaza\n类型选择: photo\n" + prompt_list, prompt_text, style_text = node.run(source, override) + assert prompt_list == [ + "目标:建筑方案。", + "环境:城市广场。", + ] + assert prompt_text == "目标:建筑方案。\n环境:城市广场。" + assert style_text == "精细模型" diff --git a/vivid_solution_prompt_analysis_and_replacement.py b/vivid_solution_prompt_analysis_and_replacement.py new file mode 100644 index 0000000..0ff500e --- /dev/null +++ b/vivid_solution_prompt_analysis_and_replacement.py @@ -0,0 +1,215 @@ +"""ComfyUI custom node: prompt analysis and replacement. + +This module preserves the original PromptReplaceParserNode behavior while +packaging it for standalone installation from a Git repository. +""" + +import ast +import json +import re +from typing import Any, Dict, List, Tuple + + +class AnyType(str): + def __ne__(self, __value: object) -> bool: + return False + + def __eq__(self, __value: object) -> bool: + return True + + +ANY = AnyType("*") +FIELDS = ["目标", "保留", "迁移", "环境", "光影", "细节", "输出"] +ALL_LABELS = ["转换描述"] + FIELDS + ["类型选择"] + + +def strip_quotes(text: Any) -> str: + value = str(text).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1].strip() + return value + + +def clean_content(text: Any) -> str: + value = strip_quotes(text) + value = value.strip().strip(",,;;") + return value.strip() + + +def has_meaningful_content(text: Any) -> bool: + marker = re.sub(r"[\s\.,,。;;::!!??、】【()\[\]\(\)\"'“”‘’\-—_]+", "", str(text)) + return bool(marker) + + +def contains_chinese(text: Any) -> bool: + return bool(re.search(r"[\u4e00-\u9fff]", str(text))) + + +def override_is_valid(text: Any) -> bool: + return has_meaningful_content(text) and contains_chinese(text) + + +def ensure_sentence(text: Any) -> str: + value = clean_content(text) + if not value: + return "" + if value[-1] not in "。!?!?": + value += "。" + return value + + +def remove_all_whitespace(text: Any) -> str: + return re.sub(r"\s+", "", str(text)) + + +def parse_label_segments(text: Any, labels: List[str]) -> Dict[str, str]: + normalized = str(text).replace("\r\n", "\n").replace("\r", "\n") + pattern = re.compile( + r"[\"'“”‘’]?(" + + "|".join(map(re.escape, labels)) + + r")[\"'“”‘’]?\s*[::]" + ) + matches = list(pattern.finditer(normalized)) + result: Dict[str, str] = {} + + for index, match in enumerate(matches): + label = match.group(1) + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(normalized) + result[label] = clean_content(normalized[start:end]) + + return result + + +def parse_source(source: Any) -> Tuple[Dict[str, str], str]: + if isinstance(source, dict): + desc = source.get("转换描述", {}) + style = source.get("类型选择", "") + if not isinstance(desc, dict): + desc = {} + return {key: clean_content(value) for key, value in desc.items()}, clean_content(style) + + raw = str(source).strip() + if not raw: + return {}, "" + + normalized_candidates = (raw, raw.replace(":", ":").replace(",", ",")) + + for candidate in normalized_candidates: + try: + data = json.loads(candidate) + if isinstance(data, dict): + return parse_source(data) + except Exception: + pass + + for candidate in normalized_candidates: + try: + data = ast.literal_eval(candidate) + if isinstance(data, dict): + return parse_source(data) + except Exception: + pass + + segments = parse_label_segments(raw, ALL_LABELS) + desc: Dict[str, str] = {} + style = clean_content(segments.get("类型选择", "")) + + for field in FIELDS: + value = segments.get(field, "") + if has_meaningful_content(value): + desc[field] = clean_content(value) + + return desc, style + + +def parse_override_text(text: Any) -> Dict[str, str]: + if isinstance(text, dict): + result: Dict[str, str] = {} + desc = text.get("转换描述", {}) + + if isinstance(desc, dict): + for field in FIELDS: + result[field] = clean_content(desc.get(field, "")) + else: + for field in FIELDS: + result[field] = clean_content(text.get(field, "")) + + result["类型选择"] = clean_content(text.get("类型选择", "")) + return result + + segments = parse_label_segments(text, FIELDS + ["类型选择"]) + return {key: clean_content(value) for key, value in segments.items()} + + +class PromptReplaceParserNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "source_text": ( + "STRING", + { + "default": "", + "multiline": True, + }, + ), + "override_text": ( + "STRING", + { + "default": "", + "multiline": True, + }, + ), + } + } + + RETURN_TYPES = (ANY, "STRING", "STRING") + RETURN_NAMES = ("prompt_list", "prompt_text", "style_text") + FUNCTION = "run" + CATEGORY = "PromptTools" + + def run(self, source_text: str, override_text: str): + base_desc, base_style = parse_source(source_text) + override_map = parse_override_text(override_text) + + final_list: List[str] = [] + for field in FIELDS: + override_value = override_map.get(field, "") + base_value = base_desc.get(field, "") + + if override_is_valid(override_value): + final_value = ensure_sentence(override_value) + elif has_meaningful_content(base_value): + final_value = ensure_sentence(base_value) + else: + continue + + final_item = f"{field}:{final_value}" + final_list.append(remove_all_whitespace(final_item)) + + prompt_text = "\n".join(final_list) + + style_text = override_map.get("类型选择", "") + if not override_is_valid(style_text): + style_text = base_style + + print( + "[PromptReplaceParserNode] " + f"base_fields={list(base_desc.keys())} " + f"override_fields={list(override_map.keys())} " + f"output_lines={len(final_list)} " + f"style={style_text or ''}" + ) + + return (final_list, prompt_text, style_text) + + +NODE_CLASS_MAPPINGS = { + "PromptReplaceParserNode": PromptReplaceParserNode, +} + + +NODE_DISPLAY_NAME_MAPPINGS = { + "PromptReplaceParserNode": "Prompt Replace Parser", +}