Compare commits

...
18 Commits
Author SHA1 Message Date
semantic-release-bot f1451baf40 chore(release): 2.7.0 [skip ci] 2026-06-26 03:00:36 +00:00
DumiFlex 589f45eb74 feat(seedlist): alt-click copy to grab only the locked seeds 2026-06-26 05:56:55 +03:00
DumiFlex fa73750ca0 feat(seedlist): add paste-to-replace locks from copied text 2026-06-26 05:56:54 +03:00
DumiFlex b49a579b33 feat(seedlist): show out-of-range locks dimmed but still editable 2026-06-26 05:56:53 +03:00
DumiFlex 34ec18feff feat(seedlist): flash a copied indicator on the modal copy button 2026-06-26 05:56:52 +03:00
DumiFlex 44d8ab9575 feat(widgets): seed-list override hint + capitalize loop bypass label 2026-06-26 05:56:51 +03:00
DumiFlex 68910bfc18 feat(seedlist): preview effective base/count/strategy from the wired loop 2026-06-26 05:56:50 +03:00
DumiFlex b5262c22d5 test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests 2026-06-26 05:56:49 +03:00
DumiFlex fb19e3cef8 feat(widgets): feed stock base-seed + count into the seed modal 2026-06-26 05:56:48 +03:00
DumiFlex 43d775ec8f feat(seedlist): per-iteration seeds button + modal 2026-06-26 05:56:47 +03:00
DumiFlex 86eb15cfa3 feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons 2026-06-26 05:56:46 +03:00
DumiFlex 0f0cd47018 feat(shared): view/set/lock per-iteration seeds modal 2026-06-26 05:56:45 +03:00
DumiFlex 1ad7e8e415 feat(shared): add per-iteration view/set/lock row (SeedLockRow) 2026-06-26 05:56:44 +03:00
DumiFlex 5a3e834c42 feat(widgets): add seed_locks field to loop + seed-list configs 2026-06-26 05:56:43 +03:00
DumiFlex cc64ca3ab8 feat(shared): ts mirror of loop seed derivation with py-ts parity corpus 2026-06-26 05:56:43 +03:00
DumiFlex fd2cdcbf08 feat(seedlist): apply per-iteration seed locks to the output series 2026-06-26 05:56:42 +03:00
DumiFlex 48845981a1 feat(loop): apply per-iteration seed locks in the context loop node 2026-06-26 05:56:40 +03:00
DumiFlex 5da64f8d78 feat(engine): add apply_seed_locks for per-iteration seed overrides 2026-06-26 05:56:39 +03:00
30 changed files with 1483 additions and 21 deletions
+28
View File
@@ -1,3 +1,31 @@
## 🎉 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)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
</details>
## 🎉 Wildcard Pipeline 2.6.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)
+13
View File
@@ -41,6 +41,19 @@ def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
def apply_seed_locks(derived: list[int], locks: dict[int, int]) -> list[int]:
"""Overlay per-index locked seeds on a derived series.
`locks` maps a 0-based iteration index to a pinned seed. Locked
indices replace the derived value (masked to MAX_SAFE_SEED);
out-of-range indices are ignored. Pure — no ComfyUI imports.
"""
return [
(locks[i] & MAX_SAFE_SEED) if i in locks else seed
for i, seed in enumerate(derived)
]
def effective_chain_seed(
*,
widget_seed: int,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.6.0",
"version": "2.7.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.6.0"
version = "2.7.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+17 -1
View File
@@ -178,7 +178,23 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// plus the shared components/shared/matrix-axis module the SPA matrix now
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
// put. Approved with the matrix-parity feature request.
const TOTAL_LIMIT = 435 * 1024; // 435 KB
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
// covers both T8 and T9 landings without per-task bump friction.
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
// was tight. Minimal bump to the actual measured ceiling.
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
// effective base/count/strategy from wired loop). Adds preview.ts pure
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
// 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
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import ContextLoopWidget from "./ContextLoopWidget.vue";
import SeedListModal from "../shared/SeedListModal.vue";
import { emptyContextLoopConfig } from "./types";
describe("ContextLoopWidget", () => {
@@ -89,3 +90,40 @@ describe("ContextLoopWidget", () => {
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
});
});
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
return mount(ContextLoopWidget, {
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
attachTo: document.body,
global: { stubs: { teleport: true } },
});
}
describe("ContextLoopWidget seeds button", () => {
it("renders the Per-iteration seeds button", () => {
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
});
it("badge shows the locked count when >0", () => {
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
});
it("hides the badge when nothing is locked", () => {
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
});
it("clicking opens the modal", async () => {
const wr = ws();
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
});
it("capitalizes the bypass row label", () => {
expect(ws().text()).toContain("Bypass loop");
});
it("passes the override hint to the modal only when Override Context seed is off", async () => {
const off = ws(); // override_seed false by default → nudge to turn it on
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
const on = ws({ override_seed: true });
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
});
});
@@ -12,8 +12,9 @@
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
* pushes via `host.setValue` so ComfyUI's widget value matches.
*/
import { computed } from "vue";
import { computed, ref } from "vue";
import type { ContextLoopConfig, LoopStrategy } from "./types";
import SeedListModal from "../shared/SeedListModal.vue";
const props = withDefaults(
defineProps<{
@@ -21,8 +22,12 @@ const props = withDefaults(
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
nodeMode?: number;
/** Base seed forwarded from the host ComfyUI INT widget. */
baseSeed?: number;
/** Iteration count forwarded from the host ComfyUI INT widget. */
count?: number;
}>(),
{ nodeMode: 0 },
{ nodeMode: 0, baseSeed: 0, count: 1 },
);
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
@@ -46,6 +51,13 @@ const OVERRIDE_SEED_TOOLTIP =
const isMuted = computed<boolean>(() => props.nodeMode === 2);
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
const seedsOpen = ref(false);
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
function onSeedLocks(next: Record<string, number>): void {
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
}
function pickStrategy(s: LoopStrategy): void {
if (props.modelValue.strategy === s) return;
emit("update:modelValue", { ...props.modelValue, strategy: s });
@@ -108,6 +120,14 @@ function toggleTotalInternal(): void {
</div>
</div>
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
Per-iteration seeds
<span class="wp-loop__seedbtn-fill" />
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
<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__row"
:title="OVERRIDE_SEED_TOOLTIP"
@@ -169,7 +189,7 @@ function toggleTotalInternal(): void {
</div>
<div class="wp-loop__row">
<span class="wp-loop__row-label">bypass loop</span>
<span class="wp-loop__row-label">Bypass loop</span>
<button
type="button"
class="wp-loop__switch"
@@ -181,6 +201,11 @@ function toggleTotalInternal(): void {
<span class="wp-loop__switch-thumb" />
</button>
</div>
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
</div>
</template>
@@ -289,8 +314,8 @@ function toggleTotalInternal(): void {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 18px;
height: 18px;
margin-left: 2px;
padding: 0;
background: transparent;
@@ -306,8 +331,15 @@ function toggleTotalInternal(): void {
border-color: var(--wp-accent, #c4b5fd);
color: var(--wp-accent, #c4b5fd);
}
.wp-loop__pi-btn .pi { font-size: 11px; }
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
.wp-loop__pi-btn .pi { font-size: 9px; }
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
.wp-loop__seedbtn-fill { flex: 1; }
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
/* Bypass-on visually dims the strategy/override/var rows since the loop
* is effectively off. The bypass row itself stays bright. */
+19
View File
@@ -30,6 +30,7 @@ describe("parseContextLoopConfig", () => {
bypass: true,
iteration_internal: false,
total_internal: false,
seed_locks: {},
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -42,6 +43,7 @@ describe("parseContextLoopConfig", () => {
bypass: false,
iteration_internal: true,
total_internal: true,
seed_locks: {},
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -65,8 +67,25 @@ describe("parseContextLoopConfig", () => {
bypass: true,
iteration_internal: false,
total_internal: true,
seed_locks: {},
};
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
expect(round).toEqual(cfg);
});
});
describe("ContextLoopConfig seed_locks", () => {
it("defaults seed_locks to {}", () => {
expect(emptyContextLoopConfig().seed_locks).toEqual({});
});
it("parses seed_locks", () => {
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
});
it("drops non-numeric lock values", () => {
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
});
it("round-trips through serialize", () => {
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
});
});
+11
View File
@@ -20,6 +20,9 @@ export interface ContextLoopConfig {
iteration_internal: boolean;
/** Same idea for `$<iteration_var_name>_total`. */
total_internal: boolean;
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
* indices re-derive from base+strategy. Empty by default. */
seed_locks: Record<string, number>;
}
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
@@ -32,6 +35,7 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
bypass: false,
iteration_internal: true,
total_internal: true,
seed_locks: {},
};
}
@@ -60,6 +64,13 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
const locks: Record<string, number> = {};
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
}
out.seed_locks = locks;
}
return out;
}
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import SeedListWidget from "./SeedListWidget.vue";
import SeedListModal from "../shared/SeedListModal.vue";
import { emptySeedListConfig } from "./types";
describe("SeedListWidget", () => {
@@ -142,3 +143,46 @@ describe("SeedListWidget", () => {
expect(w.emitted("update:modelValue")).toBeUndefined();
});
});
function ws(extra = {}) {
return mount(SeedListWidget, {
props: { modelValue: { ...emptySeedListConfig(), ...extra }, baseSeed: 7, count: 4 },
attachTo: document.body,
global: { stubs: { teleport: true } },
});
}
describe("SeedListWidget seeds button", () => {
it("renders the Per-iteration seeds button", () => {
expect(ws().find('[data-test="seedlist-seeds-btn"]').exists()).toBe(true);
});
it("badge shows the locked count when >0", () => {
expect(ws({ seed_locks: { "0": 9, "2": 8, "3": 1 } }).find('[data-test="seedlist-seeds-badge"]').text()).toMatch(/3 locked/i);
});
it("hides the badge when nothing is locked", () => {
expect(ws().find('[data-test="seedlist-seeds-badge"]').exists()).toBe(false);
});
it("clicking opens the modal", async () => {
const wr = ws();
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
});
it("previewStrategy is passed to modal when provided", async () => {
const wr = mount(SeedListWidget, {
props: { modelValue: emptySeedListConfig(), baseSeed: 7, count: 4, previewStrategy: "hash_index" },
attachTo: document.body,
global: { stubs: { teleport: true } },
});
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(wr.findComponent(SeedListModal).props("strategy")).toBe("hash_index");
});
it("passes an override hint to the modal only when override_seed is on", async () => {
const on = ws({ override_seed: true });
await on.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
const off = ws(); // override_seed false by default
await off.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
});
});
+40 -2
View File
@@ -12,8 +12,9 @@
* host glue (`src/widgets/seed_list.ts`) serializes to JSON and
* pushes via `host.setValue` so ComfyUI's widget value matches.
*/
import { computed } from "vue";
import { computed, ref } from "vue";
import type { SeedListConfig, SeedListStrategy } from "./types";
import SeedListModal from "../shared/SeedListModal.vue";
const props = withDefaults(
defineProps<{
@@ -21,8 +22,19 @@ const props = withDefaults(
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
nodeMode?: number;
/** Effective base seed for the modal preview (already resolved by the host
* glue — either local base_seed or the wired loop's seed, depending on
* override_seed + whether a loop is connected). */
baseSeed?: number;
/** Effective iteration count for the modal preview (resolved by host glue). */
count?: number;
/** Effective strategy for the modal preview. When supplied (loop wired +
* override_strategy on), the modal uses this instead of modelValue.strategy.
* The strategy CHIPS still reflect modelValue.strategy (the local config).
* Undefined = use modelValue.strategy (no loop / override off). */
previewStrategy?: SeedListStrategy;
}>(),
{ nodeMode: 0 },
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined },
);
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
@@ -55,6 +67,13 @@ const STRATEGY_LOCKED_TOOLTIP =
const isMuted = computed<boolean>(() => props.nodeMode === 2);
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
const seedsOpen = ref(false);
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
function onSeedLocks(next: Record<string, number>): void {
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
}
/** True while strategy comes from the upstream loop_config wire.
* Mirrors what `setStockWidgetDisabled("count", ...)` does for the count
* stock widget in the host glue — chips can't live as a stock widget so
@@ -125,6 +144,14 @@ function toggleOverrideStrategy(): void {
</div>
</div>
<button type="button" class="wp-seedlist__seedbtn" data-test="seedlist-seeds-btn" @click="seedsOpen = true">
<span class="wp-seedlist__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
Per-iteration seeds
<span class="wp-seedlist__seedbtn-fill" />
<span v-if="lockedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-badge">{{ lockedCount }} locked</span>
<svg class="wp-seedlist__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-seedlist__row" :title="OVERRIDE_SEED_TOOLTIP">
<span class="wp-seedlist__row-label">Override base seed from loop</span>
<button
@@ -172,6 +199,10 @@ function toggleOverrideStrategy(): void {
<span class="wp-seedlist__switch-thumb" />
</button>
</div>
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
: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>
</template>
@@ -263,4 +294,11 @@ function toggleOverrideStrategy(): void {
/* Mute / bypass dim — same convention as WP_VarTo* and WP_ContextLoop. */
.wp-seedlist--muted { opacity: 0.45; pointer-events: none; }
.wp-seedlist--bypassed { opacity: 0.65; }
.wp-seedlist__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
.wp-seedlist__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
.wp-seedlist__seedbtn-ico { color: var(--wp-accent); display: flex; }
.wp-seedlist__seedbtn-fill { flex: 1; }
.wp-seedlist__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
.wp-seedlist__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
</style>
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { resolveSeedListPreview } from "./preview";
const local = { base: 5, count: 3, strategy: "sequential" as const };
const loop = { base: 100, count: 8, strategy: "hash_index" as const };
const NONE = { override_seed: false, override_count: false, override_strategy: false };
describe("resolveSeedListPreview", () => {
it("no loop wired → all local (even if toggles on)", () => {
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, null))
.toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
});
it("no overrides → all local", () => {
expect(resolveSeedListPreview(local, NONE, loop)).toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
});
it("override_count only → loop count, local base+strategy", () => {
expect(resolveSeedListPreview(local, { ...NONE, override_count: true }, loop))
.toEqual({ baseSeed: 5, count: 8, strategy: "sequential" });
});
it("all overrides → all loop", () => {
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, loop))
.toEqual({ baseSeed: 100, count: 8, strategy: "hash_index" });
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { SeedListStrategy } from "./types";
export interface SeriesParams { base: number; count: number; strategy: SeedListStrategy }
export interface OverrideFlags { override_seed: boolean; override_count: boolean; override_strategy: boolean }
/** Mirror of wp_nodes/seed_list.py::_resolve_config for the modal preview:
* an override flag pulls from the wired loop ONLY when a loop is present;
* otherwise the local widget value wins. */
export function resolveSeedListPreview(
local: SeriesParams,
flags: OverrideFlags,
loop: SeriesParams | null,
): { baseSeed: number; count: number; strategy: SeedListStrategy } {
return {
baseSeed: flags.override_seed && loop ? loop.base : local.base,
count: flags.override_count && loop ? loop.count : local.count,
strategy: flags.override_strategy && loop ? loop.strategy : local.strategy,
};
}
+20
View File
@@ -28,6 +28,7 @@ describe("parseSeedListConfig", () => {
override_seed: true,
override_count: true,
override_strategy: true,
seed_locks: {},
};
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -38,6 +39,7 @@ describe("parseSeedListConfig", () => {
override_seed: false,
override_count: true,
override_strategy: false,
seed_locks: {},
};
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -64,6 +66,7 @@ describe("parseSeedListConfig", () => {
override_seed: false,
override_count: false,
override_strategy: false,
seed_locks: {},
});
});
@@ -89,8 +92,25 @@ describe("parseSeedListConfig", () => {
override_seed: false,
override_count: true,
override_strategy: false,
seed_locks: {},
};
const round = parseSeedListConfig(serializeSeedListConfig(cfg));
expect(round).toEqual(cfg);
});
});
describe("SeedListConfig seed_locks", () => {
it("defaults seed_locks to {}", () => {
expect(emptySeedListConfig().seed_locks).toEqual({});
});
it("parses seed_locks", () => {
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
});
it("drops non-numeric lock values", () => {
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
});
it("round-trips through serialize", () => {
const cfg = { ...emptySeedListConfig(), seed_locks: { "0": 7 } };
expect(parseSeedListConfig(serializeSeedListConfig(cfg)).seed_locks).toEqual({ "0": 7 });
});
});
+11
View File
@@ -18,6 +18,9 @@ export interface SeedListConfig {
override_count: boolean;
/** Take `strategy` from the wired loop_config. */
override_strategy: boolean;
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
* indices re-derive from base+strategy. Empty by default. */
seed_locks: Record<string, number>;
}
const STRATEGIES = new Set<SeedListStrategy>(["hash_index", "sequential", "prime_stride"]);
@@ -28,6 +31,7 @@ export function emptySeedListConfig(): SeedListConfig {
override_seed: false,
override_count: false,
override_strategy: false,
seed_locks: {},
};
}
@@ -69,6 +73,13 @@ export function parseSeedListConfig(raw: string | null | undefined): SeedListCon
if (!hasNewCount) out.override_count = legacy;
if (!hasNewStrategy) out.override_strategy = legacy;
}
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
const locks: Record<string, number> = {};
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
}
out.seed_locks = locks;
}
return out;
}
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
/** Per-iteration seed modal — shared by the Context Loop + Seed List
* node widgets. Computes the derived list locally (seed-derive mirror)
* and overlays seed_locks; edits re-emit the full merged lock map. */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { deriveLoopSeeds, parseSeedLocks, type SeedStrategy } from "./seed-derive";
import SeedLockRow from "./SeedLockRow.vue";
const props = defineProps<{
nodeName: string;
baseSeed: number;
count: number;
strategy: SeedStrategy;
seedLocks: Record<string, number>;
/** Yellow override notice text; empty/undefined hides it. Context Loop
* 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;
}>();
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
const derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
const copied = ref(false);
const pasted = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
let pastedTimer: ReturnType<typeof setTimeout> | undefined;
/** Locked indices beyond the current count — shown dimmed below the active
* rows so a forgotten out-of-range lock is visible (it re-applies if count
* grows back). */
const inactiveLocks = computed(() => {
const active = derived.value.length;
return Object.keys(props.seedLocks)
.map(Number)
.filter((i) => Number.isInteger(i) && i >= active)
.sort((a, b) => a - b);
});
function onRow(p: { index: number; seed: number | null }): void {
const next = { ...props.seedLocks };
if (p.seed == null) delete next[String(p.index)];
else next[String(p.index)] = p.seed;
emit("update:seedLocks", next);
}
function lockAll(): void {
const next: Record<string, number> = {};
derived.value.forEach((s, i) => { next[String(i)] = s; });
emit("update:seedLocks", next);
}
function unlockAll(): void { emit("update:seedLocks", {}); }
/** Build the `#N: seed` clipboard text. lockedOnly → just the locked rows
* (round-trips through Paste to re-lock exactly those); else every iteration
* with locks overlaid. */
function copyText(lockedOnly: boolean): string {
if (lockedOnly) {
return Object.keys(props.seedLocks)
.map(Number)
.sort((a, b) => a - b)
.map((i) => `#${i + 1}: ${props.seedLocks[String(i)]}`)
.join("\n");
}
return derived.value.map((s, i) => `#${i + 1}: ${props.seedLocks[String(i)] ?? s}`).join("\n");
}
function copyAll(ev: MouseEvent): void {
void navigator.clipboard?.writeText(copyText(ev.altKey)); // Alt-click → locked seeds only
copied.value = true;
if (copiedTimer) clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => { copied.value = false; }, 1500);
}
/** Read the clipboard, parse the `#N: seed` format Copy produces, and REPLACE
* the whole lock map with it. No-op on empty/garbage so a stray paste can't
* wipe existing locks. Pasted indices beyond count land as inactive rows. */
async function pasteAll(): Promise<void> {
let text = "";
try { text = (await navigator.clipboard?.readText()) ?? ""; } catch { text = ""; }
const next = parseSeedLocks(text);
if (Object.keys(next).length === 0) return;
emit("update:seedLocks", next);
pasted.value = true;
if (pastedTimer) clearTimeout(pastedTimer);
pastedTimer = setTimeout(() => { pasted.value = false; }, 1500);
}
function onKeydown(ev: KeyboardEvent): void {
if (ev.key === "Escape") { ev.preventDefault(); emit("close"); }
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
if (copiedTimer) clearTimeout(copiedTimer);
if (pastedTimer) clearTimeout(pastedTimer);
});
</script>
<template>
<Teleport to="body">
<div class="sm-overlay" @click="emit('close')">
<div class="sm" role="dialog" aria-modal="true" data-test="seed-modal" @click.stop>
<div class="sm__head">
<span class="sm__head-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
<div class="sm__title-block">
<div class="sm__title-row"><span class="sm__name">{{ nodeName }}</span><span class="sm__chip">Iteration Seeds</span></div>
<div class="sm__sub">base {{ baseSeed }} · {{ strategy }} · {{ Math.max(1, count) }} iterations</div>
</div>
<button class="sm__close" data-test="seed-modal-close" aria-label="Close" @click="emit('close')">
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 5l14 14M19 5L5 19" /></svg>
</button>
</div>
<div class="sm__bar">
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
<span class="sm__strat">{{ strategy }}</span>
<span v-if="lockedCount" class="sm__lockcount" data-test="mx-seed-lockcount">{{ lockedCount }} locked</span>
<span class="sm__bar-spacer" />
<button class="ghost" data-test="mx-seed-lockall" @click="lockAll">Lock all</button>
<button class="ghost" data-test="mx-seed-unlockall" @click="unlockAll">Unlock all</button>
<button class="ghost" :class="{ 'ghost--copied': copied }" data-test="mx-seed-copy"
title="Copy all seeds · Alt-click to copy only the locked ones" @click="copyAll">
<svg v-if="copied" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
{{ copied ? "Copied" : "Copy" }}
</button>
</div>
<div v-if="overrideHint" class="sm__hint" data-test="mx-seed-hint">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M12 11v5" stroke-linecap="round" /><circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" /></svg>
<span>{{ overrideHint }}</span>
</div>
<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" />
<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" />
</template>
</div>
<div class="sm__foot">
<span class="sm__foot-hint"><kbd>Esc</kbd> to close · locks save with the node</span>
<span class="sm__foot-spacer" />
<button class="ghost" :class="{ 'ghost--copied': pasted }" data-test="mx-seed-paste"
title="Replace all locks from copied text" @click="pasteAll">
<svg v-if="pasted" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2.5" width="8" height="4" rx="1" /><path d="M8 4.5H6a2 2 0 0 0-2 2V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6.5a2 2 0 0 0-2-2h-2" /></svg>
{{ pasted ? "Pasted" : "Paste" }}
</button>
<button class="btn" data-test="seed-modal-done" @click="emit('close')">Done</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
@import "./theme.css";
.sm-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0,0,0,.62); font-family: var(--wp-font-sans, sans-serif); }
.sm { width: 486px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: 6px; overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.55); }
.sm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: linear-gradient(180deg, color-mix(in srgb, var(--wp-accent) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); }
.sm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
.sm__title-block { flex: 1; min-width: 0; }
.sm__title-row { display: flex; align-items: center; gap: 8px; }
.sm__name { font: 600 13px var(--wp-font-sans); color: var(--wp-text); }
.sm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: 2px; background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
.sm__sub { font: 400 10px var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
.sm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; display: flex; }
.sm__close:hover { color: var(--wp-text); }
.sm__bar { display: flex; align-items: center; gap: 8px; padding: 9px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); }
.sm__count { font: 600 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
.sm__strat { font: 600 9px var(--wp-font-mono); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); }
.sm__lockcount { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
.sm__bar-spacer { flex: 1; }
.ghost { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 3px; border: 1px solid var(--wp-border); background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
.ghost:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
.ghost--copied, .ghost--copied:hover { border-color: var(--wp-green, #6bc96f); color: var(--wp-green, #6bc96f); }
.sm__hint { display: flex; align-items: center; gap: 7px; padding: 7px 14px; flex-shrink: 0; font: 11px var(--wp-font-sans); color: color-mix(in srgb, var(--wp-amber, #fbbf24) 75%, var(--wp-text-muted)); background: color-mix(in srgb, var(--wp-amber, #fbbf24) 9%, transparent); border-bottom: 1px solid var(--wp-border); }
.sm__hint svg { flex-shrink: 0; color: var(--wp-amber, #fbbf24); }
.sm__list { overflow-y: auto; padding: 6px; flex: 1; min-height: 0; }
.sm__inactive-label { padding: 8px 8px 4px; margin-top: 4px; border-top: 1px dashed var(--wp-border); font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .08em; color: var(--wp-text-dim, var(--wp-text3)); }
.sm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.sm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.sm__foot-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; }
.sm__foot-spacer { flex: 1; }
.btn { padding: 6px 14px; border: 1px solid var(--wp-accent); border-radius: 3px; background: var(--wp-accent); color: #fff; font: 11px var(--wp-font-sans); cursor: pointer; }
</style>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
/** One per-iteration row in SeedListModal — a per-row mirror of the
* module SeedLockControls. Unlocked shows the derived seed; locking
* captures it; locked exposes a seed input + ±spinners + roll. */
const props = defineProps<{
index: number; // 0-based
derived: number; // computed seed for this index (preview when unlocked)
locked: boolean;
seed: number | null; // locked seed value when locked
/** Locked index beyond the current count — dimmed but still editable;
* re-activates if count grows back. */
inactive?: boolean;
}>();
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
const SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
function randomSeed(): number {
const hi = Math.floor(Math.random() * 0x40000); // 18 bits
const lo = Math.floor(Math.random() * 0x100000000); // 32 bits
return (hi * 0x100000000 + lo) % (SEED_MAX + 1);
}
function set(seed: number | null) { emit("update", { index: props.index, seed }); }
function onLock() { set(props.locked ? null : props.derived); }
function onInput(ev: Event) {
const n = Number((ev.target as HTMLInputElement).value.trim());
if (Number.isFinite(n) && Number.isInteger(n)) set(n);
}
function bump(d: 1 | -1) {
if (!props.locked || props.seed == null) return;
set(Math.max(0, Math.min(SEED_MAX, props.seed + d)));
}
</script>
<template>
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive }">
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
<button type="button" class="toggle" :class="{ 'toggle--on': locked }"
data-test="seedrow-lock" role="switch" :aria-checked="locked" @click="onLock">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="9" rx="1.5" /><path :d="locked ? 'M8 11V8a4 4 0 0 1 8 0v3' : 'M8 11V8a4 4 0 0 1 8 0'" />
</svg>
{{ locked ? "Locked" : "Lock" }}
</button>
<span class="srow__fill" />
<span v-if="!locked" class="derived">
<span class="derived__val" data-test="seedrow-derived">{{ derived }}</span>
<span class="derived__tag">derived</span>
</span>
<span v-else class="seed-controls">
<span class="seed-input-wrap">
<input class="seed-input" data-test="seedrow-input" type="number" step="1"
:value="seed ?? ''" aria-label="Locked seed" @input="onInput" />
<span class="seed-spin">
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Increase" @click="bump(1)">
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 5 L4 0 L8 5 Z" fill="currentColor" /></svg></button>
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Decrease" @click="bump(-1)">
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 0 L4 5 L8 0 Z" fill="currentColor" /></svg></button>
</span>
</span>
<button type="button" class="seed-roll" data-test="seedrow-roll" title="Random seed"
aria-label="Random seed" @click="set(randomSeed())">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 4v5h-5" /></svg>
</button>
</span>
</div>
</template>
<style scoped>
.srow { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 5px; }
.srow--locked { background: rgba(99,102,241,0.07); box-shadow: inset 2px 0 0 var(--wp-accent, #6366f1); }
/* out-of-range lock: dimmed ("deemed down") but still interactive — hover lifts it. */
.srow--inactive { opacity: .5; }
.srow--inactive:hover { opacity: .85; }
.srow__idx { flex-shrink: 0; width: 30px; text-align: right; font: 600 11px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #7a7d88); }
.srow--locked .srow__idx { color: var(--wp-accent-text, #c4b5fd); }
.srow__fill { flex: 1; }
.toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid var(--wp-border); border-radius: 3px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); cursor: pointer; background: var(--wp-bg-deep, var(--wp-bg)); flex-shrink: 0; }
.toggle:hover { border-color: var(--wp-border-strong, var(--wp-border2)); }
.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)); }
.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; }
.seed-controls { display: inline-flex; align-items: center; gap: 6px; }
.seed-input-wrap { display: inline-flex; align-items: stretch; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; height: 24px; width: 178px; overflow: hidden; }
.seed-input-wrap:focus-within { border-color: var(--wp-accent); }
.seed-input { flex: 1; background: transparent; border: 0; padding: 0 7px; font: 11px var(--wp-font-mono, monospace); color: var(--wp-text); text-align: right; min-width: 0; -moz-appearance: textfield; }
.seed-input::-webkit-outer-spin-button, .seed-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.seed-input:focus { outline: none; }
.seed-spin { display: flex; flex-direction: column; width: 14px; flex-shrink: 0; border-left: 1px solid var(--wp-border); background: rgba(99,102,241,.04); }
.seed-spin-btn { flex: 1; display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; line-height: 0; }
.seed-spin-btn + .seed-spin-btn { border-top: 1px solid var(--wp-border); }
.seed-spin-btn:hover { color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); }
.seed-roll { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 2px 4px; display: flex; }
.seed-roll:hover { color: var(--wp-accent-text, var(--wp-text)); }
</style>
@@ -0,0 +1,111 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import SeedListModal from "../SeedListModal.vue";
// jsdom has no real clipboard; defineProperty so it survives a read-only getter.
// Returns the array capturing every writeText payload.
function stubClipboard(readValue = "") {
const writes: string[] = [];
Object.defineProperty(navigator, "clipboard", {
value: {
readText: () => Promise.resolve(readValue),
writeText: (t: string) => { writes.push(t); return Promise.resolve(); },
},
configurable: true,
});
return writes;
}
function modal(props = {}) {
return mount(SeedListModal, {
props: { nodeName: "WP Context Loop", baseSeed: 42, count: 4, strategy: "sequential",
seedLocks: { "1": 999 }, overrideHint: "Test hint.", ...props },
attachTo: document.body,
// The modal body renders inside `<Teleport to="body">`; stubbing teleport
// renders the children in place so the wrapper's `find()` reaches them
// (repo convention — see RemapRefPopup.test.ts, ConfirmDialog.test.ts).
global: { stubs: { teleport: true } },
});
}
// ES2020 lib — no Array.prototype.at; read the last emit by index.
function lastEmit(w: ReturnType<typeof modal>) {
const ev = w.emitted("update:seedLocks")!;
return ev[ev.length - 1][0];
}
describe("SeedListModal", () => {
it("renders one row per iteration (count)", () => {
expect(modal().findAll('[data-test="seedrow-idx"]')).toHaveLength(4);
});
it("shows the locked-count pill", () => {
expect(modal().find('[data-test="mx-seed-lockcount"]').text()).toMatch(/1 locked/i);
});
it("shows the override hint with the provided text; hidden when empty", () => {
const shown = modal({ overrideHint: "Heads up: override is on." });
expect(shown.find('[data-test="mx-seed-hint"]').exists()).toBe(true);
expect(shown.find('[data-test="mx-seed-hint"]').text()).toContain("Heads up: override is on.");
expect(modal({ overrideHint: "" }).find('[data-test="mx-seed-hint"]').exists()).toBe(false);
});
it("a row update re-emits update:seedLocks with the merged map", async () => {
const w = modal({ seedLocks: {} });
await w.findAll('[data-test="seedrow-lock"]')[0].trigger("click"); // lock #1 (index 0), sequential derived = 42
expect(lastEmit(w)).toEqual({ "0": 42 });
});
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");
expect(lastEmit(w)).toEqual({ "0": 42, "1": 43, "2": 44, "3": 45 });
});
it("Unlock all emits an empty map", async () => {
const w = modal({ seedLocks: { "1": 999 } });
await w.find('[data-test="mx-seed-unlockall"]').trigger("click");
expect(lastEmit(w)).toEqual({});
});
it("Copy flashes a 'Copied' indicator on click", async () => {
const w = modal();
const btn = () => w.find('[data-test="mx-seed-copy"]');
expect(btn().classes()).not.toContain("ghost--copied");
await btn().trigger("click");
expect(btn().classes()).toContain("ghost--copied");
expect(btn().text()).toContain("Copied");
w.unmount(); // clears the 1.5s reset timer via onBeforeUnmount
});
it("renders locks beyond count as a dimmed inactive group", () => {
const w = modal({ count: 3, seedLocks: { "1": 11, "5": 55 } });
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(true);
// 3 active rows (#1-#3) + the out-of-range lock shown as #6
expect(w.findAll('[data-test="seedrow-idx"]').map((n) => n.text())).toEqual(["#1", "#2", "#3", "#6"]);
});
it("no inactive group when every lock is within count", () => {
const w = modal({ count: 3, seedLocks: { "1": 11 } });
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(false);
expect(w.findAll('[data-test="seedrow-idx"]')).toHaveLength(3);
});
it("Paste replaces the whole lock map from the copied text", async () => {
stubClipboard("#2: 77\n#3: 88"); // 1-based display → indices 1 and 2
const w = modal({ seedLocks: { "0": 1 } }); // existing lock must be replaced, not merged
await w.find('[data-test="mx-seed-paste"]').trigger("click");
await flushPromises();
expect(lastEmit(w)).toEqual({ "1": 77, "2": 88 });
expect(w.find('[data-test="mx-seed-paste"]').text()).toContain("Pasted");
});
it("Paste with no parseable lines leaves locks untouched", async () => {
stubClipboard("garbage\nnot a seed line");
const w = modal({ seedLocks: { "0": 1 } });
await w.find('[data-test="mx-seed-paste"]').trigger("click");
await flushPromises();
expect(w.emitted("update:seedLocks")).toBeUndefined();
});
it("plain-click Copy writes every seed with locks overlaid", async () => {
const writes = stubClipboard();
const w = modal({ count: 3, seedLocks: { "1": 999 } }); // sequential base 42 → 42,43,44
await w.find('[data-test="mx-seed-copy"]').trigger("click");
expect(writes[writes.length - 1]).toBe("#1: 42\n#2: 999\n#3: 44");
});
it("Alt+click Copy writes only the locked seeds", async () => {
const writes = stubClipboard();
const w = modal({ count: 4, seedLocks: { "1": 999 } });
await w.find('[data-test="mx-seed-copy"]').trigger("click", { altKey: true });
expect(writes[writes.length - 1]).toBe("#2: 999");
});
});
@@ -0,0 +1,40 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import SeedLockRow from "../SeedLockRow.vue";
function row(props = {}) {
return mount(SeedLockRow, { props: { index: 0, derived: 12345, locked: false, seed: null, ...props } });
}
describe("SeedLockRow", () => {
it("unlocked: shows derived value + a Lock toggle, no input", () => {
const w = row();
expect(w.find('[data-test="seedrow-derived"]').text()).toContain("12345");
expect(w.find('[data-test="seedrow-input"]').exists()).toBe(false);
});
it("locking emits update with the derived value captured", async () => {
const w = row();
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 12345 });
});
it("unlocking emits update with null", async () => {
const w = row({ locked: true, seed: 999 });
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
});
it("editing the seed input emits update with the typed value", async () => {
const w = row({ locked: true, seed: 999 });
await w.find('[data-test="seedrow-input"]').setValue("4242");
const updates = w.emitted("update")!;
expect(updates[updates.length - 1][0]).toEqual({ index: 0, seed: 4242 });
});
it("displays #N (1-based) from the 0-based index", () => {
expect(row({ index: 2 }).find('[data-test="seedrow-idx"]').text()).toBe("#3");
});
it("inactive dims the row but stays interactive (unlock still emits)", async () => {
const w = row({ locked: true, seed: 999, inactive: true });
expect(w.find(".srow").classes()).toContain("srow--inactive");
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
});
});
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import corpus from "../../../../tests/fixtures/seed-derive-corpus.json";
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks } from "../seed-derive";
describe("seed-derive (Py≡TS parity)", () => {
it("matches the engine corpus for every (base, count, strategy)", () => {
for (const c of corpus.derive) {
expect(deriveLoopSeeds(c.base, c.count, c.strategy as never)).toEqual(c.derived);
}
});
it("applySeedLocks matches the engine", () => {
const a = corpus.applyLocks;
const locks: Record<number, number> = {};
for (const [k, v] of Object.entries(a.locks)) locks[Number(k)] = v as number;
expect(applySeedLocks(a.derived, locks)).toEqual(a.locked);
});
});
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 });
});
it("ignores blank, malformed, and #0 lines; tolerates stray whitespace", () => {
expect(parseSeedLocks("\n#2: 7\ngarbage\n #4 : 9 \n#0: 5\n#: x")).toEqual({ "1": 7, "3": 9 });
});
it("returns an empty map when nothing parses", () => {
expect(parseSeedLocks("nope\n123\nfoo: bar")).toEqual({});
});
it("masks values to 50 bits — negatives normalize like the engine", () => {
expect(parseSeedLocks("#1: -1")).toEqual({ "0": (2 ** 50) - 1 });
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* TS mirror of engine/seed_derive.py — lets the seed modal preview the
* per-iteration seed list without running the graph. Parity is guarded
* by tests/fixtures/seed-derive-corpus.json. Values masked to 50 bits.
*/
export type SeedStrategy = "sequential" | "hash_index" | "prime_stride";
const MASK = (1n << 50n) - 1n;
export function deriveLoopSeeds(base: number, count: number, strategy: SeedStrategy): number[] {
if (count <= 1) return [Number(BigInt(base) & MASK)];
if (strategy === "sequential") {
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i)) & MASK));
}
if (strategy === "prime_stride") {
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i) * 1000003n) & MASK));
}
if (strategy === "hash_index") {
return Array.from({ length: count }, (_, i) => {
const digest = sha256Bytes(`${base}:${i}`);
let v = 0n;
for (let b = 0; b < 8; b++) v = (v << 8n) | BigInt(digest[b]);
return Number(v & MASK);
});
}
throw new Error(`unknown loop seed strategy: ${strategy}`);
}
export function applySeedLocks(derived: number[], locks: Record<number, number>): number[] {
return derived.map((s, i) =>
Object.prototype.hasOwnProperty.call(locks, i) ? Number(BigInt(locks[i]) & MASK) : s,
);
}
const LOCK_LINE = /^\s*#(\d+)\s*:\s*(-?\d+)\s*$/;
/**
* Parse the `#N: seed` lines produced by the seed modal's Copy (1-based
* display index) back into a 0-based seed_locks map. Non-matching lines are
* ignored; values are masked to 50 bits like the engine (negatives normalize
* deterministically). UI-only — no Python mirror, so no parity corpus.
*/
export function parseSeedLocks(text: string): Record<string, number> {
const out: Record<string, number> = {};
for (const line of text.split(/\r?\n/)) {
const m = line.match(LOCK_LINE);
if (!m) continue;
const display = Number(m[1]);
if (display < 1) continue; // #N is 1-based; #0 / #-1 are invalid
out[String(display - 1)] = Number(BigInt(m[2]) & MASK);
}
return out;
}
function sha256Bytes(msg: string): Uint8Array {
const K = [
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a,h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
const bytes = new TextEncoder().encode(msg);
const bitLen = bytes.length * 8;
const padded = new Uint8Array((Math.ceil((bytes.length + 1 + 8) / 64)) * 64);
padded.set(bytes); padded[bytes.length] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 4, bitLen >>> 0);
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
const w = new Uint32Array(64);
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3);
const s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10);
w[i] = (w[i-16] + s0 + w[i-7] + s1) | 0;
}
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,hh=h7;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
const ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
hh=g; g=f; f=e; e=(d + t1)|0; d=c; c=b; b=a; a=(t1 + t2)|0;
}
h0=(h0+a)|0; h1=(h1+b)|0; h2=(h2+c)|0; h3=(h3+d)|0; h4=(h4+e)|0; h5=(h5+f)|0; h6=(h6+g)|0; h7=(h7+hh)|0;
}
const out = new Uint8Array(32);
const odv = new DataView(out.buffer);
[h0,h1,h2,h3,h4,h5,h6,h7].forEach((hv, i) => odv.setUint32(i*4, hv >>> 0));
return out;
}
+15
View File
@@ -37,6 +37,19 @@ export function create(node: ContextLoopHostNode, inputName: string) {
// on mute / bypass. Mirrors WP_VarTo* + WP_Cleaner widget patterns.
const nodeMode = reactiveFromGraph(node, () => node.mode ?? 0, Object.is);
// Read the stock seed + count widgets reactively so the seed modal
// preview updates whenever the user edits those widgets.
const baseSeed = reactiveFromGraph(
node,
() => Number((node.widgets ?? []).find((w) => w.name === "seed")?.value ?? 0),
Object.is,
);
const count = reactiveFromGraph(
node,
() => Number((node.widgets ?? []).find((w) => w.name === "count")?.value ?? 1),
Object.is,
);
let host: DomWidgetHost | null = null;
const wrapper: Component = {
@@ -51,6 +64,8 @@ export function create(node: ContextLoopHostNode, inputName: string) {
h(ContextLoopWidget, {
modelValue: config.value,
nodeMode: nodeMode.value,
baseSeed: baseSeed.value,
count: count.value,
"onUpdate:modelValue": onUpdate,
});
},
+66 -3
View File
@@ -17,7 +17,9 @@ import {
serializeSeedListConfig,
type SeedListConfig,
} from "../components/seed-list/types";
import type { LiteNodeLike } from "../extension/graph";
import { resolveSeedListPreview, type SeriesParams } from "../components/seed-list/preview";
import { parseContextLoopConfig } from "../components/context-loop/types";
import type { LiteNodeLike, LiteGraphLike } from "../extension/graph";
const SeedListWidget = defineAsyncComponent(
() => import("../components/seed-list/SeedListWidget.vue"),
@@ -25,6 +27,7 @@ const SeedListWidget = defineAsyncComponent(
interface SeedListHostNode extends MountTargetNode, LiteNodeLike {
mode?: number;
graph?: LiteGraphLike;
}
export function create(node: SeedListHostNode, inputName: string) {
@@ -37,6 +40,51 @@ export function create(node: SeedListHostNode, inputName: string) {
// on mute / bypass. Mirrors WP_VarTo* + WP_ContextLoop widget patterns.
const nodeMode = reactiveFromGraph(node, () => node.mode ?? 0, Object.is);
// Read the stock base_seed + count widgets reactively so the seed modal
// preview updates whenever the user edits those widgets.
const baseSeed = reactiveFromGraph(
node,
() => Number((node.widgets ?? []).find((w) => w.name === "base_seed")?.value ?? 0),
Object.is,
);
const count = reactiveFromGraph(
node,
() => Number((node.widgets ?? []).find((w) => w.name === "count")?.value ?? 1),
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
// whenever the wire or upstream widgets change (same poll+event model
// as the baseSeed/count reads above).
function readLoopSeries(): SeriesParams | null {
const inputs = (node as { inputs?: Array<{ name?: string; link?: number | null }> }).inputs;
if (!inputs) return null;
const slot = inputs.find((i) => i.name === "loop_config");
if (!slot || slot.link == null) return null;
const graph = node.graph;
if (!graph) return null;
const link = graph.links[slot.link];
if (!link) return null;
const origin = graph.getNodeById(link.origin_id);
if (!origin) return null;
const widgets = origin.widgets ?? [];
const loopBase = Number(widgets.find((w) => w.name === "seed")?.value ?? 0);
const loopCount = Number(widgets.find((w) => w.name === "count")?.value ?? 1);
const rawCfg = widgets.find((w) => w.name === "wp_context_loop_config")?.value;
const loopCfg = parseContextLoopConfig(typeof rawCfg === "string" ? rawCfg : null);
return { base: loopBase, count: loopCount, strategy: loopCfg.strategy };
}
function loopParamsEqual(a: SeriesParams | null, b: SeriesParams | null): boolean {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return a.base === b.base && a.count === b.count && a.strategy === b.strategy;
}
const loopSeries = reactiveFromGraph(node, readLoopSeries, loopParamsEqual);
/** Toggle the `disabled` flag on a stock litegraph widget by name.
* Litegraph greys the widget out + blocks input when `disabled` is
* true. Returns silently when the named widget isn't on the node yet
@@ -76,12 +124,27 @@ export function create(node: SeedListHostNode, inputName: string) {
// Re-apply the stock-widget gates whenever a toggle flips.
syncStockWidgetGates();
}
return () =>
h(SeedListWidget, {
return () => {
const localSeries: SeriesParams = {
base: baseSeed.value,
count: count.value,
strategy: config.value.strategy,
};
const flags = {
override_seed: config.value.override_seed,
override_count: config.value.override_count,
override_strategy: config.value.override_strategy,
};
const effective = resolveSeedListPreview(localSeries, flags, loopSeries.value);
return h(SeedListWidget, {
modelValue: config.value,
nodeMode: nodeMode.value,
baseSeed: effective.baseSeed,
count: effective.count,
previewStrategy: effective.strategy,
"onUpdate:modelValue": onUpdate,
});
};
},
};
+5 -2
View File
@@ -18,6 +18,7 @@ _DEFAULTS = {
"bypass": False,
"iteration_internal": True,
"total_internal": True,
"seed_locks": {},
}
@@ -33,13 +34,15 @@ _DEFAULTS = {
'"iteration_var_name": "idx", "bypass": true}',
{"strategy": "sequential", "override_seed": True,
"iteration_var_name": "idx", "bypass": True,
"iteration_internal": True, "total_internal": True},
"iteration_internal": True, "total_internal": True,
"seed_locks": {}},
),
(
'{"strategy": "wat", "override_seed": true}',
{"strategy": "hash_index", "override_seed": True,
"iteration_var_name": "iteration", "bypass": False,
"iteration_internal": True, "total_internal": True},
"iteration_internal": True, "total_internal": True,
"seed_locks": {}},
),
('{"iteration_var_name": " "}', _DEFAULTS),
(
+369
View File
@@ -0,0 +1,369 @@
{
"derive": [
{
"base": 0,
"count": 1,
"strategy": "sequential",
"derived": [
0
]
},
{
"base": 0,
"count": 1,
"strategy": "prime_stride",
"derived": [
0
]
},
{
"base": 0,
"count": 1,
"strategy": "hash_index",
"derived": [
0
]
},
{
"base": 0,
"count": 4,
"strategy": "sequential",
"derived": [
0,
1,
2,
3
]
},
{
"base": 0,
"count": 4,
"strategy": "prime_stride",
"derived": [
0,
1000003,
2000006,
3000009
]
},
{
"base": 0,
"count": 4,
"strategy": "hash_index",
"derived": [
622917770156225,
931467140767837,
186764082477291,
1058756667342483
]
},
{
"base": 0,
"count": 12,
"strategy": "sequential",
"derived": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
]
},
{
"base": 0,
"count": 12,
"strategy": "prime_stride",
"derived": [
0,
1000003,
2000006,
3000009,
4000012,
5000015,
6000018,
7000021,
8000024,
9000027,
10000030,
11000033
]
},
{
"base": 0,
"count": 12,
"strategy": "hash_index",
"derived": [
622917770156225,
931467140767837,
186764082477291,
1058756667342483,
65735575284520,
8414473785724,
638605282230683,
1002735474326755,
583013065760719,
407657353091809,
328666774886061,
536389294218733
]
},
{
"base": 42,
"count": 1,
"strategy": "sequential",
"derived": [
42
]
},
{
"base": 42,
"count": 1,
"strategy": "prime_stride",
"derived": [
42
]
},
{
"base": 42,
"count": 1,
"strategy": "hash_index",
"derived": [
42
]
},
{
"base": 42,
"count": 4,
"strategy": "sequential",
"derived": [
42,
43,
44,
45
]
},
{
"base": 42,
"count": 4,
"strategy": "prime_stride",
"derived": [
42,
1000045,
2000048,
3000051
]
},
{
"base": 42,
"count": 4,
"strategy": "hash_index",
"derived": [
921162604278642,
554502062959870,
404171251152566,
1043146765877736
]
},
{
"base": 42,
"count": 12,
"strategy": "sequential",
"derived": [
42,
43,
44,
45,
46,
47,
48,
49,
50,
51,
52,
53
]
},
{
"base": 42,
"count": 12,
"strategy": "prime_stride",
"derived": [
42,
1000045,
2000048,
3000051,
4000054,
5000057,
6000060,
7000063,
8000066,
9000069,
10000072,
11000075
]
},
{
"base": 42,
"count": 12,
"strategy": "hash_index",
"derived": [
921162604278642,
554502062959870,
404171251152566,
1043146765877736,
927201806787560,
832860729725391,
92569956950090,
470827887926830,
381328814372330,
860962065276667,
52002127413976,
947032504488781
]
},
{
"base": 593560241826833,
"count": 1,
"strategy": "sequential",
"derived": [
593560241826833
]
},
{
"base": 593560241826833,
"count": 1,
"strategy": "prime_stride",
"derived": [
593560241826833
]
},
{
"base": 593560241826833,
"count": 1,
"strategy": "hash_index",
"derived": [
593560241826833
]
},
{
"base": 593560241826833,
"count": 4,
"strategy": "sequential",
"derived": [
593560241826833,
593560241826834,
593560241826835,
593560241826836
]
},
{
"base": 593560241826833,
"count": 4,
"strategy": "prime_stride",
"derived": [
593560241826833,
593560242826836,
593560243826839,
593560244826842
]
},
{
"base": 593560241826833,
"count": 4,
"strategy": "hash_index",
"derived": [
494380373716990,
840378297563533,
93532444899055,
539231446213217
]
},
{
"base": 593560241826833,
"count": 12,
"strategy": "sequential",
"derived": [
593560241826833,
593560241826834,
593560241826835,
593560241826836,
593560241826837,
593560241826838,
593560241826839,
593560241826840,
593560241826841,
593560241826842,
593560241826843,
593560241826844
]
},
{
"base": 593560241826833,
"count": 12,
"strategy": "prime_stride",
"derived": [
593560241826833,
593560242826836,
593560243826839,
593560244826842,
593560245826845,
593560246826848,
593560247826851,
593560248826854,
593560249826857,
593560250826860,
593560251826863,
593560252826866
]
},
{
"base": 593560241826833,
"count": 12,
"strategy": "hash_index",
"derived": [
494380373716990,
840378297563533,
93532444899055,
539231446213217,
863354412581016,
207988599354989,
379472665123606,
993428677821021,
673857473725553,
991956353347512,
155512136604776,
293362051898727
]
}
],
"applyLocks": {
"base": 100,
"count": 5,
"strategy": "hash_index",
"derived": [
382771060824834,
810176757932863,
326574196932401,
552663342010863,
547418473581964
],
"locks": {
"1": 7,
"3": 9
},
"locked": [
382771060824834,
7,
326574196932401,
9,
547418473581964
]
}
}
+33
View File
@@ -0,0 +1,33 @@
import json
import pathlib
from engine.seed_derive import MAX_SAFE_SEED, apply_seed_locks, derive_loop_seeds
def test_apply_seed_locks_overrides_only_locked_indices():
derived = [10, 20, 30, 40]
assert apply_seed_locks(derived, {1: 999, 3: 888}) == [10, 999, 30, 888]
def test_apply_seed_locks_empty_is_identity():
assert apply_seed_locks([10, 20, 30], {}) == [10, 20, 30]
def test_apply_seed_locks_masks_locked_values_to_50_bits():
over = MAX_SAFE_SEED + 5
assert apply_seed_locks([0, 0], {0: over}) == [over & MAX_SAFE_SEED, 0]
def test_apply_seed_locks_ignores_out_of_range_indices():
assert apply_seed_locks([10, 20], {5: 999}) == [10, 20]
def test_apply_seed_locks_over_real_derivation():
derived = derive_loop_seeds(42, 4, "sequential") # [42, 43, 44, 45]
assert apply_seed_locks(derived, {2: 7}) == [42, 43, 7, 45]
def test_corpus_matches_engine():
corpus = json.loads(pathlib.Path("tests/fixtures/seed-derive-corpus.json").read_text())
for c in corpus["derive"]:
assert derive_loop_seeds(c["base"], c["count"], c["strategy"]) == c["derived"]
+34
View File
@@ -0,0 +1,34 @@
"""Node-level tests for WP_ContextLoop seed_locks parsing + execution.
Covers _parse_config seed_locks extraction (int-key coercion, defaults,
malformed-entry dropping) and the execute path with seed locks applied
when override_seed is on.
"""
import json
from wp_nodes.context_loop import WPContextLoop, _parse_config
def test_parse_config_reads_seed_locks_as_int_map():
cfg = _parse_config(json.dumps({"seed_locks": {"1": 999, "3": 888}}))
assert cfg["seed_locks"] == {1: 999, 3: 888}
def test_parse_config_defaults_seed_locks_empty():
assert _parse_config("{}")["seed_locks"] == {}
def test_parse_config_drops_malformed_seed_locks():
cfg = _parse_config(json.dumps({"seed_locks": {"x": "nope", "2": 5}}))
assert cfg["seed_locks"] == {2: 5}
def test_execute_applies_seed_locks_when_override_on():
raw = json.dumps({"strategy": "sequential", "override_seed": True,
"seed_locks": {"2": 7}})
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
payloads = out.values[0]
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]
+38
View File
@@ -28,6 +28,7 @@ _DEFAULTS = {
"override_seed": False,
"override_count": False,
"override_strategy": False,
"seed_locks": {},
}
@@ -89,6 +90,7 @@ def test_parse_config_full_valid_payload():
"override_seed": True,
"override_count": True,
"override_strategy": True,
"seed_locks": {},
}
@@ -501,3 +503,39 @@ def test_schema_seed_output_is_list_typed():
[seed_out] = schema.outputs
assert seed_out.is_output_list is True
assert seed_out.type_name == "INT"
# ---------------------------------------------------- seed_locks parsing / execute
def test_parse_config_reads_seed_locks():
cfg = _parse_config(json.dumps({"seed_locks": {"0": 5, "2": 9}}))
assert cfg["seed_locks"] == {0: 5, 2: 9}
def test_parse_config_defaults_seed_locks_empty():
assert _parse_config("{}")["seed_locks"] == {}
def test_parse_config_drops_malformed_seed_locks():
cfg = _parse_config(json.dumps({"seed_locks": {"x": "no", "1": 9}}))
assert cfg["seed_locks"] == {1: 9}
def test_execute_output_reflects_seed_locks():
# sequential base 10, count 3 -> [10, 11, 12]; lock index 1 -> 77
raw = json.dumps({
"strategy": "sequential",
"override_seed": False,
"override_count": False,
"override_strategy": False,
"seed_locks": {"1": 77},
})
out = WPSeedList.execute(
base_seed=10,
count=3,
wp_seed_list_config=raw,
loop_config=None,
)
seeds = _seeds(out)
assert seeds == [10, 77, 12]
+16 -2
View File
@@ -13,7 +13,7 @@ import json
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine.seed_derive import derive_loop_seeds
from engine.seed_derive import apply_seed_locks, derive_loop_seeds
from wp_nodes.types import (
ContextLoopConfigInput,
ContextLoopWidgetInput,
@@ -39,6 +39,7 @@ def _parse_config(raw: str) -> dict[str, object]:
"bypass": False,
"iteration_internal": True,
"total_internal": True,
"seed_locks": {},
}
if not raw or not isinstance(raw, str):
return defaults
@@ -58,6 +59,15 @@ def _parse_config(raw: str) -> dict[str, object]:
out["bypass"] = bool(parsed.get("bypass", False))
out["iteration_internal"] = bool(parsed.get("iteration_internal", True))
out["total_internal"] = bool(parsed.get("total_internal", True))
locks_raw = parsed.get("seed_locks", {})
locks: dict[int, int] = {}
if isinstance(locks_raw, dict):
for k, v in locks_raw.items():
try:
locks[int(k)] = int(v)
except (TypeError, ValueError):
continue
out["seed_locks"] = locks
return out
@@ -136,6 +146,7 @@ class WPContextLoop(io.ComfyNode):
bypass = bool(cfg["bypass"])
iteration_internal = bool(cfg["iteration_internal"])
total_internal = bool(cfg["total_internal"])
seed_locks: dict[int, int] = cfg["seed_locks"] # type: ignore[assignment]
# Resolve effective count. `bypass` collapses to single-run while
# preserving the list-shape output contract — downstream still
@@ -143,7 +154,10 @@ class WPContextLoop(io.ComfyNode):
effective_count = 1 if bypass else max(1, int(count))
has_override = override_seed
derived = (
derive_loop_seeds(int(seed), effective_count, strategy)
apply_seed_locks(
derive_loop_seeds(int(seed), effective_count, strategy),
seed_locks,
)
if has_override else None
)
# Build the loop_config payload once so the same dict drives both
+16 -2
View File
@@ -35,7 +35,7 @@ from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine.seed_derive import MAX_SAFE_SEED, derive_loop_seeds
from engine.seed_derive import MAX_SAFE_SEED, apply_seed_locks, derive_loop_seeds
from wp_nodes.types import ContextLoopConfigInput, SeedListConfigInput
_STRATEGIES = ("hash_index", "sequential", "prime_stride")
@@ -60,6 +60,7 @@ def _parse_config(raw: Any) -> dict[str, Any]:
"override_seed": False,
"override_count": False,
"override_strategy": False,
"seed_locks": {},
}
if raw is None:
return defaults
@@ -97,6 +98,16 @@ def _parse_config(raw: Any) -> dict[str, Any]:
out["override_count"] = legacy
if not has_new_strategy:
out["override_strategy"] = legacy
locks_raw = parsed.get("seed_locks", {})
locks: dict[int, int] = {}
if isinstance(locks_raw, dict):
for k, v in locks_raw.items():
try:
locks[int(k)] = int(v)
except (TypeError, ValueError):
continue
out["seed_locks"] = locks
return out
@@ -236,5 +247,8 @@ class WPSeedList(io.ComfyNode):
override_strategy=override_strategy,
loop_config=loop_config,
)
seeds = derive_loop_seeds(resolved_base, resolved_count, resolved_strategy)
seeds = apply_seed_locks(
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
cfg["seed_locks"],
)
return io.NodeOutput(seeds)