Files
ComfyAssets-ComfyUI_PromptM…/utils/generation_params.py
T
Vito 36564b4767 feat(parse): one generation-parameter parser in Python; scan links what the resolver cannot decide (#253)
* feat(parse): port the browser generation-parameter parser to Python

Adds utils/generation_params.py, a line-for-line port of
extractGenerationParams in web/js/comfy-metadata.js, the parser behind
the gallery, admin and metadata pages. The scan and live monitor can use
it so every surface reads prompts the same way. The JavaScript parser is
unchanged.

The port reproduces the JavaScript value semantics the result depends on
(property order, truthiness, String(), trim(), strict equality, Map keys,
JSON.parse number rounding, and the inputs on which it throws), and runs
its recursive tracing on an explicit stack so crafted graphs cannot
exhaust the Python stack.

Tests:
- behaviour tests for each tracing rule, with JavaScript results as
  expected values, including the visit-limit boundary;
- golden vectors of the JavaScript parser's results for every named case
  and every PNG fixture, so the comparison runs without Node;
- a live comparison against the JavaScript parser when Node is present;
- a randomized differential test over 9,000 generated graphs and
  workflows, coherent and corrupted, including inputs where the
  JavaScript throws.

Five deliberate porting mistakes (trim, truthiness, prepend handling,
visit limit, pair slot) each fail the tests. On a 38,602-file output
folder, all 18,491 files with a workflow give identical results.

* fix(scan): link the traced prompt when the pair resolver cannot decide

The output scan skipped every file the pair resolver called ambiguous or
unresolved, so workflows where several samplers share one prompt (a base
pass, an upscale pass and a detailer) never reached the Library from a
scan. It now links the positive prompt the shared parser traces for
those files, the prompt the inspector and the legacy pages show. Files
the resolver decides are stored as before, and graphs without a
recognized sampler keep the legacy extractor (#252).

Read-only scans of a 38,602-file output folder into temporary databases:
- links: 1,500 before, 7,954 after; none of the 1,500 dropped or changed;
- of the added links that differ from the pre-redesign scan, 1,275
  replace a number, a LoRA tag, a fragment or a truncated prompt that
  scan stored.

* refactor(parse): return a uniform pair from the SameValueZero key
2026-10-19 20:56:51 -07:00

944 lines
31 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Generation parameters traced from ComfyUI metadata: the browser parser in Python.
A port of ``extractGenerationParams`` in ``web/js/comfy-metadata.js``, the parser
behind the gallery, admin and metadata pages. For the same records it returns
what that function returns; ``tests/test_generation_params_js_parity.py``
enforces it against the JavaScript itself.
It traces the API ``prompt`` graph from the sampler: the positive and negative
inputs back to their text nodes, the model link back to a loader, and the
sampler settings, following primitives and pass-through nodes. Fields the API
graph cannot give are read from the UI ``workflow`` graph.
The result depends on JavaScript value semantics, so those are reproduced
explicitly: property order, truthiness, ``String()``, ``trim()``, strict
equality, ``Map``/``Set`` keys, and the inputs on which the JavaScript throws
(raised here as ``GenerationParamsError``). The recursive tracing runs on an
explicit stack, so a crafted graph cannot exhaust the Python stack.
"""
import json
import math
import re
from types import MappingProxyType
__all__ = [
"PLACEHOLDERS",
"GenerationParamsError",
"extract_generation_params",
"extract_from_records",
"parse_js_json",
]
PLACEHOLDERS = MappingProxyType(
{
"positivePrompt": "No prompt found",
"negativePrompt": "No negative prompt found",
"checkpoint": "Unknown",
"steps": "Unknown",
"cfgScale": "Unknown",
"sampler": "Unknown",
"seed": "Unknown",
}
)
# Upper bound on nodes expanded per lookup; crafted files can contain any graph.
MAX_VISITS = 500
MODEL_NAME_KEYS = ("ckpt_name", "unet_name", "model_name", "gguf_name")
SEED_CONTROL_VALUES = frozenset(("fixed", "increment", "decrement", "randomize"))
PASS_THROUGH_TYPES = frozenset(("Reroute", "GetNode", "SetNode"))
MODE_MUTED = 2
MODE_BYPASSED = 4
# Widget order for core nodes, used when a workflow carries no widget names.
PROMPT_MANAGER_WIDGETS = (
"text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
)
WIDGET_POSITIONS = {
"KSampler": ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"),
"KSamplerAdvanced": (
"add_noise",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"start_at_step",
"end_at_step",
"return_with_leftover_noise",
),
"CLIPTextEncode": ("text",),
"PromptManager": PROMPT_MANAGER_WIDGETS,
"PromptManagerText": PROMPT_MANAGER_WIDGETS,
"PromptManagerFocused": PROMPT_MANAGER_WIDGETS,
"PromptManagerSplit": PROMPT_MANAGER_WIDGETS,
"PromptManagerPair": (
"text",
"negative_text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
),
"CheckpointLoader": ("config_name", "ckpt_name"),
"UNETLoader": ("unet_name", "weight_dtype"),
}
# JavaScript regular expressions without the ``u`` flag fold case in ASCII only.
_FLAGS = re.IGNORECASE | re.ASCII
TEXT_KEYS = re.compile(r"(text|string|prompt|value)(_?[a-z0-9]+)?", _FLAGS)
_PROMPT_MANAGER = re.compile(r"promptmanager", _FLAGS)
_KSAMPLER = re.compile(r"ksampler", _FLAGS)
_CHECKPOINT_LOADER = re.compile(r"checkpointloader", _FLAGS)
_UNET = re.compile(r"unet", _FLAGS)
_SEED_NAME = re.compile(r"seed\Z")
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
_MAX_ARRAY_INDEX = 2**32 - 2
_MAX_SAFE_INTEGER = 2**53
# String.prototype.trim() removes WhiteSpace and LineTerminator code points.
_JS_WHITESPACE = "\t\n\v\f\r          " "    

   "
# Properties every object literal inherits; reading one yields a function or object.
_OBJECT_PROTOTYPE_KEYS = frozenset(
(
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toLocaleString",
"toString",
"valueOf",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
)
)
class GenerationParamsError(ValueError):
"""The browser parser throws on this input, so there is no result."""
class _Undefined:
"""JavaScript ``undefined``: a missing property, distinct from JSON ``null``."""
__slots__ = ()
def __repr__(self):
return "undefined"
UNDEFINED = _Undefined()
# --- JavaScript value semantics -------------------------------------------------
def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _nullish(value):
return value is None or value is UNDEFINED
def _truthy(value):
if value is None or value is UNDEFINED or value is False:
return False
if _is_number(value):
return value != 0 and not (isinstance(value, float) and math.isnan(value))
if isinstance(value, str):
return value != ""
return True
def _typeof(value):
if value is UNDEFINED:
return "undefined"
if isinstance(value, bool):
return "boolean"
if _is_number(value):
return "number"
if isinstance(value, str):
return "string"
return "object"
def _number_string(value):
"""``String(number)`` for a double, per the ECMAScript Number::toString rules."""
if isinstance(value, int):
return str(value)
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "Infinity" if value > 0 else "-Infinity"
if value == 0:
return "0"
sign = "-" if value < 0 else ""
text = repr(abs(value))
mantissa, _, exponent = text.partition("e")
whole, _, fraction = mantissa.partition(".")
raw = whole + fraction
leading = len(raw) - len(raw.lstrip("0"))
digits = raw.lstrip("0").rstrip("0")
point = len(whole) - leading + int(exponent or 0)
count = len(digits)
if count <= point <= 21:
body = digits + "0" * (point - count)
elif 0 < point <= 21:
body = digits[:point] + "." + digits[point:]
elif -6 < point <= 0:
body = "0." + "0" * -point + digits
else:
power = point - 1
mark = ("+" if power >= 0 else "-") + str(abs(power))
body = (digits if count == 1 else digits[0] + "." + digits[1:]) + "e" + mark
return sign + body
def _js_string(value):
"""``String(value)`` for any JSON-derived value."""
if value is UNDEFINED:
return "undefined"
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if _is_number(value):
return _number_string(value)
if isinstance(value, str):
return value
if isinstance(value, list):
return ",".join("" if _nullish(item) else _js_string(item) for item in value)
return "[object Object]"
def _key(value):
return value if isinstance(value, str) else _js_string(value)
def _index(key):
"""The array index a property key names, or None."""
if (
_is_number(key)
and not isinstance(key, bool)
and float(key).is_integer()
and key >= 0
):
number = int(key)
return number if number <= _MAX_ARRAY_INDEX else None
text = _key(key)
if _ARRAY_INDEX.match(text) and int(text) <= _MAX_ARRAY_INDEX:
return int(text)
return None
def _get(obj, key):
"""Property read ``obj[key]``; reading from null or undefined throws."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError(f"cannot read {key!r} of {_js_string(obj)}")
if isinstance(obj, dict):
return obj.get(_key(key), UNDEFINED)
if isinstance(obj, (list, str)):
if key == "length":
return len(obj)
index = _index(key)
return obj[index] if index is not None and index < len(obj) else UNDEFINED
return UNDEFINED
def _has(obj, key):
"""``key in obj``; throws on primitives."""
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, list):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
raise GenerationParamsError(f"cannot use 'in' on {_typeof(obj)}")
def _own(obj, key):
"""``Object.prototype.hasOwnProperty.call(obj, key)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("hasOwnProperty on null or undefined")
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, (list, str)):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
return False
def _ordered_keys(obj):
"""Own property order: array indexes ascending, then other keys as inserted."""
indexes = sorted(
(k for k in obj if _ARRAY_INDEX.match(k) and int(k) <= _MAX_ARRAY_INDEX),
key=int,
)
marked = set(indexes)
return indexes + [k for k in obj if k not in marked]
def _entries(obj):
"""``Object.entries(obj)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("Object.entries of null or undefined")
if isinstance(obj, dict):
return [(k, obj[k]) for k in _ordered_keys(obj)]
if isinstance(obj, (list, str)):
return [(str(i), item) for i, item in enumerate(obj)]
return []
def _values(obj):
"""``Object.values(obj)``."""
return [value for _, value in _entries(obj)]
def _strict_equal(left, right):
"""``left === right``."""
if left is UNDEFINED or right is UNDEFINED or left is None or right is None:
return left is right
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if _is_number(left) and _is_number(right):
return left == right
if isinstance(left, str) and isinstance(right, str):
return left == right
return left is right
def _same_value_key(value):
"""A hashable key with ``Map``/``Set`` (SameValueZero) equality."""
if value is UNDEFINED:
return ("undefined", None)
if value is None:
return ("null", None)
if isinstance(value, bool):
return ("boolean", value)
if _is_number(value):
return (
("nan", None)
if isinstance(value, float) and math.isnan(value)
else ("number", value)
)
if isinstance(value, str):
return ("string", value)
return ("object", id(value))
def _trim(text):
return text.strip(_JS_WHITESPACE)
def _first_truthy(*values):
"""``a || b || ...``: the first truthy operand, else the last."""
for value in values:
if _truthy(value):
return value
return values[-1]
def _and_chain(obj, *steps):
"""``a && a[k1] && a[k1][k2] ...``: stops at the first falsy operand."""
value = obj
for step in steps:
if not _truthy(value):
return value
value = _get(value, step)
return value
def _coalesce(value, fallback):
"""``value ?? fallback()``; the fallback runs only when needed."""
return fallback() if _nullish(value) else value
def _array(value):
"""A value used with an Array method; anything else throws."""
if isinstance(value, list):
return value
raise GenerationParamsError(f"{_typeof(value)} has no array methods")
def _iterate(value):
"""``for (const x of value)``: arrays and strings iterate, other values throw."""
if isinstance(value, (list, str)):
return value
raise GenerationParamsError(f"{_typeof(value)} is not iterable")
def _set(obj, key, value):
"""``obj[key] = value`` on an object literal (``__proto__`` is not an own key)."""
name = _key(key)
if name == "__proto__" and name not in obj:
return
obj[name] = value
def _matches(pattern, value):
"""``pattern.test(value || "")``."""
return pattern.search(_js_string(value if _truthy(value) else "")) is not None
def _run(generator):
"""Drive generator-based recursion on an explicit stack, not the call stack."""
stack = [generator]
value = None
while stack:
try:
request = stack[-1].send(value)
except StopIteration as done:
stack.pop()
value = done.value
continue
stack.append(request)
value = None
return value
# --- Tracing ---------------------------------------------------------------------
def _is_link(value):
return (
isinstance(value, list)
and len(value) == 2
and (isinstance(value[0], str) or _is_number(value[0]))
and _is_number(value[1])
)
def _is_sampler(node):
"""A KSampler or a guider (CFGGuider); the model link excludes pass-throughs."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "positive"))
and _is_link(_get(inputs, "negative"))
and _is_link(_get(inputs, "model"))
)
def _is_custom_sampler(node):
"""SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "guider"))
and _is_link(_get(inputs, "sigmas"))
)
def _workflow_nodes(workflow):
if not _truthy(workflow):
return []
nodes = _get(workflow, "nodes")
return nodes if isinstance(nodes, list) else []
def _widgets_of(node):
values = _get(node, "widgets_values")
return values if isinstance(values, list) else []
class _Context:
"""The graph being traced and recovered PromptManager widget text."""
__slots__ = ("graph", "widget_text")
def __init__(self, graph, widget_text):
self.graph = graph
self.widget_text = widget_text
def _create_context(graph, workflow):
"""Recover PromptManager text overwritten by the pre-3.2.3 SaveImage patch.
The signature is two or more PromptManager nodes with identical API text
whose workflow widgets differ; only those get their widget text back.
Otherwise the API graph is what actually ran and always wins.
"""
widgets = {}
for node in _workflow_nodes(workflow):
text = _get(_widgets_of(node), 0)
node_type = _get(node, "type")
if (
not _strict_equal(node_type, "PromptManagerPair")
and _matches(_PROMPT_MANAGER, node_type)
and isinstance(text, str)
):
widgets[_js_string(_get(node, "id"))] = text
by_api_text = {}
for node_id in widgets:
api_text = _and_chain(graph, node_id, "inputs", "text")
if isinstance(api_text, str):
by_api_text.setdefault(api_text, []).append(node_id)
widget_text = {}
for ids in by_api_text.values():
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1:
for node_id in ids:
widget_text[node_id] = widgets[node_id]
return _Context(graph, widget_text)
def _enter(context, link, seen):
"""``(node_id, node)`` for a link; None when unresolved or already expanded."""
if not _is_link(link) or len(seen) >= MAX_VISITS:
return None
node_id = _js_string(link[0])
node = _get(context.graph, node_id)
key = f"{node_id}:{_js_string(link[1])}"
if not _truthy(node) or not _truthy(_get(node, "inputs")) or key in seen:
return None
seen.add(key)
return node_id, node
def _resolve_scalar(context, value, name, seen):
"""A scalar input, following a link; an input named like the field wins."""
if not _is_link(value):
return value
entered = _enter(context, value, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
if _truthy(name) and _own(inputs, name):
return (yield _resolve_scalar(context, _get(inputs, name), name, seen))
for item in _values(inputs):
resolved = yield _resolve_scalar(context, item, name, seen)
if resolved is not UNDEFINED and _typeof(resolved) != "object":
return resolved
return UNDEFINED
def _resolve_linked_input(context, link, keys):
"""The first of ``keys`` on the node a link points to, resolving further links."""
seen = set()
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in keys if _has(inputs, k)), None)
if key is None:
return UNDEFINED
return (yield _resolve_scalar(context, _get(inputs, key), key, seen))
def _resolve_string(context, value, seen):
"""A string input; text-producing nodes may be chained (concat, primitives)."""
if isinstance(value, str):
return value
entered = _enter(context, value, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(value[1], 3):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(value[1], 2):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
delimiter = _get(inputs, "delimiter")
if not isinstance(delimiter, str):
delimiter = " "
parts = []
for key, item in _entries(inputs):
if TEXT_KEYS.fullmatch(key):
parts.append((yield _resolve_string(context, item, seen)))
return delimiter.join(part for part in parts if _trim(part))
def _compose_node_text(context, node_id, node, seen):
"""An encoder's text, joined like PromptManager: prepend + text + append."""
inputs = _get(node, "inputs")
if node_id in context.widget_text:
raw = context.widget_text[node_id]
else:
raw = yield _resolve_string(context, _get(inputs, "text"), seen)
text = _trim(raw)
if not text:
return ""
# One shared set even across siblings keeps crafted chains linear.
prepend = _trim(
(yield _resolve_string(context, _get(inputs, "prepend_text"), seen))
)
append = _trim((yield _resolve_string(context, _get(inputs, "append_text"), seen)))
# Older saves stored the already-combined prompt in ``text``.
parts = (
prepend if prepend and not text.startswith(prepend) else "",
text,
append if append and not text.endswith(append) else "",
)
return " ".join(part for part in parts if part)
def _trace_conditioning(context, link, role, seen):
"""Follow a conditioning link upstream to the encoder that produced it."""
entered = _enter(context, link, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(link[1], 1):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(link[1], 0):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
# Pass-through nodes (ControlNet apply, conditioning combine/set area, ...).
following = _first_truthy(
_get(inputs, role),
_get(inputs, "conditioning"),
_get(inputs, "conditioning_to"),
_get(inputs, "conditioning_1"),
)
return (yield _trace_conditioning(context, following, role, seen))
def _trace_model_name(context, link, seen):
"""Follow the model link upstream (through LoRA loaders etc.) to a loader."""
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None)
if key is not None:
return _get(inputs, key)
return (yield _trace_model_name(context, _get(inputs, "model"), seen))
def _find_any_model_name(nodes):
for node in nodes:
inputs = _and_chain(node, "inputs")
if not _truthy(inputs):
inputs = {}
key = next(
(k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None
)
if key is not None:
return _get(inputs, key)
return UNDEFINED
def _from_prompt_graph(graph, workflow):
context = _create_context(graph, workflow)
nodes = _values(graph)
custom = next((node for node in nodes if _is_custom_sampler(node)), UNDEFINED)
guider = UNDEFINED
if custom is not UNDEFINED:
entered = _enter(context, _get(_get(custom, "inputs"), "guider"), set())
guider = entered[1] if entered is not None else UNDEFINED
sampler = (
guider
if _is_sampler(guider)
else next((n for n in nodes if _is_sampler(n)), UNDEFINED)
)
if sampler is UNDEFINED:
loose = next(
(
n
for n in nodes
if _truthy(n)
and _truthy(_get(n, "inputs"))
and _matches(_KSAMPLER, _get(n, "class_type"))
),
UNDEFINED,
)
inputs = _and_chain(loose, "inputs")
if not _truthy(inputs):
inputs = {}
seed = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
return {
"checkpoint": _find_any_model_name(nodes),
"seed": (yield _resolve_scalar(context, seed, "seed", set())),
"steps": (
yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
),
"cfgScale": (
yield _resolve_scalar(context, _get(inputs, "cfg"), "cfg", set())
),
"sampler": (
yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
),
}
inputs = _get(sampler, "inputs")
def from_custom(field, keys):
if custom is UNDEFINED:
return UNDEFINED
return _run(
_resolve_linked_input(context, _get(_get(custom, "inputs"), field), keys)
)
result = {
"positivePrompt": (
yield _trace_conditioning(
context, _get(inputs, "positive"), "positive", set()
)
),
"negativePrompt": (
yield _trace_conditioning(
context, _get(inputs, "negative"), "negative", set()
)
),
}
model = yield _trace_model_name(context, _get(inputs, "model"), set())
result["checkpoint"] = model if _truthy(model) else _find_any_model_name(nodes)
seed_value = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
seed_name = "seed" if _has(inputs, "seed") else "noise_seed"
seed = yield _resolve_scalar(context, seed_value, seed_name, set())
result["seed"] = _coalesce(
seed, lambda: from_custom("noise", ("noise_seed", "seed"))
)
steps = yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
result["steps"] = _coalesce(steps, lambda: from_custom("sigmas", ("steps",)))
result["cfgScale"] = yield _resolve_scalar(
context, _get(inputs, "cfg"), "cfg", set()
)
sampler_name = yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
result["sampler"] = _coalesce(
sampler_name, lambda: from_custom("sampler", ("sampler_name",))
)
return result
# --- UI workflow graph -------------------------------------------------------------
def _widget_names(node):
inputs = _first_truthy(_get(node, "inputs"), [])
from_inputs = [
_get(_get(item, "widget"), "name")
for item in _array(inputs)
if _truthy(item)
and _truthy(_get(item, "widget"))
and _truthy(_get(_get(item, "widget"), "name"))
]
if from_inputs:
return from_inputs
node_type = _get(node, "type")
key = _js_string(node_type)
if key in _OBJECT_PROTOTYPE_KEYS:
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
if key in WIDGET_POSITIONS:
return list(WIDGET_POSITIONS[key])
if _matches(_CHECKPOINT_LOADER, node_type):
return ["ckpt_name"]
if _matches(_UNET, node_type):
return ["unet_name"]
return []
def _named_widgets(node):
"""Map widget values to input names, skipping control_after_generate."""
named = _get(node, "widgets_values_named")
if _truthy(named) and isinstance(named, dict):
return dict(named)
values = _widgets_of(node)
out = {}
position = 0
for name in _iterate(_widget_names(node)):
if position >= len(values):
break
_set(out, name, values[position])
position += 1
following = values[position] if position < len(values) else UNDEFINED
if (
_SEED_NAME.search(_js_string(name))
and isinstance(following, str)
and following in SEED_CONTROL_VALUES
):
position += 1
return out
def _workflow_to_graph(workflow):
"""Convert a UI workflow ({nodes, links}) into the API graph shape.
Set/Get pairs, Reroutes and bypassed nodes resolve to their real source;
muted nodes are dropped.
"""
nodes = [
n
for n in _workflow_nodes(workflow)
if _truthy(n) and not _nullish(_get(n, "id"))
]
by_id = {}
for node in nodes:
by_id[_js_string(_get(node, "id"))] = node
links = {}
raw_links = _get(workflow, "links")
for link in raw_links if isinstance(raw_links, list) else []:
if isinstance(link, list):
links[_same_value_key(_get(link, 0))] = (
_get(link, 1),
_get(link, 2),
_get(link, 5),
)
elif _truthy(link) and _typeof(link) == "object":
links[_same_value_key(_get(link, "id"))] = (
_get(link, "origin_id"),
_get(link, "origin_slot"),
_get(link, "type"),
)
setters = {}
for node in nodes:
if _strict_equal(_get(node, "type"), "SetNode"):
setters[_same_value_key(_get(_widgets_of(node), 0))] = _and_chain(
node, "inputs", 0, "link"
)
def source(link_id):
hops = 0
while True:
link = links.get(_same_value_key(link_id))
node = by_id.get(_js_string(link[0])) if link is not None else None
if (
node is None
or _strict_equal(_get(node, "mode"), MODE_MUTED)
or hops > MAX_VISITS
):
return UNDEFINED
hops += 1
node_type = _get(node, "type")
if _strict_equal(node_type, "Reroute"):
link_id = _and_chain(node, "inputs", 0, "link")
continue
if _strict_equal(node_type, "GetNode"):
link_id = setters.get(
_same_value_key(_get(_widgets_of(node), 0)), UNDEFINED
)
continue
if _strict_equal(_get(node, "mode"), MODE_BYPASSED):
link_type = link[2]
same = next(
(
item
for item in _array(_first_truthy(_get(node, "inputs"), []))
if _truthy(item)
and not _nullish(_get(item, "link"))
and (
not _truthy(link_type)
or _strict_equal(link_type, "*")
or _strict_equal(_get(item, "type"), link_type)
)
),
UNDEFINED,
)
if same is UNDEFINED:
return UNDEFINED
link_id = _get(same, "link")
continue
return [_js_string(_get(node, "id")), link[1]]
graph = {}
for node in nodes:
mode = _get(node, "mode")
node_type = _get(node, "type")
if (
_strict_equal(mode, MODE_MUTED)
or _strict_equal(mode, MODE_BYPASSED)
or (isinstance(node_type, str) and node_type in PASS_THROUGH_TYPES)
):
continue
inputs = _named_widgets(node)
for item in _iterate(_first_truthy(_get(node, "inputs"), [])):
if not _truthy(item) or _nullish(_get(item, "link")):
continue
found = source(_get(item, "link"))
if _truthy(found):
_set(inputs, _get(item, "name"), found)
_set(graph, _get(node, "id"), {"class_type": node_type, "inputs": inputs})
return graph
# --- Public API ----------------------------------------------------------------------
def _has_value(value):
return not _nullish(value) and not (isinstance(value, str) and value == "")
def extract_generation_params(data):
"""Prompts, model and sampler settings from parsed ``prompt``/``workflow`` records.
``data`` holds the parsed records (``{"prompt": ..., "workflow": ...}``). Each
field falls back to its placeholder when neither graph provides it.
Raises ``GenerationParamsError`` where the browser parser throws.
"""
data = data if _truthy(data) else {}
prompt = _get(data, "prompt")
workflow = _get(data, "workflow")
primary = {}
if _truthy(prompt) and _typeof(prompt) == "object":
primary = _run(_from_prompt_graph(prompt, workflow))
fallback = {}
if _truthy(workflow):
fallback = _run(_from_prompt_graph(_workflow_to_graph(workflow), None))
merged = {}
for key, placeholder in PLACEHOLDERS.items():
value = primary.get(key, UNDEFINED)
if not _has_value(value):
value = fallback.get(key, UNDEFINED)
merged[key] = value if _has_value(value) else placeholder
return merged
def _reject_constant(name):
raise ValueError(f"{name} is not JSON")
def _double_integer(text):
"""Integers parse as doubles in JavaScript; beyond 2**53 they round."""
value = int(text)
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
def parse_js_json(text):
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
if not isinstance(text, str):
return None
try:
return json.loads(
text, parse_constant=_reject_constant, parse_int=_double_integer
)
except (ValueError, RecursionError):
return None
def extract_from_records(prompt_text, workflow_text):
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
A record that is absent or fails to parse is treated as absent, as the
browser pages do.
"""
data = {}
for key, text in (("prompt", prompt_text), ("workflow", workflow_text)):
if isinstance(text, str):
data[key] = parse_js_json(text)
return extract_generation_params(data)