diff --git a/py/api/admin.py b/py/api/admin.py index 1bf0124a..ec3f6db7 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -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()): diff --git a/tests/test_pair_scan.py b/tests/test_pair_scan.py index c896e588..2ee70151 100644 --- a/tests/test_pair_scan.py +++ b/tests/test_pair_scan.py @@ -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 "" syntax. + g = flux_graph(text=["50", 2]) + g["50"] = { + "class_type": "Lora Loader (LoraManager)", + "inputs": {"model": ["1", 0], "text": ""}, + } + 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": ""}, + } + 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"]) diff --git a/tests/test_sampler_less_prompt.py b/tests/test_sampler_less_prompt.py index ca8d8b5d..7f6379ab 100644 --- a/tests/test_sampler_less_prompt.py +++ b/tests/test_sampler_less_prompt.py @@ -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": ""}}, + "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") diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index b36ac0a2..5ff4324a 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -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