fix(scan): refuse loader side outputs, seeded templates and keep A1111 text (#252)

Review findings: an unreadable text link counts as built from a candidate
only through that node's first output, so a LoRA loader's trigger-word output
is unknown, not its text. A node with a seed input holds a template, never the
prompt that ran. A sampler-less graph the rule cannot decide still links the
A1111 positive a metadata saver wrote beside it, as the old scan did. Only
plain numbers are numeric; words such as nan or inf are prompts.
This commit is contained in:
Vito Sansevero
2026-10-27 16:33:19 -07:00
parent 31502aa77c
commit 6ce8e25a59
4 changed files with 95 additions and 23 deletions
+6 -2
View File
@@ -1758,14 +1758,18 @@ class AdminRoutesMixin:
# No node takes a positive or negative input (a guider or wrapper
# graph): the one wired text, never a guess (#252).
found = sampler_less_prompt(canonical["prompt"])
if found["state"] != "resolved":
prompt_text = found["text"]
if prompt_text is None and isinstance(canonical.get("parameters"), str):
# A metadata saver also wrote A1111 text: its positive prompt.
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
prompt_text = parsed.strip() if parsed and parsed.strip() else None
if prompt_text is None:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if found["state"] == "ambiguous" else "found"
prompt_text = found["text"]
else:
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
if not (prompt_text and prompt_text.strip()):
+38 -1
View File
@@ -410,5 +410,42 @@ class TestSamplerLessScan(AdminAPITestCase):
{"id": 6, "type": "CLIPTextEncode", "widgets_values": ["stale text"]}
]
}
self.assertIn(self._scanned("stale.png", g, workflow), ("found", "unresolved"))
self.assertEqual(self._scanned("stale.png", g, workflow), "unresolved")
self.assertEqual(self._texts(), [])
def test_a_lora_loaders_trigger_word_output_is_not_its_text(self):
# The encoder reads the loader's trigger-word output (slot 2), not its
# text input: the prompt is unknown, never the "<lora:...>" syntax.
g = flux_graph(text=["50", 2])
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
}
g["22"]["inputs"]["model"] = ["50", 0]
self.assertEqual(self._scanned("trigger.png", g), "unresolved")
self.assertEqual(self._texts(), [])
def test_a_seeded_text_node_holds_a_template_not_the_prompt(self):
g = flux_graph(text=["80", 0])
g["80"] = {
"class_type": "RandomPrompt",
"inputs": {"text": "a {red|blue} fox", "seed": 5},
}
self.assertEqual(self._scanned("random.png", g), "unresolved")
self.assertEqual(self._texts(), [])
def test_a1111_parameters_beside_an_undecided_graph_are_read(self):
# Metadata savers write both; the old scan read the A1111 positive.
g = flux_graph()
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
}
g["22"]["inputs"]["model"] = ["50", 0]
meta = {
"prompt": json.dumps(g),
"parameters": "a red fox\nNegative prompt: blur\nSteps: 20",
}
path = self.output_dir / "saver.png"
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
self.assertEqual(self._texts(), ["a red fox"])
+16
View File
@@ -31,6 +31,22 @@ class TestSamplerLessPrompt(unittest.TestCase):
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": value}}})
self.assertEqual(sampler_less_prompt(graph)["state"], "absent", value)
def test_number_like_words_are_prompts(self):
for value in ("nan", "inf", "Infinity", "1_000"):
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": value}}})
self.assertEqual(sampler_less_prompt(graph)["text"], value)
def test_an_own_node_with_unreadable_text_beside_another_text_is_ambiguous(self):
graph = _guided(
**{
"1": {"class_type": "PromptManager", "inputs": {"text": ["5", 0]}},
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
"2": {"class_type": "Enc", "inputs": {"text": "<lora:x:1>"}},
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
}
)
self.assertEqual(sampler_less_prompt(graph)["state"], "ambiguous")
def test_unhashable_class_type_is_an_unknown_node_not_a_crash(self):
graph = _guided(**{"1": {"class_type": ["x"], "inputs": {"text": "a fox"}}})
self.assertEqual(sampler_less_prompt(graph)["text"], "a fox")
+35 -20
View File
@@ -2580,13 +2580,12 @@ def resolve_text_input(
return None
_NUMBER = re.compile(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
def _numeric(text: str) -> bool:
"""Whether ``text`` is a number (a widget value such as ``2048``, #252)."""
try:
float(text)
except ValueError:
return False
return True
"""Whether ``text`` is a plain number (a widget value such as ``2048``)."""
return _NUMBER.fullmatch(text) is not None
def sampler_less_prompt(graph: Any, *, max_visits: int = 500) -> Dict[str, Any]:
@@ -2599,7 +2598,8 @@ def sampler_less_prompt(graph: Any, *, max_visits: int = 500) -> Dict[str, Any]:
feeds nothing did not make the image. Its text is traced as a STRING
(``resolve_text_input``); a linked text no rule reads is unknown unless
it is built from a candidate's text (``_covered``). Blank and
numeric texts are never prompts. This repository's own positive-text
numeric texts are never prompts, and a node with a seed input holds a
template, not the prompt (unknown). This repository's own positive-text
nodes are authoritative: when they give a text, only they count.
Returns ``{"state", "text", "texts"}``: ``resolved`` with the one distinct
@@ -2624,11 +2624,16 @@ def sampler_less_prompt(graph: Any, *, max_visits: int = 500) -> Dict[str, Any]:
inputs = node.get("inputs") if isinstance(node, dict) else None
if node_id not in consumed or not _has_text_input(node):
continue
if _seeded(inputs):
# Generated at run time (a wildcard or a language model): its
# text input is a template, not the prompt that ran.
unknown = True
continue
value = inputs["text"]
text = resolve_text_input(nodes, node_id, "text", max_visits=max_visits)
if text is None:
# A literal that is not a string (a number) is never a prompt.
if _link(value) and not _covered(nodes, str(value[0])):
if _link(value) and not _covered(nodes, value):
unknown = True
continue
text = text.strip()
@@ -2652,26 +2657,36 @@ def _has_text_input(node: Any) -> bool:
return isinstance(inputs, dict) and "text" in inputs
def _covered(nodes: dict, source_id: str) -> bool:
def _seeded(inputs: dict) -> bool:
"""A seed input: the node's string output is a run-time value (the
resolver's ``runtime-value`` rule)."""
return any(isinstance(name, str) and name.endswith("seed") for name in inputs)
def _covered(nodes: dict, link: list) -> bool:
"""Whether an unreadable text link is built from a candidate's text.
True when a node upstream of ``source_id`` (itself included) has a
``text`` input: that candidate is read on its own, and whatever else is
joined to it at run time (a caption, a language model) is no stored
prompt to choose. False when the text comes only from inputs no candidate
holds (a builder's ``prompt`` field): the prompt is unknown then.
True when a node upstream of ``link`` is read through its first output
(slot 0, the legacy encoder rule's text) and has an unseeded ``text``
input: that candidate is read on its own, and whatever else is joined to
it at run time (a caption, a language model) is no stored prompt to
choose. Another output of such a node (a loader's trigger words) is not
its text. False when the text comes only from inputs no candidate holds
(a builder's ``prompt`` field): the prompt is unknown then.
"""
seen, stack = set(), [source_id]
seen, stack = set(), [(str(link[0]), link[1])]
while stack:
node_id = stack.pop()
if node_id in seen:
node_id, slot = stack.pop()
if (node_id, slot) in seen:
continue
seen.add(node_id)
seen.add((node_id, slot))
node = nodes.get(node_id)
inputs = node.get("inputs") if isinstance(node, dict) else None
if not isinstance(inputs, dict):
continue
if "text" in inputs:
if "text" in inputs and slot == 0 and not _seeded(inputs):
return True
stack.extend(str(value[0]) for value in inputs.values() if _link(value))
stack.extend(
(str(value[0]), value[1]) for value in inputs.values() if _link(value)
)
return False