Compare commits

...
39 Commits
Author SHA1 Message Date
semantic-release-bot 06c380fd01 chore(release): 2.8.0 [skip ci] 2026-06-26 03:44:39 +00:00
DumiFlex c48aafb143 fix(context): scope the wrapping grid to the frame chips only
The earlier frame-chip wrap fix put the grid + ellipsis on the shared
.wp-loop__chips/.wp-loop__chip rules, which also style the 3 strategy chips —
truncating 'sequential' to 'seq...'. Move the grid to a .wp-loop__chips--frames
modifier on the edit-frame row only and drop the ellipsis, so the strategy
chips keep full labels on one row while the frame chips still wrap.
2026-06-26 06:43:58 +03:00
DumiFlex ff4d8faa1f feat(manager): snapshot source/target tag_groups into published constraints
A standalone constraint references its source/target wildcards by id but
carries no axis grouping of its own — so the community preview rendered its
matrix flat + uncoloured when the wildcards weren't also published. Backfill
each wildcard's tag_groups into the published constraint as source_groups /
target_groups (sibling of the existing axis-name backfill), making the
grouping self-contained. Preview-only inner-payload metadata; the constraint
validator's inner z.object strips it on a consumer's install.
2026-06-26 06:43:58 +03:00
DumiFlex c7c867e531 feat(context): add lock-previous-seed to the iteration-seed modals
The Context Loop + Seed List seed modals show the UPCOMING derived series
(deriveLoopSeeds of the CURRENT base); under control_after_generate=randomize
that won't reproduce the last run. Add a per-frame Prev button that fills +
locks the seed the frame ACTUALLY used last run, greyed out when there's no
captured previous (no prior run, or the Loop wasn't driving seeds).

The nodes emit their executed per-iteration series as loop_seeds in the node
UI; a shared _seed-capture helper captures it on the executed event (mirroring
WP_Context) and the glue threads it through the widgets -> SeedListModal ->
SeedLockRow.
2026-06-26 06:43:57 +03:00
DumiFlex 2fb853fa81 fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips
The runtime frame-active hint read as if it scoped the Lock pick (the one
control that IS per-frame editable); reword it to name Hide-from-prompt and
Hold-across-run — the toggles it actually disables. And switch the Context
Loop edit-frame chips from a non-wrapping flex row to an auto-fill grid so 15+
frames wrap instead of overflowing the node.
2026-06-26 06:43:56 +03:00
DumiFlex e1885a3115 fix(context): sweep redundant frame lock-null when base unlocks via the modal 2026-06-26 06:43:55 +03:00
DumiFlex f38a316ccb fix(context): clear redundant frame lock-null override when the base unlocks 2026-06-26 06:43:54 +03:00
DumiFlex e56fd22bc9 fix(context): modal lock button pins the rolled seed via injected resolver 2026-06-26 06:43:53 +03:00
DumiFlex b33afd8797 fix(context): capture the loop's per-frame seeds so the lock pins the previous roll 2026-06-26 06:43:52 +03:00
DumiFlex 67a09df6d6 fix(extension): read loop seed and count as numbers for per-frame seed lock 2026-06-26 06:43:51 +03:00
DumiFlex 1f164df0c5 fix(context): per-frame seed lock computes the frame's real seed from the loop 2026-06-26 06:43:50 +03:00
DumiFlex 91b5f7581a revert(context): drop per-frame seed capture, superseded by double-shift fix 2026-06-26 06:43:50 +03:00
DumiFlex 3527ada397 fix(engine): stop double-shifting the per-iteration override seed 2026-06-26 06:43:49 +03:00
DumiFlex 25388b4650 fix(context): per-frame seed lock captures that frame's own rolled seed 2026-06-26 06:43:48 +03:00
DumiFlex 6eb4b730cb fix(context): per-frame enable overrides base enabled (frame_enabled) 2026-06-26 06:43:47 +03:00
DumiFlex 942834d16a fix(engine): apply iteration_overrides for generic-object modules 2026-06-26 06:43:46 +03:00
DumiFlex 03796c56ba fix(engine): include disabled_frames in the generic-object snapshot keys 2026-06-26 06:43:45 +03:00
DumiFlex 65683aed7c fix(context): coerce disabled_frames to int set and guard frame toggle 2026-06-26 06:43:44 +03:00
DumiFlex 8a9dc9efbd feat(context): per-frame enable/disable via disabled_frames
- add `disabled_frames?: number[]` to `ModuleEntry` (_shared.ts)
- engine gate reads the field (dict + object branches) and skips the
  module at the listed loop indices with status `skipped_frame`
- `toggleEnabled` becomes frame-aware: when a frame is active it
  adds/removes the frame index from `disabled_frames` instead of
  flipping the base `enabled` flag; empties the array to remove the key
- add `isDisabledOnFrame` helper to `ContextWidget` + `ModuleRowCtx`
- checkbox reflects effective enabled at the frame; adds an `off #k` badge
- TDD: 3 engine tests + 4 ModuleRow.test.ts cases
2026-06-26 06:43:44 +03:00
DumiFlex 8f696d6eec refactor(context): unify frame-hint markup and add false-case coverage 2026-06-26 06:43:43 +03:00
DumiFlex 1822297a9a feat(context): disable run-level controls while editing a frame 2026-06-26 06:43:42 +03:00
DumiFlex 21f285033d feat(context): make the row quick-lock act on the active frame 2026-06-26 06:43:41 +03:00
DumiFlex bb5e84d3b2 fix(context): restrict per-frame overrides to an allowlist of meaningful fields 2026-06-26 06:43:40 +03:00
DumiFlex b292699487 fix(context): add hover states to the per-kind edit modal footer buttons 2026-06-26 06:43:39 +03:00
DumiFlex 39fdb6f187 fix(context): resync editor draft on frame revert + restyle the banner for readability 2026-06-26 06:43:38 +03:00
DumiFlex 3c492f1cb9 fix(context): stack the frame banner above the editor card instead of beside it 2026-06-26 06:43:37 +03:00
DumiFlex 79cf3857ad feat(context): frame banner with revert-to-base in the module editor 2026-06-26 06:43:36 +03:00
DumiFlex e93f7ba06e feat(context): held and per-frame override row badges 2026-06-26 06:43:35 +03:00
DumiFlex 4e71e043e7 feat(context): redirect edits into iteration_overrides when a frame is active 2026-06-26 06:43:34 +03:00
DumiFlex 7b416ad0ed feat(context): add pure frame-override helpers 2026-06-26 06:43:34 +03:00
DumiFlex ece6d49929 feat(context-loop): add edit-frame selector chip row 2026-06-26 06:43:33 +03:00
DumiFlex 6dc033d57e feat(context-loop): add session frame-cursor singleton 2026-06-26 06:43:32 +03:00
DumiFlex 9663636d10 feat(context): add hold-across-run toggle to the runtime sections 2026-06-26 06:43:31 +03:00
DumiFlex 8910b98a86 feat(types): add seed_scope and iteration_overrides to module config 2026-06-26 06:43:30 +03:00
DumiFlex 10dcac4100 feat(engine): apply per-frame iteration_overrides onto module instance 2026-06-26 06:43:29 +03:00
DumiFlex 15c1ade3ef feat(engine): fixed_values honors hold seed_scope 2026-06-26 06:43:28 +03:00
DumiFlex 8871a2806d feat(engine): combine honors hold seed_scope 2026-06-26 06:43:28 +03:00
DumiFlex 072e4e7836 feat(engine): wildcard honors hold seed_scope, locked_seed still wins 2026-06-26 06:43:27 +03:00
DumiFlex 0f7aaabe3c feat(engine): thread a hold seed and loop index into the pipeline run 2026-06-26 06:43:26 +03:00
73 changed files with 2477 additions and 78 deletions
+49
View File
@@ -1,3 +1,52 @@
## 🎉 Wildcard Pipeline 2.8.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
</details>
## 🎉 Wildcard Pipeline 2.7.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+4 -1
View File
@@ -101,7 +101,10 @@ class CombineHandler(ModuleHandler):
# - chain seed otherwise → re-rolls each queue
# Both feed derive_module_rng(seed, output_var) so locking with
# locked_seed = chain_seed reproduces the unlocked roll exactly.
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
if instance.get("seed_scope") == "hold":
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
+6 -1
View File
@@ -150,7 +150,12 @@ class FixedValuesHandler(ModuleHandler):
return out
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
if instance.get("seed_scope") == "hold":
chain_seed = int(
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
+4 -1
View File
@@ -645,7 +645,10 @@ class WildcardHandler(ModuleHandler):
# unlocked pick exactly. This is the contract the lock UX
# relies on: the user sees a roll, locks it, captures the
# current chain seed, future runs reproduce the same roll.
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
if instance.get("seed_scope") == "hold":
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if locked_seed is not None:
try:
+67 -8
View File
@@ -244,10 +244,14 @@ class PipelineEngine:
modules: list[Module],
ctx: Context | None = None,
seed: int = 0,
hold_seed: int | None = None,
loop_index: int = 0,
) -> Context:
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
ctx = {} if ctx is None else ctx
ctx["__wp_node_seed__"] = seed
ctx["__wp_node_seed_hold__"] = hold_seed if hold_seed is not None else seed
ctx["__wp_loop_index__"] = loop_index
ctx["__wp_rng__"] = random.Random(seed)
ctx.setdefault("__wp_warnings__", [])
ctx.setdefault("__wp_trace__", [])
@@ -296,18 +300,52 @@ class PipelineEngine:
_module_name = getattr(module, "name", "") or ""
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
if not _module_enabled:
# Disabled modules don't run — but the trace row still
# surfaces what the module would have written, so the
# debug viewer can label it `$varname (disabled)`
# instead of falling back to an opaque short-uuid.
_k = int(ctx.get("__wp_loop_index__", 0))
# Per-frame enable override. `frame_enabled[str(k)]` overrides the
# base `enabled` flag for frame k in EITHER direction: a base-off
# module can be turned ON for select frames, a base-on module OFF
# for others. effective = frame_enabled.get(k, base_enabled).
# Legacy `disabled_frames` (a one-way blocklist) is still honoured —
# each listed frame reads as frame_enabled[k]=False.
_frame_enabled_map = (
module.get("frame_enabled")
if isinstance(module, dict)
else getattr(module, "frame_enabled", None)
)
_legacy_disabled = (
module.get("disabled_frames")
if isinstance(module, dict)
else getattr(module, "disabled_frames", None)
)
_frame_override: bool | None = None
if isinstance(_frame_enabled_map, dict) and str(_k) in _frame_enabled_map:
_frame_override = bool(_frame_enabled_map[str(_k)])
elif isinstance(_legacy_disabled, (list, tuple)) and _k in {
int(f)
for f in _legacy_disabled
if isinstance(f, (int, float)) and not isinstance(f, bool)
}:
_frame_override = False
_effective_enabled = (
_frame_override if _frame_override is not None else bool(_module_enabled)
)
if not _effective_enabled:
# Module is suppressed this frame. Distinguish a per-frame
# disable (a frame override forced it off) from a global
# disable (base off, no frame override) so the debug viewer
# can label `skipped_frame` vs `skipped_disabled`. Either way
# the trace row still surfaces what it WOULD have written so
# the debug viewer can label `$varname (disabled)` rather than
# falling back to an opaque short-uuid.
_frame_forced_off = _frame_override is False
meta = _extract_static_meta(module)
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"type": _module_type_raw,
"enabled": False,
"status": "skipped_disabled",
"enabled": bool(_module_enabled) if _frame_forced_off else False,
"status": "skipped_frame" if _frame_forced_off else "skipped_disabled",
"writes": [],
"error": None,
**meta,
@@ -325,17 +363,38 @@ class PipelineEngine:
else:
# Generic object: pull known snapshot keys via getattr so
# class-level attributes (common in tests) are visible.
# `iteration_overrides` is load-bearing — the per-frame
# override patch below reads it back off `raw`. `disabled_frames`
# is listed for snapshot honesty only (the skip gate already
# read it module-direct above).
raw = {
k: getattr(module, k)
for k in (
"id", "type", "enabled", "payload", "entries",
"meta", "library_id", "library_snapshot_at",
"library_version_at_snapshot", "name", "category_id",
"instance",
"instance", "iteration_overrides",
"disabled_frames", "frame_enabled",
)
if hasattr(module, k)
}
snapshot = coerce_legacy_module(raw)
# Per-frame iteration override: patch snapshot["instance"]
# for the current loop index BEFORE the handler runs.
# iteration_overrides is a dict keyed by stringified index;
# the matching partial dict is shallow-merged into instance.
_ov = (
raw.get("iteration_overrides")
if isinstance(raw, dict)
else getattr(module, "iteration_overrides", None)
)
if isinstance(_ov, dict):
_frame_key = str(ctx.get("__wp_loop_index__", 0))
_patch = _ov.get(_frame_key)
if isinstance(_patch, dict) and isinstance(
snapshot.get("instance"), dict
):
snapshot["instance"] = {**snapshot["instance"], **_patch}
except Exception as e:
logger.warning(
"Failed to coerce module %r at index %s: %s", module, index, e
+8
View File
@@ -33,6 +33,10 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"category_filter": "string",
"exclude_null": "boolean",
"locked_seed": "number",
# Iteration seed scope: "hold" pins RNG to the run base seed (same
# across loop iterations, re-rolls per run); "vary" (default/absent)
# rides the per-iteration seed. `locked_seed` takes priority when set.
"seed_scope": "string",
"internal": "boolean",
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
# range other than 1..1 resolves N options without replacement into a
@@ -59,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
# resolution gives fixed_values seed-lock parity with wildcard
# + combine. Locked seed pins the alternation outcome.
"locked_seed": "number",
# Iteration seed scope: hold/vary control (parity with wildcard).
"seed_scope": "string",
},
"combine": {
# v2 single-pane modal exposes a richer override surface:
@@ -69,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"template_override": "string",
"variable_binding": "string",
"locked_seed": "number",
# Iteration seed scope: hold/vary control (parity with wildcard).
"seed_scope": "string",
"internal": "boolean",
},
"derivation": {
+10 -3
View File
@@ -66,8 +66,10 @@ def effective_chain_seed(
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
base when present.
2. Otherwise ``widget_seed`` is the base.
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
→ no-op (backwards-compat for chains without ContextLoop).
3. ``loop_index`` is mixed via XOR with a stable hash ONLY when there is
no override (to vary a constant ``widget_seed`` per iteration). An
override is already per-iteration (the ContextLoop derived series) so
it is used as-is. ``loop_index=0`` → no-op.
Locked-module precedence lives in the per-module handlers, NOT here.
Locked modules read ``instance.locked_seed`` directly and ignore the
@@ -77,7 +79,12 @@ def effective_chain_seed(
matches the visual shape of ComfyUI's frontend-randomised seeds.
"""
base = seed_override if seed_override is not None else widget_seed
if loop_index == 0:
# An override IS the loop's already-per-iteration derived seed → use it
# verbatim. The loop_index XOR exists only to vary a CONSTANT widget seed
# (the override-OFF path); re-mixing an override that already differs per
# iteration is a double-shift that made the actual seed diverge from the
# derived series the preview + per-iteration seed-modal locks show.
if loop_index == 0 or seed_override is not None:
return base & MAX_SAFE_SEED
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
shift = int.from_bytes(digest[:8], "big")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.7.0",
"version": "2.8.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.7.0"
version = "2.8.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+22 -1
View File
@@ -194,7 +194,28 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
const TOTAL_LIMIT = 445 * 1024; // 445 KB
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
// gzip in the lazy ContextWidget chunk; entry stays put.
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
// blocklist so a base-off module can be turned ON for select frames). Adds
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
// 457868 bytes (140 over the 447 ceiling); minimal bump.
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
// per-frame seed under control_after_generate=randomize) plus the modal
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
const TOTAL_LIMIT = 449 * 1024; // 449 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
@@ -1,8 +1,9 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import ContextLoopWidget from "./ContextLoopWidget.vue";
import SeedListModal from "../shared/SeedListModal.vue";
import { emptyContextLoopConfig } from "./types";
import { currentFrame } from "./frame-cursor";
describe("ContextLoopWidget", () => {
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
@@ -127,3 +128,23 @@ describe("ContextLoopWidget seeds button", () => {
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
});
});
describe("ContextLoopWidget frame selector", () => {
beforeEach(() => { currentFrame.value = null; });
function fw(count = 3) {
return mount(ContextLoopWidget, {
props: { modelValue: emptyContextLoopConfig(), count },
attachTo: document.body, global: { stubs: { teleport: true } },
});
}
it("renders base + one chip per iteration", () => {
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
});
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
const wr = fw(3);
await wr.find('[data-test="loop-frame-2"]').trigger("click");
expect(currentFrame.value).toBe(1);
await wr.find('[data-test="loop-frame-base"]').trigger("click");
expect(currentFrame.value).toBe(null);
});
});
@@ -15,6 +15,7 @@
import { computed, ref } from "vue";
import type { ContextLoopConfig, LoopStrategy } from "./types";
import SeedListModal from "../shared/SeedListModal.vue";
import { currentFrame, setFrame } from "./frame-cursor";
const props = withDefaults(
defineProps<{
@@ -26,8 +27,12 @@ const props = withDefaults(
baseSeed?: number;
/** Iteration count forwarded from the host ComfyUI INT widget. */
count?: number;
/** The seed each frame used on the PREVIOUS run, captured from the node's
* executed `loop_seeds` UI payload by the host glue. Drives the seed
* modal's per-frame "lock previous" button. Null until a run lands. */
previousSeeds?: number[] | null;
}>(),
{ nodeMode: 0, baseSeed: 0, count: 1 },
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
);
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
@@ -50,6 +55,9 @@ const OVERRIDE_SEED_TOOLTIP =
const isMuted = computed<boolean>(() => props.nodeMode === 2);
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
const frameChips = computed(() =>
Array.from({ length: Math.max(1, props.count ?? 1) }, (_, i) => i),
);
const seedsOpen = ref(false);
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
@@ -128,6 +136,17 @@ function toggleTotalInternal(): void {
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
</button>
<div class="wp-loop__section">
<div class="wp-loop__label">edit frame</div>
<div class="wp-loop__chips wp-loop__chips--frames" role="radiogroup" aria-label="Edit frame">
<button type="button" class="wp-loop__chip" :class="{ 'wp-loop__chip--active': currentFrame === null }"
data-test="loop-frame-base" role="radio" :aria-checked="currentFrame === null" @click="setFrame(null)">base</button>
<button v-for="i in frameChips" :key="i" type="button" class="wp-loop__chip"
:class="{ 'wp-loop__chip--active': currentFrame === i }"
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i" @click="setFrame(i)">#{{ i + 1 }}</button>
</div>
</div>
<div
class="wp-loop__row"
:title="OVERRIDE_SEED_TOOLTIP"
@@ -204,6 +223,7 @@ function toggleTotalInternal(): void {
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:previous-seeds="previousSeeds"
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
</div>
@@ -229,9 +249,18 @@ function toggleTotalInternal(): void {
}
.wp-loop__chips { display: flex; gap: 4px; }
/* Frame chips ONLY (the strategy row has 3 fixed chips that must stay one
row + show full labels): grid so 15+ frame chips WRAP instead of
overflowing the node. auto-fill keeps a uniform cell width; the last row
grows to 1fr to fill it. */
.wp-loop__chips--frames {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
}
.wp-loop__chip {
flex: 1;
padding: 4px 6px;
text-align: center;
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
border: 1px solid var(--wp-border, #353841);
border-radius: 3px;
@@ -0,0 +1,13 @@
import { describe, it, expect, beforeEach } from "vitest";
import { currentFrame, setFrame } from "./frame-cursor";
describe("frame-cursor", () => {
beforeEach(() => { currentFrame.value = null; });
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
it("setFrame updates the shared ref", () => {
setFrame(2);
expect(currentFrame.value).toBe(2);
setFrame(null);
expect(currentFrame.value).toBe(null);
});
});
@@ -0,0 +1,12 @@
import { ref } from "vue";
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
// mounts its own app, so provide/inject can't span them — a shared module import
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
// (no frame). Session-only — never serialized into the workflow.
export const currentFrame = ref<number | null>(null);
export function setFrame(frame: number | null): void {
currentFrame.value = frame;
}
+109 -7
View File
@@ -28,6 +28,7 @@ import BundleFrame from "./bundles/BundleFrame.vue";
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import { LockSeedKey } from "./lock-seed-ctx";
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
@@ -74,7 +75,9 @@ import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./dra
import { nextBindingSuffix } from "./duplicates/binding-suffix";
import { pushToast } from "../shared/toast-store";
import { kindIcon } from "../shared/kind-icons";
import { KIND_TITLE } from "./editors/_shell";
import { KIND_TITLE, FRAME_OVERRIDABLE_FIELDS } from "./editors/_shell";
import { currentFrame } from "../context-loop/frame-cursor";
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled as feEffectiveEnabled, frameEnableOverride as feFrameEnableOverride } from "./frame-overrides";
import { varColorClass } from "../shared/var-color";
import wpLogoSvg from "../shared/wp-logo.svg?raw";
import {
@@ -151,6 +154,10 @@ const props = withDefaults(defineProps<{
nodeMode?: number;
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
lastUsedSeedReader?: (moduleId?: string) => number | null;
/** Per-frame seed reader — the seed THIS node rolls at iteration frame #k,
* computed from the upstream Loop without a run. Drives the per-frame seed
* lock so it pins the seed the module actually uses on that frame. */
frameSeedReader?: (frame: number) => number | null;
onChange: (json: string) => void;
/** Called whenever the formula-computed minimum node width changes
* (a conflict appears, a state badge gets attached). Mount glue
@@ -679,9 +686,11 @@ function onBundlePushClosed(): void {
pushBundleTargetUid.value = null;
pushBundleCascadeScan.value = null;
}
const editingModule = computed<ModuleEntry | null>(() =>
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
);
const editingModule = computed<ModuleEntry | null>(() => {
if (editingIdx.value == null) return null;
const m = value.value.modules[editingIdx.value] ?? null;
return m ? withFrameInstance(m, currentFrame.value) : null;
});
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
@@ -2880,11 +2889,22 @@ function isInternalable(m: ModuleEntry): boolean {
}
function isLocked(m: ModuleEntry): boolean {
return typeof m.instance?.locked_seed === "number";
return typeof withFrameInstance(m, currentFrame.value).instance?.locked_seed === "number";
}
function isInternal(m: ModuleEntry): boolean {
return !!m.instance?.internal;
}
function isHeld(m: ModuleEntry): boolean {
return m.instance?.seed_scope === "hold";
}
function isOverriddenOnFrame(m: ModuleEntry): boolean {
const k = currentFrame.value;
return k != null && !!m.iteration_overrides?.[String(k)];
}
function effectiveLockedSeed(m: ModuleEntry): number | undefined {
const v = withFrameInstance(m, currentFrame.value).instance?.locked_seed;
return typeof v === "number" ? v : undefined;
}
// ── Formula-driven minWidth ────────────────────────────────────────
// Pull-based pattern: createDomWidgetHost reads this through a
@@ -2938,6 +2958,22 @@ watch(
function toggleLockOnCard(idx: number) {
const m = value.value.modules[idx];
if (!m) return;
const k = currentFrame.value;
if (k != null) {
// Prefer the seed THIS module actually rolls at frame #k (computed from the
// upstream Loop), so the lock pins the frame's real seed — not the single
// representative seed a run surfaced. Falls back to last-used / stored when
// no Loop drives the node.
const frameSeed = props.frameSeedReader?.(k);
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
const fallback = typeof frameSeed === "number" ? frameSeed
: typeof lastUsed === "number" ? lastUsed
: (typeof m.instance?._ui?.last_locked_seed === "number" ? m.instance._ui.last_locked_seed : 0);
const list = [...value.value.modules];
list[idx] = toggleFrameLock(m, k, fallback);
commitModules(list);
return;
}
const inst = m.instance ?? {};
let nextInst: NonNullable<ModuleEntry["instance"]>;
if (typeof inst.locked_seed === "number") {
@@ -2980,7 +3016,9 @@ function toggleLockOnCard(idx: number) {
// map-by-id pass would toggle every sibling at once. Indexing into
// the array hits the specific instance the user clicked.
const list = [...value.value.modules];
list[idx] = { ...m, instance: nextInst };
// Unlocking base makes any frame `locked_seed: null` override redundant —
// sweep it so the row stops showing a stale "override #k". No-op on lock.
list[idx] = dropRedundantFrameLockNulls({ ...m, instance: nextInst });
commitModules(list);
}
@@ -3697,11 +3735,32 @@ function moveToEdge(idx: number, edge: "top" | "bottom") {
function toggleEnabled(idx: number) {
if (idx < 0 || idx >= value.value.modules.length) return;
const k = currentFrame.value;
if (k != null) {
// Frame-active: flip the module's per-frame enable override (symmetric —
// base-off turns ON for this frame, base-on turns OFF). Pure logic lives in
// frame-overrides.toggleFrameEnabled; base `enabled` is never touched here.
const m = value.value.modules[idx];
if (!m) return;
const list = [...value.value.modules];
list[idx] = toggleFrameEnabled(m, k);
commitModules(list);
return;
}
const list = [...value.value.modules];
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
commitModules(list);
}
// Thin wrappers binding the pure frame-overrides enable helpers to the reactive
// `currentFrame` cursor — exposed to ModuleRow via moduleRowCtx.
function effectiveEnabled(m: ModuleEntry): boolean {
return feEffectiveEnabled(m, currentFrame.value);
}
function frameEnableOverride(m: ModuleEntry): "on" | "off" | null {
return feFrameEnableOverride(m, currentFrame.value);
}
function toggleCollapsed(idx: number) {
const target = value.value.modules[idx];
if (!target) return;
@@ -3815,7 +3874,22 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
const cleaned: ModuleEntry = { ...updated };
delete (cleaned as { _originalId?: string })._originalId;
const list = [...value.value.modules];
list[targetIdx] = cleaned;
const k = currentFrame.value;
if (k == null) {
list[targetIdx] = cleaned; // base edit — unchanged behavior
} else {
const baseModule = value.value.modules[targetIdx]; // base: payload + base instance intact
const fields = FRAME_OVERRIDABLE_FIELDS[baseModule.type] ?? [];
const delta = diffInstance(baseModule.instance, cleaned.instance, fields);
// base payload + base instance preserved; the frame's override = the instance delta (replace, not merge)
let next = clearFrameOverride(baseModule, k);
if (Object.keys(delta).length) next = setFrameOverride(next, k, delta);
list[targetIdx] = next;
}
// A base-unlock (here via the modal, or via the row toggle) makes any frame
// `locked_seed: null` override redundant — sweep it so the row stops showing
// a stale "override #k". No-op while the base stays locked.
list[targetIdx] = dropRedundantFrameLockNulls(list[targetIdx]);
commitModules(list);
if (updated._originalId && updated._originalId !== updated.id) {
nextTick(() => {
@@ -3831,6 +3905,18 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
editingIdx.value = null;
}
/** Revert the currently-edited module's frame-#k override back to the
* base instance (i.e. remove the iteration_overrides[k] entry entirely).
* Called from the frame banner's "Revert to base" button in ModuleEditModal. */
function revertEditingFrame(): void {
const idx = editingIdx.value;
const k = currentFrame.value;
if (idx == null || k == null) return;
const list = [...value.value.modules];
list[idx] = clearFrameOverride(value.value.modules[idx], k);
commitModules(list);
}
/**
* Open the BundleInstanceModal for `uid`. Snapshots the live
* BundleInstance into `bundleDraft` so the user's typing in
@@ -4720,8 +4806,22 @@ const moduleRowCtx: ModuleRowCtx = {
pairingFor,
viaInboundFor,
contributorsFor,
currentFrame,
isHeld,
isOverriddenOnFrame,
effectiveLockedSeed,
effectiveEnabled,
frameEnableOverride,
};
provide(ModuleRowCtxKey, moduleRowCtx);
// Seed source for the edit-modal's Lock button — resolves the seed the module
// actually rolled (frame #k's captured seed when a frame is active, else the
// last-run seed) so locking pins what the user saw, not a random number.
provide(LockSeedKey, (m: ModuleEntry): number | null => {
const k = currentFrame.value;
if (k != null) return props.frameSeedReader?.(k) ?? null;
return props.lastUsedSeedReader?.(m._uid ?? m.id) ?? null;
});
const bundleFrameCtx: BundleFrameCtx = {
bundleChildDriftCount,
@@ -4986,8 +5086,10 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
:chain-modules="chainModules"
:via-option-pairs="editingModuleViaOptionPairs"
:last-used-seed-reader="lastUsedSeedReader"
:current-frame="currentFrame"
@save="saveEditedModule"
@close="editingIdx = null"
@revert-frame="revertEditingFrame"
/>
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
@@ -590,6 +590,26 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
});
});
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
describe("ModuleEditModal — frame banner", () => {
beforeEach(() => _resetForTests());
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
await nextTick();
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
await w.find('[data-test="modal-frame-revert"]').trigger("click");
expect(w.emitted("revert-frame")).toBeTruthy();
});
it("no frame banner on base (currentFrame null)", async () => {
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
await nextTick();
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
});
});
// ── Constraint v2 dispatcher branch ────────────────────────────────────
describe("ModuleEditModal — constraint v2 dispatcher", () => {
+72 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
@@ -51,11 +52,15 @@ const props = defineProps<{
* generic chain seed.
*/
lastUsedSeedReader?: (moduleId?: string) => number | null;
/** When a frame override context is active, the 0-based frame index.
* Null / undefined = editing the base (no banner shown). */
currentFrame?: number | null;
}>();
const emit = defineEmits<{
(e: "save", value: ModuleEntry): void;
(e: "close"): void;
(e: "revert-frame"): void;
}>();
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
@@ -63,8 +68,13 @@ const emit = defineEmits<{
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
const draft = ref<ModuleEntry | null>(null);
watch(() => props.visible, (v) => {
if (v && props.module) {
watch([() => props.visible, () => props.module], () => {
if (props.visible && props.module) {
// Re-clone on open AND whenever the source module changes under an open
// modal — e.g. "Revert to base" clears the frame override, so editingModule
// recomputes and the draft must resync to the base instance. (During normal
// editing the draft is mutated locally; props.module identity is stable, so
// this does not clobber in-progress edits.)
draft.value = JSON.parse(JSON.stringify(props.module));
window.addEventListener("keydown", onKeydown);
} else {
@@ -382,6 +392,23 @@ function cancel() {
<template>
<ModalShell :visible="visible" @close="cancel">
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
(each per-kind modal renders a plain card; ModalShell's wrapper is
a centered flex ROW, so a bare banner sibling would land beside the
card). align-self:stretch on the banner spans it to the card width. -->
<div class="wp-frame-edit-body">
<!-- Frame-context banner — shown when the user is editing overrides
for a specific iteration frame (#k). Sits above all per-kind
modal bodies. Invisible on base editing (currentFrame == null). -->
<div
v-if="currentFrame != null"
class="wp-modal-frame-banner"
data-test="modal-frame-banner"
>
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
</div>
<!-- v2 wildcard branch — single-pane tailored modal renders its
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
no tab strip, no LibraryRoundTripActions footer). -->
@@ -392,6 +419,7 @@ function cancel() {
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -404,6 +432,7 @@ function cancel() {
v-else-if="draft && draft.type === 'fixed_values'"
:module="draft"
:is-modified="instanceModified"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -421,6 +450,7 @@ function cancel() {
:upstream-vars="upstreamVars"
:upstream-resolved="upstreamResolved"
:sibling-vars="siblingVars"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -442,6 +472,7 @@ function cancel() {
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -460,13 +491,14 @@ function cancel() {
:is-modified="instanceModified"
:sibling-modules="siblingModules"
:chain-modules="chainModules"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
</div>
</ModalShell>
<!-- Themed confirm dialog — replaces window.confirm() for any
@@ -503,5 +535,42 @@ function cancel() {
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
* the v1 dispatch in the 2026-05-10 cleanup. */
.wp-frame-edit-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-height: 0;
max-height: 100%;
}
.wp-modal-frame-banner {
align-self: stretch;
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 7px 11px;
border-radius: 6px;
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
* text stays readable; amber = the override/warning accent used elsewhere. */
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
color: var(--wp-amber);
font: 500 11px var(--wp-font-sans);
}
.wp-modal-frame-banner button {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
color: var(--wp-amber);
font: 600 10px var(--wp-font-sans);
cursor: pointer;
}
.wp-modal-frame-banner button:hover {
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
}
</style>
+80
View File
@@ -47,6 +47,12 @@ function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
pairingFor: () => null,
viaInboundFor: () => [],
contributorsFor: () => [],
currentFrame: ref<number | null>(null),
isHeld: () => false,
isOverriddenOnFrame: () => false,
effectiveLockedSeed: () => undefined,
effectiveEnabled: (m) => m.enabled !== false,
frameEnableOverride: () => null,
...over,
};
}
@@ -131,3 +137,77 @@ describe("ModuleRow.vue — contributor cluster", () => {
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
});
});
describe("ModuleRow.vue — iteration-aware badges", () => {
it("shows a held badge when seed_scope is hold", () => {
const mod = module({ instance: { seed_scope: "hold" } as ModuleEntry["instance"] });
const ctx = makeCtx({ isHeld: (m) => m.instance?.seed_scope === "hold" });
const wrapper = mountRow(ctx, mod);
expect(wrapper.find('[data-test="mod-held"]').exists()).toBe(true);
});
it("shows an override badge labelled with the current frame", () => {
const frame = ref<number | null>(1);
const mod = module({ iteration_overrides: { "1": { weight: 2 } } as ModuleEntry["iteration_overrides"] });
const ctx = makeCtx({
currentFrame: frame,
isOverriddenOnFrame: (m) => {
const k = frame.value;
return k != null && !!m.iteration_overrides?.[String(k)];
},
});
const wrapper = mountRow(ctx, mod);
const badge = wrapper.find('[data-test="mod-override"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain("#2");
});
});
describe("ModuleRow.vue — per-frame enable/disable", () => {
it("checkbox is unchecked when effectiveEnabled is false on the active frame", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => false });
const wrapper = mountRow(ctx, module({ enabled: true }));
const checkbox = wrapper.find('input[type="checkbox"]');
expect(checkbox.exists()).toBe(true);
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
});
it("checkbox is checked when effectiveEnabled is true (base-off but on for this frame)", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => true });
const wrapper = mountRow(ctx, module({ enabled: false }));
const checkbox = wrapper.find('input[type="checkbox"]');
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
});
it("shows an off-#k badge when frameEnableOverride is 'off'", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "off" });
const wrapper = mountRow(ctx, module({ enabled: true }));
const badge = wrapper.find('[data-test="mod-frame-disabled"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain("#3"); // frame index 2 (0-based) → "#3"
});
it("shows an on-#k badge when frameEnableOverride is 'on'", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "on" });
const wrapper = mountRow(ctx, module({ enabled: false }));
const badge = wrapper.find('[data-test="mod-frame-enabled"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain("#3");
});
it("checkbox change calls ctx.toggleEnabled with the row index", async () => {
const frame = ref<number | null>(2);
const calls: number[] = [];
const ctx = makeCtx({
currentFrame: frame,
toggleEnabled: (idx) => { calls.push(idx); },
});
const wrapper = mountRow(ctx, module({ enabled: true }));
await wrapper.find('input[type="checkbox"]').trigger("change");
expect(calls).toEqual([0]);
});
});
+12 -4
View File
@@ -36,7 +36,7 @@ const {
tabindex="0"
draggable="true"
:class="{
'wp-disabled': !module.enabled,
'wp-disabled': !(ctx?.effectiveEnabled(module) ?? (module.enabled !== false)),
'wp-conflict-error': severityFor(module._uid ?? module.id) === 'error',
'wp-conflict-warning': severityFor(module._uid ?? module.id) === 'warning',
'wp-conflict-info': severityFor(module._uid ?? module.id) === 'info',
@@ -74,8 +74,12 @@ const {
:title="isCollapsed(module) ? 'Expand' : 'Collapse'" @click="toggleCollapsed(idx)">
<i :class="['pi', isCollapsed(module) ? 'pi-caret-right' : 'pi-caret-down']" aria-hidden="true"></i>
</button>
<label class="wp-toggle" draggable="false" :title="module.enabled ? 'Disable' : 'Enable'">
<input type="checkbox" :checked="module.enabled" :aria-label="`enable ${module.meta.name}`" @change="toggleEnabled(idx)" />
<label class="wp-toggle" draggable="false"
:title="ctx && ctx.currentFrame.value != null ? (ctx.effectiveEnabled(module) ? 'Disable on this frame' : 'Enable on this frame') : (module.enabled ? 'Disable' : 'Enable')">
<input type="checkbox"
:checked="ctx ? ctx.effectiveEnabled(module) : module.enabled"
:aria-label="`enable ${module.meta.name}`"
@change="toggleEnabled(idx)" />
<span class="wp-toggle-mark"></span>
</label>
<span class="wp-row-type-icon" :title="module.type" aria-hidden="true">
@@ -149,11 +153,15 @@ const {
class="wp-conflict-badge"
:class="`wp-conflict-badge--${severityFor(module._uid ?? module.id)}`"
:title="conflictTooltip(module._uid ?? module.id)">{{ conflictBadgeText(module._uid ?? module.id) }}</span>
<span v-if="ctx?.isHeld(module)" class="wp-mod-badge wp-mod-badge--clash" data-test="mod-held" title="Held across the run">held</span>
<span v-if="ctx?.isOverriddenOnFrame(module)" class="wp-conflict-badge wp-conflict-badge--warning" data-test="mod-override" title="Overridden on this frame">override #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
<span v-if="ctx?.frameEnableOverride(module) === 'off'" class="wp-mod-badge wp-mod-badge--missing" data-test="mod-frame-disabled" title="Disabled on this frame">off #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
<span v-else-if="ctx?.frameEnableOverride(module) === 'on'" class="wp-mod-badge wp-mod-badge--on" data-test="mod-frame-enabled" title="Enabled on this frame">on #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
</span>
<div class="wp-mod-actions" draggable="false">
<button v-if="isSeedLockable(module)" type="button" class="wp-btn--icon-sm wp-btn--warn"
:class="{ 'is-locked': isLocked(module) }" data-test="row-action-lock"
:title="isLocked(module) ? `Locked seed: ${module.instance?.locked_seed}. Click to unlock.` : 'Lock seed'"
:title="isLocked(module) ? `Locked seed: ${ctx?.effectiveLockedSeed(module)}. Click to unlock.` : 'Lock seed'"
:aria-label="isLocked(module) ? 'Unlock seed' : 'Lock seed'"
@click.stop="toggleLockOnCard(idx)"><i class="pi pi-lock" /></button>
<button v-if="module.type === 'wildcard' || module.type === 'fixed_values' || module.type === 'combine' || module.type === 'derivation'"
@@ -21,8 +21,9 @@
* `runtime-seed-up`, `runtime-seed-down` — so the existing test
* suites pass without modification.
*/
import { computed } from "vue";
import { computed, inject } from "vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import { LockSeedKey } from "../../lock-seed-ctx";
const props = withDefaults(
defineProps<{
@@ -36,6 +37,10 @@ const props = withDefaults(
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
// The seed the module actually rolled — provided by ContextWidget. Used as the
// default when locking so "Lock" pins what the user saw, not a fresh random.
const lockSeedFor = inject(LockSeedKey, null);
const instance = computed(() => props.module.instance ?? {});
const locked = computed(() => typeof instance.value.locked_seed === "number");
const seedValue = computed(() =>
@@ -71,7 +76,11 @@ function onLockClick(): void {
} else {
const ui = instance.value._ui ?? {};
const remembered = typeof ui.last_locked_seed === "number" ? ui.last_locked_seed : null;
const seed = remembered ?? randomSeed();
// Prefer the seed the module ACTUALLY rolled (frame #k's captured seed, or
// the last-run seed) so locking reproduces what the user saw; then the
// remembered seed; a fresh random only as a last resort.
const resolved = lockSeedFor?.(props.module);
const seed = typeof resolved === "number" ? resolved : (remembered ?? randomSeed());
emitInstance({
locked_seed: seed,
_ui: { ...ui, last_locked_seed: seed },
+19 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { INSTANCE_FIELDS_PER_KIND } from "./_shell";
import { FRAME_OVERRIDABLE_FIELDS, INSTANCE_FIELDS_PER_KIND } from "./_shell";
describe("INSTANCE_FIELDS_PER_KIND registry", () => {
it("lists exactly the fields each kind exposes per spec §5.5", () => {
@@ -7,7 +7,7 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
// v2 dropped `mode` + `pinned_option_id` — see
// docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §6.1.
"variable_binding", "enabled_options",
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
// SP2a multi-select (2026-06-08): per-instance count range + separator.
// SP2c: pick_independent (allow repeats / with replacement).
"pick_min", "pick_max", "pick_separator", "pick_independent",
@@ -16,15 +16,17 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
// `locked_seed` added in the combine + fixed_values syntax-parity
// cycle (2026-05-08): fixed_values now resolves `{a|b|c}` per
// value, so it gains seed-lock parity with wildcard + combine.
"values_overrides", "enabled_options", "locked_seed",
// `seed_scope` added for iteration-aware hold/vary control.
"values_overrides", "enabled_options", "locked_seed", "seed_scope",
]);
expect(INSTANCE_FIELDS_PER_KIND.combine).toEqual([
// v2 single-pane modal exposes per-instance overrides:
// - template_override (combine v2, this cycle)
// - variable_binding (matches wildcard's identity-section UX)
// - locked_seed (parity with wildcard + fixed_values)
// - seed_scope (iteration-aware hold/vary control)
// - internal (existing hide-from-prompt toggle)
"template_override", "variable_binding", "locked_seed", "internal",
"template_override", "variable_binding", "locked_seed", "seed_scope", "internal",
]);
expect(INSTANCE_FIELDS_PER_KIND.derivation).toEqual([
// Tier-D modal expansion (2026-05-10): per-rule + per-branch
@@ -54,3 +56,16 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
]);
});
});
describe("FRAME_OVERRIDABLE_FIELDS", () => {
const EXCLUDED = ["seed_scope", "variable_binding", "internal"] as const;
for (const kind of Object.keys(FRAME_OVERRIDABLE_FIELDS) as Array<keyof typeof FRAME_OVERRIDABLE_FIELDS>) {
it(`${kind}: excludes run-level/structural fields`, () => {
for (const ex of EXCLUDED) expect(FRAME_OVERRIDABLE_FIELDS[kind]).not.toContain(ex);
});
it(`${kind}: is a subset of INSTANCE_FIELDS_PER_KIND`, () => {
const sup = new Set(INSTANCE_FIELDS_PER_KIND[kind]);
for (const f of FRAME_OVERRIDABLE_FIELDS[kind]) expect(sup.has(f)).toBe(true);
});
}
});
+20 -4
View File
@@ -14,7 +14,7 @@ export type InstanceFieldKey =
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
| "exclude_null"
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
| "mode" | "pinned_option_id" | "locked_seed" | "seed_scope" | "internal" | "values_overrides"
| "template_override"
| "disabled_rule_ids"
// Derivation tier-D modal expansion (2026-05-10 cycle)
@@ -46,13 +46,13 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
// implicit in pool state. Engine handler still reads them on
// legacy snapshots; UI never writes them.
"variable_binding", "enabled_options",
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
// SP2a multi-select: count range + join separator (per-instance, local).
// SP2c: pick_independent (allow repeats / with replacement).
"pick_min", "pick_max", "pick_separator", "pick_independent",
],
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
combine: ["template_override", "variable_binding", "locked_seed", "internal"],
fixed_values: ["values_overrides", "enabled_options", "locked_seed", "seed_scope"],
combine: ["template_override", "variable_binding", "locked_seed", "seed_scope", "internal"],
derivation: [
// Tier-D modal expansion (2026-05-10):
// - rule-group overrides (cleared by Reset btn)
@@ -83,3 +83,19 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
"target_select",
],
};
// Subset of INSTANCE_FIELDS_PER_KIND that is meaningful to override PER-FRAME.
// Excludes run-level (seed_scope), structural/reference (variable_binding), and
// internal (base-only; per-frame visibility unsupported in v1). Used only by the
// per-frame save-diff so toggling Hold/binding/Hide on a frame records no override.
export const FRAME_OVERRIDABLE_FIELDS: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
wildcard: ["enabled_options", "option_weights", "category_filter", "exclude_null",
"locked_seed", "pick_min", "pick_max", "pick_separator", "pick_independent"],
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
combine: ["template_override", "locked_seed"],
derivation: ["disabled_rule_ids", "disabled_branch_keys", "action_value_overrides",
"condition_value_overrides", "rule_order_override", "locked_seed"],
constraint: ["disabled_exception_keys", "disabled_matrix_cells", "cell_mode_overrides",
"cell_factor_overrides", "exception_mode_overrides", "exception_factor_overrides",
"extra_exceptions", "target_select"],
};
@@ -186,6 +186,8 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
.wp-bdm__hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; color: var(--wp-text-muted, var(--wp-text2)); }
.wp-bdm__btn { padding: 5px 12px; border: 1px solid var(--wp-border); border-radius: 3px; background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
.wp-bdm__btn--primary { border-color: var(--wp-accent); background: var(--wp-accent); color: white; }
.wp-bdm__btn:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
.wp-bdm__btn--primary:hover { border-color: var(--wp-accent2, var(--wp-accent)); background: var(--wp-accent2, var(--wp-accent)); color: white; }
.wp-bdm__btn--quiet { border-color: transparent; color: var(--wp-text-dim, var(--wp-text3)); display: inline-flex; align-items: center; gap: 5px; font-size: 10px; }
.wp-bdm__btn--quiet:hover { border-color: var(--wp-border); color: var(--wp-text-muted, var(--wp-text2)); }
.wp-bdm__btn--quiet .pi { font-size: 10px; }
@@ -35,6 +35,9 @@ const props = withDefaults(
/** Sibling var names produced by other modules in this same Context
* node. Combined with upstreamVars to populate the dropdown. */
siblingVars?: string[];
/** When true, a frame override context is active. Run-level and
* structural controls are disabled in frame mode. */
frameActive?: boolean;
}>(),
{
isDrifted: false,
@@ -100,6 +103,7 @@ function onSpaClick(): void {
:module="module"
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:frame-active="frameActive"
@update="onUpdate"
/>
<TemplateSection
@@ -110,7 +114,7 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<div class="wp-cbm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-cbm__foot">
<a
@@ -220,6 +224,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-cbm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-cbm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-cbm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,46 @@
// R3: IdentitySection frame-active gating — combine variant.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import IdentitySection from "./IdentitySection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "cb12cd34",
type: "combine",
enabled: true,
meta: { name: "final_prompt" },
entries: [],
payload: { output_var: "final_prompt", template: "$style portrait" },
instance: {},
...overrides,
};
}
describe("combine IdentitySection — frameActive gating on variable binding", () => {
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(IdentitySection, { props: { module: makeModule() } });
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
});
it("binding input is NOT disabled when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
});
it("binding input IS disabled when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeDefined();
});
it("shows binding frame-active hint when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
});
it("does NOT show binding frame-active hint when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
});
});
@@ -21,6 +21,9 @@ const props = withDefaults(
upstreamVars?: string[];
/** Names produced by other modules in the SAME Context node. */
siblingVars?: string[];
/** When true, a frame is active — the variable binding input is
* structural (not per-frame) so it is disabled with a hint. */
frameActive?: boolean;
}>(),
{ upstreamVars: () => [], siblingVars: () => [] },
);
@@ -157,6 +160,7 @@ function onResetBinding(): void {
:value="bindingValue"
:placeholder="libraryBinding"
aria-label="Variable binding"
:disabled="frameActive || undefined"
@input="onBindingInput"
/>
</div>
@@ -171,6 +175,11 @@ function onResetBinding(): void {
><i class="pi pi-replay" aria-hidden="true" /></button>
</div>
</div>
<p
v-if="frameActive"
class="id__frame-hint"
data-test="id-binding-frame-hint"
>Applies to every frame — switch to base to change.</p>
<div
v-if="collidesWith !== null"
class="id__collision"
@@ -303,4 +312,9 @@ function onResetBinding(): void {
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
}
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
.id__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -0,0 +1,61 @@
// R3: RuntimeSection frame-active gating — combine variant.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "cb12cd34",
type: "combine",
enabled: true,
meta: { name: "final_prompt" },
entries: [],
payload: { output_var: "final_prompt", template: "$style portrait" },
instance: {},
...overrides,
};
}
describe("combine RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
});
it("hold toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -112,4 +112,21 @@ describe("combine RuntimeSection", () => {
await w.find('[data-test="runtime-hide"]').trigger("click");
expect(lastPatch(w).instance?.internal).toBe(true);
});
it("seed-scope toggle is off (vary) by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
});
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
});
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
});
});
@@ -12,7 +12,7 @@ import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -21,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -34,11 +39,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -78,4 +102,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -65,6 +65,11 @@ const props = withDefaults(
* Optional — undefined in headless/legacy mounts falls back to
* `siblingModules` alone. */
chainModules?: ChainModule[];
/** When true, a frame override context is active. Accepted for
* interface uniformity; constraint has no RuntimeSection and its
* IdentitySection carries no variable-binding input, so nothing
* is forwarded to a child here. */
frameActive?: boolean;
}>(),
{ isDrifted: false, isModified: false, siblingModules: () => [] },
);
@@ -847,6 +852,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-cnm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-cnm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-cnm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -42,6 +42,9 @@ const props = withDefaults(
* engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`). Forwarded
* verbatim to RulesSection, which renders the inline `↪#N` badge. */
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
/** When true, a frame override context is active. Run-level controls
* are disabled in frame mode. */
frameActive?: boolean;
}>(),
{
isDrifted: false,
@@ -161,7 +164,7 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<div class="wp-dvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-dvm__foot">
<a
@@ -270,6 +273,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-dvm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-dvm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-dvm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,48 @@
// R3: RuntimeSection frame-active gating — derivation variant.
// Derivation RuntimeSection only has runtime-hide (no runtime-hold),
// so only the Hide from prompt toggle needs disabling.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "dv012345",
type: "derivation",
enabled: true,
meta: { name: "mood-rules" },
entries: [],
payload: { rules: [] },
instance: {},
...overrides,
};
}
describe("derivation RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -10,7 +10,7 @@ import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -32,11 +32,17 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt applies to every frame — switch to base to change.</p>
</section>
</template>
@@ -76,4 +82,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -12,6 +12,9 @@ const props = withDefaults(
/** True when draft has unsaved instance edits. Gates "Save to library"
* visibility — pushing an unmodified payload back is a no-op. */
isModified?: boolean;
/** When true, a frame override context is active. Run-level controls
* are disabled in frame mode. */
frameActive?: boolean;
}>(),
{ isDrifted: false, isModified: false },
);
@@ -67,7 +70,7 @@ function onSpaClick(): void {
<IdentitySection :module="module" @update="onUpdate" />
<ValuesSection :module="module" @update="onUpdate" />
<div class="wp-fvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-fvm__foot">
<a
@@ -177,6 +180,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-fvm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-fvm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-fvm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,61 @@
// R3: RuntimeSection frame-active gating — fixed-values variant.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "fv012345",
type: "fixed_values",
enabled: true,
meta: { name: "presets" },
entries: [],
payload: { values: [] },
instance: {},
...overrides,
};
}
describe("fixed-values RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
});
it("hold toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -16,6 +16,11 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
};
}
function lastPatch(w: ReturnType<typeof mount>): Partial<ModuleEntry> {
const updates = w.emitted("update")! as unknown[][];
return updates[updates.length - 1][0] as Partial<ModuleEntry>;
}
describe("fixed-values RuntimeSection", () => {
it("renders Hide-from-prompt toggle", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
@@ -80,4 +85,21 @@ describe("fixed-values RuntimeSection", () => {
});
expect(on.find('[data-test="runtime-seed"]').exists()).toBe(true);
});
it("seed-scope toggle is off (vary) by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
});
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
});
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
});
});
@@ -1,18 +1,13 @@
<script setup lang="ts">
/**
* Combine RuntimeSection — composes the shared `<SeedLockControls />`
* (default "Lock seed" label; combine pins inline `{a|b|c}`
* alternation resolution, not an option pick) plus the
* Hide-from-prompt toggle.
*
* Slimmed during the 2026-05-09 extraction — previously held a
* verbatim copy of wildcard's lock UI.
* FixedValues RuntimeSection — composes the shared `<SeedLockControls />`
* plus the Hide-from-prompt and Hold-across-run toggles.
*/
import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -21,6 +16,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -34,11 +34,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -78,4 +97,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -22,6 +22,9 @@ const props = withDefaults(
* (one of its options contains a nested `@{uuid}` that resolves to a
* downstream constraint's target). Keyed by option id. */
viaOptionPairs?: Map<string, PairingBadge[]>;
/** When true, a frame override context is active. Run-level and
* structural controls are disabled in frame mode. */
frameActive?: boolean;
}>(),
{ isDrifted: false, isModified: false, upstreamVars: () => [], siblingVars: () => [], viaOptionPairs: () => new Map() },
);
@@ -98,11 +101,12 @@ function onSpaClick(): void {
:module="module"
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:frame-active="frameActive"
@update="onUpdate"
/>
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
<div class="wp-wcm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-wcm__foot">
<a
@@ -223,6 +227,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-wcm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-wcm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-wcm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,52 @@
// R3: IdentitySection frame-active gating — wildcard variant.
// variable_binding is a structural field that is meaningless to change
// on a per-frame basis, so the binding input must be disabled when
// frameActive=true.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import IdentitySection from "./IdentitySection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
type: "wildcard",
enabled: true,
meta: { name: "outfit" },
entries: [],
payload: { var_binding: "outfit", options: [] },
instance: {},
...overrides,
};
}
describe("wildcard IdentitySection — frameActive gating on variable binding", () => {
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(IdentitySection, { props: { module: makeModule() } });
const input = w.find('[data-test="id-binding"]');
expect(input.attributes("disabled")).toBeUndefined();
});
it("binding input is NOT disabled when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
const input = w.find('[data-test="id-binding"]');
expect(input.attributes("disabled")).toBeUndefined();
});
it("binding input IS disabled when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
const input = w.find('[data-test="id-binding"]');
expect(input.attributes("disabled")).toBeDefined();
});
it("shows binding frame-active hint when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
});
it("does NOT show binding frame-active hint when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
});
});
@@ -13,6 +13,9 @@ const props = withDefaults(
upstreamVars?: string[];
/** Names produced by other modules in the SAME Context node. */
siblingVars?: string[];
/** When true, a frame is active — the variable binding input is
* structural (not per-frame) so it is disabled with a hint. */
frameActive?: boolean;
}>(),
{ upstreamVars: () => [], siblingVars: () => [] },
);
@@ -162,6 +165,7 @@ function onResetBinding(): void {
:value="bindingValue"
:placeholder="libraryBinding"
aria-label="Variable binding"
:disabled="frameActive || undefined"
@input="onBindingInput"
/>
</div>
@@ -176,6 +180,11 @@ function onResetBinding(): void {
><i class="pi pi-replay" aria-hidden="true" /></button>
</div>
</div>
<p
v-if="frameActive"
class="id__frame-hint"
data-test="id-binding-frame-hint"
>Applies to every frame — switch to base to change.</p>
<div
v-if="collidesWith !== null"
class="id__collision"
@@ -287,4 +296,9 @@ function onResetBinding(): void {
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
}
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
.id__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -0,0 +1,73 @@
// R3: RuntimeSection frame-active gating — wildcard variant.
// Hide from prompt + Hold across run are run-level controls that apply
// to every frame, so they must be disabled (not interactive) when the
// user is editing a per-frame override context. Tests assert:
// - controls get `disabled` attribute when frameActive=true
// - controls are NOT disabled when frameActive is absent/false
// - a hint message is shown in frame-active mode
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
type: "wildcard",
enabled: true,
meta: { name: "outfit" },
entries: [],
payload: { var_binding: "outfit", options: [] },
instance: {},
...overrides,
};
}
describe("wildcard RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
const btn = w.find('[data-test="runtime-hide"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
const btn = w.find('[data-test="runtime-hold"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
const btn = w.find('[data-test="runtime-hide"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
const btn = w.find('[data-test="runtime-hold"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
const btn = w.find('[data-test="runtime-hide"]');
expect(btn.attributes("disabled")).toBeDefined();
});
it("hold toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
const btn = w.find('[data-test="runtime-hold"]');
expect(btn.attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -176,4 +176,21 @@ describe("RuntimeSection", () => {
expect(seed).toBeGreaterThanOrEqual(0);
expect(seed).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER);
});
it("seed-scope toggle is off (vary) by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
});
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
});
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
});
});
@@ -11,7 +11,7 @@ import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -20,6 +20,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -33,11 +38,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -77,4 +101,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled, frameEnableOverride } from "./frame-overrides";
import type { ModuleEntry } from "../../widgets/_shared";
function mod(extra: Partial<ModuleEntry> = {}): ModuleEntry {
return { id: "m1", type: "wildcard", enabled: true, meta: { name: "x" }, entries: [], instance: {}, ...extra };
}
describe("frame-overrides", () => {
it("withFrameInstance overlays the frame patch onto the base instance", () => {
const m = mod({
instance: { variable_binding: "x" },
iteration_overrides: { "2": { pinned_option_id: "0", mode: "pinned" } },
});
const r = withFrameInstance(m, 2);
expect(r.instance).toEqual({ variable_binding: "x", pinned_option_id: "0", mode: "pinned" });
expect(withFrameInstance(m, null)).toBe(m);
expect(withFrameInstance(m, 1)).toBe(m);
});
it("diffInstance captures only changed declared fields", () => {
const base = { variable_binding: "x", pinned_option_id: null };
const edited = { variable_binding: "x", pinned_option_id: "0", mode: "pinned" };
expect(diffInstance(base, edited, ["pinned_option_id", "mode", "variable_binding"]))
.toEqual({ pinned_option_id: "0", mode: "pinned" });
});
it("setFrameOverride writes merged and clearFrameOverride removes", () => {
const m = mod();
const a = setFrameOverride(m, 2, { pinned_option_id: "0" });
expect(a.iteration_overrides).toEqual({ "2": { pinned_option_id: "0" } });
const b = clearFrameOverride(a, 2);
expect(b.iteration_overrides).toBeUndefined();
});
it("toggleFrameLock: locks an unlocked frame with the fallback", () => {
const m = mod();
const r = toggleFrameLock(m, 2, 999);
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: 999 });
});
it("toggleFrameLock: unlocking a frame-only lock drops the key", () => {
const m = mod({ iteration_overrides: { "2": { locked_seed: 999 } } });
const r = toggleFrameLock(m, 2, 0);
expect(r.iteration_overrides).toBeUndefined();
});
it("toggleFrameLock: unlocking a frame writes null when base is locked", () => {
const m = mod({ instance: { locked_seed: 5 } });
const r = toggleFrameLock(m, 2, 0);
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: null });
});
// ── per-frame enable override (frame_enabled) ──────────────────────────
it("effectiveEnabled: frame override wins, else base", () => {
expect(effectiveEnabled(mod({ enabled: true }), null)).toBe(true);
expect(effectiveEnabled(mod({ enabled: true }), 2)).toBe(true); // no override → base
const off = mod({ enabled: true, frame_enabled: { "2": false } });
expect(effectiveEnabled(off, 2)).toBe(false);
expect(effectiveEnabled(off, 1)).toBe(true);
const on = mod({ enabled: false, frame_enabled: { "2": true } });
expect(effectiveEnabled(on, 2)).toBe(true); // base off, frame on
expect(effectiveEnabled(on, 1)).toBe(false); // base off elsewhere
expect(effectiveEnabled(on, null)).toBe(false); // base view
});
it("effectiveEnabled: folds legacy disabled_frames", () => {
const m = mod({ enabled: true, disabled_frames: [2] });
expect(effectiveEnabled(m, 2)).toBe(false);
expect(effectiveEnabled(m, 0)).toBe(true);
});
it("frameEnableOverride: reports on / off / null vs base", () => {
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), 2)).toBe("off");
expect(frameEnableOverride(mod({ enabled: false, frame_enabled: { "2": true } }), 2)).toBe("on");
expect(frameEnableOverride(mod({ enabled: true }), 2)).toBeNull();
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": true } }), 2)).toBeNull(); // equals base
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), null)).toBeNull();
});
it("toggleFrameEnabled: a base-OFF module turns ON for the frame (the bug fix)", () => {
const r = toggleFrameEnabled(mod({ enabled: false }), 2);
expect(r.frame_enabled).toEqual({ "2": true });
expect(r.enabled).toBe(false); // base untouched
});
it("toggleFrameEnabled: a base-ON module turns OFF for the frame", () => {
const r = toggleFrameEnabled(mod({ enabled: true }), 2);
expect(r.frame_enabled).toEqual({ "2": false });
});
it("toggleFrameEnabled: toggling back to base drops the override + the field", () => {
const r = toggleFrameEnabled(mod({ enabled: true, frame_enabled: { "2": false } }), 2);
expect(r.frame_enabled).toBeUndefined();
});
it("toggleFrameEnabled: migrates legacy disabled_frames into frame_enabled", () => {
const r = toggleFrameEnabled(mod({ enabled: true, disabled_frames: [1] }), 2);
expect(r.disabled_frames).toBeUndefined();
expect(r.frame_enabled).toEqual({ "1": false, "2": false });
});
it("toggleFrameEnabled: recovers the user's stuck state (base off, stray disabled_frames)", () => {
// Repro of the QA bug: base disabled with a leftover blocklist entry;
// enabling frame #2 should run ONLY #2 and clear the noise.
const r = toggleFrameEnabled(mod({ enabled: false, disabled_frames: [1] }), 2);
expect(r.disabled_frames).toBeUndefined();
expect(r.frame_enabled).toEqual({ "2": true });
});
// ── dropRedundantFrameLockNulls ────────────────────────────────────────
it("dropRedundantFrameLockNulls: clears a frame locked_seed:null once base is unlocked", () => {
// Repro of the QA bug: lock #4, lock base, unlock #4 (writes null override),
// unlock base → the null override is now redundant and must be swept.
const m = mod({ instance: {}, iteration_overrides: { "3": { locked_seed: null } } });
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toBeUndefined();
});
it("dropRedundantFrameLockNulls: KEEPS the null override while base is locked", () => {
const m = mod({ instance: { locked_seed: 5 }, iteration_overrides: { "3": { locked_seed: null } } });
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toEqual({ "3": { locked_seed: null } });
});
it("dropRedundantFrameLockNulls: keeps a frame's real lock + other override fields", () => {
const m = mod({
instance: {},
iteration_overrides: { "1": { locked_seed: 99 }, "2": { locked_seed: null, pinned_option_id: "0" } },
});
expect(dropRedundantFrameLockNulls(m).iteration_overrides)
.toEqual({ "1": { locked_seed: 99 }, "2": { pinned_option_id: "0" } });
});
});
+158
View File
@@ -0,0 +1,158 @@
import type { ModuleEntry } from "../../widgets/_shared";
type Instance = NonNullable<ModuleEntry["instance"]>;
type IterationOverrides = NonNullable<ModuleEntry["iteration_overrides"]>;
/** Effective module at frame `k`: base instance overlaid with the frame's
* override patch. Returns the same reference when there's nothing to overlay. */
export function withFrameInstance(module: ModuleEntry, frame: number | null): ModuleEntry {
if (frame == null) return module;
const patch = module.iteration_overrides?.[String(frame)];
if (!patch) return module;
return { ...module, instance: { ...(module.instance ?? {}), ...patch } };
}
/** Changed override fields between base and edited instances, scoped to `fields`.
* Per-field JSON compare (values are small + JSON-able). Accepts loose
* Record type so callers can pass plain object literals without casting. */
export function diffInstance(
base: Record<string, unknown> | Instance | undefined,
edited: Record<string, unknown> | Instance | undefined,
fields: readonly string[],
): Record<string, unknown> {
const b = (base ?? {}) as Record<string, unknown>;
const e = (edited ?? {}) as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const f of fields) {
if (JSON.stringify(b[f]) !== JSON.stringify(e[f])) out[f] = e[f];
}
return out;
}
/** Merge `patch` into the module's frame override (clone). Empty result drops
* the frame key; empty map drops `iteration_overrides` entirely. */
export function setFrameOverride(
module: ModuleEntry, frame: number, patch: Record<string, unknown>,
): ModuleEntry {
const map: IterationOverrides = { ...(module.iteration_overrides ?? {}) };
const key = String(frame);
const merged = { ...(map[key] ?? {}), ...patch } as Partial<Instance>;
if (Object.keys(merged).length === 0) {
delete map[key];
} else {
map[key] = merged;
}
const out: ModuleEntry = { ...module, iteration_overrides: map };
if (Object.keys(map).length === 0) delete out.iteration_overrides;
return out;
}
/** Remove a frame's override entirely (clone). */
export function clearFrameOverride(module: ModuleEntry, frame: number): ModuleEntry {
if (!module.iteration_overrides) return module;
const map: IterationOverrides = { ...module.iteration_overrides };
delete map[String(frame)];
const out: ModuleEntry = { ...module, iteration_overrides: map };
if (Object.keys(map).length === 0) delete out.iteration_overrides;
return out;
}
/** Toggle locked_seed for ONE frame (the row quick-lock when a frame is active).
* Locks with `fallback` if currently unlocked at that frame; otherwise unlocks —
* writing an explicit `null` when the BASE is locked (to override it on this frame)
* or dropping the frame's locked_seed when the base is already unlocked. */
export function toggleFrameLock(module: ModuleEntry, frame: number, fallback: number): ModuleEntry {
const eff = withFrameInstance(module, frame).instance ?? {};
const baseLocked = typeof module.instance?.locked_seed === "number";
if (typeof eff.locked_seed === "number") {
if (baseLocked) return setFrameOverride(module, frame, { locked_seed: null });
const rest = { ...(module.iteration_overrides?.[String(frame)] ?? {}) } as Record<string, unknown>;
delete rest.locked_seed;
let next = clearFrameOverride(module, frame);
if (Object.keys(rest).length) next = setFrameOverride(next, frame, rest);
return next;
}
return setFrameOverride(module, frame, { locked_seed: fallback });
}
/** Per-frame enabled state at frame `k`: the explicit `frame_enabled[k]`
* override if present, else the legacy `disabled_frames` blocklist folded in
* (a listed frame reads as `false`), else `undefined` — meaning "no override,
* use base `enabled`". */
export function frameEnabledAt(module: ModuleEntry, k: number): boolean | undefined {
const fe = module.frame_enabled?.[String(k)];
if (typeof fe === "boolean") return fe;
if ((module.disabled_frames ?? []).includes(k)) return false;
return undefined;
}
/** Effective enabled state in the current view: `frame_enabled[k] ?? base
* enabled` when a frame is active, base `enabled` otherwise. */
export function effectiveEnabled(module: ModuleEntry, frame: number | null): boolean {
const base = module.enabled !== false;
if (frame == null) return base;
return frameEnabledAt(module, frame) ?? base;
}
/** Per-frame enable override vs base at `frame`: "on" (forced on while base
* off), "off" (forced off while base on), or null (no frame active / no
* override / equals base). Drives the on-#k / off-#k row badge. */
export function frameEnableOverride(
module: ModuleEntry, frame: number | null,
): "on" | "off" | null {
if (frame == null) return null;
const fe = frameEnabledAt(module, frame);
if (fe == null) return null;
const base = module.enabled !== false;
if (fe === base) return null;
return fe ? "on" : "off";
}
/** Toggle a module's per-frame enable state for ONE frame (the row checkbox when
* a frame is active). Sets `frame_enabled[frame]` to the opposite of the current
* effective state — symmetric, so a base-off module turns ON for the frame and a
* base-on module turns OFF. Migrates any legacy `disabled_frames` into the map,
* then drops entries that match base so only real overrides persist. Base
* `enabled` is never touched. */
export function toggleFrameEnabled(module: ModuleEntry, frame: number): ModuleEntry {
const base = module.enabled !== false;
const next = !(frameEnabledAt(module, frame) ?? base);
const map: Record<string, boolean> = { ...(module.frame_enabled ?? {}) };
for (const df of module.disabled_frames ?? []) map[String(df)] = false;
map[String(frame)] = next;
for (const key of Object.keys(map)) {
if (map[key] === base) delete map[key];
}
const out: ModuleEntry = { ...module };
delete out.disabled_frames;
if (Object.keys(map).length === 0) delete out.frame_enabled;
else out.frame_enabled = map;
return out;
}
/** Drop redundant frame `locked_seed: null` overrides. A frame's
* `locked_seed: null` exists ONLY to force that frame UNLOCKED over a BASE
* lock; once the base is itself unlocked it's meaningless, so it's swept (and
* the frame's override entry removed when nothing else remains). No-op while
* the base stays locked. Without this, unlocking base after unlocking a frame
* leaves a stale entry so the row keeps showing "override #k". */
export function dropRedundantFrameLockNulls(module: ModuleEntry): ModuleEntry {
const ov = module.iteration_overrides;
if (!ov || typeof module.instance?.locked_seed === "number") return module;
const next: IterationOverrides = {};
let changed = false;
for (const [k, patch] of Object.entries(ov)) {
if (patch && (patch as Record<string, unknown>).locked_seed === null) {
const rest = { ...patch } as Record<string, unknown>;
delete rest.locked_seed;
changed = true;
if (Object.keys(rest).length) next[k] = rest as Partial<Instance>;
} else {
next[k] = patch;
}
}
if (!changed) return module;
const out: ModuleEntry = { ...module, iteration_overrides: next };
if (Object.keys(next).length === 0) delete out.iteration_overrides;
return out;
}
+15
View File
@@ -0,0 +1,15 @@
import type { InjectionKey } from "vue";
import type { ModuleEntry } from "../../widgets/_shared";
/**
* Resolves the seed to pin when the user locks a module's seed — the seed the
* module ACTUALLY rolled: frame #k's captured seed when an iteration frame is
* active, else the module's last-run seed. Provided by ContextWidget (which
* owns the frame cursor + the run-seed readers) and injected by
* SeedLockControls, which lives deep inside the per-kind edit modal. Returns
* null when unavailable (cold start, no run yet) → the control falls back to
* its remembered seed, then a fresh random.
*/
export type LockSeedResolver = (module: ModuleEntry) => number | null;
export const LockSeedKey: InjectionKey<LockSeedResolver> = Symbol("wpLockSeed");
+19
View File
@@ -53,6 +53,25 @@ export interface ModuleRowCtx {
* single `↥×N` chip. Empty on rows no constraint covers (every
* constraint / source / non-target row). */
contributorsFor: (id: string) => PairingBadge[];
/** Reactive cursor: the frame currently open in the override editor,
* or `null` when no frame is active (base view). */
currentFrame: Ref<number | null>;
/** True when the module's seed is held constant across all loop
* iterations (`instance.seed_scope === "hold"`). */
isHeld: (m: ModuleEntry) => boolean;
/** True when the module has an override entry for `currentFrame`. */
isOverriddenOnFrame: (m: ModuleEntry) => boolean;
/** The effective locked_seed for this module at `currentFrame` (or base when
* no frame is active). `undefined` when the effective instance is unlocked. */
effectiveLockedSeed: (m: ModuleEntry) => number | undefined;
/** Effective enabled state for the current view: `frame_enabled[k] ?? base
* enabled` when a frame is active, base `enabled` otherwise. Drives the row
* checkbox + the disabled (hatched) row styling. */
effectiveEnabled: (m: ModuleEntry) => boolean;
/** Per-frame enable override vs base at `currentFrame`: "on" (forced on while
* base off), "off" (forced off while base on), or null (no frame active / no
* override / equals base). Drives the on-#k / off-#k row badge. */
frameEnableOverride: (m: ModuleEntry) => "on" | "off" | null;
}
export const ModuleRowCtxKey: InjectionKey<ModuleRowCtx> = Symbol("moduleRowCtx");
+6 -1
View File
@@ -33,8 +33,12 @@ const props = withDefaults(
* The strategy CHIPS still reflect modelValue.strategy (the local config).
* Undefined = use modelValue.strategy (no loop / override off). */
previewStrategy?: SeedListStrategy;
/** The seed each frame used on the PREVIOUS run, captured from the node's
* executed `loop_seeds` UI payload by the host glue. Drives the seed
* modal's per-frame "lock previous" button. Null until a run lands. */
previousSeeds?: number[] | null;
}>(),
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined },
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null },
);
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
@@ -201,6 +205,7 @@ function toggleOverrideStrategy(): void {
</div>
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:previous-seeds="previousSeeds"
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
</div>
+7 -2
View File
@@ -16,6 +16,11 @@ const props = defineProps<{
* shows it when "Override Context seed" is OFF (a nudge); Seed List shows
* it when "Override base seed from loop" is ON (base comes from the loop). */
overrideHint?: string;
/** The seed each frame used on the PREVIOUS run (captured loop series),
* indexed by frame. Drives the per-row "lock previous" button; absent or
* short → that frame's button greys out. Distinct from the computed
* `derived` series, which is the UPCOMING seeds from the current base. */
previousSeeds?: number[] | null;
}>();
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
@@ -128,14 +133,14 @@ onBeforeUnmount(() => {
<div class="sm__list">
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
:seed="seedLocks[String(i)] ?? null" @update="onRow" />
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null" @update="onRow" />
<template v-if="inactiveLocks.length">
<div class="sm__inactive-label" data-test="mx-seed-inactive">
Inactive · won't apply at count {{ derived.length }}
</div>
<SeedLockRow v-for="i in inactiveLocks" :key="`inact-${i}`" :index="i"
:derived="seedLocks[String(i)]" :locked="true" :seed="seedLocks[String(i)]"
:inactive="true" @update="onRow" />
:previous="previousSeeds?.[i] ?? null" :inactive="true" @update="onRow" />
</template>
</div>
+24
View File
@@ -10,6 +10,11 @@ const props = defineProps<{
/** Locked index beyond the current count — dimmed but still editable;
* re-activates if count grows back. */
inactive?: boolean;
/** The seed this frame ACTUALLY used on the previous run (captured from the
* node's loop series). null/undefined when there's no prior run for this
* frame — the "lock previous" button greys out. Distinct from `derived`,
* which is the UPCOMING seed computed from the current base. */
previous?: number | null;
}>();
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
@@ -21,6 +26,9 @@ function randomSeed(): number {
}
function set(seed: number | null) { emit("update", { index: props.index, seed }); }
function onLock() { set(props.locked ? null : props.derived); }
/** Fill + lock the seed this frame used on the previous run. No-op (button is
* disabled) when there's no captured previous for this frame. */
function lockPrevious() { if (props.previous != null) set(props.previous); }
function onInput(ev: Event) {
const n = Number((ev.target as HTMLInputElement).value.trim());
if (Number.isFinite(n) && Number.isInteger(n)) set(n);
@@ -41,6 +49,17 @@ function bump(d: 1 | -1) {
</svg>
{{ locked ? "Locked" : "Lock" }}
</button>
<button type="button" class="toggle toggle--prev" data-test="seedrow-lockprev"
:disabled="previous == null || undefined"
:title="previous == null
? 'No previous run captured for this frame'
: `Lock the seed this frame used last run (${previous})`"
@click="lockPrevious">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v5h5" /><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" /><path d="M12 7v5l3 2" />
</svg>
Prev
</button>
<span class="srow__fill" />
<span v-if="!locked" class="derived">
<span class="derived__val" data-test="seedrow-derived">{{ derived }}</span>
@@ -79,6 +98,11 @@ function bump(d: 1 | -1) {
.toggle svg { color: var(--wp-text-dim, var(--wp-text3)); }
.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
.toggle--on svg { color: var(--wp-accent-text, var(--wp-text)); }
/* "Lock previous run's seed" — secondary action; greys out when the frame has
no captured previous (no prior run). */
.toggle--prev { color: var(--wp-text-dim, var(--wp-text3)); }
.toggle:disabled { opacity: .4; cursor: not-allowed; }
.toggle:disabled:hover { border-color: var(--wp-border); color: var(--wp-text-dim, var(--wp-text3)); }
.derived { display: inline-flex; align-items: center; gap: 8px; }
.derived__val { font: 11px var(--wp-font-mono, monospace); color: var(--wp-text4, #888); }
.derived__tag { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .07em; color: var(--wp-text-dim, var(--wp-text3)); border: 1px solid var(--wp-border); border-radius: 3px; padding: 1px 5px; }
@@ -51,6 +51,20 @@ describe("SeedListModal", () => {
await w.findAll('[data-test="seedrow-lock"]')[0].trigger("click"); // lock #1 (index 0), sequential derived = 42
expect(lastEmit(w)).toEqual({ "0": 42 });
});
it("threads previousSeeds → each row's lock-previous button (click locks the previous)", async () => {
const w = modal({ seedLocks: {}, previousSeeds: [111, 222, 333, 444] });
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
expect(prevBtns).toHaveLength(4);
await prevBtns[2].trigger("click"); // frame #3 → its previous seed 333
expect(lastEmit(w)).toEqual({ "2": 333 });
});
it("greys the lock-previous button for frames with no captured previous", () => {
const w = modal({ seedLocks: {}, previousSeeds: [111] }); // only frame 0 has a previous
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
expect(prevBtns[0].attributes("disabled")).toBeUndefined();
expect(prevBtns[1].attributes("disabled")).toBeDefined();
expect(prevBtns[3].attributes("disabled")).toBeDefined();
});
it("Lock all locks every iteration to its derived seed", async () => {
const w = modal({ seedLocks: {} });
await w.find('[data-test="mx-seed-lockall"]').trigger("click");
@@ -37,4 +37,15 @@ describe("SeedLockRow", () => {
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
});
it("lock-previous: fills + locks the captured previous seed", async () => {
const w = row({ previous: 777 });
const btn = w.find('[data-test="seedrow-lockprev"]');
expect(btn.attributes("disabled")).toBeUndefined();
await btn.trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 777 });
});
it("lock-previous: disabled when the frame has no previous seed", () => {
expect(row({ previous: null }).find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined();
expect(row().find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined(); // absent → disabled
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import corpus from "../../../../tests/fixtures/seed-derive-corpus.json";
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks } from "../seed-derive";
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks, effectiveChainSeed } from "../seed-derive";
describe("seed-derive (Py≡TS parity)", () => {
it("matches the engine corpus for every (base, count, strategy)", () => {
@@ -16,6 +16,19 @@ describe("seed-derive (Py≡TS parity)", () => {
});
});
describe("effectiveChainSeed (Py≡TS parity)", () => {
it("uses an override verbatim — no loop_index XOR (engine no-double-shift rule)", () => {
expect(effectiveChainSeed(42, 999, 0)).toBe(999);
expect(effectiveChainSeed(42, 999, 3)).toBe(999);
});
it("XORs loop_index into a constant widget seed only when there is no override", () => {
expect(effectiveChainSeed(42, null, 0)).toBe(42);
expect(effectiveChainSeed(42, null, 1)).toBe(482072580190692);
expect(effectiveChainSeed(42, null, 5)).toBe(343395897700430);
expect(effectiveChainSeed(2 ** 60, null, 3)).toBe(385286345697257);
});
});
describe("parseSeedLocks (Paste — inverse of the modal copy format)", () => {
it("parses #N: seed lines into a 0-based lock map", () => {
expect(parseSeedLocks("#1: 42\n#2: 43\n#3: 44")).toEqual({ "0": 42, "1": 43, "2": 44 });
+9 -1
View File
@@ -343,7 +343,8 @@
.wp-mod-badge--mod,
.wp-mod-badge--drift,
.wp-mod-badge--missing,
.wp-mod-badge--clash {
.wp-mod-badge--clash,
.wp-mod-badge--on {
flex-shrink: 0;
}
.wp-mod-badge--mod {
@@ -358,6 +359,13 @@
background: color-mix(in oklab, var(--wp-danger) 18%, transparent);
color: var(--wp-danger);
}
/* `--on` variant: per-frame ENABLE override — module is off on base but turned
* ON for this frame. Green (--wp-success) to read as "running here", the
* positive inverse of the `--missing` red used for the off-#k badge. */
.wp-mod-badge--on {
background: color-mix(in oklab, var(--wp-success) 18%, transparent);
color: var(--wp-success);
}
/* `--clash` variant: id reused by a DIFFERENT kind (shared id-space).
* Violet (--wp-accent) — distinct from MOD orange / DRIFT amber / MISSING
* red so a cross-kind collision is unmistakable. */
+19
View File
@@ -32,6 +32,25 @@ export function applySeedLocks(derived: number[], locks: Record<number, number>)
);
}
/**
* TS mirror of engine/seed_derive.py `effective_chain_seed` — the seed a
* WP_Context node rolls at iteration `loopIndex`. An override (the loop's
* already-per-iteration derived seed) is used verbatim; only a constant
* widget seed (no override) gets the loop_index XOR. Mirrors the engine's
* no-double-shift rule so a per-frame seed lock pins the seed the module
* actually rolls at that frame.
*/
export function effectiveChainSeed(
widgetSeed: number, seedOverride: number | null, loopIndex: number,
): number {
const base = seedOverride != null ? BigInt(seedOverride) : BigInt(widgetSeed);
if (loopIndex === 0 || seedOverride != null) return Number(base & MASK);
const digest = sha256Bytes(`loop:${loopIndex}`);
let shift = 0n;
for (let b = 0; b < 8; b++) shift = (shift << 8n) | BigInt(digest[b]);
return Number((base ^ shift) & MASK);
}
const LOCK_LINE = /^\s*#(\d+)\s*:\s*(-?\d+)\s*$/;
/**
+50
View File
@@ -9,6 +9,7 @@ import {
findDownstreamAssemblers,
findRootGraph,
hasUpstreamLoopOverridingSeed,
resolveUpstreamLoopSeed,
type LiteGraphLike,
type LiteNodeLike,
} from "./graph";
@@ -36,6 +37,55 @@ function fakeContextNode(id: number, vars: string[], upstreamLink?: number): Lit
};
}
describe("resolveUpstreamLoopSeed", () => {
function loopNode(seed: number, count: number, cfg: object): LiteNodeLike {
return {
id: 1,
type: "WP_ContextLoop",
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
widgets: [
{ name: "seed", value: seed },
{ name: "count", value: count },
{ name: "wp_context_loop_config", value: JSON.stringify(cfg) },
],
} as LiteNodeLike;
}
function mkGraph(loop: LiteNodeLike, ctx: LiteNodeLike): LiteGraphLike {
return {
_nodes: [loop, ctx],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: loop, 2: ctx } as Record<number, LiteNodeLike>)[id] ?? null,
};
}
it("reads the Loop's NUMERIC seed + count (regression: widgetValue is string-only)", () => {
const loop = loopNode(175588593219397, 4, { override_seed: true, strategy: "hash_index", seed_locks: {} });
const ctx = fakeContextNode(2, [], 100);
expect(resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx)).toEqual({
overrideSeed: true,
baseSeed: 175588593219397,
count: 4,
strategy: "hash_index",
seedLocks: {},
});
});
it("returns null when no Loop is upstream", () => {
const ctx = fakeContextNode(2, [], undefined);
const graph: LiteGraphLike = { _nodes: [ctx], links: {}, getNodeById: () => null };
expect(resolveUpstreamLoopSeed(graph, ctx)).toBeNull();
});
it("carries override_seed=false + the Loop's per-iteration seed_locks", () => {
const loop = loopNode(10, 3, { override_seed: false, strategy: "sequential", seed_locks: { "1": 999 } });
const ctx = fakeContextNode(2, [], 100);
const got = resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx);
expect(got?.overrideSeed).toBe(false);
expect(got?.strategy).toBe("sequential");
expect(got?.seedLocks).toEqual({ "1": 999 });
});
});
describe("collectUpstreamVariables", () => {
it("returns the upstream chain's writes only — never the starting node's own", () => {
const a = fakeContextNode(1, ["style"]);
+66
View File
@@ -13,6 +13,7 @@ import {
type WildcardOption,
} from "../components/context/editors/wildcard/probability";
import { ensure as ensurePreviewLookup, lookup as previewLookup } from "./preview-resolver";
import type { SeedStrategy } from "../components/shared/seed-derive";
// ── Subgraph boundary primer ────────────────────────────────────────────
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
@@ -100,6 +101,14 @@ function widgetValue(node: LiteNodeLike, name: string): string {
return typeof w?.value === "string" ? w.value : "";
}
/** Numeric widget read — `widgetValue` is string-only (returns "" for Int /
* Float / combo widgets), so a numeric widget like the Loop's `seed`/`count`
* must be read here. Coerces the raw value; NaN when absent/unparseable. */
function widgetNumber(node: LiteNodeLike, name: string): number {
const w = node.widgets?.find((x) => x.name === name);
return typeof w?.value === "number" ? w.value : Number(w?.value);
}
function isSubgraphNode(n: LiteNodeLike): boolean {
if (typeof n.isSubgraphNode === "function") {
@@ -430,6 +439,63 @@ export function hasUpstreamLoopOverridingSeed(
return false;
}
export interface UpstreamLoopSeed {
overrideSeed: boolean;
baseSeed: number;
count: number;
strategy: SeedStrategy;
/** 0-based iteration index (stringified) -> pinned seed (the Loop's own
* per-iteration seed-modal locks). Overlaid on the derived series. */
seedLocks: Record<string, number>;
}
/**
* Walk upstream to the first WP_ContextLoop and return its seed-deriving
* config — base seed + count + strategy + override flag + per-iteration locks —
* or null when no Loop drives this node. Lets a downstream WP_Context compute
* the seed each iteration actually rolls (for the per-frame seed lock). Same
* chain walk as `hasUpstreamLoopOverridingSeed`.
*/
export function resolveUpstreamLoopSeed(
rootGraph: LiteGraphLike,
node: LiteNodeLike,
): UpstreamLoopSeed | null {
const parents = buildSubgraphParents(rootGraph);
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
while (cur && !seen.has(locator(cur.graph, cur.node))) {
seen.add(locator(cur.graph, cur.node));
if (cur.node.type === "WP_ContextLoop" && !isSkippedMode(cur.node)) {
const cfg = parseWidgetJson<{
strategy?: string;
override_seed?: boolean;
seed_locks?: Record<string, number>;
}>(widgetValue(cur.node, "wp_context_loop_config"), {});
const strategy: SeedStrategy =
cfg.strategy === "sequential" || cfg.strategy === "prime_stride"
? cfg.strategy
: "hash_index";
const rawSeed = widgetNumber(cur.node, "seed");
const rawCount = widgetNumber(cur.node, "count");
const seedLocks: Record<string, number> = {};
if (cfg.seed_locks && typeof cfg.seed_locks === "object") {
for (const [k, v] of Object.entries(cfg.seed_locks)) {
if (typeof v === "number" && Number.isFinite(v)) seedLocks[k] = v;
}
}
return {
overrideSeed: cfg.override_seed === true,
baseSeed: Number.isFinite(rawSeed) ? rawSeed : 0,
count: Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1,
strategy,
seedLocks,
};
}
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
}
return null;
}
/**
* Variable bindings contributed by every `WP_ContextInjector` upstream
* of `node`. Used by the assembler so the in-template preview shows
@@ -227,3 +227,73 @@ describe("buildModulePublishable — constraint axis-name backfill", () => {
expect(inner).not.toHaveProperty("target_wildcard_name");
});
});
describe("buildModulePublishable — constraint axis-group backfill", () => {
// The grouping (tag_groups) lives on the WILDCARDS, not the constraint. These
// are the source/target wildcards as they sit in the live module catalog.
const moodWc = {
id: "mood0001", type: "wildcard", name: "mood",
payload: { var_binding: "mood", tag_groups: { temperament: ["bold", "serene"] } },
} as unknown as ModuleRow;
const colorWc = {
id: "color001", type: "wildcard", name: "color",
payload: {
var_binding: "color",
tag_groups: { saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"] },
},
} as unknown as ModuleRow;
// Single-axis wildcard: no tag_groups → nothing to snapshot.
const flatWc = {
id: "flat0001", type: "wildcard", name: "flat", payload: { var_binding: "flat" },
} as unknown as ModuleRow;
const catalog: ModuleRow[] = [moodWc, colorWc, flatWc];
function constraintRow(payload: Record<string, unknown>): ModuleRow {
return {
id: "cn000002", type: "constraint", name: "Mood x Color",
description: "", category_id: null, tags: [], is_favorite: false,
payload, payload_hash: "h".repeat(64), version: 1,
created_at: "2026-01-01", updated_at: "2026-01-01", content_rating: "safe" as const,
} as unknown as ModuleRow;
}
it("snapshots the source/target wildcards' tag_groups into the published constraint", () => {
const row = constraintRow({
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
});
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
expect(inner.source_groups).toEqual({ temperament: ["bold", "serene"] });
expect(inner.target_groups).toEqual({
saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"],
});
});
it("does NOT overwrite groups already embedded on the payload", () => {
const row = constraintRow({
source_wildcard_id: "mood0001", target_wildcard_id: "color001",
source_groups: { custom: ["x"] }, matrix: {}, exceptions: [],
});
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
expect(inner.source_groups).toEqual({ custom: ["x"] });
expect(inner.target_groups).toBeDefined(); // target still backfilled
});
it("leaves groups absent for a single-axis wildcard or a dangling ref", () => {
const row = constraintRow({
source_wildcard_id: "flat0001", target_wildcard_id: "gone9999", matrix: {}, exceptions: [],
});
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
expect(inner).not.toHaveProperty("source_groups");
expect(inner).not.toHaveProperty("target_groups");
});
it("a constraint carrying snapshotted groups still passes strict validation", () => {
const row = constraintRow({
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
});
const pub = buildModulePublishable(row, catalog);
expect(() =>
buildPublishBody({ payload: pub.payload, name: pub.name, description: pub.description }),
).not.toThrow();
});
});
@@ -234,6 +234,58 @@ function backfillConstraintNames(
return out;
}
/**
* Backfill a CONSTRAINT payload's axis GROUPINGS from the live catalog —
* sibling of `backfillConstraintNames`. A constraint references its source /
* target wildcards by id, but the per-axis grouping (`tag_groups: {axis:
* [sub-category, …]}`) lives on those WILDCARDS, not on the constraint. The
* community preview groups + colours its matrix by those axes, and a STANDALONE
* constraint (its wildcards not also published) has no dependency to resolve
* them from — so the matrix renders flat. Snapshotting each wildcard's
* tag_groups into the published constraint as `source_groups` / `target_groups`
* makes the grouping self-contained: the community needs nothing but the
* constraint itself.
*
* Non-mutating; fills only an ABSENT field, only when the id resolves to a
* wildcard carrying a non-empty `{axis: string[]}` tag_groups. A no-op for
* non-constraints, an empty catalog, single-axis wildcards, or dangling refs
* (those fall back to flat, same as a legacy constraint). These extra inner-
* payload keys ride the same path the cached names do — the constraint
* validator's inner `z.object` strips them on a consumer's install, so they're
* preview-only metadata that never reaches the engine.
*/
function backfillConstraintGroups(
type: string,
payload: Record<string, unknown>,
catalog: ModuleRow[],
): Record<string, unknown> {
if (type !== "constraint" || catalog.length === 0) return payload;
const groupsById = (id: unknown): Record<string, string[]> | undefined => {
if (typeof id !== "string") return undefined;
const row = catalog.find((m) => m.id === id);
const tg = (row?.payload as { tag_groups?: unknown } | undefined)?.tag_groups;
if (!tg || typeof tg !== "object") return undefined;
const out: Record<string, string[]> = {};
for (const [axis, members] of Object.entries(tg as Record<string, unknown>)) {
if (Array.isArray(members)) {
const tags = members.filter((m): m is string => typeof m === "string");
if (tags.length) out[axis] = tags;
}
}
return Object.keys(out).length ? out : undefined;
};
const out = { ...payload };
if (typeof out.source_groups === "undefined") {
const g = groupsById(out.source_wildcard_id);
if (g) out.source_groups = g;
}
if (typeof out.target_groups === "undefined") {
const g = groupsById(out.target_wildcard_id);
if (g) out.target_groups = g;
}
return out;
}
/**
* Build the engine-row payload for a module library row. Strips
* server-stamped lifecycle fields + the local `history` sidecar —
@@ -249,9 +301,13 @@ export function buildModulePublishable(
row: ModuleRow,
catalog: ModuleRow[] = [],
): PublishablePayload {
const inner = backfillConstraintNames(
const inner = backfillConstraintGroups(
row.type,
stripHistory(row.payload as Record<string, unknown>),
backfillConstraintNames(
row.type,
stripHistory(row.payload as Record<string, unknown>),
catalog,
),
catalog,
);
const payload: Record<string, unknown> = {
+46
View File
@@ -0,0 +1,46 @@
/**
* Shared "previous run seed series" capture for the Context Loop + Seed List
* widget glue. Both nodes emit their executed per-iteration series as
* `loop_seeds` in the node UI; this stores it onto `node.__wp_prev_seeds__` so
* the seed modal can offer a per-frame "lock previous" button.
*
* Mirrors the WP_Context capture in `context.ts`. Captured series is the run
* that JUST finished — robust to control_after_generate rotating the live seed
* widget to the NEXT run's value. No listener cleanup (same rationale as
* context.ts): the handler filters by node id and returns early when stale.
*/
import { app } from "#comfyui/app";
type ExecutedEvent = CustomEvent<{ node: string | number; output?: unknown }>;
/** ComfyUI wraps UI values in single-element arrays; probe both the top-level
* and the nested `ui` shape for cross-version safety. */
function pickArrayValue(obj: Record<string, unknown>, key: string): unknown {
const direct = obj[key];
if (direct !== undefined) return Array.isArray(direct) ? direct[0] : direct;
const ui = obj.ui as Record<string, unknown> | undefined;
if (ui) {
const fromUi = ui[key];
if (fromUi !== undefined) return Array.isArray(fromUi) ? fromUi[0] : fromUi;
}
return undefined;
}
/** Attach an `executed` listener that captures this node's `loop_seeds` series
* onto `node.__wp_prev_seeds__` (or clears it when the series is empty). */
export function attachLoopSeedsCapture(node: { id: string | number }): void {
function onExecuted(ev: Event): void {
const detail = (ev as ExecutedEvent).detail;
if (!detail || String(detail.node) !== String(node.id)) return;
if (!detail.output || typeof detail.output !== "object") return;
const rawSeries = pickArrayValue(detail.output as Record<string, unknown>, "loop_seeds");
if (!Array.isArray(rawSeries)) return;
const series = rawSeries
.map((v) => (typeof v === "number" ? v : Number(v)))
.filter((n) => Number.isFinite(n));
(node as unknown as { __wp_prev_seeds__?: number[] }).__wp_prev_seeds__ =
series.length ? series : undefined;
}
(app as unknown as { api?: { addEventListener: (n: string, fn: (e: Event) => void) => void } })
.api?.addEventListener("executed", onExecuted);
}
+17
View File
@@ -620,6 +620,10 @@ export interface ModuleEntry {
* project, adapted to our `_fresh_instance` shape.
*/
locked_seed?: number | null;
/** Iteration seed scope: "hold" pins this module's RNG to the run base
* seed (identical across loop iterations, re-rolls per run); "vary"
* (default/absent) rides the per-iteration seed. `locked_seed` wins. */
seed_scope?: "vary" | "hold";
/**
* Internal — when true, every binding this module produces is
* marked engine-only. Downstream modules in the same chain can
@@ -777,6 +781,19 @@ export interface ModuleEntry {
master_lock_by?: string;
};
};
/** Per-frame override patches: stringified 0-based iteration index ->
* partial instance merged onto this module's instance for that frame. */
iteration_overrides?: Record<string, Partial<NonNullable<ModuleEntry["instance"]>>>;
/** Legacy per-frame blocklist (0-based loop indices on which the
* module was suppressed). Superseded by `frame_enabled`; still read for
* back-compat and folded into `frame_enabled` on the first frame toggle. */
disabled_frames?: number[];
/** Per-frame enable override: stringified 0-based frame index -> enabled.
* Overrides the base `enabled` flag for that frame in EITHER direction — a
* base-off module can be turned ON for select frames, a base-on module OFF
* for others. effective = frame_enabled[k] ?? base enabled. Only frames that
* differ from base are stored. */
frame_enabled?: Record<string, boolean>;
}
export function parseWidgetJson<T>(raw: string, fallback: T): T {
+59
View File
@@ -14,10 +14,12 @@ import {
collectUpstreamWildcardUuids,
findRootGraph,
hasUpstreamLoopOverridingSeed,
resolveUpstreamLoopSeed,
type LiteGraphLike,
type LiteNodeLike,
} from "../extension/graph";
import { collectCrossNodePairingsFull, collectFullChainModules } from "../extension/cross-node-pairings";
import { deriveLoopSeeds, applySeedLocks, effectiveChainSeed } from "../components/shared/seed-derive";
import type { ChainModule, PairingBadge, RowPairings, TargetSelect } from "../extension/constraint-pairs";
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
import { applyVarAccessor, type ResolvedValue } from "./richTokenize";
@@ -442,6 +444,50 @@ export function create(node: ContextNode, inputName: string) {
.__wp_last_used_seed__;
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
// Per-frame seed reader for the iteration-frame lock. Computes the seed
// THIS module rolls at frame #k WITHOUT a run, mirroring the engine:
// resolve the upstream Loop's derived series (base + strategy + the Loop's
// own per-iteration locks) and feed it through effectiveChainSeed exactly
// like wp_nodes/context_node.py. Null when no frame active / no Loop /
// frame out of range — caller then falls back to the stored seed.
function frameSeedReader(frame: number): number | null {
if (!Number.isInteger(frame) || frame < 0) return null;
// Prefer the seed THIS frame ACTUALLY rolled last run — captured from
// the Loop's derived series in the run's UI payload (see onExecuted).
// This is the PREVIOUS run's value, which is what "lock to reproduce"
// needs; it survives control_after_generate=randomize rotating the
// live seed widget to the NEXT run's seed.
const captured = (node as unknown as { __wp_loop_seeds__?: number[] }).__wp_loop_seeds__;
if (Array.isArray(captured) && frame < captured.length
&& typeof captured[frame] === "number") {
return captured[frame];
}
// No run captured yet (cold start) / Loop not driving seeds → compute
// from the current Loop config. Exact for fixed control; for randomize
// before any run there's nothing better than the about-to-run series.
const startGraph =
(node as unknown as { graph?: LiteGraphLike }).graph
?? (app.graph as unknown as LiteGraphLike);
const rootGraph = findRootGraph(startGraph);
const loop = resolveUpstreamLoopSeed(rootGraph, node);
const seedWidget = (node as unknown as {
widgets?: Array<{ name?: string; value?: unknown }>;
}).widgets?.find((w) => w.name === "seed");
const nodeSeedRaw = Number(seedWidget?.value);
const nodeSeed = Number.isFinite(nodeSeedRaw) ? nodeSeedRaw : 0;
if (loop && loop.overrideSeed) {
if (frame >= loop.count) return null;
const series = applySeedLocks(
deriveLoopSeeds(loop.baseSeed, loop.count, loop.strategy),
loop.seedLocks as unknown as Record<number, number>,
);
return effectiveChainSeed(nodeSeed, series[frame], frame);
}
// No override (or no Loop): the per-node widget seed varies per
// iteration via the loop_index XOR — same path as a base-off override.
return effectiveChainSeed(nodeSeed, null, frame);
}
return () => {
// Reactive lock: read the upstream-override ref inside the
// render thunk so Vue tracks it. `syncSeedWidgetGate` is a
@@ -462,6 +508,7 @@ export function create(node: ContextNode, inputName: string) {
chainModules: chainModulesRef.value,
nodeMode: nodeMode.value,
lastUsedSeedReader,
frameSeedReader,
onChange: (json: string) => host.setValue(json),
onRequestMinWidth: (w: number) => {
if (w === dynamicMinWidth) return;
@@ -562,6 +609,18 @@ export function create(node: ContextNode, inputName: string) {
(node as unknown as { __wp_last_used_per_module__?: Record<string, number> })
.__wp_last_used_per_module__ = perModule;
}
// The Loop's per-iteration derived series for THIS run. Stored so the
// per-frame seed lock pins the seed each frame actually rolled — the
// captured series is the PREVIOUS run's (the live seed widget may have
// since rotated under control_after_generate=randomize).
const rawSeries = pickArrayValue(out, "loop_seeds");
if (Array.isArray(rawSeries)) {
const series = rawSeries
.map((v) => (typeof v === "number" ? v : Number(v)))
.filter((n) => Number.isFinite(n));
(node as unknown as { __wp_loop_seeds__?: number[] }).__wp_loop_seeds__ =
series.length ? series : undefined;
}
// No fallback when `module_seeds` is missing — the chain-level
// `__wp_last_used_seed__` already updated above gives the
// toggle a sensible fallback via the chain-level reader path,
+17
View File
@@ -10,6 +10,7 @@
*/
import { defineAsyncComponent, h, ref, type Component } from "vue";
import { createDomWidgetHost, type DomWidgetHost, type MountTargetNode } from "./_shared";
import { attachLoopSeedsCapture } from "./_seed-capture";
import { reactiveFromGraph } from "../extension/reactive";
import {
emptyContextLoopConfig,
@@ -50,6 +51,16 @@ export function create(node: ContextLoopHostNode, inputName: string) {
Object.is,
);
// The Loop's per-iteration seed series from the PREVIOUS run, captured in
// `onExecuted` below. Drives the seed modal's "lock previous" button.
// Reactive so an open modal refreshes the instant a run lands; `onExecuted`
// assigns a fresh array each run, so ref equality detects the change.
const previousSeeds = reactiveFromGraph(
node,
() => (node as unknown as { __wp_prev_seeds__?: number[] }).__wp_prev_seeds__ ?? null,
Object.is,
);
let host: DomWidgetHost | null = null;
const wrapper: Component = {
@@ -66,6 +77,7 @@ export function create(node: ContextLoopHostNode, inputName: string) {
nodeMode: nodeMode.value,
baseSeed: baseSeed.value,
count: count.value,
previousSeeds: previousSeeds.value,
"onUpdate:modelValue": onUpdate,
});
},
@@ -81,5 +93,10 @@ export function create(node: ContextLoopHostNode, inputName: string) {
minHeight: 140,
minWidth: 240,
});
// Capture the Loop's executed `loop_seeds` series for the modal's "lock
// previous" button (see _seed-capture).
attachLoopSeedsCapture(node);
return host;
}
+16
View File
@@ -10,6 +10,7 @@
*/
import { defineAsyncComponent, h, ref, watch, type Component } from "vue";
import { createDomWidgetHost, type DomWidgetHost, type MountTargetNode } from "./_shared";
import { attachLoopSeedsCapture } from "./_seed-capture";
import { reactiveFromGraph } from "../extension/reactive";
import {
emptySeedListConfig,
@@ -53,6 +54,16 @@ export function create(node: SeedListHostNode, inputName: string) {
Object.is,
);
// The node's per-iteration seed series from the PREVIOUS run, captured in
// `onExecuted` below. Drives the seed modal's "lock previous" button.
// Reactive so an open modal refreshes the instant a run lands; `onExecuted`
// assigns a fresh array each run, so ref equality detects the change.
const previousSeeds = reactiveFromGraph(
node,
() => (node as unknown as { __wp_prev_seeds__?: number[] }).__wp_prev_seeds__ ?? null,
Object.is,
);
// Walk the `loop_config` input to its upstream WP_ContextLoop node and
// read its stock `seed` + `count` widgets plus `wp_context_loop_config`
// strategy. Returns null when unwired. Reactive so the preview updates
@@ -142,6 +153,7 @@ export function create(node: SeedListHostNode, inputName: string) {
baseSeed: effective.baseSeed,
count: effective.count,
previewStrategy: effective.strategy,
previousSeeds: previousSeeds.value,
"onUpdate:modelValue": onUpdate,
});
};
@@ -206,5 +218,9 @@ export function create(node: SeedListHostNode, inputName: string) {
prevHasLink = now;
});
// Capture the node's executed `loop_seeds` series for the modal's "lock
// previous" button (see _seed-capture).
attachLoopSeedsCapture(node);
return host;
}
+277
View File
@@ -0,0 +1,277 @@
from engine.pipeline import PipelineEngine
def _run(modules, *, loop_index, seed=42):
return PipelineEngine().run(modules, seed=seed, loop_index=loop_index)
def _wildcard(binding, values, *, overrides=None):
m = {
"type": "wildcard",
"payload": {
"var_binding": binding,
"options": [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)],
},
"instance": {"variable_binding": binding},
}
if overrides is not None:
m["iteration_overrides"] = overrides
return m
def test_override_pins_option_only_on_its_frame():
# Override frame 2: set mode=pinned + pinned_option_id="0" (value "a").
m = _wildcard(
"x",
["a", "b", "c", "d"],
overrides={"2": {"mode": "pinned", "pinned_option_id": "0"}},
)
# Frame 2 must always return "a" (pinned), regardless of seed.
for s in range(10):
assert _run([m], loop_index=2, seed=s)["x"] == "a"
# Non-override frames must NOT be pinned: check across many seeds to
# confirm they produce values other than "a" (RNG-driven diversity).
others = {
_run([m], loop_index=k, seed=s)["x"]
for k in (0, 1, 3, 5)
for s in range(20)
}
assert others != {"a"}
def test_combine_template_override_on_frame():
m = {
"type": "combine",
"payload": {"output_var": "y", "template": "base text"},
"instance": {},
"iteration_overrides": {"1": {"template_override": "frame one text"}},
}
assert _run([m], loop_index=1)["y"] == "frame one text"
assert _run([m], loop_index=0)["y"] == "base text"
def test_constraint_cell_factor_override_applies_only_on_its_frame():
"""iteration_overrides on a constraint module patches the instance before
the handler registers its metadata — the wildcard downstream picks up
a different matrix on the override frame vs a non-override frame.
Setup:
- source wildcard "src" picks "s1" (pinned on all frames via locked_seed
+ mode=pinned so the source is predictable and the matrix row is stable)
- constraint: matrix s1 -> t1 boost factor 1.0 (baseline — no real effect)
- target wildcard "tgt" has two options "t1" (id="0") and "t2" (id="1"),
each weight 1.0. With a large boost factor (100.0) on frame 3, the
target wildcard should pick "t1" virtually every time; on other frames
the default factor 1.0 leaves both options equally weighted so variance
is possible.
The test asserts:
- frame 3 result is "t1" (the boosted option wins under a large factor)
- at least one other frame is not forced to "t1" (many_other_results is
NOT {"t1"}) — this verifies the override is frame-local, not global.
"""
src_module = {
"type": "wildcard",
"id": "src-uuid",
"payload": {
"var_binding": "src",
"options": [{"id": "s0", "value": "s1", "weight": 1.0}],
},
"instance": {
"variable_binding": "src",
"mode": "pinned",
"pinned_option_id": "s0",
},
}
constraint_module = {
"type": "constraint",
"id": "con-uuid",
"payload": {
"source_wildcard_id": "src-uuid",
"target_wildcard_id": "tgt-uuid",
"matrix": {
"s1": {
"t1": {"mode": "boost", "factor": 1.0},
},
},
"exceptions": [],
},
"instance": {},
# On frame 3: override cell factor to 100.0 (dominates random picks).
"iteration_overrides": {
"3": {"cell_factor_overrides": {'["s1","t1"]': 100.0}},
},
}
tgt_module = {
"type": "wildcard",
"id": "tgt-uuid",
"payload": {
"var_binding": "tgt",
"options": [
{"id": "0", "value": "t1", "weight": 1.0},
{"id": "1", "value": "t2", "weight": 1.0},
],
},
"instance": {"variable_binding": "tgt"},
}
chain = [src_module, constraint_module, tgt_module]
# Frame 3: massive boost forces "t1".
result_frame3 = _run(chain, loop_index=3, seed=42)["tgt"]
assert result_frame3 == "t1", (
f"expected 't1' on frame 3 (boosted), got {result_frame3!r}"
)
# Other frames: run many seeds to confirm "t2" appears at least once,
# proving the override does NOT leak across frames.
other_results = {
_run(chain, loop_index=k, seed=s)["tgt"]
for k in (0, 1, 2, 4)
for s in range(10)
}
assert "t2" in other_results, (
"expected 't2' to appear on non-override frames but only got: "
f"{other_results!r}"
)
class _ObjModule:
"""Minimal duck-typed module — NOT a dict and NOT a dataclass — to
exercise the generic-object snapshot branch in PipelineEngine.run."""
def __init__(self, **attrs):
for key, value in attrs.items():
setattr(self, key, value)
def test_iteration_override_applies_to_generic_object_module():
"""A module arriving as a generic object (not a dict / not a dataclass)
must still honor iteration_overrides. Regression: the generic-object
snapshot key tuple omitted iteration_overrides, so per-frame overrides
were silently dropped for non-dict modules."""
m = _ObjModule(
type="combine",
payload={"output_var": "y", "template": "base text"},
instance={},
iteration_overrides={"1": {"template_override": "frame one text"}},
)
# Frame 1 carries an override → the template is replaced.
assert _run([m], loop_index=1)["y"] == "frame one text"
# Frame 0 has no override entry → base template.
assert _run([m], loop_index=0)["y"] == "base text"
# ── R4: per-frame enable/disable (disabled_frames) ──────────────────────────
def _wildcard_df(binding, values, *, disabled_frames=None):
"""Wildcard module with optional disabled_frames list."""
m = {
"type": "wildcard",
"payload": {
"var_binding": binding,
"options": [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)],
},
"instance": {"variable_binding": binding},
}
if disabled_frames is not None:
m["disabled_frames"] = disabled_frames
return m
def test_disabled_frames_skips_only_on_listed_frame():
"""A module with disabled_frames=[2] produces no binding at loop_index=2
but produces one at every other frame."""
m = _wildcard_df("z", ["alpha", "beta", "gamma"], disabled_frames=[2])
# Frame 2: module should be skipped — "z" must NOT appear in context.
ctx_frame2 = _run([m], loop_index=2)
assert "z" not in ctx_frame2, (
f"expected 'z' absent at frame 2 (disabled_frames=[2]), got {ctx_frame2.get('z')!r}"
)
# Frames 0, 1, 3: module should run — "z" must be bound.
for k in (0, 1, 3):
result = _run([m], loop_index=k)
assert "z" in result, (
f"expected 'z' bound at frame {k} but it was missing; ctx={result!r}"
)
def test_disabled_frames_trace_row_carries_skipped_frame_status():
"""Trace row at the disabled frame should carry status='skipped_frame'."""
m = _wildcard_df("z", ["alpha", "beta"], disabled_frames=[2])
ctx = _run([m], loop_index=2)
trace = ctx.get("__wp_trace__", [])
assert len(trace) == 1, f"expected 1 trace row, got {trace!r}"
row = trace[0]
assert row["status"] == "skipped_frame", (
f"expected status='skipped_frame', got {row['status']!r}"
)
assert row["enabled"] is True, (
"frame-skip row must show enabled=True (module is not globally disabled)"
)
def test_disabled_frames_multiple_frames():
"""disabled_frames=[0, 3] skips frames 0 and 3, runs on 1 and 2."""
m = _wildcard_df("w", ["x", "y"], disabled_frames=[0, 3])
for k in (0, 3):
result = _run([m], loop_index=k)
assert "w" not in result, (
f"expected 'w' absent at disabled frame {k}, got {result.get('w')!r}"
)
for k in (1, 2):
result = _run([m], loop_index=k)
assert "w" in result, (
f"expected 'w' bound at enabled frame {k}"
)
# ── frame_enabled: symmetric per-frame enable override ──────────────────────
#
# Model: effective_enabled(k) = frame_enabled[str(k)] ?? base `enabled`.
# - base ON + frame_enabled[k]=False -> module skipped on frame k only
# - base OFF + frame_enabled[k]=True -> module runs on frame k only
# Legacy `disabled_frames` stays readable (back-compat) and means
# frame_enabled[k]=False for each listed frame.
def _wildcard_fe(binding, values, *, enabled=True, frame_enabled=None):
"""Wildcard module with base `enabled` + optional frame_enabled map."""
m = {
"type": "wildcard",
"enabled": enabled,
"payload": {
"var_binding": binding,
"options": [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)],
},
"instance": {"variable_binding": binding},
}
if frame_enabled is not None:
m["frame_enabled"] = frame_enabled
return m
def test_frame_enable_overrides_base_off_runs_only_on_that_frame():
"""A base-disabled module turned ON for frame 2 runs ONLY on frame 2 —
frame_enabled overrides base `enabled` per frame. Impossible under the old
disabled_frames blocklist (base-off skipped every frame)."""
m = _wildcard_fe("z", ["alpha"], enabled=False, frame_enabled={"2": True})
# Frame 2: runs despite base-off.
assert "z" in _run([m], loop_index=2), "expected 'z' bound on the frame-enabled frame 2"
# Every other frame: base-off wins.
for k in (0, 1, 3):
assert "z" not in _run([m], loop_index=k), f"expected 'z' absent at frame {k} (base off)"
def test_frame_disable_overrides_base_on_via_frame_enabled():
"""A base-ON module turned OFF for frame 1 via frame_enabled runs on every
frame except 1 — the symmetric inverse of the base-off case."""
m = _wildcard_fe("z", ["alpha"], enabled=True, frame_enabled={"1": False})
assert "z" not in _run([m], loop_index=1), "expected 'z' absent on the frame-disabled frame 1"
for k in (0, 2, 3):
assert "z" in _run([m], loop_index=k), f"expected 'z' bound at frame {k}"
+8 -6
View File
@@ -65,12 +65,14 @@ def test_effective_chain_seed_loop_index_mixes_when_positive():
assert b != c
def test_effective_chain_seed_override_path_mixes_loop_index():
a = effective_chain_seed(widget_seed=42, seed_override=10, loop_index=0)
b = effective_chain_seed(widget_seed=42, seed_override=10, loop_index=1)
assert a == 10
assert b != 10
assert b != 42
def test_effective_chain_seed_override_used_as_is_ignores_loop_index():
# An override IS the loop's already-per-iteration derived seed, so the
# loop_index XOR must NOT be re-applied on top — the override is used
# verbatim at every index. (The XOR only exists to vary a CONSTANT widget
# seed when there is no override; see the override-OFF mixing test.)
assert effective_chain_seed(widget_seed=42, seed_override=10, loop_index=0) == 10
assert effective_chain_seed(widget_seed=42, seed_override=10, loop_index=1) == 10
assert effective_chain_seed(widget_seed=42, seed_override=10, loop_index=3) == 10
def test_effective_chain_seed_deterministic():
+116
View File
@@ -0,0 +1,116 @@
from engine.pipeline import PipelineEngine
from engine.seed_derive import effective_chain_seed
def _run(modules, *, widget_seed, loop_index):
"""Run the engine the way wp_nodes/context_node.py does: a per-iteration
chain seed plus a held seed (loop_index pinned to 0)."""
chain_seed = effective_chain_seed(
widget_seed=widget_seed, seed_override=None, loop_index=loop_index
)
hold_seed = effective_chain_seed(
widget_seed=widget_seed, seed_override=None, loop_index=0
)
return PipelineEngine().run(
modules, seed=chain_seed, hold_seed=hold_seed, loop_index=loop_index
)
def test_run_exposes_hold_seed_and_loop_index_in_ctx():
ctx = _run([], widget_seed=42, loop_index=3)
expected_hold = effective_chain_seed(
widget_seed=42, seed_override=None, loop_index=0
)
assert ctx["__wp_loop_index__"] == 3
assert ctx["__wp_node_seed_hold__"] == expected_hold
assert ctx["__wp_node_seed__"] != ctx["__wp_node_seed_hold__"]
def _wildcard(binding, values, *, seed_scope=None):
instance = {"variable_binding": binding}
if seed_scope is not None:
instance["seed_scope"] = seed_scope
options = [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)]
return {
"type": "wildcard",
"payload": {"var_binding": binding, "options": options},
"instance": instance,
}
VALUES = ["a", "b", "c", "d", "e", "f", "g", "h"]
def test_hold_wildcard_is_identical_across_iterations():
held = [
_run([_wildcard("x", VALUES, seed_scope="hold")], widget_seed=42, loop_index=k)["x"]
for k in range(4)
]
assert len(set(held)) == 1
def test_vary_wildcard_changes_across_iterations():
varied = [_run([_wildcard("x", VALUES)], widget_seed=42, loop_index=k)["x"] for k in range(6)]
assert len(set(varied)) > 1
def test_locked_seed_wins_over_hold():
m = _wildcard("x", VALUES, seed_scope="hold")
m["instance"]["locked_seed"] = 12345
a = _run([m], widget_seed=42, loop_index=0)["x"]
b = _run([m], widget_seed=999, loop_index=7)["x"]
assert a == b
def _combine(output_var, template, *, seed_scope=None):
instance = {}
if seed_scope is not None:
instance["seed_scope"] = seed_scope
return {
"type": "combine",
"payload": {"output_var": output_var, "template": template},
"instance": instance,
}
COMBINE_TMPL = "{alpha|bravo|charlie|delta|echo|foxtrot|golf|hotel}"
def test_hold_combine_is_identical_across_iterations():
held = [
_run([_combine("y", COMBINE_TMPL, seed_scope="hold")], widget_seed=42, loop_index=k)["y"]
for k in range(4)
]
assert len(set(held)) == 1
def test_vary_combine_changes_across_iterations():
varied = [
_run([_combine("y", COMBINE_TMPL)], widget_seed=42, loop_index=k)["y"]
for k in range(6)
]
assert len(set(varied)) > 1
# fixed_values: single entry with name="z" and alternation value so the resolver
# picks one of the 8 options based on the seed — same mechanic as combine.
FIXED_TMPL = "{a|b|c|d|e|f|g|h}"
def _fixed(binding, *, seed_scope=None):
instance = {}
if seed_scope is not None:
instance["seed_scope"] = seed_scope
return {
"type": "fixed_values",
"payload": {"values": [{"name": binding, "value": FIXED_TMPL}]},
"instance": instance,
}
def test_hold_fixed_values_is_identical_across_iterations():
held = [
_run([_fixed("z", seed_scope="hold")], widget_seed=42, loop_index=k)["z"]
for k in range(4)
]
assert len(set(held)) == 1
+11
View File
@@ -32,3 +32,14 @@ def test_execute_applies_seed_locks_when_override_on():
assert payloads[2].internals["__wp_seed_override__"] == 7
assert payloads[0].internals["__wp_seed_override__"] == 42
assert payloads[2].internals["__wp_loop_seeds__"] == [42, 43, 7, 45]
# The same series surfaces to the node UI so the frontend can offer
# "lock previous" in the seed modal.
assert out.ui == {"loop_seeds": [[42, 43, 7, 45]]}
def test_execute_emits_empty_ui_seeds_when_override_off():
# Override off → the Loop doesn't drive seeds, so there's no series to
# offer; the empty UI list greys the "lock previous" button per frame.
raw = json.dumps({"strategy": "sequential", "override_seed": False})
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
assert out.ui == {"loop_seeds": [[]]}
+1 -1
View File
@@ -51,7 +51,7 @@ def _ctx_capture():
captured: dict = {}
class _FakePipeline:
def run(self, modules, *, ctx, seed):
def run(self, modules, *, ctx, seed, hold_seed=None, loop_index=0):
captured.clear()
captured.update(ctx)
return ctx
+5 -1
View File
@@ -324,7 +324,11 @@ def test_execute_widget_mode_matches_derive_loop_seeds():
wp_seed_list_config=_cfg(strategy="hash_index"),
loop_config=None,
)
assert _seeds(out) == derive_loop_seeds(42, 4, "hash_index")
seeds = _seeds(out)
assert seeds == derive_loop_seeds(42, 4, "hash_index")
# The series also surfaces to the node UI so the frontend can offer
# "lock previous" in the seed modal.
assert out.ui == {"loop_seeds": [[int(s) for s in seeds]]}
def test_execute_widget_mode_ignores_wired_loop_config_when_all_off():
+10 -1
View File
@@ -216,4 +216,13 @@ class WPContextLoop(io.ComfyNode):
debug={},
internals=internals,
))
return io.NodeOutput(payloads, loop_config_payload)
# 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]},
)
+26 -3
View File
@@ -157,12 +157,24 @@ class WPContext(io.ComfyNode):
# When ContextLoop is upstream with override_seed=true, the
# override replaces the widget seed; loop_index>0 XORs in a
# stable hash shift so each iteration walks a distinct seed.
loop_index = int(upstream_internals.get("__wp_loop_index__", 0))
chain_seed = effective_chain_seed(
widget_seed=int(seed),
seed_override=upstream_internals.get("__wp_seed_override__"),
loop_index=int(upstream_internals.get("__wp_loop_index__", 0)),
loop_index=loop_index,
)
hold_seed = effective_chain_seed(
widget_seed=int(seed),
seed_override=upstream_internals.get("__wp_seed_override__"),
loop_index=0,
)
ctx = PipelineEngine().run(
module_list,
ctx=ctx,
seed=chain_seed,
hold_seed=hold_seed,
loop_index=loop_index,
)
ctx = PipelineEngine().run(module_list, ctx=ctx, seed=chain_seed)
payload = build_payload(ctx, upstream_debug=upstream_debug, seed=chain_seed)
# Emit two seed-tracking values via the UI payload so the
@@ -193,7 +205,18 @@ class WPContext(io.ComfyNode):
es = entry.get("seed")
if isinstance(key, str) and key and isinstance(es, int):
module_seeds[key] = es
# The Loop's full derived per-iteration series (present only when the
# Loop drives seeds via override). Captured by the frontend so the
# per-frame seed lock pins the seed each frame ACTUALLY rolled THIS
# run — robust to control_after_generate=randomize, where the live
# seed widget has already rotated to the NEXT run's seed.
_loop_seeds = upstream_internals.get("__wp_loop_seeds__")
loop_seeds = [int(s) for s in _loop_seeds] if isinstance(_loop_seeds, list) else []
return io.NodeOutput(
payload,
ui={"seed": [int(seed)], "module_seeds": [module_seeds]},
ui={
"seed": [int(seed)],
"module_seeds": [module_seeds],
"loop_seeds": [loop_seeds],
},
)
+3 -1
View File
@@ -251,4 +251,6 @@ class WPSeedList(io.ComfyNode):
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
cfg["seed_locks"],
)
return io.NodeOutput(seeds)
# Surface the per-iteration series to the node UI so the frontend can
# offer "lock previous" in the seed modal (captured in seed_list.ts).
return io.NodeOutput(seeds, ui={"loop_seeds": [[int(s) for s in seeds]]})