Files
DumiFlex-ComfyUI-Wildcard-P…/wp_nodes/context_loop.py
T
Dumitru Picalau 8d0fa337b2 chore: fixes from the 2026-10-21 review (#93)
Shorter release notes, SeedList help, small-model docs; _chat_turn split and pyright in CI; ESLint a11y ratchet and Modal focus trap; tags/models/Image Filter tests; AI LAN warning, web allow expiry, one thinking setting per answer on local models, recommended hints and Reset to recommended (16k context minimum, 32k recommended); Assembler Clear template clears the negative; Vue 3.5.44, SECURITY and Dependabot.
2026-10-22 06:15:13 +02:00

307 lines
14 KiB
Python
Raw 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"}
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.
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"),
],
not_idempotent=False,
)
@classmethod
def execute(cls, seed, count, wp_context_loop_config):
cfg = _parse_config(wp_context_loop_config)
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)
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"])
# 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"
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),
}
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])
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
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={},
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 []
return io.NodeOutput(
payloads,
loop_config_payload,
ui={"loop_seeds": [ui_seeds]},
)