Files
DumiFlex e41ae4ded0 feat(debug): trace steps say why they came out that way, per kind (#370)
Wildcard steps list their options with weights and why each was cut, the pool chain, locked seed, separator and matched value; fixed values, combines and node templates show what they read; constraint steps link the steps they re-weighted; Model Info, Toggles, Injector and Context Loop rows carry their own facts.
2026-11-12 02:33:22 +01:00

405 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
"""WP_ContextLoop — optional loop-head node for WP_Context chains.
Emits N PIPELINE_CONTEXT outputs (`is_list=True`) so ComfyUI auto-iterates
the entire downstream chain N times in a single run. Each emitted payload
carries `__wp_loop_index__` + (optionally) `__wp_seed_override__` in its
`internals` dict; WP_Context downstream picks these up via
`engine.seed_derive.effective_chain_seed` and varies per-iteration without
disturbing locked-seed modules (whose precedence runs at the per-module
handler level, untouched by this node).
"""
import json
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine.seed_derive import apply_seed_locks, derive_loop_seeds
from engine.sweep import HOLD_KEY, PIN_KEY, parse_sweep, sweep_frames, sweep_total
from wp_nodes.types import (
FRAME0_KEY,
ContextLoopConfigInput,
ContextLoopWidgetInput,
ContextPayload,
PipelineContext,
)
_STRATEGIES = {"sequential", "hash_index", "prime_stride"}
#: Most frames a loop emits (the `count` widget's max).
_MAX_FRAMES = 999
def _first(values: Any, default: Any = None) -> Any:
"""A widget's value under ``is_input_list`` (ComfyUI wraps it in a list)."""
if isinstance(values, list):
return values[0] if values else default
return default if values is None else values
def _wired_values(values: Any) -> list[str]:
"""The ``values`` input's entries: every list entry, one per line, blank
lines dropped. Empty when nothing is wired."""
if values is None:
return []
out: list[str] = []
for entry in values if isinstance(values, list) else [values]:
if entry is None:
continue
out.extend(line.strip() for line in str(entry).splitlines() if line.strip())
return out
def _parse_config(raw: str) -> dict[str, Any]:
"""Recovery-friendly JSON parse for the DOM widget value.
Unknown keys ignored; missing keys backfilled from defaults; type
mismatches collapse to defaults. Mirrors the TS-side recovery path
so a workflow with a malformed widget value still loads instead of
crashing the run.
"""
defaults: dict[str, Any] = {
"strategy": "hash_index",
"override_seed": False,
"iteration_var_name": "iteration",
"bypass": False,
"iteration_internal": True,
"total_internal": True,
"seed_locks": {},
"bypass_frames": [],
"sweep": parse_sweep(None),
}
if not raw or not isinstance(raw, str):
return defaults
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return defaults
if not isinstance(parsed, dict):
return defaults
out = dict(defaults)
if parsed.get("strategy") in _STRATEGIES:
out["strategy"] = parsed["strategy"]
out["override_seed"] = bool(parsed.get("override_seed", False))
name = parsed.get("iteration_var_name", "iteration")
if isinstance(name, str) and name.strip():
out["iteration_var_name"] = name.strip()
out["bypass"] = bool(parsed.get("bypass", False))
out["iteration_internal"] = bool(parsed.get("iteration_internal", True))
out["total_internal"] = bool(parsed.get("total_internal", True))
locks_raw = parsed.get("seed_locks", {})
locks: dict[int, int] = {}
if isinstance(locks_raw, dict):
for k, v in locks_raw.items():
try:
locks[int(k)] = int(v)
except (TypeError, ValueError):
continue
out["seed_locks"] = locks
frames_raw = parsed.get("bypass_frames", [])
frames: set[int] = set()
if isinstance(frames_raw, list):
for x in frames_raw:
if isinstance(x, bool): # bool is an int subclass — exclude
continue
if isinstance(x, int):
n = x
elif isinstance(x, float) and x.is_integer():
n = int(x)
else:
continue
if n >= 0:
frames.add(n)
out["bypass_frames"] = sorted(frames)
out["sweep"] = parse_sweep(parsed.get("sweep"))
return out
class WPContextLoop(io.ComfyNode):
"""Optional loop head — emits N PIPELINE_CONTEXT outputs in one run."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_ContextLoop",
display_name="WP Context Loop",
category="wildcard-pipeline",
inputs=[
io.Int.Input(
"seed",
default=0,
min=0,
max=0xFFFFFFFFFFFFFFFF,
control_after_generate=True,
tooltip=(
"Starting point for randomisation. Change for a different "
"roll; keep the same to reproduce. Right-click → Convert "
"widget to input to drive from another seed node."
),
),
io.Int.Input(
"count",
default=1,
min=1,
max=999,
tooltip=(
"How many iterations the series runs (1–999). Set to 1 "
"to disable looping without removing the node."
),
),
# Widget-only TYPE (`WP_CONTEXT_LOOP_WIDGET`), distinct
# from the wire-payload type (`WP_CONTEXT_LOOP_CONFIG`)
# used on the output below + on WP_SeedList's
# `loop_config` socket. Separating the two types lets the
# `getCustomWidgets` factory register against the widget
# type only — the wire type has NO factory, so ComfyUI
# renders consumers (SeedList) as plain sockets and
# Loop's own input stays a DOM widget. The previous
# single-type design forced an `inputName` gate in
# main.ts that ended up hiding SeedList's socket
# entirely.
io.String.Input(
"values",
optional=True,
force_input=True,
tooltip=(
"Optional text from another node: one frame per line (or per "
"list entry), each writing its line to $iteration_value. Sets "
"the frame count; with a sweep on, every value runs every "
"combination."
),
),
ContextLoopWidgetInput.Input(
"wp_context_loop_config",
socketless=True,
default="{}",
),
],
outputs=[
# `is_output_list=True` is the V3 contract for emitting a
# list that ComfyUI auto-iterates downstream. Not `is_list`
# (that was wrong — stub accepted any kwarg, real API
# rejects). See `comfy_api/latest/_io.py:Output.__init__`.
PipelineContext.Output("context", is_output_list=True),
# Single-value side output: the resolved config payload
# so sibling helpers (WP_SeedList etc.) can mirror the
# loop's count / strategy / base_seed without the user
# double-wiring widgets. Plain (non-list) — receiving
# nodes get one dict per graph run regardless of the
# context-fan-out happening on the first output.
ContextLoopConfigInput.Output("loop_config"),
],
# A list wired into `values` must arrive in ONE call (one loop),
# not run the whole loop once per entry.
is_input_list=True,
not_idempotent=False,
# The node id stamps the Loop's trace rows so WP Debug groups
# them under this node.
hidden=[io.Hidden.unique_id],
)
@classmethod
def execute(cls, seed, count, wp_context_loop_config, values=None):
seed = _first(seed, 0)
count = _first(count, 1)
cfg = _parse_config(_first(wp_context_loop_config, "{}"))
wired_values = _wired_values(values)
override_seed = bool(cfg["override_seed"])
strategy = cfg["strategy"]
iteration_var_name = cfg["iteration_var_name"]
bypass = bool(cfg["bypass"])
iteration_internal = bool(cfg["iteration_internal"])
total_internal = bool(cfg["total_internal"])
seed_locks: dict[int, int] = cfg["seed_locks"] # type: ignore[assignment]
bypass_frames: list[int] = cfg["bypass_frames"] # type: ignore[assignment]
# Resolve which frames to emit + the seed series they draw from.
#
# - Whole-loop `bypass` (existing behavior) collapses to a single
# run while preserving the list-shape output contract.
# - Per-frame bypass (`bypass_frames`, 0-based) skips individual
# iterations WITHOUT renumbering the kept ones: each kept frame
# keeps its original index as `__wp_loop_index__`, so it draws
# `derived[idx]` and its per-frame overrides still key correctly.
# Out-of-range indices are ignored; an all-bypassed config floors
# to a full run so the `is_output_list` socket is never empty.
# The seed series is derived for the FULL count (not the reduced
# one) so kept frame N always draws `derived[N]`.
has_override = override_seed
configured_count = max(1, int(count))
# Sweep mode: one frame per combination of the chosen wildcards'
# options, capped at the sweep limit. The combination count replaces
# `count` (the widget keeps `count` in step, but the config is the
# authority so an API-queued workflow still sweeps correctly).
sweep = cfg["sweep"]
sweep_pins: list[dict[str, str]] = []
if sweep["enabled"] and sweep["axes"] and not bypass:
sweep_pins = sweep_frames(sweep["axes"], sweep["limit"])
if sweep_pins:
configured_count = len(sweep_pins)
# A wired value list sets the frame count: every value runs every
# sweep combination (the value is the outer, slower axis).
combos = max(1, len(sweep_pins))
if wired_values and not bypass:
wired_values = wired_values[: max(1, _MAX_FRAMES // combos)]
configured_count = len(wired_values) * combos
if bypass:
total_count = 1
kept = [0]
else:
in_range_bypassed = {
i for i in bypass_frames if 0 <= i < configured_count
}
kept = [i for i in range(configured_count) if i not in in_range_bypassed]
if not kept:
kept = list(range(configured_count))
total_count = configured_count
derived = (
apply_seed_locks(
derive_loop_seeds(int(seed), total_count, strategy),
seed_locks,
)
if has_override else None
)
# Build the loop_config payload once so the same dict drives both
# the per-iteration internals AND the side output. `count` is the
# configured/effective total (so WP_SeedList + the seed modal
# re-derive the identical full series); `active_count` is how many
# frames actually emit after per-frame bypass. `base_seed` carries
# the user's `seed` input verbatim so WP_SeedList can re-derive the
# same series locally.
loop_config_payload: dict[str, object] = {
"count": total_count,
"active_count": len(kept),
# The 0-based indices this loop actually emits (== range(count)
# for a normal run). A wired WP_SeedList mirrors this so it emits
# ONE seed per kept frame — otherwise it would derive the full
# `count` seeds and the sampler would run more times than the
# loop has prompts.
"kept_indices": list(kept),
"strategy": strategy,
"base_seed": int(seed),
"override_seed": has_override,
}
if sweep_pins:
# Combinations before the limit, so a UI can say "64 of 120".
loop_config_payload["sweep_total"] = sweep_total(sweep["axes"])
if wired_values:
loop_config_payload["values_count"] = len(wired_values)
# Build the internal-flags map once — same across iterations
# (per-iteration values differ but their internal-ness doesn't).
# Engine reads `__wp_internal_flags__` from the payload internals
# at downstream merge time; PromptAssembler then strips internal
# keys at render so they never leak into prompts.
total_var_name = f"{iteration_var_name}_total"
value_var_name = f"{iteration_var_name}_value"
internal_flags: dict[str, bool] = {}
if iteration_internal:
internal_flags[iteration_var_name] = True
if total_internal:
internal_flags[total_var_name] = True
def frame(idx: int) -> tuple[dict[str, str], dict[str, object]]:
# User-facing iteration counter is the frame's ORIGINAL 1-based
# number (bypass keeps identity — frame 5 stays "5 of 18", not
# renumbered). The internal `__wp_loop_index__` below stays the
# 0-based original index because `derived[idx]` indexes the
# full series + `effective_chain_seed` math assumes zero-origin
# (loop_index=0 means "no XOR shift"). `total` is the configured
# count so bypassing a frame never rewrites the other frames'
# "of N" text (which would bust their downstream caches).
context_vars = {
iteration_var_name: str(idx + 1),
total_var_name: str(total_count),
}
if wired_values:
# Bypass collapses to frame 0, which reads the first value.
pick = min(idx // combos, len(wired_values) - 1)
context_vars[value_var_name] = wired_values[pick]
internals: dict[str, object] = {
"__wp_loop_index__": idx,
"__wp_seed_override__": (derived[idx] if has_override else None),
}
# Only stamp the derived series when override is active —
# otherwise the values would be meaningless (downstream uses
# widget seeds). WP_Debug renders this only when present.
if has_override:
internals["__wp_loop_seeds__"] = list(derived or [])
if sweep_pins:
internals[PIN_KEY] = dict(sweep_pins[idx % combos])
internals[HOLD_KEY] = bool(sweep["hold_others"])
if internal_flags:
# Merge by-value so successive iterations don't share
# the same dict reference; downstream WP_Context copies
# internals defensively but explicit copy here is safer.
internals["__wp_internal_flags__"] = dict(internal_flags)
return context_vars, internals
hidden = getattr(cls, "hidden", None)
node_id = getattr(hidden, "unique_id", None) if hidden is not None else None
def frame_trace(idx: int, context_vars: dict[str, str]) -> list[dict[str, Any]]:
# One WP Debug trace row per variable the Loop writes, so Debug
# names the Loop as their writer and shows the frame it ran.
detail: dict[str, Any] = {"frame": idx + 1, "of": total_count}
if has_override and derived:
detail["seed"] = int(derived[idx])
if sweep_pins:
detail["combo"] = idx % combos + 1
detail["combos"] = combos
if wired_values:
detail["value_line"] = min(idx // combos, len(wired_values) - 1) + 1
detail["values"] = len(wired_values)
if bypass:
detail["bypass"] = True
if has_override:
detail["strategy"] = strategy
if len(kept) < total_count:
detail["skipped"] = total_count - len(kept)
if sweep_pins:
# This frame's sweep pins: module uid → option id.
detail["pins"] = dict(sweep_pins[idx % combos])
detail["hold_others"] = bool(sweep["hold_others"])
rows: list[dict[str, Any]] = []
for name, value in context_vars.items():
row: dict[str, Any] = {
"node": "WP_ContextLoop",
"binding": name,
"internal": bool(internal_flags.get(name)),
"type": "value" if name == value_var_name else (
"total" if name == total_var_name else "iteration"
),
"value": value,
"detail": detail,
}
if node_id is not None:
row["node_id"] = str(node_id)
rows.append(row)
return rows
payloads = []
for idx in kept:
context_vars, internals = frame(idx)
if idx != 0:
# Frame 0's payload rides along so a held module downstream
# resolves against frame 0's inputs on every frame (its
# $iteration, and whatever a Context derives from it).
ctx0, int0 = frame(0)
internals[FRAME0_KEY] = {"context": ctx0, "internals": int0}
payloads.append(ContextPayload(
context=context_vars,
debug={"__wp_trace__": frame_trace(idx, context_vars)},
internals=internals,
))
# Surface the per-iteration series to the node UI so the frontend can
# offer "lock previous" in the seed modal (captured in context_loop.ts).
# Only meaningful when the Loop drives the seeds (override on); an empty
# list otherwise greys the button out per-frame.
ui_seeds = [int(s) for s in derived] if (has_override and derived) else []
ui: dict[str, list[Any]] = {"loop_seeds": [ui_seeds]}
if wired_values:
# The widget shows the frame count the wire set, after a run.
ui["loop_values"] = [{"count": len(wired_values), "frames": total_count}]
return io.NodeOutput(payloads, loop_config_payload, ui=ui)