Files
DumiFlex-ComfyUI-Wildcard-P…/wp_nodes/injector_node.py
T
DumiFlex ccbf38af99 feat(nodes): add wp_seedlist + split loop config types + wp_ prefix sweep + tooltips
Three coordinated changes to the WP_* node family.

1. New WP_SeedList node
   Emits a list of N derived seeds for a downstream loop (is_output_list).
   Three independent override toggles (override_seed / override_count /
   override_strategy) each mirror the wired Loop's base_seed / count /
   strategy. Connecting loop_config auto-flips override_count +
   override_strategy ON so the natural intent of wiring in a loop gets
   the defaults right; override_seed stays explicit opt-in.
   All emitted seeds are masked to 50 bits via the new MAX_SAFE_SEED
   constant in engine.seed_derive so values sit inside ComfyUI's
   frontend randomize range and copy-paste reproduces identically in a
   standalone sampler.

2. Context Loop type split + downstream Context seed widget lock
   Split WP_CONTEXT_LOOP_CONFIG (wire payload) from a new
   WP_CONTEXT_LOOP_WIDGET (DOM-widget-only TYPE). Loop's own widget
   mounts on the widget type; the wire type has no factory so consumers
   like WP_SeedList render their loop_config socket as a plain input
   instead of being hidden by a fallback-undefined widget.
   New graph walker hasUpstreamLoopOverridingSeed: WP_Context's widget
   glue greys out the local seed stock widget when an upstream Loop has
   override_seed ON, so users read "this value is dead while the Loop
   drives it" without inspecting the Loop config separately.

3. wp_ prefix sweep + tooltips across every WP_* node input
   Custom DOM widget input names now carry the wp_ prefix so canvas
   socket labels read as branded WP widgets: modules -> wp_modules,
   rows -> wp_rows, viewer -> wp_viewer, cleaner -> wp_cleaner,
   context_loop_config -> wp_context_loop_config. Every stock input
   across the node family gets a tooltip describing what it does and
   when to wire it from another node. Tests + comfy_api stub updated.
   CLAUDE.md notes the wp_ convention for future custom widgets.

Bundle size budget bumped 388 -> 395 KB to fit the SeedList Vue chunk
(~4.9 KB net gzip) + override lock UI; entry stays at ~4 KB gzipped.
2026-05-28 12:40:44 +03:00

313 lines
13 KiB
Python

"""WP_ContextInjector — lifts arbitrary ComfyUI outputs into named
$var ctx bindings on the PipelineContext.
Spec: docs/superpowers/specs/2026-05-10-context-injector-design.md
"""
from __future__ import annotations
import json
import re
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from wp_nodes.types import InjectorRowsInput, PipelineContext
_BINDING_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$")
# Tokenizer for the per-row template field. Matches `$$` (literal `$`),
# `$<name>` slot refs, and falls through to literal text.
_TEMPLATE_TOKEN_RE = re.compile(r"\$\$|\$([A-Za-z_][A-Za-z0-9_]*)")
def _render_template(template: str, slot_values: dict[str, Any]) -> str:
"""Substitute `$<slot_name>` refs with stringified socket values.
- `$$` -> literal `$`
- `$slot_name` where slot is wired -> stringified value at that slot
(Python `str()` semantics — booleans yield "True"/"False" to match
the pass-through path's downstream stringification.)
- `$slot_name` where slot is NOT wired -> left as literal `$slot_name`
so the user can spot the typo in the rendered output
"""
def repl(m: re.Match[str]) -> str:
if m.group(0) == "$$":
return "$"
name = m.group(1)
if name in slot_values:
return str(slot_values[name])
return m.group(0)
return _TEMPLATE_TOKEN_RE.sub(repl, template)
class WPContextInjector(io.ComfyNode):
"""Graph-side ctx writer. No library, no payload, no resolve-time logic.
For each enabled row with a non-empty binding AND a live wire,
writes ``ctx[binding] = stringify(value)``.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_ContextInjector",
display_name="WP Context Injector",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input(
"upstream",
optional=True,
tooltip=(
"Optional upstream Context to extend. Variables pass "
"through; this node's rows write fresh bindings on "
"top. Leave unconnected to start a new chain."
),
),
InjectorRowsInput.Input("wp_rows", socketless=True),
# NO `io.Autogrow.Input` here. V3 Autogrow declares all
# `max` slots up-front (see comfy_api/latest/_io.py:997)
# and ComfyUI's frontend doesn't shrink on disconnect.
# We manage `input_*` sockets manually on the frontend
# via litegraph's `addInput`/`removeInput` (see
# `src/widgets/injector.ts`) and rely on
# `accept_all_inputs=True` to receive whatever kwargs
# the user has wired at execute time.
],
outputs=[PipelineContext.Output("context")],
not_idempotent=True,
accept_all_inputs=True,
)
@classmethod
def execute(
cls,
wp_rows: str = "",
upstream: PipelineContext | None = None,
**slot_values: Any,
):
# V3 Autogrow namespaces dynamic inputs as `inputs.input_0`,
# `inputs.input_1`, … — but the widget JSON's `slot_name` field
# holds the bare label `input_0`. Normalize incoming kwargs to
# strip the parent input id prefix so lookups work with either
# form (whatever ComfyUI's executor decides to pass).
normalized: dict[str, Any] = {}
for key, val in slot_values.items():
if key.startswith("inputs."):
normalized[key[len("inputs."):]] = val
else:
normalized[key] = val
slot_values = normalized
upstream_ctx: dict = upstream.context if upstream is not None else {}
upstream_debug: dict = upstream.debug if upstream is not None else {}
upstream_internals: dict = upstream.internals if upstream is not None else {}
ctx: dict = dict(upstream_ctx)
for key, value in upstream_internals.items():
if isinstance(value, list):
ctx[key] = list(value)
elif isinstance(value, dict):
ctx[key] = dict(value)
else:
ctx[key] = value
# Empty rows / parse failure → forward unchanged.
try:
parsed = json.loads(wp_rows) if wp_rows else {"version": 1, "rows": []}
except json.JSONDecodeError:
parsed = {"version": 1, "rows": []}
warnings: list[dict] = []
traces: list[dict] = []
internal_keys: set[str] = set()
existing_internal = upstream_ctx.get("__wp_internal_keys__")
if isinstance(existing_internal, list):
internal_keys.update(existing_internal)
elif isinstance(existing_internal, set):
internal_keys.update(existing_internal)
rows_list = parsed.get("rows", []) if isinstance(parsed, dict) else []
def _emit_trace(
binding_name: str, stored_val: Any, trace_kind: str, is_internal: bool
) -> None:
# `value` surfaces the written ctx value in Debug's trace
# tab. Engine module traces use `writes: [{...}]` for the
# same purpose; injector trace stays flat (single-write per
# row) so a top-level `value` keeps the shape minimal while
# giving Debug something to render. Stringified to keep the
# JSON shape stable across primitive types — Debug formats
# further.
value = (
stored_val
if isinstance(stored_val, (str, int, float, bool))
else str(stored_val)
)
traces.append({
"node": "WP_ContextInjector",
"binding": binding_name,
"internal": is_internal,
"type": trace_kind,
"value": value,
})
def _validated_binding(row: dict) -> str | None:
"""Shared binding validation for both row kinds. Returns the
stripped binding, or None when the row should be skipped
(appending a warning for reserved / invalid names)."""
binding = row.get("binding", "")
if not isinstance(binding, str) or not binding.strip():
return None
stripped = binding.strip()
if stripped.startswith("_"):
warnings.append({
"type": "injector_reserved_binding",
"binding": stripped,
"row_uid": row.get("_uid"),
})
return None
if not _BINDING_RE.match(stripped):
warnings.append({
"type": "injector_invalid_binding",
"binding": stripped,
"row_uid": row.get("_uid"),
})
return None
return stripped
# Two-tier row model:
# - SOCKET rows (kind absent / "socket"): bind one wired socket
# to a $variable. An optional template substitutes ONLY the
# row's OWN `$input_N` — rows resolve top-to-bottom, so an
# early row must not read a later socket's value.
# - GENERAL rows (kind "general"): durable, not tied to a
# socket. Survives socket disconnect/reconnect. Resolved
# AFTER all socket rows so its template can reference BOTH the
# raw sockets (`$input_N`) AND the variables produced by the
# socket rows (`$test`).
socket_rows: list[dict] = []
general_rows: list[dict] = []
for row in rows_list:
if not isinstance(row, dict):
continue
if row.get("kind") == "general":
general_rows.append(row)
else:
socket_rows.append(row)
# ── Pass 1: socket rows (existing order) ──────────────────────
for row in socket_rows:
if not row.get("enabled", False):
continue
stripped = _validated_binding(row)
if stripped is None:
continue
slot_name = row.get("slot_name", "")
template_raw = row.get("template", None)
template_str = (
template_raw.strip()
if isinstance(template_raw, str) and template_raw.strip()
else ""
)
# Pass-through path: row has no template, just needs the
# wired socket value. Requires the slot to be wired or the
# row is skipped (matches legacy behavior).
if not template_str:
if not isinstance(slot_name, str) or slot_name not in slot_values:
continue
value = slot_values[slot_name]
if isinstance(value, bool):
stored: Any = value
elif isinstance(value, (str, int, float)):
stored = value
else:
stored = str(value)
trace_type = type(value).__name__
else:
# Template path: render `$<slot_name>` substitutions
# against ONLY this row's OWN socket. Other `$input_M`
# refs are out of scope (left as literal `$input_M`, same
# as any unknown ref) — top-to-bottom resolution means an
# early socket row must not read a later socket. To
# compose multiple sockets, use a GENERAL row instead.
own_slot: dict[str, Any] = (
{slot_name: slot_values.get(slot_name, "")}
if isinstance(slot_name, str)
else {}
)
stored = _render_template(template_str, own_slot)
trace_type = "str(template)"
ctx[stripped] = stored
is_internal = bool(row.get("internal", False))
if is_internal:
internal_keys.add(stripped)
_emit_trace(stripped, stored, trace_type, is_internal)
# ── Pass 2: general rows (in order, after all socket rows) ────
# A general row resolves its template against `merged`: every
# non-`__`-prefixed ctx var written so far (socket-row bindings +
# any earlier general-row binding) overlaid with the raw sockets.
# So `$test` (a socket-row output) AND `$input_0` (a raw socket)
# both resolve. Processing in order lets a later general row read
# an earlier general row's binding.
for row in general_rows:
if not row.get("enabled", False):
continue
stripped = _validated_binding(row)
if stripped is None:
continue
template_raw = row.get("template", None)
template_str = (
template_raw.strip()
if isinstance(template_raw, str) and template_raw.strip()
else ""
)
# A general row has no socket — it needs a non-empty template
# to produce anything. Skip otherwise.
if not template_str:
continue
merged: dict[str, Any] = {
k: v for k, v in ctx.items() if not k.startswith("__")
}
merged.update(slot_values)
stored = _render_template(template_str, merged)
ctx[stripped] = stored
is_internal = bool(row.get("internal", False))
if is_internal:
internal_keys.add(stripped)
_emit_trace(stripped, stored, "str(template)", is_internal)
if internal_keys:
ctx["__wp_internal_keys__"] = sorted(internal_keys)
# Carry user-marked-internal bindings on `__wp_internal_flags__` in the
# cross-node `internals` field — the SAME channel WP_Context +
# WP_ContextLoop use, and the only one the PromptAssembler consults
# (via `strip_internals`) to keep an internal var out of the rendered
# prompt. Writing only `__wp_internal_keys__` above left the toggle
# cosmetic: nothing downstream stripped on it.
out_internals = dict(upstream_internals)
if internal_keys:
flags = dict(out_internals.get("__wp_internal_flags__") or {})
for name in internal_keys:
flags[name] = True
out_internals["__wp_internal_flags__"] = flags
debug = dict(upstream_debug)
if traces:
existing_trace = debug.get("__wp_trace__", [])
debug["__wp_trace__"] = list(existing_trace) + traces
if warnings:
existing_warn = debug.get("__wp_warnings__", [])
debug["__wp_warnings__"] = list(existing_warn) + warnings
return io.NodeOutput(
PipelineContext.Type(
context=ctx,
debug=debug,
internals=out_internals,
)
)