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
+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