diff --git a/py/nodes/prompt.py b/py/nodes/prompt.py index 0bebfb26..6eba2ab3 100644 --- a/py/nodes/prompt.py +++ b/py/nodes/prompt.py @@ -7,6 +7,7 @@ from ..services.wildcard_service import ( contains_dynamic_syntax, get_wildcard_service, is_trigger_words_input, + linked_text_requires_rerun, ) @@ -85,6 +86,10 @@ class PromptLM: ), }, "optional": optional_inputs, + "hidden": { + "prompt": "PROMPT", + "unique_id": "UNIQUE_ID", + }, } RETURN_TYPES = ("CONDITIONING", "STRING") @@ -100,10 +105,16 @@ class PromptLM: text: str, clip: Any | None = None, seed: int | None = None, + prompt: dict | None = None, + unique_id: str | None = None, **kwargs: Any, ): del clip, kwargs - if contains_dynamic_syntax(text) and seed is None: + if seed is not None: + return False + if contains_dynamic_syntax(text): + return float("NaN") + if text is None and linked_text_requires_rerun(prompt, unique_id, "text"): return float("NaN") return False @@ -112,8 +123,11 @@ class PromptLM: text: str, clip: Any, seed: int | None = None, + prompt: dict | None = None, + unique_id: str | None = None, **kwargs: Any, ): + del prompt, unique_id expanded_text = get_wildcard_service().expand_text(text, seed=seed) trigger_words = [] diff --git a/py/nodes/text.py b/py/nodes/text.py index 02bc2664..512bc9f3 100644 --- a/py/nodes/text.py +++ b/py/nodes/text.py @@ -1,6 +1,10 @@ from __future__ import annotations -from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service +from ..services.wildcard_service import ( + contains_dynamic_syntax, + get_wildcard_service, + linked_text_requires_rerun, +) class TextLM: @@ -34,6 +38,10 @@ class TextLM: }, ), }, + "hidden": { + "prompt": "PROMPT", + "unique_id": "UNIQUE_ID", + }, } RETURN_TYPES = ("STRING",) @@ -42,10 +50,27 @@ class TextLM: FUNCTION = "process" @classmethod - def IS_CHANGED(cls, text: str, seed: int | None = None): - if contains_dynamic_syntax(text) and seed is None: + def IS_CHANGED( + cls, + text: str, + seed: int | None = None, + prompt: dict | None = None, + unique_id: str | None = None, + ): + if seed is not None: + return False + if contains_dynamic_syntax(text): + return float("NaN") + if text is None and linked_text_requires_rerun(prompt, unique_id, "text"): return float("NaN") return False - def process(self, text: str, seed: int | None = None): + def process( + self, + text: str, + seed: int | None = None, + prompt: dict | None = None, + unique_id: str | None = None, + ): + del prompt, unique_id return (get_wildcard_service().expand_text(text, seed=seed),) diff --git a/py/services/wildcard_service.py b/py/services/wildcard_service.py index 8016febe..36d78f2f 100644 --- a/py/services/wildcard_service.py +++ b/py/services/wildcard_service.py @@ -39,6 +39,51 @@ def contains_dynamic_syntax(text: str) -> bool: ) +def _is_prompt_link(value: Any) -> bool: + """Return True for ComfyUI prompt-graph links ([node_id, output_index]).""" + + return ( + isinstance(value, list) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], (int, float)) + ) + + +def linked_text_requires_rerun(prompt: Any, node_id: Any, input_name: str) -> bool: + """Decide if a linked text input forces re-execution for dynamic expansion. + + IS_CHANGED only receives constant inputs, so a linked text arrives as None. + This walks the prompt graph to the upstream node and returns False only + when that node is fully constant and free of dynamic syntax. Dynamic + syntax — or anything that cannot be statically resolved — returns True. + """ + + if not isinstance(prompt, dict) or node_id is None: + return True + node = prompt.get(str(node_id)) + if not isinstance(node, dict): + return True + inputs = node.get("inputs") + if not isinstance(inputs, dict): + return True + value = inputs.get(input_name) + if not _is_prompt_link(value): + return contains_dynamic_syntax(value) + upstream = prompt.get(value[0]) + if not isinstance(upstream, dict): + return True + upstream_inputs = upstream.get("inputs") + if not isinstance(upstream_inputs, dict): + return True + for upstream_value in upstream_inputs.values(): + if _is_prompt_link(upstream_value): + return True + if contains_dynamic_syntax(upstream_value): + return True + return False + + def get_wildcards_dir(create: bool = False) -> str: """Return the managed wildcard directory inside the settings folder.""" diff --git a/tests/nodes/test_prompt_text_wildcards.py b/tests/nodes/test_prompt_text_wildcards.py index 65a3717f..e8a2beb5 100644 --- a/tests/nodes/test_prompt_text_wildcards.py +++ b/tests/nodes/test_prompt_text_wildcards.py @@ -84,3 +84,100 @@ def test_prompt_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic(): def test_prompt_lm_is_changed_keeps_cache_for_seeded_or_static_text(): assert PromptLM.IS_CHANGED("__flower__", clip="clip", seed=11) is False assert PromptLM.IS_CHANGED("plain text", clip="clip", seed=None) is False + + +def _linked_prompt(upstream_inputs): + return { + "1": {"class_type": "TextMultiline", "inputs": upstream_inputs}, + "2": { + "class_type": "PromptLM", + "inputs": {"text": ["1", 0], "clip": ["3", 0]}, + }, + } + + +def test_prompt_lm_is_changed_forces_rerun_for_linked_dynamic_text(): + prompt = _linked_prompt({"text": "{red|blue|green}"}) + + result = PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=prompt, unique_id="2") + + assert result != result + + +def test_prompt_lm_is_changed_keeps_cache_for_linked_static_text(): + prompt = _linked_prompt({"text": "a plain static prompt"}) + + assert PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=prompt, unique_id="2") is False + assert PromptLM.IS_CHANGED(None, clip="clip", seed=5, prompt=prompt, unique_id="2") is False + + +def test_prompt_lm_is_changed_forces_rerun_when_linked_text_unresolvable(): + chained = _linked_prompt({"text": ["9", 0]}) + + result = PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=chained, unique_id="2") + + assert result != result + assert PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=None, unique_id="2") != 0 + missing_upstream = _linked_prompt({"text": "static"}) + missing_upstream["2"]["inputs"]["text"] = ["99", 0] + assert ( + PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=missing_upstream, unique_id="2") + != 0 + ) + + +def test_text_lm_is_changed_forces_rerun_for_linked_dynamic_text(): + prompt = _linked_prompt({"text": "__flower__"}) + + result = TextLM.IS_CHANGED(None, seed=None, prompt=prompt, unique_id="2") + + assert result != result + + +def test_text_lm_is_changed_keeps_cache_for_linked_static_text(): + prompt = _linked_prompt({"text": "a plain static prompt"}) + + assert TextLM.IS_CHANGED(None, seed=None, prompt=prompt, unique_id="2") is False + + +def test_text_lm_process_accepts_hidden_inputs(monkeypatch): + node = TextLM() + + class StubService: + def expand_text(self, text, seed=None): + return text + + monkeypatch.setattr("py.nodes.text.get_wildcard_service", lambda: StubService()) + + assert node.process("hello", seed=None, prompt={}, unique_id="2") == ("hello",) + + +def test_prompt_lm_encode_accepts_hidden_inputs(monkeypatch): + node = PromptLM() + + class StubService: + def expand_text(self, text, seed=None): + return text + + class StubEncoder: + def encode(self, clip, prompt): + return ("conditioning",) + + monkeypatch.setattr("py.nodes.prompt.get_wildcard_service", lambda: StubService()) + monkeypatch.setattr("nodes.CLIPTextEncode", lambda: StubEncoder(), raising=False) + + result = node.encode("hello", "clip", seed=None, prompt={}, unique_id="2") + + assert result == ("conditioning", "hello") + + +def test_prompt_lm_input_types_declare_hidden_prompt_inputs(): + hidden = PromptLM.INPUT_TYPES()["hidden"] + + assert hidden == {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"} + + +def test_text_lm_input_types_declare_hidden_prompt_inputs(): + hidden = TextLM.INPUT_TYPES()["hidden"] + + assert hidden == {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"}