Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).
- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
_occurrence_matches matcher (carrier_ctx None from callers — nested-pick
threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
without consumption); its import + both wildcard_handler call sites + the
_claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
selector covered zero, new constraint_partial_reach when next N / pick
matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
_CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.
Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
via explicit target_select={mode:"first"}; added an all-mode-default test and
a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
no longer "claims" anything, so the constraint applies on the next actual
resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
two-all-constraints-compose-on-one-target test (both excludes zero the pool →
excludes-all fallback), plus a single-all-constraint survivor test.
648 lines
23 KiB
Python
648 lines
23 KiB
Python
"""Custom ComfyUI socket type ``PIPELINE_CONTEXT`` and widget-only types."""
|
||
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||
from comfy_api.latest._io import ( # pyright: ignore[reportMissingImports]
|
||
ComfyTypeIO,
|
||
comfytype,
|
||
)
|
||
|
||
from engine.context import strip_engine_internals
|
||
from engine.modules import Module, module_from_dict
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ContextPayload:
|
||
"""Value carried across ``PIPELINE_CONTEXT`` sockets.
|
||
|
||
``context`` holds user-facing variables (internals stripped). ``debug``
|
||
carries run metadata (upstream snapshot, seed, trace) for the debug node.
|
||
|
||
``internals`` is a deliberate carve-out for engine-internal state that
|
||
MUST survive cross-node boundaries — currently `__wp_picks__` (so a
|
||
downstream constraint-aware wildcard can read its source's pick from
|
||
a wildcard that lived in an upstream Context) and `__wp_constraints__`
|
||
(so a constraint registered upstream still applies to a downstream
|
||
target wildcard). The standard `strip_internals` filter would drop
|
||
these alongside trace/rng/etc; this field lets them travel without
|
||
leaking into the user-facing `context` payload that the assembler
|
||
consumes.
|
||
"""
|
||
|
||
context: dict[str, Any] = field(default_factory=dict)
|
||
debug: dict[str, Any] = field(default_factory=dict)
|
||
internals: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
@comfytype(io_type="PIPELINE_CONTEXT")
|
||
class PipelineContext(ComfyTypeIO):
|
||
"""Socket type carrying ContextPayload across nodes."""
|
||
|
||
Type = ContextPayload
|
||
|
||
|
||
@comfytype(io_type="WP_CONTEXT_MODULES")
|
||
class ContextModulesInput:
|
||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CONTEXT_MODULES"]``."""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None, # widget_type
|
||
None, # force_input
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_DEBUG_VIEWER")
|
||
class DebugViewerInput:
|
||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_DEBUG_VIEWER"]``."""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None,
|
||
None,
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_CLEANER")
|
||
class CleanerWidgetInput:
|
||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CLEANER"]``.
|
||
|
||
The widget payload is a JSON string holding the cleaner config:
|
||
``{mode, intensity, rules_override, blocklist, preset_id?}``. The
|
||
cleaner node parses it at execute time via ``json.loads``.
|
||
"""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None,
|
||
None,
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_INJECTOR_ROWS")
|
||
class InjectorRowsInput:
|
||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_INJECTOR_ROWS"]``."""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None, # widget_type
|
||
None, # force_input
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
def deserialize_modules(payload: str) -> list[Module]:
|
||
"""Parse the widget JSON payload into a list of engine module dataclasses.
|
||
|
||
Raises ``ValueError`` on malformed JSON or a non-dict root.
|
||
"""
|
||
try:
|
||
data = json.loads(payload)
|
||
except json.JSONDecodeError as exc:
|
||
raise ValueError(f"Invalid modules JSON: {exc.msg}") from exc
|
||
|
||
if not isinstance(data, dict):
|
||
raise ValueError(
|
||
f"Modules payload must be a JSON object, got {type(data).__name__}"
|
||
)
|
||
|
||
raw_modules = data.get("modules") or []
|
||
return [module_from_dict(entry) for entry in raw_modules]
|
||
|
||
|
||
def deserialize_node_input(
|
||
raw: "dict[str, Any] | str | None",
|
||
) -> "tuple[list[dict[str, Any]], dict[str, Any], list[str]]":
|
||
"""Parse a WP_Context node's ``modules`` widget value.
|
||
|
||
Returns ``(modules, catalog, pick_order)``:
|
||
|
||
- ``modules``: ordered list of every entry the user has in
|
||
the widget — wildcards, combines, derivations, constraints,
|
||
pipelines, fixed_values, all in one list. Same shape SPA
|
||
editors emit + ``PipelineEngine.run`` accepts.
|
||
- ``catalog``: the wildcard subset of ``modules``, keyed by
|
||
``id`` (= 8-hex uuid), each value mapped back to the
|
||
canonical ``SnapshotEntry`` shape the engine resolver
|
||
consumes via ``ctx["__wp_catalog__"]``.
|
||
- ``pick_order``: kept in the return signature for back-compat
|
||
with existing callers; always empty under the unified model
|
||
(the modules list itself preserves order).
|
||
|
||
Returns the modules portion as a ``list[dict]`` rather than the
|
||
typed ``list[Module]`` produced by ``deserialize_modules``. The
|
||
legacy ``module_from_dict`` requires fields the SPA shape doesn't
|
||
carry (and only knows how to type ``fixed_values``), so the raw-
|
||
dict path is the safe one for the unified shape — the engine
|
||
already coerces dicts at execute time.
|
||
|
||
Robust to malformed input: any failure path returns
|
||
``([], {}, [])`` so graph runs never crash on a broken widget
|
||
value.
|
||
"""
|
||
if raw is None:
|
||
return [], {}, []
|
||
if isinstance(raw, str):
|
||
try:
|
||
data = json.loads(raw)
|
||
except Exception:
|
||
return [], {}, []
|
||
else:
|
||
data = raw
|
||
if not isinstance(data, dict):
|
||
return [], {}, []
|
||
|
||
raw_modules = data.get("modules")
|
||
modules: list[dict[str, Any]] = (
|
||
list(raw_modules) if isinstance(raw_modules, list) else []
|
||
)
|
||
|
||
# Bundle enable-gate. `BundleInstance.enabled` used to cascade into
|
||
# every child's `module.enabled` at toggle time, which destroyed each
|
||
# child's individual on/off state every time the user disabled a
|
||
# bundle. Now the bundle gate is a non-destructive overlay applied at
|
||
# the engine boundary: child.instance.enabled stays whatever the user
|
||
# set, and we AND it with the bundle's gate here so the engine
|
||
# (which only knows about flat `modules[].enabled`) sees the
|
||
# effective view without needing bundle awareness.
|
||
#
|
||
# Effective enabled = bundle.enabled AND every ancestor bundle's
|
||
# enabled AND child.enabled. Tier-2 nesting walks one parent_uid
|
||
# hop; the API cap forbids deeper chains so the loop terminates
|
||
# in at most two steps.
|
||
raw_bundles = data.get("bundles")
|
||
bundle_enabled: dict[str, bool] = {}
|
||
bundle_parent: dict[str, str | None] = {}
|
||
if isinstance(raw_bundles, list):
|
||
for b in raw_bundles:
|
||
if not isinstance(b, dict):
|
||
continue
|
||
uid = b.get("_uid")
|
||
if isinstance(uid, str):
|
||
bundle_enabled[uid] = bool(b.get("enabled", True))
|
||
parent = b.get("parent_uid")
|
||
bundle_parent[uid] = parent if isinstance(parent, str) else None
|
||
if bundle_enabled:
|
||
def _bundle_chain_enabled(uid: str) -> bool:
|
||
# Walk up parent_uid, return false on the first disabled ancestor.
|
||
# Defensive depth cap (8) absorbs corrupt cycles even though
|
||
# the API tier-2 rule keeps real chains at 1–2 hops.
|
||
seen: set[str] = set()
|
||
cur: str | None = uid
|
||
depth = 0
|
||
while cur is not None and cur not in seen and depth < 8:
|
||
seen.add(cur)
|
||
if bundle_enabled.get(cur, True) is False:
|
||
return False
|
||
cur = bundle_parent.get(cur)
|
||
depth += 1
|
||
return True
|
||
|
||
gated: list[dict[str, Any]] = []
|
||
for m in modules:
|
||
if not isinstance(m, dict):
|
||
gated.append(m)
|
||
continue
|
||
origin = m.get("bundle_origin")
|
||
if isinstance(origin, str) and not _bundle_chain_enabled(origin):
|
||
# Shallow-clone so we don't mutate the workflow-state
|
||
# row the SPA holds onto for undo / restore.
|
||
gated.append({**m, "enabled": False})
|
||
else:
|
||
gated.append(m)
|
||
modules = gated
|
||
|
||
# Build the wildcard catalog from the modules list directly.
|
||
# Filter by `type == "wildcard"` and synthesise a SnapshotEntry
|
||
# per row keyed by `id`. Resolver looks up `ctx["__wp_catalog__"][uuid]`
|
||
# and reads `entry["payload"]["options"]` — that's the only
|
||
# contract this map needs to satisfy.
|
||
catalog: dict[str, dict[str, Any]] = {}
|
||
for m in modules:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
if m.get("type") != "wildcard":
|
||
continue
|
||
mid = m.get("id")
|
||
if not isinstance(mid, str):
|
||
continue
|
||
payload = m.get("payload")
|
||
if not isinstance(payload, dict):
|
||
continue
|
||
catalog[mid] = {
|
||
"snapshot_version": 1,
|
||
"uuid": mid,
|
||
"type": "wildcard",
|
||
"name": m.get("meta", {}).get("name") if isinstance(m.get("meta"), dict) else "",
|
||
"payload": payload,
|
||
"payload_hash": m.get("payload_hash") or "",
|
||
"source": {"kind": "user"},
|
||
}
|
||
|
||
return modules, catalog, []
|
||
|
||
|
||
#: Internal ctx keys whose lifetime spans the full graph chain rather
|
||
#: than a single Context node. Pre-fix every `__`-prefixed key got
|
||
#: dropped at each socket boundary, so `__wp_picks__` (set when a
|
||
#: wildcard rolls) and `__wp_constraints__` (set when a constraint
|
||
#: module registers) didn't survive between nodes — a constraint in
|
||
#: Context B targeting a wildcard in Context C couldn't see the
|
||
#: source's pick recorded in Context A. Listed here explicitly so
|
||
#: future cross-node internals are an opt-in addition rather than a
|
||
#: default-leak.
|
||
_CROSS_NODE_INTERNAL_KEYS = (
|
||
"__wp_picks__",
|
||
"__wp_constraints__",
|
||
# `__wp_constraints__` propagates so a constraint registered in
|
||
# Context A can fire on a wildcard rolled in Context B. The matching
|
||
# SP3 reach-selector hit counter ALSO has to propagate — without it,
|
||
# Context B's first/next counters reset (a `first` constraint would
|
||
# re-fire on B's first target instance even though A already spent
|
||
# it), and B re-runs the never_applied end-of-run check against
|
||
# constraints A already applied, re-emitting the same warnings.
|
||
"__wp_constraint_hits__",
|
||
# User-marked-internal vars propagate across nodes as regular keys
|
||
# in `context`, but downstream PromptAssemblers need to know WHICH
|
||
# of those are internal so they can skip them at render time. Carry
|
||
# the flag map alongside picks/constraints so the next node's
|
||
# PromptAssembler can call `strip_internals` and re-apply the filter.
|
||
"__wp_internal_flags__",
|
||
# Loop bookkeeping from WP_ContextLoop. These reach the FIRST
|
||
# WP_Context (direct ContextLoop child) via the payload internals,
|
||
# but were dropped at that node's output boundary — so a SECOND,
|
||
# chained WP_Context defaulted loop_index=0 and seed_override=None,
|
||
# making its own seed (whatever drives it — widget OR an external
|
||
# node like rgthree Seed) identical across every iteration, and
|
||
# ignoring override entirely. Propagating them lets effective_chain_seed
|
||
# vary + override the seed for EVERY node in the chain, not just the
|
||
# first (2026-05-26).
|
||
# - __wp_loop_index__ : per-iteration index (drives the XOR shift)
|
||
# - __wp_seed_override__: derived base seed when override_seed=true
|
||
# - __wp_loop_seeds__ : the full derived series (debug display)
|
||
"__wp_loop_index__",
|
||
"__wp_seed_override__",
|
||
"__wp_loop_seeds__",
|
||
)
|
||
|
||
|
||
def build_payload(
|
||
ctx: dict[str, Any],
|
||
upstream_debug: dict[str, Any],
|
||
seed: int,
|
||
) -> ContextPayload:
|
||
"""Construct the socket-boundary payload. Strips internals from
|
||
`context`, but carries the cross-node-internal subset on the
|
||
dedicated `internals` field so the next node's execute can merge
|
||
them back into its ctx before running its own pipeline.
|
||
|
||
Trace + warnings accumulate across the chain in `debug.__wp_trace__` /
|
||
`debug.__wp_warnings__` (cumulative-merge pattern matching the
|
||
injector node) so a terminal WPDebug at the chain tail sees every
|
||
module + injector contribution, not just the immediate upstream's.
|
||
"""
|
||
internals = {
|
||
key: ctx[key]
|
||
for key in _CROSS_NODE_INTERNAL_KEYS
|
||
if key in ctx
|
||
}
|
||
upstream_trace = upstream_debug.get("__wp_trace__", [])
|
||
upstream_warnings = upstream_debug.get("__wp_warnings__", [])
|
||
this_trace = ctx.get("__wp_trace__", [])
|
||
this_warnings = ctx.get("__wp_warnings__", [])
|
||
# Dedup constraint finalisation warnings across the cross-node
|
||
# accumulator. Even with __wp_constraint_hits__ now propagating
|
||
# cross-node (so downstream knows what upstream applied), every
|
||
# Context still runs its own end-of-run check — if a constraint
|
||
# legitimately never fires anywhere (target wildcard absent from
|
||
# the whole chain) or under-reaches a `next N` / `pick` selector,
|
||
# every node along the way emits the same row and the user sees N
|
||
# copies. Keying on (type, module_id) here collapses them to one.
|
||
_DEDUP_CONSTRAINT_WARNINGS = frozenset({
|
||
"constraint_never_applied",
|
||
"constraint_partial_reach",
|
||
})
|
||
combined_warnings = list(upstream_warnings) + list(this_warnings)
|
||
deduped: list[dict[str, Any]] = []
|
||
seen_keys: set[tuple[str, str]] = set()
|
||
for w in combined_warnings:
|
||
if not isinstance(w, dict):
|
||
deduped.append(w)
|
||
continue
|
||
wtype = w.get("type")
|
||
if wtype in _DEDUP_CONSTRAINT_WARNINGS:
|
||
mid = w.get("module_id") or ""
|
||
key = (str(wtype), str(mid))
|
||
if key in seen_keys:
|
||
continue
|
||
seen_keys.add(key)
|
||
deduped.append(w)
|
||
return ContextPayload(
|
||
context=strip_engine_internals(ctx),
|
||
debug={
|
||
"upstream": upstream_debug,
|
||
"node_seed": seed,
|
||
"__wp_trace__": list(upstream_trace) + list(this_trace),
|
||
"__wp_warnings__": deduped,
|
||
},
|
||
internals=internals,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_VAR_PICKER")
|
||
class VarPickerInput:
|
||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_VAR_PICKER"]``.
|
||
|
||
The widget renders a dropdown of upstream ``$var`` names + a live
|
||
parse preview strip. Stored as a plain string property
|
||
(``node.properties["var_name"]``) carrying the ``"$seed"`` form;
|
||
Python ``lookup_var`` strips the leading ``$`` on read.
|
||
"""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None,
|
||
None,
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_CONTEXT_LOOP_CONFIG")
|
||
class ContextLoopConfigInput(ComfyTypeIO):
|
||
"""Custom type — Input renders as a widget via
|
||
``getCustomWidgets["WP_CONTEXT_LOOP_CONFIG"]`` (JSON string config);
|
||
Output carries the parsed dict so sibling nodes like WP_SeedList can
|
||
mirror WP_ContextLoop's count / strategy / base_seed without the
|
||
user double-wiring widgets.
|
||
|
||
Input payload: JSON string ``{strategy, override_seed,
|
||
iteration_var_name, bypass}``. ``WPContextLoop.execute`` parses it
|
||
defensively (recovery path tolerates missing / malformed keys).
|
||
|
||
Output payload: dict ``{count, strategy, base_seed, override_seed}``
|
||
emitted by ``WPContextLoop.execute`` — the resolved view (post-
|
||
bypass count, validated strategy). Receivers should still defend
|
||
against unexpected shapes when reading (see
|
||
``wp_nodes/seed_list.py::_resolve_config`` for the pattern).
|
||
"""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None,
|
||
None,
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_CONTEXT_LOOP_WIDGET")
|
||
class ContextLoopWidgetInput(ComfyTypeIO):
|
||
"""Widget-only twin of ``ContextLoopConfigInput``.
|
||
|
||
Earlier the Loop's DOM widget AND the wire payload both used
|
||
``WP_CONTEXT_LOOP_CONFIG``. The ``getCustomWidgets`` factory is keyed
|
||
by TYPE, so registering a factory for ``WP_CONTEXT_LOOP_CONFIG`` made
|
||
EVERY consumer of that type (including WP_SeedList's
|
||
``loop_config`` socket) try to mount the Loop's DOM widget. Gating
|
||
the factory on input-name kept the widget off SeedList but ComfyUI
|
||
still hid the socket because a (broken / undefined) widget was
|
||
registered for the input's type.
|
||
|
||
Split fix: this widget-only type is unique to WP_ContextLoop's
|
||
own ``wp_context_loop_config`` input. ``WP_CONTEXT_LOOP_CONFIG``
|
||
stays as the wire-payload type — Loop's OUTPUT and SeedList's INPUT
|
||
both use that, with NO factory registered → ComfyUI renders them as
|
||
plain sockets and the user can wire Loop → SeedList directly.
|
||
|
||
Payload format is identical to ``ContextLoopConfigInput.Input``'s
|
||
(JSON string with the same keys). Kept as a separate class so the
|
||
canvas typing can discriminate without runtime ambiguity.
|
||
"""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None,
|
||
None,
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|
||
|
||
|
||
@comfytype(io_type="WP_SEED_LIST_CONFIG")
|
||
class SeedListConfigInput(ComfyTypeIO):
|
||
"""Custom type — Input renders as a widget via
|
||
``getCustomWidgets["WP_SEED_LIST_CONFIG"]`` (JSON string config).
|
||
Holds WP_SeedList's strategy chip + two override toggles in a
|
||
single socketless DOM widget so the canvas isn't littered with
|
||
three separate stock widgets for what is conceptually one
|
||
configuration block.
|
||
|
||
Payload: JSON string ``{strategy, override_seed, override_config}``.
|
||
``WPSeedList.execute`` parses it defensively (unknown strategy →
|
||
"hash_index", non-bool → False) before resolving against the
|
||
optional ``loop_config`` socket.
|
||
"""
|
||
|
||
Type = str
|
||
|
||
class Input(io.WidgetInput):
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
display_name: str | None = None,
|
||
optional: bool = False,
|
||
tooltip: str | None = None,
|
||
lazy: bool | None = None,
|
||
default: str | None = None,
|
||
socketless: bool | None = None,
|
||
extra_dict: dict[str, Any] | None = None,
|
||
raw_link: bool | None = None,
|
||
advanced: bool | None = None,
|
||
):
|
||
super().__init__(
|
||
id,
|
||
display_name,
|
||
optional,
|
||
tooltip,
|
||
lazy,
|
||
default,
|
||
socketless,
|
||
None,
|
||
None,
|
||
extra_dict,
|
||
raw_link,
|
||
advanced,
|
||
)
|