* feat(resolver): trace multi-stage chains through pass-through nodes The inspection resolver (server and local Inspect) now compares a sampler with the earlier sampler its latent continues through image-space pass-through nodes: decode, image filter, model upscale, scale and encode, listed in a new validated `lineage` section of the semantics table. Equal prompts resolve (hires-fix, refiner); different prompts are multistage-differs. A later stage whose positive adds conditioning to the shared base prompt through an additive composite (ConditioningCombine or Concat) keeps the base pair, the text legacy linked (three-stage Flux). Image-edit batches stay ambiguous and run-time text stays untraceable. Ingestion keeps the default profile (D1). A card no longer shows a seed when the inspector names a chain stage whose seed differs from the parser's pick. * feat(resolver): leave out run-time PromptManager parts, labelled Maintainer option A: when a PromptManager prepend_text or append_text is fed directly by a node whose output is decided at run time (LoraManager's TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt resolves to the node's own text, as legacy showed. The side's source is runtime-parts-omitted and the resolved reason says that text added at run time, such as trigger words, is not included; the prompt block shows that line (D-NEW-R1). The toggle's output is never computed. Literal and traced parts are combined as before; an indirect run-time part, a run-time main text or an unknown feeder keep their outcome. The browser port changes identically. Ingestion is unchanged. * fix(resolver): keep run-time and added-conditioning labels honest A text that left out a run-time PromptManager part is now labelled when any node on its traced path omitted it, so the label survives encoders, concatenations and chained PromptManagers. Comparisons include what a text leaves out, so a partial prompt never equals a complete one: a branch with trigger words and one without stay ambiguous, and such stages are multistage-differs. Only classes listed as run time are left out. The added-conditioning floor labels its positive (D126, "Conditioning added at a later stage is not included."); the run-time line is D125. A card hides its seed only when the stage the inspector names reads a different seed than the parser; an agreeing seed is kept, as on main. The browser port moves its lineage, latent and part helpers into local-resolver-stages.mjs, keeping parity.
208 lines
7.7 KiB
Python
208 lines
7.7 KiB
Python
"""Workspace card facts from the metadata records a header open already loaded.
|
|
|
|
The card shows what the inspector shows, so it reads exactly what the
|
|
inspector's browser parser reads: each ``prompt``/``workflow`` record through
|
|
the inspection projection (``project_graph``: strict JSON, the depth and node
|
|
bounds, big integers and decimals as text), and only graphs it parsed, through
|
|
the shared parser (``extract_generation_params``, the Python twin of the
|
|
browser parser the inspector fills from since #251). No separate seed
|
|
heuristic and no extra file reads. One parse feeds both the seed and the
|
|
positive that confirms a first-line card title. One exception keeps the card
|
|
from ever showing a seed the inspector does not: when sampler stages read
|
|
different seed inputs and the inspector's resolution names one of them for a
|
|
resolved pair (whose facts are that stage's), the card shows no seed when the
|
|
named stage's seed differs from the parser's. Only such graphs are resolved,
|
|
with the inspector's own profile.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from collections.abc import Mapping
|
|
|
|
from .generation_params import (
|
|
PLACEHOLDERS,
|
|
GenerationParamsError,
|
|
_is_sampler,
|
|
extract_generation_params,
|
|
)
|
|
from .inspection_response import project_graph
|
|
from .inspection_semantics import SemanticsError, get_semantics
|
|
from .prompt_pair_resolver import INSPECTION_PROFILE, resolve_prompt_pair
|
|
|
|
__all__ = [
|
|
"MAX_RECORD_BYTES",
|
|
"MAX_SEED_DIGITS",
|
|
"card_seed",
|
|
"header_params",
|
|
"parsed_positive",
|
|
]
|
|
|
|
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
|
|
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
|
_RECORD_KEYS = ("prompt", "workflow")
|
|
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
|
|
# is no seed, and never reaches int(), whose max-str-digits guard raises.
|
|
MAX_SEED_DIGITS = 20
|
|
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
|
|
|
|
|
|
def _utf8_size_over(text: str, limit: int) -> bool:
|
|
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
|
|
|
|
A code point takes one to four bytes, so most records are decided from
|
|
their length alone without copying them.
|
|
"""
|
|
if len(text) > limit:
|
|
return True
|
|
if len(text) * 4 <= limit:
|
|
return False
|
|
return len(text.encode("utf-8", "surrogatepass")) > limit
|
|
|
|
|
|
def header_params(info) -> dict | None:
|
|
"""The shared parser's result for the inspector's view of ``info``'s records.
|
|
|
|
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
|
|
A record the projection does not parse is left out, as the inspector
|
|
leaves it out. None when no record parses, a record is not text or is
|
|
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
|
|
"""
|
|
if not isinstance(info, Mapping):
|
|
return None
|
|
graphs = {}
|
|
for key in _RECORD_KEYS:
|
|
text = info.get(key)
|
|
if text is None:
|
|
continue
|
|
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
|
|
return None
|
|
graph = project_graph(text, key)
|
|
if graph["status"] == "parsed":
|
|
graphs[key] = graph["value"]
|
|
if not graphs:
|
|
return None
|
|
try:
|
|
params = extract_generation_params(graphs)
|
|
except (GenerationParamsError, RecursionError):
|
|
# Defence in depth: a parser failure gives no card facts, never a 500.
|
|
return None
|
|
if isinstance(params, dict) and _inspector_names_another_seed(
|
|
graphs, card_seed(params)
|
|
):
|
|
return {**params, "seed": None}
|
|
return params
|
|
|
|
|
|
_SEED_INPUTS = ("seed", "noise_seed", "noise")
|
|
|
|
|
|
def _stage_seed(graph: Mapping, node_id: str) -> str | None:
|
|
"""The seed the parser reads for one sampler node, or None when unknown.
|
|
|
|
A literal seed input is that seed. A linked one is read by the parser
|
|
itself when it treats the node as a sampler, with every other such node
|
|
hidden from it (no ``positive`` input), so linked seeds resolve as they do
|
|
on the card; any other linked seed is unknown.
|
|
"""
|
|
node = graph.get(node_id)
|
|
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
|
|
return None
|
|
inputs = node["inputs"]
|
|
literal = next((inputs[k] for k in _SEED_INPUTS if k in inputs), None)
|
|
if not isinstance(literal, (list, tuple)):
|
|
# A literal needs no second parse (the cost is measured in TESTING.md).
|
|
return card_seed({"seed": literal})
|
|
if _is_sampler(node):
|
|
only = {
|
|
key: (
|
|
{**other, "inputs": {**other["inputs"], "positive": None}}
|
|
if key != node_id and _is_sampler(other)
|
|
else other
|
|
)
|
|
for key, other in graph.items()
|
|
}
|
|
try:
|
|
return card_seed(extract_generation_params({"prompt": only}))
|
|
except (GenerationParamsError, RecursionError):
|
|
return None
|
|
return None
|
|
|
|
|
|
def _inspector_names_another_seed(graphs: dict, parsed: str | None) -> bool:
|
|
"""Whether the inspector shows another stage's seed than the parser's.
|
|
|
|
True when the prompt graph's sampler stages read different seed inputs, the
|
|
inspection resolution names one sampler for a resolved pair (the
|
|
inspector's facts are then that stage's, D19), and that stage's seed is
|
|
known and differs from ``parsed``. A seed that agrees is kept.
|
|
"""
|
|
if parsed is None:
|
|
return False
|
|
graph = graphs.get("prompt")
|
|
if not isinstance(graph, Mapping):
|
|
return False
|
|
try:
|
|
samplers = get_semantics().samplers
|
|
except SemanticsError:
|
|
return False
|
|
seeds = set()
|
|
for node in graph.values():
|
|
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
|
|
continue
|
|
if not isinstance(node.get("class_type"), str):
|
|
continue
|
|
if node["class_type"] not in samplers:
|
|
continue
|
|
inputs = node["inputs"]
|
|
value = next((inputs[key] for key in _SEED_INPUTS if key in inputs), None)
|
|
seeds.add(json.dumps(value, sort_keys=True, default=str))
|
|
if len(seeds) < 2:
|
|
return False
|
|
try:
|
|
resolution = resolve_prompt_pair(
|
|
{"prompt": dict(graph)}, profile=INSPECTION_PROFILE
|
|
)
|
|
except RecursionError:
|
|
return True
|
|
named = resolution["sampler_id"]
|
|
if resolution["state"] != "resolved" or named is None:
|
|
return False
|
|
seed = _stage_seed(graph, named)
|
|
return seed is not None and seed != parsed
|
|
|
|
|
|
def card_seed(params: dict | None) -> str | None:
|
|
"""The parsed seed as a decimal string, else None.
|
|
|
|
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
|
|
ComfyUI's randomized seeds are often that large. The projection carries
|
|
integers beyond 2**53 as their exact digits, so a digit string is the
|
|
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
|
|
digits. A placeholder, other or longer text, a boolean or a decimal gives
|
|
None.
|
|
"""
|
|
if not isinstance(params, Mapping):
|
|
return None
|
|
value = params.get("seed")
|
|
if isinstance(value, bool):
|
|
return None
|
|
try:
|
|
if isinstance(value, int):
|
|
return str(value)
|
|
if isinstance(value, str) and _INTEGER.match(value):
|
|
return str(int(value))
|
|
except ValueError:
|
|
# Python's integer string conversion limit: no seed, never a 500.
|
|
return None
|
|
return None
|
|
|
|
|
|
def parsed_positive(params: dict | None) -> str | None:
|
|
"""The parsed positive prompt text, or None for the placeholder or blank."""
|
|
if not isinstance(params, Mapping):
|
|
return None
|
|
text = params.get("positivePrompt")
|
|
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
|
|
return None
|
|
return text if text.strip() else None
|