R6 and R9 for nodes whose sources are installed beside ComfyUI, each entry's input names and output slots read from the node's code (the task report lists file and line): - detailer stages UltimateSDUpscale (image), UltimateSDUpscaleNoUpscale (upscaled_image) and DetailerForEach (image), judged with their upstream samplers like FaceDetailer; - pass-throughs ControlNetInpaintingAliMamaApply and LTXVConditioning (positive 0, negative 1) and FluxKontextMultiReferenceLatentMethod; - encoder Text to Conditioning, strings TextInput and DisplayText; - facts model pass-throughs Lora Loader (LoraManager), Power Lora Loader (rgthree), ModelPatchTorchSettings, CFGNorm, PerturbedAttentionGuidance. Impact Pack encodes a detailer's non-empty wildcard instead of its positive input, so FaceDetailer and DetailerForEach now list it as a prompt override: a literal wildcard is decided at run time and a linked one names its source, instead of resolving to the positive input. The overwritten PromptManager refusal (R1) still holds behind the new pass-throughs. The default profile does not read the table.
1775 lines
74 KiB
Python
1775 lines
74 KiB
Python
"""Conservatively recover a prompt pair from one sampler or A1111 parameters.
|
|
|
|
``resolve_prompt_pair`` returns a dictionary with ``state``, ``positive``,
|
|
``negative``, ``sampler_id``, ``reason`` and ``candidates``. Each recovered side
|
|
contains effective ``text``, deduplicated-recipe ``storage_text`` and
|
|
``provenance``; ComfyUI provenance records the exact output
|
|
slot and complete followed link path. States distinguish a resolved pair (an
|
|
explicit empty negative is valid), absent data, unsupported/unresolved data and
|
|
ambiguous sampler selection. Unknown conditioning semantics are never flattened.
|
|
|
|
Future nodes can register declarative ``OutputAdapter`` entries by
|
|
``(class_type, output_slot)``. This does not prescribe a future paired-node schema.
|
|
Effective text uses a unique bounded execution record for traced paired nodes
|
|
when available; cached graph-only results remain explicitly inferred.
|
|
The resolver has no
|
|
ComfyUI, database or configuration dependencies; its only file read is the
|
|
inspection profile's node semantics table (``utils.inspection_semantics``).
|
|
|
|
Two ``ResolverProfile`` values exist. ``DEFAULT_PROFILE`` (ingestion: scan, live
|
|
monitor, admin import) keeps the historical behavior exactly, including the
|
|
500-node gate, and only adds ``reason_code: None``. ``INSPECTION_PROFILE`` bounds
|
|
work instead of graph size: a per-candidate visit allowance, a total visit budget
|
|
shared by every candidate and attribution walk, a node ceiling backstop, and
|
|
bounds on built provenance and text. It also names why a result is not resolved
|
|
(``reason_code``, and ``node`` for the first unsupported node), and a prompt graph
|
|
without a supported sampler is ``unresolved`` (``no-sampler``), never ``absent``.
|
|
When the semantics table cannot be loaded, inspection of a prompt graph is
|
|
``unresolved`` with a fixed reason; ingestion never loads the table.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from collections import deque
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
|
|
from .pair_execution import ExecutionEvidenceError, read_pair_execution
|
|
|
|
_LOGGER = logging.getLogger("prompt_manager.prompt_pair_resolver")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OutputAdapter:
|
|
"""A known output's text input, output kind, and optional PM composition.
|
|
|
|
``kind`` is ``conditioning`` or ``string``. Composition is opt-in and follows
|
|
the existing PromptManager prepend/main/append rule. Adapters describe pure
|
|
text provenance, not arbitrary executable transforms. ``storage_input`` keeps
|
|
the resolved input as the recipe even without composition (negative editor).
|
|
"""
|
|
|
|
input_name: str
|
|
kind: str
|
|
compose: bool = False
|
|
storage_input: bool = False
|
|
# Historical PromptManager classes (inspection only): ``comma`` strips the
|
|
# text and joins prepend/append with ", " exactly as their code did;
|
|
# ``strip`` only strips. ``default`` stands in for a missing optional input.
|
|
comma: bool = False
|
|
strip: bool = False
|
|
default: Optional[str] = None
|
|
|
|
|
|
_SAMPLERS = frozenset({"KSampler", "KSamplerAdvanced", "SamplerCustom"})
|
|
_PROMPT_NODES = frozenset(
|
|
{"PromptManager", "PromptManagerFocused", "PromptManagerSplit"}
|
|
)
|
|
_DEFAULT_ADAPTERS = {
|
|
(name, slot): OutputAdapter("text", kind, True)
|
|
for name in _PROMPT_NODES
|
|
for slot, kind in ((0, "conditioning"), (1, "string"))
|
|
}
|
|
for _slot, _kind in ((0, "conditioning"), (2, "string")):
|
|
_DEFAULT_ADAPTERS[("PromptManagerPair", _slot)] = OutputAdapter("text", _kind, True)
|
|
for _slot, _kind in ((1, "conditioning"), (3, "string")):
|
|
_DEFAULT_ADAPTERS[("PromptManagerPair", _slot)] = OutputAdapter(
|
|
"negative_text", _kind, storage_input=True
|
|
)
|
|
_DEFAULT_ADAPTERS[("PromptManagerText", 0)] = OutputAdapter("text", "string", True)
|
|
_DEFAULT_ADAPTERS[("CLIPTextEncode", 0)] = OutputAdapter("text", "conditioning")
|
|
for _name in ("PrimitiveString", "PrimitiveStringMultiline"):
|
|
_DEFAULT_ADAPTERS[(_name, 0)] = OutputAdapter("value", "string")
|
|
for _name in ("Text Multiline", "String Literal", "ShowText|pysssss"):
|
|
_DEFAULT_ADAPTERS[(_name, 0)] = OutputAdapter("text", "string")
|
|
# This repository's node classes up to 07a5af3 (``prompt_manager_*.py``), still in
|
|
# saved workflows. Inspection only: the default profile keeps them unsupported.
|
|
_COMMA = OutputAdapter("text", "conditioning", comma=True)
|
|
_NEGATIVE = OutputAdapter("negative_text", "conditioning", strip=True, default="")
|
|
_HISTORICAL_ADAPTERS = {
|
|
("PromptManagerV2", 0): _COMMA,
|
|
("PromptManagerV2", 1): _NEGATIVE,
|
|
("PromptManagerV2", 2): replace(_COMMA, kind="string"),
|
|
("PromptManagerV2", 3): replace(_NEGATIVE, kind="string"),
|
|
("PromptManagerV2Text", 0): replace(_COMMA, kind="string"),
|
|
("PromptManagerV2Text", 1): replace(_NEGATIVE, kind="string"),
|
|
("PromptManagerNegative", 0): OutputAdapter("text", "conditioning", strip=True),
|
|
("PromptManagerNegative", 1): OutputAdapter("text", "string", strip=True),
|
|
("PromptManagerPositiveText", 0): replace(_COMMA, kind="string"),
|
|
("PromptManagerNegativeText", 0): replace(_COMMA, kind="string"),
|
|
}
|
|
for _name in ("PromptManagerPositive", "PromptManagerDetailer"):
|
|
_HISTORICAL_ADAPTERS[(_name, 0)] = _COMMA
|
|
_HISTORICAL_ADAPTERS[(_name, 1)] = replace(_COMMA, kind="string")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolverProfile:
|
|
"""Resolution bounds. ``legacy_node_gate`` set means the historical code path.
|
|
|
|
``max_path_entries`` and ``max_built_chars`` bound memory across the whole
|
|
resolution (provenance entries and newly built text). ``max_scanned_units``
|
|
bounds linear work across the whole resolution: input keys validated or
|
|
scanned, characters stripped or joined, and provenance entries copied, each
|
|
charged before the work is done. ``None`` disables each bound.
|
|
``semantics`` reads the declarative node table (inspection only; loaded
|
|
lazily so the default profile never imports it). ``selection`` is reserved
|
|
for a later slice and stays off.
|
|
"""
|
|
|
|
name: str
|
|
per_candidate_visits: int
|
|
total_visits: int
|
|
max_depth: int
|
|
max_text_length: int
|
|
node_ceiling: Optional[int]
|
|
legacy_node_gate: Optional[int]
|
|
semantics: bool = False
|
|
selection: bool = False
|
|
max_path_entries: Optional[int] = None
|
|
max_built_chars: Optional[int] = None
|
|
max_scanned_units: Optional[int] = None
|
|
|
|
@property
|
|
def legacy(self) -> bool:
|
|
return self.legacy_node_gate is not None
|
|
|
|
|
|
DEFAULT_PROFILE = ResolverProfile(
|
|
name="default",
|
|
per_candidate_visits=500,
|
|
total_visits=500,
|
|
max_depth=64,
|
|
max_text_length=100_000,
|
|
node_ceiling=None,
|
|
legacy_node_gate=500,
|
|
)
|
|
INSPECTION_PROFILE = ResolverProfile(
|
|
name="inspection",
|
|
per_candidate_visits=2_000,
|
|
total_visits=20_000,
|
|
max_depth=64,
|
|
max_text_length=100_000,
|
|
node_ceiling=20_000,
|
|
legacy_node_gate=None,
|
|
semantics=True,
|
|
max_path_entries=64 * 20_000,
|
|
max_built_chars=16 * 100_000,
|
|
# About four passes over the 2 MiB role-record budget.
|
|
max_scanned_units=8_000_000,
|
|
)
|
|
# Without the semantics table every sampler reads its pair directly.
|
|
_DIRECT_SAMPLER = {
|
|
"strategy": "ksampler",
|
|
"conditioning": {"positive": "positive", "negative": "negative"},
|
|
"latent": "latent_image",
|
|
}
|
|
# Strategies whose nodes are prompt candidates. A detailer stage (D19) re-samples
|
|
# an upstream image: it is judged together with the samplers found in its image
|
|
# ancestry, and a sampler consumed only by detailers is not a candidate alone.
|
|
_CANDIDATE_STRATEGIES = frozenset({"ksampler", "custom-advanced", "detailer-stage"})
|
|
_DETAILER = "detailer-stage"
|
|
_GUIDER_ABSENT_REASON = "Guider has no negative input"
|
|
# ConditioningZeroOut discards its input text: an explicit empty prompt.
|
|
_ZEROED_SOURCE = "zeroed-conditioning"
|
|
# Runtime selector choices expanded per sampler (the spec's candidate cap).
|
|
_MAX_BRANCH_CANDIDATES = 32
|
|
# Statically undecidable text (a search, a list output): never pick an item.
|
|
_RUNTIME_REASON = "Text is decided at run time"
|
|
# Uncoded outcomes the inspector explains by their exact reason.
|
|
EMPTY_POSITIVE_REASON = "Positive prompt is empty"
|
|
MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
|
|
_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
|
|
# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
|
|
# with the last-run node's prompt (comfy-metadata.js ``createContext``).
|
|
OVERWRITTEN_PROMPT_REASON = "Saved PromptManager text was overwritten by another node"
|
|
# Trace outcomes that leave an input unverifiable against execution evidence.
|
|
_UNVERIFIABLE_CODES = frozenset({"unsupported-node", "runtime-value"})
|
|
# JoinStringMulti's own ``inputcount`` range; anything else never executed.
|
|
_MAX_JOIN_INPUTS = 1000
|
|
_SAMPLER_LIKE_INPUTS = ("latent_image", "guider", "positive", "negative")
|
|
# The stage-walk input lists (``stage_inputs``) live in the semantics table; the
|
|
# default profile never reads them.
|
|
_NO_SAMPLER_NAMES = 3
|
|
_CLASS_NAME_LENGTH = 64
|
|
|
|
|
|
class _Unresolved(Exception):
|
|
"""A non-resolved outcome; ``code`` is the closed machine vocabulary."""
|
|
|
|
def __init__(
|
|
self, message: str, code: Optional[str] = None, node: Optional[dict] = None
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.node = node
|
|
|
|
|
|
class _Diverging(Exception):
|
|
"""A runtime selector whose branches trace to different texts.
|
|
|
|
Raised from inside tracing; ``_GraphResolver.branches`` resolves the sampler
|
|
again once per choice, so each candidate is internally consistent.
|
|
"""
|
|
|
|
def __init__(self, key: Tuple[str, int]) -> None:
|
|
super().__init__(key)
|
|
# The condition output (node ID, slot): one runtime value, so every
|
|
# selector it drives is forced together.
|
|
self.key = key
|
|
|
|
|
|
def _node_ref(node_id: str, class_type: Any) -> dict:
|
|
name = class_type[:_CLASS_NAME_LENGTH] if isinstance(class_type, str) else None
|
|
return {"id": node_id[:_CLASS_NAME_LENGTH], "class_type": name}
|
|
|
|
|
|
class _Budget:
|
|
"""Visits shared by every candidate, plus a per-candidate allowance.
|
|
|
|
With ``total`` equal to the allowance (the default profile) this is exactly
|
|
the historical single counter, so the reset per sampler is a no-op there.
|
|
"""
|
|
|
|
def __init__(self, total: int) -> None:
|
|
self.total = total
|
|
self.allowance = total
|
|
|
|
def reset(self, allowance: int) -> None:
|
|
self.allowance = allowance
|
|
|
|
@property
|
|
def remaining(self) -> int:
|
|
return min(self.total, self.allowance)
|
|
|
|
def spend(self) -> None:
|
|
# Checked before decrementing, so an exhausted allowance costs no total.
|
|
if self.total <= 0 or self.allowance <= 0:
|
|
raise _Unresolved("Resolution visit limit reached", "work-limit")
|
|
self.total -= 1
|
|
self.allowance -= 1
|
|
|
|
def charge(self, cost: int) -> None:
|
|
"""Charge a memoized subtree's visit cost to this candidate only."""
|
|
if cost > self.allowance:
|
|
raise _Unresolved("Resolution visit limit reached", "work-limit")
|
|
self.allowance -= cost
|
|
|
|
|
|
def _link(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, (list, tuple))
|
|
and len(value) == 2
|
|
and isinstance(value[0], (str, int))
|
|
and not isinstance(value[0], bool)
|
|
and isinstance(value[1], int)
|
|
and not isinstance(value[1], bool)
|
|
and value[1] >= 0
|
|
)
|
|
|
|
|
|
def _result(
|
|
state: str,
|
|
positive: Optional[dict] = None,
|
|
negative: Optional[dict] = None,
|
|
sampler_id: Optional[str] = None,
|
|
reason: str = "",
|
|
candidates: Optional[list] = None,
|
|
reason_code: Optional[str] = None,
|
|
node: Optional[dict] = None,
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"state": state,
|
|
"positive": positive,
|
|
"negative": negative,
|
|
"sampler_id": sampler_id,
|
|
"reason": reason,
|
|
"candidates": candidates or [],
|
|
"reason_code": reason_code,
|
|
"node": node,
|
|
}
|
|
|
|
|
|
def _role_text(side: Any) -> Optional[str]:
|
|
return side["text"] if isinstance(side, dict) else None
|
|
|
|
|
|
def _outcome_key(result: Dict[str, Any]) -> tuple:
|
|
"""What a reader sees of a result: state and the two role texts."""
|
|
return (
|
|
result["state"],
|
|
_role_text(result["positive"]),
|
|
_role_text(result["negative"]),
|
|
)
|
|
|
|
|
|
def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""The default profile's wire: today's keys plus ``reason_code: None``."""
|
|
legacy = {key: value for key, value in result.items() if key != "node"}
|
|
legacy["reason_code"] = None
|
|
legacy["candidates"] = [_legacy_result(item) for item in result["candidates"]]
|
|
return legacy
|
|
|
|
|
|
class _GraphResolver:
|
|
def __init__(
|
|
self,
|
|
graph: dict,
|
|
adapters: Mapping[Tuple[str, int], OutputAdapter],
|
|
max_visits: int,
|
|
max_depth: int,
|
|
max_text_length: int,
|
|
metadata: Optional[dict] = None,
|
|
*,
|
|
budget: Optional[_Budget] = None,
|
|
profile: ResolverProfile = DEFAULT_PROFILE,
|
|
semantics: Optional[Any] = None,
|
|
overwritten: frozenset = frozenset(),
|
|
) -> None:
|
|
# ``max_visits`` is the per-candidate allowance; ``profile`` supplies only
|
|
# the path selection and whole-resolution bounds. ``resolve_text_input``
|
|
# relies on the legacy default profile here.
|
|
self.graph = graph
|
|
historical = _HISTORICAL_ADAPTERS if semantics is not None else {}
|
|
self.adapters = {**_DEFAULT_ADAPTERS, **historical, **adapters}
|
|
# Nodes whose saved text is not their own (inspection; R1).
|
|
self.overwritten = overwritten
|
|
self.per_candidate_visits = max_visits
|
|
self.budget = budget if budget is not None else _Budget(max_visits)
|
|
self.budget.reset(max_visits)
|
|
self.legacy = profile.legacy
|
|
self.path_entries = profile.max_path_entries
|
|
self.built_chars = profile.max_built_chars
|
|
self.scanned = profile.max_scanned_units
|
|
self.checked: dict = {}
|
|
# Unfolded visit cost of each memo entry (inspection): a memo hit charges
|
|
# it to the current candidate, so each candidate's outcome is independent
|
|
# of tracing order while the shared work is done only once.
|
|
self.costs: dict = {}
|
|
# Height of each memo entry (deepest depth reached below it, relative to
|
|
# its own depth): a hit raises ``depth-limit`` exactly where a fresh walk
|
|
# from the reusing depth would, so depth too is independent of order.
|
|
self.heights: dict = {}
|
|
self.deepest = 0
|
|
self.max_depth = max_depth
|
|
self.max_text_length = max_text_length
|
|
self.memo: dict = {}
|
|
self.active: set = set()
|
|
self.metadata = metadata or {}
|
|
self.text_sources: dict = {}
|
|
# Forced runtime selector choices (inspection); a result traced through
|
|
# one is "tainted" and never memoized, since it depends on the choice.
|
|
self.choices: dict = {}
|
|
self.tainted = False
|
|
# Latent-walk outcome per sampler (inspection): ``(error or None, visit
|
|
# cost)``. It never depends on selector choices, so one walk serves every
|
|
# branch run; ``latent_charged`` holds the samplers already paid for by
|
|
# the current candidate.
|
|
self.latent_walks: dict = {}
|
|
self.latent_charged: set = set()
|
|
# ``semantics`` (inspection) names the samplers and guiders it follows.
|
|
self.semantics = semantics
|
|
if semantics is None:
|
|
self.sampler_classes = self.stage_classes = _SAMPLERS
|
|
else:
|
|
self.sampler_classes = frozenset(
|
|
name
|
|
for name, entry in semantics.samplers.items()
|
|
if entry["strategy"] in _CANDIDATE_STRATEGIES
|
|
)
|
|
# Any listed sampler upstream in latent ancestry is an earlier stage.
|
|
self.stage_classes = frozenset(semantics.samplers)
|
|
|
|
def node(self, node_id: str) -> Tuple[str, dict]:
|
|
if self.legacy:
|
|
return self.checked_node(node_id)
|
|
if node_id not in self.checked:
|
|
try:
|
|
self.checked[node_id] = self.checked_node(node_id)
|
|
except _Unresolved as error:
|
|
if error.code == "work-limit":
|
|
raise
|
|
self.checked[node_id] = error
|
|
checked = self.checked[node_id]
|
|
if isinstance(checked, _Unresolved):
|
|
raise _Unresolved(str(checked), checked.code, checked.node)
|
|
return checked
|
|
|
|
def checked_node(self, node_id: str) -> Tuple[str, dict]:
|
|
node = self.graph.get(node_id)
|
|
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
|
|
raise _Unresolved("Missing or malformed graph node", "malformed")
|
|
self.scan(len(node["inputs"]))
|
|
if any(not isinstance(key, str) for key in node["inputs"]):
|
|
raise _Unresolved("Malformed node input names", "malformed")
|
|
return str(node.get("class_type", "")), node["inputs"]
|
|
|
|
def scan(self, units: int) -> None:
|
|
"""Charge linear work before doing it (inspection; no-op when legacy)."""
|
|
if self.scanned is not None:
|
|
if units > self.scanned:
|
|
self.scanned = -1
|
|
raise _Unresolved("Resolution work limit reached", "work-limit")
|
|
self.scanned -= units
|
|
|
|
def unsupported(self, message: str, node_id: str) -> _Unresolved:
|
|
return self.named(message, "unsupported-node", node_id)
|
|
|
|
def named(self, message: str, code: str, node_id: str) -> _Unresolved:
|
|
"""An outcome that names the node it is about (bounded class name)."""
|
|
node = self.graph.get(node_id)
|
|
class_type = node.get("class_type") if isinstance(node, dict) else None
|
|
if isinstance(class_type, str):
|
|
class_type = class_type[:_CLASS_NAME_LENGTH]
|
|
return _Unresolved(message, code, _node_ref(node_id, class_type))
|
|
|
|
def bounded(self, text: str) -> str:
|
|
if len(text) > self.max_text_length:
|
|
raise _Unresolved("Resolved text exceeds the length limit", "text-limit")
|
|
return text
|
|
|
|
def built(self, text: str) -> str:
|
|
"""Charge newly built text against the profile's whole-resolution bound."""
|
|
if self.built_chars is not None:
|
|
self.built_chars -= len(text)
|
|
if self.built_chars < 0:
|
|
raise _Unresolved("Resolution work limit reached", "work-limit")
|
|
return text
|
|
|
|
def text(self, value: Any, kind: str, depth: int = 0) -> Tuple[str, list, str]:
|
|
if not self.legacy:
|
|
self.deepest = max(self.deepest, depth)
|
|
if depth >= self.max_depth:
|
|
raise _Unresolved("Resolution depth limit reached", "depth-limit")
|
|
if isinstance(value, str) and kind == "string":
|
|
return self.bounded(value), [], value
|
|
if not _link(value):
|
|
raise _Unresolved("Expected a typed graph link or text input", "malformed")
|
|
node_id, slot = str(value[0]), value[1]
|
|
key = (node_id, slot, kind)
|
|
if key in self.active:
|
|
raise _Unresolved("Cyclic prompt graph", "malformed")
|
|
if key in self.memo:
|
|
if not self.legacy:
|
|
height = self.heights[key]
|
|
if depth + height >= self.max_depth:
|
|
raise _Unresolved("Resolution depth limit reached", "depth-limit")
|
|
self.budget.charge(self.costs[key])
|
|
self.deepest = max(self.deepest, depth + height)
|
|
return self.memo[key]
|
|
allowance = self.budget.allowance
|
|
outer_deepest = self.deepest
|
|
outer_tainted, self.tainted = self.tainted, False
|
|
self.deepest = depth
|
|
try:
|
|
self.budget.spend()
|
|
name, inputs = self.node(node_id)
|
|
self.scan(len(inputs))
|
|
if node_id in self.overwritten:
|
|
raise _Unresolved(OVERWRITTEN_PROMPT_REASON, "malformed")
|
|
self.active.add(key)
|
|
if name == "Reroute" and slot == 0:
|
|
keys = [key for key in ("value", "input", "in") if key in inputs]
|
|
if len(keys) != 1:
|
|
raise _Unresolved(
|
|
"Reroute input is not uniquely identifiable", "malformed"
|
|
)
|
|
text, path, storage_text = self.text(inputs[keys[0]], kind, depth + 1)
|
|
if self.semantics is not None:
|
|
self.inherit_source(node_id, slot, [path])
|
|
elif (name, slot) in self.adapters:
|
|
adapter = self.adapters[(name, slot)]
|
|
if not isinstance(adapter, OutputAdapter) or adapter.kind != kind:
|
|
raise _Unresolved(_INCOMPATIBLE_SLOT, "malformed")
|
|
evidence = (
|
|
self.execution_record(node_id, inputs)
|
|
if name == "PromptManagerPair"
|
|
else None
|
|
)
|
|
if evidence is not None:
|
|
text = evidence[
|
|
(
|
|
"positive_text"
|
|
if adapter.input_name == "text"
|
|
else "negative_effective_text"
|
|
)
|
|
]
|
|
storage_text = evidence[adapter.input_name]
|
|
path = []
|
|
self.text_sources[(node_id, slot)] = "execution"
|
|
elif adapter.comma or adapter.strip:
|
|
text, path, storage_text = self.historical(adapter, inputs, depth)
|
|
else:
|
|
text, path, storage_text = self.text(
|
|
inputs.get(adapter.input_name), "string", depth + 1
|
|
)
|
|
if adapter.compose or adapter.storage_input:
|
|
storage_text = text
|
|
if adapter.compose:
|
|
before, before_path, _ = self.text(
|
|
inputs.get("prepend_text", ""), "string", depth + 1
|
|
)
|
|
after, after_path, _ = self.text(
|
|
inputs.get("append_text", ""), "string", depth + 1
|
|
)
|
|
self.scan(
|
|
len(before)
|
|
+ len(after)
|
|
+ len(before_path)
|
|
+ len(path)
|
|
+ len(after_path)
|
|
)
|
|
text = self.built(
|
|
self.bounded(
|
|
" ".join(
|
|
part
|
|
for part in (before.strip(), text, after.strip())
|
|
if part
|
|
)
|
|
)
|
|
)
|
|
path = before_path + path + after_path
|
|
elif (
|
|
name in ("StringConcatenate", "Text Concatenate")
|
|
and slot == 0
|
|
and kind == "string"
|
|
):
|
|
if name == "StringConcatenate":
|
|
keys = ["string_a", "string_b"]
|
|
delimiter = inputs.get("delimiter", "")
|
|
clean = False
|
|
else:
|
|
keys = sorted(
|
|
key for key in inputs if re.fullmatch(r"text_[a-z]", key)
|
|
)
|
|
delimiter = inputs.get("delimiter", " ")
|
|
clean = (
|
|
str(inputs.get("clean_whitespace", "true")).lower() == "true"
|
|
)
|
|
if not keys or not isinstance(delimiter, str):
|
|
raise _Unresolved("Malformed concatenation", "malformed")
|
|
parts, path, size = [], [], 0
|
|
for input_name in keys:
|
|
part, followed, _ = self.text(
|
|
inputs.get(input_name, ""), "string", depth + 1
|
|
)
|
|
# Charged before stripping: stripped-away text is work too.
|
|
self.scan(len(part) + len(followed))
|
|
if clean:
|
|
part = part.strip()
|
|
path.extend(followed)
|
|
if part or name == "StringConcatenate":
|
|
size += len(part) + (len(delimiter) if parts else 0)
|
|
if size > self.max_text_length:
|
|
raise _Unresolved(
|
|
"Resolved text exceeds the length limit", "text-limit"
|
|
)
|
|
parts.append(part)
|
|
text = self.built(delimiter.join(parts))
|
|
storage_text = text
|
|
elif self.semantics is not None and self.follows(name, slot, kind):
|
|
text, path, storage_text = self.semantic(
|
|
node_id, name, slot, kind, inputs, depth
|
|
)
|
|
else:
|
|
raise self.unsupported(
|
|
"Unsupported node semantics or output slot", node_id
|
|
)
|
|
# Memoized paths stay bounded even for diamond-shaped string graphs.
|
|
self.scan(len(path) + 1)
|
|
path = [{"node_id": node_id, "output_slot": slot}] + path
|
|
if self.legacy:
|
|
path_limit = self.max_depth * max(1, len(self.graph))
|
|
else:
|
|
path_limit = self.max_depth * max(1, self.per_candidate_visits)
|
|
if self.path_entries is not None:
|
|
self.path_entries -= len(path)
|
|
if len(path) > path_limit or (
|
|
self.path_entries is not None and self.path_entries < 0
|
|
):
|
|
raise _Unresolved("Provenance path limit reached", "work-limit")
|
|
if not self.tainted:
|
|
self.memo[key] = (text, path, storage_text)
|
|
self.costs[key] = allowance - self.budget.allowance
|
|
self.heights[key] = self.deepest - depth
|
|
return text, path, storage_text
|
|
finally:
|
|
self.deepest = max(outer_deepest, self.deepest)
|
|
self.tainted = outer_tainted or self.tainted
|
|
self.active.discard(key)
|
|
|
|
def historical(
|
|
self, adapter: OutputAdapter, inputs: dict, depth: int
|
|
) -> Tuple[str, list, str]:
|
|
"""``prompt_manager_*.py`` at 07a5af3: strip, then join with ", ".
|
|
|
|
Prepend and append are stripped and skipped when empty; the separator is
|
|
left out when either side is empty or already ends with a comma. The
|
|
stripped main text is the recipe.
|
|
"""
|
|
text, path, _ = self.text(
|
|
inputs.get(adapter.input_name, adapter.default), "string", depth + 1
|
|
)
|
|
self.scan(len(text))
|
|
text = text.strip()
|
|
storage_text = text
|
|
if adapter.comma:
|
|
before, before_path, _ = self.text(
|
|
inputs.get("prepend_text", ""), "string", depth + 1
|
|
)
|
|
after, after_path, _ = self.text(
|
|
inputs.get("append_text", ""), "string", depth + 1
|
|
)
|
|
self.scan(len(before) + len(after) + len(before_path) + len(after_path))
|
|
before, after = before.strip(), after.strip()
|
|
if before:
|
|
joint = ", " if text and not before.endswith(",") else ""
|
|
text = f"{before}{joint}{text}"
|
|
if after:
|
|
joint = ", " if text and not text.endswith(",") else ""
|
|
text = f"{text}{joint}{after}"
|
|
text = self.built(self.bounded(text))
|
|
path = before_path + path + after_path
|
|
return text, path, storage_text
|
|
|
|
def follows(self, name: str, slot: int, kind: str) -> bool:
|
|
"""Whether the semantics table traces this output (inspection only)."""
|
|
semantics = self.semantics
|
|
if name in semantics.runtime:
|
|
return True
|
|
if (name, slot) in semantics.encoders or (name, slot) in semantics.strings:
|
|
return True
|
|
if slot == 0 and name in semantics.selectors:
|
|
return True
|
|
return kind == "conditioning" and (
|
|
(name, slot) in semantics.conditioning
|
|
or (slot == 0 and name in semantics.composites)
|
|
)
|
|
|
|
def semantic(
|
|
self, node_id: str, name: str, slot: int, kind: str, inputs: dict, depth: int
|
|
) -> Tuple[str, list, str]:
|
|
"""Trace a table-listed output: runtime, encoder, string, pass-through,
|
|
zeroed, selector or composite.
|
|
|
|
The caller (``text``) prepends this node to the returned path.
|
|
"""
|
|
semantics = self.semantics
|
|
if name in semantics.runtime:
|
|
raise self.named(_RUNTIME_REASON, "runtime-value", node_id)
|
|
for table, expected, build in (
|
|
(semantics.encoders, "conditioning", self.encoded),
|
|
(semantics.strings, "string", self.stringed),
|
|
):
|
|
listed = table.get((name, slot))
|
|
if listed is not None:
|
|
if kind != expected:
|
|
raise _Unresolved(_INCOMPATIBLE_SLOT, "malformed")
|
|
return build(node_id, listed, inputs, depth)
|
|
entry = (
|
|
semantics.conditioning.get((name, slot)) if kind == "conditioning" else None
|
|
)
|
|
if entry is not None and entry["strategy"] == "zeroed":
|
|
# The upstream text is discarded, so it is not traced.
|
|
self.text_sources[(node_id, slot)] = _ZEROED_SOURCE
|
|
return "", [], ""
|
|
if entry is not None: # passthrough: one input carries the text
|
|
traced = self.text(inputs.get(entry["input"]), kind, depth + 1)
|
|
self.inherit_source(node_id, slot, [traced[1]])
|
|
return traced
|
|
if name in semantics.selectors:
|
|
return self.select(node_id, semantics.selectors[name], inputs, kind, depth)
|
|
parts = [
|
|
self.text(inputs.get(input_name), kind, depth + 1)
|
|
for input_name in semantics.composites[name]["inputs"]
|
|
]
|
|
if len({part[0] for part in parts}) != 1:
|
|
# Several texts are in effect at once: not one sendable prompt.
|
|
raise self.named(
|
|
"Combined conditioning has different texts", "composite", node_id
|
|
)
|
|
return self.merged(node_id, slot, parts)
|
|
|
|
def select(
|
|
self, node_id: str, entry: Mapping, inputs: dict, kind: str, depth: int
|
|
) -> Tuple[str, list, str]:
|
|
"""A literal condition picks one branch; a linked one is a runtime choice.
|
|
|
|
A runtime choice traces both branches. Equal texts converge; different
|
|
texts raise ``_Diverging`` unless this choice is already forced.
|
|
"""
|
|
condition = inputs.get(entry["condition"])
|
|
branches = entry["branches"]
|
|
if type(condition) is bool:
|
|
choice: Optional[bool] = condition
|
|
elif not _link(condition):
|
|
raise _Unresolved("Malformed selector condition", "malformed")
|
|
elif (str(condition[0]), condition[1]) in self.choices:
|
|
choice = self.choices[(str(condition[0]), condition[1])]
|
|
self.tainted = True
|
|
else:
|
|
choice = None
|
|
if choice is not None:
|
|
branch = branches["true" if choice else "false"]
|
|
traced = self.text(inputs.get(branch), kind, depth + 1)
|
|
self.inherit_source(node_id, 0, [traced[1]])
|
|
return traced
|
|
parts = [
|
|
self.text(inputs.get(branches[label]), kind, depth + 1)
|
|
for label in ("true", "false")
|
|
]
|
|
if parts[0][0] != parts[1][0]:
|
|
raise _Diverging((str(condition[0]), condition[1]))
|
|
return self.merged(node_id, 0, parts)
|
|
|
|
def merged(self, node_id: str, slot: int, parts: list) -> Tuple[str, list, str]:
|
|
"""One text reached through several followed paths (deduplicated)."""
|
|
text, merged, storage_text = self.joined_paths(parts, parts)
|
|
self.inherit_source(node_id, slot, [part[1] for part in parts])
|
|
return text, merged, storage_text
|
|
|
|
def joined_paths(self, chosen: list, parts: list) -> Tuple[str, list, str]:
|
|
"""``chosen``'s one text with every part's followed path, deduplicated."""
|
|
text = chosen[0][0]
|
|
storages = {part[2] for part in chosen}
|
|
storage_text = chosen[0][2] if len(storages) == 1 else text
|
|
paths = [part[1] for part in parts]
|
|
self.scan(sum(len(path) for path in paths))
|
|
merged, seen = [], set()
|
|
for path in paths:
|
|
for step in path:
|
|
identity = (step["node_id"], step["output_slot"])
|
|
if identity not in seen:
|
|
seen.add(identity)
|
|
merged.append(step)
|
|
return text, merged, storage_text
|
|
|
|
def encoded(
|
|
self, node_id: str, entry: Mapping, inputs: dict, depth: int
|
|
) -> Tuple[str, list, str]:
|
|
"""A text encoder's prompt (``single`` or ``parts-equal``).
|
|
|
|
``single`` reads one string input unchanged; image, VAE and other inputs
|
|
are not text. ``parts-equal`` reads every listed part: identical parts
|
|
are that text, exactly one non-empty part (exact ``""`` emptiness) is
|
|
that text, and anything else is several texts in effect at once
|
|
(``composite``, D6). Like ``CLIPTextEncode``, an encoder's text source
|
|
stays ``graph-inferred``.
|
|
"""
|
|
names = entry["inputs"]
|
|
if entry["strategy"] == "single":
|
|
return self.text(inputs.get(names[0]), "string", depth + 1)
|
|
if entry["strategy"] != "parts-equal":
|
|
raise self.unsupported("Unsupported encoder strategy", node_id)
|
|
parts = [self.text(inputs.get(name), "string", depth + 1) for name in names]
|
|
# Comparing the parts reads every character once.
|
|
self.scan(sum(len(part[0]) for part in parts))
|
|
if len({part[0] for part in parts}) == 1:
|
|
return self.joined_paths(parts, parts)
|
|
filled = [part for part in parts if part[0] != ""]
|
|
if len(filled) == 1:
|
|
return self.joined_paths(filled, parts)
|
|
raise self.named("Encoder parts have different texts", "composite", node_id)
|
|
|
|
def stringed(
|
|
self, node_id: str, entry: Mapping, inputs: dict, depth: int
|
|
) -> Tuple[str, list, str]:
|
|
"""A string source: ``literal``, ``strip-newlines`` or ``join-multi``.
|
|
|
|
Each reproduces the pinned node's code exactly, and only from literal
|
|
settings; a linked setting is never guessed.
|
|
"""
|
|
strategy = entry["strategy"]
|
|
source = entry["inputs"][0]
|
|
if strategy == "literal":
|
|
return self.text(inputs.get(source), "string", depth + 1)
|
|
if strategy == "strip-newlines":
|
|
# KJNodes StringConstantMultiline: replace("\n", "").strip().
|
|
flag = self.setting(node_id, inputs, entry["params"]["flag"], bool)
|
|
text, path, storage_text = self.text(
|
|
inputs.get(source), "string", depth + 1
|
|
)
|
|
if not flag:
|
|
return text, path, storage_text
|
|
self.scan(len(text))
|
|
text = self.built(self.bounded(text.replace("\n", "").strip()))
|
|
return text, path, text
|
|
if strategy == "join-multi":
|
|
return self.join_multi(node_id, entry, inputs, depth)
|
|
raise self.unsupported("Unsupported string strategy", node_id)
|
|
|
|
def setting(self, node_id: str, inputs: dict, name: str, kind: type) -> Any:
|
|
"""A literal node setting; linked is ``unsupported-node``, else malformed."""
|
|
if name not in inputs:
|
|
raise _Unresolved("Missing string node setting", "malformed")
|
|
value = inputs[name]
|
|
if _link(value):
|
|
raise self.unsupported("Linked string node setting", node_id)
|
|
if type(value) is not kind:
|
|
raise _Unresolved("Malformed string node setting", "malformed")
|
|
return value
|
|
|
|
def join_multi(
|
|
self, node_id: str, entry: Mapping, inputs: dict, depth: int
|
|
) -> Tuple[str, list, str]:
|
|
"""KJNodes JoinStringMulti with a literal ``return_list`` of false.
|
|
|
|
``string_1`` is always kept (even empty); ``string_2`` .. ``string_N``
|
|
(``N`` = ``inputcount``) are appended after the literal delimiter unless
|
|
missing or exactly ``""``; inputs past ``N`` are ignored. A list output
|
|
(or a linked flag) is decided at run time.
|
|
"""
|
|
params = entry["params"]
|
|
flag = params["list_flag"]
|
|
if flag not in inputs:
|
|
raise _Unresolved("Missing string node setting", "malformed")
|
|
if _link(inputs[flag]) or inputs[flag] is True:
|
|
raise self.named(_RUNTIME_REASON, "runtime-value", node_id)
|
|
if inputs[flag] is not False:
|
|
raise _Unresolved("Malformed string node setting", "malformed")
|
|
count = self.setting(node_id, inputs, params["count"], int)
|
|
delimiter = self.setting(node_id, inputs, params["delimiter"], str)
|
|
if not 1 <= count <= _MAX_JOIN_INPUTS:
|
|
raise _Unresolved("Malformed string node setting", "malformed")
|
|
self.scan(count)
|
|
names = [entry["inputs"][0]] + [
|
|
f"{params['input_prefix']}{index}" for index in range(2, count + 1)
|
|
]
|
|
parts: list = []
|
|
path: list = []
|
|
seen: set = set()
|
|
size = 0
|
|
for position, input_name in enumerate(names):
|
|
if position and input_name not in inputs:
|
|
continue
|
|
part, followed, _ = self.text(inputs.get(input_name), "string", depth + 1)
|
|
self.scan(len(part) + len(followed))
|
|
for step in followed: # one link read by several parts is listed once
|
|
identity = (step["node_id"], step["output_slot"])
|
|
if identity not in seen:
|
|
seen.add(identity)
|
|
path.append(step)
|
|
if position and not part:
|
|
continue
|
|
size += len(part) + (len(delimiter) if parts else 0)
|
|
if size > self.max_text_length:
|
|
raise _Unresolved(
|
|
"Resolved text exceeds the length limit", "text-limit"
|
|
)
|
|
parts.append(part)
|
|
text = self.built(delimiter.join(parts))
|
|
return text, path, text
|
|
|
|
def source_of(self, path: list) -> str:
|
|
if not path:
|
|
return "graph-inferred"
|
|
origin = path[0]
|
|
return self.text_sources.get(
|
|
(origin["node_id"], origin["output_slot"]), "graph-inferred"
|
|
)
|
|
|
|
def inherit_source(self, node_id: str, slot: int, paths: list) -> None:
|
|
"""A forwarding node reports its input's text source when unambiguous."""
|
|
sources = {self.source_of(path) for path in paths}
|
|
if len(sources) == 1:
|
|
self.text_sources[(node_id, slot)] = sources.pop()
|
|
else:
|
|
self.text_sources.pop((node_id, slot), None)
|
|
|
|
def unverifiable(self, error: _Unresolved) -> bool:
|
|
"""Whether a failed input trace only means "cannot be read statically".
|
|
|
|
Such an input is not checked against a pair's execution record. The
|
|
default profile keeps its historical message test; inspection decides
|
|
by code, so every unsupported or run-time source qualifies.
|
|
"""
|
|
if self.semantics is None:
|
|
return str(error) == "Unsupported node semantics or output slot"
|
|
return error.code in _UNVERIFIABLE_CODES
|
|
|
|
def execution_record(self, node_id: str, inputs: dict) -> Optional[dict]:
|
|
try:
|
|
record = read_pair_execution(self.metadata, node_id)
|
|
except ExecutionEvidenceError as error:
|
|
raise _Unresolved(str(error), "malformed") from error
|
|
if record is None:
|
|
return None
|
|
for name in ("text", "negative_text", "prepend_text", "append_text"):
|
|
try:
|
|
expected = self.text(inputs.get(name, ""), "string")[0]
|
|
except _Unresolved as error:
|
|
if self.unverifiable(error):
|
|
value = inputs.get(name)
|
|
if _link(value):
|
|
source_name, _ = self.node(str(value[0]))
|
|
known_slots = {
|
|
slot
|
|
for (kind, slot) in self.adapters
|
|
if kind == source_name
|
|
}
|
|
if known_slots and value[1] not in known_slots:
|
|
raise
|
|
if (
|
|
source_name
|
|
in ("StringConcatenate", "Text Concatenate", "Reroute")
|
|
and value[1] != 0
|
|
):
|
|
raise
|
|
continue
|
|
raise
|
|
if name in ("prepend_text", "append_text"):
|
|
expected = expected.strip()
|
|
if expected != record[name]:
|
|
raise _Unresolved(
|
|
"Execution evidence does not match recorded node inputs",
|
|
"malformed",
|
|
)
|
|
return record
|
|
|
|
def side(
|
|
self,
|
|
value: Any,
|
|
sampler_id: str,
|
|
guider_id: Optional[str] = None,
|
|
depth: int = 0,
|
|
) -> dict:
|
|
text, path, storage_text = self.text(value, "conditioning", depth)
|
|
origin = path[0]
|
|
provenance = {
|
|
"format": "comfyui",
|
|
"sampler_id": sampler_id,
|
|
**origin,
|
|
"path": path,
|
|
"text_source": self.text_sources.get(
|
|
(origin["node_id"], origin["output_slot"]), "graph-inferred"
|
|
),
|
|
}
|
|
if guider_id is not None:
|
|
# The conditioning path starts below the guider the sampler reads.
|
|
provenance["guider_id"] = guider_id
|
|
return {"text": text, "storage_text": storage_text, "provenance": provenance}
|
|
|
|
def guider(self, value: Any) -> Tuple[str, dict, Mapping, str, int]:
|
|
"""Follow a sampler's ``guider`` link (through Reroutes) to a listed guider.
|
|
|
|
Returns the guider's ID and inputs, its conditioning input names, the
|
|
reason used when it has no negative input, and the depth reached. Each
|
|
hop draws a visit; an unlisted guider class is ``unsupported-node``.
|
|
"""
|
|
seen: set = set()
|
|
depth = 0
|
|
while True:
|
|
if depth >= self.max_depth:
|
|
raise _Unresolved("Resolution depth limit reached", "depth-limit")
|
|
if not _link(value):
|
|
raise _Unresolved("Expected a guider link", "malformed")
|
|
node_id, slot = str(value[0]), value[1]
|
|
if node_id in seen:
|
|
raise _Unresolved("Cyclic guider ancestry", "malformed")
|
|
seen.add(node_id)
|
|
self.budget.spend()
|
|
name, inputs = self.node(node_id)
|
|
self.scan(len(inputs))
|
|
depth += 1
|
|
if name == "Reroute" and slot == 0:
|
|
keys = [key for key in ("value", "input", "in") if key in inputs]
|
|
if len(keys) != 1:
|
|
raise _Unresolved(
|
|
"Reroute input is not uniquely identifiable", "malformed"
|
|
)
|
|
value = inputs[keys[0]]
|
|
continue
|
|
entry = self.semantics.guiders.get(name)
|
|
if entry is None or slot != 0:
|
|
raise self.unsupported("Unsupported guider", node_id)
|
|
conditioning = {
|
|
"positive": entry["positive"],
|
|
"negative": entry.get("negative"),
|
|
}
|
|
reason = entry.get("absent_reason", _GUIDER_ABSENT_REASON)
|
|
return node_id, inputs, conditioning, reason, depth
|
|
|
|
def sampler_entry(self, name: str) -> Mapping:
|
|
if self.semantics is None:
|
|
return _DIRECT_SAMPLER
|
|
return self.semantics.samplers[name]
|
|
|
|
def pair(self, sampler_id: str) -> dict:
|
|
self.budget.reset(self.per_candidate_visits)
|
|
self.latent_charged = set()
|
|
if not self.legacy and self.budget.total <= 0:
|
|
return _result(
|
|
"unresolved",
|
|
sampler_id=sampler_id,
|
|
reason="Resolution visit limit reached",
|
|
reason_code="work-limit",
|
|
)
|
|
if self.semantics is None:
|
|
return self.sides(sampler_id)
|
|
return self.branches(sampler_id)
|
|
|
|
def stage(self, sampler_id: str, later: frozenset) -> dict:
|
|
"""One sampler's result; a detailer is compared with its upstream stages.
|
|
|
|
``later`` holds the detailers already being judged below this one, so a
|
|
cyclic stage ancestry is malformed instead of recursing, and a chain
|
|
deeper than ``max_depth`` stages is ``depth-limit``.
|
|
"""
|
|
if len(later) >= self.max_depth:
|
|
return _result(
|
|
"unresolved",
|
|
sampler_id=sampler_id,
|
|
reason="Resolution depth limit reached",
|
|
reason_code="depth-limit",
|
|
)
|
|
own = self.sides(sampler_id)
|
|
if own["state"] == "unresolved":
|
|
return own
|
|
name, inputs = self.node(sampler_id)
|
|
entry = self.semantics.samplers.get(name)
|
|
if entry is None or entry["strategy"] != _DETAILER:
|
|
return own
|
|
try:
|
|
upstream = self.upstream_stages(
|
|
sampler_id, inputs.get(entry["image_stage"])
|
|
)
|
|
if any(node_id in later for node_id in upstream):
|
|
raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
|
|
except _Unresolved as error:
|
|
return _result(
|
|
"unresolved",
|
|
sampler_id=sampler_id,
|
|
reason=str(error),
|
|
reason_code=error.code,
|
|
node=error.node,
|
|
)
|
|
if not upstream:
|
|
return own
|
|
later = later | {sampler_id}
|
|
stages = [self.stage(node_id, later) for node_id in upstream]
|
|
blocked = next((item for item in stages if item["state"] == "unresolved"), None)
|
|
if blocked is not None:
|
|
# A stage that cannot be read cannot be compared: never pick one.
|
|
return _result(
|
|
"unresolved",
|
|
sampler_id=sampler_id,
|
|
reason=blocked["reason"],
|
|
reason_code=blocked["reason_code"],
|
|
node=blocked["node"],
|
|
)
|
|
flat: list = []
|
|
for item in [own] + stages:
|
|
flat.extend(item["candidates"] if item["state"] == "ambiguous" else [item])
|
|
if len({_outcome_key(item) for item in flat}) == 1:
|
|
return own
|
|
return _result(
|
|
"ambiguous",
|
|
sampler_id=sampler_id,
|
|
reason="Sampler stages have different prompts",
|
|
candidates=flat,
|
|
reason_code="multistage-differs",
|
|
)
|
|
|
|
@staticmethod
|
|
def walk_from(value: list) -> Tuple[deque, set]:
|
|
"""A breadth-first queue holding one start link, and its queued IDs."""
|
|
start = str(value[0])
|
|
return deque([(start, 0)]), {start}
|
|
|
|
def enqueue(self, queue: deque, queued: set, inputs: dict, depth: int) -> None:
|
|
"""Queue each linked input once per walk; every edge is charged as scanned.
|
|
|
|
Deduplicating at enqueue keeps a dense shared fan-in to one entry per
|
|
node, so the queue never outgrows the nodes the visit budget pays for.
|
|
Breadth-first, a node's first enqueue is at its least depth, as before.
|
|
"""
|
|
self.scan(len(inputs))
|
|
for item in inputs.values():
|
|
if _link(item):
|
|
key = str(item[0])
|
|
if key not in queued:
|
|
queued.add(key)
|
|
queue.append((key, depth))
|
|
|
|
def stage_shaped(self, name: str, inputs: dict) -> bool:
|
|
"""An unlisted node that may itself be a sampler stage."""
|
|
if name in self.semantics.all_classes:
|
|
return False
|
|
stage_inputs = self.semantics.stage_inputs
|
|
if any(_link(inputs.get(key)) for key in stage_inputs["shaped"]):
|
|
return True
|
|
return _link(inputs.get("model")) and any(
|
|
_link(inputs.get(key)) for key in stage_inputs["with_model"]
|
|
)
|
|
|
|
def upstream_stages(self, origin: str, value: Any) -> list:
|
|
"""Samplers reached from a detailer's image input, in breadth-first order.
|
|
|
|
Every link input of non-sampler nodes is walked (decoders, upscalers,
|
|
compositors), stopping at listed samplers. Each node draws a visit.
|
|
"""
|
|
if not _link(value):
|
|
raise _Unresolved("Expected an image link", "malformed")
|
|
found: list = []
|
|
queue, queued = self.walk_from(value)
|
|
while queue:
|
|
node_id, depth = queue.popleft()
|
|
if node_id == origin:
|
|
raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
|
|
if depth >= self.max_depth:
|
|
raise _Unresolved("Resolution depth limit reached", "depth-limit")
|
|
self.budget.spend()
|
|
name, inputs = self.node(node_id)
|
|
if name in self.stage_classes:
|
|
found.append(node_id)
|
|
continue
|
|
if self.stage_shaped(name, inputs):
|
|
raise self.unsupported("Unsupported sampler stage ancestry", node_id)
|
|
self.enqueue(queue, queued, inputs, depth + 1)
|
|
return found
|
|
|
|
def final_stages(self, sampler_ids: list) -> list:
|
|
"""Drop samplers that only feed detailers: each is judged in that chain.
|
|
|
|
A sampler whose output also reaches a terminal node other than a later
|
|
stage or a preview stays a candidate of its own (the file may be its
|
|
output). Any walk that fails or runs out of budget absorbs nothing.
|
|
"""
|
|
detailers = [
|
|
node_id
|
|
for node_id in sampler_ids
|
|
if self.semantics.samplers[self.graph[node_id]["class_type"]]["strategy"]
|
|
== _DETAILER
|
|
]
|
|
if not detailers:
|
|
return sampler_ids
|
|
try:
|
|
consumers = self.consumers()
|
|
except _Unresolved:
|
|
return sampler_ids
|
|
absorbed: set = set()
|
|
for detailer in detailers:
|
|
self.budget.reset(self.per_candidate_visits)
|
|
try:
|
|
name, inputs = self.node(detailer)
|
|
image = inputs.get(self.semantics.samplers[name]["image_stage"])
|
|
for node_id in self.upstream_stages(detailer, image):
|
|
if not self.reaches_output(node_id, consumers):
|
|
absorbed.add(node_id)
|
|
except _Unresolved:
|
|
continue
|
|
kept = [node_id for node_id in sampler_ids if node_id not in absorbed]
|
|
return kept or sampler_ids
|
|
|
|
def consumers(self) -> dict:
|
|
"""Reverse links: node ID to the IDs of nodes reading one of its outputs."""
|
|
consumers: dict = {}
|
|
for node_id, node in self.graph.items():
|
|
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
|
|
continue
|
|
self.scan(len(node["inputs"]))
|
|
for value in node["inputs"].values():
|
|
if _link(value):
|
|
consumers.setdefault(str(value[0]), []).append(node_id)
|
|
return consumers
|
|
|
|
def reaches_output(self, node_id: str, consumers: dict) -> bool:
|
|
"""Whether a sampler's output reaches a terminal node besides later stages."""
|
|
seen, queue = {node_id}, deque([node_id])
|
|
while queue:
|
|
current = queue.popleft()
|
|
for consumer in consumers.get(current, ()):
|
|
if consumer in seen:
|
|
continue
|
|
seen.add(consumer)
|
|
self.budget.spend()
|
|
name = str(self.graph[consumer].get("class_type", ""))
|
|
if (
|
|
name in self.stage_classes
|
|
or name in self.semantics.non_file_outputs
|
|
):
|
|
continue
|
|
if not consumers.get(consumer):
|
|
return True
|
|
queue.append(consumer)
|
|
return False
|
|
|
|
def branches(self, sampler_id: str) -> dict:
|
|
"""Resolve once per runtime selector choice that changes a role text.
|
|
|
|
Every run draws from the same candidate allowance. Results that agree
|
|
collapse to one; otherwise the sampler is ``ambiguous`` with one
|
|
candidate per choice set, in true-before-false order.
|
|
"""
|
|
pending: list = [{}]
|
|
results: list = []
|
|
try:
|
|
while pending:
|
|
self.choices = pending.pop(0)
|
|
try:
|
|
results.append(self.stage(sampler_id, frozenset()))
|
|
except _Diverging as diverging:
|
|
if len(results) + len(pending) + 2 > _MAX_BRANCH_CANDIDATES:
|
|
raise _Unresolved("Too many selector branches", "work-limit")
|
|
pending.extend(
|
|
{**self.choices, diverging.key: choice}
|
|
for choice in (True, False)
|
|
)
|
|
except _Unresolved as error:
|
|
return _result(
|
|
"unresolved",
|
|
sampler_id=sampler_id,
|
|
reason=str(error),
|
|
reason_code=error.code,
|
|
node=error.node,
|
|
)
|
|
finally:
|
|
self.choices = {}
|
|
if all(item["state"] == "unresolved" for item in results):
|
|
return results[0]
|
|
if len({_outcome_key(item) for item in results}) == 1:
|
|
return results[0]
|
|
return _result(
|
|
"ambiguous",
|
|
sampler_id=sampler_id,
|
|
reason="Selector branches have different prompts",
|
|
candidates=results,
|
|
)
|
|
|
|
def sides(self, sampler_id: str) -> dict:
|
|
positive = negative = None
|
|
try:
|
|
name, inputs = self.node(sampler_id)
|
|
entry = self.sampler_entry(name)
|
|
self.check_overrides(sampler_id, entry, inputs)
|
|
# A second sampler in the latent ancestry denotes a multistage run;
|
|
# associating the final image with one prompt pair would be a guess.
|
|
latent = entry.get("latent")
|
|
if latent is not None and latent in inputs:
|
|
if self.semantics is None:
|
|
self.latent_source(inputs[latent], set(), 0)
|
|
else:
|
|
self.latent_checked(sampler_id, inputs[latent])
|
|
guider_id, depth = None, 0
|
|
conditioning = entry["conditioning"]
|
|
absent_reason = "Negative input is absent"
|
|
if entry["strategy"] == "custom-advanced":
|
|
guider_id, inputs, conditioning, guider_reason, depth = self.guider(
|
|
inputs.get(conditioning["guider"])
|
|
)
|
|
if conditioning["negative"] is None:
|
|
absent_reason = guider_reason
|
|
positive = self.side(
|
|
inputs.get(conditioning["positive"]), sampler_id, guider_id, depth
|
|
)
|
|
if not positive["text"].strip():
|
|
raise _Unresolved(EMPTY_POSITIVE_REASON)
|
|
negative_name = conditioning["negative"]
|
|
if negative_name is None or negative_name not in inputs:
|
|
return _result(
|
|
"absent",
|
|
positive,
|
|
sampler_id=sampler_id,
|
|
reason=absent_reason,
|
|
)
|
|
negative = self.side(inputs[negative_name], sampler_id, guider_id, depth)
|
|
return _result("resolved", positive, negative, sampler_id)
|
|
except _Unresolved as error:
|
|
return _result(
|
|
"unresolved",
|
|
positive,
|
|
negative,
|
|
sampler_id,
|
|
str(error),
|
|
reason_code=error.code,
|
|
node=error.node,
|
|
)
|
|
|
|
def check_overrides(self, sampler_id: str, entry: Mapping, inputs: dict) -> None:
|
|
"""A set prompt override replaces the conditioning inputs' prompt.
|
|
|
|
Exact ``""`` (or a missing input) leaves the prompt alone. A literal is
|
|
processed at run time (a detailer wildcard); a link is a source the
|
|
inspector does not follow, so it is named.
|
|
"""
|
|
for name in entry.get("prompt_overrides", ()):
|
|
value = inputs.get(name)
|
|
if value is None or value == "":
|
|
continue
|
|
if _link(value):
|
|
raise self.unsupported("Unsupported prompt override", str(value[0]))
|
|
raise self.named(_RUNTIME_REASON, "runtime-value", sampler_id)
|
|
|
|
def latent_source(self, value: Any, seen: set, depth: int) -> None:
|
|
if not _link(value) or depth >= self.max_depth:
|
|
code = "malformed" if not _link(value) else "depth-limit"
|
|
raise _Unresolved("Unknown latent ancestry", code)
|
|
node_id, slot = str(value[0]), value[1]
|
|
if self.legacy:
|
|
if node_id in seen or len(seen) >= max(0, self.budget.remaining):
|
|
raise _Unresolved("Cyclic or excessive latent ancestry")
|
|
elif node_id in seen:
|
|
raise _Unresolved("Cyclic or excessive latent ancestry", "malformed")
|
|
else:
|
|
self.budget.spend()
|
|
seen.add(node_id)
|
|
name, inputs = self.node(node_id)
|
|
if name in self.stage_classes:
|
|
# ``multistage-differs`` is reserved for when stage texts are compared.
|
|
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
|
|
if name in ("EmptyLatentImage", "EmptySD3LatentImage") and slot == 0:
|
|
return
|
|
if name == "Reroute" and slot == 0 and "value" in inputs:
|
|
self.latent_source(inputs["value"], seen, depth + 1)
|
|
return
|
|
raise self.unsupported("Unsupported latent ancestry", node_id)
|
|
|
|
def latent_checked(self, sampler_id: str, value: Any) -> None:
|
|
"""``latent_stages`` once per sampler per resolution.
|
|
|
|
A candidate reusing another candidate's walk is charged its visit cost
|
|
(as the text memo does), so outcomes stay independent of order; branch
|
|
runs of one candidate share its allowance and pay once. A walk that ran
|
|
out of work is never reused, since it depends on the remaining budget.
|
|
"""
|
|
if sampler_id in self.latent_walks:
|
|
error, cost = self.latent_walks[sampler_id]
|
|
if sampler_id not in self.latent_charged:
|
|
self.budget.charge(cost)
|
|
self.latent_charged.add(sampler_id)
|
|
else:
|
|
before = self.budget.allowance
|
|
try:
|
|
self.latent_stages(sampler_id, value)
|
|
error = None
|
|
except _Unresolved as raised:
|
|
if raised.code == "work-limit":
|
|
raise
|
|
error = raised
|
|
self.latent_walks[sampler_id] = (error, before - self.budget.allowance)
|
|
self.latent_charged.add(sampler_id)
|
|
if error is not None:
|
|
node = dict(error.node) if error.node is not None else None
|
|
raise _Unresolved(str(error), error.code, node)
|
|
|
|
def latent_stages(self, origin: str, value: Any) -> None:
|
|
"""Semantics table: is there an earlier sampler in the latent ancestry?
|
|
|
|
Every link input of non-sampler nodes is walked breadth-first (encoders,
|
|
decoders, masks, conditioning), stopping at listed samplers; each node
|
|
draws a visit. No sampler found means a single stage whatever the origin
|
|
(VAEEncode, EmptyLatentBatch, WanImageToVideo slot 2, ...). An unlisted
|
|
node with sampler-shaped inputs could itself be a stage, so it is
|
|
``unsupported-node``. Budget or depth exhaustion is never single stage.
|
|
"""
|
|
if not _link(value):
|
|
raise _Unresolved("Unknown latent ancestry", "malformed")
|
|
queue, queued = self.walk_from(value)
|
|
while queue:
|
|
node_id, depth = queue.popleft()
|
|
if node_id == origin:
|
|
raise _Unresolved("Cyclic latent ancestry", "malformed")
|
|
if depth >= self.max_depth:
|
|
raise _Unresolved("Resolution depth limit reached", "depth-limit")
|
|
self.budget.spend()
|
|
name, inputs = self.node(node_id)
|
|
if name in self.stage_classes:
|
|
# ``multistage-differs`` is reserved for when stage texts are
|
|
# compared (PR3); until then no stage's prompt is chosen.
|
|
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
|
|
if self.stage_shaped(name, inputs):
|
|
raise self.unsupported("Unsupported latent ancestry", node_id)
|
|
self.enqueue(queue, queued, inputs, depth + 1)
|
|
|
|
def anchor(self, node_id: str) -> str:
|
|
seen = set()
|
|
slot = 0
|
|
while len(seen) < self.max_depth and (
|
|
not self.legacy or len(seen) < self.budget.remaining
|
|
):
|
|
if node_id in seen:
|
|
raise _Unresolved("Cyclic output ancestry", "malformed")
|
|
if not self.legacy:
|
|
self.budget.spend()
|
|
seen.add(node_id)
|
|
name, inputs = self.node(node_id)
|
|
if name in self.sampler_classes and slot == 0:
|
|
return node_id
|
|
key = {
|
|
"SaveImage": "images",
|
|
"PreviewImage": "images",
|
|
"VAEDecode": "samples",
|
|
"VAEDecodeTiled": "samples",
|
|
"Reroute": "value",
|
|
}.get(name)
|
|
value = inputs.get(key) if key and slot == 0 else None
|
|
if not _link(value):
|
|
raise self.unsupported("Unsupported output ancestry", node_id)
|
|
node_id, slot = str(value[0]), value[1]
|
|
code = "depth-limit" if len(seen) >= self.max_depth else "work-limit"
|
|
raise _Unresolved("Output ancestry limit reached", code)
|
|
|
|
|
|
_FOOTER = re.compile(r"(?m)^Steps:\s*\d+\s*,[^\n]*$")
|
|
# Inspection also reads a footer of ``Steps: N`` alone (no other settings).
|
|
_BARE_FOOTER = re.compile(r"(?m)^Steps:\s*\d+\s*$")
|
|
# sd-dynamic-prompts writes these one-line records after the settings footer.
|
|
_FOOTER_TRAILERS = ("Template:", "Negative Template:")
|
|
|
|
|
|
def _trailer_lines(text: str) -> bool:
|
|
"""Whether ``text`` after the footer is empty or only template records.
|
|
|
|
At most one ``Template:`` line, then at most one ``Negative Template:``
|
|
line; anything else (a multi-line template included) is not recognized.
|
|
"""
|
|
lines = re.split(r"\r?\n|\r", text.strip()) if text.strip() else []
|
|
prefixes = [
|
|
next(
|
|
(
|
|
index
|
|
for index, name in enumerate(_FOOTER_TRAILERS)
|
|
if line.startswith(name)
|
|
),
|
|
None,
|
|
)
|
|
for line in lines
|
|
]
|
|
return None not in prefixes and prefixes == sorted(set(prefixes))
|
|
|
|
|
|
def _footer(value: str, inspection: bool) -> Optional[re.Match]:
|
|
"""The unique A1111 settings footer, or ``None``.
|
|
|
|
The default profile keeps the historical rule (one ``Steps: N, ...`` line,
|
|
and it is the last line). Inspection (R5) accepts a bare ``Steps: N``
|
|
footer when there is no full one, and template records after it.
|
|
"""
|
|
footers = list(_FOOTER.finditer(value))
|
|
if inspection and not footers:
|
|
footers = list(_BARE_FOOTER.finditer(value))
|
|
if len(footers) != 1:
|
|
return None
|
|
rest = value[footers[0].end() :]
|
|
if inspection and _trailer_lines(rest):
|
|
return footers[0]
|
|
return None if rest.strip() else footers[0]
|
|
|
|
|
|
def _parameters(value: Any, max_text_length: int, inspection: bool = False) -> dict:
|
|
if not isinstance(value, str) or not value:
|
|
return _result("absent", reason="No A1111 parameters")
|
|
if len(value) > max_text_length:
|
|
return _result(
|
|
"unresolved",
|
|
reason="Parameters exceed the length limit",
|
|
reason_code="text-limit",
|
|
)
|
|
footer = _footer(value, inspection)
|
|
if footer is None:
|
|
return _result(
|
|
"unresolved",
|
|
reason="A1111 settings footer is not unique",
|
|
reason_code="malformed",
|
|
)
|
|
body = value[: footer.start()].rstrip("\r\n")
|
|
if inspection:
|
|
# R4: text that starts with the delimiter has an empty positive (#165).
|
|
body = "\n" + body
|
|
delimiter = "\nNegative prompt:"
|
|
if body.count(delimiter) > 1:
|
|
return _result("ambiguous", reason="Multiple negative prompt delimiters")
|
|
positive, found, negative = body.partition(delimiter)
|
|
positive = positive.strip()
|
|
if not positive:
|
|
return _result("unresolved", reason=EMPTY_POSITIVE_REASON)
|
|
provenance = {
|
|
"format": "a1111",
|
|
"field": "parameters",
|
|
"text_source": "embedded-parameters",
|
|
}
|
|
positive_side = {
|
|
"text": positive,
|
|
"storage_text": positive,
|
|
"provenance": {**provenance, "role": "positive"},
|
|
}
|
|
if not found:
|
|
return _result(
|
|
"absent", positive_side, reason="Negative prompt delimiter is absent"
|
|
)
|
|
negative_side = {
|
|
"text": negative.strip(),
|
|
"storage_text": negative.strip(),
|
|
"provenance": {**provenance, "role": "negative"},
|
|
}
|
|
return _result("resolved", positive_side, negative_side)
|
|
|
|
|
|
def _profile_with(
|
|
profile: ResolverProfile,
|
|
max_visits: Optional[int],
|
|
max_depth: Optional[int],
|
|
max_text_length: Optional[int],
|
|
) -> ResolverProfile:
|
|
"""Explicit ``max_*`` keywords override the profile they are given with."""
|
|
changes: Dict[str, Any] = {}
|
|
if max_visits is not None:
|
|
changes["per_candidate_visits"] = max_visits
|
|
if profile.legacy: # one historical number: gate, total and allowance
|
|
changes.update(total_visits=max_visits, legacy_node_gate=max_visits)
|
|
if max_depth is not None:
|
|
changes["max_depth"] = max_depth
|
|
if max_text_length is not None:
|
|
changes["max_text_length"] = max_text_length
|
|
return replace(profile, **changes) if changes else profile
|
|
|
|
|
|
def _sampler_like_classes(graph: dict) -> list:
|
|
"""Distinct classes, in graph order, of nodes shaped like samplers/guiders."""
|
|
names: list = []
|
|
for node in graph.values():
|
|
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
|
|
continue
|
|
name = node.get("class_type")
|
|
if not isinstance(name, str) or not name or name in names:
|
|
continue
|
|
if any(_link(node["inputs"].get(key)) for key in _SAMPLER_LIKE_INPUTS):
|
|
names.append(name[:_CLASS_NAME_LENGTH])
|
|
if len(names) == _NO_SAMPLER_NAMES:
|
|
break
|
|
return names
|
|
|
|
|
|
def resolve_prompt_pair(
|
|
metadata: Any,
|
|
*,
|
|
output_node_id: Optional[str] = None,
|
|
adapters: Optional[Mapping[Tuple[str, int], OutputAdapter]] = None,
|
|
profile: ResolverProfile = DEFAULT_PROFILE,
|
|
max_visits: Optional[int] = None,
|
|
max_depth: Optional[int] = None,
|
|
max_text_length: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Resolve parsed metadata; a present API graph is authoritative over text.
|
|
|
|
Accepts ``{"prompt": api_graph, "parameters": optional_a1111_text}`` or a
|
|
direct API graph. An optional saver/sampler node ID anchors branch selection.
|
|
Unanchored multiple samplers are ambiguous, even when their texts are equal.
|
|
``profile`` selects the bounds; explicit ``max_*`` keywords override it.
|
|
"""
|
|
profile = _profile_with(profile, max_visits, max_depth, max_text_length)
|
|
result = _resolve(metadata, output_node_id, adapters, profile)
|
|
return _legacy_result(result) if profile.legacy else result
|
|
|
|
|
|
SEMANTICS_UNAVAILABLE_REASON = "Inspection node semantics are unavailable"
|
|
|
|
|
|
def _inspection_semantics() -> Optional[Any]:
|
|
"""The validated node table, or ``None`` when it cannot be loaded.
|
|
|
|
Imported here, not at module level: ingestion (the default profile) must
|
|
keep working whatever state the table file is in.
|
|
"""
|
|
from .inspection_semantics import SemanticsError, get_semantics
|
|
|
|
try:
|
|
return get_semantics()
|
|
except SemanticsError as error:
|
|
_LOGGER.error("Inspection node semantics table is unusable: %s", error)
|
|
return None
|
|
|
|
|
|
def _limit_result(profile: ResolverProfile, graph: dict) -> Optional[Dict[str, Any]]:
|
|
if profile.legacy:
|
|
if (
|
|
len(graph) > profile.legacy_node_gate
|
|
or profile.max_depth <= 0
|
|
or profile.max_text_length <= 0
|
|
):
|
|
return _result("unresolved", reason="Resolution limit reached")
|
|
return None
|
|
if profile.node_ceiling is not None and len(graph) > profile.node_ceiling:
|
|
return _result(
|
|
"unresolved",
|
|
reason="Prompt graph exceeds the inspection node ceiling",
|
|
reason_code="graph-ceiling",
|
|
)
|
|
if profile.max_depth <= 0:
|
|
return _result(
|
|
"unresolved", reason="Resolution limit reached", reason_code="depth-limit"
|
|
)
|
|
if profile.max_text_length <= 0:
|
|
return _result(
|
|
"unresolved", reason="Resolution limit reached", reason_code="text-limit"
|
|
)
|
|
return None
|
|
|
|
|
|
def _resolve(
|
|
metadata: Any,
|
|
output_node_id: Optional[str],
|
|
adapters: Optional[Mapping[Tuple[str, int], OutputAdapter]],
|
|
profile: ResolverProfile,
|
|
) -> Dict[str, Any]:
|
|
if not isinstance(metadata, dict) or not metadata:
|
|
return _result("absent", reason="No parsed prompt metadata")
|
|
if "prompt" in metadata:
|
|
graph = metadata["prompt"]
|
|
elif "parameters" in metadata:
|
|
return _parameters(
|
|
metadata["parameters"], profile.max_text_length, not profile.legacy
|
|
)
|
|
else:
|
|
graph = metadata
|
|
if not isinstance(graph, dict) or not graph:
|
|
return _result(
|
|
"unresolved",
|
|
reason="API graph is missing or malformed",
|
|
reason_code="malformed",
|
|
)
|
|
limited = _limit_result(profile, graph)
|
|
if limited is not None:
|
|
return limited
|
|
semantics = None
|
|
if profile.semantics:
|
|
semantics = _inspection_semantics()
|
|
if semantics is None:
|
|
return _result("unresolved", reason=SEMANTICS_UNAVAILABLE_REASON)
|
|
normalized = {str(node_id): node for node_id, node in graph.items()}
|
|
if len(normalized) != len(graph):
|
|
return _result(
|
|
"unresolved",
|
|
reason="Graph node IDs are not unique",
|
|
reason_code="malformed",
|
|
)
|
|
overwritten = frozenset()
|
|
if semantics is not None and "prompt" in metadata:
|
|
overwritten = _overwritten_prompt_nodes(metadata.get("workflow"), normalized)
|
|
resolver = _GraphResolver(
|
|
normalized,
|
|
adapters or {},
|
|
profile.per_candidate_visits,
|
|
profile.max_depth,
|
|
profile.max_text_length,
|
|
metadata if "prompt" in metadata else None,
|
|
budget=_Budget(profile.total_visits),
|
|
profile=profile,
|
|
semantics=semantics,
|
|
overwritten=overwritten,
|
|
)
|
|
if output_node_id is not None:
|
|
try:
|
|
sampler_ids = [resolver.anchor(str(output_node_id))]
|
|
except _Unresolved as error:
|
|
return _result(
|
|
"unresolved", reason=str(error), reason_code=error.code, node=error.node
|
|
)
|
|
else:
|
|
sampler_ids = [
|
|
node_id
|
|
for node_id, node in normalized.items()
|
|
if isinstance(node, dict)
|
|
and isinstance(node.get("class_type"), str)
|
|
and node["class_type"] in resolver.sampler_classes
|
|
]
|
|
if semantics is not None:
|
|
sampler_ids = resolver.final_stages(sampler_ids)
|
|
if not sampler_ids:
|
|
return _no_sampler(normalized, profile)
|
|
if len(sampler_ids) > 1:
|
|
return _result(
|
|
"ambiguous",
|
|
reason="Multiple sampler branches",
|
|
candidates=[resolver.pair(node_id) for node_id in sampler_ids],
|
|
)
|
|
return resolver.pair(sampler_ids[0])
|
|
|
|
|
|
def _overwritten_prompt_nodes(workflow: Any, graph: dict) -> frozenset:
|
|
"""API node IDs whose saved PromptManager text is another node's (R1).
|
|
|
|
The pre-3.2.3 signature (comfy-metadata.js ``createContext``): two or more
|
|
workflow nodes of a PromptManager type other than ``PromptManagerPair``
|
|
share one API ``text`` while their first widget values differ. Their API
|
|
class may have been rewritten to ``CLIPTextEncode``, so nodes are matched by
|
|
ID. Unlike the legacy viewer, the widget text is never substituted: it was
|
|
not recorded as executed, so these nodes are refused instead.
|
|
"""
|
|
nodes = workflow.get("nodes") if isinstance(workflow, dict) else None
|
|
if not isinstance(nodes, list):
|
|
return frozenset()
|
|
widgets: Dict[str, str] = {}
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
kind, values, node_id = (
|
|
node.get("type"),
|
|
node.get("widgets_values"),
|
|
node.get("id"),
|
|
)
|
|
if (
|
|
isinstance(kind, str)
|
|
and kind != "PromptManagerPair"
|
|
and "promptmanager" in kind.lower()
|
|
and isinstance(values, list)
|
|
and values
|
|
and isinstance(values[0], str)
|
|
and isinstance(node_id, (str, int))
|
|
and not isinstance(node_id, bool)
|
|
):
|
|
widgets[str(node_id)] = values[0]
|
|
by_text: Dict[str, list] = {}
|
|
for node_id, widget in widgets.items():
|
|
node = graph.get(node_id)
|
|
inputs = node.get("inputs") if isinstance(node, dict) else None
|
|
text = inputs.get("text") if isinstance(inputs, dict) else None
|
|
if isinstance(text, str):
|
|
by_text.setdefault(text, []).append(node_id)
|
|
return frozenset(
|
|
node_id
|
|
for ids in by_text.values()
|
|
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1
|
|
for node_id in ids
|
|
)
|
|
|
|
|
|
def _no_sampler(graph: dict, profile: ResolverProfile) -> Dict[str, Any]:
|
|
reason = "No supported sampler pair found"
|
|
if profile.legacy:
|
|
has_pair_inputs = any(
|
|
isinstance(node, dict)
|
|
and isinstance(node.get("inputs"), dict)
|
|
and ("positive" in node["inputs"] or "negative" in node["inputs"])
|
|
for node in graph.values()
|
|
)
|
|
return _result("unresolved" if has_pair_inputs else "absent", reason=reason)
|
|
# A prompt graph is a record: its prompt is unresolved, never "not recorded".
|
|
seen = _sampler_like_classes(graph)
|
|
if seen:
|
|
reason = f"{reason} (saw {', '.join(seen)})"
|
|
return _result("unresolved", reason=reason, reason_code="no-sampler")
|
|
|
|
|
|
def resolve_text_input(
|
|
graph: Any,
|
|
node_id: str,
|
|
input_name: str,
|
|
*,
|
|
max_visits: int = 500,
|
|
max_depth: int = 64,
|
|
max_text_length: int = 100_000,
|
|
) -> Optional[str]:
|
|
"""Resolve one raw STRING input; empty text is distinct from unknown input."""
|
|
if not isinstance(graph, dict) or len(graph) > max_visits:
|
|
return None
|
|
normalized = {str(key): value for key, value in graph.items()}
|
|
if len(normalized) != len(graph) or max_depth <= 0 or max_text_length <= 0:
|
|
return None
|
|
resolver = _GraphResolver(normalized, {}, max_visits, max_depth, max_text_length)
|
|
try:
|
|
_, inputs = resolver.node(str(node_id))
|
|
return resolver.text(inputs.get(input_name), "string")[0]
|
|
except _Unresolved:
|
|
return None
|