Files
ComfyAssets-ComfyUI_PromptM…/utils/prompt_graph.py
T
Vito caa9649f32 feat: reuse positive and negative prompt combinations (#195)
* feat(db): add pair schema and stage restore migrations for #193

* feat(database): persist reusable ordered prompt pairs (#193)

* feat(api): expose reusable pairs and atomic metadata enrichment (#193)

* feat(metadata): resolve canonical prompt pairs with bounded provenance (#193)

* feat(scan): enrich image pairs without guessing or duplicate links (#193)

* fix(database): preserve pair tags during global maintenance (#193)

* feat(tracking): enrich live image pairs and cancel scans safely (#193)

* feat(nodes): encode paired prompts and count cached recipe runs (#193)

* fix(database): validate restored pair ownership and provenance (#193)

* feat(admin): report pair scan outcomes and cancellation (#193)

* feat(ui): add paired prompt editor and explicit recipe library for #193

* fix(autocomplete): include reusable pair metadata vocabulary (#193)

* fix(pairs): honor recent sorting and conservative scan outcomes (#193)

* fix(ui): protect paired draft saves and clarify negative variants (#193)

* fix(metadata): preserve bounded paired execution evidence (#193)

* fix(tracking): follow actual pair output roles and prefer execution evidence (#193)

* docs(pairs): document live validation and retain renderer screenshots (#193)

* test(api): inject disposable databases before API startup (#194)

* test(api): canonicalize portable temporary gallery roots (#194)

* docs(ui): prototype branching negative variant history (#193)

* docs(ui): show selected negative beneath positive in timeline concept (#193)

* docs: retain approved timeline for later gallery redesign (#193)

* test(db): close restore fixture connections before cleanup (#194)

* test(db): reuse existing handle guards in all pair fixtures (#194)

* chore: clarify rollback test and intentional exception handling (#193)
2026-10-17 08:00:42 -07:00

334 lines
12 KiB
Python

"""Find which PromptManager nodes feed a sampler's positive input in a ComfyUI graph.
Works on the API-format prompt graph ({node_id: {"class_type", "inputs"}}) that
ComfyUI queues and embeds in saved images. Usage counting and image linking use it
so that negative-prompt PromptManager nodes never count as "the prompt" of a run.
Pure functions with no ComfyUI imports, so they are unit-testable. Mirrors the
link-tracing rules of web/js/comfy-metadata.js.
"""
import re
from typing import Any, Dict, List, Optional, Tuple
PROMPT_MANAGER_TYPES = frozenset(
{
"PromptManager",
"PromptManagerText",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerPair",
}
)
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
MAX_VISITS = 500
# Longest text resolve_text will build; StringConcatenate chains can double
# the text at every level, so anything larger is treated as unknowable
MAX_TEXT_LENGTH = 100_000
# Deepest chain of string nodes resolve_text follows (recursion stays bounded)
MAX_DEPTH = 64
# Inputs that carry positive conditioning or prompt text towards a sampler
_CONDITIONING_KEY = re.compile(r"^conditioning(_\w+)?$")
_TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
# Input names that carry a negative prompt (text_negative, neg_text, negative_prompt)
_NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE)
def follows_input(key: Any) -> bool:
"""Whether an input name carries positive conditioning or prompt text."""
if not isinstance(key, str) or _NEGATIVE_KEY.search(key):
return False
return key == "positive" or bool(
_CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)
)
def _is_link(value: Any) -> bool:
return (
isinstance(value, (list, tuple))
and len(value) == 2
and isinstance(value[0], (str, int))
and isinstance(value[1], int)
)
def _inputs(node: Any) -> Dict[str, Any]:
inputs = node.get("inputs") if isinstance(node, dict) else None
return inputs if isinstance(inputs, dict) else {}
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...).
A sampler is a node taking a model plus positive/negative conditioning, or a
guider taking a model plus conditioning. Nodes named like a sampler also count
without a model link, so a negative-only graph is never mistaken for one with
no sampler at all.
"""
roots = []
for node in graph.values():
if not isinstance(node, dict):
continue
inputs = _inputs(node)
class_name = str(node.get("class_type", "")).lower()
has_model = _is_link(inputs.get("model"))
if _is_link(inputs.get("positive")) and _is_link(inputs.get("negative")):
if has_model or "sampler" in class_name:
roots.append(inputs["positive"])
elif has_model and _is_link(inputs.get("conditioning")):
if "guider" in class_name:
roots.append(inputs["conditioning"])
return roots
def _upstream_links(inputs: Dict[str, Any]) -> List[Any]:
"""Inputs to follow from a pass-through or encoder node, never `negative`."""
links = []
for key, value in inputs.items():
if not _is_link(value):
continue
if follows_input(key):
links.append(value)
return links
def has_sampler(graph: Any) -> bool:
"""Whether the graph contains a node recognised as a sampler or guider."""
return isinstance(graph, dict) and bool(_positive_roots(graph))
def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
"""Node/input identities reaching actual sampler positive wires."""
if not isinstance(graph, dict):
return []
found: List[Tuple[str, str]] = []
seen = set()
stack = list(reversed(_positive_roots(graph)))
while stack and len(seen) < MAX_VISITS:
link = stack.pop()
node_id, slot = str(link[0]), link[1]
if (node_id, slot) in seen:
continue
seen.add((node_id, slot))
node = graph.get(node_id)
if not isinstance(node, dict):
continue
if node.get("class_type") in PROMPT_MANAGER_TYPES:
field = "text"
if node.get("class_type") == "PromptManagerPair":
field = {
0: "text",
1: "negative_text",
2: "text",
3: "negative_text",
}.get(slot)
if field is None:
continue
identity = (node_id, field)
if identity not in found:
found.append(identity)
continue
stack.extend(reversed(_upstream_links(_inputs(node))))
return found
def positive_prompt_nodes(graph: Any) -> List[str]:
"""Nodes whose main editor reaches a sampler's positive input."""
return list(
dict.fromkeys(
node_id
for node_id, field in positive_prompt_components(graph)
if field == "text"
)
)
def run_prompt_components(graph: Any) -> List[Tuple[str, str]]:
"""Actual positive components, retaining standalone main-editor fallback."""
components = positive_prompt_components(graph)
if components or not isinstance(graph, dict) or has_sampler(graph):
return components
return [
(str(node_id), "text")
for node_id, node in graph.items()
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
]
def run_prompt_nodes(graph: Any) -> List[str]:
"""Prompt nodes that represent a run: the positive ones, if any can be found.
When the graph has no recognisable sampler (an unrecognised custom sampler,
say), every PromptManager node counts, as before 3.2.4, so an exotic workflow
never silently loses usage counting or image linking. When a sampler exists
and no PromptManager node feeds its positive input, nothing counts: the only
PromptManager nodes are negative prompts.
"""
positive = positive_prompt_nodes(graph)
if positive or not isinstance(graph, dict) or has_sampler(graph):
return positive
return [
str(node_id)
for node_id, node in graph.items()
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
]
def _join_text_inputs(
inputs: Dict[str, Any], keys: List[str], resolve
) -> Optional[str]:
"""WAS-style join: resolve each key, optionally strip, skip empty, join by
delimiter."""
delimiter = inputs.get("delimiter", " ")
if not isinstance(delimiter, str):
return None
clean = str(inputs.get("clean_whitespace", "true")).lower() == "true"
parts = []
for key in keys:
value = resolve(inputs.get(key, ""))
if value is None:
return None
value = value.strip() if clean else value
if value:
parts.append(value)
return delimiter.join(parts)
def _resolve_string_node(
class_type: str, inputs: Dict[str, Any], resolve
) -> Optional[str]:
"""Output of a known pure string node, or None when the type isn't known."""
if class_type in ("PrimitiveString", "PrimitiveStringMultiline"):
return resolve(inputs.get("value"))
if class_type in ("ShowText|pysssss", "Text Multiline", "String Literal"):
return resolve(inputs.get("text", inputs.get("string")))
if class_type == "StringConcatenate":
a, b = resolve(inputs.get("string_a", "")), resolve(inputs.get("string_b", ""))
delimiter = inputs.get("delimiter", "")
if a is None or b is None or not isinstance(delimiter, str):
return None
return f"{a}{delimiter}{b}"
if class_type == "Text Concatenate":
keys = sorted(k for k in inputs if re.fullmatch(r"text_[a-z]", k))
return _join_text_inputs(inputs, keys, resolve)
return None
def resolve_text(graph: Any, node_id: str) -> Optional[str]:
"""A prompt node's `text` as it will be at run time, if knowable from the graph.
Typed text, or text produced by known pure string nodes (core primitives and
StringConcatenate, WAS Text Concatenate/Multiline, pysssss ShowText). Anything
else, including PromptSearchList batches, returns None rather than a guess.
Callers compare the result with the text the node actually received.
"""
if not isinstance(graph, dict):
return None
# Cycle detection is scoped to the current path so a node read by two
# branches (a diamond, or StringConcatenate(string_a=X, string_b=X))
# resolves in both. Each node's text is computed once and memoised, so a
# chain of diamonds costs one expansion per node rather than one per path,
# and the shared visit budget bounds total work whatever the shape. A node
# that fails because of a cycle fails from every path (the cycle is in the
# graph, not in the walk), so memoising None is sound.
path = {str(node_id)}
memo: Dict[str, Optional[str]] = {}
budget = [MAX_VISITS]
def resolve(value: Any) -> Optional[str]:
if isinstance(value, str):
return value if len(value) <= MAX_TEXT_LENGTH else None
if not _is_link(value):
return None
source_id = str(value[0])
if source_id in memo:
return memo[source_id]
if source_id in path or budget[0] <= 0 or len(path) > MAX_DEPTH:
return None
budget[0] -= 1
source = graph.get(source_id)
if not isinstance(source, dict):
memo[source_id] = None
return None
path.add(source_id)
try:
text = _resolve_string_node(
str(source.get("class_type", "")), _inputs(source), resolve
)
finally:
path.discard(source_id)
if isinstance(text, str) and len(text) > MAX_TEXT_LENGTH:
text = None
memo[source_id] = text
return text
text = resolve(_inputs(graph.get(str(node_id))).get("text"))
return text.strip() if isinstance(text, str) and text.strip() else None
def intrinsic_pair_nodes(graph: Any) -> List[str]:
"""Paired nodes whose matching outputs feed the same sampler, or standalone."""
if not isinstance(graph, dict) or len(graph) > MAX_VISITS:
return []
if not has_sampler(graph):
return [
str(key)
for key, value in graph.items()
if isinstance(value, dict)
and value.get("class_type") == "PromptManagerPair"
]
def origin(link: Any, role: str, kind: str = "conditioning") -> Optional[str]:
seen = set()
while _is_link(link) and len(seen) < MAX_DEPTH:
node_id, slot = str(link[0]), link[1]
if (node_id, slot, kind) in seen:
return None
seen.add((node_id, slot, kind))
node = graph.get(node_id)
if not isinstance(node, dict):
return None
name, inputs = node.get("class_type"), _inputs(node)
if name == "PromptManagerPair":
expected = {
("positive", "conditioning"): 0,
("negative", "conditioning"): 1,
("positive", "string"): 2,
("negative", "string"): 3,
}
return node_id if slot == expected[(role, kind)] else None
if name == "CLIPTextEncode" and slot == 0 and kind == "conditioning":
link, kind = inputs.get("text"), "string"
elif name == "Reroute" and slot == 0:
keys = [key for key in ("value", "input", "in") if key in inputs]
if len(keys) != 1:
return None
link = inputs[keys[0]]
else:
return None
return None
found = []
for node in graph.values():
if not isinstance(node, dict):
continue
inputs = _inputs(node)
if not _is_link(inputs.get("positive")) or not _is_link(inputs.get("negative")):
continue
if (
not _is_link(inputs.get("model"))
and "sampler" not in str(node.get("class_type", "")).lower()
):
continue
positive = origin(inputs["positive"], "positive")
if (
positive
and positive == origin(inputs["negative"], "negative")
and positive not in found
):
found.append(positive)
return found