fix: re-run dynamic prompts fed through linked text inputs

IS_CHANGED only receives constant inputs, so a linked text always
arrived as None and the node kept serving its cached first expansion.
Declare hidden PROMPT/UNIQUE_ID inputs and walk the prompt graph to
the upstream node: rerun only when its constants contain dynamic
syntax or cannot be statically resolved, keep caching for static
linked text.

Fixes #1120
This commit is contained in:
Will Miao
2026-09-24 09:33:27 +08:00
parent 2f9bd3ee7d
commit dae18b3d1d
4 changed files with 186 additions and 5 deletions
+15 -1
View File
@@ -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 = []
+29 -4
View File
@@ -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),)
+45
View File
@@ -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."""
+97
View File
@@ -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"}